CoolFace
Apppublic

kussssh/IPO-Analyzer

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
scraper.py588 linesDownload Raw Back to backend
1"""2SEBI IPO Document Scraper — Scrapes DRHP, RHP, and Prospectus listings from SEBI.3Cross-references companies across all 3 document types to build a unified IPO tracker.4Filters out addendum, abridged, and corrigendum documents.5Supports FULL PAGINATION — fetches ALL entries across all pages, sorted latest-first.6"""7 8import re9import time10import datetime11import requests12from bs4 import BeautifulSoup13from typing import List, Dict, Optional, Set, Tuple14from urllib.parse import urlencode, urlparse, parse_qs, urlunparse15from pathlib import Path16 17from backend.config import UPLOAD_DIR18 19SEBI_BASE = "https://www.sebi.gov.in"20 21SEBI_LISTING_URLS = {22    "drhp": "https://www.sebi.gov.in/sebiweb/home/HomeAction.do?doListing=yes&sid=3&ssid=15&smid=10",23    "rhp": "https://www.sebi.gov.in/sebiweb/home/HomeAction.do?doListing=yes&sid=3&ssid=15&smid=11",24    "prospectus": "https://www.sebi.gov.in/sebiweb/home/HomeAction.do?doListing=yes&sid=3&ssid=15&smid=12",25}26 27EXCLUDE_KEYWORDS = ["addendum", "abridged", "corrigendum"]28 29NAME_SUFFIXES = [30    " - drhp",31    " - draft red herring prospectus",32    " -drhp",33    " - rhp",34    " - red herring prospectus",35    " -rhp",36    " - prospectus",37    " -prospectus",38    " - draft abridged prospectus",39    " drhp",40    " rhp",41]42 43HEADERS = {44    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "45    "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",46    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",47}48 49MAX_PAGES = 500  # Cap increased for full coverage50 51 52# ─────────────────────────────────────────────53# Helpers54# ─────────────────────────────────────────────55 56 57def normalize_company_name(name: str) -> str:58    n = name.strip().lower()59    for suffix in NAME_SUFFIXES:60        if n.endswith(suffix):61            n = n[: -len(suffix)]62    n = re.sub(r"\s*(limited|ltd\.?)\s*$", "", n)63    n = re.sub(r"[^\w\s]", " ", n)64    n = re.sub(r"\s+", " ", n).strip()65    return n66 67 68def _parse_date(date_str: str) -> str:69    """Return a YYYY-MM-DD sortable string from various SEBI date formats."""70    if not date_str:71        return "0000-00-00"72    for fmt in ("%b %d, %Y", "%d-%b-%Y", "%d/%m/%Y", "%B %d, %Y", "%d %b %Y"):73        try:74            return datetime.datetime.strptime(date_str.strip(), fmt).strftime(75                "%Y-%m-%d"76            )77        except ValueError:78            pass79    return date_str  # return raw if unparseable80 81 82def _absolute_url(href: str) -> str:83    if href.startswith("http"):84        return href85    return SEBI_BASE + ("" if href.startswith("/") else "/") + href86 87 88def _update_query_param(url: str, key: str, value: str) -> str:89    parsed = urlparse(url)90    params = parse_qs(parsed.query)91    params[key] = [value]92    return urlunparse(parsed._replace(query=urlencode(params, doseq=True)))93 94 95def _page_signature(entries: List[Dict]) -> Tuple:96    """Fingerprint top-5 URLs to detect duplicate/repeated pages."""97    return tuple(e["detail_url"] for e in entries[:5])98 99 100def _extract_total_records(soup: BeautifulSoup) -> Optional[int]:101    text = soup.get_text(" ", strip=True)102    m = re.search(r"\d+\s+to\s+\d+\s+of\s+([\d,]+)\s+records?", text, re.IGNORECASE)103    if m:104        return int(m.group(1).replace(",", ""))105    m = re.search(r"total\s*:?\s*([\d,]+)", text, re.IGNORECASE)106    if m:107        return int(m.group(1).replace(",", ""))108    return None109 110 111def _parse_table_entries(soup: BeautifulSoup) -> List[Dict]:112    """Parse one page's table into a list of entry dicts."""113    table = soup.find("table", class_="table")114    if not table:115        tables = soup.find_all("table")116        table = tables[0] if tables else None117    if not table:118        return _fallback_link_entries(soup)119 120    entries = []121    for row in table.find_all("tr"):122        cells = row.find_all("td")123        if len(cells) < 2:124            continue125 126        date_str = cells[0].get_text(strip=True)127        title_cell = cells[1]128        links = title_cell.find_all("a", href=True)129        if not links:130            continue131 132        first_link = links[0]133        name = first_link.get_text(strip=True)134        href = first_link["href"]135 136        if not name or len(name) < 3:137            continue138        if any(kw in name.lower() for kw in EXCLUDE_KEYWORDS):139            continue140 141        full_url = _absolute_url(href)142        entries.append(143            {144                "name": name,145                "date": date_str,146                "detail_url": full_url,147                "is_pdf": href.lower().endswith(".pdf"),148                "normalized_name": normalize_company_name(name),149            }150        )151    return entries152 153 154def _fallback_link_entries(soup: BeautifulSoup) -> List[Dict]:155    entries = []156    for link in soup.find_all("a", href=True):157        href = link["href"]158        name = link.get_text(strip=True)159        if not name or len(name) < 3:160            continue161        if "javascript:" in href:162            continue163        if not (164            "/filings/" in href or "/sebi_data/" in href or "HomeAction.do" in href165        ):166            continue167        if any(kw in name.lower() for kw in EXCLUDE_KEYWORDS):168            continue169        entries.append(170            {171                "name": name,172                "date": "",173                "detail_url": _absolute_url(href),174                "is_pdf": href.lower().endswith(".pdf"),175                "normalized_name": normalize_company_name(name),176            }177        )178    return entries179 180 181def _try_fetch_page(182    session: requests.Session, page_no: int, doc_type: str183) -> Optional[str]:184    """185    Fetch page N using the actual SEBI AJAX pagination endpoint.186    page_no is 1-indexed.187    """188    ajax_url = "https://www.sebi.gov.in/sebiweb/ajax/home/getnewslistinfo.jsp"189    smid_map = {"drhp": "10", "rhp": "11", "prospectus": "12"}190 191    payload = {192        "nextValue": str(page_no - 1),  # SEBI is 0-indexed internally193        "next": "n",194        "search": "",195        "fromDate": "",196        "toDate": "",197        "deptId": "-1",198        "sid": "3",199        "ssid": "15",200        "smid": smid_map.get(doc_type, "10"),201    }202 203    try:204        resp = session.post(ajax_url, headers=HEADERS, data=payload, timeout=30)205        if resp.ok and len(resp.text) > 500:206            return resp.text207    except Exception as e:208        print(f"Error fetching page {page_no}: {e}")209        pass210 211    return None212 213 214# ─────────────────────────────────────────────215# Main scraper with full pagination216# ─────────────────────────────────────────────217 218 219def scrape_sebi_listing(doc_type: str) -> List[Dict]:220    """221    Scrape ALL pages from a SEBI listing (DRHP / RHP / Prospectus) using AJAX pagination.222    """223    base_url = SEBI_LISTING_URLS.get(doc_type)224    if not base_url:225        print(f"❌ Unknown doc_type: {doc_type}")226        return []227 228    session = requests.Session()229    session.headers.update(HEADERS)230 231    # ── Page 1 ──232    try:233        resp = session.get(base_url, timeout=30)234        resp.raise_for_status()235    except Exception as e:236        print(f"Failed to fetch SEBI {doc_type.upper()} main page: {e}")237        return []238 239    soup1 = BeautifulSoup(resp.text, "html.parser")240    total_records = _extract_total_records(soup1)241 242    # Actually fetch page 1 from AJAX to be consistent243    page1_html = _try_fetch_page(session, 1, doc_type)244    if page1_html:245        page1_entries = _parse_table_entries(BeautifulSoup(page1_html, "html.parser"))246    else:247        page1_entries = _parse_table_entries(soup1)248 249    page_size = max(len(page1_entries), 25)250 251    if total_records and total_records > 0:252        total_pages = min(MAX_PAGES, -(-total_records // page_size))  # ceil division253    else:254        total_pages = MAX_PAGES255 256    print(257        f"[{doc_type.upper()}] Total records detected: {total_records or 'unknown'} | Pages estimated: {total_pages}"258    )259 260    seen_urls: Set[str] = set()261    seen_sigs: Set[Tuple] = set()262    all_entries: List[Dict] = []263 264    # Initial page additions265    sig = _page_signature(page1_entries)266    seen_sigs.add(sig)267    for e in page1_entries:268        if e["detail_url"] not in seen_urls:269            seen_urls.add(e["detail_url"])270            all_entries.append(e)271 272    # ── Pages 2..N ──273    consecutive_empty = 0274    for page_no in range(2, total_pages + 1):275        html = _try_fetch_page(session, page_no, doc_type)276        if not html:277            consecutive_empty += 1278            if consecutive_empty >= 3:279                print(f"  Stopping at page {page_no} due to 3 consecutive empty pages")280                break281            continue282 283        soup = BeautifulSoup(html, "html.parser")284        page_entries = _parse_table_entries(soup)285 286        if not page_entries:287            consecutive_empty += 1288            if consecutive_empty >= 3:289                print(f"  Stopping at page {page_no} due to 3 consecutive empty pages")290                break291            continue292 293        sig = _page_signature(page_entries)294        if sig in seen_sigs:295            print(f"  Page {page_no}: duplicate detected — pagination exhausted")296            break297 298        seen_sigs.add(sig)299        consecutive_empty = 0300        new_count = 0301        for e in page_entries:302            if e["detail_url"] not in seen_urls:303                seen_urls.add(e["detail_url"])304                all_entries.append(e)305                new_count += 1306 307        print(308            f"  Page {page_no}: {len(page_entries)} entries | {new_count} new | "309            f"Running total: {len(all_entries)}"310        )311        time.sleep(0.4)  # polite scraping delay312 313     # ── Sort latest-first ──314    all_entries.sort(key=lambda e: _parse_date(e.get("date", "")), reverse=True)315 316    print(317         f"[{doc_type.upper()}] {len(all_entries)} total entries scraped (all pages, latest first)"318    )319    return all_entries320 321 322# ── Convenience wrappers ──323 324 325def scrape_drhp_list() -> List[Dict]:326    return scrape_sebi_listing("drhp")327 328 329def scrape_rhp_list() -> List[Dict]:330    return scrape_sebi_listing("rhp")331 332 333def scrape_prospectus_list() -> List[Dict]:334    return scrape_sebi_listing("prospectus")335 336 337# ─────────────────────────────────────────────338# IPO Tracker339# ─────────────────────────────────────────────340 341 342def build_ipo_tracker() -> List[Dict]:343    """344    Scrape all 3 SEBI listing pages with full pagination and cross-reference345    companies to build a unified IPO tracker, sorted latest-date-first.346    """347    print("Building IPO Tracker - scraping ALL pages from SEBI...")348 349    drhp_entries = scrape_drhp_list()350    rhp_entries = scrape_rhp_list()351    prospectus_entries = scrape_prospectus_list()352 353    rhp_map: Dict[str, Dict] = {}354    for e in rhp_entries:355        rhp_map.setdefault(e["normalized_name"], e)356 357    prospectus_map: Dict[str, Dict] = {}358    for e in prospectus_entries:359        prospectus_map.setdefault(e["normalized_name"], e)360 361    tracker: List[Dict] = []362    seen: Set[str] = set()363 364    # DRHP-anchored entries365    for drhp in drhp_entries:366        norm = drhp["normalized_name"]367        if norm in seen:368            continue369        seen.add(norm)370 371        rhp = rhp_map.get(norm)372        prosp = prospectus_map.get(norm)373        stage = "prospectus" if prosp else ("rhp" if rhp else "drhp")374 375        latest_date = (prosp or rhp or drhp).get("date", "")376 377        tracker.append(378            {379                "company_name": _clean_display_name(drhp["name"]),380                "normalized_name": norm,381                "drhp": _entry_summary(drhp),382                "rhp": _entry_summary(rhp),383                "prospectus": _entry_summary(prosp),384                "stage": stage,385                "latest_date": latest_date,386            }387        )388 389    # RHP-only entries390    for entry in rhp_entries:391        norm = entry["normalized_name"]392        if norm in seen:393            continue394        seen.add(norm)395        prosp = prospectus_map.get(norm)396        tracker.append(397            {398                "company_name": _clean_display_name(entry["name"]),399                "normalized_name": norm,400                "drhp": None,401                "rhp": _entry_summary(entry),402                "prospectus": _entry_summary(prosp),403                "stage": "prospectus" if prosp else "rhp",404                "latest_date": (prosp or entry).get("date", ""),405            }406        )407 408    # Prospectus-only entries409    for entry in prospectus_entries:410        norm = entry["normalized_name"]411        if norm in seen:412            continue413        seen.add(norm)414        tracker.append(415            {416                "company_name": _clean_display_name(entry["name"]),417                "normalized_name": norm,418                "drhp": None,419                "rhp": None,420                "prospectus": _entry_summary(entry),421                "stage": "prospectus",422                "latest_date": entry.get("date", ""),423            }424        )425 426    # Sort latest-first427    tracker.sort(key=lambda c: _parse_date(c.get("latest_date", "")), reverse=True)428 429    print(f"✅ IPO Tracker: {len(tracker)} unique companies (sorted by latest date)")430    return tracker431 432 433def _clean_display_name(name: str) -> str:434    cleaned = name.strip()435    cleaned = re.sub(436        r"\s*-\s*(DRHP|RHP|Prospectus|Draft\s+Abridged\s+Prospectus)\s*$",437        "",438        cleaned,439        flags=re.IGNORECASE,440    )441    if cleaned == cleaned.upper() and len(cleaned) > 5:442        cleaned = cleaned.title()443    return cleaned.strip()444 445 446def _entry_summary(entry: Optional[Dict]) -> Optional[Dict]:447    if not entry:448        return None449    return {450        "name": entry["name"],451        "date": entry["date"],452        "detail_url": entry["detail_url"],453        "is_pdf": entry["is_pdf"],454    }455 456 457# ─────────────────────────────────────────────458# PDF Discovery & Download459# ─────────────────────────────────────────────460 461 462def find_pdf_on_detail_page(detail_url: str) -> Optional[str]:463    """Navigate to a SEBI detail page and find the embedded PDF link."""464    try:465        resp = requests.get(detail_url, headers=HEADERS, timeout=30)466        resp.raise_for_status()467    except Exception as e:468        print(f"❌ Failed to fetch detail page: {e}")469        return None470 471    raw_html = resp.text472 473    # Strategy 1: iframe ?file= param474    file_param = re.findall(475        r'[?&]file=(https?://[^"\'&\s]+\.pdf)', raw_html, re.IGNORECASE476    )477    if file_param:478        print(f"  ✅ Found PDF via ?file= param")479        return file_param[0]480 481    soup = BeautifulSoup(raw_html, "html.parser")482 483    # Strategy 2: iframe src484    for iframe in soup.find_all("iframe"):485        src = iframe.get("src", "")486        if ".pdf" in src.lower():487            m = re.search(r"[?&]file=(https?://[^&\s]+)", src)488            if m:489                return m.group(1)490            if src.startswith("http"):491                return src492            return SEBI_BASE + src493 494    # Strategy 3: anchor tags495    for a in soup.find_all("a", href=True):496        href = a["href"]497        if ".pdf" in href.lower() and "sebi_data" in href.lower():498            return _absolute_url(href)499 500    # Strategy 4: raw regex in HTML501    pdf_refs = re.findall(502        r'(https?://www\.sebi\.gov\.in/sebi_data/[^"\'&\s]+\.pdf)',503        raw_html,504        re.IGNORECASE,505    )506    if pdf_refs:507        return pdf_refs[0]508 509    print(f"  ❌ Could not find PDF for: {detail_url}")510    return None511 512 513def download_pdf(pdf_url: str, filename: str, max_retries: int = 5) -> str:514    """Download a PDF from SEBI with resumable retry logic. Returns local file path."""515    pdf_path = UPLOAD_DIR / filename516    if pdf_path.exists() and pdf_path.stat().st_size > 10000:517        print(f"📦 PDF already downloaded: {filename}")518        return str(pdf_path)519 520    temp_path = pdf_path.with_suffix(".tmp")521 522    for attempt in range(1, max_retries + 1):523        try:524            # Resume from partial download if available — avoids re-downloading on every retry525            resume_from = temp_path.stat().st_size if temp_path.exists() else 0526            req_headers = {**HEADERS}527            if resume_from > 0:528                req_headers["Range"] = f"bytes={resume_from}-"529                print(f"⬇️ Resuming PDF from {resume_from / 1024 / 1024:.1f} MB (attempt {attempt}/{max_retries}): {pdf_url}")530            else:531                print(f"⬇️ Downloading PDF (attempt {attempt}/{max_retries}): {pdf_url}")532 533            resp = requests.get(pdf_url, headers=req_headers, timeout=300, stream=True)534 535            # Server doesn't support Range — restart fresh536            if resume_from > 0 and resp.status_code == 200:537                resume_from = 0538                temp_path.unlink(missing_ok=True)539            # Range not satisfiable — server says file is already fully sent540            elif resp.status_code == 416:541                if temp_path.exists() and temp_path.stat().st_size > 10000:542                    if pdf_path.exists():543                        pdf_path.unlink()544                    temp_path.rename(pdf_path)545                    return str(pdf_path)546                temp_path.unlink(missing_ok=True)547                resume_from = 0548                continue549 550            resp.raise_for_status()551 552            total_bytes = resume_from553            write_mode = "ab" if resume_from > 0 else "wb"554            with open(temp_path, write_mode) as f:555                for chunk in resp.iter_content(chunk_size=1024 * 1024):  # 1 MB chunks556                    if chunk:557                        f.write(chunk)558                        total_bytes += len(chunk)559 560            if total_bytes < 10000:561                print(f"  ⚠️ File too small ({total_bytes} bytes), retrying...")562                temp_path.unlink(missing_ok=True)563                continue564 565            if pdf_path.exists():566                pdf_path.unlink()567            temp_path.rename(pdf_path)568            print(f"✅ Downloaded: {filename} ({total_bytes / 1024 / 1024:.1f} MB)")569            return str(pdf_path)570 571        except (572            requests.exceptions.ChunkedEncodingError,573            requests.exceptions.ConnectionError,574            requests.exceptions.Timeout,575        ) as e:576            print(f"  ⚠️ Attempt {attempt} failed: {e}")577            if attempt < max_retries:578                wait = min(attempt * 5, 30)579                print(f"  ⏳ Retrying in {wait}s (partial file kept for resume)...")580                time.sleep(wait)581            else:582                temp_path.unlink(missing_ok=True)583                raise Exception(584                    f"Failed to download PDF after {max_retries} attempts: {e}"585                )586 587    raise Exception(f"Failed to download PDF after {max_retries} attempts")588