Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- app.py +668 -112
- templates/batch.html +260 -273
- templates/index.html +254 -391
- templates/landing.html +132 -251
- utils/agents.py +1553 -0
app.py
CHANGED
|
@@ -1,68 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import urllib.parse
|
| 2 |
import math
|
| 3 |
-
import requests
|
| 4 |
import re
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from google_play_scraper import reviews, Sort, search, app as app_info
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
app = Flask(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
# --- The Rock-Solid Old App Logic ---
|
| 11 |
def extract_app_id(url_or_name: str) -> str:
|
| 12 |
url_or_name = url_or_name.strip()
|
| 13 |
-
|
| 14 |
-
# 1. If it's a Play Store URL, parse out the 'id' param safely
|
| 15 |
if "play.google.com" in url_or_name:
|
| 16 |
parsed = urllib.parse.urlparse(url_or_name)
|
| 17 |
-
|
| 18 |
-
if 'id' in
|
| 19 |
-
return
|
| 20 |
-
|
| 21 |
-
# 2. If it's a raw package name (e.g. com.whatsapp) with no spaces
|
| 22 |
if "." in url_or_name and " " not in url_or_name:
|
| 23 |
return url_or_name
|
| 24 |
-
|
| 25 |
return ""
|
| 26 |
|
|
|
|
| 27 |
def scrape_store_ids(query: str, n_hits: int = 5):
|
| 28 |
-
"""Directly scrape Play Store search results page to get valid package IDs."""
|
| 29 |
try:
|
| 30 |
url = f"https://play.google.com/store/search?q={urllib.parse.quote(query)}&c=apps"
|
| 31 |
-
headers = {"User-Agent": "Mozilla/5.0
|
| 32 |
resp = requests.get(url, headers=headers, timeout=10)
|
| 33 |
-
if resp.status_code != 200:
|
| 34 |
-
|
| 35 |
-
# Regex to find package names like details?id=com.something
|
| 36 |
pids = re.findall(r'details\?id=([a-zA-Z0-9._]+)', resp.text)
|
| 37 |
-
|
| 38 |
-
# Remove duplicates and known junk
|
| 39 |
-
unique_pids = []
|
| 40 |
for p in pids:
|
| 41 |
-
if p not in
|
| 42 |
-
|
| 43 |
-
return
|
| 44 |
-
except:
|
| 45 |
return []
|
| 46 |
|
|
|
|
| 47 |
def serialize_review(r: dict) -> dict:
|
| 48 |
-
"""Return all useful fields from a review, with dates serialized to ISO strings."""
|
| 49 |
return {
|
| 50 |
"reviewId": r.get("reviewId", ""),
|
| 51 |
"userName": r.get("userName", ""),
|
| 52 |
-
"userImage": r.get("userImage", ""),
|
| 53 |
"content": r.get("content", ""),
|
| 54 |
-
"score": r.get("score", 0),
|
| 55 |
-
"thumbsUpCount": r.get("thumbsUpCount", 0),
|
| 56 |
-
"reviewCreatedVersion": r.get("reviewCreatedVersion", ""),
|
| 57 |
-
"at": r["at"].isoformat() if r.get("at") else "",
|
| 58 |
-
"replyContent": r.get("replyContent", "") or "",
|
| 59 |
-
"repliedAt": r["repliedAt"].isoformat() if r.get("repliedAt") else "",
|
| 60 |
}
|
| 61 |
|
|
|
|
| 62 |
def fetch_app_reviews(app_id, review_count, sort_order, star_ratings_input):
|
| 63 |
-
"""Core helper to fetch reviews for a single app ID."""
|
| 64 |
info = app_info(app_id, lang='en', country='us')
|
| 65 |
-
|
| 66 |
sort_map = {
|
| 67 |
'MOST_RELEVANT': Sort.MOST_RELEVANT,
|
| 68 |
'NEWEST': Sort.NEWEST,
|
|
@@ -78,69 +98,600 @@ def fetch_app_reviews(app_id, review_count, sort_order, star_ratings_input):
|
|
| 78 |
reverse=True
|
| 79 |
)
|
| 80 |
|
| 81 |
-
per_bucket = math.ceil(
|
| 82 |
-
all_reviews = []
|
| 83 |
-
seen_ids = set()
|
| 84 |
|
| 85 |
for star in star_filters:
|
| 86 |
result, _ = reviews(
|
| 87 |
-
app_id,
|
| 88 |
-
|
| 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 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
all_reviews.append(
|
| 102 |
-
|
| 103 |
return info, all_reviews
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
@app.route('/scrape', methods=['POST'])
|
| 110 |
def scrape():
|
| 111 |
try:
|
| 112 |
-
data
|
| 113 |
-
identifier
|
| 114 |
-
count_type
|
| 115 |
-
count
|
| 116 |
-
|
| 117 |
app_id = extract_app_id(identifier)
|
| 118 |
if not app_id:
|
| 119 |
-
# Fallback 1: Library search
|
| 120 |
results = search(identifier, lang="en", country="us", n_hits=1)
|
| 121 |
if results and results[0].get('appId'):
|
| 122 |
app_id = results[0]['appId']
|
| 123 |
else:
|
| 124 |
-
# Fallback 2: Direct HTML scrape (Rock-solid)
|
| 125 |
pids = scrape_store_ids(identifier, n_hits=1)
|
| 126 |
-
if pids:
|
| 127 |
-
|
|
|
|
|
|
|
| 128 |
|
| 129 |
info, all_reviews = fetch_app_reviews(
|
| 130 |
-
app_id, count, data.get('sort_order'), data.get('star_ratings')
|
| 131 |
-
)
|
| 132 |
|
| 133 |
return jsonify({
|
| 134 |
"app_info": {
|
| 135 |
-
"title":
|
| 136 |
-
"icon":
|
| 137 |
-
"score":
|
| 138 |
"reviews": info['reviews'],
|
| 139 |
-
"appId":
|
| 140 |
},
|
| 141 |
"reviews": all_reviews,
|
| 142 |
})
|
| 143 |
-
|
| 144 |
except Exception as e:
|
| 145 |
return jsonify({"error": str(e)}), 500
|
| 146 |
|
|
@@ -148,116 +699,121 @@ def scrape():
|
|
| 148 |
@app.route('/find-apps', methods=['POST'])
|
| 149 |
def find_apps():
|
| 150 |
try:
|
| 151 |
-
data
|
| 152 |
-
query
|
| 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 |
-
|
| 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":
|
| 169 |
-
"title":
|
| 170 |
-
"icon":
|
| 171 |
-
"score":
|
| 172 |
-
"developer": info.get('developer',
|
| 173 |
-
"installs":
|
| 174 |
})
|
| 175 |
-
except
|
| 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
|
| 185 |
-
app_ids
|
| 186 |
-
count_type
|
| 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 |
-
|
| 194 |
-
|
| 195 |
for app_id in app_ids:
|
| 196 |
try:
|
| 197 |
info, app_reviews = fetch_app_reviews(
|
| 198 |
-
app_id, reviews_per_app, data.get('sort_order'), data.get('star_ratings')
|
| 199 |
-
)
|
| 200 |
batch_results.append({
|
| 201 |
"title": info['title'],
|
| 202 |
-
"icon":
|
| 203 |
"score": info['score'],
|
| 204 |
-
"appId": app_id
|
| 205 |
})
|
| 206 |
-
|
| 207 |
-
except:
|
| 208 |
continue
|
| 209 |
-
|
| 210 |
-
return jsonify({
|
| 211 |
-
"apps": batch_results,
|
| 212 |
-
"reviews": all_combined_reviews
|
| 213 |
-
})
|
| 214 |
except Exception as e:
|
| 215 |
return jsonify({"error": str(e)}), 500
|
| 216 |
|
| 217 |
|
| 218 |
-
|
| 219 |
@app.route("/search-suggestions", methods=["POST"])
|
| 220 |
def search_suggestions():
|
| 221 |
-
"""Return top app matches for a keyword — used by the UI search dropdown."""
|
| 222 |
try:
|
| 223 |
-
query = (request.json or {}).get("query",
|
| 224 |
if not query or len(query) < 2:
|
| 225 |
return jsonify({"results": []})
|
| 226 |
|
| 227 |
hits = search(query, lang="en", country="us", n_hits=6)
|
| 228 |
results = []
|
| 229 |
for h in hits:
|
| 230 |
-
aid = h.get("appId",
|
| 231 |
if not aid or aid == "None" or "." not in aid:
|
| 232 |
continue
|
| 233 |
results.append({
|
| 234 |
"appId": aid,
|
| 235 |
"storeUrl": f"https://play.google.com/store/apps/details?id={aid}",
|
| 236 |
-
"title": h.get("title",
|
| 237 |
-
"icon": h.get("icon",
|
| 238 |
"score": round(h.get("score") or 0, 1),
|
| 239 |
-
"developer": h.get("developer",
|
| 240 |
-
"installs": h.get("installs",
|
| 241 |
})
|
| 242 |
-
|
| 243 |
return jsonify({"results": results[:5]})
|
| 244 |
except Exception as e:
|
| 245 |
return jsonify({"error": str(e)}), 500
|
| 246 |
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
@app.route('/scraper')
|
| 249 |
def scraper():
|
| 250 |
return render_template('index.html')
|
| 251 |
|
| 252 |
-
|
| 253 |
@app.route('/batch')
|
| 254 |
def batch():
|
| 255 |
return render_template('batch.html')
|
| 256 |
|
| 257 |
-
|
| 258 |
@app.route('/')
|
| 259 |
def landing():
|
| 260 |
return render_template('landing.html')
|
| 261 |
|
|
|
|
| 262 |
if __name__ == "__main__":
|
| 263 |
app.run(host="0.0.0.0", debug=True, port=7860)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PlayPulse Intelligence — Flask App (v2)
|
| 3 |
+
─────────────────────────────────────────
|
| 4 |
+
Key improvements over v1
|
| 5 |
+
• Chat has conversation memory (per session, server-side deque)
|
| 6 |
+
• Intent router is enum-strict + falls back properly
|
| 7 |
+
• 6 inline chat tools (no agent needed for simple queries)
|
| 8 |
+
• Agent is one of those tools — called only for deep analysis
|
| 9 |
+
• /chat returns structured payload: reply + optional table / chart_data / agent_data
|
| 10 |
+
• "tabular format" requests produce real table JSON the frontend can render
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
import urllib.parse
|
| 14 |
import math
|
|
|
|
| 15 |
import re
|
| 16 |
+
import json
|
| 17 |
+
import requests
|
| 18 |
+
from collections import deque, defaultdict
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
from flask import Flask, request, render_template, jsonify, session
|
| 21 |
from google_play_scraper import reviews, Sort, search, app as app_info
|
| 22 |
+
import pandas as pd
|
| 23 |
+
from utils.agents import run_agent, build_llm
|
| 24 |
+
import os
|
| 25 |
|
| 26 |
app = Flask(__name__)
|
| 27 |
+
app.secret_key = os.getenv("FLASK_SECRET", "playpulse-secret-2026")
|
| 28 |
+
|
| 29 |
+
# ── Per-session conversation memory (server-side, max 20 turns) ───────────
|
| 30 |
+
# key: session_id → deque of {"role": "user"|"assistant", "content": str}
|
| 31 |
+
_CONV_MEMORY: dict[str, deque] = defaultdict(lambda: deque(maxlen=20))
|
| 32 |
+
|
| 33 |
+
MAX_HISTORY_FOR_LLM = 6 # last N turns sent to LLM for context
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 37 |
+
# SCRAPER HELPERS (unchanged from v1)
|
| 38 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 39 |
|
|
|
|
| 40 |
def extract_app_id(url_or_name: str) -> str:
|
| 41 |
url_or_name = url_or_name.strip()
|
|
|
|
|
|
|
| 42 |
if "play.google.com" in url_or_name:
|
| 43 |
parsed = urllib.parse.urlparse(url_or_name)
|
| 44 |
+
qp = urllib.parse.parse_qs(parsed.query)
|
| 45 |
+
if 'id' in qp:
|
| 46 |
+
return qp['id'][0]
|
|
|
|
|
|
|
| 47 |
if "." in url_or_name and " " not in url_or_name:
|
| 48 |
return url_or_name
|
|
|
|
| 49 |
return ""
|
| 50 |
|
| 51 |
+
|
| 52 |
def scrape_store_ids(query: str, n_hits: int = 5):
|
|
|
|
| 53 |
try:
|
| 54 |
url = f"https://play.google.com/store/search?q={urllib.parse.quote(query)}&c=apps"
|
| 55 |
+
headers = {"User-Agent": "Mozilla/5.0"}
|
| 56 |
resp = requests.get(url, headers=headers, timeout=10)
|
| 57 |
+
if resp.status_code != 200:
|
| 58 |
+
return []
|
|
|
|
| 59 |
pids = re.findall(r'details\?id=([a-zA-Z0-9._]+)', resp.text)
|
| 60 |
+
unique: list[str] = []
|
|
|
|
|
|
|
| 61 |
for p in pids:
|
| 62 |
+
if p not in unique and "None" not in p:
|
| 63 |
+
unique.append(p)
|
| 64 |
+
return unique[:n_hits]
|
| 65 |
+
except Exception:
|
| 66 |
return []
|
| 67 |
|
| 68 |
+
|
| 69 |
def serialize_review(r: dict) -> dict:
|
|
|
|
| 70 |
return {
|
| 71 |
"reviewId": r.get("reviewId", ""),
|
| 72 |
"userName": r.get("userName", ""),
|
| 73 |
+
"userImage": r.get("userImage", ""),
|
| 74 |
"content": r.get("content", ""),
|
| 75 |
+
"score": r.get("score", 0),
|
| 76 |
+
"thumbsUpCount": r.get("thumbsUpCount", 0),
|
| 77 |
+
"reviewCreatedVersion": r.get("reviewCreatedVersion", ""),
|
| 78 |
+
"at": r["at"].isoformat() if r.get("at") else "",
|
| 79 |
+
"replyContent": r.get("replyContent", "") or "",
|
| 80 |
+
"repliedAt": r["repliedAt"].isoformat() if r.get("repliedAt") else "",
|
| 81 |
}
|
| 82 |
|
| 83 |
+
|
| 84 |
def fetch_app_reviews(app_id, review_count, sort_order, star_ratings_input):
|
|
|
|
| 85 |
info = app_info(app_id, lang='en', country='us')
|
|
|
|
| 86 |
sort_map = {
|
| 87 |
'MOST_RELEVANT': Sort.MOST_RELEVANT,
|
| 88 |
'NEWEST': Sort.NEWEST,
|
|
|
|
| 98 |
reverse=True
|
| 99 |
)
|
| 100 |
|
| 101 |
+
per_bucket = math.ceil(_review_limit(review_count) / len(star_filters))
|
| 102 |
+
all_reviews: list[dict] = []
|
| 103 |
+
seen_ids: set[str] = set()
|
| 104 |
|
| 105 |
for star in star_filters:
|
| 106 |
result, _ = reviews(
|
| 107 |
+
app_id, lang='en', country='us',
|
| 108 |
+
sort=selected_sort, count=per_bucket,
|
|
|
|
|
|
|
|
|
|
| 109 |
filter_score_with=star,
|
| 110 |
)
|
| 111 |
for r in result:
|
| 112 |
rid = r.get('reviewId', '')
|
| 113 |
if rid not in seen_ids:
|
| 114 |
seen_ids.add(rid)
|
| 115 |
+
s = serialize_review(r)
|
| 116 |
+
s['appTitle'] = info['title']
|
| 117 |
+
s['appId'] = app_id
|
| 118 |
+
all_reviews.append(s)
|
| 119 |
+
|
| 120 |
return info, all_reviews
|
| 121 |
|
| 122 |
+
|
| 123 |
+
def _review_limit(count):
|
| 124 |
+
try:
|
| 125 |
+
return int(count)
|
| 126 |
+
except Exception:
|
| 127 |
+
return 150
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 131 |
+
# INLINE CHAT TOOLS (fast, no heavy agent needed for simple queries)
|
| 132 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 133 |
+
|
| 134 |
+
def _tool_rating_breakdown(df: pd.DataFrame) -> dict:
|
| 135 |
+
"""Star rating distribution across all reviews."""
|
| 136 |
+
dist = df["score"].value_counts().sort_index()
|
| 137 |
+
total = max(1, len(df))
|
| 138 |
+
rows = [
|
| 139 |
+
{
|
| 140 |
+
"Stars": f"{'★' * int(s)} ({int(s)})",
|
| 141 |
+
"Count": int(c),
|
| 142 |
+
"Percentage": f"{round(c/total*100,1)}%",
|
| 143 |
+
}
|
| 144 |
+
for s, c in dist.items()
|
| 145 |
+
]
|
| 146 |
+
return {
|
| 147 |
+
"table": {
|
| 148 |
+
"title": "Rating Distribution",
|
| 149 |
+
"columns": ["Stars", "Count", "Percentage"],
|
| 150 |
+
"rows": rows,
|
| 151 |
+
},
|
| 152 |
+
"summary": f"{len(df)} reviews: avg {round(df['score'].mean(),2)}/5",
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _tool_app_comparison(df: pd.DataFrame) -> dict:
|
| 157 |
+
"""Per-app avg rating + negative % table."""
|
| 158 |
+
if "appId" not in df.columns and "appTitle" not in df.columns:
|
| 159 |
+
return {"error": "No app column in data"}
|
| 160 |
+
|
| 161 |
+
app_col = "appTitle" if "appTitle" in df.columns else "appId"
|
| 162 |
+
rows = []
|
| 163 |
+
for app_name, grp in df.groupby(app_col):
|
| 164 |
+
sc = pd.to_numeric(grp["score"], errors="coerce")
|
| 165 |
+
rows.append({
|
| 166 |
+
"App": str(app_name),
|
| 167 |
+
"Reviews": len(grp),
|
| 168 |
+
"Avg Rating": f"{round(float(sc.mean()),2)} ★",
|
| 169 |
+
"% Negative": f"{round(float((sc <= 2).mean()*100),1)}%",
|
| 170 |
+
"% Positive": f"{round(float((sc >= 4).mean()*100),1)}%",
|
| 171 |
+
})
|
| 172 |
+
rows.sort(key=lambda x: x["Avg Rating"])
|
| 173 |
+
return {
|
| 174 |
+
"table": {
|
| 175 |
+
"title": "App Comparison",
|
| 176 |
+
"columns": ["App", "Reviews", "Avg Rating", "% Negative", "% Positive"],
|
| 177 |
+
"rows": rows,
|
| 178 |
+
},
|
| 179 |
+
"summary": f"Compared {len(rows)} apps",
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _tool_top_reviews(df: pd.DataFrame, min_stars: int = 1,
|
| 184 |
+
max_stars: int = 2, n: int = 5,
|
| 185 |
+
app_filter: str = "") -> dict:
|
| 186 |
+
"""Filtered review list as table."""
|
| 187 |
+
sc = pd.to_numeric(df["score"], errors="coerce")
|
| 188 |
+
mask = (sc >= min_stars) & (sc <= max_stars)
|
| 189 |
+
if app_filter:
|
| 190 |
+
app_col = "appTitle" if "appTitle" in df.columns else "appId"
|
| 191 |
+
mask &= df[app_col].astype(str).str.lower().str.contains(
|
| 192 |
+
re.escape(app_filter.lower()), na=False)
|
| 193 |
+
|
| 194 |
+
subset = df[mask].head(n)
|
| 195 |
+
tc = "content" if "content" in df.columns else df.columns[0]
|
| 196 |
+
app_col = "appTitle" if "appTitle" in df.columns else ("appId" if "appId" in df.columns else None)
|
| 197 |
+
|
| 198 |
+
rows = []
|
| 199 |
+
for _, r in subset.iterrows():
|
| 200 |
+
row = {
|
| 201 |
+
"User": str(r.get("userName", ""))[:20],
|
| 202 |
+
"Stars": "★" * int(r.get("score", 0)),
|
| 203 |
+
"Review": str(r.get(tc, ""))[:120],
|
| 204 |
+
}
|
| 205 |
+
if app_col:
|
| 206 |
+
row["App"] = str(r.get(app_col, ""))
|
| 207 |
+
if "thumbsUpCount" in df.columns:
|
| 208 |
+
row["Helpful"] = int(r.get("thumbsUpCount", 0))
|
| 209 |
+
rows.append(row)
|
| 210 |
+
|
| 211 |
+
label = f"{min_stars}–{max_stars} star"
|
| 212 |
+
cols = list(rows[0].keys()) if rows else []
|
| 213 |
+
return {
|
| 214 |
+
"table": {
|
| 215 |
+
"title": f"Top {label} Reviews" + (f" — {app_filter}" if app_filter else ""),
|
| 216 |
+
"columns": cols,
|
| 217 |
+
"rows": rows,
|
| 218 |
+
},
|
| 219 |
+
"summary": f"Showing {len(rows)} of {int(mask.sum())} matching reviews",
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _tool_top_helpful(df: pd.DataFrame, n: int = 5) -> dict:
|
| 224 |
+
"""Most helpful reviews."""
|
| 225 |
+
if "thumbsUpCount" not in df.columns:
|
| 226 |
+
return {"error": "No helpful votes column"}
|
| 227 |
+
df2 = df.copy()
|
| 228 |
+
df2["__h"] = pd.to_numeric(df2["thumbsUpCount"], errors="coerce").fillna(0)
|
| 229 |
+
subset = df2.nlargest(n, "__h")
|
| 230 |
+
tc = "content" if "content" in df.columns else df.columns[0]
|
| 231 |
+
app_col = "appTitle" if "appTitle" in df.columns else None
|
| 232 |
+
|
| 233 |
+
rows = []
|
| 234 |
+
for _, r in subset.iterrows():
|
| 235 |
+
row = {
|
| 236 |
+
"Stars": "★" * int(r.get("score", 0)),
|
| 237 |
+
"Helpful": int(r.get("thumbsUpCount", 0)),
|
| 238 |
+
"Review": str(r.get(tc, ""))[:120],
|
| 239 |
+
}
|
| 240 |
+
if app_col:
|
| 241 |
+
row["App"] = str(r.get(app_col, ""))
|
| 242 |
+
rows.append(row)
|
| 243 |
+
return {
|
| 244 |
+
"table": {
|
| 245 |
+
"title": "Most Helpful Reviews",
|
| 246 |
+
"columns": list(rows[0].keys()) if rows else [],
|
| 247 |
+
"rows": rows,
|
| 248 |
+
},
|
| 249 |
+
"summary": f"Top {len(rows)} most helpful reviews",
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _tool_keyword_search(df: pd.DataFrame, keyword: str, n: int = 8) -> dict:
|
| 254 |
+
"""Search review text for keyword."""
|
| 255 |
+
tc = "content" if "content" in df.columns else df.columns[0]
|
| 256 |
+
mask = df[tc].astype(str).str.lower().str.contains(
|
| 257 |
+
re.escape(keyword.lower()), na=False)
|
| 258 |
+
subset = df[mask].head(n)
|
| 259 |
+
app_col = "appTitle" if "appTitle" in df.columns else None
|
| 260 |
+
|
| 261 |
+
rows = []
|
| 262 |
+
for _, r in subset.iterrows():
|
| 263 |
+
row = {
|
| 264 |
+
"Stars": "★" * int(r.get("score", 0)),
|
| 265 |
+
"Review": str(r.get(tc, ""))[:150],
|
| 266 |
+
}
|
| 267 |
+
if app_col:
|
| 268 |
+
row["App"] = str(r.get(app_col, ""))
|
| 269 |
+
rows.append(row)
|
| 270 |
+
return {
|
| 271 |
+
"table": {
|
| 272 |
+
"title": f'Reviews mentioning "{keyword}"',
|
| 273 |
+
"columns": list(rows[0].keys()) if rows else [],
|
| 274 |
+
"rows": rows,
|
| 275 |
+
},
|
| 276 |
+
"summary": f"Found {int(mask.sum())} reviews mentioning '{keyword}'",
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 281 |
+
# INTENT CLASSIFIER (enum-strict, multi-class)
|
| 282 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 283 |
+
|
| 284 |
+
INTENT_SYSTEM = """You are an intent classifier for a game-review chat assistant.
|
| 285 |
+
Classify the user message into EXACTLY ONE of these intents:
|
| 286 |
+
|
| 287 |
+
TABLE — user wants data in tabular / structured / list format
|
| 288 |
+
COMPARISON — comparing apps / games against each other
|
| 289 |
+
KEYWORD — wants to search for a specific word/phrase in reviews
|
| 290 |
+
HELPFUL — wants the most helpful / upvoted reviews
|
| 291 |
+
ANALYSIS — deep insight, summary, cluster analysis, sentiment, recommendations
|
| 292 |
+
FILTER — filtering the visible table (show only X stars, only app Y)
|
| 293 |
+
GREETING — hi, hello, thanks, small talk
|
| 294 |
+
GENERAL — questions about features, how to use the tool, unrelated
|
| 295 |
+
|
| 296 |
+
Return ONLY one word from the list above. No explanation."""
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def classify_intent(message: str, llm) -> str:
|
| 300 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 301 |
+
try:
|
| 302 |
+
resp = llm.invoke([
|
| 303 |
+
SystemMessage(content=INTENT_SYSTEM),
|
| 304 |
+
HumanMessage(content=f'Message: "{message}"'),
|
| 305 |
+
])
|
| 306 |
+
raw = getattr(resp, "content", str(resp)).strip().upper().split()[0]
|
| 307 |
+
valid = {"TABLE","COMPARISON","KEYWORD","HELPFUL","ANALYSIS","FILTER","GREETING","GENERAL"}
|
| 308 |
+
return raw if raw in valid else "ANALYSIS"
|
| 309 |
+
except Exception:
|
| 310 |
+
return "ANALYSIS"
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# ═════════════════════════════════���═════════════════════════════════════════
|
| 314 |
+
# PARAMETER EXTRACTOR (LLM extracts structured params from natural language)
|
| 315 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 316 |
+
|
| 317 |
+
def extract_params(message: str, intent: str, llm, apps: list[str]) -> dict:
|
| 318 |
+
"""Extract structured parameters from a message given its intent."""
|
| 319 |
+
app_list_str = ", ".join(apps[:10]) if apps else "none"
|
| 320 |
+
|
| 321 |
+
system = f"""Extract parameters from the user message for intent={intent}.
|
| 322 |
+
Known app names in dataset: [{app_list_str}]
|
| 323 |
+
|
| 324 |
+
Return ONLY valid JSON (no markdown):
|
| 325 |
+
{{
|
| 326 |
+
"min_stars": 1-5 or null,
|
| 327 |
+
"max_stars": 1-5 or null,
|
| 328 |
+
"n": integer count or 5,
|
| 329 |
+
"app_filter": "exact app name or title from known list, or empty string",
|
| 330 |
+
"keyword": "search term or empty string",
|
| 331 |
+
"metric": "avg_rating|pct_negative|pct_positive|count or empty"
|
| 332 |
+
}}"""
|
| 333 |
+
|
| 334 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 335 |
+
try:
|
| 336 |
+
resp = llm.invoke([
|
| 337 |
+
SystemMessage(content=system),
|
| 338 |
+
HumanMessage(content=message),
|
| 339 |
+
])
|
| 340 |
+
raw = getattr(resp, "content", str(resp)).strip()
|
| 341 |
+
raw = re.sub(r"^```(?:json)?", "", raw).strip().rstrip("```")
|
| 342 |
+
return json.loads(raw)
|
| 343 |
+
except Exception:
|
| 344 |
+
return {"min_stars": None, "max_stars": None, "n": 5,
|
| 345 |
+
"app_filter": "", "keyword": "", "metric": ""}
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 349 |
+
# RESPONSE FORMATTER (converts tool output + agent report → rich reply)
|
| 350 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 351 |
+
|
| 352 |
+
def _format_agent_report(report: dict) -> str:
|
| 353 |
+
"""Convert agent report dict into a well-structured markdown-like text reply."""
|
| 354 |
+
parts = []
|
| 355 |
+
|
| 356 |
+
if report.get("direct_answer"):
|
| 357 |
+
parts.append(report["direct_answer"])
|
| 358 |
+
|
| 359 |
+
problems = report.get("top_problems", [])
|
| 360 |
+
if problems:
|
| 361 |
+
parts.append("\n**Top Issues:**")
|
| 362 |
+
for i, p in enumerate(problems[:4], 1):
|
| 363 |
+
sev = p.get("severity","").upper()
|
| 364 |
+
issue = p.get("issue","")
|
| 365 |
+
desc = p.get("description","")
|
| 366 |
+
ev = p.get("evidence","")
|
| 367 |
+
parts.append(f"{i}. **{issue}** [{sev}] — {desc}" + (f' _"{ev}"_' if ev else ""))
|
| 368 |
+
|
| 369 |
+
strengths = report.get("key_strengths", [])
|
| 370 |
+
if strengths:
|
| 371 |
+
parts.append("\n**What Users Love:**")
|
| 372 |
+
for s in strengths[:3]:
|
| 373 |
+
parts.append(f"• **{s.get('strength','')}** — {s.get('description','')}")
|
| 374 |
+
|
| 375 |
+
recs = report.get("recommendations", [])
|
| 376 |
+
if recs:
|
| 377 |
+
parts.append("\n**Recommendations:**")
|
| 378 |
+
for i, r in enumerate(recs[:3], 1):
|
| 379 |
+
parts.append(f"{i}. [{r.get('priority','').upper()}] {r.get('action','')} — {r.get('rationale','')}")
|
| 380 |
+
|
| 381 |
+
return "\n".join(parts) if parts else report.get("executive_summary", "Analysis complete.")
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _build_agent_table(report: dict, app_breakdown: list) -> dict | None:
|
| 385 |
+
"""If agent ran app_comparison tool, surface it as a table."""
|
| 386 |
+
if not app_breakdown:
|
| 387 |
+
return None
|
| 388 |
+
rows = [
|
| 389 |
+
{
|
| 390 |
+
"App": a.get("app",""),
|
| 391 |
+
"Reviews": a.get("count",""),
|
| 392 |
+
"Avg Rating": f"{a.get('avg_rating','?')} ★",
|
| 393 |
+
"% Negative": f"{a.get('pct_negative','?')}%",
|
| 394 |
+
"% Positive": f"{a.get('pct_positive','?')}%",
|
| 395 |
+
}
|
| 396 |
+
for a in app_breakdown
|
| 397 |
+
]
|
| 398 |
+
return {
|
| 399 |
+
"title": "App Breakdown",
|
| 400 |
+
"columns": ["App","Reviews","Avg Rating","% Negative","% Positive"],
|
| 401 |
+
"rows": rows,
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 406 |
+
# /chat ENDPOINT — the core of PlayPulse Intelligence
|
| 407 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 408 |
+
|
| 409 |
+
@app.route('/chat', methods=['POST'])
|
| 410 |
+
def chat():
|
| 411 |
+
try:
|
| 412 |
+
data = request.json or {}
|
| 413 |
+
user_message = data.get('message', '').strip()
|
| 414 |
+
current_reviews = data.get('reviews', [])
|
| 415 |
+
session_id = data.get('session_id') or request.remote_addr or "default"
|
| 416 |
+
|
| 417 |
+
if not user_message:
|
| 418 |
+
return jsonify({"error": "No message provided"}), 400
|
| 419 |
+
|
| 420 |
+
llm = build_llm()
|
| 421 |
+
if not llm:
|
| 422 |
+
return jsonify({"reply": "AI service unavailable — no API key configured.", "type": "error"})
|
| 423 |
+
|
| 424 |
+
# ── Conversation memory ────────────────────────────────────────────
|
| 425 |
+
memory = _CONV_MEMORY[session_id]
|
| 426 |
+
memory.append({"role": "user", "content": user_message})
|
| 427 |
+
|
| 428 |
+
# ── Build context from reviews ─────────────────────────────────────
|
| 429 |
+
df = pd.DataFrame(current_reviews) if current_reviews else pd.DataFrame()
|
| 430 |
+
has_data = not df.empty
|
| 431 |
+
|
| 432 |
+
# Detected app names for parameter extraction
|
| 433 |
+
apps: list[str] = []
|
| 434 |
+
if has_data:
|
| 435 |
+
for col in ["appTitle", "appId"]:
|
| 436 |
+
if col in df.columns:
|
| 437 |
+
apps = df[col].dropna().astype(str).unique().tolist()
|
| 438 |
+
break
|
| 439 |
+
|
| 440 |
+
# ── Classify intent ────────────────────────────────────────────────
|
| 441 |
+
intent = classify_intent(user_message, llm)
|
| 442 |
+
print(f"[ChatRouter] Intent: {intent} | has_data: {has_data} | apps: {apps[:3]}")
|
| 443 |
+
|
| 444 |
+
# ── Handle GREETING / GENERAL ──────────────────────────────────────
|
| 445 |
+
if intent in ("GREETING", "GENERAL"):
|
| 446 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 447 |
+
history_msgs = []
|
| 448 |
+
for turn in list(memory)[-MAX_HISTORY_FOR_LLM:]:
|
| 449 |
+
if turn["role"] == "user":
|
| 450 |
+
history_msgs.append(HumanMessage(content=turn["content"]))
|
| 451 |
+
else:
|
| 452 |
+
from langchain_core.messages import AIMessage
|
| 453 |
+
history_msgs.append(AIMessage(content=turn["content"]))
|
| 454 |
+
|
| 455 |
+
sys_msg = SystemMessage(content=(
|
| 456 |
+
"You are PlayPulse Intelligence, a friendly AI assistant for analyzing "
|
| 457 |
+
"Google Play Store reviews. Be helpful, concise, and conversational. "
|
| 458 |
+
"If the user greets you, greet back briefly. "
|
| 459 |
+
"If they ask what you can do, explain you can analyze reviews, compare apps, "
|
| 460 |
+
"find issues, show ratings, and answer questions about the scraped data."
|
| 461 |
+
))
|
| 462 |
+
resp = llm.invoke([sys_msg] + history_msgs)
|
| 463 |
+
reply = getattr(resp, "content", str(resp)).strip()
|
| 464 |
+
memory.append({"role": "assistant", "content": reply})
|
| 465 |
+
return jsonify({"reply": reply, "type": "general"})
|
| 466 |
+
|
| 467 |
+
# ── No data loaded — ask user to scrape first ─────────────────────
|
| 468 |
+
if not has_data and intent not in ("GREETING","GENERAL"):
|
| 469 |
+
reply = ("No reviews loaded yet. Please scrape an app first using the search bar, "
|
| 470 |
+
"then I can analyze the data for you! 🎮")
|
| 471 |
+
memory.append({"role": "assistant", "content": reply})
|
| 472 |
+
return jsonify({"reply": reply, "type": "general"})
|
| 473 |
+
|
| 474 |
+
# ── FILTER intent ─────────────────────────────────────────────────
|
| 475 |
+
if intent == "FILTER":
|
| 476 |
+
params = extract_params(user_message, intent, llm, apps)
|
| 477 |
+
filter_payload: dict = {}
|
| 478 |
+
if params.get("min_stars"):
|
| 479 |
+
stars = list(range(
|
| 480 |
+
int(params.get("min_stars",1)),
|
| 481 |
+
int(params.get("max_stars",params.get("min_stars",1)))+1
|
| 482 |
+
))
|
| 483 |
+
filter_payload["stars"] = stars
|
| 484 |
+
if params.get("app_filter"):
|
| 485 |
+
filter_payload["app"] = params["app_filter"]
|
| 486 |
+
if params.get("keyword"):
|
| 487 |
+
filter_payload["query"] = params["keyword"]
|
| 488 |
+
|
| 489 |
+
# Also show a summary table via TABLE tool
|
| 490 |
+
result = _tool_top_reviews(
|
| 491 |
+
df,
|
| 492 |
+
min_stars=int(params.get("min_stars") or 1),
|
| 493 |
+
max_stars=int(params.get("max_stars") or 5),
|
| 494 |
+
n=int(params.get("n") or 8),
|
| 495 |
+
app_filter=params.get("app_filter",""),
|
| 496 |
+
)
|
| 497 |
+
reply = result.get("summary","Filters applied.")
|
| 498 |
+
table = result.get("table")
|
| 499 |
+
memory.append({"role": "assistant", "content": reply})
|
| 500 |
+
return jsonify({
|
| 501 |
+
"reply": reply,
|
| 502 |
+
"filters": filter_payload,
|
| 503 |
+
"table": table,
|
| 504 |
+
"type": "filter",
|
| 505 |
+
})
|
| 506 |
+
|
| 507 |
+
# ── COMPARISON intent ─────────────────────────────────────────────
|
| 508 |
+
if intent == "COMPARISON":
|
| 509 |
+
result = _tool_app_comparison(df)
|
| 510 |
+
if "error" in result:
|
| 511 |
+
reply = result["error"]
|
| 512 |
+
memory.append({"role": "assistant", "content": reply})
|
| 513 |
+
return jsonify({"reply": reply, "type": "general"})
|
| 514 |
+
|
| 515 |
+
# Also ask LLM to narrate
|
| 516 |
+
narration_prompt = (
|
| 517 |
+
f"Here is a comparison table of apps by rating:\n"
|
| 518 |
+
f"{json.dumps(result['table']['rows'], indent=2)}\n\n"
|
| 519 |
+
f"User asked: '{user_message}'\n"
|
| 520 |
+
f"Write a 2-3 sentence natural language summary highlighting "
|
| 521 |
+
f"the worst and best performing apps."
|
| 522 |
+
)
|
| 523 |
+
from langchain_core.messages import HumanMessage
|
| 524 |
+
narr_resp = llm.invoke([HumanMessage(content=narration_prompt)])
|
| 525 |
+
narration = getattr(narr_resp, "content", str(narr_resp)).strip()
|
| 526 |
+
|
| 527 |
+
memory.append({"role": "assistant", "content": narration})
|
| 528 |
+
return jsonify({
|
| 529 |
+
"reply": narration,
|
| 530 |
+
"table": result["table"],
|
| 531 |
+
"type": "comparison",
|
| 532 |
+
})
|
| 533 |
+
|
| 534 |
+
# ── TABLE intent ──────────────────────────────────────────────────
|
| 535 |
+
if intent == "TABLE":
|
| 536 |
+
# Check what the PREVIOUS assistant message was about
|
| 537 |
+
# so "get me this in tabular format" works correctly
|
| 538 |
+
prev_context = ""
|
| 539 |
+
history = list(memory)
|
| 540 |
+
for turn in reversed(history[:-1]): # skip current user msg
|
| 541 |
+
if turn["role"] == "assistant":
|
| 542 |
+
prev_context = turn["content"]
|
| 543 |
+
break
|
| 544 |
+
|
| 545 |
+
# If previous answer was about app comparison / ratings → show comparison table
|
| 546 |
+
comp_keywords = ["rating","low rating","negative","ranked","comparison","games"]
|
| 547 |
+
if any(k in prev_context.lower() for k in comp_keywords) or "tabular" in user_message.lower():
|
| 548 |
+
result = _tool_app_comparison(df)
|
| 549 |
+
if "table" in result:
|
| 550 |
+
reply = f"Here's the comparison table. {result['summary']}"
|
| 551 |
+
memory.append({"role": "assistant", "content": reply})
|
| 552 |
+
return jsonify({
|
| 553 |
+
"reply": reply,
|
| 554 |
+
"table": result["table"],
|
| 555 |
+
"type": "table",
|
| 556 |
+
})
|
| 557 |
+
|
| 558 |
+
# Otherwise extract params and show filtered reviews table
|
| 559 |
+
params = extract_params(user_message, "TABLE", llm, apps)
|
| 560 |
+
result = _tool_top_reviews(
|
| 561 |
+
df,
|
| 562 |
+
min_stars=int(params.get("min_stars") or 1),
|
| 563 |
+
max_stars=int(params.get("max_stars") or 5),
|
| 564 |
+
n=int(params.get("n") or 10),
|
| 565 |
+
app_filter=params.get("app_filter",""),
|
| 566 |
+
)
|
| 567 |
+
reply = result.get("summary","")
|
| 568 |
+
memory.append({"role": "assistant", "content": reply})
|
| 569 |
+
return jsonify({
|
| 570 |
+
"reply": reply,
|
| 571 |
+
"table": result.get("table"),
|
| 572 |
+
"type": "table",
|
| 573 |
+
})
|
| 574 |
+
|
| 575 |
+
# ── KEYWORD intent ────────────────────────────────────────────────
|
| 576 |
+
if intent == "KEYWORD":
|
| 577 |
+
params = extract_params(user_message, intent, llm, apps)
|
| 578 |
+
kw = params.get("keyword","")
|
| 579 |
+
if not kw:
|
| 580 |
+
# Ask LLM to extract keyword from message
|
| 581 |
+
from langchain_core.messages import HumanMessage
|
| 582 |
+
kw_resp = llm.invoke([HumanMessage(content=(
|
| 583 |
+
f'Extract the search keyword or phrase from: "{user_message}". '
|
| 584 |
+
f'Return ONLY the keyword, nothing else.'
|
| 585 |
+
))])
|
| 586 |
+
kw = getattr(kw_resp, "content", str(kw_resp)).strip().strip('"')
|
| 587 |
+
|
| 588 |
+
result = _tool_keyword_search(df, kw, n=10)
|
| 589 |
+
reply = result.get("summary","")
|
| 590 |
+
memory.append({"role": "assistant", "content": reply})
|
| 591 |
+
return jsonify({
|
| 592 |
+
"reply": reply,
|
| 593 |
+
"table": result.get("table"),
|
| 594 |
+
"type": "keyword",
|
| 595 |
+
})
|
| 596 |
+
|
| 597 |
+
# ── HELPFUL intent ────────────────────────────────────────────────
|
| 598 |
+
if intent == "HELPFUL":
|
| 599 |
+
params = extract_params(user_message, intent, llm, apps)
|
| 600 |
+
result = _tool_top_helpful(df, n=int(params.get("n") or 5))
|
| 601 |
+
if "error" in result:
|
| 602 |
+
reply = result["error"]
|
| 603 |
+
else:
|
| 604 |
+
reply = result.get("summary","")
|
| 605 |
+
memory.append({"role": "assistant", "content": reply})
|
| 606 |
+
return jsonify({
|
| 607 |
+
"reply": reply,
|
| 608 |
+
"table": result.get("table"),
|
| 609 |
+
"type": "helpful",
|
| 610 |
+
})
|
| 611 |
+
|
| 612 |
+
# ── ANALYSIS intent (deep — calls LangGraph agent) ────────────────
|
| 613 |
+
# Also used as fallback for everything not caught above
|
| 614 |
+
# Build conversation context string for agent
|
| 615 |
+
history_context = "\n".join(
|
| 616 |
+
f"{'User' if t['role']=='user' else 'Assistant'}: {t['content']}"
|
| 617 |
+
for t in list(memory)[-MAX_HISTORY_FOR_LLM:]
|
| 618 |
+
)
|
| 619 |
+
enriched_query = (
|
| 620 |
+
f"Conversation so far:\n{history_context}\n\n"
|
| 621 |
+
f"User's current question: {user_message}"
|
| 622 |
+
) if len(memory) > 2 else user_message
|
| 623 |
+
|
| 624 |
+
# Run the full LangGraph agent
|
| 625 |
+
agent_state = run_agent(enriched_query, df=df if has_data else None)
|
| 626 |
+
report = agent_state.get("report", {})
|
| 627 |
+
breakdown = agent_state.get("app_breakdown", [])
|
| 628 |
+
|
| 629 |
+
# Format the reply text
|
| 630 |
+
reply = _format_agent_report(report)
|
| 631 |
+
if not reply.strip():
|
| 632 |
+
reply = report.get("executive_summary","I've completed the analysis.")
|
| 633 |
+
|
| 634 |
+
# Build optional table from app breakdown
|
| 635 |
+
table = _build_agent_table(report, breakdown)
|
| 636 |
+
|
| 637 |
+
memory.append({"role": "assistant", "content": reply})
|
| 638 |
+
return jsonify({
|
| 639 |
+
"reply": reply,
|
| 640 |
+
"table": table,
|
| 641 |
+
"agent_data": {
|
| 642 |
+
"top_problems": report.get("top_problems",[]),
|
| 643 |
+
"key_strengths": report.get("key_strengths",[]),
|
| 644 |
+
"recommendations": report.get("recommendations",[]),
|
| 645 |
+
"clusters": agent_state.get("clusters",[]),
|
| 646 |
+
"sentiment": agent_state.get("sentiment",{}),
|
| 647 |
+
"stats": agent_state.get("stats",{}),
|
| 648 |
+
},
|
| 649 |
+
"type": "analysis",
|
| 650 |
+
})
|
| 651 |
+
|
| 652 |
+
except Exception as e:
|
| 653 |
+
import traceback
|
| 654 |
+
print(f"[Chat ERROR] {e}\n{traceback.format_exc()}")
|
| 655 |
+
return jsonify({"error": str(e)}), 500
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 659 |
+
# SCRAPE ROUTES (unchanged from v1)
|
| 660 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 661 |
|
| 662 |
@app.route('/scrape', methods=['POST'])
|
| 663 |
def scrape():
|
| 664 |
try:
|
| 665 |
+
data = request.json
|
| 666 |
+
identifier = data.get('identifier', '').strip()
|
| 667 |
+
count_type = data.get('review_count_type', 'fixed')
|
| 668 |
+
count = 100000 if count_type == 'all' else data.get('review_count', 150)
|
| 669 |
+
|
| 670 |
app_id = extract_app_id(identifier)
|
| 671 |
if not app_id:
|
|
|
|
| 672 |
results = search(identifier, lang="en", country="us", n_hits=1)
|
| 673 |
if results and results[0].get('appId'):
|
| 674 |
app_id = results[0]['appId']
|
| 675 |
else:
|
|
|
|
| 676 |
pids = scrape_store_ids(identifier, n_hits=1)
|
| 677 |
+
if pids:
|
| 678 |
+
app_id = pids[0]
|
| 679 |
+
else:
|
| 680 |
+
return jsonify({"error": f"App '{identifier}' not found"}), 404
|
| 681 |
|
| 682 |
info, all_reviews = fetch_app_reviews(
|
| 683 |
+
app_id, count, data.get('sort_order'), data.get('star_ratings'))
|
|
|
|
| 684 |
|
| 685 |
return jsonify({
|
| 686 |
"app_info": {
|
| 687 |
+
"title": info['title'],
|
| 688 |
+
"icon": info['icon'],
|
| 689 |
+
"score": info['score'],
|
| 690 |
"reviews": info['reviews'],
|
| 691 |
+
"appId": app_id,
|
| 692 |
},
|
| 693 |
"reviews": all_reviews,
|
| 694 |
})
|
|
|
|
| 695 |
except Exception as e:
|
| 696 |
return jsonify({"error": str(e)}), 500
|
| 697 |
|
|
|
|
| 699 |
@app.route('/find-apps', methods=['POST'])
|
| 700 |
def find_apps():
|
| 701 |
try:
|
| 702 |
+
data = request.json
|
| 703 |
+
query = data.get('query', '').strip()
|
| 704 |
app_count = int(data.get('app_count', 10))
|
| 705 |
+
app_ids = scrape_store_ids(query, n_hits=app_count)
|
|
|
|
|
|
|
|
|
|
| 706 |
if not app_ids:
|
| 707 |
+
hits = search(query, lang="en", country="us", n_hits=app_count)
|
|
|
|
| 708 |
app_ids = [h['appId'] for h in hits if h.get('appId')]
|
| 709 |
+
|
| 710 |
results = []
|
| 711 |
for aid in app_ids:
|
| 712 |
try:
|
| 713 |
info = app_info(aid, lang='en', country='us')
|
| 714 |
results.append({
|
| 715 |
+
"appId": aid,
|
| 716 |
+
"title": info['title'],
|
| 717 |
+
"icon": info['icon'],
|
| 718 |
+
"score": info['score'],
|
| 719 |
+
"developer": info.get('developer','Unknown'),
|
| 720 |
+
"installs": info.get('installs','0+'),
|
| 721 |
})
|
| 722 |
+
except Exception:
|
| 723 |
+
continue
|
| 724 |
+
|
| 725 |
return jsonify({"results": results})
|
| 726 |
except Exception as e:
|
| 727 |
return jsonify({"error": str(e)}), 500
|
| 728 |
|
| 729 |
+
|
| 730 |
@app.route('/scrape-batch', methods=['POST'])
|
| 731 |
def scrape_batch():
|
| 732 |
try:
|
| 733 |
+
data = request.json
|
| 734 |
+
app_ids = data.get('app_ids', [])
|
| 735 |
+
count_type = data.get('review_count_type', 'fixed')
|
| 736 |
reviews_per_app = 100000 if count_type == 'all' else int(data.get('reviews_per_app', 100))
|
| 737 |
+
|
| 738 |
if not app_ids:
|
| 739 |
return jsonify({"error": "No app IDs provided"}), 400
|
| 740 |
+
|
| 741 |
+
batch_results: list[dict] = []
|
| 742 |
+
all_combined: list[dict] = []
|
| 743 |
+
|
| 744 |
for app_id in app_ids:
|
| 745 |
try:
|
| 746 |
info, app_reviews = fetch_app_reviews(
|
| 747 |
+
app_id, reviews_per_app, data.get('sort_order'), data.get('star_ratings'))
|
|
|
|
| 748 |
batch_results.append({
|
| 749 |
"title": info['title'],
|
| 750 |
+
"icon": info['icon'],
|
| 751 |
"score": info['score'],
|
| 752 |
+
"appId": app_id,
|
| 753 |
})
|
| 754 |
+
all_combined.extend(app_reviews)
|
| 755 |
+
except Exception:
|
| 756 |
continue
|
| 757 |
+
|
| 758 |
+
return jsonify({"apps": batch_results, "reviews": all_combined})
|
|
|
|
|
|
|
|
|
|
| 759 |
except Exception as e:
|
| 760 |
return jsonify({"error": str(e)}), 500
|
| 761 |
|
| 762 |
|
|
|
|
| 763 |
@app.route("/search-suggestions", methods=["POST"])
|
| 764 |
def search_suggestions():
|
|
|
|
| 765 |
try:
|
| 766 |
+
query = (request.json or {}).get("query","").strip()
|
| 767 |
if not query or len(query) < 2:
|
| 768 |
return jsonify({"results": []})
|
| 769 |
|
| 770 |
hits = search(query, lang="en", country="us", n_hits=6)
|
| 771 |
results = []
|
| 772 |
for h in hits:
|
| 773 |
+
aid = h.get("appId","")
|
| 774 |
if not aid or aid == "None" or "." not in aid:
|
| 775 |
continue
|
| 776 |
results.append({
|
| 777 |
"appId": aid,
|
| 778 |
"storeUrl": f"https://play.google.com/store/apps/details?id={aid}",
|
| 779 |
+
"title": h.get("title",""),
|
| 780 |
+
"icon": h.get("icon",""),
|
| 781 |
"score": round(h.get("score") or 0, 1),
|
| 782 |
+
"developer": h.get("developer",""),
|
| 783 |
+
"installs": h.get("installs",""),
|
| 784 |
})
|
|
|
|
| 785 |
return jsonify({"results": results[:5]})
|
| 786 |
except Exception as e:
|
| 787 |
return jsonify({"error": str(e)}), 500
|
| 788 |
|
| 789 |
|
| 790 |
+
# ═════════════════════════════════════════════════════════════════════════���═
|
| 791 |
+
# CLEAR CHAT MEMORY (optional endpoint for "New Chat" button)
|
| 792 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 793 |
+
|
| 794 |
+
@app.route('/chat/clear', methods=['POST'])
|
| 795 |
+
def clear_chat():
|
| 796 |
+
session_id = (request.json or {}).get('session_id') or request.remote_addr or "default"
|
| 797 |
+
_CONV_MEMORY[session_id].clear()
|
| 798 |
+
return jsonify({"ok": True})
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 802 |
+
# PAGE ROUTES
|
| 803 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 804 |
+
|
| 805 |
@app.route('/scraper')
|
| 806 |
def scraper():
|
| 807 |
return render_template('index.html')
|
| 808 |
|
|
|
|
| 809 |
@app.route('/batch')
|
| 810 |
def batch():
|
| 811 |
return render_template('batch.html')
|
| 812 |
|
|
|
|
| 813 |
@app.route('/')
|
| 814 |
def landing():
|
| 815 |
return render_template('landing.html')
|
| 816 |
|
| 817 |
+
|
| 818 |
if __name__ == "__main__":
|
| 819 |
app.run(host="0.0.0.0", debug=True, port=7860)
|
templates/batch.html
CHANGED
|
@@ -6,90 +6,78 @@
|
|
| 6 |
<title>Batch Intelligence | PlayPulse</title>
|
| 7 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
| 8 |
<style>
|
| 9 |
-
:root {
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
:
|
| 27 |
-
:
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
.
|
| 36 |
-
|
| 37 |
-
.
|
| 38 |
-
.
|
| 39 |
-
.
|
| 40 |
-
.
|
| 41 |
-
|
| 42 |
-
.
|
| 43 |
-
.
|
| 44 |
-
.
|
| 45 |
-
|
| 46 |
-
.
|
| 47 |
-
.
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
.
|
| 60 |
-
.
|
| 61 |
-
.
|
| 62 |
-
.
|
| 63 |
-
.
|
| 64 |
-
.
|
| 65 |
-
.
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
.table-
|
| 69 |
-
table
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
tr:last-child td
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
.
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
.
|
| 81 |
-
.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; }
|
| 82 |
-
.star-row:hover { border-color: var(--accent); }
|
| 83 |
-
.star-row input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; padding: 0; border: none; background: transparent; flex-shrink: 0; }
|
| 84 |
-
.star-label { display: flex; align-items: center; gap: 5px; font-size: 13px; font-weight: 600; flex: 1; }
|
| 85 |
-
.stars-on { color: var(--amber); letter-spacing: -1px; }
|
| 86 |
-
.stars-off { color: var(--border); letter-spacing: -1px; }
|
| 87 |
-
|
| 88 |
-
.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; }
|
| 89 |
-
.spinner { width: 40px; height: 40px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
| 90 |
-
@keyframes spin { to { transform: rotate(360deg); } }
|
| 91 |
-
|
| 92 |
-
.hidden { display: none !important; }
|
| 93 |
</style>
|
| 94 |
</head>
|
| 95 |
<body>
|
|
@@ -99,13 +87,13 @@
|
|
| 99 |
<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>
|
| 100 |
BATCH INTEL
|
| 101 |
</a>
|
| 102 |
-
<nav style="margin-left:
|
| 103 |
-
<a href="/" style="color:
|
| 104 |
-
<a href="/scraper" style="color:
|
| 105 |
-
<a href="/batch" style="color:
|
| 106 |
</nav>
|
| 107 |
<div style="flex:1"></div>
|
| 108 |
-
<button
|
| 109 |
</div>
|
| 110 |
|
| 111 |
<div class="main">
|
|
@@ -114,55 +102,37 @@
|
|
| 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);
|
| 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 |
-
|
| 128 |
-
<div
|
| 129 |
-
|
| 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;
|
| 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">
|
| 152 |
<div class="label">Sort Method</div>
|
| 153 |
-
<select id="sort">
|
| 154 |
-
<option value="MOST_RELEVANT">Most Relevant</option>
|
| 155 |
-
<option value="NEWEST">Newest</option>
|
| 156 |
-
<option value="RATING">Top Ratings</option>
|
| 157 |
-
</select>
|
| 158 |
</div>
|
| 159 |
-
|
| 160 |
<div class="input-group">
|
| 161 |
-
<div class="label">
|
| 162 |
<span>Star Rating Filter</span>
|
| 163 |
-
<div style="display:flex;gap:5px">
|
| 164 |
-
<button class="quick-btn" style="font-size:9px;
|
| 165 |
-
<button class="quick-btn" style="font-size:9px;
|
| 166 |
</div>
|
| 167 |
</div>
|
| 168 |
<div class="star-filter-grid">
|
|
@@ -173,16 +143,11 @@
|
|
| 173 |
<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>
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
-
|
| 177 |
<button class="btn-main" id="go" onclick="runBatch()">
|
| 178 |
<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>
|
| 179 |
RUN BATCH ANALYSIS
|
| 180 |
</button>
|
| 181 |
-
|
| 182 |
-
<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;">
|
| 183 |
-
<strong style="color:var(--text)">About Batch Mode</strong><br>
|
| 184 |
-
This will search for apps matching your query, scrape reviews for each, and combine them into a single comparison set.
|
| 185 |
-
</div>
|
| 186 |
</aside>
|
| 187 |
|
| 188 |
<div class="content">
|
|
@@ -191,21 +156,24 @@
|
|
| 191 |
<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>
|
| 192 |
<p>Run batch analysis to compare app data</p>
|
| 193 |
</div>
|
| 194 |
-
|
| 195 |
<div id="results" class="hidden">
|
| 196 |
<div class="batch-summary">
|
| 197 |
<div class="label">Comparing These Apps:</div>
|
| 198 |
<div class="apps-grid" id="appsGrid"></div>
|
| 199 |
</div>
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
| 201 |
<div class="table-container">
|
| 202 |
<table id="reviewsTable">
|
| 203 |
<thead>
|
| 204 |
<tr>
|
| 205 |
-
<th style="width:
|
| 206 |
-
<th style="width:
|
| 207 |
-
<th>
|
| 208 |
-
<th style="width:
|
|
|
|
| 209 |
</tr>
|
| 210 |
</thead>
|
| 211 |
<tbody id="reviewsBody"></tbody>
|
|
@@ -213,7 +181,6 @@
|
|
| 213 |
</div>
|
| 214 |
</div>
|
| 215 |
</div>
|
| 216 |
-
|
| 217 |
<div id="loader" class="loader-overlay hidden">
|
| 218 |
<div class="spinner"></div>
|
| 219 |
<p style="color:var(--muted);font-size:14px" id="loaderMsg">Searching for apps...</p>
|
|
@@ -221,176 +188,196 @@
|
|
| 221 |
</div>
|
| 222 |
</div>
|
| 223 |
|
| 224 |
-
<
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
function setMode(m) {
|
| 229 |
-
currentMode = m;
|
| 230 |
-
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
| 231 |
-
document.getElementById('btn-' + m).classList.add('active');
|
| 232 |
-
document.getElementById('reviews_per_app').classList.toggle('hidden', m === 'all');
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
function selectAllStars(check) {
|
| 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 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
}
|
| 267 |
|
| 268 |
-
function renderAppSelection()
|
| 269 |
-
const list
|
| 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
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
}
|
| 295 |
|
| 296 |
-
|
| 297 |
-
const
|
| 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 |
-
|
| 303 |
-
document.getElementById('welcome').classList.add('hidden');
|
| 304 |
-
document.getElementById('results').classList.add('hidden');
|
| 305 |
-
document.getElementById('loader').classList.remove('hidden');
|
| 306 |
-
document.getElementById('go').disabled = true;
|
| 307 |
-
|
| 308 |
-
try {
|
| 309 |
-
const res = await fetch('/scrape-batch', {
|
| 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,
|
| 317 |
-
star_ratings: stars.length === 5 ? 'all' : stars
|
| 318 |
-
})
|
| 319 |
-
});
|
| 320 |
-
|
| 321 |
-
const data = await res.json();
|
| 322 |
-
if (!res.ok) throw new Error(data.error || 'Batch scraping failed');
|
| 323 |
-
|
| 324 |
-
currentData = data;
|
| 325 |
-
render(data);
|
| 326 |
-
} catch(e) {
|
| 327 |
-
alert(e.message);
|
| 328 |
-
} finally {
|
| 329 |
-
document.getElementById('loader').classList.add('hidden');
|
| 330 |
-
document.getElementById('go').disabled = false;
|
| 331 |
-
}
|
| 332 |
-
}
|
| 333 |
-
|
| 334 |
-
function render(data) {
|
| 335 |
document.getElementById('results').classList.remove('hidden');
|
|
|
|
|
|
|
| 336 |
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
<div class="
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
<div class="app-mini-title">${a.title}</div>
|
| 343 |
-
<div class="app-mini-score">${a.score.toFixed(1)} ★</div>
|
| 344 |
-
</div>
|
| 345 |
-
</div>
|
| 346 |
-
`).join('');
|
| 347 |
-
|
| 348 |
-
// Render reviews table
|
| 349 |
-
document.getElementById('reviewsBody').innerHTML = data.reviews.map(r => {
|
| 350 |
-
const app = data.apps.find(a => a.appId === r.appId) || {title: r.appTitle};
|
| 351 |
return `
|
| 352 |
<tr>
|
| 353 |
<td>
|
| 354 |
<div class="app-tag">${app.title}</div>
|
| 355 |
-
<div style="font-size:11px;
|
| 356 |
</td>
|
| 357 |
<td>
|
| 358 |
-
<div class="score-stars">${'★'.repeat(r.score)}</div>
|
| 359 |
</td>
|
| 360 |
<td>
|
| 361 |
<div class="review-content">${r.content}</div>
|
|
|
|
| 362 |
</td>
|
| 363 |
-
<td>
|
| 364 |
-
|
| 365 |
-
</td>
|
| 366 |
</tr>
|
| 367 |
`;
|
| 368 |
}).join('');
|
| 369 |
}
|
| 370 |
|
| 371 |
-
function downloadCSV()
|
| 372 |
-
if
|
| 373 |
-
const esc
|
| 374 |
-
const hdr
|
| 375 |
-
|
| 376 |
-
const
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
| 385 |
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
a.href = url;
|
| 390 |
-
a.download = `batch_comparison_${new Date().getTime()}.csv`;
|
| 391 |
-
a.click();
|
| 392 |
}
|
| 393 |
-
</script>
|
| 394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
</body>
|
| 396 |
-
</html>
|
|
|
|
| 6 |
<title>Batch Intelligence | PlayPulse</title>
|
| 7 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
| 8 |
<style>
|
| 9 |
+
:root { --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; }
|
| 10 |
+
* { box-sizing:border-box; margin:0; padding:0; }
|
| 11 |
+
::-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;}
|
| 12 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;}
|
| 13 |
+
.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);}
|
| 14 |
+
.header{height:60px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:20px;}
|
| 15 |
+
.main{flex:1;display:flex;overflow:hidden;}
|
| 16 |
+
.sidebar{width:300px;background:var(--surface);border-right:1px solid var(--border);padding:15px;display:flex;flex-direction:column;gap:15px;overflow-y:auto;}
|
| 17 |
+
.content{flex:1;background:var(--bg);position:relative;display:flex;flex-direction:column;}
|
| 18 |
+
.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;}
|
| 19 |
+
.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);}
|
| 20 |
+
.logo{font-weight:800;font-size:18px;color:var(--accent);display:flex;align-items:center;gap:8px;text-decoration:none;}
|
| 21 |
+
.input-group{display:flex;flex-direction:column;gap:6px;}
|
| 22 |
+
.label{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:0.5px;}
|
| 23 |
+
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);}
|
| 24 |
+
.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;}
|
| 25 |
+
.scroll-view{flex:1;overflow-y:auto;padding:30px;display:flex;flex-direction:column;gap:25px;}
|
| 26 |
+
.batch-summary{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:15px;}
|
| 27 |
+
.apps-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;}
|
| 28 |
+
.app-mini-card{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px;display:flex;align-items:center;gap:10px;}
|
| 29 |
+
.app-mini-card img{width:32px;height:32px;border-radius:6px;}
|
| 30 |
+
.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);}
|
| 31 |
+
.table-container{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden;}
|
| 32 |
+
table{width:100%;border-collapse:collapse;font-size:13px;}
|
| 33 |
+
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);}
|
| 34 |
+
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);}
|
| 35 |
+
.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);}
|
| 36 |
+
.score-stars{color:var(--amber);white-space:nowrap;}
|
| 37 |
+
.review-content{color:#cbd5e1;line-height:1.5;max-width:500px;word-wrap:break-word;}
|
| 38 |
+
.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);}
|
| 39 |
+
.dev-reply-label{font-weight:700;color:var(--accent);font-size:9px;text-transform:uppercase;margin-bottom:3px;display:block;}
|
| 40 |
+
.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);}
|
| 41 |
+
.helpful-pill svg{width:10px;height:10px;color:var(--accent);}
|
| 42 |
+
.star-filter-grid{display:flex;flex-direction:column;gap:6px;}
|
| 43 |
+
.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);}
|
| 44 |
+
.star-row input[type="checkbox"]{width:15px;height:15px;accent-color:var(--accent);cursor:pointer;padding:0;border:none;background:transparent;flex-shrink:0;}
|
| 45 |
+
.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;}
|
| 46 |
+
.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;}
|
| 47 |
+
.spinner{width:40px;height:40px;border:4px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;}
|
| 48 |
+
@keyframes spin{to{transform:rotate(360deg);}}
|
| 49 |
+
.hidden{display:none!important;}
|
| 50 |
+
|
| 51 |
+
/* ── Chat styles ── */
|
| 52 |
+
#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);}
|
| 53 |
+
#chat-dialer:hover{transform:scale(1.1) rotate(5deg);box-shadow:0 12px 40px rgba(59,130,246,0.6);}
|
| 54 |
+
#chat-dialer svg{width:24px;height:24px;color:white;fill:none;stroke:currentColor;stroke-width:2.5;}
|
| 55 |
+
#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);}
|
| 56 |
+
#chat-window.open{transform:translateY(0) scale(1);opacity:1;pointer-events:auto;}
|
| 57 |
+
.chat-header{padding:14px 18px;background:var(--accent);color:white;display:flex;align-items:center;gap:12px;flex-shrink:0;}
|
| 58 |
+
.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%;}
|
| 59 |
+
.chat-header-actions{display:flex;gap:8px;align-items:center;}
|
| 60 |
+
.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);}
|
| 61 |
+
.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;}
|
| 62 |
+
.msg-row{display:flex;flex-direction:column;gap:4px;} .msg-row.user{align-items:flex-end;} .msg-row.bot{align-items:flex-start;}
|
| 63 |
+
.message{max-width:88%;padding:11px 15px;border-radius:16px;font-size:13px;line-height:1.6;}
|
| 64 |
+
.message.user{background:var(--accent);color:white;border-bottom-right-radius:4px;}
|
| 65 |
+
.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;}
|
| 66 |
+
.msg-section{margin-top:10px;font-weight:700;font-size:11px;color:var(--accent);letter-spacing:0.05em;text-transform:uppercase;}
|
| 67 |
+
.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;}
|
| 68 |
+
.chat-table-wrap{max-width:100%;overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface2);margin-top:4px;}
|
| 69 |
+
.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;}
|
| 70 |
+
.chat-table{width:100%;border-collapse:collapse;font-size:12px;}
|
| 71 |
+
.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;}
|
| 72 |
+
.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);}
|
| 73 |
+
.typing-indicator{display:flex;gap:4px;padding:12px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:16px;width:fit-content;}
|
| 74 |
+
.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;}
|
| 75 |
+
@keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-6px)}}
|
| 76 |
+
.chat-input-area{padding:14px 16px;background:var(--surface);border-top:1px solid var(--border);display:flex;gap:10px;flex-shrink:0;}
|
| 77 |
+
#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);}
|
| 78 |
+
.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;}
|
| 79 |
+
.chat-suggestions{display:flex;flex-wrap:wrap;gap:6px;padding:0 16px 10px;}
|
| 80 |
+
.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);}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
</style>
|
| 82 |
</head>
|
| 83 |
<body>
|
|
|
|
| 87 |
<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>
|
| 88 |
BATCH INTEL
|
| 89 |
</a>
|
| 90 |
+
<nav style="margin-left:30px;display:flex;gap:20px;">
|
| 91 |
+
<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>
|
| 92 |
+
<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>
|
| 93 |
+
<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>
|
| 94 |
</nav>
|
| 95 |
<div style="flex:1"></div>
|
| 96 |
+
<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>
|
| 97 |
</div>
|
| 98 |
|
| 99 |
<div class="main">
|
|
|
|
| 102 |
<div class="label">Step 1: Discover Apps</div>
|
| 103 |
<div style="display:flex;gap:8px;">
|
| 104 |
<input type="text" id="query" placeholder="e.g. Multiplayer Games..." value="Multiplayer Games" style="flex:1">
|
| 105 |
+
<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>
|
| 106 |
</div>
|
| 107 |
</div>
|
|
|
|
| 108 |
<div class="input-group">
|
| 109 |
<div class="label">Discovery Limit</div>
|
| 110 |
<input type="number" id="app_count" value="10" min="1" max="50">
|
|
|
|
| 111 |
</div>
|
| 112 |
+
<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;">
|
| 113 |
+
<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>
|
| 114 |
+
<div id="appList" style="max-height:160px;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:4px;padding-right:4px;"></div>
|
| 115 |
+
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</div>
|
|
|
|
| 117 |
<div class="input-group">
|
| 118 |
<div class="label">Step 2: Scrape Settings</div>
|
| 119 |
+
<div class="label" style="font-size:10px;margin-top:10px;">Reviews Per App</div>
|
| 120 |
<div class="mode-toggle">
|
| 121 |
<div class="mode-btn active" id="btn-fixed" onclick="setMode('fixed')">Custom</div>
|
| 122 |
<div class="mode-btn" id="btn-all" onclick="setMode('all')">Fetch All</div>
|
| 123 |
</div>
|
| 124 |
<input type="number" id="reviews_per_app" value="50" min="10" step="10">
|
| 125 |
</div>
|
|
|
|
| 126 |
<div class="input-group">
|
| 127 |
<div class="label">Sort Method</div>
|
| 128 |
+
<select id="sort"><option value="MOST_RELEVANT">Most Relevant</option><option value="NEWEST">Newest</option><option value="RATING">Top Ratings</option></select>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
</div>
|
|
|
|
| 130 |
<div class="input-group">
|
| 131 |
+
<div class="label" style="display:flex;justify-content:space-between;align-items:center;">
|
| 132 |
<span>Star Rating Filter</span>
|
| 133 |
+
<div style="display:flex;gap:5px;">
|
| 134 |
+
<button class="quick-btn" style="font-size:9px;padding:2px 5px;cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:white;border-radius:4px;" onclick="selectAllStars(true)">All</button>
|
| 135 |
+
<button class="quick-btn" style="font-size:9px;padding:2px 5px;cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:white;border-radius:4px;" onclick="selectAllStars(false)">None</button>
|
| 136 |
</div>
|
| 137 |
</div>
|
| 138 |
<div class="star-filter-grid">
|
|
|
|
| 143 |
<label class="star-row"><input type="checkbox" class="star-cb" value="1" checked><span class="star-label"><span class="stars-on">★</span><span class="stars-off">★★★★</span></span></label>
|
| 144 |
</div>
|
| 145 |
</div>
|
|
|
|
| 146 |
<button class="btn-main" id="go" onclick="runBatch()">
|
| 147 |
<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>
|
| 148 |
RUN BATCH ANALYSIS
|
| 149 |
</button>
|
| 150 |
+
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
</aside>
|
| 152 |
|
| 153 |
<div class="content">
|
|
|
|
| 156 |
<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>
|
| 157 |
<p>Run batch analysis to compare app data</p>
|
| 158 |
</div>
|
|
|
|
| 159 |
<div id="results" class="hidden">
|
| 160 |
<div class="batch-summary">
|
| 161 |
<div class="label">Comparing These Apps:</div>
|
| 162 |
<div class="apps-grid" id="appsGrid"></div>
|
| 163 |
</div>
|
| 164 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
| 165 |
+
<div class="label">Reviews Comparison</div>
|
| 166 |
+
<div style="font-size:11px;color:var(--muted);" id="resultStats"></div>
|
| 167 |
+
</div>
|
| 168 |
<div class="table-container">
|
| 169 |
<table id="reviewsTable">
|
| 170 |
<thead>
|
| 171 |
<tr>
|
| 172 |
+
<th style="width:180px">Application / User</th>
|
| 173 |
+
<th style="width:90px">Score</th>
|
| 174 |
+
<th>Feedback & Developer Response</th>
|
| 175 |
+
<th style="width:100px">Helpful</th>
|
| 176 |
+
<th style="width:110px">Date</th>
|
| 177 |
</tr>
|
| 178 |
</thead>
|
| 179 |
<tbody id="reviewsBody"></tbody>
|
|
|
|
| 181 |
</div>
|
| 182 |
</div>
|
| 183 |
</div>
|
|
|
|
| 184 |
<div id="loader" class="loader-overlay hidden">
|
| 185 |
<div class="spinner"></div>
|
| 186 |
<p style="color:var(--muted);font-size:14px" id="loaderMsg">Searching for apps...</p>
|
|
|
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
|
| 191 |
+
<!-- Chat bubble -->
|
| 192 |
+
<div id="chat-dialer" onclick="toggleChat()">
|
| 193 |
+
<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>
|
| 194 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
+
<div id="chat-window">
|
| 197 |
+
<div class="chat-header">
|
| 198 |
+
<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>
|
| 199 |
+
<div class="chat-header-info">
|
| 200 |
+
<div class="chat-header-title">PlayPulse Intelligence</div>
|
| 201 |
+
<div class="chat-header-status"><span class="status-dot"></span> Agent Online</div>
|
| 202 |
+
</div>
|
| 203 |
+
<div class="chat-header-actions">
|
| 204 |
+
<button class="chat-clear-btn" onclick="clearChat()">Clear</button>
|
| 205 |
+
<div style="cursor:pointer;opacity:0.7;" onclick="toggleChat()">
|
| 206 |
+
<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>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
<div class="chat-messages" id="chat-messages">
|
| 211 |
+
<div class="msg-row bot">
|
| 212 |
+
<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>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
<div class="chat-suggestions" id="chat-sug">
|
| 216 |
+
<div class="sug-chip" onclick="fillChat('Compare all apps by rating')">Compare apps</div>
|
| 217 |
+
<div class="sug-chip" onclick="fillChat('Which app has the most complaints?')">Most complaints</div>
|
| 218 |
+
<div class="sug-chip" onclick="fillChat('Show 1 star reviews in table')">1★ table</div>
|
| 219 |
+
<div class="sug-chip" onclick="fillChat('What are the common issues?')">Common issues</div>
|
| 220 |
+
</div>
|
| 221 |
+
<div class="chat-input-area">
|
| 222 |
+
<input type="text" id="chat-input" placeholder="Ask about the batch analysis…" onkeydown="if(event.key==='Enter') sendChatMessage()">
|
| 223 |
+
<button class="btn-send" onclick="sendChatMessage()">
|
| 224 |
+
<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>
|
| 225 |
+
</button>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
|
| 229 |
+
<script>
|
| 230 |
+
let currentData=null;
|
| 231 |
+
let currentMode='fixed';
|
| 232 |
+
|
| 233 |
+
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');}
|
| 234 |
+
function selectAllStars(check){document.querySelectorAll('.star-cb').forEach(cb=>cb.checked=check);}
|
| 235 |
+
|
| 236 |
+
let foundApps=[];
|
| 237 |
+
async function findApps(){
|
| 238 |
+
const q=document.getElementById('query').value.trim();if(!q)return;
|
| 239 |
+
const btn=document.getElementById('btnFind');btn.disabled=true;btn.innerText='Searching...';
|
| 240 |
+
try{
|
| 241 |
+
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})});
|
| 242 |
+
const data=await res.json();if(!res.ok)throw new Error(data.error||'Discovery failed');
|
| 243 |
+
foundApps=data.results;renderAppSelection();
|
| 244 |
+
}catch(e){alert(e.message);}finally{btn.disabled=false;btn.innerText='Find';}
|
| 245 |
}
|
| 246 |
|
| 247 |
+
function renderAppSelection(){
|
| 248 |
+
const list=document.getElementById('appList');
|
| 249 |
document.getElementById('selectionArea').classList.remove('hidden');
|
| 250 |
+
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('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
updateSelectionCount();
|
| 252 |
}
|
| 253 |
|
| 254 |
+
function toggleAllApps(check){document.querySelectorAll('.app-cb').forEach(cb=>cb.checked=check);updateSelectionCount();}
|
| 255 |
+
function updateSelectionCount(){const count=document.querySelectorAll('.app-cb:checked').length;document.getElementById('selectedCount').innerText=`${count} selected`;document.getElementById('go').disabled=count===0;}
|
| 256 |
+
|
| 257 |
+
async function runBatch(){
|
| 258 |
+
const selectedAppIds=[...document.querySelectorAll('.app-cb:checked')].map(cb=>cb.value);
|
| 259 |
+
if(!selectedAppIds.length)return alert('Select at least one app');
|
| 260 |
+
const stars=[...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value));
|
| 261 |
+
if(!stars.length)return alert('Select at least one star rating');
|
| 262 |
+
document.getElementById('welcome').classList.add('hidden');document.getElementById('results').classList.add('hidden');
|
| 263 |
+
document.getElementById('loader').classList.remove('hidden');document.getElementById('go').disabled=true;
|
| 264 |
+
try{
|
| 265 |
+
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})});
|
| 266 |
+
const data=await res.json();if(!res.ok)throw new Error(data.error||'Batch failed');
|
| 267 |
+
currentData=data;render(data);
|
| 268 |
+
}catch(e){alert(e.message);}finally{document.getElementById('loader').classList.add('hidden');document.getElementById('go').disabled=false;}
|
| 269 |
}
|
| 270 |
|
| 271 |
+
function render(data,customReviews){
|
| 272 |
+
const reviews=customReviews||data.reviews;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
document.getElementById('results').classList.remove('hidden');
|
| 274 |
+
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('');
|
| 275 |
+
document.getElementById('resultStats').innerText=`Found ${reviews.length} reviews`;
|
| 276 |
|
| 277 |
+
document.getElementById('reviewsBody').innerHTML=reviews.map(r=>{
|
| 278 |
+
const app=data.apps.find(a=>a.appId===r.appId)||{title:r.appTitle};
|
| 279 |
+
const replyHtml = r.replyContent ? `<div class="dev-reply"><span class="dev-reply-label">Developer Reply</span>${r.replyContent}</div>` : '';
|
| 280 |
+
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>`;
|
| 281 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
return `
|
| 283 |
<tr>
|
| 284 |
<td>
|
| 285 |
<div class="app-tag">${app.title}</div>
|
| 286 |
+
<div style="font-size:11px;font-weight:700;color:var(--text);">${r.userName}</div>
|
| 287 |
</td>
|
| 288 |
<td>
|
| 289 |
+
<div class="score-stars">${'★'.repeat(r.score)}<span style="color:var(--border)">${'★'.repeat(5-r.score)}</span></div>
|
| 290 |
</td>
|
| 291 |
<td>
|
| 292 |
<div class="review-content">${r.content}</div>
|
| 293 |
+
${replyHtml}
|
| 294 |
</td>
|
| 295 |
+
<td>${helpfulHtml}</td>
|
| 296 |
+
<td><div style="color:var(--muted);font-size:11px;">${new Date(r.at).toLocaleDateString(undefined, {month:'short', day:'numeric', year:'numeric'})}</div></td>
|
|
|
|
| 297 |
</tr>
|
| 298 |
`;
|
| 299 |
}).join('');
|
| 300 |
}
|
| 301 |
|
| 302 |
+
function downloadCSV(){
|
| 303 |
+
if(!currentData)return;
|
| 304 |
+
const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`;
|
| 305 |
+
const hdr=['App Name','App ID','User','Score','Date','Content','Thumbs Up','Developer Reply'];
|
| 306 |
+
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(','));
|
| 307 |
+
const blob=new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'});
|
| 308 |
+
const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`batch_${Date.now()}.csv`});a.click();
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// ══════════════════════════════════════════════════════════════════════
|
| 312 |
+
// CHAT (shared logic identical to index.html)
|
| 313 |
+
// ══════════════════════════════════════════════════════════════════════
|
| 314 |
+
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;})();
|
| 315 |
+
|
| 316 |
+
function toggleChat(){document.getElementById('chat-window').classList.toggle('open');}
|
| 317 |
+
function fillChat(t){const i=document.getElementById('chat-input');i.value=t;i.focus();}
|
| 318 |
|
| 319 |
+
async function clearChat(){
|
| 320 |
+
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>`;
|
| 321 |
+
await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})});
|
|
|
|
|
|
|
|
|
|
| 322 |
}
|
|
|
|
| 323 |
|
| 324 |
+
async function sendChatMessage(){
|
| 325 |
+
const input=document.getElementById('chat-input');const msg=input.value.trim();if(!msg)return;
|
| 326 |
+
appendUserMsg(msg);input.value='';
|
| 327 |
+
const container=document.getElementById('chat-messages');
|
| 328 |
+
const typing=document.createElement('div');typing.className='typing-indicator';typing.innerHTML='<div class="dot"></div><div class="dot"></div><div class="dot"></div>';
|
| 329 |
+
container.appendChild(typing);container.scrollTop=container.scrollHeight;
|
| 330 |
+
try{
|
| 331 |
+
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:[]})});
|
| 332 |
+
const data=await res.json();
|
| 333 |
+
if(typing.parentNode)container.removeChild(typing);
|
| 334 |
+
if(data.error){appendBotMsg('⚠️ '+data.error,null);return;}
|
| 335 |
+
appendBotMsg(data.reply||'',data.table||null);
|
| 336 |
+
if(data.type==='filter'&&data.filters)applyChatFilters(data.filters);
|
| 337 |
+
}catch(e){if(typing.parentNode)container.removeChild(typing);appendBotMsg('Connection error.',null);}
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
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;}
|
| 341 |
+
|
| 342 |
+
function appendBotMsg(text,table){
|
| 343 |
+
const c=document.getElementById('chat-messages');const row=document.createElement('div');row.className='msg-row bot';
|
| 344 |
+
if(text&&text.trim()){const b=document.createElement('div');b.className='message bot';b.innerHTML=renderMD(text);row.appendChild(b);}
|
| 345 |
+
if(table&&table.rows&&table.rows.length){row.appendChild(buildTable(table));}
|
| 346 |
+
c.appendChild(row);c.scrollTop=c.scrollHeight;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
function renderMD(text){
|
| 350 |
+
const lines=text.split('\n');let html='',inList=false;
|
| 351 |
+
for(let raw of lines){
|
| 352 |
+
if(/^\*\*[^*]+\*\*:?$/.test(raw.trim())){if(inList){html+='</div>';inList=false;}html+=`<div class="msg-section">${escHtml(raw.trim().replace(/^\*\*/,'').replace(/\*\*:?$/,''))}</div>`;continue;}
|
| 353 |
+
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;}
|
| 354 |
+
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;}
|
| 355 |
+
if(inList&&raw.trim()===''){html+='</div>';inList=false;}
|
| 356 |
+
if(raw.trim()===''){html+='<br>';}else{html+=`<span>${inlineFmt(raw)}</span><br>`;}
|
| 357 |
+
}
|
| 358 |
+
if(inList)html+='</div>';return html;
|
| 359 |
+
}
|
| 360 |
+
function inlineFmt(t){return escHtml(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em style="color:var(--muted2)">$1</em>');}
|
| 361 |
+
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
| 362 |
+
|
| 363 |
+
function buildTable(td){
|
| 364 |
+
const{title,columns,rows}=td;const w=document.createElement('div');w.className='chat-table-wrap';
|
| 365 |
+
let h='';if(title)h+=`<div class="chat-table-title">${escHtml(title)}</div>`;
|
| 366 |
+
h+='<table class="chat-table"><thead><tr>';for(const c of columns)h+=`<th>${escHtml(c)}</th>`;h+='</tr></thead><tbody>';
|
| 367 |
+
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>';}
|
| 368 |
+
h+='</tbody></table>';w.innerHTML=h;return w;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
function applyChatFilters(raw){
|
| 372 |
+
if(!currentData)return;
|
| 373 |
+
try{
|
| 374 |
+
const f=typeof raw==='string'?JSON.parse(raw):raw;let filtered=currentData.reviews;
|
| 375 |
+
if(f.stars&&f.stars.length)filtered=filtered.filter(r=>f.stars.includes(r.score));
|
| 376 |
+
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);});}
|
| 377 |
+
if(f.query){const q=f.query.toLowerCase();filtered=filtered.filter(r=>(r.content||'').toLowerCase().includes(q)||(r.userName||'').toLowerCase().includes(q));}
|
| 378 |
+
render(currentData,filtered);
|
| 379 |
+
}catch(e){console.error('Filter error',e);}
|
| 380 |
+
}
|
| 381 |
+
</script>
|
| 382 |
</body>
|
| 383 |
+
</html>
|
templates/index.html
CHANGED
|
@@ -21,21 +21,16 @@
|
|
| 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; }
|
| 35 |
.main { flex: 1; display: flex; overflow: hidden; }
|
| 36 |
.sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
|
| 37 |
.content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
|
| 38 |
-
|
| 39 |
.logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
|
| 40 |
.input-group { display: flex; flex-direction: column; gap: 8px; }
|
| 41 |
.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; }
|
|
@@ -50,12 +45,9 @@
|
|
| 50 |
.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; }
|
| 51 |
.btn-icon:hover { color: white; border-color: var(--accent); }
|
| 52 |
.btn-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
|
| 53 |
-
|
| 54 |
.view-tabs { display: flex; gap: 10px; }
|
| 55 |
.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; }
|
| 56 |
.tab.active { background: var(--accent); color: white; border-color: var(--accent); }
|
| 57 |
-
|
| 58 |
-
/* Star Filter */
|
| 59 |
.star-filter-grid { display: flex; flex-direction: column; gap: 6px; }
|
| 60 |
.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; }
|
| 61 |
.star-row:hover { border-color: var(--accent); }
|
|
@@ -67,26 +59,18 @@
|
|
| 67 |
.quick-btn:hover { color: white; border-color: var(--accent); }
|
| 68 |
.filter-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
| 69 |
.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); }
|
| 70 |
-
|
| 71 |
-
/* Layout */
|
| 72 |
.scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
|
| 73 |
-
|
| 74 |
-
/* App header card */
|
| 75 |
.app-card { background: var(--surface); border: 1px solid var(--border); padding: 20px; border-radius: 16px; display: flex; gap: 20px; }
|
| 76 |
.app-card img { width: 80px; height: 80px; border-radius: 16px; object-fit: cover; }
|
| 77 |
.app-stats { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
| 78 |
.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; }
|
| 79 |
.stat-val { font-size: 15px; font-weight: 800; line-height: 1; }
|
| 80 |
.stat-key { font-size: 9px; font-weight: 700; text-transform: uppercase; color: var(--muted); margin-top: 3px; letter-spacing: .5px; }
|
| 81 |
-
|
| 82 |
-
/* Summary bar */
|
| 83 |
.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; }
|
| 84 |
.star-dist { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 160px; }
|
| 85 |
.star-bar-row { display: flex; align-items: center; gap: 7px; font-size: 11px; }
|
| 86 |
.star-bar-track { flex: 1; height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
| 87 |
.star-bar-fill { height: 100%; border-radius: 3px; background: var(--amber); }
|
| 88 |
-
|
| 89 |
-
/* Review card */
|
| 90 |
.review-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; transition: border-color .15s; }
|
| 91 |
.review-card:hover { border-color: #2d3a4f; }
|
| 92 |
.review-main { padding: 16px 18px; }
|
|
@@ -98,125 +82,37 @@
|
|
| 98 |
.review-date { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
| 99 |
.review-stars { display: flex; gap: 1px; flex-shrink: 0; }
|
| 100 |
.review-text { font-size: 13px; color: #cbd5e1; line-height: 1.6; }
|
| 101 |
-
|
| 102 |
-
/* Meta pills row */
|
| 103 |
.review-footer { padding: 10px 18px; background: var(--bg); border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
| 104 |
.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); }
|
| 105 |
.meta-pill svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 2.5; flex-shrink: 0; }
|
| 106 |
.meta-pill.thumbs { color: #3b82f6; border-color: rgba(59,130,246,.25); background: var(--accent-dim); }
|
| 107 |
.meta-pill.version { color: #a78bfa; border-color: rgba(167,139,250,.25); background: rgba(167,139,250,.08); }
|
| 108 |
.meta-pill.replied { color: var(--green); border-color: rgba(34,197,94,.25); background: var(--green-dim); }
|
| 109 |
-
|
| 110 |
-
/* Dev reply block */
|
| 111 |
.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; }
|
| 112 |
.dev-reply-header { font-size: 11px; font-weight: 700; color: var(--green); margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
|
| 113 |
.dev-reply-text { font-size: 12px; color: var(--muted2); line-height: 1.55; }
|
| 114 |
.dev-reply-date { font-size: 10px; color: var(--muted); margin-top: 5px; }
|
| 115 |
-
|
| 116 |
-
/* Overlays */
|
| 117 |
.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; }
|
| 118 |
.spinner { width: 40px; height: 40px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
| 119 |
.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; }
|
| 120 |
.site-overlay h3 { font-size: 18px; font-weight: 700; }
|
| 121 |
.site-overlay p { color: var(--muted); font-size: 14px; max-width: 400px; }
|
| 122 |
-
|
| 123 |
.hidden { display: none !important; }
|
| 124 |
@keyframes spin { to { transform: rotate(360deg); } }
|
| 125 |
-
|
| 126 |
-
/* Search Suggestions Overlay */
|
| 127 |
.search-wrap { position: relative; }
|
| 128 |
-
.suggestions-box {
|
| 129 |
-
|
| 130 |
-
top: calc(100% + 8px);
|
| 131 |
-
left: 0;
|
| 132 |
-
right: 0;
|
| 133 |
-
background: var(--surface2);
|
| 134 |
-
border: 1px solid var(--border);
|
| 135 |
-
border-radius: 12px;
|
| 136 |
-
z-index: 1000;
|
| 137 |
-
max-height: 400px;
|
| 138 |
-
overflow-y: auto;
|
| 139 |
-
box-shadow: 0 15px 40px rgba(0,0,0,0.6);
|
| 140 |
-
backdrop-filter: blur(10px);
|
| 141 |
-
}
|
| 142 |
-
.suggestion-item {
|
| 143 |
-
display: flex;
|
| 144 |
-
align-items: center;
|
| 145 |
-
padding: 12px 14px;
|
| 146 |
-
gap: 12px;
|
| 147 |
-
cursor: pointer;
|
| 148 |
-
transition: .2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 149 |
-
border-bottom: 1px solid var(--border);
|
| 150 |
-
}
|
| 151 |
.suggestion-item:last-child { border-bottom: none; }
|
| 152 |
-
.suggestion-item:hover { background: var(--accent-dim);
|
| 153 |
-
.suggestion-item img {
|
| 154 |
-
width: 44px;
|
| 155 |
-
height: 44px;
|
| 156 |
-
border-radius: 10px;
|
| 157 |
-
object-fit: cover;
|
| 158 |
-
border: 1px solid var(--border);
|
| 159 |
-
}
|
| 160 |
.suggestion-info { flex: 1; min-width: 0; }
|
| 161 |
-
.suggestion-title {
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
}
|
| 169 |
-
.suggestion-sub {
|
| 170 |
-
font-size: 11px;
|
| 171 |
-
color: var(--muted);
|
| 172 |
-
margin-top: 2px;
|
| 173 |
-
white-space: nowrap;
|
| 174 |
-
overflow: hidden;
|
| 175 |
-
text-overflow: ellipsis;
|
| 176 |
-
}
|
| 177 |
-
.suggestion-score {
|
| 178 |
-
font-size: 11px;
|
| 179 |
-
font-weight: 700;
|
| 180 |
-
color: var(--amber);
|
| 181 |
-
background: rgba(245, 158, 11, 0.1);
|
| 182 |
-
padding: 2px 6px;
|
| 183 |
-
border-radius: 4px;
|
| 184 |
-
}
|
| 185 |
-
.suggestion-loading {
|
| 186 |
-
padding: 30px 20px;
|
| 187 |
-
text-align: center;
|
| 188 |
-
color: var(--muted);
|
| 189 |
-
font-size: 12px;
|
| 190 |
-
display: flex;
|
| 191 |
-
flex-direction: column;
|
| 192 |
-
align-items: center;
|
| 193 |
-
gap: 10px;
|
| 194 |
-
}
|
| 195 |
-
.suggestion-loading .spinner-small {
|
| 196 |
-
width: 20px;
|
| 197 |
-
height: 20px;
|
| 198 |
-
border: 2px solid var(--border);
|
| 199 |
-
border-top-color: var(--accent);
|
| 200 |
-
border-radius: 50%;
|
| 201 |
-
animation: spin .8s linear infinite;
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
/* Search Section Page Styles */
|
| 205 |
-
.search-results-grid {
|
| 206 |
-
display: grid;
|
| 207 |
-
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 208 |
-
gap: 15px;
|
| 209 |
-
}
|
| 210 |
-
.search-app-card {
|
| 211 |
-
background: var(--surface);
|
| 212 |
-
border: 1px solid var(--border);
|
| 213 |
-
border-radius: 16px;
|
| 214 |
-
padding: 16px;
|
| 215 |
-
display: flex;
|
| 216 |
-
gap: 15px;
|
| 217 |
-
cursor: pointer;
|
| 218 |
-
transition: .2s;
|
| 219 |
-
}
|
| 220 |
.search-app-card:hover { border-color: var(--accent); background: var(--surface2); transform: translateY(-2px); }
|
| 221 |
.search-app-card img { width: 60px; height: 60px; border-radius: 12px; }
|
| 222 |
.search-app-info { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
|
|
@@ -225,19 +121,66 @@
|
|
| 225 |
.search-app-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 600; }
|
| 226 |
.search-app-score { color: var(--amber); }
|
| 227 |
.search-app-installs { color: var(--muted2); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
</style>
|
| 229 |
</head>
|
| 230 |
<body>
|
| 231 |
|
| 232 |
<div class="header">
|
| 233 |
-
<a href="/" class="logo" style="text-decoration:
|
| 234 |
<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>
|
| 235 |
PLAYPULSE
|
| 236 |
</a>
|
| 237 |
-
<nav style="margin-left:
|
| 238 |
-
<a href="/" style="color:
|
| 239 |
-
<a href="/scraper" style="color:
|
| 240 |
-
<a href="/batch" style="color:
|
| 241 |
</nav>
|
| 242 |
<div style="flex:1"></div>
|
| 243 |
<div class="view-tabs">
|
|
@@ -258,7 +201,6 @@
|
|
| 258 |
<div class="suggestions-box hidden" id="suggestionsBox"></div>
|
| 259 |
</div>
|
| 260 |
</div>
|
| 261 |
-
|
| 262 |
<div class="input-group">
|
| 263 |
<div class="label">Amount of Data</div>
|
| 264 |
<div class="toggle-grp">
|
|
@@ -267,7 +209,6 @@
|
|
| 267 |
</div>
|
| 268 |
<input type="number" id="manualCount" value="200" placeholder="Count (e.g. 500)">
|
| 269 |
</div>
|
| 270 |
-
|
| 271 |
<div class="input-group">
|
| 272 |
<div class="label">Strategy</div>
|
| 273 |
<select id="sort">
|
|
@@ -276,11 +217,10 @@
|
|
| 276 |
<option value="RATING">Top Ratings</option>
|
| 277 |
</select>
|
| 278 |
</div>
|
| 279 |
-
|
| 280 |
<div class="input-group">
|
| 281 |
<div class="label">
|
| 282 |
<span>Star Rating Filter</span>
|
| 283 |
-
<div style="display:flex;gap:5px">
|
| 284 |
<button class="quick-btn" onclick="selectAllStars(true)">All</button>
|
| 285 |
<button class="quick-btn" onclick="selectAllStars(false)">None</button>
|
| 286 |
</div>
|
|
@@ -294,12 +234,10 @@
|
|
| 294 |
</div>
|
| 295 |
<div class="filter-chips" id="filterChips"></div>
|
| 296 |
</div>
|
| 297 |
-
|
| 298 |
<button class="btn-main" id="go" onclick="run()">
|
| 299 |
<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>
|
| 300 |
START SCRAPING
|
| 301 |
</button>
|
| 302 |
-
|
| 303 |
<div class="input-group">
|
| 304 |
<div class="label">Recent Sessions</div>
|
| 305 |
<div id="recentList" style="display:flex;flex-direction:column;gap:8px;"></div>
|
|
@@ -314,16 +252,14 @@
|
|
| 314 |
</div>
|
| 315 |
<div id="results" class="hidden"></div>
|
| 316 |
</div>
|
| 317 |
-
|
| 318 |
<div id="loader" class="loader-overlay hidden">
|
| 319 |
<div class="spinner"></div>
|
| 320 |
<p style="color:var(--muted);font-size:14px" id="loaderMsg">Connecting to servers…</p>
|
| 321 |
</div>
|
| 322 |
-
|
| 323 |
<div id="siteView" class="hidden" style="height:100%">
|
| 324 |
<div class="site-overlay">
|
| 325 |
<h3>Web View Shielded</h3>
|
| 326 |
-
<p>Google Play Store blocks previewing inside other apps
|
| 327 |
<button class="btn-main" style="width:auto;padding:12px 24px" onclick="openTarget()">
|
| 328 |
Open on Google Play
|
| 329 |
<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>
|
|
@@ -333,6 +269,44 @@
|
|
| 333 |
</div>
|
| 334 |
</div>
|
| 335 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
<script>
|
| 337 |
let mode = 'limit';
|
| 338 |
let currentData = null;
|
|
@@ -343,20 +317,17 @@
|
|
| 343 |
document.getElementById('btnLimit').classList.toggle('active', m==='limit');
|
| 344 |
document.getElementById('manualCount').classList.toggle('hidden', m==='all');
|
| 345 |
}
|
| 346 |
-
|
| 347 |
function switchView(v, event) {
|
| 348 |
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 349 |
event.target.classList.add('active');
|
| 350 |
document.getElementById('dataView').classList.toggle('hidden', v!=='data');
|
| 351 |
document.getElementById('siteView').classList.toggle('hidden', v!=='site');
|
| 352 |
}
|
| 353 |
-
|
| 354 |
function openTarget() {
|
| 355 |
const url = document.getElementById('target').value;
|
| 356 |
if (url.startsWith('http')) window.open(url,'_blank');
|
| 357 |
else if (currentData) window.open(`https://play.google.com/store/apps/details?id=${currentData.app_info.appId}`,'_blank');
|
| 358 |
}
|
| 359 |
-
|
| 360 |
function selectAllStars(checked) {
|
| 361 |
document.querySelectorAll('.star-cb').forEach(cb => cb.checked = checked);
|
| 362 |
updateChips();
|
|
@@ -370,31 +341,19 @@
|
|
| 370 |
return [...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value));
|
| 371 |
}
|
| 372 |
document.querySelectorAll('.star-cb').forEach(cb => cb.addEventListener('change', updateChips));
|
| 373 |
-
|
| 374 |
function renderStars(score) {
|
| 375 |
-
let out
|
| 376 |
-
for
|
| 377 |
return out;
|
| 378 |
}
|
| 379 |
-
|
| 380 |
-
function
|
| 381 |
-
if (!iso) return '';
|
| 382 |
-
return new Date(iso).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'});
|
| 383 |
-
}
|
| 384 |
-
|
| 385 |
-
function fmtNum(n) {
|
| 386 |
-
if (!n) return null;
|
| 387 |
-
if (n>=1000000) return (n/1000000).toFixed(1)+'M';
|
| 388 |
-
if (n>=1000) return (n/1000).toFixed(1)+'k';
|
| 389 |
-
return String(n);
|
| 390 |
-
}
|
| 391 |
|
| 392 |
async function run() {
|
| 393 |
-
const query
|
| 394 |
-
if
|
| 395 |
-
const selectedStars
|
| 396 |
-
if
|
| 397 |
-
|
| 398 |
document.getElementById('results').innerHTML='';
|
| 399 |
currentData=null;
|
| 400 |
document.getElementById('welcome').classList.add('hidden');
|
|
@@ -402,41 +361,21 @@
|
|
| 402 |
document.getElementById('loader').classList.remove('hidden');
|
| 403 |
hideSuggestions();
|
| 404 |
document.getElementById('go').disabled=true;
|
| 405 |
-
|
| 406 |
const msgs=['Connecting to servers…','Fetching app info…','Scraping reviews…','Processing data…'];
|
| 407 |
let mi=0;
|
| 408 |
document.getElementById('loaderMsg').textContent=msgs[0];
|
| 409 |
const msgInt=setInterval(()=>{mi=(mi+1)%msgs.length;document.getElementById('loaderMsg').textContent=msgs[mi];},2200);
|
| 410 |
-
|
| 411 |
try {
|
| 412 |
-
const res
|
| 413 |
-
method:'POST',
|
| 414 |
-
headers:{'Content-Type':'application/json'},
|
| 415 |
-
body:JSON.stringify({
|
| 416 |
-
identifier:query,
|
| 417 |
-
review_count_type:mode,
|
| 418 |
-
review_count:parseInt(document.getElementById('manualCount').value)||200,
|
| 419 |
-
sort_order:document.getElementById('sort').value,
|
| 420 |
-
star_ratings:selectedStars.length===5?'all':selectedStars
|
| 421 |
-
})
|
| 422 |
-
});
|
| 423 |
const data=await res.json();
|
| 424 |
-
if
|
| 425 |
-
|
| 426 |
currentData=data;
|
| 427 |
-
document.getElementById('results').classList.remove('hidden');
|
| 428 |
render(data,selectedStars);
|
| 429 |
save(data.app_info);
|
| 430 |
} catch(e) {
|
| 431 |
-
document.getElementById('results').classList.remove('hidden');
|
| 432 |
-
document.getElementById('results').innerHTML = `
|
| 433 |
-
<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);border-radius:14px;padding:24px 28px;display:flex;flex-direction:column;gap:10px">
|
| 434 |
-
<div style="display:flex;align-items:center;gap:10px">
|
| 435 |
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
| 436 |
-
<span style="font-weight:700;color:#ef4444;font-size:14px">Operation Failed</span>
|
| 437 |
-
</div>
|
| 438 |
-
<p style="color:var(--muted2);font-size:13px;line-height:1.6">${e.message}</p>
|
| 439 |
-
</div>`;
|
| 440 |
} finally {
|
| 441 |
clearInterval(msgInt);
|
| 442 |
document.getElementById('loader').classList.add('hidden');
|
|
@@ -444,10 +383,9 @@
|
|
| 444 |
}
|
| 445 |
}
|
| 446 |
|
| 447 |
-
function render(data,
|
| 448 |
-
const
|
| 449 |
-
|
| 450 |
-
// Compute stats
|
| 451 |
const dist={1:0,2:0,3:0,4:0,5:0};
|
| 452 |
reviews.forEach(r=>{if(r.score>=1&&r.score<=5)dist[r.score]++;});
|
| 453 |
const total=reviews.length;
|
|
@@ -455,122 +393,35 @@
|
|
| 455 |
const avgScore=total?(reviews.reduce((a,r)=>a+(r.score||0),0)/total).toFixed(2):'—';
|
| 456 |
const totalLikes=reviews.reduce((a,r)=>a+(r.thumbsUpCount||0),0);
|
| 457 |
const filterLabel=selectedStars.length===5?'All Ratings':selectedStars.sort((a,b)=>b-a).map(s=>`${s}★`).join(', ');
|
| 458 |
-
|
| 459 |
-
// Star distribution bars
|
| 460 |
-
const starDistHTML=[5,4,3,2,1].map(s=>{
|
| 461 |
-
const pct=total?Math.round((dist[s]/total)*100):0;
|
| 462 |
-
return `<div class="star-bar-row">
|
| 463 |
-
<span style="color:var(--amber);width:12px;text-align:right">${s}</span>
|
| 464 |
-
<div class="star-bar-track"><div class="star-bar-fill" style="width:${pct}%"></div></div>
|
| 465 |
-
<span style="color:var(--muted);width:30px;text-align:right">${pct}%</span>
|
| 466 |
-
</div>`;
|
| 467 |
-
}).join('');
|
| 468 |
-
|
| 469 |
-
// Individual review cards
|
| 470 |
const reviewsHTML=reviews.map(r=>{
|
| 471 |
const thumbsLabel=fmtNum(r.thumbsUpCount);
|
| 472 |
const hasReply=r.replyContent&&r.replyContent.trim();
|
| 473 |
const version=r.reviewCreatedVersion;
|
| 474 |
-
|
| 475 |
-
// Build meta pills — only if data exists
|
| 476 |
const pills=[
|
| 477 |
-
thumbsLabel
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
</span>` : '',
|
| 481 |
-
version ? `<span class="meta-pill version">
|
| 482 |
-
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
| 483 |
-
v${version}
|
| 484 |
-
</span>` : '',
|
| 485 |
-
hasReply ? `<span class="meta-pill replied">
|
| 486 |
-
<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>
|
| 487 |
-
Dev replied
|
| 488 |
-
</span>` : ''
|
| 489 |
].filter(Boolean).join('');
|
| 490 |
-
|
| 491 |
-
// Developer reply block
|
| 492 |
-
const replyHTML=hasReply?`
|
| 493 |
-
<div class="dev-reply">
|
| 494 |
-
<div class="dev-reply-header">
|
| 495 |
-
<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>
|
| 496 |
-
Developer Response
|
| 497 |
-
</div>
|
| 498 |
-
<div class="dev-reply-text">${r.replyContent}</div>
|
| 499 |
-
${r.repliedAt?`<div class="dev-reply-date">${fmtDate(r.repliedAt)}</div>`:''}
|
| 500 |
-
</div>`:'';
|
| 501 |
-
|
| 502 |
-
// User avatar
|
| 503 |
const initials=(r.userName||'?').trim().split(/\s+/).map(w=>w[0]).join('').slice(0,2).toUpperCase();
|
| 504 |
-
const avatarHTML=r.userImage
|
| 505 |
-
|
| 506 |
-
:`<div class="user-avatar">${initials}</div>`;
|
| 507 |
-
|
| 508 |
-
return `
|
| 509 |
-
<div class="review-card">
|
| 510 |
-
<div class="review-main">
|
| 511 |
-
<div class="review-header">
|
| 512 |
-
${avatarHTML}
|
| 513 |
-
<div class="review-meta">
|
| 514 |
-
<div class="review-username">${r.userName||'Anonymous'}</div>
|
| 515 |
-
<div class="review-date">${fmtDate(r.at)}</div>
|
| 516 |
-
</div>
|
| 517 |
-
<div class="review-stars">${renderStars(r.score)}</div>
|
| 518 |
-
</div>
|
| 519 |
-
<div class="review-text">${r.content||'<em style="color:var(--muted)">No review text</em>'}</div>
|
| 520 |
-
</div>
|
| 521 |
-
${pills?`<div class="review-footer">${pills}</div>`:''}
|
| 522 |
-
${replyHTML}
|
| 523 |
-
</div>`;
|
| 524 |
}).join('');
|
| 525 |
-
|
| 526 |
document.getElementById('results').innerHTML=`
|
| 527 |
-
<div class="app-card">
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
<h2 style="font-size:20px;font-weight:800;margin-bottom:3px">${info.title}</h2>
|
| 531 |
-
<div style="color:var(--accent);font-weight:700;font-size:11px;margin-bottom:10px">${info.appId}</div>
|
| 532 |
-
<div class="app-stats">
|
| 533 |
-
<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>
|
| 534 |
-
<div class="stat-pill"><span class="stat-val">${total.toLocaleString()}</span><span class="stat-key">Fetched</span></div>
|
| 535 |
-
<div class="stat-pill"><span class="stat-val" style="color:var(--green)">${repliedCount}</span><span class="stat-key">Replied</span></div>
|
| 536 |
-
<div class="stat-pill"><span class="stat-val" style="color:var(--accent)">${fmtNum(totalLikes)||'0'}</span><span class="stat-key">Total Likes</span></div>
|
| 537 |
-
</div>
|
| 538 |
-
</div>
|
| 539 |
-
</div>
|
| 540 |
-
|
| 541 |
-
<div class="summary-bar">
|
| 542 |
-
<div>
|
| 543 |
-
<div style="font-size:24px;font-weight:800;color:var(--amber)">${avgScore}</div>
|
| 544 |
-
<div style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.5px;margin-top:2px">Avg Score</div>
|
| 545 |
-
</div>
|
| 546 |
-
<div class="star-dist">${starDistHTML}</div>
|
| 547 |
-
<div style="font-size:11px;color:var(--muted);padding:6px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--border)">
|
| 548 |
-
Filter:<br><strong style="color:var(--accent)">${filterLabel}</strong>
|
| 549 |
-
</div>
|
| 550 |
-
</div>
|
| 551 |
-
|
| 552 |
-
<div style="display:flex;flex-direction:column;gap:10px">${reviewsHTML}</div>
|
| 553 |
-
`;
|
| 554 |
}
|
| 555 |
|
| 556 |
function downloadCSV() {
|
| 557 |
-
if
|
| 558 |
const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`;
|
| 559 |
const hdr=['Review ID','User','Score','Date','Content','Thumbs Up','App Version','Dev Reply','Dev Reply Date'];
|
| 560 |
-
const rows=currentData.reviews.map(r=>[
|
| 561 |
-
esc(r.reviewId||''),
|
| 562 |
-
esc(r.userName||''),
|
| 563 |
-
r.score||0,
|
| 564 |
-
esc((r.at||'').slice(0,10)),
|
| 565 |
-
esc(r.content||''),
|
| 566 |
-
r.thumbsUpCount||0,
|
| 567 |
-
esc(r.reviewCreatedVersion||''),
|
| 568 |
-
esc(r.replyContent||''),
|
| 569 |
-
esc((r.repliedAt||'').slice(0,10))
|
| 570 |
-
].join(','));
|
| 571 |
const blob=new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'});
|
| 572 |
const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`${currentData.app_info.appId}_reviews.csv`});
|
| 573 |
-
a.click();
|
| 574 |
}
|
| 575 |
|
| 576 |
function save(info) {
|
|
@@ -579,133 +430,145 @@
|
|
| 579 |
localStorage.setItem('scrapes',JSON.stringify(list));
|
| 580 |
loadRecent();
|
| 581 |
}
|
| 582 |
-
|
| 583 |
function loadRecent() {
|
| 584 |
const list=JSON.parse(localStorage.getItem('scrapes')||'[]');
|
| 585 |
-
document.getElementById('recentList').innerHTML=list.map(x=>`
|
| 586 |
-
<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)'">
|
| 587 |
-
<img src="${x.icon}" style="width:26px;height:26px;border-radius:5px" alt="">
|
| 588 |
-
<span style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${x.title}</span>
|
| 589 |
-
<span style="font-size:10px;color:var(--muted)">${(x.score||0).toFixed(1)}★</span>
|
| 590 |
-
</div>`).join('');
|
| 591 |
}
|
| 592 |
-
|
| 593 |
loadRecent();
|
| 594 |
|
| 595 |
-
// ── Live search suggestions ──────────────────────────────────────────
|
| 596 |
-
let searchTimer
|
| 597 |
-
const targetEl
|
| 598 |
-
const sugBox
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
}
|
|
|
|
|
|
|
| 604 |
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
|
|
|
| 608 |
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
hideSuggestions(); return;
|
| 612 |
-
}
|
| 613 |
-
if (q.length < 2) { hideSuggestions(); return; }
|
| 614 |
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
|
|
|
| 618 |
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
}
|
| 624 |
-
|
| 625 |
-
document.getElementById('results').innerHTML = `
|
| 626 |
-
<div style="margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
| 627 |
-
<span style="font-size: 18px; font-weight: 800;">Search Results: "${q}"</span>
|
| 628 |
-
</div>
|
| 629 |
-
<div class="search-results-grid" id="searchGrid">
|
| 630 |
-
<div style="grid-column: 1/-1; padding: 40px; text-align: center;">
|
| 631 |
-
<div class="spinner" style="margin: 0 auto 15px;"></div>
|
| 632 |
-
<p style="color:var(--muted)">Searching the Play Store...</p>
|
| 633 |
-
</div>
|
| 634 |
-
</div>
|
| 635 |
-
`;
|
| 636 |
-
|
| 637 |
-
searchTimer = setTimeout(async () => {
|
| 638 |
-
try {
|
| 639 |
-
const res = await fetch('/search-suggestions', {
|
| 640 |
-
method: 'POST',
|
| 641 |
-
headers: { 'Content-Type': 'application/json' },
|
| 642 |
-
body: JSON.stringify({ query: q })
|
| 643 |
-
});
|
| 644 |
-
const data = await res.json();
|
| 645 |
-
|
| 646 |
-
if (!data.results || !data.results.length) {
|
| 647 |
-
sugBox.innerHTML = '<div class="suggestion-loading">No results found</div>';
|
| 648 |
-
return;
|
| 649 |
-
}
|
| 650 |
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
</div>
|
| 658 |
-
<div class="suggestion-score">${r.score > 0 ? r.score + ' ★' : ''}</div>
|
| 659 |
-
</div>`).join('');
|
| 660 |
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
<div class="search-app-meta">
|
| 672 |
-
<span class="search-app-score">${r.score > 0 ? '★ ' + r.score : 'N/A'}</span>
|
| 673 |
-
<span class="search-app-installs">${r.installs}</span>
|
| 674 |
-
<a href="${r.storeUrl}" target="_blank" onclick="event.stopPropagation()" style="margin-left:auto; color:var(--muted); font-size:10px; text-decoration:none; display:flex; align-items:center; gap:4px;">
|
| 675 |
-
Store <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg>
|
| 676 |
-
</a>
|
| 677 |
-
</div>
|
| 678 |
-
</div>
|
| 679 |
-
</div>
|
| 680 |
-
`).join('');
|
| 681 |
-
|
| 682 |
-
document.getElementById('searchGrid').innerHTML = gridHTML;
|
| 683 |
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
|
|
|
|
|
|
| 688 |
}
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
}
|
| 699 |
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
|
|
|
|
|
|
| 709 |
</script>
|
| 710 |
</body>
|
| 711 |
</html>
|
|
|
|
| 21 |
--muted2: #94a3b8;
|
| 22 |
}
|
| 23 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
|
|
| 24 |
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 25 |
::-webkit-scrollbar-track { background: transparent; }
|
| 26 |
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
|
| 27 |
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
| 28 |
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
|
|
|
|
| 29 |
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
|
|
|
| 30 |
.header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
|
| 31 |
.main { flex: 1; display: flex; overflow: hidden; }
|
| 32 |
.sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
|
| 33 |
.content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
|
|
|
|
| 34 |
.logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
|
| 35 |
.input-group { display: flex; flex-direction: column; gap: 8px; }
|
| 36 |
.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; }
|
|
|
|
| 45 |
.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; }
|
| 46 |
.btn-icon:hover { color: white; border-color: var(--accent); }
|
| 47 |
.btn-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
|
|
|
|
| 48 |
.view-tabs { display: flex; gap: 10px; }
|
| 49 |
.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; }
|
| 50 |
.tab.active { background: var(--accent); color: white; border-color: var(--accent); }
|
|
|
|
|
|
|
| 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); }
|
|
|
|
| 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 |
.scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
|
|
|
|
|
|
|
| 63 |
.app-card { background: var(--surface); border: 1px solid var(--border); padding: 20px; border-radius: 16px; display: flex; gap: 20px; }
|
| 64 |
.app-card img { width: 80px; height: 80px; border-radius: 16px; object-fit: cover; }
|
| 65 |
.app-stats { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
| 66 |
.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; }
|
| 67 |
.stat-val { font-size: 15px; font-weight: 800; line-height: 1; }
|
| 68 |
.stat-key { font-size: 9px; font-weight: 700; text-transform: uppercase; color: var(--muted); margin-top: 3px; letter-spacing: .5px; }
|
|
|
|
|
|
|
| 69 |
.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; }
|
| 70 |
.star-dist { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 160px; }
|
| 71 |
.star-bar-row { display: flex; align-items: center; gap: 7px; font-size: 11px; }
|
| 72 |
.star-bar-track { flex: 1; height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
| 73 |
.star-bar-fill { height: 100%; border-radius: 3px; background: var(--amber); }
|
|
|
|
|
|
|
| 74 |
.review-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; transition: border-color .15s; }
|
| 75 |
.review-card:hover { border-color: #2d3a4f; }
|
| 76 |
.review-main { padding: 16px 18px; }
|
|
|
|
| 82 |
.review-date { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
| 83 |
.review-stars { display: flex; gap: 1px; flex-shrink: 0; }
|
| 84 |
.review-text { font-size: 13px; color: #cbd5e1; line-height: 1.6; }
|
|
|
|
|
|
|
| 85 |
.review-footer { padding: 10px 18px; background: var(--bg); border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
| 86 |
.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); }
|
| 87 |
.meta-pill svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 2.5; flex-shrink: 0; }
|
| 88 |
.meta-pill.thumbs { color: #3b82f6; border-color: rgba(59,130,246,.25); background: var(--accent-dim); }
|
| 89 |
.meta-pill.version { color: #a78bfa; border-color: rgba(167,139,250,.25); background: rgba(167,139,250,.08); }
|
| 90 |
.meta-pill.replied { color: var(--green); border-color: rgba(34,197,94,.25); background: var(--green-dim); }
|
|
|
|
|
|
|
| 91 |
.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; }
|
| 92 |
.dev-reply-header { font-size: 11px; font-weight: 700; color: var(--green); margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
|
| 93 |
.dev-reply-text { font-size: 12px; color: var(--muted2); line-height: 1.55; }
|
| 94 |
.dev-reply-date { font-size: 10px; color: var(--muted); margin-top: 5px; }
|
|
|
|
|
|
|
| 95 |
.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; }
|
| 96 |
.spinner { width: 40px; height: 40px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
| 97 |
.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; }
|
| 98 |
.site-overlay h3 { font-size: 18px; font-weight: 700; }
|
| 99 |
.site-overlay p { color: var(--muted); font-size: 14px; max-width: 400px; }
|
|
|
|
| 100 |
.hidden { display: none !important; }
|
| 101 |
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
|
|
|
| 102 |
.search-wrap { position: relative; }
|
| 103 |
+
.suggestions-box { position: absolute; top: calc(100% + 8px); left: 0; right: 0; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; z-index: 1000; max-height: 400px; overflow-y: auto; box-shadow: 0 15px 40px rgba(0,0,0,0.6); backdrop-filter: blur(10px); }
|
| 104 |
+
.suggestion-item { display: flex; align-items: center; padding: 12px 14px; gap: 12px; cursor: pointer; transition: .2s; border-bottom: 1px solid var(--border); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
.suggestion-item:last-child { border-bottom: none; }
|
| 106 |
+
.suggestion-item:hover { background: var(--accent-dim); }
|
| 107 |
+
.suggestion-item img { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; border: 1px solid var(--border); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
.suggestion-info { flex: 1; min-width: 0; }
|
| 109 |
+
.suggestion-title { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
|
| 110 |
+
.suggestion-sub { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 111 |
+
.suggestion-score { font-size: 11px; font-weight: 700; color: var(--amber); background: rgba(245,158,11,0.1); padding: 2px 6px; border-radius: 4px; }
|
| 112 |
+
.suggestion-loading { padding: 30px 20px; text-align: center; color: var(--muted); font-size: 12px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
| 113 |
+
.suggestion-loading .spinner-small { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
| 114 |
+
.search-results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 15px; }
|
| 115 |
+
.search-app-card { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 16px; display: flex; gap: 15px; cursor: pointer; transition: .2s; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
.search-app-card:hover { border-color: var(--accent); background: var(--surface2); transform: translateY(-2px); }
|
| 117 |
.search-app-card img { width: 60px; height: 60px; border-radius: 12px; }
|
| 118 |
.search-app-info { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
|
|
|
|
| 121 |
.search-app-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 600; }
|
| 122 |
.search-app-score { color: var(--amber); }
|
| 123 |
.search-app-installs { color: var(--muted2); }
|
| 124 |
+
|
| 125 |
+
/* ── Chat styles ── */
|
| 126 |
+
#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); }
|
| 127 |
+
#chat-dialer:hover { transform:scale(1.1) rotate(5deg); box-shadow:0 12px 40px rgba(59,130,246,0.6); }
|
| 128 |
+
#chat-dialer svg { width:24px; height:24px; color:white; fill:none; stroke:currentColor; stroke-width:2.5; }
|
| 129 |
+
#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); }
|
| 130 |
+
#chat-window.open { transform:translateY(0) scale(1); opacity:1; pointer-events:auto; }
|
| 131 |
+
.chat-header { padding:14px 18px; background:var(--accent); color:white; display:flex; align-items:center; gap:12px; flex-shrink:0; }
|
| 132 |
+
.chat-header-info { flex:1; }
|
| 133 |
+
.chat-header-title { font-weight:800; font-size:15px; }
|
| 134 |
+
.chat-header-status { font-size:10px; opacity:0.8; display:flex; align-items:center; gap:4px; }
|
| 135 |
+
.status-dot { width:6px; height:6px; background:#22c55e; border-radius:50%; }
|
| 136 |
+
.chat-header-actions { display:flex; gap:8px; align-items:center; }
|
| 137 |
+
.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; }
|
| 138 |
+
.chat-clear-btn:hover { background:rgba(255,255,255,0.25); }
|
| 139 |
+
.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; }
|
| 140 |
+
.msg-row { display:flex; flex-direction:column; gap:4px; }
|
| 141 |
+
.msg-row.user { align-items:flex-end; }
|
| 142 |
+
.msg-row.bot { align-items:flex-start; }
|
| 143 |
+
.message { max-width:88%; padding:11px 15px; border-radius:16px; font-size:13px; line-height:1.6; }
|
| 144 |
+
.message.user { background:var(--accent); color:white; border-bottom-right-radius:4px; }
|
| 145 |
+
.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; }
|
| 146 |
+
.msg-section { margin-top:10px; font-weight:700; font-size:11px; color:var(--accent); letter-spacing:0.05em; text-transform:uppercase; }
|
| 147 |
+
.msg-item { display:flex; gap:8px; margin-top:5px; }
|
| 148 |
+
.msg-item-num { font-weight:700; color:var(--accent); min-width:16px; }
|
| 149 |
+
.msg-bullet { color:var(--accent); min-width:14px; }
|
| 150 |
+
.chat-table-wrap { max-width:100%; overflow-x:auto; border:1px solid var(--border); border-radius:12px; background:var(--surface2); margin-top:4px; }
|
| 151 |
+
.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; }
|
| 152 |
+
.chat-table { width:100%; border-collapse:collapse; font-size:12px; }
|
| 153 |
+
.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; }
|
| 154 |
+
.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; }
|
| 155 |
+
.chat-table tr:last-child td { border-bottom:none; }
|
| 156 |
+
.chat-table tr:hover td { background:var(--surface); }
|
| 157 |
+
.typing-indicator { display:flex; gap:4px; padding:12px 16px; background:var(--surface2); border:1px solid var(--border); border-radius:16px; width:fit-content; }
|
| 158 |
+
.dot { width:6px; height:6px; background:var(--muted); border-radius:50%; animation:bounce 1.4s infinite; }
|
| 159 |
+
.dot:nth-child(2) { animation-delay:0.2s; }
|
| 160 |
+
.dot:nth-child(3) { animation-delay:0.4s; }
|
| 161 |
+
@keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-6px)} }
|
| 162 |
+
.chat-input-area { padding:14px 16px; background:var(--surface); border-top:1px solid var(--border); display:flex; gap:10px; flex-shrink:0; }
|
| 163 |
+
#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; }
|
| 164 |
+
#chat-input:focus { border-color:var(--accent); }
|
| 165 |
+
.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; }
|
| 166 |
+
.btn-send:hover { transform:scale(1.05); }
|
| 167 |
+
.btn-send svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:2.5; }
|
| 168 |
+
.chat-suggestions { display:flex; flex-wrap:wrap; gap:6px; padding:0 16px 10px; }
|
| 169 |
+
.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; }
|
| 170 |
+
.sug-chip:hover { border-color:var(--accent); color:var(--accent); }
|
| 171 |
</style>
|
| 172 |
</head>
|
| 173 |
<body>
|
| 174 |
|
| 175 |
<div class="header">
|
| 176 |
+
<a href="/" class="logo" style="text-decoration:none;">
|
| 177 |
<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>
|
| 178 |
PLAYPULSE
|
| 179 |
</a>
|
| 180 |
+
<nav style="margin-left:30px;display:flex;gap:20px;">
|
| 181 |
+
<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>
|
| 182 |
+
<a href="/scraper" style="color:var(--text);text-decoration:none;font-size:13px;font-weight:700;border-bottom:2px solid var(--accent);padding-bottom:4px;">Single Explorer</a>
|
| 183 |
+
<a href="/batch" 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)'">Batch Intelligence</a>
|
| 184 |
</nav>
|
| 185 |
<div style="flex:1"></div>
|
| 186 |
<div class="view-tabs">
|
|
|
|
| 201 |
<div class="suggestions-box hidden" id="suggestionsBox"></div>
|
| 202 |
</div>
|
| 203 |
</div>
|
|
|
|
| 204 |
<div class="input-group">
|
| 205 |
<div class="label">Amount of Data</div>
|
| 206 |
<div class="toggle-grp">
|
|
|
|
| 209 |
</div>
|
| 210 |
<input type="number" id="manualCount" value="200" placeholder="Count (e.g. 500)">
|
| 211 |
</div>
|
|
|
|
| 212 |
<div class="input-group">
|
| 213 |
<div class="label">Strategy</div>
|
| 214 |
<select id="sort">
|
|
|
|
| 217 |
<option value="RATING">Top Ratings</option>
|
| 218 |
</select>
|
| 219 |
</div>
|
|
|
|
| 220 |
<div class="input-group">
|
| 221 |
<div class="label">
|
| 222 |
<span>Star Rating Filter</span>
|
| 223 |
+
<div style="display:flex;gap:5px;">
|
| 224 |
<button class="quick-btn" onclick="selectAllStars(true)">All</button>
|
| 225 |
<button class="quick-btn" onclick="selectAllStars(false)">None</button>
|
| 226 |
</div>
|
|
|
|
| 234 |
</div>
|
| 235 |
<div class="filter-chips" id="filterChips"></div>
|
| 236 |
</div>
|
|
|
|
| 237 |
<button class="btn-main" id="go" onclick="run()">
|
| 238 |
<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>
|
| 239 |
START SCRAPING
|
| 240 |
</button>
|
|
|
|
| 241 |
<div class="input-group">
|
| 242 |
<div class="label">Recent Sessions</div>
|
| 243 |
<div id="recentList" style="display:flex;flex-direction:column;gap:8px;"></div>
|
|
|
|
| 252 |
</div>
|
| 253 |
<div id="results" class="hidden"></div>
|
| 254 |
</div>
|
|
|
|
| 255 |
<div id="loader" class="loader-overlay hidden">
|
| 256 |
<div class="spinner"></div>
|
| 257 |
<p style="color:var(--muted);font-size:14px" id="loaderMsg">Connecting to servers…</p>
|
| 258 |
</div>
|
|
|
|
| 259 |
<div id="siteView" class="hidden" style="height:100%">
|
| 260 |
<div class="site-overlay">
|
| 261 |
<h3>Web View Shielded</h3>
|
| 262 |
+
<p>Google Play Store blocks previewing inside other apps. Use the button below to view it in a new tab.</p>
|
| 263 |
<button class="btn-main" style="width:auto;padding:12px 24px" onclick="openTarget()">
|
| 264 |
Open on Google Play
|
| 265 |
<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>
|
|
|
|
| 269 |
</div>
|
| 270 |
</div>
|
| 271 |
|
| 272 |
+
<!-- Chat bubble -->
|
| 273 |
+
<div id="chat-dialer" onclick="toggleChat()">
|
| 274 |
+
<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>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<div id="chat-window">
|
| 278 |
+
<div class="chat-header">
|
| 279 |
+
<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>
|
| 280 |
+
<div class="chat-header-info">
|
| 281 |
+
<div class="chat-header-title">PlayPulse Intelligence</div>
|
| 282 |
+
<div class="chat-header-status"><span class="status-dot"></span> Agent Online</div>
|
| 283 |
+
</div>
|
| 284 |
+
<div class="chat-header-actions">
|
| 285 |
+
<button class="chat-clear-btn" onclick="clearChat()">Clear</button>
|
| 286 |
+
<div style="cursor:pointer;opacity:0.7;" onclick="toggleChat()">
|
| 287 |
+
<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>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
<div class="chat-messages" id="chat-messages">
|
| 292 |
+
<div class="msg-row bot">
|
| 293 |
+
<div class="message bot">👋 Hi! I'm PlayPulse Intelligence. Ask me anything about the loaded reviews — comparisons, issues, sentiment, keyword search, or say <em>"show in table"</em>.</div>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
<div class="chat-suggestions" id="chat-sug">
|
| 297 |
+
<div class="sug-chip" onclick="fillChat('What are the main issues?')">Main issues</div>
|
| 298 |
+
<div class="sug-chip" onclick="fillChat('Compare all apps by rating')">Compare apps</div>
|
| 299 |
+
<div class="sug-chip" onclick="fillChat('Show most helpful reviews')">Most helpful</div>
|
| 300 |
+
<div class="sug-chip" onclick="fillChat('Show 1 star reviews in table')">1★ table</div>
|
| 301 |
+
</div>
|
| 302 |
+
<div class="chat-input-area">
|
| 303 |
+
<input type="text" id="chat-input" placeholder="Ask about the reviews…" onkeydown="if(event.key==='Enter') sendChatMessage()">
|
| 304 |
+
<button class="btn-send" onclick="sendChatMessage()">
|
| 305 |
+
<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>
|
| 306 |
+
</button>
|
| 307 |
+
</div>
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
<script>
|
| 311 |
let mode = 'limit';
|
| 312 |
let currentData = null;
|
|
|
|
| 317 |
document.getElementById('btnLimit').classList.toggle('active', m==='limit');
|
| 318 |
document.getElementById('manualCount').classList.toggle('hidden', m==='all');
|
| 319 |
}
|
|
|
|
| 320 |
function switchView(v, event) {
|
| 321 |
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 322 |
event.target.classList.add('active');
|
| 323 |
document.getElementById('dataView').classList.toggle('hidden', v!=='data');
|
| 324 |
document.getElementById('siteView').classList.toggle('hidden', v!=='site');
|
| 325 |
}
|
|
|
|
| 326 |
function openTarget() {
|
| 327 |
const url = document.getElementById('target').value;
|
| 328 |
if (url.startsWith('http')) window.open(url,'_blank');
|
| 329 |
else if (currentData) window.open(`https://play.google.com/store/apps/details?id=${currentData.app_info.appId}`,'_blank');
|
| 330 |
}
|
|
|
|
| 331 |
function selectAllStars(checked) {
|
| 332 |
document.querySelectorAll('.star-cb').forEach(cb => cb.checked = checked);
|
| 333 |
updateChips();
|
|
|
|
| 341 |
return [...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value));
|
| 342 |
}
|
| 343 |
document.querySelectorAll('.star-cb').forEach(cb => cb.addEventListener('change', updateChips));
|
|
|
|
| 344 |
function renderStars(score) {
|
| 345 |
+
let out='';
|
| 346 |
+
for(let i=1;i<=5;i++) out+=`<span style="font-size:13px;color:${i<=score?'var(--amber)':'var(--border)'}">★</span>`;
|
| 347 |
return out;
|
| 348 |
}
|
| 349 |
+
function fmtDate(iso) { if(!iso)return''; return new Date(iso).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); }
|
| 350 |
+
function fmtNum(n) { if(!n)return null; if(n>=1000000)return(n/1000000).toFixed(1)+'M'; if(n>=1000)return(n/1000).toFixed(1)+'k'; return String(n); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
async function run() {
|
| 353 |
+
const query=document.getElementById('target').value.trim();
|
| 354 |
+
if(!query)return;
|
| 355 |
+
const selectedStars=getSelectedStars();
|
| 356 |
+
if(!selectedStars.length){alert('Select at least one star rating.');return;}
|
|
|
|
| 357 |
document.getElementById('results').innerHTML='';
|
| 358 |
currentData=null;
|
| 359 |
document.getElementById('welcome').classList.add('hidden');
|
|
|
|
| 361 |
document.getElementById('loader').classList.remove('hidden');
|
| 362 |
hideSuggestions();
|
| 363 |
document.getElementById('go').disabled=true;
|
|
|
|
| 364 |
const msgs=['Connecting to servers…','Fetching app info…','Scraping reviews…','Processing data…'];
|
| 365 |
let mi=0;
|
| 366 |
document.getElementById('loaderMsg').textContent=msgs[0];
|
| 367 |
const msgInt=setInterval(()=>{mi=(mi+1)%msgs.length;document.getElementById('loaderMsg').textContent=msgs[mi];},2200);
|
|
|
|
| 368 |
try {
|
| 369 |
+
const res=await fetch('/scrape',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({identifier:query,review_count_type:mode,review_count:parseInt(document.getElementById('manualCount').value)||200,sort_order:document.getElementById('sort').value,star_ratings:selectedStars.length===5?'all':selectedStars})});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
const data=await res.json();
|
| 371 |
+
if(!res.ok)throw new Error(data.error||'Scraping failed');
|
|
|
|
| 372 |
currentData=data;
|
| 373 |
+
document.getElementById('results').classList.remove('hidden');
|
| 374 |
render(data,selectedStars);
|
| 375 |
save(data.app_info);
|
| 376 |
} catch(e) {
|
| 377 |
+
document.getElementById('results').classList.remove('hidden');
|
| 378 |
+
document.getElementById('results').innerHTML=`<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);border-radius:14px;padding:24px 28px;"><div style="display:flex;align-items:center;gap:10px;"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg><span style="font-weight:700;color:#ef4444;">Operation Failed</span></div><p style="color:var(--muted2);font-size:13px;margin-top:10px;">${e.message}</p></div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
} finally {
|
| 380 |
clearInterval(msgInt);
|
| 381 |
document.getElementById('loader').classList.add('hidden');
|
|
|
|
| 383 |
}
|
| 384 |
}
|
| 385 |
|
| 386 |
+
function render(data,selectedStars,customReviews) {
|
| 387 |
+
const reviews=customReviews||data.reviews;
|
| 388 |
+
const info=data.app_info;
|
|
|
|
| 389 |
const dist={1:0,2:0,3:0,4:0,5:0};
|
| 390 |
reviews.forEach(r=>{if(r.score>=1&&r.score<=5)dist[r.score]++;});
|
| 391 |
const total=reviews.length;
|
|
|
|
| 393 |
const avgScore=total?(reviews.reduce((a,r)=>a+(r.score||0),0)/total).toFixed(2):'—';
|
| 394 |
const totalLikes=reviews.reduce((a,r)=>a+(r.thumbsUpCount||0),0);
|
| 395 |
const filterLabel=selectedStars.length===5?'All Ratings':selectedStars.sort((a,b)=>b-a).map(s=>`${s}★`).join(', ');
|
| 396 |
+
const starDistHTML=[5,4,3,2,1].map(s=>{const pct=total?Math.round((dist[s]/total)*100):0;return`<div class="star-bar-row"><span style="color:var(--amber);width:12px;text-align:right">${s}</span><div class="star-bar-track"><div class="star-bar-fill" style="width:${pct}%"></div></div><span style="color:var(--muted);width:30px;text-align:right">${pct}%</span></div>`;}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
const reviewsHTML=reviews.map(r=>{
|
| 398 |
const thumbsLabel=fmtNum(r.thumbsUpCount);
|
| 399 |
const hasReply=r.replyContent&&r.replyContent.trim();
|
| 400 |
const version=r.reviewCreatedVersion;
|
|
|
|
|
|
|
| 401 |
const pills=[
|
| 402 |
+
thumbsLabel?`<span class="meta-pill thumbs"><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>${thumbsLabel} helpful</span>`:'',
|
| 403 |
+
version?`<span class="meta-pill version"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>v${version}</span>`:'',
|
| 404 |
+
hasReply?`<span class="meta-pill replied"><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>Dev replied</span>`:''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
].filter(Boolean).join('');
|
| 406 |
+
const replyHTML=hasReply?`<div class="dev-reply"><div class="dev-reply-header"><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>Developer Response</div><div class="dev-reply-text">${r.replyContent}</div>${r.repliedAt?`<div class="dev-reply-date">${fmtDate(r.repliedAt)}</div>`:''}</div>`:'';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
const initials=(r.userName||'?').trim().split(/\s+/).map(w=>w[0]).join('').slice(0,2).toUpperCase();
|
| 408 |
+
const avatarHTML=r.userImage?`<div class="user-avatar"><img src="${r.userImage}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${initials}'"></div>`:`<div class="user-avatar">${initials}</div>`;
|
| 409 |
+
return`<div class="review-card"><div class="review-main"><div class="review-header">${avatarHTML}<div class="review-meta"><div class="review-username">${r.userName||'Anonymous'}</div><div class="review-date">${fmtDate(r.at)}</div></div><div class="review-stars">${renderStars(r.score)}</div></div><div class="review-text">${r.content||'<em style="color:var(--muted)">No review text</em>'}</div></div>${pills?`<div class="review-footer">${pills}</div>`:''} ${replyHTML}</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
}).join('');
|
|
|
|
| 411 |
document.getElementById('results').innerHTML=`
|
| 412 |
+
<div class="app-card"><img src="${info.icon}" alt="icon"><div style="flex:1"><h2 style="font-size:20px;font-weight:800;margin-bottom:3px">${info.title}</h2><div style="color:var(--accent);font-weight:700;font-size:11px;margin-bottom:10px">${info.appId}</div><div class="app-stats"><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><div class="stat-pill"><span class="stat-val">${total.toLocaleString()}</span><span class="stat-key">Fetched</span></div><div class="stat-pill"><span class="stat-val" style="color:var(--green)">${repliedCount}</span><span class="stat-key">Replied</span></div><div class="stat-pill"><span class="stat-val" style="color:var(--accent)">${fmtNum(totalLikes)||'0'}</span><span class="stat-key">Total Likes</span></div></div></div></div>
|
| 413 |
+
<div class="summary-bar"><div><div style="font-size:24px;font-weight:800;color:var(--amber)">${avgScore}</div><div style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.5px;margin-top:2px">Avg Score</div></div><div class="star-dist">${starDistHTML}</div><div style="font-size:11px;color:var(--muted);padding:6px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--border)">Filter:<br><strong style="color:var(--accent)">${filterLabel}</strong></div></div>
|
| 414 |
+
<div style="display:flex;flex-direction:column;gap:10px">${reviewsHTML}</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
}
|
| 416 |
|
| 417 |
function downloadCSV() {
|
| 418 |
+
if(!currentData)return;
|
| 419 |
const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`;
|
| 420 |
const hdr=['Review ID','User','Score','Date','Content','Thumbs Up','App Version','Dev Reply','Dev Reply Date'];
|
| 421 |
+
const rows=currentData.reviews.map(r=>[esc(r.reviewId||''),esc(r.userName||''),r.score||0,esc((r.at||'').slice(0,10)),esc(r.content||''),r.thumbsUpCount||0,esc(r.reviewCreatedVersion||''),esc(r.replyContent||''),esc((r.repliedAt||'').slice(0,10))].join(','));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
const blob=new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'});
|
| 423 |
const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`${currentData.app_info.appId}_reviews.csv`});
|
| 424 |
+
a.click();URL.revokeObjectURL(a.href);
|
| 425 |
}
|
| 426 |
|
| 427 |
function save(info) {
|
|
|
|
| 430 |
localStorage.setItem('scrapes',JSON.stringify(list));
|
| 431 |
loadRecent();
|
| 432 |
}
|
|
|
|
| 433 |
function loadRecent() {
|
| 434 |
const list=JSON.parse(localStorage.getItem('scrapes')||'[]');
|
| 435 |
+
document.getElementById('recentList').innerHTML=list.map(x=>`<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)'"><img src="${x.icon}" style="width:26px;height:26px;border-radius:5px" alt=""><span style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${x.title}</span><span style="font-size:10px;color:var(--muted)">${(x.score||0).toFixed(1)}★</span></div>`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
}
|
|
|
|
| 437 |
loadRecent();
|
| 438 |
|
| 439 |
+
// ── Live search suggestions ────────────────────────────────────────────
|
| 440 |
+
let searchTimer=null;
|
| 441 |
+
const targetEl=document.getElementById('target');
|
| 442 |
+
const sugBox=document.getElementById('suggestionsBox');
|
| 443 |
+
function hideSuggestions(){sugBox.classList.add('hidden');sugBox.innerHTML='';}
|
| 444 |
+
targetEl.addEventListener('input',()=>{
|
| 445 |
+
clearTimeout(searchTimer);
|
| 446 |
+
const q=targetEl.value.trim();
|
| 447 |
+
if(q.startsWith('http')||/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i.test(q)){hideSuggestions();return;}
|
| 448 |
+
if(q.length<2){hideSuggestions();return;}
|
| 449 |
+
sugBox.classList.remove('hidden');
|
| 450 |
+
sugBox.innerHTML='<div class="suggestion-loading"><div class="spinner-small"></div>Searching…</div>';
|
| 451 |
+
if(document.getElementById('results').classList.contains('hidden')){document.getElementById('welcome').classList.add('hidden');document.getElementById('results').classList.remove('hidden');}
|
| 452 |
+
document.getElementById('results').innerHTML=`<div style="margin-bottom:20px;display:flex;align-items:center;gap:10px;"><span style="font-size:18px;font-weight:800;">Search Results: "${q}"</span></div><div class="search-results-grid" id="searchGrid"><div style="grid-column:1/-1;padding:40px;text-align:center;"><div class="spinner" style="margin:0 auto 15px;"></div><p style="color:var(--muted)">Searching the Play Store...</p></div></div>`;
|
| 453 |
+
searchTimer=setTimeout(async()=>{
|
| 454 |
+
try {
|
| 455 |
+
const res=await fetch('/search-suggestions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q})});
|
| 456 |
+
const data=await res.json();
|
| 457 |
+
if(!data.results||!data.results.length){sugBox.innerHTML='<div class="suggestion-loading">No results found</div>';return;}
|
| 458 |
+
sugBox.innerHTML=data.results.map(r=>`<div class="suggestion-item" onclick="selectSuggestion('${r.appId}','${r.title.replace(/'/g,"\\'")}')"><img src="${r.icon}" alt=""><div class="suggestion-info"><div class="suggestion-title">${r.title}</div><div class="suggestion-sub">${r.developer} • ${r.installs}</div></div><div class="suggestion-score">${r.score>0?r.score+' ★':''}</div></div>`).join('');
|
| 459 |
+
const gridHTML=data.results.map(r=>`<div class="search-app-card" onclick="selectSuggestion('${r.appId}','${r.title.replace(/'/g,"\\'")}')"><img src="${r.icon}" alt=""><div class="search-app-info"><div><div class="search-app-title" title="${r.title}">${r.title}</div><div style="font-size:10px;color:var(--accent);font-family:monospace;margin-bottom:4px;">${r.appId}</div><div class="search-app-dev">${r.developer}</div></div><div class="search-app-meta"><span class="search-app-score">${r.score>0?'★ '+r.score:'N/A'}</span><span class="search-app-installs">${r.installs}</span><a href="${r.storeUrl}" target="_blank" onclick="event.stopPropagation()" style="margin-left:auto;color:var(--muted);font-size:10px;text-decoration:none;">Store ↗</a></div></div></div>`).join('');
|
| 460 |
+
document.getElementById('searchGrid').innerHTML=gridHTML;
|
| 461 |
+
} catch(err){hideSuggestions();}
|
| 462 |
+
},400);
|
| 463 |
+
});
|
| 464 |
+
function selectSuggestion(appId,title){
|
| 465 |
+
const validId=appId&&appId!=='null'&&appId!=='None'&&!appId.includes('None');
|
| 466 |
+
targetEl.value=validId?`https://play.google.com/store/apps/details?id=${appId}`:title;
|
| 467 |
+
hideSuggestions();run();
|
| 468 |
}
|
| 469 |
+
document.addEventListener('click',e=>{if(!targetEl.contains(e.target)&&!sugBox.contains(e.target))hideSuggestions();});
|
| 470 |
+
targetEl.addEventListener('keydown',e=>{if(e.key==='Escape')hideSuggestions();if(e.key==='Enter'){hideSuggestions();run();}});
|
| 471 |
|
| 472 |
+
// ══════════════════════════════════════════════════════════════════════
|
| 473 |
+
// CHAT — rich rendering, tables, markdown-lite, session memory
|
| 474 |
+
// ══════════════════════════════════════════════════════════════════════
|
| 475 |
+
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;})();
|
| 476 |
|
| 477 |
+
function toggleChat(){document.getElementById('chat-window').classList.toggle('open');}
|
| 478 |
+
function fillChat(t){const i=document.getElementById('chat-input');i.value=t;i.focus();}
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
+
async function clearChat(){
|
| 481 |
+
document.getElementById('chat-messages').innerHTML=`<div class="msg-row bot"><div class="message bot">Chat cleared. Ask me anything about the loaded reviews!</div></div>`;
|
| 482 |
+
await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})});
|
| 483 |
+
}
|
| 484 |
|
| 485 |
+
async function sendChatMessage(){
|
| 486 |
+
const input=document.getElementById('chat-input');
|
| 487 |
+
const msg=input.value.trim();
|
| 488 |
+
if(!msg)return;
|
| 489 |
+
appendUserMsg(msg);
|
| 490 |
+
input.value='';
|
| 491 |
+
const container=document.getElementById('chat-messages');
|
| 492 |
+
const typing=document.createElement('div');
|
| 493 |
+
typing.className='typing-indicator';
|
| 494 |
+
typing.innerHTML='<div class="dot"></div><div class="dot"></div><div class="dot"></div>';
|
| 495 |
+
container.appendChild(typing);
|
| 496 |
+
container.scrollTop=container.scrollHeight;
|
| 497 |
+
try {
|
| 498 |
+
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:[]})});
|
| 499 |
+
const data=await res.json();
|
| 500 |
+
if(typing.parentNode)container.removeChild(typing);
|
| 501 |
+
if(data.error){appendBotMsg('⚠️ '+data.error,null);return;}
|
| 502 |
+
appendBotMsg(data.reply||'',data.table||null);
|
| 503 |
+
if(data.type==='filter'&&data.filters)applyChatFilters(data.filters);
|
| 504 |
+
} catch(e){
|
| 505 |
+
if(typing.parentNode)container.removeChild(typing);
|
| 506 |
+
appendBotMsg('Connection error. Is the server running?',null);
|
| 507 |
}
|
| 508 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
|
| 510 |
+
function appendUserMsg(text){
|
| 511 |
+
const c=document.getElementById('chat-messages');
|
| 512 |
+
const row=document.createElement('div');row.className='msg-row user';
|
| 513 |
+
row.innerHTML=`<div class="message user">${escHtml(text)}</div>`;
|
| 514 |
+
c.appendChild(row);c.scrollTop=c.scrollHeight;
|
| 515 |
+
}
|
|
|
|
|
|
|
|
|
|
| 516 |
|
| 517 |
+
function appendBotMsg(text,table){
|
| 518 |
+
const c=document.getElementById('chat-messages');
|
| 519 |
+
const row=document.createElement('div');row.className='msg-row bot';
|
| 520 |
+
if(text&&text.trim()){
|
| 521 |
+
const b=document.createElement('div');b.className='message bot';
|
| 522 |
+
b.innerHTML=renderMD(text);row.appendChild(b);
|
| 523 |
+
}
|
| 524 |
+
if(table&&table.rows&&table.rows.length){row.appendChild(buildTable(table));}
|
| 525 |
+
c.appendChild(row);c.scrollTop=c.scrollHeight;
|
| 526 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
|
| 528 |
+
function renderMD(text){
|
| 529 |
+
const lines=text.split('\n');let html='',inList=false;
|
| 530 |
+
for(let raw of lines){
|
| 531 |
+
if(/^\*\*[^*]+\*\*:?$/.test(raw.trim())){
|
| 532 |
+
if(inList){html+='</div>';inList=false;}
|
| 533 |
+
html+=`<div class="msg-section">${escHtml(raw.trim().replace(/^\*\*/,'').replace(/\*\*:?$/,''))}</div>`;continue;
|
| 534 |
}
|
| 535 |
+
const nm=raw.match(/^(\d+)\.\s+(.+)/);
|
| 536 |
+
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;}
|
| 537 |
+
const bm=raw.match(/^[•\-\*]\s+(.+)/);
|
| 538 |
+
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;}
|
| 539 |
+
if(inList&&raw.trim()===''){html+='</div>';inList=false;}
|
| 540 |
+
if(raw.trim()===''){html+='<br>';}else{html+=`<span>${inlineFmt(raw)}</span><br>`;}
|
| 541 |
+
}
|
| 542 |
+
if(inList)html+='</div>';
|
| 543 |
+
return html;
|
| 544 |
+
}
|
| 545 |
+
function inlineFmt(t){return escHtml(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em style="color:var(--muted2)">$1</em>');}
|
| 546 |
+
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
| 547 |
+
|
| 548 |
+
function buildTable(td){
|
| 549 |
+
const {title,columns,rows}=td;
|
| 550 |
+
const w=document.createElement('div');w.className='chat-table-wrap';
|
| 551 |
+
let h='';
|
| 552 |
+
if(title)h+=`<div class="chat-table-title">${escHtml(title)}</div>`;
|
| 553 |
+
h+='<table class="chat-table"><thead><tr>';
|
| 554 |
+
for(const c of columns)h+=`<th>${escHtml(c)}</th>`;
|
| 555 |
+
h+='</tr></thead><tbody>';
|
| 556 |
+
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>';}
|
| 557 |
+
h+='</tbody></table>';
|
| 558 |
+
w.innerHTML=h;return w;
|
| 559 |
}
|
| 560 |
|
| 561 |
+
function applyChatFilters(raw){
|
| 562 |
+
if(!currentData)return;
|
| 563 |
+
try{
|
| 564 |
+
const f=typeof raw==='string'?JSON.parse(raw):raw;
|
| 565 |
+
let filtered=currentData.reviews;
|
| 566 |
+
if(f.stars&&f.stars.length)filtered=filtered.filter(r=>f.stars.includes(r.score));
|
| 567 |
+
if(f.app){const q=f.app.toLowerCase();filtered=filtered.filter(r=>(r.appTitle||'').toLowerCase().includes(q)||(r.appId||'').toLowerCase().includes(q));}
|
| 568 |
+
if(f.query){const q=f.query.toLowerCase();filtered=filtered.filter(r=>(r.content||'').toLowerCase().includes(q)||(r.userName||'').toLowerCase().includes(q));}
|
| 569 |
+
render(currentData,getSelectedStars(),filtered);
|
| 570 |
+
}catch(e){console.error('Filter error',e);}
|
| 571 |
+
}
|
| 572 |
</script>
|
| 573 |
</body>
|
| 574 |
</html>
|
templates/landing.html
CHANGED
|
@@ -6,239 +6,57 @@
|
|
| 6 |
<title>PlayPulse | Intelligence Platform</title>
|
| 7 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
|
| 8 |
<style>
|
| 9 |
-
:root {
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
:
|
| 23 |
-
:
|
| 24 |
-
:
|
| 25 |
-
::-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
.
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
}
|
| 48 |
-
.
|
| 49 |
-
.
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
.
|
| 59 |
-
|
| 60 |
-
font-weight: 800;
|
| 61 |
-
font-size: 24px;
|
| 62 |
-
letter-spacing: -1px;
|
| 63 |
-
color: var(--text);
|
| 64 |
-
display: flex;
|
| 65 |
-
align-items: center;
|
| 66 |
-
gap: 10px;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
.logo-icon {
|
| 70 |
-
width: 32px;
|
| 71 |
-
height: 32px;
|
| 72 |
-
background: var(--accent-gradient);
|
| 73 |
-
border-radius: 8px;
|
| 74 |
-
display: flex;
|
| 75 |
-
align-items: center;
|
| 76 |
-
justify-content: center;
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
main {
|
| 80 |
-
flex: 1;
|
| 81 |
-
display: flex;
|
| 82 |
-
flex-direction: column;
|
| 83 |
-
align-items: center;
|
| 84 |
-
justify-content: center;
|
| 85 |
-
padding: 60px 5%;
|
| 86 |
-
max-width: 1200px;
|
| 87 |
-
margin: 0 auto;
|
| 88 |
-
text-align: center;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
.hero-tag {
|
| 92 |
-
background: rgba(59, 130, 246, 0.1);
|
| 93 |
-
border: 1px solid rgba(59, 130, 246, 0.2);
|
| 94 |
-
color: var(--accent);
|
| 95 |
-
padding: 6px 16px;
|
| 96 |
-
border-radius: 100px;
|
| 97 |
-
font-size: 13px;
|
| 98 |
-
font-weight: 700;
|
| 99 |
-
margin-bottom: 24px;
|
| 100 |
-
text-transform: uppercase;
|
| 101 |
-
letter-spacing: 1px;
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
h1 {
|
| 105 |
-
font-family: 'Outfit', sans-serif;
|
| 106 |
-
font-size: clamp(40px, 8vw, 72px);
|
| 107 |
-
font-weight: 800;
|
| 108 |
-
line-height: 1.1;
|
| 109 |
-
margin-bottom: 20px;
|
| 110 |
-
letter-spacing: -2px;
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
h1 span {
|
| 114 |
-
background: var(--accent-gradient);
|
| 115 |
-
-webkit-background-clip: text;
|
| 116 |
-
background-clip: text;
|
| 117 |
-
-webkit-text-fill-color: transparent;
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
.sub-hero {
|
| 121 |
-
color: var(--muted);
|
| 122 |
-
font-size: clamp(16px, 2vw, 20px);
|
| 123 |
-
max-width: 600px;
|
| 124 |
-
margin-bottom: 60px;
|
| 125 |
-
line-height: 1.6;
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
.cards-container {
|
| 129 |
-
display: grid;
|
| 130 |
-
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
| 131 |
-
gap: 30px;
|
| 132 |
-
width: 100%;
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
.card {
|
| 136 |
-
background: var(--surface);
|
| 137 |
-
border: 1px solid var(--border);
|
| 138 |
-
padding: 40px;
|
| 139 |
-
border-radius: 24px;
|
| 140 |
-
text-align: left;
|
| 141 |
-
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 142 |
-
cursor: pointer;
|
| 143 |
-
position: relative;
|
| 144 |
-
overflow: hidden;
|
| 145 |
-
display: flex;
|
| 146 |
-
flex-direction: column;
|
| 147 |
-
height: 100%;
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
.card:hover {
|
| 151 |
-
border-color: rgba(59, 130, 246, 0.5);
|
| 152 |
-
transform: translateY(-8px);
|
| 153 |
-
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
.card.disabled {
|
| 157 |
-
cursor: not-allowed;
|
| 158 |
-
opacity: 0.8;
|
| 159 |
-
}
|
| 160 |
-
.card.disabled:hover { transform: none; border-color: var(--border); }
|
| 161 |
-
|
| 162 |
-
.card-icon {
|
| 163 |
-
width: 56px;
|
| 164 |
-
height: 56px;
|
| 165 |
-
background: rgba(255, 255, 255, 0.03);
|
| 166 |
-
border: 1px solid var(--border);
|
| 167 |
-
border-radius: 16px;
|
| 168 |
-
display: flex;
|
| 169 |
-
align-items: center;
|
| 170 |
-
justify-content: center;
|
| 171 |
-
margin-bottom: 24px;
|
| 172 |
-
transition: 0.3s;
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
.card:hover .card-icon {
|
| 176 |
-
background: var(--accent);
|
| 177 |
-
color: white;
|
| 178 |
-
border-color: var(--accent);
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
.card h2 {
|
| 182 |
-
font-family: 'Outfit', sans-serif;
|
| 183 |
-
font-size: 24px;
|
| 184 |
-
margin-bottom: 12px;
|
| 185 |
-
font-weight: 700;
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
.card p {
|
| 189 |
-
color: var(--muted);
|
| 190 |
-
line-height: 1.6;
|
| 191 |
-
font-size: 15px;
|
| 192 |
-
margin-bottom: 24px;
|
| 193 |
-
flex: 1;
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
.badge {
|
| 197 |
-
position: absolute;
|
| 198 |
-
top: 20px;
|
| 199 |
-
right: 20px;
|
| 200 |
-
background: rgba(255, 255, 255, 0.05);
|
| 201 |
-
border: 1px solid var(--border);
|
| 202 |
-
padding: 4px 12px;
|
| 203 |
-
border-radius: 100px;
|
| 204 |
-
font-size: 11px;
|
| 205 |
-
font-weight: 700;
|
| 206 |
-
color: var(--muted);
|
| 207 |
-
text-transform: uppercase;
|
| 208 |
-
}
|
| 209 |
-
.badge.active {
|
| 210 |
-
background: var(--accent-dim);
|
| 211 |
-
color: var(--accent);
|
| 212 |
-
border-color: rgba(59, 130, 246, 0.2);
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
.btn {
|
| 216 |
-
display: inline-flex;
|
| 217 |
-
align-items: center;
|
| 218 |
-
gap: 8px;
|
| 219 |
-
font-weight: 700;
|
| 220 |
-
font-size: 14px;
|
| 221 |
-
color: var(--text);
|
| 222 |
-
transition: 0.3s;
|
| 223 |
-
}
|
| 224 |
-
|
| 225 |
-
.card:hover .btn {
|
| 226 |
-
color: var(--accent);
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
.card.disabled .btn { color: var(--muted); }
|
| 230 |
-
|
| 231 |
-
footer {
|
| 232 |
-
padding: 40px;
|
| 233 |
-
text-align: center;
|
| 234 |
-
color: var(--muted);
|
| 235 |
-
font-size: 13px;
|
| 236 |
-
}
|
| 237 |
-
|
| 238 |
-
@media (max-width: 768px) {
|
| 239 |
-
h1 { font-size: 48px; }
|
| 240 |
-
.cards-container { grid-template-columns: 1fr; }
|
| 241 |
-
}
|
| 242 |
</style>
|
| 243 |
</head>
|
| 244 |
<body>
|
|
@@ -257,41 +75,104 @@
|
|
| 257 |
<main>
|
| 258 |
<div class="hero-tag">Next-Gen Intelligence</div>
|
| 259 |
<h1>Extract Insights from <span>Global App Data</span></h1>
|
| 260 |
-
<p class="sub-hero">The most powerful tool for analyzing app reviews, sentiment, and developer responses in real-time.</p>
|
| 261 |
|
| 262 |
<div class="cards-container">
|
| 263 |
-
<!-- Single Search Card -->
|
| 264 |
<div class="card" onclick="location.href='/scraper'">
|
| 265 |
<div class="badge active">Live Now</div>
|
| 266 |
<div class="card-icon">
|
| 267 |
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35"/></svg>
|
| 268 |
</div>
|
| 269 |
<h2>Single App Explorer</h2>
|
| 270 |
-
<p>Deep-dive into any Play Store app. Extract hundreds of reviews, analyze ratings, and
|
| 271 |
-
<div class="btn">
|
| 272 |
-
Explore Now
|
| 273 |
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
| 274 |
-
</div>
|
| 275 |
</div>
|
| 276 |
-
|
| 277 |
-
<!-- Batch Intelligence Card -->
|
| 278 |
<div class="card" onclick="location.href='/batch'">
|
| 279 |
<div class="badge active">New Mode</div>
|
| 280 |
<div class="card-icon">
|
| 281 |
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
| 282 |
</div>
|
| 283 |
<h2>Batch Intelligence</h2>
|
| 284 |
-
<p>Compare multiple apps side-by-side. Track competitor updates and aggregate sentiment across entire categories.</p>
|
| 285 |
-
<div class="btn">
|
| 286 |
-
Start Analysis
|
| 287 |
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
| 288 |
-
</div>
|
| 289 |
</div>
|
| 290 |
</div>
|
| 291 |
</main>
|
| 292 |
|
| 293 |
-
<footer>
|
| 294 |
-
|
| 295 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
</body>
|
| 297 |
-
</html>
|
|
|
|
| 6 |
<title>PlayPulse | Intelligence Platform</title>
|
| 7 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
|
| 8 |
<style>
|
| 9 |
+
:root { --bg:#0b0e14; --surface:#151921; --surface2:#1c2333; --border:rgba(255,255,255,0.08); --accent:#3b82f6; --accent-gradient:linear-gradient(135deg,#3b82f6 0%,#2dd4bf 100%); --text:#f1f5f9; --muted:#94a3b8; }
|
| 10 |
+
*{box-sizing:border-box;margin:0;padding:0;}
|
| 11 |
+
::-webkit-scrollbar{width:6px;} ::-webkit-scrollbar-track{background:transparent;} ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:10px;}
|
| 12 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;flex-direction:column;overflow-x:hidden;}
|
| 13 |
+
.blob{position:fixed;width:500px;height:500px;background:var(--accent);filter:blur(120px);opacity:0.1;z-index:-1;border-radius:50%;}
|
| 14 |
+
.blob-1{top:-100px;right:-100px;} .blob-2{bottom:-100px;left:-100px;background:#2dd4bf;}
|
| 15 |
+
header{padding:30px 5%;display:flex;justify-content:space-between;align-items:center;}
|
| 16 |
+
.logo{font-family:'Outfit',sans-serif;font-weight:800;font-size:24px;letter-spacing:-1px;color:var(--text);display:flex;align-items:center;gap:10px;}
|
| 17 |
+
.logo-icon{width:32px;height:32px;background:var(--accent-gradient);border-radius:8px;display:flex;align-items:center;justify-content:center;}
|
| 18 |
+
main{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 5%;max-width:1200px;margin:0 auto;text-align:center;width:100%;}
|
| 19 |
+
.hero-tag{background:rgba(59,130,246,0.1);border:1px solid rgba(59,130,246,0.2);color:var(--accent);padding:6px 16px;border-radius:100px;font-size:13px;font-weight:700;margin-bottom:24px;text-transform:uppercase;letter-spacing:1px;}
|
| 20 |
+
h1{font-family:'Outfit',sans-serif;font-size:clamp(40px,8vw,72px);font-weight:800;line-height:1.1;margin-bottom:20px;letter-spacing:-2px;}
|
| 21 |
+
h1 span{background:var(--accent-gradient);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
|
| 22 |
+
.sub-hero{color:var(--muted);font-size:clamp(16px,2vw,20px);max-width:600px;margin-bottom:60px;line-height:1.6;}
|
| 23 |
+
.cards-container{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:30px;width:100%;}
|
| 24 |
+
.card{background:var(--surface);border:1px solid var(--border);padding:40px;border-radius:24px;text-align:left;transition:all 0.3s cubic-bezier(0.4,0,0.2,1);cursor:pointer;position:relative;overflow:hidden;display:flex;flex-direction:column;height:100%;}
|
| 25 |
+
.card:hover{border-color:rgba(59,130,246,0.5);transform:translateY(-8px);box-shadow:0 20px 40px rgba(0,0,0,0.4);}
|
| 26 |
+
.card-icon{width:56px;height:56px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:16px;display:flex;align-items:center;justify-content:center;margin-bottom:24px;transition:0.3s;}
|
| 27 |
+
.card:hover .card-icon{background:var(--accent);color:white;border-color:var(--accent);}
|
| 28 |
+
.card h2{font-family:'Outfit',sans-serif;font-size:24px;margin-bottom:12px;font-weight:700;}
|
| 29 |
+
.card p{color:var(--muted);line-height:1.6;font-size:15px;margin-bottom:24px;flex:1;}
|
| 30 |
+
.badge{position:absolute;top:20px;right:20px;background:rgba(255,255,255,0.05);border:1px solid var(--border);padding:4px 12px;border-radius:100px;font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;}
|
| 31 |
+
.badge.active{background:rgba(59,130,246,0.1);color:var(--accent);border-color:rgba(59,130,246,0.2);}
|
| 32 |
+
.btn{display:inline-flex;align-items:center;gap:8px;font-weight:700;font-size:14px;color:var(--text);transition:0.3s;}
|
| 33 |
+
.card:hover .btn{color:var(--accent);}
|
| 34 |
+
footer{padding:40px;text-align:center;color:var(--muted);font-size:13px;}
|
| 35 |
+
@media(max-width:768px){h1{font-size:48px;}.cards-container{grid-template-columns:1fr;}}
|
| 36 |
+
|
| 37 |
+
/* ── Chat styles ── */
|
| 38 |
+
#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);}
|
| 39 |
+
#chat-dialer:hover{transform:scale(1.1) rotate(5deg);box-shadow:0 12px 40px rgba(59,130,246,0.6);}
|
| 40 |
+
#chat-dialer svg{width:24px;height:24px;color:white;fill:none;stroke:currentColor;stroke-width:2.5;}
|
| 41 |
+
#chat-window{position:fixed;bottom:90px;right:24px;width:420px;height:560px;background:var(--surface);border:1px solid rgba(255,255,255,0.1);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);}
|
| 42 |
+
#chat-window.open{transform:translateY(0) scale(1);opacity:1;pointer-events:auto;}
|
| 43 |
+
.chat-header{padding:14px 18px;background:var(--accent);color:white;display:flex;align-items:center;gap:12px;flex-shrink:0;}
|
| 44 |
+
.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%;}
|
| 45 |
+
.chat-header-actions{display:flex;gap:8px;align-items:center;}
|
| 46 |
+
.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);}
|
| 47 |
+
.chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;background-image:radial-gradient(rgba(255,255,255,0.05) 1px,transparent 1px);background-size:20px 20px;}
|
| 48 |
+
.msg-row{display:flex;flex-direction:column;gap:4px;} .msg-row.user{align-items:flex-end;} .msg-row.bot{align-items:flex-start;}
|
| 49 |
+
.message{max-width:88%;padding:11px 15px;border-radius:16px;font-size:13px;line-height:1.6;}
|
| 50 |
+
.message.user{background:var(--accent);color:white;border-bottom-right-radius:4px;}
|
| 51 |
+
.message.bot{background:var(--surface2);color:var(--text);border:1px solid rgba(255,255,255,0.08);border-bottom-left-radius:4px;white-space:pre-wrap;word-break:break-word;}
|
| 52 |
+
.msg-section{margin-top:10px;font-weight:700;font-size:11px;color:var(--accent);letter-spacing:0.05em;text-transform:uppercase;}
|
| 53 |
+
.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;}
|
| 54 |
+
.typing-indicator{display:flex;gap:4px;padding:12px 16px;background:var(--surface2);border:1px solid rgba(255,255,255,0.08);border-radius:16px;width:fit-content;}
|
| 55 |
+
.dot{width:6px;height:6px;background:#64748b;border-radius:50%;animation:bounce 1.4s infinite;} .dot:nth-child(2){animation-delay:0.2s;} .dot:nth-child(3){animation-delay:0.4s;}
|
| 56 |
+
@keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-6px)}}
|
| 57 |
+
.chat-input-area{padding:14px 16px;background:var(--surface);border-top:1px solid rgba(255,255,255,0.06);display:flex;gap:10px;flex-shrink:0;}
|
| 58 |
+
#chat-input{flex:1;background:var(--bg);border:1px solid rgba(255,255,255,0.08);color:white;padding:10px 14px;border-radius:12px;font-size:13px;outline:none;} #chat-input:focus{border-color:var(--accent);}
|
| 59 |
+
.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;}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</style>
|
| 61 |
</head>
|
| 62 |
<body>
|
|
|
|
| 75 |
<main>
|
| 76 |
<div class="hero-tag">Next-Gen Intelligence</div>
|
| 77 |
<h1>Extract Insights from <span>Global App Data</span></h1>
|
| 78 |
+
<p class="sub-hero">The most powerful tool for analyzing app reviews, sentiment, and developer responses in real-time. Powered by AI chat.</p>
|
| 79 |
|
| 80 |
<div class="cards-container">
|
|
|
|
| 81 |
<div class="card" onclick="location.href='/scraper'">
|
| 82 |
<div class="badge active">Live Now</div>
|
| 83 |
<div class="card-icon">
|
| 84 |
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35"/></svg>
|
| 85 |
</div>
|
| 86 |
<h2>Single App Explorer</h2>
|
| 87 |
+
<p>Deep-dive into any Play Store app. Extract hundreds of reviews, analyze ratings, and chat with AI to get instant insights.</p>
|
| 88 |
+
<div class="btn">Explore Now <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
|
|
|
|
|
|
| 90 |
<div class="card" onclick="location.href='/batch'">
|
| 91 |
<div class="badge active">New Mode</div>
|
| 92 |
<div class="card-icon">
|
| 93 |
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
| 94 |
</div>
|
| 95 |
<h2>Batch Intelligence</h2>
|
| 96 |
+
<p>Compare multiple apps side-by-side. Track competitor updates and aggregate sentiment across entire game categories.</p>
|
| 97 |
+
<div class="btn">Start Analysis <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
</main>
|
| 101 |
|
| 102 |
+
<footer>© 2026 PlayPulse Intelligence. Powered by Google Play Scraper Engine.</footer>
|
| 103 |
+
|
| 104 |
+
<!-- Chat bubble -->
|
| 105 |
+
<div id="chat-dialer" onclick="toggleChat()">
|
| 106 |
+
<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>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div id="chat-window">
|
| 110 |
+
<div class="chat-header">
|
| 111 |
+
<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>
|
| 112 |
+
<div class="chat-header-info">
|
| 113 |
+
<div class="chat-header-title">PlayPulse Intelligence</div>
|
| 114 |
+
<div class="chat-header-status"><span class="status-dot"></span> Agent Online</div>
|
| 115 |
+
</div>
|
| 116 |
+
<div class="chat-header-actions">
|
| 117 |
+
<button class="chat-clear-btn" onclick="clearChat()">Clear</button>
|
| 118 |
+
<div style="cursor:pointer;opacity:0.7;" onclick="toggleChat()">
|
| 119 |
+
<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>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
<div class="chat-messages" id="chat-messages">
|
| 124 |
+
<div class="msg-row bot">
|
| 125 |
+
<div class="message bot">👋 Welcome to PlayPulse! I can help you understand what tools are available, or answer general app-store questions. Head to <strong>Single Explorer</strong> or <strong>Batch Intelligence</strong> to start analyzing reviews.</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="chat-input-area">
|
| 129 |
+
<input type="text" id="chat-input" placeholder="Ask a question…" onkeydown="if(event.key==='Enter') sendChatMessage()">
|
| 130 |
+
<button class="btn-send" onclick="sendChatMessage()">
|
| 131 |
+
<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>
|
| 132 |
+
</button>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<script>
|
| 137 |
+
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;})();
|
| 138 |
+
|
| 139 |
+
function toggleChat(){document.getElementById('chat-window').classList.toggle('open');}
|
| 140 |
+
|
| 141 |
+
async function clearChat(){
|
| 142 |
+
document.getElementById('chat-messages').innerHTML=`<div class="msg-row bot"><div class="message bot">Chat cleared!</div></div>`;
|
| 143 |
+
await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
async function sendChatMessage(){
|
| 147 |
+
const input=document.getElementById('chat-input');const msg=input.value.trim();if(!msg)return;
|
| 148 |
+
appendUserMsg(msg);input.value='';
|
| 149 |
+
const container=document.getElementById('chat-messages');
|
| 150 |
+
const typing=document.createElement('div');typing.className='typing-indicator';typing.innerHTML='<div class="dot"></div><div class="dot"></div><div class="dot"></div>';
|
| 151 |
+
container.appendChild(typing);container.scrollTop=container.scrollHeight;
|
| 152 |
+
try{
|
| 153 |
+
const res=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,session_id:SESSION_ID,reviews:[]})});
|
| 154 |
+
const data=await res.json();
|
| 155 |
+
if(typing.parentNode)container.removeChild(typing);
|
| 156 |
+
appendBotMsg(data.reply||data.error||'Something went wrong.',null);
|
| 157 |
+
}catch(e){if(typing.parentNode)container.removeChild(typing);appendBotMsg('Connection error.',null);}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
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;}
|
| 161 |
+
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);}c.appendChild(row);c.scrollTop=c.scrollHeight;}
|
| 162 |
+
|
| 163 |
+
function renderMD(text){
|
| 164 |
+
const lines=text.split('\n');let html='',inList=false;
|
| 165 |
+
for(let raw of lines){
|
| 166 |
+
if(/^\*\*[^*]+\*\*:?$/.test(raw.trim())){if(inList){html+='</div>';inList=false;}html+=`<div class="msg-section">${escHtml(raw.trim().replace(/^\*\*/,'').replace(/\*\*:?$/,''))}</div>`;continue;}
|
| 167 |
+
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;}
|
| 168 |
+
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;}
|
| 169 |
+
if(inList&&raw.trim()===''){html+='</div>';inList=false;}
|
| 170 |
+
if(raw.trim()===''){html+='<br>';}else{html+=`<span>${inlineFmt(raw)}</span><br>`;}
|
| 171 |
+
}
|
| 172 |
+
if(inList)html+='</div>';return html;
|
| 173 |
+
}
|
| 174 |
+
function inlineFmt(t){return escHtml(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em>$1</em>');}
|
| 175 |
+
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
| 176 |
+
</script>
|
| 177 |
</body>
|
| 178 |
+
</html>
|
utils/agents.py
ADDED
|
@@ -0,0 +1,1553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
╔══════════════════════════════════════════════════════════════════════════════╗
|
| 4 |
+
║ REVIEW INTELLIGENCE AGENT v4 — LangGraph + LangChain ║
|
| 5 |
+
║ BERT sentiment · merged tools · app-aware · Pinecone native embed ║
|
| 6 |
+
║ Code-agent tool · scope guard · LLM fallback chain ║
|
| 7 |
+
╚══════════════════════════════════════════════════════════════════════════════╝
|
| 8 |
+
|
| 9 |
+
CHANGES FROM v3:
|
| 10 |
+
① NLP — DistilBERT (transformers) replaces LLM batched sentiment.
|
| 11 |
+
Zero token cost. Runs locally. Falls back to rating-heuristic.
|
| 12 |
+
② Tools — get_top_negative / get_top_positive merged into one tool
|
| 13 |
+
get_reviews_by_rating(min_stars, max_stars, n, app_name?).
|
| 14 |
+
All tools accept optional app_name to handle multi-app CSVs.
|
| 15 |
+
③ Pinecone — uses pc.create_index_for_model() + idx.upsert_records()
|
| 16 |
+
(Pinecone integrated embedding, no OpenAI dependency).
|
| 17 |
+
Query uses pc.inference.embed() for the query vector.
|
| 18 |
+
④ Code-Agent — new @tool run_pandas_code(code) that POSTs to a
|
| 19 |
+
deployed HuggingFace Space (docker) for heavy pandas/stats.
|
| 20 |
+
⑤ Scope guard — planner tracks iteration count; terminates cleanly
|
| 21 |
+
after MAX_PLANNER_ITERATIONS to avoid infinite loops.
|
| 22 |
+
|
| 23 |
+
Install:
|
| 24 |
+
pip install langgraph langchain langchain-core langchain-openai \
|
| 25 |
+
langchain-groq pinecone pandas transformers torch \
|
| 26 |
+
rich python-dotenv requests
|
| 27 |
+
|
| 28 |
+
Usage:
|
| 29 |
+
python review_agent_v4.py --csv reviews.csv
|
| 30 |
+
python review_agent_v4.py --csv reviews.csv \\
|
| 31 |
+
--query "Which action game has the most ad complaints?"
|
| 32 |
+
python review_agent_v4.py --csv reviews.csv --use-pinecone \\
|
| 33 |
+
--query "Show crash issues for com.JindoBlu app"
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import argparse
|
| 39 |
+
import hashlib
|
| 40 |
+
import json
|
| 41 |
+
import logging
|
| 42 |
+
import operator
|
| 43 |
+
import os
|
| 44 |
+
import re
|
| 45 |
+
import sys
|
| 46 |
+
import time
|
| 47 |
+
import textwrap
|
| 48 |
+
import uuid
|
| 49 |
+
from datetime import datetime
|
| 50 |
+
from pathlib import Path
|
| 51 |
+
from typing import Annotated, Any, Optional, TypedDict
|
| 52 |
+
|
| 53 |
+
import pandas as pd
|
| 54 |
+
import requests
|
| 55 |
+
|
| 56 |
+
# ── LangGraph / LangChain ──────────────────────────────────────────────────
|
| 57 |
+
from langgraph.graph import StateGraph, START, END
|
| 58 |
+
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage
|
| 59 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 60 |
+
from langchain_core.tools import tool
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
from langchain_groq import ChatGroq
|
| 64 |
+
HAS_GROQ = True
|
| 65 |
+
except ImportError:
|
| 66 |
+
HAS_GROQ = False
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
from langchain_openai import ChatOpenAI
|
| 70 |
+
HAS_OPENAI_PKG = True
|
| 71 |
+
except ImportError:
|
| 72 |
+
HAS_OPENAI_PKG = False
|
| 73 |
+
|
| 74 |
+
# ── Pinecone (native SDK only — no langchain-pinecone needed) ──────────────
|
| 75 |
+
try:
|
| 76 |
+
from pinecone import Pinecone as PineconeClient, ServerlessSpec
|
| 77 |
+
from pinecone import CloudProvider, AwsRegion, EmbedModel, IndexEmbed
|
| 78 |
+
HAS_PINECONE = True
|
| 79 |
+
except ImportError:
|
| 80 |
+
HAS_PINECONE = False
|
| 81 |
+
|
| 82 |
+
# ── BERT sentiment (local, zero token cost) ────────────────────────────────
|
| 83 |
+
try:
|
| 84 |
+
from transformers import pipeline as hf_pipeline
|
| 85 |
+
HAS_TRANSFORMERS = True
|
| 86 |
+
except ImportError:
|
| 87 |
+
HAS_TRANSFORMERS = False
|
| 88 |
+
|
| 89 |
+
# ── Rich terminal ──────────────────────────────────────────────────────────
|
| 90 |
+
try:
|
| 91 |
+
from rich.console import Console
|
| 92 |
+
from rich.panel import Panel
|
| 93 |
+
from rich.table import Table
|
| 94 |
+
from rich import box
|
| 95 |
+
RICH = True
|
| 96 |
+
console = Console()
|
| 97 |
+
except ImportError:
|
| 98 |
+
RICH = False
|
| 99 |
+
console = None # type: ignore
|
| 100 |
+
|
| 101 |
+
logging.basicConfig(level=logging.WARNING)
|
| 102 |
+
|
| 103 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 104 |
+
# CONSTANTS
|
| 105 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 106 |
+
|
| 107 |
+
# LLM providers
|
| 108 |
+
GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 109 |
+
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
| 110 |
+
OPENROUTER_MODEL = "meta-llama/llama-3.3-70b-instruct"
|
| 111 |
+
NVIDIA_URL = "https://integrate.api.nvidia.com/v1"
|
| 112 |
+
NVIDIA_MODEL = "meta/llama-3.3-70b-instruct"
|
| 113 |
+
|
| 114 |
+
# BERT model — lightweight distilled, ~67M params, runs on CPU
|
| 115 |
+
BERT_SENTIMENT_MODEL = "distilbert-base-uncased-finetuned-sst-2-english"
|
| 116 |
+
BERT_BATCH_SIZE = 64 # reviews per inference batch
|
| 117 |
+
MAX_BERT_TEXT_LEN = 512 # chars; BERT max tokens ≈ 512 subwords
|
| 118 |
+
|
| 119 |
+
# Pipeline limits
|
| 120 |
+
SAMPLE_ROWS_FOR_SCHEMA = 5
|
| 121 |
+
MAX_REVIEWS_NLP = 5_000 # BERT is fast; can handle more
|
| 122 |
+
MAX_CLUSTERS = 10
|
| 123 |
+
MAX_PLANNER_ITERATIONS = 4 # ④ scope guard: stop after N iterations
|
| 124 |
+
ANOMALY_SIGMA = 2.0
|
| 125 |
+
|
| 126 |
+
# Pinecone integrated embedding
|
| 127 |
+
PINECONE_EMBED_MODEL = "multilingual-e5-large" # free on Pinecone starter
|
| 128 |
+
PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX", "review-agent-v4")
|
| 129 |
+
PINECONE_NAMESPACE = "reviews"
|
| 130 |
+
PINECONE_TOP_K = 6
|
| 131 |
+
PINECONE_UPSERT_BATCH = 96 # upsert_records batch size
|
| 132 |
+
|
| 133 |
+
# Code-agent HuggingFace Space endpoint
|
| 134 |
+
# Deploy the companion space (see code_agent_space/ folder) and set this env var
|
| 135 |
+
HF_CODE_AGENT_URL = os.getenv(
|
| 136 |
+
"HF_CODE_AGENT_URL",
|
| 137 |
+
"https://YOUR-HF-USERNAME-review-code-agent.hf.space/run"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 142 |
+
# ① LLM PROVIDER CHAIN — Groq → OpenRouter → NVIDIA NIMs
|
| 143 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 144 |
+
|
| 145 |
+
def _make_groq():
|
| 146 |
+
key = os.getenv("GROQ_API_KEY", "")
|
| 147 |
+
if not key or not HAS_GROQ:
|
| 148 |
+
return None
|
| 149 |
+
return ChatGroq(model=GROQ_MODEL, temperature=0, max_retries=1, api_key=key)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _make_openrouter():
|
| 153 |
+
key = os.getenv("OPENROUTER_API_KEY", "")
|
| 154 |
+
if not key or not HAS_OPENAI_PKG:
|
| 155 |
+
return None
|
| 156 |
+
return ChatOpenAI(
|
| 157 |
+
model=OPENROUTER_MODEL, temperature=0, max_retries=1, api_key=key,
|
| 158 |
+
base_url=OPENROUTER_URL,
|
| 159 |
+
default_headers={"HTTP-Referer": "review-agent-v4"},
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _make_nvidia():
|
| 164 |
+
key = os.getenv("NVIDIA_API_KEY", "")
|
| 165 |
+
if not key or not HAS_OPENAI_PKG:
|
| 166 |
+
return None
|
| 167 |
+
return ChatOpenAI(
|
| 168 |
+
model=NVIDIA_MODEL, temperature=0, max_retries=1,
|
| 169 |
+
api_key=key, base_url=NVIDIA_URL,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def build_llm():
|
| 174 |
+
"""Return LLM chain with .with_fallbacks() or None."""
|
| 175 |
+
providers = [p for p in [_make_groq(), _make_openrouter(), _make_nvidia()]
|
| 176 |
+
if p is not None]
|
| 177 |
+
if not providers:
|
| 178 |
+
return None
|
| 179 |
+
primary = providers[0]
|
| 180 |
+
fallbacks = providers[1:]
|
| 181 |
+
return primary.with_fallbacks(fallbacks) if fallbacks else primary
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
LLM = build_llm()
|
| 185 |
+
_PROVIDER_TAG = ("Groq→OpenRouter→NVIDIA" if LLM else "NO LLM — heuristic only")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 189 |
+
# ① BERT SENTIMENT ANALYSER (local, zero cost)
|
| 190 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 191 |
+
|
| 192 |
+
_bert_pipe = None # lazy-loaded
|
| 193 |
+
|
| 194 |
+
def _load_bert():
|
| 195 |
+
global _bert_pipe
|
| 196 |
+
if _bert_pipe is None and HAS_TRANSFORMERS:
|
| 197 |
+
_log("Loading DistilBERT sentiment model (first run may download ~270 MB)…", "info")
|
| 198 |
+
_bert_pipe = hf_pipeline(
|
| 199 |
+
"sentiment-analysis",
|
| 200 |
+
model=BERT_SENTIMENT_MODEL,
|
| 201 |
+
truncation=True,
|
| 202 |
+
max_length=512,
|
| 203 |
+
device=-1, # CPU; set device=0 for GPU
|
| 204 |
+
batch_size=BERT_BATCH_SIZE,
|
| 205 |
+
)
|
| 206 |
+
_log("DistilBERT loaded ✓", "ok")
|
| 207 |
+
return _bert_pipe
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def bert_sentiment(texts: list[str]) -> dict[str, Any]:
|
| 211 |
+
"""
|
| 212 |
+
Run DistilBERT sentiment on a list of texts.
|
| 213 |
+
Returns counts + themes extracted purely from rating distribution (no LLM).
|
| 214 |
+
Falls back to None if transformers not installed.
|
| 215 |
+
"""
|
| 216 |
+
pipe = _load_bert()
|
| 217 |
+
if pipe is None:
|
| 218 |
+
return {}
|
| 219 |
+
|
| 220 |
+
# Truncate texts for BERT
|
| 221 |
+
clean = [t[:MAX_BERT_TEXT_LEN] for t in texts if t.strip()]
|
| 222 |
+
if not clean:
|
| 223 |
+
return {}
|
| 224 |
+
|
| 225 |
+
pos = neg = 0
|
| 226 |
+
for i in range(0, len(clean), BERT_BATCH_SIZE):
|
| 227 |
+
batch = clean[i:i + BERT_BATCH_SIZE]
|
| 228 |
+
try:
|
| 229 |
+
results = pipe(batch)
|
| 230 |
+
for r in results:
|
| 231 |
+
if r["label"] == "POSITIVE":
|
| 232 |
+
pos += 1
|
| 233 |
+
else:
|
| 234 |
+
neg += 1
|
| 235 |
+
except Exception as e:
|
| 236 |
+
_log(f"BERT batch error: {e}", "warn")
|
| 237 |
+
|
| 238 |
+
total = max(1, pos + neg)
|
| 239 |
+
neu = 0 # DistilBERT SST-2 is binary; neutral is inferred below
|
| 240 |
+
pct_pos = round(pos / total * 100, 1)
|
| 241 |
+
pct_neg = round(neg / total * 100, 1)
|
| 242 |
+
|
| 243 |
+
# Heuristic tone
|
| 244 |
+
if pct_pos >= 60: tone = "Positive"
|
| 245 |
+
elif pct_neg >= 50: tone = "Negative"
|
| 246 |
+
elif abs(pct_pos - pct_neg) < 20: tone = "Polarised"
|
| 247 |
+
else: tone = "Mixed"
|
| 248 |
+
|
| 249 |
+
return {
|
| 250 |
+
"pct_positive": pct_pos,
|
| 251 |
+
"pct_neutral": round(100 - pct_pos - pct_neg, 1),
|
| 252 |
+
"pct_negative": pct_neg,
|
| 253 |
+
"overall_tone": tone,
|
| 254 |
+
"method": "DistilBERT (local)",
|
| 255 |
+
"themes": [], # populated by LLM cluster node
|
| 256 |
+
"phrases": [],
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 261 |
+
# GRAPH STATE
|
| 262 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 263 |
+
|
| 264 |
+
class ReviewState(TypedDict, total=False):
|
| 265 |
+
# Inputs
|
| 266 |
+
filepath: str
|
| 267 |
+
user_query: str
|
| 268 |
+
use_pinecone: bool
|
| 269 |
+
|
| 270 |
+
# Data
|
| 271 |
+
raw_df: Any # pd.DataFrame
|
| 272 |
+
total_rows: int
|
| 273 |
+
columns: list[str]
|
| 274 |
+
schema: dict[str, Optional[str]]
|
| 275 |
+
schema_confidence: str
|
| 276 |
+
detected_apps: list[str] # unique app names found in data
|
| 277 |
+
|
| 278 |
+
# Analysis
|
| 279 |
+
stats: dict[str, Any]
|
| 280 |
+
app_breakdown: list[dict]
|
| 281 |
+
sentiment: dict[str, Any]
|
| 282 |
+
clusters: list[dict]
|
| 283 |
+
sample_texts: list[str]
|
| 284 |
+
|
| 285 |
+
# Planner
|
| 286 |
+
tool_results: list[dict]
|
| 287 |
+
planner_notes: str
|
| 288 |
+
planner_iter: int # ④ scope guard counter
|
| 289 |
+
|
| 290 |
+
# RAG
|
| 291 |
+
rag_context: str
|
| 292 |
+
|
| 293 |
+
# Report
|
| 294 |
+
report: dict[str, Any]
|
| 295 |
+
|
| 296 |
+
# Message trace (reducer = append)
|
| 297 |
+
messages: Annotated[list[BaseMessage], operator.add]
|
| 298 |
+
|
| 299 |
+
# Meta
|
| 300 |
+
errors: list[str]
|
| 301 |
+
run_id: str
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _empty_state(filepath: str, query: str, use_pinecone: bool) -> ReviewState:
|
| 305 |
+
return ReviewState(
|
| 306 |
+
filepath=filepath, user_query=query, use_pinecone=use_pinecone,
|
| 307 |
+
raw_df=None, total_rows=0, columns=[], schema={},
|
| 308 |
+
schema_confidence="", detected_apps=[],
|
| 309 |
+
stats={}, app_breakdown=[], sentiment={}, clusters=[],
|
| 310 |
+
sample_texts=[], tool_results=[], planner_notes="",
|
| 311 |
+
planner_iter=0, rag_context="", report={},
|
| 312 |
+
messages=[], errors=[], run_id=f"run_{int(time.time())}",
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 317 |
+
# TOOL CLOSURE STATE (set before planner runs)
|
| 318 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 319 |
+
|
| 320 |
+
_ACTIVE: dict[str, Any] = {}
|
| 321 |
+
|
| 322 |
+
def _df() -> Optional[pd.DataFrame]: return _ACTIVE.get("raw_df")
|
| 323 |
+
def _schema() -> dict: return _ACTIVE.get("schema", {})
|
| 324 |
+
def _apps() -> list[str]: return _ACTIVE.get("detected_apps", [])
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 328 |
+
# ② TOOLS — merged, app-aware
|
| 329 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 330 |
+
|
| 331 |
+
def _base_df(app_name: Optional[str] = None) -> Optional[pd.DataFrame]:
|
| 332 |
+
"""Return DataFrame, optionally filtered to a specific app."""
|
| 333 |
+
df = _df()
|
| 334 |
+
if df is None:
|
| 335 |
+
return None
|
| 336 |
+
ac = _schema().get("app")
|
| 337 |
+
if app_name and ac:
|
| 338 |
+
mask = df[ac].astype(str).str.lower().str.contains(
|
| 339 |
+
re.escape(app_name.lower()), na=False)
|
| 340 |
+
return df[mask].copy()
|
| 341 |
+
return df.copy()
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@tool
|
| 345 |
+
def get_reviews_by_rating(
|
| 346 |
+
min_stars: int = 1,
|
| 347 |
+
max_stars: int = 5,
|
| 348 |
+
n: int = 15,
|
| 349 |
+
app_name: Optional[str] = None,
|
| 350 |
+
) -> str:
|
| 351 |
+
"""
|
| 352 |
+
Retrieve reviews filtered by star rating range [min_stars, max_stars].
|
| 353 |
+
Replaces separate positive/negative tools.
|
| 354 |
+
|
| 355 |
+
Examples:
|
| 356 |
+
get_reviews_by_rating(1, 2) — all 1-2 star reviews
|
| 357 |
+
get_reviews_by_rating(4, 5, n=20) — top positive reviews
|
| 358 |
+
get_reviews_by_rating(1, 1, app_name="Challenge") — 1-star for one app
|
| 359 |
+
|
| 360 |
+
Use this for any star-based filtering. It understands that ratings 1-2
|
| 361 |
+
are negative, 3 is neutral, and 4-5 are positive.
|
| 362 |
+
"""
|
| 363 |
+
df = _base_df(app_name)
|
| 364 |
+
s = _schema()
|
| 365 |
+
rc = s.get("rating"); tc = s.get("text")
|
| 366 |
+
if df is None or not rc or not tc:
|
| 367 |
+
return json.dumps({"error": "No rating or text column."})
|
| 368 |
+
|
| 369 |
+
df["__r"] = pd.to_numeric(df[rc], errors="coerce")
|
| 370 |
+
mask = (df["__r"] >= min_stars) & (df["__r"] <= max_stars)
|
| 371 |
+
filtered = df[mask].dropna(subset=["__r"])
|
| 372 |
+
|
| 373 |
+
cols = {tc: "text", rc: "rating"}
|
| 374 |
+
ac = s.get("app")
|
| 375 |
+
if ac: cols[ac] = "app"
|
| 376 |
+
uc = s.get("user")
|
| 377 |
+
if uc: cols[uc] = "user"
|
| 378 |
+
|
| 379 |
+
rows = (filtered[list(cols.keys())]
|
| 380 |
+
.rename(columns=cols)
|
| 381 |
+
.head(n)
|
| 382 |
+
.to_dict("records"))
|
| 383 |
+
return json.dumps({
|
| 384 |
+
"filter": f"{min_stars}–{max_stars} stars",
|
| 385 |
+
"app_filter": app_name or "all",
|
| 386 |
+
"count_matched": int(mask.sum()),
|
| 387 |
+
"returned": len(rows),
|
| 388 |
+
"reviews": rows,
|
| 389 |
+
}, ensure_ascii=False)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
@tool
|
| 393 |
+
def get_most_helpful_reviews(
|
| 394 |
+
n: int = 10,
|
| 395 |
+
app_name: Optional[str] = None,
|
| 396 |
+
) -> str:
|
| 397 |
+
"""
|
| 398 |
+
Return the reviews with the most helpful/thumbs-up votes.
|
| 399 |
+
Optionally filtered to a specific app. Useful for finding highly-validated complaints.
|
| 400 |
+
"""
|
| 401 |
+
df = _base_df(app_name)
|
| 402 |
+
s = _schema()
|
| 403 |
+
hc = s.get("helpful"); tc = s.get("text"); rc = s.get("rating")
|
| 404 |
+
if df is None or not hc or not tc:
|
| 405 |
+
return json.dumps({"error": "No helpful column found."})
|
| 406 |
+
|
| 407 |
+
df["__h"] = pd.to_numeric(df[hc], errors="coerce").fillna(0)
|
| 408 |
+
cols = {tc: "text", hc: "helpful_votes"}
|
| 409 |
+
if rc: cols[rc] = "rating"
|
| 410 |
+
ac = s.get("app")
|
| 411 |
+
if ac: cols[ac] = "app"
|
| 412 |
+
|
| 413 |
+
rows = (df.nlargest(n, "__h")[list(cols.keys())]
|
| 414 |
+
.rename(columns=cols)
|
| 415 |
+
.to_dict("records"))
|
| 416 |
+
return json.dumps({
|
| 417 |
+
"app_filter": app_name or "all",
|
| 418 |
+
"returned": len(rows),
|
| 419 |
+
"reviews": rows,
|
| 420 |
+
}, ensure_ascii=False)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
@tool
|
| 424 |
+
def get_rating_timeseries(app_name: Optional[str] = None) -> str:
|
| 425 |
+
"""
|
| 426 |
+
Return daily average rating over time [{date, avg_rating, count}].
|
| 427 |
+
Pass app_name to see trends for a specific app only.
|
| 428 |
+
Useful for spotting when ratings dropped after an update.
|
| 429 |
+
"""
|
| 430 |
+
df = _base_df(app_name)
|
| 431 |
+
s = _schema()
|
| 432 |
+
dc = s.get("date"); rc = s.get("rating")
|
| 433 |
+
if df is None or not dc or not rc:
|
| 434 |
+
return json.dumps({"error": "No date or rating column."})
|
| 435 |
+
|
| 436 |
+
df["__d"] = pd.to_datetime(df[dc], errors="coerce")
|
| 437 |
+
df["__r"] = pd.to_numeric(df[rc], errors="coerce")
|
| 438 |
+
df = df.dropna(subset=["__d", "__r"])
|
| 439 |
+
grp = df.groupby(df["__d"].dt.date)["__r"]
|
| 440 |
+
daily = pd.DataFrame({
|
| 441 |
+
"date": grp.mean().index.astype(str),
|
| 442 |
+
"avg_rating": grp.mean().values.round(2),
|
| 443 |
+
"count": grp.count().values,
|
| 444 |
+
})
|
| 445 |
+
return json.dumps({
|
| 446 |
+
"app_filter": app_name or "all",
|
| 447 |
+
"rows": daily.to_dict("records"),
|
| 448 |
+
}, ensure_ascii=False)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
@tool
|
| 452 |
+
def search_reviews_by_keyword(
|
| 453 |
+
keyword: str,
|
| 454 |
+
max_results: int = 20,
|
| 455 |
+
app_name: Optional[str] = None,
|
| 456 |
+
) -> str:
|
| 457 |
+
"""
|
| 458 |
+
Full-text search reviews for a keyword or phrase.
|
| 459 |
+
Optionally filter to a specific app.
|
| 460 |
+
Use this to investigate specific topics: 'crash', 'ads', 'login', etc.
|
| 461 |
+
"""
|
| 462 |
+
df = _base_df(app_name)
|
| 463 |
+
s = _schema()
|
| 464 |
+
tc = s.get("text"); rc = s.get("rating")
|
| 465 |
+
if df is None or not tc:
|
| 466 |
+
return json.dumps({"error": "No text column."})
|
| 467 |
+
|
| 468 |
+
mask = df[tc].astype(str).str.lower().str.contains(
|
| 469 |
+
re.escape(keyword.lower()), na=False, regex=True)
|
| 470 |
+
cols = {tc: "text"}
|
| 471 |
+
if rc: cols[rc] = "rating"
|
| 472 |
+
ac = s.get("app")
|
| 473 |
+
if ac: cols[ac] = "app"
|
| 474 |
+
|
| 475 |
+
rows = (df[mask][list(cols.keys())]
|
| 476 |
+
.rename(columns=cols)
|
| 477 |
+
.head(max_results)
|
| 478 |
+
.to_dict("records"))
|
| 479 |
+
return json.dumps({
|
| 480 |
+
"keyword": keyword,
|
| 481 |
+
"app_filter": app_name or "all",
|
| 482 |
+
"count_matched": int(mask.sum()),
|
| 483 |
+
"returned": len(rows),
|
| 484 |
+
"reviews": rows,
|
| 485 |
+
}, ensure_ascii=False)
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
@tool
|
| 489 |
+
def get_app_comparison(metric: str = "avg_rating") -> str:
|
| 490 |
+
"""
|
| 491 |
+
Compare all detected apps by a metric: avg_rating, pct_negative,
|
| 492 |
+
pct_positive, count, or helpful_votes.
|
| 493 |
+
Use when the CSV has multiple apps and you need to rank them.
|
| 494 |
+
"""
|
| 495 |
+
breakdown = _ACTIVE.get("app_breakdown", [])
|
| 496 |
+
if not breakdown:
|
| 497 |
+
return json.dumps({"error": "No app breakdown computed yet."})
|
| 498 |
+
valid_metrics = {"avg_rating","pct_negative","pct_positive","count","helpful_votes"}
|
| 499 |
+
m = metric if metric in valid_metrics else "avg_rating"
|
| 500 |
+
ranked = sorted(breakdown, key=lambda x: x.get(m) or 0, reverse=(m != "pct_negative"))
|
| 501 |
+
return json.dumps({
|
| 502 |
+
"metric": m,
|
| 503 |
+
"ranking": [
|
| 504 |
+
{"rank": i+1, "app": r["app"], m: r.get(m)}
|
| 505 |
+
for i, r in enumerate(ranked)
|
| 506 |
+
],
|
| 507 |
+
}, ensure_ascii=False)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
@tool
|
| 511 |
+
def get_rating_distribution(app_name: Optional[str] = None) -> str:
|
| 512 |
+
"""
|
| 513 |
+
Return star count breakdown {1: N, 2: N, 3: N, 4: N, 5: N}.
|
| 514 |
+
Optionally for a single app.
|
| 515 |
+
"""
|
| 516 |
+
df = _base_df(app_name)
|
| 517 |
+
s = _schema(); rc = s.get("rating")
|
| 518 |
+
if df is None or not rc:
|
| 519 |
+
return json.dumps({"error": "No rating column."})
|
| 520 |
+
df["__r"] = pd.to_numeric(df[rc], errors="coerce")
|
| 521 |
+
dist = df["__r"].value_counts().sort_index()
|
| 522 |
+
return json.dumps({
|
| 523 |
+
"app_filter": app_name or "all",
|
| 524 |
+
"distribution": {str(int(k)): int(v) for k, v in dist.items() if not pd.isna(k)},
|
| 525 |
+
}, ensure_ascii=False)
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 529 |
+
# ③ CODE-AGENT TOOL — runs pandas/stats code on HuggingFace Space
|
| 530 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 531 |
+
|
| 532 |
+
@tool
|
| 533 |
+
def run_pandas_code(code: str) -> str:
|
| 534 |
+
"""
|
| 535 |
+
Execute arbitrary pandas / statistics Python code on the review DataFrame
|
| 536 |
+
hosted in a HuggingFace Space code-execution endpoint.
|
| 537 |
+
|
| 538 |
+
Use this when you need:
|
| 539 |
+
- custom aggregations not covered by other tools
|
| 540 |
+
- statistical tests (e.g. t-test between two apps' ratings)
|
| 541 |
+
- pivot tables, cross-tabs, rolling averages
|
| 542 |
+
- complex filtering with multiple conditions
|
| 543 |
+
|
| 544 |
+
The remote environment has: pandas, numpy, scipy, statsmodels.
|
| 545 |
+
The DataFrame is available as `df` with the current schema columns.
|
| 546 |
+
The result must be assigned to a variable named `result`.
|
| 547 |
+
|
| 548 |
+
Example code:
|
| 549 |
+
import scipy.stats as stats
|
| 550 |
+
g1 = df[df['app']=='AppA']['score'].dropna()
|
| 551 |
+
g2 = df[df['app']=='AppB']['score'].dropna()
|
| 552 |
+
t, p = stats.ttest_ind(g1, g2)
|
| 553 |
+
result = {'t_stat': round(float(t),4), 'p_value': round(float(p),4)}
|
| 554 |
+
"""
|
| 555 |
+
url = HF_CODE_AGENT_URL.rstrip("/") + "/execute"
|
| 556 |
+
if "YOUR-HF-USERNAME" in url:
|
| 557 |
+
return json.dumps({
|
| 558 |
+
"error": "HF_CODE_AGENT_URL not configured. "
|
| 559 |
+
"Set env var HF_CODE_AGENT_URL to your deployed HuggingFace Space URL.",
|
| 560 |
+
"hint": "See code_agent_space/app.py in this repo for the companion Space.",
|
| 561 |
+
})
|
| 562 |
+
|
| 563 |
+
# Send the CSV as base64 + code to the Space
|
| 564 |
+
df = _df()
|
| 565 |
+
if df is None:
|
| 566 |
+
return json.dumps({"error": "No dataframe loaded."})
|
| 567 |
+
|
| 568 |
+
import base64, io
|
| 569 |
+
buf = io.StringIO()
|
| 570 |
+
df.to_csv(buf, index=False)
|
| 571 |
+
csv_b64 = base64.b64encode(buf.getvalue().encode()).decode()
|
| 572 |
+
|
| 573 |
+
try:
|
| 574 |
+
resp = requests.post(
|
| 575 |
+
url,
|
| 576 |
+
json={"csv_b64": csv_b64, "code": code, "schema": _schema()},
|
| 577 |
+
timeout=30,
|
| 578 |
+
)
|
| 579 |
+
resp.raise_for_status()
|
| 580 |
+
return resp.text
|
| 581 |
+
except requests.exceptions.ConnectionError:
|
| 582 |
+
return json.dumps({"error": f"Cannot reach HF Space at {url}. Is it deployed?"})
|
| 583 |
+
except requests.exceptions.Timeout:
|
| 584 |
+
return json.dumps({"error": "Code agent timed out (>30s)."})
|
| 585 |
+
except Exception as e:
|
| 586 |
+
return json.dumps({"error": str(e)})
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
# All tools list — used by planner
|
| 590 |
+
TOOLS = [
|
| 591 |
+
get_reviews_by_rating,
|
| 592 |
+
get_most_helpful_reviews,
|
| 593 |
+
get_rating_timeseries,
|
| 594 |
+
search_reviews_by_keyword,
|
| 595 |
+
get_app_comparison,
|
| 596 |
+
get_rating_distribution,
|
| 597 |
+
run_pandas_code,
|
| 598 |
+
]
|
| 599 |
+
|
| 600 |
+
TOOL_MAP = {t.name: t for t in TOOLS}
|
| 601 |
+
|
| 602 |
+
TOOL_DESCRIPTIONS = """
|
| 603 |
+
- get_reviews_by_rating(min_stars, max_stars, n, app_name?) — filter by star range 1-5. Use instead of separate positive/negative tools.
|
| 604 |
+
- get_most_helpful_reviews(n, app_name?) — most upvoted reviews
|
| 605 |
+
- get_rating_timeseries(app_name?) — daily avg rating trend
|
| 606 |
+
- search_reviews_by_keyword(keyword, max_results, app_name?) — full-text search
|
| 607 |
+
- get_app_comparison(metric) — rank all apps by metric (avg_rating|pct_negative|count…)
|
| 608 |
+
- get_rating_distribution(app_name?) — star count breakdown
|
| 609 |
+
- run_pandas_code(code) — custom pandas/scipy code on HuggingFace Space for complex stats
|
| 610 |
+
"""
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 614 |
+
# LLM HELPER
|
| 615 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 616 |
+
|
| 617 |
+
def _llm_json(system: str, user: str, fallback_fn=None) -> dict | list:
|
| 618 |
+
if LLM is None:
|
| 619 |
+
return fallback_fn() if fallback_fn else {}
|
| 620 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 621 |
+
SystemMessage(content=system),
|
| 622 |
+
("human", "{u}")
|
| 623 |
+
])
|
| 624 |
+
chain = prompt | LLM
|
| 625 |
+
try:
|
| 626 |
+
resp = chain.invoke({"u": user})
|
| 627 |
+
raw = getattr(resp, "content", str(resp)).strip()
|
| 628 |
+
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
| 629 |
+
raw = re.sub(r"\s*```$", "", raw)
|
| 630 |
+
return json.loads(raw)
|
| 631 |
+
except Exception as e:
|
| 632 |
+
_log(f"LLM JSON parse failed: {e}", "warn")
|
| 633 |
+
return fallback_fn() if fallback_fn else {}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 637 |
+
# NODE 1 — INGESTION
|
| 638 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 639 |
+
|
| 640 |
+
def node_ingest(state: ReviewState) -> dict:
|
| 641 |
+
_log("Node [ingest]", "agent")
|
| 642 |
+
|
| 643 |
+
if state.get("raw_df") is not None:
|
| 644 |
+
df = state["raw_df"]
|
| 645 |
+
_log(f"Using provided DataFrame: {len(df):,} rows", "ok")
|
| 646 |
+
return {
|
| 647 |
+
"total_rows": len(df),
|
| 648 |
+
"columns": list(df.columns),
|
| 649 |
+
"messages": [HumanMessage(content=f"Using provided data with {len(df)} reviews")],
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
fp = state["filepath"]
|
| 653 |
+
if not fp:
|
| 654 |
+
return {"errors": ["ingest: no filepath or dataframe provided"]}
|
| 655 |
+
|
| 656 |
+
ext = Path(fp).suffix.lower()
|
| 657 |
+
try:
|
| 658 |
+
if ext in (".xls", ".xlsx", ".xlsm"):
|
| 659 |
+
df = pd.read_excel(fp)
|
| 660 |
+
else:
|
| 661 |
+
for enc in ("utf-8", "utf-8-sig", "latin-1", "cp1252"):
|
| 662 |
+
try:
|
| 663 |
+
df = pd.read_csv(fp, encoding=enc); break
|
| 664 |
+
except Exception: continue
|
| 665 |
+
else:
|
| 666 |
+
raise ValueError(f"Cannot decode {fp}")
|
| 667 |
+
df = df.dropna(how="all")
|
| 668 |
+
_log(f"Loaded {len(df):,} rows × {len(df.columns)} cols", "ok")
|
| 669 |
+
return {
|
| 670 |
+
"raw_df": df, "total_rows": len(df),
|
| 671 |
+
"columns": list(df.columns),
|
| 672 |
+
"messages": [HumanMessage(content=f"Loaded {len(df)} reviews")],
|
| 673 |
+
}
|
| 674 |
+
except Exception as e:
|
| 675 |
+
return {"errors": [f"ingest: {e}"],
|
| 676 |
+
"messages": [HumanMessage(content=f"ERROR loading: {e}")]}
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 680 |
+
# NODE 2 — LLM SCHEMA DETECTION
|
| 681 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 682 |
+
|
| 683 |
+
_SCHEMA_SYS = """You are a CSV schema analyst.
|
| 684 |
+
Given column names and sample rows from a user-review dataset, map each role to the exact column name.
|
| 685 |
+
Return ONLY valid JSON (no markdown):
|
| 686 |
+
{{
|
| 687 |
+
"text": "<review text column or null>",
|
| 688 |
+
"rating": "<numeric star/score column or null>",
|
| 689 |
+
"date": "<date/timestamp column or null>",
|
| 690 |
+
"app": "<app/product/game identifier column or null>",
|
| 691 |
+
"user": "<reviewer name/id column or null>",
|
| 692 |
+
"helpful": "<helpful/upvote count column or null>",
|
| 693 |
+
"confidence": "high|medium|low",
|
| 694 |
+
"reasoning": "one sentence"
|
| 695 |
+
}}
|
| 696 |
+
Use EXACT column names. null if uncertain."""
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def node_schema(state: ReviewState) -> dict:
|
| 700 |
+
_log("Node [schema]", "agent")
|
| 701 |
+
df = state.get("raw_df")
|
| 702 |
+
if df is None:
|
| 703 |
+
return {"errors": ["schema: no dataframe"]}
|
| 704 |
+
|
| 705 |
+
sample = df.head(SAMPLE_ROWS_FOR_SCHEMA).to_dict(orient="records")
|
| 706 |
+
result = _llm_json(
|
| 707 |
+
_SCHEMA_SYS,
|
| 708 |
+
json.dumps({"columns": state["columns"], "sample_rows": sample}),
|
| 709 |
+
fallback_fn=lambda: _heuristic_schema(state["columns"]),
|
| 710 |
+
)
|
| 711 |
+
schema = {k: result.get(k) for k in ["text","rating","date","app","user","helpful"]}
|
| 712 |
+
conf = result.get("confidence","low")
|
| 713 |
+
_log(f"Schema ({conf}): {schema}", "ok")
|
| 714 |
+
|
| 715 |
+
# Detect unique apps so tools can be app-aware
|
| 716 |
+
apps: list[str] = []
|
| 717 |
+
ac = schema.get("app")
|
| 718 |
+
if ac and df is not None and ac in df.columns:
|
| 719 |
+
apps = sorted(df[ac].dropna().astype(str).unique().tolist())
|
| 720 |
+
_log(f"Detected {len(apps)} apps: {apps[:5]}", "info")
|
| 721 |
+
|
| 722 |
+
return {
|
| 723 |
+
"schema": schema, "schema_confidence": conf,
|
| 724 |
+
"detected_apps": apps,
|
| 725 |
+
"messages": [AIMessage(content=f"Schema ({conf}): {schema}. Apps: {apps[:5]}")],
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
def _heuristic_schema(cols: list[str]) -> dict:
|
| 730 |
+
low = {c.lower(): c for c in cols}
|
| 731 |
+
def first(pats):
|
| 732 |
+
for p in pats:
|
| 733 |
+
for cl, c in low.items():
|
| 734 |
+
if p in cl: return c
|
| 735 |
+
return None
|
| 736 |
+
return {
|
| 737 |
+
"text": first(["content","review","text","body","comment"]),
|
| 738 |
+
"rating": first(["score","rating","stars","rate","grade"]),
|
| 739 |
+
"date": first(["date","time","created","posted","at"]),
|
| 740 |
+
"app": first(["app","product","game","title","name"]),
|
| 741 |
+
"user": first(["user","reviewer","author"]),
|
| 742 |
+
"helpful": first(["helpful","thumbs","vote","like","useful"]),
|
| 743 |
+
"confidence": "low", "reasoning": "heuristic fallback",
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
|
| 747 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 748 |
+
# NODE 3 — STATISTICAL ANALYSIS
|
| 749 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 750 |
+
|
| 751 |
+
def node_stats(state: ReviewState) -> dict:
|
| 752 |
+
_log("Node [stats]", "agent")
|
| 753 |
+
df = state.get("raw_df"); s = state.get("schema", {})
|
| 754 |
+
rc = s.get("rating"); tc = s.get("text")
|
| 755 |
+
if df is None or not rc:
|
| 756 |
+
return {"stats": {}}
|
| 757 |
+
|
| 758 |
+
df2 = df.copy()
|
| 759 |
+
df2["__r"] = pd.to_numeric(df2[rc], errors="coerce")
|
| 760 |
+
valid = df2["__r"].dropna()
|
| 761 |
+
|
| 762 |
+
stats: dict[str, Any] = {
|
| 763 |
+
"total_reviews": int(len(df2)),
|
| 764 |
+
"rated_reviews": int(len(valid)),
|
| 765 |
+
"avg_rating": round(float(valid.mean()), 3) if len(valid) else None,
|
| 766 |
+
"median_rating": float(valid.median()) if len(valid) else None,
|
| 767 |
+
"std_rating": round(float(valid.std()), 3) if len(valid) else None,
|
| 768 |
+
"pct_positive": round(float((valid >= 4).mean() * 100), 1),
|
| 769 |
+
"pct_negative": round(float((valid <= 2).mean() * 100), 1),
|
| 770 |
+
"pct_neutral": round(float(((valid > 2) & (valid < 4)).mean() * 100), 1),
|
| 771 |
+
"rating_distribution": {
|
| 772 |
+
str(int(k)): int(v)
|
| 773 |
+
for k, v in df2["__r"].value_counts().sort_index().items()
|
| 774 |
+
if not pd.isna(k)
|
| 775 |
+
},
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
if tc:
|
| 779 |
+
lens = df2[tc].dropna().astype(str).str.len()
|
| 780 |
+
stats["avg_review_length"] = int(lens.mean())
|
| 781 |
+
stats["short_reviews_pct"] = round(float((lens < 20).mean() * 100), 1)
|
| 782 |
+
|
| 783 |
+
hc = s.get("helpful")
|
| 784 |
+
if hc:
|
| 785 |
+
df2["__h"] = pd.to_numeric(df2[hc], errors="coerce").fillna(0)
|
| 786 |
+
stats["total_helpful_votes"] = int(df2["__h"].sum())
|
| 787 |
+
|
| 788 |
+
dc = s.get("date")
|
| 789 |
+
if dc:
|
| 790 |
+
try:
|
| 791 |
+
df2["__d"] = pd.to_datetime(df2[dc], errors="coerce")
|
| 792 |
+
daily = df2.dropna(subset=["__d","__r"]).groupby(df2["__d"].dt.date)["__r"].mean()
|
| 793 |
+
if len(daily) > 7:
|
| 794 |
+
m, std = daily.mean(), daily.std()
|
| 795 |
+
bad = daily[daily < (m - ANOMALY_SIGMA * std)]
|
| 796 |
+
stats["anomaly_days"] = [
|
| 797 |
+
{"date": str(d), "avg_rating": round(float(v), 2)}
|
| 798 |
+
for d, v in bad.items()
|
| 799 |
+
]
|
| 800 |
+
except Exception:
|
| 801 |
+
pass
|
| 802 |
+
|
| 803 |
+
# Per-app breakdown
|
| 804 |
+
app_rows = []
|
| 805 |
+
ac = s.get("app")
|
| 806 |
+
if ac:
|
| 807 |
+
for name, grp in df2.groupby(ac):
|
| 808 |
+
gr = pd.to_numeric(grp["__r"], errors="coerce")
|
| 809 |
+
row: dict[str, Any] = {
|
| 810 |
+
"app": str(name), "count": int(len(grp)),
|
| 811 |
+
"avg_rating": round(float(gr.mean()), 2) if len(gr) else None,
|
| 812 |
+
"pct_negative": round(float((gr <= 2).mean() * 100), 1),
|
| 813 |
+
"pct_positive": round(float((gr >= 4).mean() * 100), 1),
|
| 814 |
+
}
|
| 815 |
+
if hc:
|
| 816 |
+
row["helpful_votes"] = int(
|
| 817 |
+
pd.to_numeric(grp[hc], errors="coerce").fillna(0).sum())
|
| 818 |
+
app_rows.append(row)
|
| 819 |
+
app_rows.sort(key=lambda x: x.get("avg_rating") or 5)
|
| 820 |
+
stats["apps_analyzed"] = len(app_rows)
|
| 821 |
+
|
| 822 |
+
_log(f"Stats: avg={stats.get('avg_rating')} neg={stats.get('pct_negative')}%", "ok")
|
| 823 |
+
return {
|
| 824 |
+
"stats": stats, "app_breakdown": app_rows,
|
| 825 |
+
"messages": [AIMessage(content=(
|
| 826 |
+
f"Stats: {stats['total_reviews']} reviews, avg {stats.get('avg_rating')}/5"))],
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 831 |
+
# NODE 4 — ① BERT SENTIMENT (local, zero token cost)
|
| 832 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 833 |
+
|
| 834 |
+
def node_nlp(state: ReviewState) -> dict:
|
| 835 |
+
_log("Node [nlp/BERT]", "agent")
|
| 836 |
+
df = state.get("raw_df"); s = state.get("schema", {})
|
| 837 |
+
tc = s.get("text")
|
| 838 |
+
if df is None or not tc:
|
| 839 |
+
return {"sentiment": _rating_sentiment_fallback(state)}
|
| 840 |
+
|
| 841 |
+
# Sample
|
| 842 |
+
sample_df = df.sample(min(MAX_REVIEWS_NLP, len(df)), random_state=42)
|
| 843 |
+
texts = sample_df[tc].fillna("").astype(str).tolist()
|
| 844 |
+
|
| 845 |
+
# ① Try BERT first (free, local)
|
| 846 |
+
bert_result = bert_sentiment(texts)
|
| 847 |
+
if bert_result:
|
| 848 |
+
_log(f"BERT sentiment: pos={bert_result['pct_positive']}% "
|
| 849 |
+
f"neg={bert_result['pct_negative']}% tone={bert_result['overall_tone']}", "ok")
|
| 850 |
+
return {
|
| 851 |
+
"sentiment": bert_result,
|
| 852 |
+
"sample_texts": texts[:500],
|
| 853 |
+
"messages": [AIMessage(content=(
|
| 854 |
+
f"BERT sentiment: {bert_result['overall_tone']} "
|
| 855 |
+
f"(pos={bert_result['pct_positive']}% neg={bert_result['pct_negative']}%)"))],
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
# ② Fall back to rating-based heuristic (no transformers installed)
|
| 859 |
+
_log("BERT unavailable — using rating heuristic", "warn")
|
| 860 |
+
fallback = _rating_sentiment_fallback(state)
|
| 861 |
+
return {
|
| 862 |
+
"sentiment": fallback,
|
| 863 |
+
"sample_texts": texts[:500],
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
def _rating_sentiment_fallback(state: ReviewState) -> dict:
|
| 868 |
+
stats = state.get("stats", {})
|
| 869 |
+
return {
|
| 870 |
+
"pct_positive": stats.get("pct_positive", 0),
|
| 871 |
+
"pct_neutral": stats.get("pct_neutral", 0),
|
| 872 |
+
"pct_negative": stats.get("pct_negative", 0),
|
| 873 |
+
"overall_tone": "Unknown",
|
| 874 |
+
"method": "rating-heuristic (no BERT)",
|
| 875 |
+
"themes": [], "phrases": [],
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 880 |
+
# NODE 5 — LLM CLUSTERING (topic discovery, not keyword matching)
|
| 881 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 882 |
+
|
| 883 |
+
_CLUSTER_SYS = f"""You are a product issue analyst.
|
| 884 |
+
Given a mixed sample of user reviews, discover distinct topic clusters.
|
| 885 |
+
Return ONLY valid JSON:
|
| 886 |
+
{{
|
| 887 |
+
"clusters": [
|
| 888 |
+
{{
|
| 889 |
+
"label": "Short name",
|
| 890 |
+
"type": "issue|praise|request|general",
|
| 891 |
+
"description": "1-2 sentence summary",
|
| 892 |
+
"frequency_signal": "high|medium|low",
|
| 893 |
+
"severity": "critical|high|medium|low",
|
| 894 |
+
"example_quote": "verbatim short quote",
|
| 895 |
+
"keywords": ["word1","word2"]
|
| 896 |
+
}}
|
| 897 |
+
]
|
| 898 |
+
}}
|
| 899 |
+
Max {MAX_CLUSTERS} clusters. Merge near-duplicates. Issues first."""
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
def node_cluster(state: ReviewState) -> dict:
|
| 903 |
+
_log("Node [cluster]", "agent")
|
| 904 |
+
df = state.get("raw_df"); s = state.get("schema", {})
|
| 905 |
+
rc = s.get("rating"); tc = s.get("text")
|
| 906 |
+
if df is None or not tc or LLM is None:
|
| 907 |
+
return {"clusters": []}
|
| 908 |
+
|
| 909 |
+
df2 = df.copy()
|
| 910 |
+
if rc:
|
| 911 |
+
df2["__r"] = pd.to_numeric(df2[rc], errors="coerce")
|
| 912 |
+
neg = df2.nsmallest(50, "__r")
|
| 913 |
+
pos = df2.nlargest(30, "__r")
|
| 914 |
+
rnd = df2.sample(min(50, len(df2)), random_state=7)
|
| 915 |
+
combined = pd.concat([neg, pos, rnd]).drop_duplicates(subset=[tc])
|
| 916 |
+
else:
|
| 917 |
+
combined = df2.sample(min(130, len(df2)), random_state=7)
|
| 918 |
+
|
| 919 |
+
sample = [{"text": str(r[tc])[:280], "rating": r.get(rc)}
|
| 920 |
+
for _, r in combined.iterrows()][:130]
|
| 921 |
+
result = _llm_json(_CLUSTER_SYS, json.dumps(sample))
|
| 922 |
+
clusters = result.get("clusters", [])
|
| 923 |
+
|
| 924 |
+
_sev = {"critical":0,"high":1,"medium":2,"low":3}
|
| 925 |
+
_typ = {"issue":0,"request":1,"general":2,"praise":3}
|
| 926 |
+
clusters.sort(key=lambda c: (
|
| 927 |
+
_typ.get(c.get("type","general"),9),
|
| 928 |
+
_sev.get(c.get("severity","low"),9)))
|
| 929 |
+
|
| 930 |
+
_log(f"Discovered {len(clusters)} clusters", "ok")
|
| 931 |
+
return {
|
| 932 |
+
"clusters": clusters,
|
| 933 |
+
"messages": [AIMessage(content=f"Found {len(clusters)} topic clusters")],
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
+
|
| 937 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 938 |
+
# NODE 6 — ④ PLANNER (ReAct with scope guard)
|
| 939 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 940 |
+
|
| 941 |
+
_PLANNER_SYS = """You are an analytical planner for a review intelligence system.
|
| 942 |
+
A user asked a specific question. Decide which tools to call to best answer it.
|
| 943 |
+
|
| 944 |
+
Available tools:
|
| 945 |
+
{tools}
|
| 946 |
+
|
| 947 |
+
Already computed context:
|
| 948 |
+
{context}
|
| 949 |
+
|
| 950 |
+
Detected apps in dataset: {apps}
|
| 951 |
+
|
| 952 |
+
User query: {query}
|
| 953 |
+
|
| 954 |
+
Rules:
|
| 955 |
+
- Use get_reviews_by_rating(min_stars, max_stars) instead of separate positive/negative tools.
|
| 956 |
+
- If multiple apps exist and the query targets one, pass app_name to every tool call.
|
| 957 |
+
- Use run_pandas_code only for custom stats not covered by other tools.
|
| 958 |
+
- Choose 1-3 tools maximum. Do not repeat a tool with identical arguments.
|
| 959 |
+
|
| 960 |
+
Return ONLY valid JSON:
|
| 961 |
+
{{
|
| 962 |
+
"reasoning": "1-2 sentences",
|
| 963 |
+
"calls": [{{"tool": "name", "args": {{...}}}}],
|
| 964 |
+
"done": false
|
| 965 |
+
}}
|
| 966 |
+
Set "done": true if the context already fully answers the query with no more tools needed.
|
| 967 |
+
"""
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
def node_planner(state: ReviewState) -> dict:
|
| 971 |
+
_log("Node [planner]", "agent")
|
| 972 |
+
query = state.get("user_query", "").strip()
|
| 973 |
+
itr = state.get("planner_iter", 0)
|
| 974 |
+
|
| 975 |
+
# ④ Scope guard — terminate if iterations exceeded
|
| 976 |
+
if itr >= MAX_PLANNER_ITERATIONS:
|
| 977 |
+
_log(f"Planner reached max iterations ({MAX_PLANNER_ITERATIONS}) — terminating", "warn")
|
| 978 |
+
return {
|
| 979 |
+
"planner_notes": f"Terminated after {itr} iterations (scope guard).",
|
| 980 |
+
"planner_iter": itr,
|
| 981 |
+
"messages": [AIMessage(content="Planner: max iterations reached, proceeding to report.")],
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
if not query or LLM is None:
|
| 985 |
+
return {"planner_notes": "No query or LLM unavailable.", "planner_iter": itr}
|
| 986 |
+
|
| 987 |
+
# Load active state for tool closures
|
| 988 |
+
_ACTIVE.update(state)
|
| 989 |
+
|
| 990 |
+
context = {
|
| 991 |
+
"total_reviews": state.get("stats",{}).get("total_reviews"),
|
| 992 |
+
"avg_rating": state.get("stats",{}).get("avg_rating"),
|
| 993 |
+
"clusters": [c.get("label") for c in state.get("clusters",[])[:5]],
|
| 994 |
+
"overall_tone": state.get("sentiment",{}).get("overall_tone"),
|
| 995 |
+
"has_date": bool(state.get("schema",{}).get("date")),
|
| 996 |
+
"has_helpful": bool(state.get("schema",{}).get("helpful")),
|
| 997 |
+
"tools_called": [t["tool"] for t in state.get("tool_results",[])],
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
plan = _llm_json(
|
| 1001 |
+
_PLANNER_SYS.format(
|
| 1002 |
+
tools=TOOL_DESCRIPTIONS,
|
| 1003 |
+
context=json.dumps(context),
|
| 1004 |
+
apps=state.get("detected_apps", [])[:10],
|
| 1005 |
+
query=query,
|
| 1006 |
+
),
|
| 1007 |
+
"",
|
| 1008 |
+
)
|
| 1009 |
+
|
| 1010 |
+
reasoning = plan.get("reasoning", "")
|
| 1011 |
+
done = plan.get("done", False)
|
| 1012 |
+
_log(f"Planner iter={itr+1}: {reasoning}", "info")
|
| 1013 |
+
|
| 1014 |
+
# ④ If planner says done, skip tool calls
|
| 1015 |
+
if done:
|
| 1016 |
+
return {
|
| 1017 |
+
"planner_notes": reasoning,
|
| 1018 |
+
"planner_iter": itr + 1,
|
| 1019 |
+
"messages": [AIMessage(content=f"Planner done: {reasoning}")],
|
| 1020 |
+
}
|
| 1021 |
+
|
| 1022 |
+
tool_results = list(state.get("tool_results", []))
|
| 1023 |
+
for call in plan.get("calls", []):
|
| 1024 |
+
tname = call.get("tool","")
|
| 1025 |
+
args = call.get("args",{})
|
| 1026 |
+
t = TOOL_MAP.get(tname)
|
| 1027 |
+
if not t:
|
| 1028 |
+
continue
|
| 1029 |
+
_log(f" Tool: {tname}({args})", "info")
|
| 1030 |
+
try:
|
| 1031 |
+
result = t.invoke(args)
|
| 1032 |
+
tool_results.append({"tool": tname, "args": args, "result": result, "ok": True})
|
| 1033 |
+
except Exception as e:
|
| 1034 |
+
tool_results.append({"tool": tname, "args": args, "error": str(e), "ok": False})
|
| 1035 |
+
_log(f" Tool {tname} failed: {e}", "warn")
|
| 1036 |
+
|
| 1037 |
+
return {
|
| 1038 |
+
"tool_results": tool_results,
|
| 1039 |
+
"planner_notes": reasoning,
|
| 1040 |
+
"planner_iter": itr + 1,
|
| 1041 |
+
"messages": [AIMessage(content=f"Planner iter {itr+1}: {reasoning}")],
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
|
| 1045 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1046 |
+
# NODE 7 — ③ PINECONE RAG (native integrated embedding, no OpenAI needed)
|
| 1047 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1048 |
+
|
| 1049 |
+
def node_rag(state: ReviewState) -> dict:
|
| 1050 |
+
_log("Node [rag/pinecone]", "agent")
|
| 1051 |
+
if not state.get("use_pinecone") or not HAS_PINECONE:
|
| 1052 |
+
return {"rag_context": ""}
|
| 1053 |
+
|
| 1054 |
+
api_key = os.getenv("PINECONE_API_KEY", "")
|
| 1055 |
+
if not api_key:
|
| 1056 |
+
_log("PINECONE_API_KEY missing — skipping RAG", "warn")
|
| 1057 |
+
return {"rag_context": ""}
|
| 1058 |
+
|
| 1059 |
+
try:
|
| 1060 |
+
pc = PineconeClient(api_key=api_key)
|
| 1061 |
+
|
| 1062 |
+
# ③ Create index with Pinecone's integrated embedding model (no OpenAI)
|
| 1063 |
+
existing = [idx.name for idx in pc.list_indexes()]
|
| 1064 |
+
if PINECONE_INDEX_NAME not in existing:
|
| 1065 |
+
_log(f"Creating Pinecone index '{PINECONE_INDEX_NAME}' with {PINECONE_EMBED_MODEL}…", "info")
|
| 1066 |
+
pc.create_index_for_model(
|
| 1067 |
+
name=PINECONE_INDEX_NAME,
|
| 1068 |
+
cloud="aws",
|
| 1069 |
+
region="us-east-1",
|
| 1070 |
+
embed={
|
| 1071 |
+
"model": PINECONE_EMBED_MODEL,
|
| 1072 |
+
"field_map": {"text": "chunk_text"},
|
| 1073 |
+
},
|
| 1074 |
+
)
|
| 1075 |
+
time.sleep(3) # wait for index to be ready
|
| 1076 |
+
|
| 1077 |
+
idx = pc.Index(PINECONE_INDEX_NAME)
|
| 1078 |
+
|
| 1079 |
+
# Upsert review sample as records — Pinecone auto-embeds chunk_text
|
| 1080 |
+
texts = state.get("sample_texts", [])[:500]
|
| 1081 |
+
if texts:
|
| 1082 |
+
records = [
|
| 1083 |
+
{"_id": f"rev_{i}_{state.get('run_id','')}", "chunk_text": t}
|
| 1084 |
+
for i, t in enumerate(texts) if t.strip()
|
| 1085 |
+
]
|
| 1086 |
+
# Batch upsert
|
| 1087 |
+
for i in range(0, len(records), PINECONE_UPSERT_BATCH):
|
| 1088 |
+
batch = records[i:i + PINECONE_UPSERT_BATCH]
|
| 1089 |
+
idx.upsert_records(namespace=PINECONE_NAMESPACE, records=batch)
|
| 1090 |
+
_log(f"Upserted {len(records)} reviews to Pinecone", "ok")
|
| 1091 |
+
|
| 1092 |
+
# ③ Query: use pc.inference.embed() for the query vector (Pinecone-native)
|
| 1093 |
+
query = state.get("user_query") or "most common user complaints and praise"
|
| 1094 |
+
q_embeddings = pc.inference.embed(
|
| 1095 |
+
model=PINECONE_EMBED_MODEL,
|
| 1096 |
+
inputs=[query],
|
| 1097 |
+
parameters={"input_type": "query", "truncate": "END"},
|
| 1098 |
+
)
|
| 1099 |
+
q_vector = q_embeddings[0].values
|
| 1100 |
+
|
| 1101 |
+
results = idx.query(
|
| 1102 |
+
namespace=PINECONE_NAMESPACE,
|
| 1103 |
+
vector=q_vector,
|
| 1104 |
+
top_k=PINECONE_TOP_K,
|
| 1105 |
+
include_metadata=True,
|
| 1106 |
+
)
|
| 1107 |
+
|
| 1108 |
+
matches = results.get("matches", [])
|
| 1109 |
+
rag_context = "\n\n".join(
|
| 1110 |
+
f"[Similar review {i+1} score={round(m.get('score',0),3)}]: "
|
| 1111 |
+
f"{m.get('metadata',{}).get('chunk_text','')}"
|
| 1112 |
+
for i, m in enumerate(matches)
|
| 1113 |
+
)
|
| 1114 |
+
_log(f"RAG: retrieved {len(matches)} similar reviews", "ok")
|
| 1115 |
+
return {
|
| 1116 |
+
"rag_context": rag_context,
|
| 1117 |
+
"messages": [AIMessage(content=f"RAG: {len(matches)} similar reviews retrieved")],
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
except Exception as e:
|
| 1121 |
+
_log(f"Pinecone RAG failed: {e}", "warn")
|
| 1122 |
+
return {"rag_context": ""}
|
| 1123 |
+
|
| 1124 |
+
|
| 1125 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1126 |
+
# NODE 8 — REPORT SYNTHESISER
|
| 1127 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1128 |
+
|
| 1129 |
+
_REPORT_SYS = """You are a senior product intelligence analyst.
|
| 1130 |
+
Produce a structured insight report from pipeline data, tool results, and RAG context.
|
| 1131 |
+
User query: {query}
|
| 1132 |
+
RAG context: {rag}
|
| 1133 |
+
|
| 1134 |
+
Return ONLY valid JSON:
|
| 1135 |
+
{{
|
| 1136 |
+
"executive_summary": "2-3 sentences. Lead with direct answer if query present.",
|
| 1137 |
+
"direct_answer": "1-2 sentence direct answer, or null.",
|
| 1138 |
+
"top_problems": [
|
| 1139 |
+
{{"issue":"","description":"","severity":"critical|high|medium|low",
|
| 1140 |
+
"frequency":"high|medium|low","evidence":""}}
|
| 1141 |
+
],
|
| 1142 |
+
"key_strengths": [{{"strength":"","description":"","evidence":""}}],
|
| 1143 |
+
"trend_observations": [{{"observation":"","detail":"","data_ref":""}}],
|
| 1144 |
+
"anomalies": [{{"anomaly":"","type":"Spike|Pattern|Outlier|Trend",
|
| 1145 |
+
"detail":"","hypothesis":""}}],
|
| 1146 |
+
"recommendations": [
|
| 1147 |
+
{{"priority":"critical|high|medium|low","action":"",
|
| 1148 |
+
"rationale":"","expected_impact":""}}
|
| 1149 |
+
],
|
| 1150 |
+
"confidence_note": ""
|
| 1151 |
+
}}"""
|
| 1152 |
+
|
| 1153 |
+
|
| 1154 |
+
def node_report(state: ReviewState) -> dict:
|
| 1155 |
+
_log("Node [report]", "agent")
|
| 1156 |
+
payload = {
|
| 1157 |
+
"stats": state.get("stats",{}),
|
| 1158 |
+
"sentiment": state.get("sentiment",{}),
|
| 1159 |
+
"clusters": state.get("clusters",[])[:MAX_CLUSTERS],
|
| 1160 |
+
"app_breakdown": state.get("app_breakdown",[])[:10],
|
| 1161 |
+
"tool_results": [
|
| 1162 |
+
{"tool": t["tool"], "result": str(t.get("result",""))[:400]}
|
| 1163 |
+
for t in state.get("tool_results",[]) if t.get("ok")
|
| 1164 |
+
],
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
if LLM is None:
|
| 1168 |
+
return {"report": _heuristic_report(state)}
|
| 1169 |
+
|
| 1170 |
+
system = _REPORT_SYS.format(
|
| 1171 |
+
query=state.get("user_query") or "(general analysis)",
|
| 1172 |
+
rag=state.get("rag_context") or "None",
|
| 1173 |
+
)
|
| 1174 |
+
report = _llm_json(system, json.dumps(payload),
|
| 1175 |
+
fallback_fn=lambda: _heuristic_report(state))
|
| 1176 |
+
_log("Report synthesised", "ok")
|
| 1177 |
+
return {
|
| 1178 |
+
"report": report,
|
| 1179 |
+
"messages": [AIMessage(content="Report synthesis complete.")],
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
|
| 1183 |
+
def _heuristic_report(state: ReviewState) -> dict:
|
| 1184 |
+
clusters = state.get("clusters",[])
|
| 1185 |
+
stats = state.get("stats",{})
|
| 1186 |
+
sent = state.get("sentiment",{})
|
| 1187 |
+
problems = [
|
| 1188 |
+
{"issue": c.get("label","?"), "description": c.get("description",""),
|
| 1189 |
+
"severity": c.get("severity","medium"), "frequency": c.get("frequency_signal","medium"),
|
| 1190 |
+
"evidence": c.get("example_quote","")[:150]}
|
| 1191 |
+
for c in clusters if c.get("type")=="issue"
|
| 1192 |
+
][:5]
|
| 1193 |
+
return {
|
| 1194 |
+
"executive_summary": (
|
| 1195 |
+
f"Analysed {stats.get('total_reviews','?')} reviews. "
|
| 1196 |
+
f"Avg rating {stats.get('avg_rating','?')}/5. "
|
| 1197 |
+
f"Tone: {sent.get('overall_tone','?')} (method: {sent.get('method','?')})."),
|
| 1198 |
+
"direct_answer": None,
|
| 1199 |
+
"top_problems": problems,
|
| 1200 |
+
"key_strengths": [],
|
| 1201 |
+
"trend_observations": [],
|
| 1202 |
+
"anomalies": [
|
| 1203 |
+
{"anomaly": f"Drop on {d['date']}", "type": "Spike",
|
| 1204 |
+
"detail": f"Avg {d['avg_rating']}", "hypothesis": "Possible bad update"}
|
| 1205 |
+
for d in stats.get("anomaly_days",[])[:3]
|
| 1206 |
+
],
|
| 1207 |
+
"recommendations": [
|
| 1208 |
+
{"priority": p["severity"], "action": f"Fix: {p['issue']}",
|
| 1209 |
+
"rationale": p["description"], "expected_impact": "Better ratings"}
|
| 1210 |
+
for p in problems[:4]
|
| 1211 |
+
],
|
| 1212 |
+
"confidence_note": "Heuristic fallback — LLM unavailable.",
|
| 1213 |
+
}
|
| 1214 |
+
|
| 1215 |
+
|
| 1216 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1217 |
+
# CONDITIONAL EDGES
|
| 1218 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1219 |
+
|
| 1220 |
+
def _route_after_stats(state: ReviewState) -> str:
|
| 1221 |
+
return "nlp" if state.get("schema",{}).get("text") else "planner"
|
| 1222 |
+
|
| 1223 |
+
def _route_after_cluster(state: ReviewState) -> str:
|
| 1224 |
+
if state.get("user_query","").strip() and LLM is not None:
|
| 1225 |
+
return "planner"
|
| 1226 |
+
return "rag"
|
| 1227 |
+
|
| 1228 |
+
def _route_after_planner(state: ReviewState) -> str:
|
| 1229 |
+
# ④ Also route to report if scope guard triggered
|
| 1230 |
+
itr = state.get("planner_iter", 0)
|
| 1231 |
+
done = itr >= MAX_PLANNER_ITERATIONS
|
| 1232 |
+
if state.get("use_pinecone") and HAS_PINECONE and not done:
|
| 1233 |
+
return "rag"
|
| 1234 |
+
return "report"
|
| 1235 |
+
|
| 1236 |
+
|
| 1237 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1238 |
+
# GRAPH ASSEMBLY
|
| 1239 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1240 |
+
|
| 1241 |
+
def build_graph():
|
| 1242 |
+
g = StateGraph(ReviewState)
|
| 1243 |
+
|
| 1244 |
+
g.add_node("ingest", node_ingest)
|
| 1245 |
+
g.add_node("schema", node_schema)
|
| 1246 |
+
g.add_node("stats", node_stats)
|
| 1247 |
+
g.add_node("nlp", node_nlp)
|
| 1248 |
+
g.add_node("cluster", node_cluster)
|
| 1249 |
+
g.add_node("planner", node_planner)
|
| 1250 |
+
g.add_node("rag", node_rag)
|
| 1251 |
+
g.add_node("report", node_report)
|
| 1252 |
+
|
| 1253 |
+
g.add_edge(START, "ingest")
|
| 1254 |
+
g.add_edge("ingest", "schema")
|
| 1255 |
+
g.add_edge("schema", "stats")
|
| 1256 |
+
|
| 1257 |
+
g.add_conditional_edges("stats", _route_after_stats,
|
| 1258 |
+
{"nlp":"nlp","planner":"planner"})
|
| 1259 |
+
g.add_edge("nlp", "cluster")
|
| 1260 |
+
|
| 1261 |
+
g.add_conditional_edges("cluster", _route_after_cluster,
|
| 1262 |
+
{"planner":"planner","rag":"rag"})
|
| 1263 |
+
|
| 1264 |
+
g.add_conditional_edges("planner", _route_after_planner,
|
| 1265 |
+
{"rag":"rag","report":"report"})
|
| 1266 |
+
|
| 1267 |
+
g.add_edge("rag", "report")
|
| 1268 |
+
g.add_edge("report", END)
|
| 1269 |
+
|
| 1270 |
+
return g.compile()
|
| 1271 |
+
|
| 1272 |
+
|
| 1273 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1274 |
+
# RENDERER
|
| 1275 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1276 |
+
|
| 1277 |
+
def render(state: ReviewState):
|
| 1278 |
+
if RICH: _render_rich(state)
|
| 1279 |
+
else: _render_plain(state)
|
| 1280 |
+
|
| 1281 |
+
|
| 1282 |
+
def _render_rich(state: ReviewState):
|
| 1283 |
+
stats = state.get("stats",{})
|
| 1284 |
+
sent = state.get("sentiment",{})
|
| 1285 |
+
clusters = state.get("clusters",[])
|
| 1286 |
+
apps = state.get("app_breakdown",[])
|
| 1287 |
+
report = state.get("report",{})
|
| 1288 |
+
query = state.get("user_query","")
|
| 1289 |
+
tools = state.get("tool_results",[])
|
| 1290 |
+
itr = state.get("planner_iter",0)
|
| 1291 |
+
|
| 1292 |
+
console.rule("[bold cyan]REVIEW INTELLIGENCE v4 · LangGraph[/bold cyan]")
|
| 1293 |
+
|
| 1294 |
+
if query and report.get("direct_answer"):
|
| 1295 |
+
console.print(Panel(
|
| 1296 |
+
f"[bold yellow]Q:[/bold yellow] {query}\n\n"
|
| 1297 |
+
f"[bold green]{report['direct_answer']}[/bold green]",
|
| 1298 |
+
title="[bold]Direct Answer[/bold]", border_style="bright_green"))
|
| 1299 |
+
|
| 1300 |
+
console.print(Panel(
|
| 1301 |
+
f"[italic]{report.get('executive_summary','')}[/italic]",
|
| 1302 |
+
title="[bold]Executive Summary[/bold]", border_style="cyan"))
|
| 1303 |
+
|
| 1304 |
+
# Metrics
|
| 1305 |
+
t = Table(box=box.SIMPLE, show_header=False)
|
| 1306 |
+
t.add_column("", style="dim"); t.add_column("", style="bold")
|
| 1307 |
+
method_tag = sent.get("method","")
|
| 1308 |
+
for k, v in [
|
| 1309 |
+
("Total Reviews", f"{stats.get('total_reviews','?'):,}"),
|
| 1310 |
+
("Avg Rating", f"{stats.get('avg_rating','?')} / 5 σ={stats.get('std_rating','?')}"),
|
| 1311 |
+
("% Positive", f"[green]{sent.get('pct_positive','?')}%[/green]"),
|
| 1312 |
+
("% Neutral", f"[blue]{sent.get('pct_neutral','?')}%[/blue]"),
|
| 1313 |
+
("% Negative", f"[red]{sent.get('pct_negative','?')}%[/red]"),
|
| 1314 |
+
("Tone", str(sent.get("overall_tone","?"))),
|
| 1315 |
+
("Sentiment method", f"[dim]{method_tag}[/dim]"),
|
| 1316 |
+
("LLM Provider", f"[dim]{_PROVIDER_TAG}[/dim]"),
|
| 1317 |
+
("Planner iters", str(itr)),
|
| 1318 |
+
]:
|
| 1319 |
+
t.add_row(k, v)
|
| 1320 |
+
console.print(Panel(t, title="[bold]Key Metrics[/bold]", border_style="green"))
|
| 1321 |
+
|
| 1322 |
+
# Rating distribution bar
|
| 1323 |
+
console.print("[bold]Rating Distribution[/bold]")
|
| 1324 |
+
for star in [5,4,3,2,1]:
|
| 1325 |
+
cnt = stats.get("rating_distribution",{}).get(str(star), 0)
|
| 1326 |
+
total = max(1, stats.get("total_reviews",1))
|
| 1327 |
+
bar = "█" * int(cnt/total*40)
|
| 1328 |
+
color = "green" if star>=4 else "yellow" if star==3 else "red"
|
| 1329 |
+
console.print(f" {'★'*star:<5} [{color}]{bar:<40}[/{color}] {cnt:,}")
|
| 1330 |
+
console.print()
|
| 1331 |
+
|
| 1332 |
+
# Detected apps
|
| 1333 |
+
det_apps = state.get("detected_apps",[])
|
| 1334 |
+
if det_apps:
|
| 1335 |
+
console.print(Panel(
|
| 1336 |
+
" ".join(f"[cyan]{a}[/cyan]" for a in det_apps[:15]),
|
| 1337 |
+
title=f"[bold]Detected Apps ({len(det_apps)})[/bold]",
|
| 1338 |
+
border_style="dim"))
|
| 1339 |
+
console.print()
|
| 1340 |
+
|
| 1341 |
+
# Clusters
|
| 1342 |
+
if clusters:
|
| 1343 |
+
t = Table(title="LLM-Discovered Clusters", box=box.ROUNDED, border_style="magenta")
|
| 1344 |
+
t.add_column("Type",width=8); t.add_column("Label",style="bold")
|
| 1345 |
+
t.add_column("Freq",width=7); t.add_column("Severity",width=10)
|
| 1346 |
+
t.add_column("Description",style="dim")
|
| 1347 |
+
for c in clusters[:10]:
|
| 1348 |
+
typ = c.get("type","?")
|
| 1349 |
+
tc_ = "green" if typ=="praise" else "red" if typ=="issue" else "blue"
|
| 1350 |
+
sev = c.get("severity","?")
|
| 1351 |
+
sc = "red" if sev in("critical","high") else "yellow" if sev=="medium" else "blue"
|
| 1352 |
+
t.add_row(f"[{tc_}]{typ}[/{tc_}]", c.get("label",""),
|
| 1353 |
+
c.get("frequency_signal",""), f"[{sc}]{sev}[/{sc}]",
|
| 1354 |
+
c.get("description","")[:80])
|
| 1355 |
+
console.print(t); console.print()
|
| 1356 |
+
|
| 1357 |
+
# Top problems
|
| 1358 |
+
if report.get("top_problems"):
|
| 1359 |
+
t = Table(title="Top Problems", box=box.ROUNDED, border_style="red")
|
| 1360 |
+
t.add_column("#",width=3,style="dim"); t.add_column("Issue",style="bold")
|
| 1361 |
+
t.add_column("Severity",width=10); t.add_column("Evidence",style="dim")
|
| 1362 |
+
for i, p in enumerate(report["top_problems"],1):
|
| 1363 |
+
sc = "red" if p.get("severity") in("critical","high") else \
|
| 1364 |
+
"yellow" if p.get("severity")=="medium" else "blue"
|
| 1365 |
+
t.add_row(str(i), p.get("issue",""),
|
| 1366 |
+
f"[{sc}]{p.get('severity','')}[/{sc}]",
|
| 1367 |
+
p.get("evidence","")[:90])
|
| 1368 |
+
console.print(t); console.print()
|
| 1369 |
+
|
| 1370 |
+
# Key strengths
|
| 1371 |
+
if report.get("key_strengths"):
|
| 1372 |
+
t = Table(title="Key Strengths", box=box.ROUNDED, border_style="green")
|
| 1373 |
+
t.add_column("Strength",style="bold"); t.add_column("Evidence",style="dim")
|
| 1374 |
+
for p in report["key_strengths"]:
|
| 1375 |
+
t.add_row(p.get("strength",""), p.get("evidence","")[:100])
|
| 1376 |
+
console.print(t); console.print()
|
| 1377 |
+
|
| 1378 |
+
# Anomalies
|
| 1379 |
+
if report.get("anomalies"):
|
| 1380 |
+
t = Table(title="Anomalies", box=box.ROUNDED, border_style="yellow")
|
| 1381 |
+
t.add_column("Anomaly",style="bold"); t.add_column("Type",width=10)
|
| 1382 |
+
t.add_column("Detail",style="dim"); t.add_column("Hypothesis",style="dim")
|
| 1383 |
+
for a in report["anomalies"]:
|
| 1384 |
+
t.add_row(a.get("anomaly",""), a.get("type",""),
|
| 1385 |
+
a.get("detail","")[:70], a.get("hypothesis","")[:60])
|
| 1386 |
+
console.print(t); console.print()
|
| 1387 |
+
|
| 1388 |
+
# Per-app
|
| 1389 |
+
if apps:
|
| 1390 |
+
t = Table(title="Per-App Breakdown", box=box.ROUNDED, border_style="cyan")
|
| 1391 |
+
t.add_column("App",style="bold"); t.add_column("Reviews",justify="right")
|
| 1392 |
+
t.add_column("Avg",justify="right"); t.add_column("% Neg",justify="right")
|
| 1393 |
+
t.add_column("% Pos",justify="right")
|
| 1394 |
+
for a in apps:
|
| 1395 |
+
avg = a.get("avg_rating") or 5
|
| 1396 |
+
col = "red" if avg<2.5 else "yellow" if avg<3.5 else "green"
|
| 1397 |
+
t.add_row(str(a["app"])[:40], str(a["count"]),
|
| 1398 |
+
f"[{col}]{avg}[/{col}]",
|
| 1399 |
+
f"{a.get('pct_negative','?')}%",
|
| 1400 |
+
f"{a.get('pct_positive','?')}%")
|
| 1401 |
+
console.print(t); console.print()
|
| 1402 |
+
|
| 1403 |
+
# Recommendations
|
| 1404 |
+
if report.get("recommendations"):
|
| 1405 |
+
t = Table(title="Recommendations", box=box.ROUNDED, border_style="bright_white")
|
| 1406 |
+
t.add_column("#",width=3,style="dim"); t.add_column("Priority",width=10)
|
| 1407 |
+
t.add_column("Action",style="bold"); t.add_column("Impact",style="dim")
|
| 1408 |
+
for i, r in enumerate(report["recommendations"],1):
|
| 1409 |
+
pc = "red" if r.get("priority") in("critical","high") else \
|
| 1410 |
+
"yellow" if r.get("priority")=="medium" else "blue"
|
| 1411 |
+
t.add_row(str(i), f"[{pc}]{r.get('priority','')}[/{pc}]",
|
| 1412 |
+
r.get("action",""), r.get("expected_impact","")[:70])
|
| 1413 |
+
console.print(t); console.print()
|
| 1414 |
+
|
| 1415 |
+
# Tool call log
|
| 1416 |
+
if tools:
|
| 1417 |
+
t = Table(title="Tool Calls", box=box.SIMPLE, border_style="dim")
|
| 1418 |
+
t.add_column("Tool",style="dim"); t.add_column("Args",style="dim"); t.add_column("✓",width=3)
|
| 1419 |
+
for tc_ in tools:
|
| 1420 |
+
ok = "[green]✓[/green]" if tc_.get("ok") else "[red]✗[/red]"
|
| 1421 |
+
t.add_row(tc_.get("tool",""), str(tc_.get("args",{}))[:55], ok)
|
| 1422 |
+
console.print(t); console.print()
|
| 1423 |
+
|
| 1424 |
+
if report.get("confidence_note"):
|
| 1425 |
+
console.print(Panel(f"[dim]{report['confidence_note']}[/dim]",
|
| 1426 |
+
title="Caveats", border_style="dim"))
|
| 1427 |
+
console.rule("[dim]End of Report — v4[/dim]")
|
| 1428 |
+
|
| 1429 |
+
|
| 1430 |
+
def _render_plain(state: ReviewState):
|
| 1431 |
+
S = "="*72
|
| 1432 |
+
def sec(t): print(f"\n{S}\n {t}\n{S}")
|
| 1433 |
+
r = state.get("report",{}); st = state.get("stats",{})
|
| 1434 |
+
se = state.get("sentiment",{}); q = state.get("user_query","")
|
| 1435 |
+
sec("REVIEW INTELLIGENCE REPORT v4")
|
| 1436 |
+
if q:
|
| 1437 |
+
print(f"\nQuery : {q}")
|
| 1438 |
+
print(f"Answer: {r.get('direct_answer','(see summary)')}")
|
| 1439 |
+
print(f"\nSummary : {r.get('executive_summary','')}")
|
| 1440 |
+
print(f"Sentiment: {se.get('overall_tone','?')} [{se.get('method','?')}]")
|
| 1441 |
+
print(f"Provider : {_PROVIDER_TAG}")
|
| 1442 |
+
sec("METRICS")
|
| 1443 |
+
for k,v in [("Reviews",st.get("total_reviews")),("Avg",st.get("avg_rating")),
|
| 1444 |
+
("Pos%",se.get("pct_positive")),("Neg%",se.get("pct_negative"))]:
|
| 1445 |
+
print(f" {k:<12}: {v}")
|
| 1446 |
+
sec("CLUSTERS")
|
| 1447 |
+
for c in state.get("clusters",[])[:8]:
|
| 1448 |
+
print(f" [{c.get('type','?')}] {c.get('label','?')} sev={c.get('severity','?')}")
|
| 1449 |
+
sec("PROBLEMS")
|
| 1450 |
+
for i,p in enumerate(r.get("top_problems",[]),1):
|
| 1451 |
+
print(f" #{i} [{p.get('severity','')}] {p.get('issue','')}")
|
| 1452 |
+
print(f" {p.get('evidence','')[:100]}")
|
| 1453 |
+
sec("RECOMMENDATIONS")
|
| 1454 |
+
for i,rec in enumerate(r.get("recommendations",[]),1):
|
| 1455 |
+
print(f" #{i} [{rec.get('priority','')}] {rec.get('action','')}")
|
| 1456 |
+
print(f"\n{S}\n End\n{S}\n")
|
| 1457 |
+
|
| 1458 |
+
|
| 1459 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1460 |
+
# LOGGING
|
| 1461 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1462 |
+
|
| 1463 |
+
def _log(msg: str, level: str = "info"):
|
| 1464 |
+
ts = datetime.now().strftime("%H:%M:%S")
|
| 1465 |
+
icons = {"info":"·","ok":"✓","warn":"⚠","err":"✗","agent":"▶"}
|
| 1466 |
+
icon = icons.get(level,"·")
|
| 1467 |
+
if RICH:
|
| 1468 |
+
colors = {"info":"dim white","ok":"green","warn":"yellow","err":"red","agent":"cyan"}
|
| 1469 |
+
console.print(f"[{colors.get(level,'white')}][{ts}] {icon} {msg}[/{colors.get(level,'white')}]")
|
| 1470 |
+
else:
|
| 1471 |
+
print(f"[{ts}] {icon} {msg}")
|
| 1472 |
+
|
| 1473 |
+
|
| 1474 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1475 |
+
# ENTRY POINT
|
| 1476 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 1477 |
+
|
| 1478 |
+
def main():
|
| 1479 |
+
parser = argparse.ArgumentParser(
|
| 1480 |
+
description="Review Intelligence Agent v4 — LangGraph + LangChain",
|
| 1481 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 1482 |
+
epilog=textwrap.dedent("""
|
| 1483 |
+
Environment variables:
|
| 1484 |
+
GROQ_API_KEY Primary LLM (free tier)
|
| 1485 |
+
OPENROUTER_API_KEY Fallback #1
|
| 1486 |
+
NVIDIA_API_KEY Fallback #2 (NIMs)
|
| 1487 |
+
PINECONE_API_KEY Pinecone integrated embedding (no OpenAI needed)
|
| 1488 |
+
PINECONE_INDEX Index name (default: review-agent-v4)
|
| 1489 |
+
HF_CODE_AGENT_URL Deployed HuggingFace Space for pandas code execution
|
| 1490 |
+
|
| 1491 |
+
NLP:
|
| 1492 |
+
DistilBERT runs locally (free, no API).
|
| 1493 |
+
pip install transformers torch to enable it.
|
| 1494 |
+
Falls back to rating-heuristic if not installed.
|
| 1495 |
+
|
| 1496 |
+
Examples:
|
| 1497 |
+
python review_agent_v4.py --csv reviews.csv
|
| 1498 |
+
python review_agent_v4.py --csv reviews.csv \\
|
| 1499 |
+
--query "Which action game has the most ad complaints?"
|
| 1500 |
+
python review_agent_v4.py --csv reviews.csv --use-pinecone \\
|
| 1501 |
+
--query "Show 1-star reviews for com.JindoBlu app"
|
| 1502 |
+
"""),
|
| 1503 |
+
)
|
| 1504 |
+
parser.add_argument("--csv", required=True)
|
| 1505 |
+
parser.add_argument("--query", default="",
|
| 1506 |
+
help="Natural language question about the reviews")
|
| 1507 |
+
parser.add_argument("--use-pinecone",action="store_true",
|
| 1508 |
+
help="Enable Pinecone RAG (requires PINECONE_API_KEY)")
|
| 1509 |
+
parser.add_argument("--save-json", action="store_true")
|
| 1510 |
+
args = parser.parse_args()
|
| 1511 |
+
|
| 1512 |
+
if not os.path.exists(args.csv):
|
| 1513 |
+
print(f"File not found: {args.csv}"); sys.exit(1)
|
| 1514 |
+
|
| 1515 |
+
_log(f"LLM: {_PROVIDER_TAG}", "info")
|
| 1516 |
+
_log(f"BERT: {'DistilBERT (local)' if HAS_TRANSFORMERS else 'unavailable (pip install transformers torch)'}", "info")
|
| 1517 |
+
if args.use_pinecone and not HAS_PINECONE:
|
| 1518 |
+
_log("pinecone not installed — RAG disabled (pip install pinecone)", "warn")
|
| 1519 |
+
|
| 1520 |
+
graph = build_graph()
|
| 1521 |
+
init = _empty_state(args.csv, args.query.strip(),
|
| 1522 |
+
args.use_pinecone and HAS_PINECONE)
|
| 1523 |
+
t0 = time.time()
|
| 1524 |
+
final = graph.invoke(init)
|
| 1525 |
+
_log(f"Pipeline complete in {round(time.time()-t0,1)}s "
|
| 1526 |
+
f"(planner iters: {final.get('planner_iter',0)})", "ok")
|
| 1527 |
+
|
| 1528 |
+
render(final)
|
| 1529 |
+
|
| 1530 |
+
if args.save_json:
|
| 1531 |
+
out = str(Path(args.csv).with_suffix("")) + "_report_v4.json"
|
| 1532 |
+
safe = {k: v for k, v in final.items() if k != "raw_df"}
|
| 1533 |
+
with open(out, "w", encoding="utf-8") as f:
|
| 1534 |
+
json.dump(safe, f, indent=2, default=str)
|
| 1535 |
+
_log(f"JSON saved → {out}", "ok")
|
| 1536 |
+
|
| 1537 |
+
if final.get("errors"):
|
| 1538 |
+
_log(f"Non-fatal errors: {final['errors']}", "warn")
|
| 1539 |
+
|
| 1540 |
+
|
| 1541 |
+
def run_agent(query: str, csv_path: Optional[str] = None, df: Optional[pd.DataFrame] = None, use_pinecone: bool = False) -> dict:
|
| 1542 |
+
"""Entry point for the web app or other modules."""
|
| 1543 |
+
graph = build_graph()
|
| 1544 |
+
state = _empty_state(csv_path or "", query.strip(), use_pinecone and HAS_PINECONE)
|
| 1545 |
+
|
| 1546 |
+
if df is not None:
|
| 1547 |
+
state["raw_df"] = df
|
| 1548 |
+
|
| 1549 |
+
return graph.invoke(state)
|
| 1550 |
+
|
| 1551 |
+
|
| 1552 |
+
if __name__ == "__main__":
|
| 1553 |
+
main()
|