| |
| """ |
| 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_TRACKER = {} |
|
|
| 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 |
| |
| |
| 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, |
| 'crawlDate': datetime.utcnow().isoformat() + 'Z', |
| } |
|
|
|
|
| def index_document(doc): |
| """Index a document to OpenSearch.""" |
| try: |
| |
| 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"}, |
| |
| "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] |
| 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) |
| |
| |
| page_data['title_suggest'] = page_data['title'] |
| |
| if index_document(page_data): |
| indexed += 1 |
| print(f" → Indexed: {page_data['title'][:60]}") |
| |
| |
| for link in page_data['links'][:50]: |
| INLINK_TRACKER[link] = INLINK_TRACKER.get(link, 0) + 1 |
| |
| |
| if depth < max_depth: |
| for link in page_data['links'][:30]: |
| if link not in visited: |
| queue.append((link, depth + 1)) |
| |
| |
| time.sleep(CRAWL_DELAY) |
| |
| |
| 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: |
| 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 |
| |
| 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: |
| |
| urls = [ |
| |
| 'https://en.wikipedia.org/wiki/Main_Page', |
| 'https://en.wikipedia.org/wiki/Portal:Current_events', |
| 'https://www.britannica.com/', |
| |
| '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/', |
| |
| 'https://techcrunch.com/', |
| 'https://arstechnica.com/', |
| 'https://lobste.rs/', |
| 'https://theverge.com/', |
| 'https://www.wired.com/', |
| 'https://9to5linux.com/', |
| |
| 'https://www.nature.com/', |
| 'https://www.scientificamerican.com/', |
| 'https://arxiv.org/', |
| 'https://www.nasa.gov/', |
| |
| 'https://www.khanacademy.org/', |
| 'https://www.coursera.org/', |
| 'https://www.edx.org/', |
| 'https://ocw.mit.edu/', |
| |
| 'https://github.com/trending', |
| 'https://stackoverflow.com/', |
| 'https://dev.to/', |
| 'https://www.freecodecamp.org/', |
| |
| 'https://www.who.int/', |
| 'https://www.mayoclinic.org/', |
| 'https://www.webmd.com/', |
| |
| 'https://www.reuters.com/business/', |
| 'https://www.bloomberg.com/', |
| |
| 'https://www.reddit.com/r/all/', |
| 'https://www.reddit.com/r/science/', |
| 'https://www.reddit.com/r/technology/', |
| 'https://www.reddit.com/r/worldnews/', |
| |
| '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 |
| |
| |
| create_index() |
| |
| if single_run: |
| urls = get_seed_urls() |
| count = crawl(urls) |
| print(f"\n[DONE] Single crawl complete. Indexed {count} pages.") |
| return |
| |
| |
| interval = int(os.environ.get('CRAWL_INTERVAL', '21600')) |
| 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() |
|
|