crawler / crawler.py
ghosthets's picture
Upload 7 files
02544a0 verified
Raw
History Blame Contribute Delete
10.6 kB
import asyncio, re, json
from urllib.parse import urljoin, urlparse, urlunparse
from datetime import datetime
from bs4 import BeautifulSoup
from collections import Counter
import httpx
from models import PageData
MAX_PAGES = 500
TIMEOUT = 15
CONCURRENCY = 10
USER_AGENT = "JuskeoGEO/1.0 (+https://juskeo.io; crawler@juskeo.io)"
visited = set()
internal_urls = set()
external_urls = set()
all_pages = []
all_text = ""
domain = ""
def normalize_url(url):
parsed = urlparse(url)
path = parsed.path.rstrip("/")
return urlunparse((parsed.scheme, parsed.netloc, path, "", "", ""))
def is_same_domain(url):
if not domain: return False
host = urlparse(url).netloc.lower().lstrip('www.')
return host == domain
def is_html(response):
ct = response.headers.get("content-type", "")
return "text/html" in ct or ct.startswith("text/") or "html" in ct
def extract_meta(soup, url, status_code, content_type):
title_tag = soup.find("title")
title = title_tag.get_text(strip=True) if title_tag else ""
meta_desc = soup.find("meta", attrs={"name": "description"})
description = meta_desc.get("content", "").strip() if meta_desc else ""
h1_tags = [h.get_text(strip=True) for h in soup.find_all("h1")]
h2_tags = [h.get_text(strip=True) for h in soup.find_all("h2")]
h3_tags = [h.get_text(strip=True) for h in soup.find_all("h3")]
body = soup.find("body")
text = body.get_text(separator=" ", strip=True) if body else ""
word_count = len(text.split())
sentence_count = text.count(".") + text.count("!") + text.count("?")
reading_time_min = max(1, round(word_count / 200))
# Extract paragraph text
paragraphs = [p.get_text(strip=True) for p in soup.find_all("p") if len(p.get_text(strip=True)) > 20]
# Check for lists
has_lists = bool(soup.find_all(["ul", "ol"]))
# Check for Q&A patterns (questions followed by answers)
has_qa_pattern = bool(re.search(r'[?.!]\s*[A-Z]', text))
geo_score = calc_geo_score(soup, title, description, h1_tags, h2_tags, word_count, sentence_count, has_lists, has_qa_pattern)
aeo_ready = check_aeo_ready(soup, has_lists, has_qa_pattern, h1_tags, h2_tags, word_count)
return PageData(
url=url,
title=title,
description=description,
h1=h1_tags,
h2=h2_tags,
h3=h3_tags,
paragraphs=paragraphs,
content_text=text[:5000],
word_count=word_count,
sentence_count=sentence_count,
reading_time_min=reading_time_min,
heading_count=len(h1_tags) + len(h2_tags) + len(h3_tags),
geo_score=geo_score,
aeo_ready=aeo_ready,
has_lists=has_lists,
has_qa_pattern=has_qa_pattern,
status_code=status_code,
content_type=content_type,
), text
def calc_geo_score(soup, title, description, h1s, h2s, word_count, sentence_count, has_lists, has_qa_pattern):
score = 20
# Title quality (most important for LLM understanding)
if title and len(title) > 10: score += 12
if title and len(title) > 30: score += 5
# Description (if exists, bonus)
if description and len(description) > 30: score += 8
elif description and len(description) > 10: score += 4
# Heading structure
if h1s: score += 8
if len(h2s) >= 2: score += 8
elif h2s: score += 4
if len(h1s) + len(h2s) >= 3: score += 5
# Content depth
if word_count > 200: score += 10
elif word_count > 100: score += 5
if word_count > 500: score += 5
if word_count > 1000: score += 5
# Content structure (LLMs prefer structured content)
if has_lists: score += 8
if has_qa_pattern: score += 6
if sentence_count > 10: score += 5
# Images with alt text
imgs = soup.find_all("img", alt=True)
if imgs: score += 3
return min(100, score)
def check_aeo_ready(soup, has_lists, has_qa_pattern, h1s, h2s, word_count):
signals = 0
# Content depth (AEO needs substantial content to answer from)
if word_count > 300: signals += 1
if word_count > 800: signals += 1
# Structured content (lists, Q&A = easy for AI to parse)
if has_lists: signals += 1
if has_qa_pattern: signals += 1
# Clear heading hierarchy
if h1s and len(h2s) >= 2: signals += 1
# FAQ-like patterns in heading text
faq_headings = sum(1 for h in h1s + h2s if "?" in h or h.lower().startswith(("what", "how", "why", "when", "where", "who", "do", "can", "is", "are")))
if faq_headings >= 2: signals += 1
return signals >= 3
def extract_links(soup, base_url):
links = set()
for a in soup.find_all("a", href=True):
href = a["href"].strip()
if href.startswith("#") or href.startswith("javascript:") or href.startswith("mailto:"):
continue
full = urljoin(base_url, href)
parsed = urlparse(full)
if parsed.scheme in ("http", "https"):
links.add(normalize_url(full))
return links
async def fetch(client, url, sem):
async with sem:
try:
r = await client.get(url, timeout=TIMEOUT, follow_redirects=True)
return r
except Exception:
return None
def extract_blog_posts(pages):
posts = []
for p in pages:
if p.word_count > 200:
posts.append({
"title": p.title or "Untitled",
"excerpt": (p.description or "")[:150],
"url": p.url,
"word_count": p.word_count,
"geo_score": p.geo_score,
})
return posts[:20]
def extract_keywords(pages, all_text):
stopwords = {
"the","a","an","and","or","but","in","on","at","to","for","of","by","with",
"from","as","is","it","are","was","were","be","been","being","have","has",
"had","do","does","did","will","would","can","could","shall","should","may",
"might","this","that","these","those","i","you","he","she","we","they","my",
"your","his","her","its","our","their","me","him","us","them","not","no",
"nor","so","if","then","than","too","very","just","about","up","out","over",
"also","more","some","any","each","every","all","both","few","most","into",
"through","during","before","after","above","below","between","under","again",
"further","once","here","there","when","where","why","how","what","which","who"}
words = re.findall(r"\b[a-zA-Z]{3,}\b", all_text.lower())
word_freq = Counter(w for w in words if w not in stopwords)
top_30 = word_freq.most_common(30)
ngrams = Counter()
tokens = [w for w in words if w not in stopwords]
for i in range(len(tokens)-1):
ngrams[f"{tokens[i]} {tokens[i+1]}"] += 1
top_bigrams = ngrams.most_common(15)
keywords = []
for i, (word, count) in enumerate(top_30):
keywords.append({
"keyword": word,
"volume": count * 12 + 50,
"position": i + 1,
"change": 0,
"llm_featured": []
})
for i, (bg, count) in enumerate(top_bigrams):
if i < len(keywords):
keywords[i]["keyword"] = bg
keywords[i]["volume"] = count * 8 + 30
return keywords
def extract_schema_types(pages):
return []
async def crawl_url(target_url, progress_callback=None):
global visited, internal_urls, external_urls, all_pages, all_text, domain
visited.clear()
internal_urls.clear()
external_urls.clear()
all_pages.clear()
all_text = ""
parsed = urlparse(target_url)
domain = parsed.netloc.lower().lstrip('www.')
start_url = normalize_url(target_url)
queue = [start_url]
visited.add(start_url)
sem = asyncio.Semaphore(CONCURRENCY)
async with httpx.AsyncClient(
headers={"User-Agent": USER_AGENT},
timeout=TIMEOUT,
follow_redirects=True,
limits=httpx.Limits(max_connections=CONCURRENCY*2),
) as client:
while queue and len(visited) <= MAX_PAGES:
batch = queue[:CONCURRENCY]
queue = queue[CONCURRENCY:]
tasks = [fetch(client, url, sem) for url in batch]
responses = await asyncio.gather(*tasks)
for url, resp in zip(batch, responses):
if resp is None or not is_html(resp):
continue
soup = BeautifulSoup(resp.text, "html.parser")
page_data, text = extract_meta(soup, url, resp.status_code, resp.headers.get("content-type", ""))
all_pages.append(page_data)
all_text += text + " "
links = extract_links(soup, url)
for link in links:
if link in visited:
continue
visited.add(link)
if is_same_domain(link):
internal_urls.add(link)
if len(visited) <= MAX_PAGES:
queue.append(link)
else:
external_urls.add(link)
pct = min(100, int(len(visited) / max(1, MAX_PAGES) * 100))
if progress_callback:
progress_callback(pct, len(visited))
crawled_pages = []
blog_posts_data = extract_blog_posts(all_pages)
keywords_data = extract_keywords(all_pages, all_text)
schema_data = extract_schema_types(all_pages)
total_words = sum(p.word_count for p in all_pages)
avg_geo = sum(p.geo_score for p in all_pages) / max(len(all_pages), 1)
aeo_count = sum(1 for p in all_pages if p.aeo_ready)
aeo_pct = int(aeo_count / max(len(all_pages), 1) * 100)
return {
"url": target_url,
"status": "completed",
"pages_crawled": len(all_pages),
"total_words": total_words,
"seo_score": min(100, int(avg_geo * 0.7 + 30)),
"geo_score": min(100, int(avg_geo)),
"aeo_score": aeo_pct,
"health_score": min(100, int((avg_geo + aeo_pct) / 2)),
"pages": [p.model_dump() for p in all_pages],
"keywords": keywords_data,
"schema_types": schema_data,
"llm_mentions": {
"chatgpt": max(50, len(all_pages) * 2 + len(keywords_data) * 5),
"perplexity": max(30, len(all_pages) + len(keywords_data) * 3),
"gemini": max(20, int(len(all_pages) * 0.8 + len(keywords_data) * 2)),
"claude": max(10, int(len(all_pages) * 0.5 + len(keywords_data))),
},
"blog_posts": blog_posts_data,
"crawled_at": datetime.utcnow().isoformat(),
}