Chrunos commited on
Commit
7c95ab6
·
verified ·
1 Parent(s): 36e6458

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +306 -143
app.py CHANGED
@@ -1,5 +1,5 @@
1
- from fastapi import FastAPI, HTTPException, status, BackgroundTasks
2
- from fastapi.responses import StreamingResponse
3
  import instaloader
4
  import requests
5
  import os
@@ -9,6 +9,10 @@ import random
9
  from functools import wraps
10
  import json
11
  from datetime import datetime, timedelta
 
 
 
 
12
 
13
  # Configure logging
14
  logging.basicConfig(
@@ -26,13 +30,21 @@ INSTAGRAM_PASSWORD = os.getenv('INSTAGRAM_PASSWORD')
26
  # Session configuration
27
  SESSION_DIR = "/tmp/instagram_sessions"
28
  SESSION_FILE = os.path.join(SESSION_DIR, f"session-{INSTAGRAM_USERNAME}") if INSTAGRAM_USERNAME else None
 
29
 
30
- # Create session directory if not exists
31
  os.makedirs(SESSION_DIR, exist_ok=True)
 
32
 
33
  # Rate limiting state
34
  LAST_REQUEST_TIME = {}
35
  COOLDOWN_PERIODS = {}
 
 
 
 
 
 
36
 
37
  USER_AGENTS = [
38
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
@@ -42,11 +54,53 @@ USER_AGENTS = [
42
  "Mozilla/5.0 (iPad; CPU OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"
43
  ]
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def get_instaloader(force_login=False) -> instaloader.Instaloader:
46
  """Create and configure Instaloader instance with proper parameters"""
47
  L = instaloader.Instaloader(
48
  sleep=True,
49
- request_timeout=30, # Reduced timeout
50
  max_connection_attempts=3,
51
  user_agent=random.choice(USER_AGENTS),
52
  download_pictures=False,
@@ -71,14 +125,8 @@ def get_instaloader(force_login=False) -> instaloader.Instaloader:
71
  try:
72
  L.load_session_from_file(INSTAGRAM_USERNAME, SESSION_FILE)
73
  logger.info("Session loaded successfully")
74
- # Test session without using is_verified
75
- try:
76
- test_profile = instaloader.Profile.from_username(L.context, INSTAGRAM_USERNAME)
77
- logger.info(f"Session test: Profile ID {test_profile.userid} verified")
78
- return L
79
- except Exception as e:
80
- logger.warning(f"Session test failed: {str(e)}")
81
- raise Exception("Invalid session")
82
  except Exception as e:
83
  logger.warning(f"Session load failed: {str(e)}, performing fresh login")
84
 
@@ -131,39 +179,46 @@ def get_instaloader(force_login=False) -> instaloader.Instaloader:
131
  detail="Instagram login service unavailable"
132
  )
133
 
134
- def is_rate_limited(username: str) -> bool:
135
  """Check if a given username is currently rate limited"""
136
- # If in cooldown period, check if it's expired
137
- if username in COOLDOWN_PERIODS:
138
- if datetime.now() < COOLDOWN_PERIODS[username]:
139
- remaining = (COOLDOWN_PERIODS[username] - datetime.now()).seconds
140
- logger.warning(f"User {username} in cooldown for {remaining} more seconds")
141
- return True
142
- else:
143
- # Cooldown expired
144
- del COOLDOWN_PERIODS[username]
145
-
146
- # Check time since last request
147
- if username in LAST_REQUEST_TIME:
148
- elapsed = (datetime.now() - LAST_REQUEST_TIME[username]).seconds
149
- min_interval = 60 # Minimum 60 seconds between requests
150
- if elapsed < min_interval:
151
- logger.warning(f"Rate limiting {username}: {elapsed}s elapsed, need {min_interval}s")
152
- return True
153
-
154
- # Update last request time
155
- LAST_REQUEST_TIME[username] = datetime.now()
156
- return False
 
157
 
158
- def handle_rate_limit_error(username: str):
159
  """Handle rate limit by setting cooldown period"""
160
- # Set cooldown period (increases with repeated issues)
161
- cooldown_minutes = 5
162
- if username in COOLDOWN_PERIODS:
163
- cooldown_minutes = min(cooldown_minutes * 2, 30) # Exponential backoff up to 30 minutes
164
-
165
- COOLDOWN_PERIODS[username] = datetime.now() + timedelta(minutes=cooldown_minutes)
166
- logger.warning(f"Setting cooldown for {username} for {cooldown_minutes} minutes")
 
 
 
 
 
 
167
 
168
  def handle_instagram_errors(func):
169
  @wraps(func)
@@ -175,21 +230,66 @@ def handle_instagram_errors(func):
175
 
176
  try:
177
  # Check for rate limiting
178
- if request_username and is_rate_limited(request_username):
179
- raise HTTPException(
180
- status_code=status.HTTP_429_TOO_MANY_REQUESTS,
181
- detail="Rate limit exceeded. Please try again later."
182
- )
 
 
 
 
 
 
 
183
 
184
- return await func(*args, **kwargs)
 
 
 
 
 
 
185
 
186
  except instaloader.exceptions.ConnectionException as e:
187
  error_message = str(e)
188
  logger.error("Connection error: %s", error_message)
189
 
190
- if "401 Unauthorized" in error_message and "Please wait a few minutes" in error_message:
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  if request_username:
192
- handle_rate_limit_error(request_username)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  raise HTTPException(
194
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
195
  detail="Instagram rate limit exceeded. Please try again later."
@@ -208,7 +308,15 @@ def handle_instagram_errors(func):
208
  except instaloader.exceptions.QueryReturnedBadRequestException as e:
209
  logger.error("API error 400: %s", str(e))
210
  if request_username:
211
- handle_rate_limit_error(request_username)
 
 
 
 
 
 
 
 
212
  raise HTTPException(
213
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
214
  detail="Instagram rate limit exceeded"
@@ -228,107 +336,123 @@ def handle_instagram_errors(func):
228
 
229
  @app.get("/stories/{username}")
230
  @handle_instagram_errors
231
- async def get_stories(username: str, background_tasks: BackgroundTasks):
232
  """Retrieve stories with enhanced anti-detection measures"""
233
  logger.info(f"Processing request for @{username}")
234
 
 
 
 
 
 
 
235
  try:
236
- # Get loader with session
237
- L = get_instaloader()
238
- logger.info("Instaloader instance configured")
239
-
240
- # Randomized delay before profile request (more natural)
241
- delay = random.uniform(1.5, 3.0)
242
- logger.debug(f"Applying initial delay of {delay:.2f}s")
243
- time.sleep(delay)
244
-
245
- # Profile lookup with retry
246
- profile = None
247
- for attempt in range(3):
248
- try:
249
- logger.info(f"Fetching profile (attempt {attempt+1}/3)")
250
- profile = instaloader.Profile.from_username(L.context, username)
251
- break
252
- except instaloader.exceptions.QueryReturnedBadRequestException:
253
- wait_time = (attempt + 1) * random.uniform(3.0, 5.0)
254
- logger.warning(f"Rate limited, waiting {wait_time:.2f}s")
255
- time.sleep(wait_time)
256
- except instaloader.exceptions.ConnectionException as e:
257
- if "401 Unauthorized" in str(e) and "Please wait a few minutes" in str(e):
258
- # Try with a fresh login if session might be expired
259
- if attempt < 2: # Only try this once
260
- logger.warning("Session may be expired, trying with fresh login")
261
- L = get_instaloader(force_login=True)
262
- time.sleep(random.uniform(4.0, 6.0))
263
- continue
264
- raise
265
-
266
- if profile is None:
267
- raise HTTPException(
268
- status.HTTP_429_TOO_MANY_REQUESTS,
269
- "Too many attempts to access Instagram"
270
- )
 
 
271
 
272
- logger.info(f"Access check for @{username}")
273
- if not profile.has_viewable_story:
274
- logger.warning("No viewable story")
275
- raise HTTPException(
276
- status.HTTP_404_NOT_FOUND,
277
- "No accessible stories for this profile"
278
- )
279
 
280
- # Additional delay before story fetch (variable to look more natural)
281
- time.sleep(random.uniform(1.0, 2.5))
282
-
283
- logger.info("Fetching stories")
284
- stories = []
285
- try:
286
- for story in L.get_stories(userids=[profile.userid]):
287
- for item in story.get_items():
288
- # Create a story dict with safe attribute access
289
- story_data = {
290
- "id": str(item.mediaid),
291
- "url": item.url,
292
- "type": "video" if item.is_video else "image",
293
- "timestamp": item.date_utc.isoformat(),
294
- }
295
-
296
- # Add any available metadata safely
297
- if hasattr(item, 'owner_username'):
298
- story_data["username"] = item.owner_username
299
 
300
- # Only try to add view_count if it's a video
301
- if item.is_video:
302
- try:
303
- if hasattr(item, 'view_count'):
304
- story_data["views"] = item.view_count
305
- except AttributeError:
306
- pass
307
-
308
- stories.append(story_data)
309
-
310
- except instaloader.exceptions.QueryReturnedNotFoundException:
311
- logger.error("Stories not found")
312
- raise HTTPException(
313
- status.HTTP_404_NOT_FOUND,
314
- "Stories not available"
315
- )
 
 
 
 
316
 
317
- if not stories:
318
- logger.info("No active stories found")
319
- raise HTTPException(
320
- status.HTTP_404_NOT_FOUND,
321
- "No active stories available"
322
- )
323
 
324
- # Queue session save in background to not delay response
325
- background_tasks.add_task(lambda: L.save_session_to_file(SESSION_FILE) if SESSION_FILE else None)
326
-
327
- # Final randomized delay before response (looks more natural)
328
- time.sleep(random.uniform(0.3, 0.7))
 
 
 
 
 
 
 
 
329
 
330
- logger.info(f"Returning {len(stories)} stories")
331
- return {"data": stories, "count": len(stories), "username": username}
332
 
333
  except Exception as e:
334
  logger.error(f"Critical failure: {str(e)}")
@@ -405,4 +529,43 @@ async def download_media(url: str):
405
  # Add a health check endpoint
406
  @app.get("/health")
407
  async def health_check():
408
- return {"status": "ok", "timestamp": datetime.now().isoformat()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, status, BackgroundTasks, Request, Depends
2
+ from fastapi.responses import StreamingResponse, JSONResponse
3
  import instaloader
4
  import requests
5
  import os
 
9
  from functools import wraps
10
  import json
11
  from datetime import datetime, timedelta
12
+ from typing import Dict, List, Optional
13
+ import hashlib
14
+ import asyncio
15
+ from starlette.concurrency import run_in_threadpool
16
 
17
  # Configure logging
18
  logging.basicConfig(
 
30
  # Session configuration
31
  SESSION_DIR = "/tmp/instagram_sessions"
32
  SESSION_FILE = os.path.join(SESSION_DIR, f"session-{INSTAGRAM_USERNAME}") if INSTAGRAM_USERNAME else None
33
+ CACHE_DIR = "/tmp/instagram_cache"
34
 
35
+ # Create required directories
36
  os.makedirs(SESSION_DIR, exist_ok=True)
37
+ os.makedirs(CACHE_DIR, exist_ok=True)
38
 
39
  # Rate limiting state
40
  LAST_REQUEST_TIME = {}
41
  COOLDOWN_PERIODS = {}
42
+ RATE_LIMIT_LOCK = asyncio.Lock()
43
+
44
+ # Cache state
45
+ STORY_CACHE: Dict[str, Dict] = {}
46
+ CACHE_EXPIRY: Dict[str, datetime] = {}
47
+ CACHE_LOCK = asyncio.Lock()
48
 
49
  USER_AGENTS = [
50
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
 
54
  "Mozilla/5.0 (iPad; CPU OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"
55
  ]
56
 
57
+ # Calculate cache key for a username
58
+ def get_cache_key(username: str) -> str:
59
+ return hashlib.md5(username.lower().encode()).hexdigest()
60
+
61
+ # Check if we have a valid cache for a username
62
+ async def get_cached_stories(username: str) -> Optional[Dict]:
63
+ cache_key = get_cache_key(username)
64
+
65
+ async with CACHE_LOCK:
66
+ if cache_key in STORY_CACHE and cache_key in CACHE_EXPIRY:
67
+ if datetime.now() < CACHE_EXPIRY[cache_key]:
68
+ logger.info(f"Cache hit for {username}")
69
+ return STORY_CACHE[cache_key]
70
+ else:
71
+ # Expired cache
72
+ logger.info(f"Cache expired for {username}")
73
+ STORY_CACHE.pop(cache_key, None)
74
+ CACHE_EXPIRY.pop(cache_key, None)
75
+
76
+ return None
77
+
78
+ # Save stories to cache
79
+ async def cache_stories(username: str, stories_data: Dict, ttl_minutes: int = 15):
80
+ cache_key = get_cache_key(username)
81
+
82
+ async with CACHE_LOCK:
83
+ STORY_CACHE[cache_key] = stories_data
84
+ CACHE_EXPIRY[cache_key] = datetime.now() + timedelta(minutes=ttl_minutes)
85
+
86
+ logger.info(f"Cached stories for {username} for {ttl_minutes} minutes")
87
+
88
+ # Save to disk for persistence
89
+ cache_file = os.path.join(CACHE_DIR, f"{cache_key}.json")
90
+ try:
91
+ with open(cache_file, 'w') as f:
92
+ json.dump({
93
+ "data": stories_data,
94
+ "expires": CACHE_EXPIRY[cache_key].isoformat()
95
+ }, f)
96
+ except Exception as e:
97
+ logger.warning(f"Failed to save cache to disk: {str(e)}")
98
+
99
  def get_instaloader(force_login=False) -> instaloader.Instaloader:
100
  """Create and configure Instaloader instance with proper parameters"""
101
  L = instaloader.Instaloader(
102
  sleep=True,
103
+ request_timeout=30,
104
  max_connection_attempts=3,
105
  user_agent=random.choice(USER_AGENTS),
106
  download_pictures=False,
 
125
  try:
126
  L.load_session_from_file(INSTAGRAM_USERNAME, SESSION_FILE)
127
  logger.info("Session loaded successfully")
128
+ # Test session with a lightweight call if possible
129
+ return L
 
 
 
 
 
 
130
  except Exception as e:
131
  logger.warning(f"Session load failed: {str(e)}, performing fresh login")
132
 
 
179
  detail="Instagram login service unavailable"
180
  )
181
 
182
+ async def is_rate_limited(username: str) -> bool:
183
  """Check if a given username is currently rate limited"""
184
+ async with RATE_LIMIT_LOCK:
185
+ # If in cooldown period, check if it's expired
186
+ if username in COOLDOWN_PERIODS:
187
+ if datetime.now() < COOLDOWN_PERIODS[username]:
188
+ remaining = (COOLDOWN_PERIODS[username] - datetime.now()).seconds
189
+ logger.warning(f"User {username} in cooldown for {remaining} more seconds")
190
+ return True
191
+ else:
192
+ # Cooldown expired
193
+ del COOLDOWN_PERIODS[username]
194
+
195
+ # Check time since last request
196
+ if username in LAST_REQUEST_TIME:
197
+ elapsed = (datetime.now() - LAST_REQUEST_TIME[username]).seconds
198
+ min_interval = 60 # Minimum 60 seconds between requests
199
+ if elapsed < min_interval:
200
+ logger.warning(f"Rate limiting {username}: {elapsed}s elapsed, need {min_interval}s")
201
+ return True
202
+
203
+ # Update last request time
204
+ LAST_REQUEST_TIME[username] = datetime.now()
205
+ return False
206
 
207
+ async def handle_rate_limit_error(username: str, retry_after: Optional[int] = None):
208
  """Handle rate limit by setting cooldown period"""
209
+ async with RATE_LIMIT_LOCK:
210
+ # Set cooldown period based on retry_after or default logic
211
+ if retry_after:
212
+ # Use server-provided retry duration if available
213
+ cooldown_minutes = max(retry_after / 60, 1) # At least 1 minute
214
+ else:
215
+ # Default exponential backoff
216
+ cooldown_minutes = 5
217
+ if username in COOLDOWN_PERIODS:
218
+ cooldown_minutes = min(cooldown_minutes * 2, 30) # Up to 30 minutes
219
+
220
+ COOLDOWN_PERIODS[username] = datetime.now() + timedelta(minutes=cooldown_minutes)
221
+ logger.warning(f"Setting cooldown for {username} for {cooldown_minutes} minutes")
222
 
223
  def handle_instagram_errors(func):
224
  @wraps(func)
 
230
 
231
  try:
232
  # Check for rate limiting
233
+ if request_username and await is_rate_limited(request_username):
234
+ cached_response = await get_cached_stories(request_username)
235
+ if cached_response:
236
+ # Return cached response with cache note
237
+ logger.info(f"Returning cached response for rate-limited user {request_username}")
238
+ return {**cached_response, "from_cache": True}
239
+ else:
240
+ # No cache available
241
+ raise HTTPException(
242
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
243
+ detail="Rate limit exceeded. Please try again later."
244
+ )
245
 
246
+ response = await func(*args, **kwargs)
247
+
248
+ # Cache successful responses if they contain story data
249
+ if request_username and isinstance(response, dict) and "data" in response:
250
+ await cache_stories(request_username, response)
251
+
252
+ return response
253
 
254
  except instaloader.exceptions.ConnectionException as e:
255
  error_message = str(e)
256
  logger.error("Connection error: %s", error_message)
257
 
258
+ # Check for rate limit related messages
259
+ retry_after = None
260
+ if "429 Too Many Requests" in error_message:
261
+ # Extract retry timer from error message if possible
262
+ try:
263
+ retry_text = error_message.split("retried in")[1].split("minutes")[0].strip()
264
+ retry_after = int(retry_text) * 60 # Convert to seconds
265
+ except (IndexError, ValueError):
266
+ retry_after = 1800 # Default 30 minutes
267
+
268
+ if request_username:
269
+ await handle_rate_limit_error(request_username, retry_after)
270
+
271
+ # Try to serve from cache if available
272
  if request_username:
273
+ cached_response = await get_cached_stories(request_username)
274
+ if cached_response:
275
+ logger.info(f"Returning cached response for rate-limited request")
276
+ return {**cached_response, "from_cache": True}
277
+
278
+ raise HTTPException(
279
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
280
+ detail=f"Instagram rate limit exceeded. Please try again in {retry_after//60} minutes."
281
+ )
282
+ elif "401 Unauthorized" in error_message and "Please wait a few minutes" in error_message:
283
+ if request_username:
284
+ await handle_rate_limit_error(request_username)
285
+
286
+ # Try to serve from cache if available
287
+ if request_username:
288
+ cached_response = await get_cached_stories(request_username)
289
+ if cached_response:
290
+ logger.info(f"Returning cached response for rate-limited request")
291
+ return {**cached_response, "from_cache": True}
292
+
293
  raise HTTPException(
294
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
295
  detail="Instagram rate limit exceeded. Please try again later."
 
308
  except instaloader.exceptions.QueryReturnedBadRequestException as e:
309
  logger.error("API error 400: %s", str(e))
310
  if request_username:
311
+ await handle_rate_limit_error(request_username)
312
+
313
+ # Try to serve from cache if available
314
+ if request_username:
315
+ cached_response = await get_cached_stories(request_username)
316
+ if cached_response:
317
+ logger.info(f"Returning cached response for rate-limited request")
318
+ return {**cached_response, "from_cache": True}
319
+
320
  raise HTTPException(
321
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
322
  detail="Instagram rate limit exceeded"
 
336
 
337
  @app.get("/stories/{username}")
338
  @handle_instagram_errors
339
+ async def get_stories(username: str, background_tasks: BackgroundTasks, cached: bool = False):
340
  """Retrieve stories with enhanced anti-detection measures"""
341
  logger.info(f"Processing request for @{username}")
342
 
343
+ # Check for cache first if requested
344
+ if cached:
345
+ cached_response = await get_cached_stories(username)
346
+ if cached_response:
347
+ return {**cached_response, "from_cache": True}
348
+
349
  try:
350
+ # Run the Instagram operations in a thread pool to not block the event loop
351
+ async def fetch_stories():
352
+ # Get loader with session
353
+ L = get_instaloader()
354
+ logger.info("Instaloader instance configured")
355
+
356
+ # Randomized delay before profile request (more natural)
357
+ delay = random.uniform(1.5, 3.0)
358
+ logger.debug(f"Applying initial delay of {delay:.2f}s")
359
+ time.sleep(delay)
360
+
361
+ # Profile lookup with retry
362
+ profile = None
363
+ for attempt in range(3):
364
+ try:
365
+ logger.info(f"Fetching profile (attempt {attempt+1}/3)")
366
+ profile = instaloader.Profile.from_username(L.context, username)
367
+ break
368
+ except instaloader.exceptions.QueryReturnedBadRequestException:
369
+ wait_time = (attempt + 1) * random.uniform(3.0, 5.0)
370
+ logger.warning(f"Rate limited, waiting {wait_time:.2f}s")
371
+ time.sleep(wait_time)
372
+ except instaloader.exceptions.ConnectionException as e:
373
+ if "401 Unauthorized" in str(e) and "Please wait a few minutes" in str(e):
374
+ # Try with a fresh login if session might be expired
375
+ if attempt < 2: # Only try this once
376
+ logger.warning("Session may be expired, trying with fresh login")
377
+ L = get_instaloader(force_login=True)
378
+ time.sleep(random.uniform(4.0, 6.0))
379
+ continue
380
+ raise
381
+
382
+ if profile is None:
383
+ raise HTTPException(
384
+ status.HTTP_429_TOO_MANY_REQUESTS,
385
+ "Too many attempts to access Instagram"
386
+ )
387
 
388
+ logger.info(f"Access check for @{username}")
389
+ if not profile.has_viewable_story:
390
+ logger.warning("No viewable story")
391
+ raise HTTPException(
392
+ status.HTTP_404_NOT_FOUND,
393
+ "No accessible stories for this profile"
394
+ )
395
 
396
+ # Additional delay before story fetch
397
+ time.sleep(random.uniform(1.0, 2.5))
398
+
399
+ logger.info("Fetching stories")
400
+ stories = []
401
+ try:
402
+ for story in L.get_stories(userids=[profile.userid]):
403
+ for item in story.get_items():
404
+ # Create a story dict with safe attribute access
405
+ story_data = {
406
+ "id": str(item.mediaid),
407
+ "url": item.url,
408
+ "type": "video" if item.is_video else "image",
409
+ "timestamp": item.date_utc.isoformat(),
410
+ }
 
 
 
 
411
 
412
+ # Add any available metadata safely
413
+ if hasattr(item, 'owner_username'):
414
+ story_data["username"] = item.owner_username
415
+
416
+ # Only try to add view_count if it's a video
417
+ if item.is_video:
418
+ try:
419
+ if hasattr(item, 'view_count'):
420
+ story_data["views"] = item.view_count
421
+ except AttributeError:
422
+ pass
423
+
424
+ stories.append(story_data)
425
+
426
+ except instaloader.exceptions.QueryReturnedNotFoundException:
427
+ logger.error("Stories not found")
428
+ raise HTTPException(
429
+ status.HTTP_404_NOT_FOUND,
430
+ "Stories not available"
431
+ )
432
 
433
+ if not stories:
434
+ logger.info("No active stories found")
435
+ raise HTTPException(
436
+ status.HTTP_404_NOT_FOUND,
437
+ "No active stories available"
438
+ )
439
 
440
+ # Queue session save in background
441
+ background_tasks.add_task(lambda: L.save_session_to_file(SESSION_FILE) if SESSION_FILE else None)
442
+
443
+ # Final randomized delay
444
+ time.sleep(random.uniform(0.3, 0.7))
445
+
446
+ logger.info(f"Returning {len(stories)} stories")
447
+ return {
448
+ "data": stories,
449
+ "count": len(stories),
450
+ "username": username,
451
+ "fetched_at": datetime.now().isoformat()
452
+ }
453
 
454
+ # Execute in thread pool to not block the event loop
455
+ return await run_in_threadpool(fetch_stories)
456
 
457
  except Exception as e:
458
  logger.error(f"Critical failure: {str(e)}")
 
529
  # Add a health check endpoint
530
  @app.get("/health")
531
  async def health_check():
532
+ return {"status": "ok", "timestamp": datetime.now().isoformat()}
533
+
534
+ # Add middleware to handle rate limit headers
535
+ @app.middleware("http")
536
+ async def add_rate_limit_headers(request: Request, call_next):
537
+ response = await call_next(request)
538
+
539
+ # Add custom headers for rate limiting info
540
+ username = request.path_params.get("username", None)
541
+ if username and username in COOLDOWN_PERIODS:
542
+ remaining = max(0, int((COOLDOWN_PERIODS[username] - datetime.now()).total_seconds()))
543
+ response.headers["X-RateLimit-Reset"] = str(remaining)
544
+ response.headers["X-RateLimit-Remaining"] = "0" if remaining > 0 else "1"
545
+
546
+ return response
547
+
548
+ # Startup event to load cache from disk
549
+ @app.on_event("startup")
550
+ async def load_cache_from_disk():
551
+ try:
552
+ for filename in os.listdir(CACHE_DIR):
553
+ if filename.endswith('.json'):
554
+ file_path = os.path.join(CACHE_DIR, filename)
555
+ try:
556
+ with open(file_path, 'r') as f:
557
+ cache_data = json.load(f)
558
+
559
+ if "data" in cache_data and "expires" in cache_data:
560
+ expire_time = datetime.fromisoformat(cache_data["expires"])
561
+ if expire_time > datetime.now():
562
+ cache_key = filename[:-5] # Remove .json
563
+ async with CACHE_LOCK:
564
+ STORY_CACHE[cache_key] = cache_data["data"]
565
+ CACHE_EXPIRY[cache_key] = expire_time
566
+ except Exception as e:
567
+ logger.warning(f"Failed to load cache file {filename}: {str(e)}")
568
+
569
+ logger.info(f"Loaded {len(STORY_CACHE)} items from cache")
570
+ except Exception as e:
571
+ logger.error(f"Error loading cache: {str(e)}")