eesha-search-engine / scripts /lightweight_crawler.py
fuhaddesmond's picture
Phase 1+2: Wikipedia import, BM25 ranking, authority scoring, freshness boosting
c0577e6 verified
Raw
History Blame Contribute Delete
14.6 kB
#!/usr/bin/env python3
"""
Eesha Search - Lightweight Crawler for HF Spaces
=================================================
A simple web crawler that replaces Apache Nutch for single-container deployments.
Fetches URLs, extracts text + links, indexes to OpenSearch.
NOT external search engines. This is our OWN crawler building our OWN index.
"""
import json
import os
import sys
import time
import urllib.request
import urllib.parse
import re
import hashlib
from datetime import datetime
from html.parser import HTMLParser
OPENSEARCH_URL = os.environ.get('OPENSEARCH_URL', 'http://localhost:9200')
OPENSEARCH_INDEX = os.environ.get('OPENSEARCH_INDEX', 'nutch')
MAX_PAGES = int(os.environ.get('CRAWL_MAX_PAGES', '10000'))
MAX_DEPTH = int(os.environ.get('CRAWL_MAX_DEPTH', '4'))
CRAWL_DELAY = float(os.environ.get('CRAWL_DELAY', '0.5'))
USER_AGENT = 'EeshaSearch/1.0 (Eesha Browser Independent Search Crawler; +https://eesha.search)'
# Inlink tracking for authority scoring
INLINK_TRACKER = {} # url -> count of pages linking to it
class PageParser(HTMLParser):
"""Simple HTML parser that extracts title, text, links, images, and meta tags."""
def __init__(self):
super().__init__()
self.title = ''
self.in_title = False
self.text_parts = []
self.links = []
self.images = []
self.description = ''
self.in_meta = False
self.current_tag = None
def handle_starttag(self, tag, attrs):
attrs_dict = dict(attrs)
self.current_tag = tag.lower()
if tag.lower() == 'title':
self.in_title = True
elif tag.lower() == 'a' and 'href' in attrs_dict:
self.links.append(attrs_dict['href'])
elif tag.lower() == 'img' and 'src' in attrs_dict:
self.images.append(attrs_dict['src'])
elif tag.lower() == 'meta':
name = attrs_dict.get('name', attrs_dict.get('property', '')).lower()
if name in ('description', 'og:description'):
self.description = attrs_dict.get('content', '')
elif name in ('og:image', 'thumbnail'):
img_url = attrs_dict.get('content', '')
if img_url:
self.images.append(img_url)
def handle_endtag(self, tag):
if tag.lower() == 'title':
self.in_title = False
def handle_data(self, data):
if self.in_title:
self.title += data
else:
stripped = data.strip()
if stripped:
self.text_parts.append(stripped)
def get_text(self, max_length=5000):
text = ' '.join(self.text_parts)
return text[:max_length]
def is_valid_url(url):
"""Check if URL is crawlable."""
try:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https'):
return False
if not parsed.hostname:
return False
skip = {'localhost', '127.0.0.1', 'facebook.com', 'instagram.com',
'twitter.com', 'x.com', 'tiktok.com', 'linkedin.com'}
if parsed.hostname in skip:
return False
return True
except Exception:
return False
def resolve_url(base_url, href):
"""Resolve a relative URL against a base URL."""
try:
return urllib.parse.urljoin(base_url, href)
except Exception:
return None
def fetch_page(url, timeout=15):
"""Fetch a web page and return its content."""
try:
headers = {'User-Agent': USER_AGENT}
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
content_type = resp.headers.get('Content-Type', '')
if 'text/html' not in content_type and 'text/plain' not in content_type:
return None
return resp.read().decode('utf-8', errors='replace')
except Exception as e:
print(f" [WARN] Failed to fetch {url}: {e}")
return None
def parse_page(html, base_url):
"""Parse HTML and extract structured data."""
parser = PageParser()
try:
parser.feed(html)
except Exception:
pass
# Resolve relative URLs
resolved_links = []
for link in parser.links:
resolved = resolve_url(base_url, link)
if resolved and is_valid_url(resolved):
resolved_links.append(resolved)
resolved_images = []
for img in parser.images:
resolved = resolve_url(base_url, img)
if resolved and resolved.startswith('http'):
resolved_images.append(resolved)
return {
'title': parser.title.strip() or 'Untitled',
'content': parser.get_text(),
'description': parser.description[:500] if parser.description else '',
'url': base_url,
'links': resolved_links[:50],
'images': resolved_images[:20],
'host': urllib.parse.urlparse(base_url).hostname,
'inlink_count': 0, # Updated after crawl by update_inlinks()
'crawlDate': datetime.utcnow().isoformat() + 'Z',
}
def index_document(doc):
"""Index a document to OpenSearch."""
try:
# Use URL hash as document ID for deduplication
doc_id = hashlib.md5(doc['url'].encode()).hexdigest()
data = json.dumps({
'doc': doc,
'doc_as_upsert': True
}).encode('utf-8')
req = urllib.request.Request(
f"{OPENSEARCH_URL}/{OPENSEARCH_INDEX}/_update/{doc_id}",
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
urllib.request.urlopen(req, timeout=10)
return True
except Exception as e:
print(f" [WARN] Failed to index {doc['url']}: {e}")
return False
def create_index():
"""Create the OpenSearch index if it doesn't exist."""
try:
req = urllib.request.Request(
f"{OPENSEARCH_URL}/{OPENSEARCH_INDEX}",
method='HEAD'
)
try:
urllib.request.urlopen(req, timeout=5)
print(f"[OK] Index '{OPENSEARCH_INDEX}' already exists")
return
except urllib.error.HTTPError:
pass
mapping = {
"mappings": {
"properties": {
"title": {"type": "text", "analyzer": "english"},
"url": {"type": "keyword"},
"content": {"type": "text", "analyzer": "english"},
"description": {"type": "text", "analyzer": "english"},
"keywords": {"type": "keyword"},
"images": {"type": "keyword"},
"videos": {"type": "keyword"},
"host": {"type": "keyword"},
"crawlDate": {"type": "date", "format": "strict_date_optional_time||epoch_millis"},
# Autocomplete field - uses edge_ngram for prefix matching
"title_suggest": {
"type": "text",
"analyzer": "edge_ngram_analyzer",
"search_analyzer": "standard"
}
}
},
"settings": {
"analysis": {
"analyzer": {
"edge_ngram_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "edge_ngram_filter"]
}
},
"filter": {
"edge_ngram_filter": {
"type": "edge_ngram",
"min_gram": 2,
"max_gram": 20
}
}
}
}
}
data = json.dumps(mapping).encode('utf-8')
req = urllib.request.Request(
f"{OPENSEARCH_URL}/{OPENSEARCH_INDEX}",
data=data,
headers={'Content-Type': 'application/json'},
method='PUT'
)
urllib.request.urlopen(req, timeout=10)
print(f"[OK] Created index '{OPENSEARCH_INDEX}' with autocomplete mapping")
except Exception as e:
print(f"[WARN] Could not create index: {e}")
def crawl(seed_urls, max_pages=MAX_PAGES, max_depth=MAX_DEPTH):
"""Crawl URLs up to max_pages and max_depth."""
visited = set()
queue = [(url, 0) for url in seed_urls] # (url, depth)
indexed = 0
print(f"\n{'='*60}")
print(f"Eesha Search - Independent Crawler")
print(f"Seeds: {len(seed_urls)} | Max pages: {max_pages} | Max depth: {max_depth}")
print(f"{'='*60}")
while queue and indexed < max_pages:
url, depth = queue.pop(0)
if url in visited or depth > max_depth:
continue
visited.add(url)
if not is_valid_url(url):
continue
print(f" [{indexed+1}/{max_pages}] Fetching (depth {depth}): {url[:80]}...")
html = fetch_page(url)
if not html:
continue
page_data = parse_page(html, url)
# Also populate the autocomplete suggest field
page_data['title_suggest'] = page_data['title']
if index_document(page_data):
indexed += 1
print(f" → Indexed: {page_data['title'][:60]}")
# Track inlinks for authority scoring
for link in page_data['links'][:50]:
INLINK_TRACKER[link] = INLINK_TRACKER.get(link, 0) + 1
# Add discovered links to queue
if depth < max_depth:
for link in page_data['links'][:30]:
if link not in visited:
queue.append((link, depth + 1))
# Respect crawl delay
time.sleep(CRAWL_DELAY)
# Update inlink counts in OpenSearch for authority scoring
update_inlinks()
print(f"\n[DONE] Crawled and indexed {indexed} pages")
return indexed
def update_inlinks():
"""Update inlink_count field in OpenSearch for authority scoring."""
if not INLINK_TRACKER:
return
print(f"\n[INLINKS] Updating authority scores for {len(INLINK_TRACKER)} URLs...")
updated = 0
for url, count in INLINK_TRACKER.items():
if count < 2: # Only update pages with 2+ incoming links (reduces API calls)
continue
try:
doc_id = hashlib.md5(url.encode()).hexdigest()[:16]
data = json.dumps({'doc': {'inlink_count': count}}).encode('utf-8')
req = urllib.request.Request(
f"{OPENSEARCH_URL}/{OPENSEARCH_INDEX}/_update/{doc_id}",
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
urllib.request.urlopen(req, timeout=5)
updated += 1
except Exception:
pass # Document may not exist in our index
print(f"[INLINKS] Updated authority scores for {updated} pages")
def get_seed_urls():
"""Get seed URLs from seed file or use defaults."""
seed_file = os.environ.get('SEED_OUTPUT', '/root/nutch/urls/seed.txt')
urls = []
if os.path.exists(seed_file):
with open(seed_file, 'r') as f:
for line in f:
url = line.strip()
if url and not url.startswith('#') and is_valid_url(url):
urls.append(url)
if not urls:
# Default seed URLs — expanded for aggressive crawling
urls = [
# Reference
'https://en.wikipedia.org/wiki/Main_Page',
'https://en.wikipedia.org/wiki/Portal:Current_events',
'https://www.britannica.com/',
# News
'https://news.ycombinator.com/',
'https://www.bbc.com/news',
'https://www.reuters.com/',
'https://www.theguardian.com/international',
'https://apnews.com/',
'https://www.aljazeera.com/',
# Tech
'https://techcrunch.com/',
'https://arstechnica.com/',
'https://lobste.rs/',
'https://theverge.com/',
'https://www.wired.com/',
'https://9to5linux.com/',
# Science
'https://www.nature.com/',
'https://www.scientificamerican.com/',
'https://arxiv.org/',
'https://www.nasa.gov/',
# Education
'https://www.khanacademy.org/',
'https://www.coursera.org/',
'https://www.edx.org/',
'https://ocw.mit.edu/',
# Programming
'https://github.com/trending',
'https://stackoverflow.com/',
'https://dev.to/',
'https://www.freecodecamp.org/',
# Health
'https://www.who.int/',
'https://www.mayoclinic.org/',
'https://www.webmd.com/',
# Finance
'https://www.reuters.com/business/',
'https://www.bloomberg.com/',
# Culture
'https://www.reddit.com/r/all/',
'https://www.reddit.com/r/science/',
'https://www.reddit.com/r/technology/',
'https://www.reddit.com/r/worldnews/',
# Africa-focused
'https://www.bbc.com/news/world/africa',
'https://www.aljazeera.com/africa/',
'https://allafrica.com/',
'https://techcabal.com/',
]
return urls[:100]
def main():
"""Main entry point."""
single_run = '--once' in sys.argv
# Ensure index exists
create_index()
if single_run:
urls = get_seed_urls()
count = crawl(urls)
print(f"\n[DONE] Single crawl complete. Indexed {count} pages.")
return
# Continuous mode
interval = int(os.environ.get('CRAWL_INTERVAL', '21600')) # 6 hours
print(f"Eesha Search Independent Crawler starting...")
print(f"Crawl interval: {interval}s ({interval//3600}h)")
while True:
try:
urls = get_seed_urls()
crawl(urls)
except Exception as e:
print(f"[ERROR] Crawl cycle failed: {e}")
print(f"\n[NEXT] Sleeping {interval}s until next crawl...")
time.sleep(interval)
if __name__ == '__main__':
main()