Update app.py
Browse files
app.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
-
B2B Content Strategy Planner (
|
| 3 |
Deployed for Hugging Face Spaces.
|
| 4 |
-
Features:
|
| 5 |
"""
|
| 6 |
|
| 7 |
import gradio as gr
|
|
@@ -21,6 +21,15 @@ from typing import List, Dict, Set, Tuple, Optional
|
|
| 21 |
# 1. CONFIGURATION & MASSIVE TOPIC PACKS
|
| 22 |
# ============================================================================
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
TOPIC_PACKS = {
|
| 25 |
# --- Tech & Infra ---
|
| 26 |
"cloud": ["cloud", "cloud-computing", "saas", "paas", "iaas", "cloud-migration", "cloud-native", "multicloud", "hybrid-cloud", "serverless"],
|
|
@@ -73,7 +82,7 @@ AI_MODELS = [
|
|
| 73 |
]
|
| 74 |
|
| 75 |
# ============================================================================
|
| 76 |
-
# 2. CRAWLER &
|
| 77 |
# ============================================================================
|
| 78 |
|
| 79 |
def normalize_str(s: str) -> str:
|
|
@@ -94,49 +103,96 @@ def classify_stage(url: str) -> str:
|
|
| 94 |
return 'TOFU'
|
| 95 |
|
| 96 |
def crawl_website_unlimited(start_url: str):
|
| 97 |
-
"""Robust Crawler"""
|
| 98 |
if not start_url.startswith(('http://', 'https://')): start_url = f'https://{start_url}'
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
queue = [start_url]
|
| 102 |
visited = set()
|
| 103 |
found_urls = []
|
| 104 |
-
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'}
|
| 105 |
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
| 107 |
curr = queue.pop(0)
|
| 108 |
if curr in visited: continue
|
| 109 |
visited.add(curr)
|
| 110 |
|
| 111 |
try:
|
| 112 |
-
time.sleep(0.
|
| 113 |
-
r = requests.get(curr, headers=
|
| 114 |
-
if 'text/html' not in r.headers.get('Content-Type', '').lower(): continue
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
for a in soup.find_all('a', href=True):
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
return list(set(found_urls))
|
| 126 |
|
| 127 |
def fetch_sitemap_links(url: str):
|
| 128 |
-
|
|
|
|
| 129 |
if not url.startswith('http'): url = f'https://{url}'
|
| 130 |
-
try:
|
| 131 |
-
r = requests.get(f"{url.rstrip('/')}/sitemap.xml", timeout=5)
|
| 132 |
-
if r.status_code == 200:
|
| 133 |
-
urls = re.findall(r'<loc>(.*?)</loc>', r.text)
|
| 134 |
-
if len(urls) > 5: return list(set(urls)), f"✅ Found {len(urls)} URLs (XML)"
|
| 135 |
-
except: pass
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
def categorize_links(urls):
|
| 142 |
cats = defaultdict(list)
|
|
@@ -150,7 +206,13 @@ def categorize_links(urls):
|
|
| 150 |
break
|
| 151 |
if not assigned:
|
| 152 |
parts = list(filter(None, urlparse(url).path.split('/')))
|
| 153 |
-
if parts:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return dict(cats)
|
| 155 |
|
| 156 |
# ============================================================================
|
|
@@ -379,7 +441,7 @@ def ui_fetch(url):
|
|
| 379 |
cats = categorize_links(urls)
|
| 380 |
state['urls'] = urls
|
| 381 |
cat_list = ["All"] + sorted(list(cats.keys()))
|
| 382 |
-
return urls, cats, msg, gr.update(choices=cat_list, value="All"), gr.update(choices=[])
|
| 383 |
|
| 384 |
def ui_update_topics(category, categories_data):
|
| 385 |
if not categories_data: return gr.update(choices=[])
|
|
@@ -392,7 +454,7 @@ def ui_analyze_planner(urls, category, topic):
|
|
| 392 |
if not urls: return "Fetch data first.", None, None
|
| 393 |
if not topic: return "Select a topic.", None, None
|
| 394 |
|
| 395 |
-
# Match logic
|
| 396 |
matched = [u for u in urls if normalize_str(topic) in path_norm(u)]
|
| 397 |
|
| 398 |
stages = [classify_stage(u) for u in matched]
|
|
@@ -417,14 +479,6 @@ def ui_add_comp(url, current_comps):
|
|
| 417 |
txt = "\n".join([f"✅ {c['domain']}: {c['stats']['Count']} pages" for c in current_comps])
|
| 418 |
return current_comps, txt, ""
|
| 419 |
|
| 420 |
-
def ui_comp_search(user_urls, competitors, gap_topic, str_topic):
|
| 421 |
-
if not user_urls: return "Fetch user data first.", ""
|
| 422 |
-
|
| 423 |
-
g_html, _ = analyze_gaps_strengths(user_urls, competitors, gap_topic)
|
| 424 |
-
_, s_html = analyze_gaps_strengths(user_urls, competitors, str_topic)
|
| 425 |
-
|
| 426 |
-
return g_html, s_html
|
| 427 |
-
|
| 428 |
def ui_comp_master(user_urls, competitors):
|
| 429 |
if not user_urls: return None, "", "", ""
|
| 430 |
|
|
@@ -444,7 +498,6 @@ def ui_comp_master(user_urls, competitors):
|
|
| 444 |
g_html, s_html = analyze_gaps_strengths(user_urls, competitors, None)
|
| 445 |
|
| 446 |
# 3. Strategy
|
| 447 |
-
# Extract raw lists for summary
|
| 448 |
raw_gaps = g_html.count("<span")
|
| 449 |
raw_str = s_html.count("<span")
|
| 450 |
strat = generate_strategic_summary(range(raw_gaps), range(raw_str))
|
|
|
|
| 1 |
"""
|
| 2 |
+
B2B Content Strategy Planner (Final Robust Edition)
|
| 3 |
Deployed for Hugging Face Spaces.
|
| 4 |
+
Features: Browser-Like Fetching, Multi-Model AI, Visual Intelligence, 25+ Industry Packs.
|
| 5 |
"""
|
| 6 |
|
| 7 |
import gradio as gr
|
|
|
|
| 21 |
# 1. CONFIGURATION & MASSIVE TOPIC PACKS
|
| 22 |
# ============================================================================
|
| 23 |
|
| 24 |
+
# Global Headers to mimic a real browser (Fixes "Not able to fetch" issues)
|
| 25 |
+
HEADERS = {
|
| 26 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
| 27 |
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
| 28 |
+
'Accept-Language': 'en-US,en;q=0.5',
|
| 29 |
+
'Connection': 'keep-alive',
|
| 30 |
+
'Upgrade-Insecure-Requests': '1'
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
TOPIC_PACKS = {
|
| 34 |
# --- Tech & Infra ---
|
| 35 |
"cloud": ["cloud", "cloud-computing", "saas", "paas", "iaas", "cloud-migration", "cloud-native", "multicloud", "hybrid-cloud", "serverless"],
|
|
|
|
| 82 |
]
|
| 83 |
|
| 84 |
# ============================================================================
|
| 85 |
+
# 2. ROBUST CRAWLER & FETCHER (FIXED)
|
| 86 |
# ============================================================================
|
| 87 |
|
| 88 |
def normalize_str(s: str) -> str:
|
|
|
|
| 103 |
return 'TOFU'
|
| 104 |
|
| 105 |
def crawl_website_unlimited(start_url: str):
|
| 106 |
+
"""Robust Crawler with Real Browser Headers"""
|
| 107 |
if not start_url.startswith(('http://', 'https://')): start_url = f'https://{start_url}'
|
| 108 |
+
|
| 109 |
+
# Parse domain safely
|
| 110 |
+
try:
|
| 111 |
+
parsed_url = urlparse(start_url)
|
| 112 |
+
domain = parsed_url.netloc.replace('www.', '')
|
| 113 |
+
if not domain: return []
|
| 114 |
+
except: return []
|
| 115 |
|
| 116 |
queue = [start_url]
|
| 117 |
visited = set()
|
| 118 |
found_urls = []
|
|
|
|
| 119 |
|
| 120 |
+
# Safety limit
|
| 121 |
+
max_pages = 250
|
| 122 |
+
|
| 123 |
+
while queue and len(found_urls) < max_pages:
|
| 124 |
curr = queue.pop(0)
|
| 125 |
if curr in visited: continue
|
| 126 |
visited.add(curr)
|
| 127 |
|
| 128 |
try:
|
| 129 |
+
time.sleep(0.1) # Small delay to avoid blocking
|
| 130 |
+
r = requests.get(curr, headers=HEADERS, timeout=8)
|
|
|
|
| 131 |
|
| 132 |
+
# Skip non-HTML
|
| 133 |
+
content_type = r.headers.get('Content-Type', '').lower()
|
| 134 |
+
if 'text/html' not in content_type: continue
|
| 135 |
+
|
| 136 |
+
if r.status_code == 200:
|
| 137 |
+
found_urls.append(curr)
|
| 138 |
+
|
| 139 |
+
soup = BeautifulSoup(r.text, 'lxml') # Uses lxml for speed
|
| 140 |
|
| 141 |
for a in soup.find_all('a', href=True):
|
| 142 |
+
href = a['href'].strip()
|
| 143 |
+
full = urljoin(curr, href)
|
| 144 |
+
|
| 145 |
+
# Basic cleaning
|
| 146 |
+
clean_url = full.split('#')[0].split('?')[0].rstrip('/')
|
| 147 |
+
|
| 148 |
+
# Check domain match
|
| 149 |
+
if domain in urlparse(clean_url).netloc:
|
| 150 |
+
# Filter junk
|
| 151 |
+
if not any(ext in clean_url.lower() for ext in ['.pdf', '.jpg', '.png', '.css', '.js', 'login', 'signup']):
|
| 152 |
+
if clean_url not in visited and clean_url not in queue:
|
| 153 |
+
queue.append(clean_url)
|
| 154 |
+
except Exception:
|
| 155 |
+
continue
|
| 156 |
|
| 157 |
return list(set(found_urls))
|
| 158 |
|
| 159 |
def fetch_sitemap_links(url: str):
|
| 160 |
+
"""Tries multiple sitemap locations, falls back to crawl"""
|
| 161 |
+
url = url.strip()
|
| 162 |
if not url.startswith('http'): url = f'https://{url}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
+
base_url = f"{urlparse(url).scheme}://{urlparse(url).netloc}"
|
| 165 |
+
|
| 166 |
+
# Common sitemap locations
|
| 167 |
+
candidates = [
|
| 168 |
+
url,
|
| 169 |
+
f"{base_url}/sitemap.xml",
|
| 170 |
+
f"{base_url}/sitemap_index.xml",
|
| 171 |
+
f"{base_url}/wp-sitemap.xml",
|
| 172 |
+
f"{base_url}/page-sitemap.xml"
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
found_urls = []
|
| 176 |
+
|
| 177 |
+
# 1. Try Sitemaps
|
| 178 |
+
for candidate in candidates:
|
| 179 |
+
if not candidate.endswith('.xml') and candidate != url: continue
|
| 180 |
+
try:
|
| 181 |
+
r = requests.get(candidate, headers=HEADERS, timeout=10)
|
| 182 |
+
if r.status_code == 200:
|
| 183 |
+
# Use regex for speed & robustness against bad XML
|
| 184 |
+
urls = re.findall(r'<loc>(.*?)</loc>', r.text)
|
| 185 |
+
if len(urls) > 5:
|
| 186 |
+
found_urls = list(set(urls))
|
| 187 |
+
return found_urls, f"✅ Found {len(found_urls)} URLs via XML Sitemap ({candidate})"
|
| 188 |
+
except: continue
|
| 189 |
+
|
| 190 |
+
# 2. Fallback to Crawler if sitemap failed
|
| 191 |
+
crawl_urls = crawl_website_unlimited(base_url)
|
| 192 |
+
if len(crawl_urls) > 0:
|
| 193 |
+
return crawl_urls, f"✅ Crawled {len(crawl_urls)} pages (Sitemap not found)"
|
| 194 |
+
|
| 195 |
+
return [], "❌ Could not fetch content. Site might be blocking bots."
|
| 196 |
|
| 197 |
def categorize_links(urls):
|
| 198 |
cats = defaultdict(list)
|
|
|
|
| 206 |
break
|
| 207 |
if not assigned:
|
| 208 |
parts = list(filter(None, urlparse(url).path.split('/')))
|
| 209 |
+
if parts:
|
| 210 |
+
# Use first folder as category if sensible
|
| 211 |
+
cat_candidate = parts[0].title()
|
| 212 |
+
if len(cat_candidate) < 20 and not any(x in cat_candidate.lower() for x in ['202', '0', '1']):
|
| 213 |
+
cats[cat_candidate].append(parts[-1])
|
| 214 |
+
else:
|
| 215 |
+
cats["General"].append(parts[-1])
|
| 216 |
return dict(cats)
|
| 217 |
|
| 218 |
# ============================================================================
|
|
|
|
| 441 |
cats = categorize_links(urls)
|
| 442 |
state['urls'] = urls
|
| 443 |
cat_list = ["All"] + sorted(list(cats.keys()))
|
| 444 |
+
return urls, cats, msg, gr.update(choices=cat_list, value="All"), gr.update(choices=[], value=None)
|
| 445 |
|
| 446 |
def ui_update_topics(category, categories_data):
|
| 447 |
if not categories_data: return gr.update(choices=[])
|
|
|
|
| 454 |
if not urls: return "Fetch data first.", None, None
|
| 455 |
if not topic: return "Select a topic.", None, None
|
| 456 |
|
| 457 |
+
# Match logic
|
| 458 |
matched = [u for u in urls if normalize_str(topic) in path_norm(u)]
|
| 459 |
|
| 460 |
stages = [classify_stage(u) for u in matched]
|
|
|
|
| 479 |
txt = "\n".join([f"✅ {c['domain']}: {c['stats']['Count']} pages" for c in current_comps])
|
| 480 |
return current_comps, txt, ""
|
| 481 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
def ui_comp_master(user_urls, competitors):
|
| 483 |
if not user_urls: return None, "", "", ""
|
| 484 |
|
|
|
|
| 498 |
g_html, s_html = analyze_gaps_strengths(user_urls, competitors, None)
|
| 499 |
|
| 500 |
# 3. Strategy
|
|
|
|
| 501 |
raw_gaps = g_html.count("<span")
|
| 502 |
raw_str = s_html.count("<span")
|
| 503 |
strat = generate_strategic_summary(range(raw_gaps), range(raw_str))
|