Catalog-AI / src /utils /http_client.py
gouri
prod
46ea0ef
Raw
History Blame Contribute Delete
10.9 kB
"""HTTP client for making requests with Jina-safe rate limiting."""
import requests
import time
import threading
import re
from typing import Optional, Dict, List
from urllib.parse import urlparse, urlunparse
from dataclasses import dataclass
@dataclass
class JinaResponse:
"""Response from Jina API with extracted data."""
url: str
title: str
text: str
links: List[str]
class HTTPClient:
"""
Wrapper for HTTP requests with Jina AI integration.
Implements Jina-safe patterns:
- Concurrency limit (4 max concurrent)
- Fixed delay (400ms between calls)
- Limited retries (3 max with backoff)
- URL caching (never hit same URL twice)
- Global cooldown (60s after 5x429)
- Non-content page blocklist
"""
# Class-level state for all instances
_jina_semaphore = threading.Semaphore(4) # Max 4 concurrent
_jina_429_count = 0
_jina_lock = threading.Lock() # Protect shared counters
_last_429_reset = time.time()
# Non-content pages to skip
BLOCKLIST = [
'/login', '/signin', '/sign-in',
'/cart', '/basket',
'/account', '/my-account', '/profile',
'/checkout', '/payment',
'/privacy', '/terms', '/conditions',
'/faq', '/help', '/support',
'/contact', '/about-us',
'/sitemap', '/robots.txt'
]
def __init__(self, timeout: int = 15):
self.timeout = timeout
self.jina_base_url = "https://r.jina.ai/"
def _should_skip_jina_url(self, url: str) -> bool:
"""Check if URL is on blocklist (non-content page)."""
url_lower = url.lower()
return any(blocked in url_lower for blocked in self.BLOCKLIST)
def _jina_call_with_limit(self, url: str) -> Optional[str]:
"""
Make Jina API call with concurrency limiting and retry logic.
Retry strategy: 20s, 40s, 60s delays
After all retries fail: skip URL and wait 60s before continuing
Args:
url: URL to scrape
Returns:
Markdown content or None
"""
with self._jina_semaphore:
# Fixed delay to smooth traffic (Jina hates bursts)
time.sleep(0.4)
# Retry delays: 20s, 40s, 60s
retry_delays = [20, 40, 60]
# Limited retries with progressive backoff
for attempt in range(3):
try:
result = self._jina_request_sync(url)
# Reset 429 count on success
if result is not None:
with HTTPClient._jina_lock:
HTTPClient._jina_429_count = 0
return result
except Exception as e:
error_str = str(e)
# Check for 429 or rate limit errors
if "429" in error_str or "rate limit" in error_str.lower():
with HTTPClient._jina_lock:
HTTPClient._jina_429_count += 1
current_count = HTTPClient._jina_429_count
# Global cooldown if 429 spikes
if current_count >= 5:
print(f" ⚠️ Jina rate limit spike detected. Cooling down for 60s...")
time.sleep(60)
with HTTPClient._jina_lock:
HTTPClient._jina_429_count = 0
# Retry with progressive backoff: 20s, 40s, 60s
if attempt < 2:
retry_delay = retry_delays[attempt]
print(f" ⚠️ Jina rate limit (429) for {url} - retrying in {retry_delay}s (attempt {attempt + 1}/3)")
time.sleep(retry_delay)
else:
print(f" ✗ Jina rate limit (429) for {url} - all retries exhausted")
print(f" ⏸️ Skipping URL and waiting 60s before continuing...")
time.sleep(60)
return None
# For other errors, retry with progressive backoff
elif attempt < 2:
retry_delay = retry_delays[attempt]
print(f" ⚠️ Jina error for {url}: {e} - retrying in {retry_delay}s (attempt {attempt + 1}/3)")
time.sleep(retry_delay)
else:
print(f" ✗ Failed to scrape {url} after {attempt + 1} attempts: {e}")
print(f" ⏸️ Skipping URL and waiting 60s before continuing...")
time.sleep(60)
return None
return None
def _jina_request_sync(self, url: str) -> Optional[str]:
"""Synchronous Jina request."""
try:
headers = {
"X-Return-Format": "markdown",
"X-Remove-Selector": "nav, footer, header, .navigation, .menu, script, style, .cookie"
}
response = requests.get(
f"{self.jina_base_url}{url}",
headers=headers,
timeout=self.timeout
)
response.raise_for_status()
return response.text
except requests.exceptions.Timeout:
print(f" ✗ Timeout scraping {url}")
return None
except requests.exceptions.RequestException as e:
# Re-raise to be caught by async wrapper for 429 handling
raise
def scrape_with_jina(self, url: str) -> Optional[str]:
"""
Scrape a URL using Jina AI Reader with rate limiting.
Args:
url: The URL to scrape
Returns:
Markdown content or None if failed
"""
# Check blocklist
if self._should_skip_jina_url(url):
print(f" ⏭️ Skipping non-content page: {url}")
return None
# Make rate-limited call
result = self._jina_call_with_limit(url)
return result
def get(self, url: str, headers: Optional[Dict] = None) -> Optional[requests.Response]:
"""
Make a GET request (non-Jina).
Args:
url: The URL to request
headers: Optional headers
Returns:
Response object or None if failed
"""
try:
response = requests.get(url, headers=headers, timeout=self.timeout)
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
print(f" ✗ Request failed for {url}: {e}")
return None
class JinaClient:
"""
Jina AI client for URL discovery and content extraction.
Provides structured responses with title, text, and extracted links.
Used by AI crawler for semantic page classification.
"""
def __init__(self, api_key: str = None):
"""
Initialize Jina client.
Args:
api_key: Jina API key (currently not required for r.jina.ai)
"""
self.api_key = api_key
self.http_client = HTTPClient()
def fetch(self, url: str) -> JinaResponse:
"""
Fetch page content with structured extraction.
Args:
url: URL to fetch
Returns:
JinaResponse with title, text, and links
"""
# Get markdown content
markdown = self.http_client.scrape_with_jina(url)
if not markdown:
return JinaResponse(
url=url,
title="",
text="",
links=[]
)
# Extract title from markdown
title = self._extract_title(markdown)
# Extract links from markdown
links = self._extract_links(markdown)
# Clean text (remove markdown formatting)
text = self._clean_text(markdown)
return JinaResponse(
url=url,
title=title,
text=text,
links=links
)
def _extract_title(self, markdown: str) -> str:
"""Extract title from markdown (first H1)."""
# Look for first # heading
match = re.search(r'^#\s+(.+)$', markdown, re.MULTILINE)
if match:
return match.group(1).strip()
# Try to get first line if no heading
lines = markdown.split('\n')
for line in lines:
line = line.strip()
if line and not line.startswith('[') and not line.startswith('!'):
return line[:100] # Max 100 chars
return ""
def _extract_links(self, markdown: str) -> List[str]:
"""Extract all links from markdown."""
# Pattern: [text](url)
link_pattern = r'\[([^\]]+)\]\(([^\)]+)\)'
matches = re.findall(link_pattern, markdown)
# Extract URLs
urls = []
for text, url in matches:
# Skip anchors and mailto
if url.startswith('#') or url.startswith('mailto:'):
continue
# Skip images (common false positive)
if any(ext in url.lower() for ext in ['.jpg', '.png', '.gif', '.svg', '.webp']):
continue
urls.append(url)
return urls
def _clean_text(self, markdown: str) -> str:
"""Clean markdown to plain text."""
# Remove markdown links but keep text: [text](url) -> text
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', markdown)
# Remove images: ![alt](url) -> ""
text = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', '', text)
# Remove headings markers: ### Heading -> Heading
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# Remove bold/italic: **text** or *text* -> text
text = re.sub(r'\*\*([^\*]+)\*\*', r'\1', text)
text = re.sub(r'\*([^\*]+)\*', r'\1', text)
# Remove code blocks: ```code``` -> ""
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
# Remove inline code: `code` -> code
text = re.sub(r'`([^`]+)`', r'\1', text)
# Clean up extra whitespace
text = re.sub(r'\n{3,}', '\n\n', text)
text = text.strip()
return text