from __future__ import annotations import logging import re import threading from datetime import datetime from dataclasses import dataclass from typing import Optional, Tuple from urllib.parse import quote import numpy as np import pandas as pd import requests from bs4 import BeautifulSoup from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry logger = logging.getLogger(__name__) def _browser_headers() -> dict: return { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/125.0.0.0 Safari/537.36" ), "Accept": ( "text/html,application/xhtml+xml,application/xml;" "q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" ), "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Cache-Control": "no-cache", "Pragma": "no-cache", "Referer": "https://www.screener.in/", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", } def _clean_number(text: str) -> float: return float(text.replace(",", "").strip()) @dataclass(frozen=True) class CapThresholds: large: float = 20000.0 mid: float = 5000.0 class _ThreadLocalSessionFactory: def __init__( self, headers: Optional[dict] = None, timeout: Tuple[float, float] = (5.0, 15.0), pool_maxsize: int = 32, prime_homepage: bool = True, ): self._local = threading.local() self.headers = headers or _browser_headers() self.timeout = timeout self.pool_maxsize = pool_maxsize self.prime_homepage = prime_homepage def _build_session(self) -> requests.Session: session = requests.Session() session.headers.update(self.headers) retry = Retry( total=3, connect=3, read=3, backoff_factor=0.35, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=frozenset(["GET"]), raise_on_status=False, ) adapter = HTTPAdapter( max_retries=retry, pool_connections=self.pool_maxsize, pool_maxsize=self.pool_maxsize, ) session.mount("https://", adapter) session.mount("http://", adapter) return session def get(self) -> requests.Session: if not hasattr(self._local, "session"): self._local.session = self._build_session() if self.prime_homepage: try: self._local.session.get( "https://www.screener.in/", timeout=self.timeout, ) except requests.RequestException: pass return self._local.session class ScreenerScraper: def __init__( self, timeout: Tuple[float, float] = (5.0, 15.0), pool_maxsize: int = 32, thresholds: CapThresholds = CapThresholds(), ): self._session_factory = _ThreadLocalSessionFactory( timeout=timeout, pool_maxsize=pool_maxsize, prime_homepage=True, ) self.timeout = timeout self.thresholds = thresholds def _session(self) -> requests.Session: return self._session_factory.get() @staticmethod def _normalize_symbol(symbol: str) -> str: return quote(symbol.strip().upper(), safe="-._~") def _fetch(self, url: str) -> str: session = self._session() response = session.get(url, timeout=self.timeout) if response.status_code == 200: return response.text preview = "" try: preview = response.text[:250].replace("\n", " ").replace("\r", " ") except Exception: preview = "" raise RuntimeError( f"Request failed\n" f"URL: {url}\n" f"Status: {response.status_code}\n" f"Reason: {response.reason}\n" f"Preview: {preview}" ) @staticmethod def _parse_market_cap_crore(html: str) -> Optional[float]: patterns = [ r"Mkt Cap:\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*Crore", r"Market Cap\s*₹\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*Cr\.?", r"Market Cap:\s*₹\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*Cr\.?", r"Market Capitalization.*?₹\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*Cr\.?", ] for pattern in patterns: match = re.search(pattern, html, flags=re.IGNORECASE | re.DOTALL) if match: try: return _clean_number(match.group(1)) except Exception: continue return None def _fetch_screener_html(self, symbol: str, consolidated: bool = True) -> str: s = self._normalize_symbol(symbol) urls = ( [ f"https://www.screener.in/company/{s}/consolidated/", f"https://www.screener.in/company/{s}/", ] if consolidated else [ f"https://www.screener.in/company/{s}/", f"https://www.screener.in/company/{s}/consolidated/", ] ) last_error: Optional[Exception] = None for url in urls: try: html = self._fetch(url) return html except Exception as e: last_error = e raise RuntimeError(f"Failed to fetch Screener page for {symbol}. Last error: {last_error}") from last_error def _fetch_nse_quote_html(self, symbol: str) -> str: s = self._normalize_symbol(symbol) url = f"https://www.nseindia.com/get-quotes/equity?symbol={s}" session = self._session() try: session.get("https://www.nseindia.com/", timeout=self.timeout) except requests.RequestException: pass return self._fetch(url) def get_market_cap_crore(self, symbol: str, consolidated: bool = True) -> Optional[float]: try: html = self._fetch_screener_html(symbol, consolidated=consolidated) cap = self._parse_market_cap_crore(html) if cap is not None: return cap except Exception as e: logger.warning("Screener lookup failed for %s: %s", symbol, e) try: html = self._fetch_nse_quote_html(symbol) cap = self._parse_market_cap_crore(html) if cap is not None: return cap except Exception as e: logger.warning("NSE lookup failed for %s: %s", symbol, e) return None def classify_market_cap(self, market_cap_crore: Optional[float]) -> str: if market_cap_crore is None: return "Unknown" if market_cap_crore >= self.thresholds.large: return "Large Cap" if market_cap_crore >= self.thresholds.mid: return "Mid Cap" return "Small Cap" def get_cap_info(self, symbol: str, consolidated: bool = True) -> dict: cap = self.get_market_cap_crore(symbol, consolidated=consolidated) return { "symbol": symbol.upper().strip(), "market_cap_crore": cap, "market_cap_class": self.classify_market_cap(cap), } # -- Keep original helper methods for get_stock_info -- def _fetch_html(self, ticker: str, consolidated: bool = True) -> str: return self._fetch_screener_html(ticker, consolidated) @staticmethod def _make_soup(html: str): try: return BeautifulSoup(html, "lxml") except Exception: return BeautifulSoup(html, "html.parser") @staticmethod def _clean_text(value: str) -> str: return " ".join(value.split()).replace("₹", "Rs.") def get_stock_info(self, ticker): html = self._fetch_html(ticker, consolidated=True) soup = self._make_soup(html) data = { "ticker": ticker.upper(), "key_metrics": {}, "history": {}, "documents": {}, } top_ratios = soup.find("ul", id="top-ratios") if top_ratios: for li in top_ratios.find_all("li"): n_span = li.find("span", class_="name") v_span = li.find("span", class_="value") if n_span and v_span: name = n_span.get_text(strip=True).replace("₹", "Rs.") val = self._clean_text(v_span.get_text(" ", strip=True)) data["key_metrics"][name] = val sections = { "quarters": "Quarterly Results", "profit-loss": "Profit & Loss", "balance-sheet": "Balance Sheet", "cash-flow": "Cash Flows", "ratios": "Financial Ratios", } for sec_id, sec_name in sections.items(): sec = soup.find("section", id=sec_id) if not sec: continue tbl = sec.find("table") if not tbl: continue thead = tbl.find("thead") headers_list = [th.get_text(" ", strip=True) for th in thead.find_all("th")] if thead else [] tbody = tbl.find("tbody") if not tbody: continue rows = [] for tr in tbody.find_all("tr"): tds = tr.find_all("td") if not tds: continue cols = [td.get_text(" ", strip=True) for td in tds] rname = tr.find("td", class_="text") if rname and cols: cols[0] = rname.get_text(" ", strip=True).replace("+", "").strip() rows.append(cols) if not rows: continue if headers_list: if len(headers_list) == len(rows[0]) - 1: headers_list = ["Metric"] + headers_list elif len(headers_list) == len(rows[0]): headers_list[0] = "Metric" else: headers_list = ["Metric"] + headers_list[1:] data["history"][sec_id] = { "title": sec_name, "headers": headers_list, "rows": rows, } docs = soup.find_all("div", class_="documents") for doc in docs: h3 = doc.find("h3") if not h3: continue sec_name = h3.get_text(" ", strip=True) data["documents"][sec_name] = [] ul = doc.find("ul", class_="list-links") if not ul: continue for li in ul.find_all("li"): text_div = li.find("div") a = li.find("a") date_str = " ".join(text_div.get_text(" ", strip=True).replace("\n", " ").split()) if text_div else "" link_str = a.get_text(" ", strip=True) if a else "" if link_str and date_str.endswith(link_str): date_str = date_str[:-len(link_str)].strip() data["documents"][sec_name].append({"date": date_str, "title": link_str}) return data class FeatureEngineer: @staticmethod def clean_numeric(val): if pd.isna(val): return np.nan if isinstance(val, (int, float, np.number)): return float(val) if not isinstance(val, str): return val val = val.replace(",", "").replace("%", "").replace("₹", "").replace("Rs.", "").strip() if val in ["-", ""]: return np.nan try: return float(val) except ValueError: return val @staticmethod def parse_date(date_str): try: return pd.to_datetime(date_str, format="%b %Y") except Exception: return pd.to_datetime(date_str, errors="coerce") @staticmethod def _section_to_frame(sec_id, sec_data): headers = sec_data.get("headers") or [] rows = sec_data.get("rows") or [] if len(headers) < 2 or not rows: return None df = pd.DataFrame(rows, columns=headers[: len(rows[0])]) if "Metric" not in df.columns: df.columns = ["Metric"] + list(df.columns[1:]) df = df.set_index("Metric").transpose().reset_index().rename(columns={"index": "Date_Str"}) metric_cols = [c for c in df.columns if c != "Date_Str"] if metric_cols: cleaned = df[metric_cols].replace( {",": "", "%": "", "₹": "", "Rs.": ""}, regex=True, ) df[metric_cols] = cleaned.apply(pd.to_numeric, errors="coerce") df["Date"] = df["Date_Str"].map(FeatureEngineer.parse_date) df = df.dropna(subset=["Date"]).sort_values("Date").set_index("Date") df = df.drop(columns=["Date_Str"]) prefix = sec_id.split("-")[0].upper() + "_" df.columns = [f"{prefix}{col}" for col in df.columns] return df def build_features(self, data): ticker = data["ticker"] df_dict = {} for sec_id, sec_data in data["history"].items(): df = self._section_to_frame(sec_id, sec_data) if df is not None and not df.empty: df_dict[sec_id] = df if "quarters" not in df_dict or df_dict["quarters"].empty: print("No quarterly data found.") return None main_df = df_dict["quarters"].copy() for sec_id in ["profit-loss", "balance-sheet", "cash-flow", "ratios"]: if sec_id in df_dict and not df_dict[sec_id].empty: annual_df = df_dict[sec_id].sort_index() main_df = pd.merge_asof( main_df.sort_index(), annual_df, left_index=True, right_index=True, direction="backward", ) announcements = data.get("documents", {}).get("Announcements", []) if announcements: event_dates = [] for ann in announcements: ds = ann.get("date", "") if "20" in ds: parsed = pd.to_datetime(ds, errors="coerce") if pd.notnull(parsed): event_dates.append(parsed) if event_dates: events_series = pd.Series(1, index=pd.DatetimeIndex(event_dates)) quarterly_counts = events_series.resample("Q").sum() main_df["ANN_COUNT"] = 0 for q_date, count in quarterly_counts.items(): valid_idx = main_df.index[main_df.index <= q_date] if len(valid_idx) > 0: main_df.loc[valid_idx[-1], "ANN_COUNT"] += int(count) for k, v in data.get("key_metrics", {}).items(): main_df[f"STATIC_{k.replace(' ', '_')}"] = self.clean_numeric(v) main_df["Ticker"] = ticker if "QUARTERS_Sales" in main_df.columns: main_df["QUARTERS_Sales_YoY_Growth"] = main_df["QUARTERS_Sales"].pct_change(periods=4) * 100 return main_df def run_pipeline(ticker): print(f"[{ticker}] Scraping data...") data = ScreenerScraper().get_stock_info(ticker) print(f"[{ticker}] Engineering features...") df = FeatureEngineer().build_features(data) if df is not None: filename = f"{ticker}_features.parquet" print(f"[{ticker}] Exporting to {filename}...") df.to_parquet(filename, engine="pyarrow", index=True) print("Success!") return filename print("Failed to build features.") return None if __name__ == "__main__": import sys ticker = sys.argv[1] if len(sys.argv) > 1 else "TCS" run_pipeline(ticker)