Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| Extract Timothy Sykes trades from profit.ly | |
| Two-phase approach: | |
| Phase 1: Collect all page URLs | |
| Phase 2: Extract trades from each page | |
| Saves to CSV with resume capability | |
| """ | |
| import argparse | |
| import csv | |
| import logging | |
| import os | |
| import random | |
| import re | |
| import time | |
| from dataclasses import dataclass | |
| import requests | |
| from bs4 import BeautifulSoup | |
| # Configure logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(levelname)s - %(message)s", | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Constants | |
| BASE_URL = "https://profit.ly/user/timothysykes/trades" | |
| CSV_FILE = "data/trades.csv" | |
| PAGE_SIZE = 25 | |
| MAX_RETRIES = 3 | |
| INITIAL_RETRY_DELAY = 5.0 | |
| # Rate limiting (randomized between min/max) | |
| MIN_DELAY = 2.0 | |
| MAX_DELAY = 5.0 | |
| CSV_FIELDS = ["profit", "ticker", "date", "username", "entry_comments", "exit_comments"] | |
| HEADERS = [ | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", | |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", | |
| ] | |
| class Trade: | |
| """Represents a single trade.""" | |
| profit: str | |
| ticker: str | |
| date: str | |
| username: str | |
| entry_comments: str | |
| exit_comments: str | |
| def to_dict(self) -> dict[str, str]: | |
| return { | |
| "profit": self.profit, | |
| "ticker": self.ticker, | |
| "date": self.date, | |
| "username": self.username, | |
| "entry_comments": self.entry_comments, | |
| "exit_comments": self.exit_comments, | |
| } | |
| def get_total_pages(html: str) -> int | None: | |
| """Extract total pages from pagination control.""" | |
| soup = BeautifulSoup(html, "html.parser") | |
| input_elem = soup.find("input", {"id": "paginationInput"}) | |
| if input_elem: | |
| max_val = input_elem.get("max") | |
| if max_val is not None: | |
| try: | |
| return int(str(max_val)) | |
| except (ValueError, TypeError): | |
| pass | |
| return None | |
| def extract_trades(html: str) -> list[Trade]: | |
| """Extract all trades from HTML content using BeautifulSoup.""" | |
| trades = [] | |
| soup = BeautifulSoup(html, "html.parser") | |
| # Find all trade feed bodies | |
| feed_bodies = soup.find_all("section", class_="trade-feed-body") | |
| for feed in feed_bodies: | |
| # Extract profit from next card-header (it comes AFTER the feed body in HTML) | |
| card_header = feed.find_next("div", class_="card-header") | |
| if not card_header: | |
| continue | |
| profit_elem = card_header.find("a", class_=["trade-up", "trade-down"]) | |
| ticker_elem = card_header.find("a", class_="trade-ticker") | |
| if not profit_elem or not ticker_elem: | |
| continue | |
| # Extract ticker and type | |
| ticker = ticker_elem.get_text(strip=True) | |
| profit = profit_elem.get_text(strip=True) | |
| # Extract date | |
| date_elem = feed.find("date") | |
| date = date_elem.get_text(strip=True) if date_elem else "" | |
| # Extract username | |
| username_elem = feed.find("a", class_="name") | |
| username = username_elem.get_text(strip=True) if username_elem else "" | |
| # Extract comments | |
| wall_texts = feed.find_all("p", class_="wall-text") | |
| entry = "" | |
| exit_comments = "" | |
| if wall_texts: | |
| entry_text = wall_texts[0].get_text(strip=True) | |
| entry = re.sub(r"^Entry comments:\s*", "", entry_text).strip() | |
| if len(wall_texts) >= 2: | |
| exit_text = wall_texts[1].get_text(strip=True) | |
| exit_comments = re.sub(r"^Exit comments:\s*", "", exit_text).strip() | |
| trades.append( | |
| Trade( | |
| profit=profit, | |
| ticker=ticker, | |
| date=date, | |
| username=username, | |
| entry_comments=entry, | |
| exit_comments=exit_comments, | |
| ) | |
| ) | |
| return trades | |
| def write_csv_header() -> None: | |
| """Write CSV header if file doesn't exist.""" | |
| os.makedirs(os.path.dirname(CSV_FILE), exist_ok=True) | |
| if not os.path.exists(CSV_FILE): | |
| with open(CSV_FILE, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=CSV_FIELDS) | |
| writer.writeheader() | |
| # Global set to track seen trades (profit+ticker+date) | |
| _seen_trades: set[tuple[str, str, str]] = set() | |
| def load_existing_trades() -> None: | |
| """Load existing trades from CSV into the seen set to avoid duplicates.""" | |
| if os.path.exists(CSV_FILE): | |
| with open(CSV_FILE, newline="", encoding="utf-8") as f: | |
| reader = csv.DictReader(f) | |
| for row in reader: | |
| _seen_trades.add((row["profit"], row["ticker"], row["date"])) | |
| def append_trades(trades: list[Trade]) -> int: | |
| """Append trades to CSV file, skipping duplicates. Returns count appended.""" | |
| count = 0 | |
| with open(CSV_FILE, "a", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=CSV_FIELDS) | |
| for trade in trades: | |
| key = (trade.profit, trade.ticker, trade.date) | |
| if key not in _seen_trades: | |
| _seen_trades.add(key) | |
| writer.writerow(trade.to_dict()) | |
| count += 1 | |
| return count | |
| def fetch_page(session: requests.Session, page: int) -> str: | |
| """Fetch a single page with retry logic.""" | |
| url = f"{BASE_URL}?page={page}&size={PAGE_SIZE}" | |
| ua = random.choice(HEADERS) | |
| session.headers.update({"User-Agent": ua}) | |
| for attempt in range(MAX_RETRIES): | |
| try: | |
| response = session.get(url, timeout=30) | |
| response.raise_for_status() | |
| return response.text | |
| except requests.HTTPError as e: | |
| if e.response.status_code == 429: | |
| delay = min(INITIAL_RETRY_DELAY * (2**attempt), 60) | |
| logger.warning(f"Rate limited (429), waiting {delay}s...") | |
| time.sleep(delay) | |
| elif e.response.status_code >= 500: | |
| delay = INITIAL_RETRY_DELAY * (2**attempt) | |
| logger.warning(f"Server error {e.response.status_code}, retry {attempt + 1}/{MAX_RETRIES}") | |
| time.sleep(delay) | |
| else: | |
| raise | |
| except requests.RequestException as e: | |
| delay = INITIAL_RETRY_DELAY * (2**attempt) | |
| logger.warning(f"Request failed, retry {attempt + 1}/{MAX_RETRIES}: {e}") | |
| time.sleep(delay) | |
| raise RuntimeError(f"Failed to fetch page {page} after {MAX_RETRIES} attempts") | |
| def phase1_collect_urls(output_dir: str = "data", max_pages: int | None = None) -> None: | |
| """Phase 1: Download raw HTML files for all pages.""" | |
| session = requests.Session() | |
| # Fetch first page to get total pages | |
| logger.info("Phase 1: Discovering total pages...") | |
| html = fetch_page(session, 1) | |
| total_pages = get_total_pages(html) | |
| if not total_pages: | |
| logger.error("Could not determine total pages, using fallback detection") | |
| total_pages = 0 | |
| # Limit pages if specified (for testing) | |
| if max_pages is not None: | |
| total_pages = min(total_pages, max_pages) | |
| logger.info(f"Total pages to fetch: {total_pages}") | |
| # Create output directory | |
| os.makedirs(output_dir, exist_ok=True) | |
| # Save first page HTML | |
| with open(os.path.join(output_dir, "page_1.html"), "w", encoding="utf-8") as f: | |
| f.write(html) | |
| logger.info("Saved page 1") | |
| # Fetch remaining pages | |
| for page in range(2, total_pages + 1): | |
| try: | |
| html = fetch_page(session, page) | |
| output_file = os.path.join(output_dir, f"page_{page}.html") | |
| with open(output_file, "w", encoding="utf-8") as f: | |
| f.write(html) | |
| logger.info(f"Saved page {page}/{total_pages}") | |
| # Rate limiting | |
| delay = random.uniform(MIN_DELAY, MAX_DELAY) | |
| time.sleep(delay) | |
| except KeyboardInterrupt: | |
| logger.info(f"Interrupted at page {page}") | |
| break | |
| except Exception as e: | |
| logger.error(f"Error on page {page}: {e}") | |
| time.sleep(10) | |
| logger.info(f"Phase 1 complete: {total_pages} HTML files saved to {output_dir}") | |
| def phase2_extract_trades(input_dir: str = "data") -> None: | |
| """Phase 2: Read saved HTML files and extract trades to CSV.""" | |
| # Initialize CSV | |
| write_csv_header() | |
| load_existing_trades() | |
| # Find all HTML files | |
| html_files = sorted( | |
| [f for f in os.listdir(input_dir) if f.startswith("page_") and f.endswith(".html")], | |
| key=lambda x: int(x.split("_")[1].split(".")[0]), | |
| ) | |
| if not html_files: | |
| logger.error(f"No HTML files found in {input_dir}. Run phase 1 first.") | |
| return | |
| logger.info(f"Phase 2: Processing {len(html_files)} HTML files from {input_dir}") | |
| processed_count = 0 | |
| for html_file in html_files: | |
| try: | |
| file_path = os.path.join(input_dir, html_file) | |
| with open(file_path, encoding="utf-8") as f: | |
| html = f.read() | |
| trades = extract_trades(html) | |
| if trades: | |
| page_num = html_file.replace("page_", "").replace(".html", "") | |
| count = append_trades(trades) | |
| logger.info(f"Page {page_num}: {count} new trades") | |
| processed_count += 1 | |
| except KeyboardInterrupt: | |
| logger.info(f"Interrupted at {html_file}") | |
| break | |
| except Exception as e: | |
| logger.error(f"Error on {html_file}: {e}") | |
| logger.info(f"Phase 2 complete: {processed_count} pages processed") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Extract Timothy Sykes trades") | |
| parser.add_argument( | |
| "phase", | |
| choices=["1", "2", "both"], | |
| default="both", | |
| nargs="?", | |
| help="Phase to run: 1 (collect URLs), 2 (extract trades), both (default)", | |
| ) | |
| parser.add_argument( | |
| "--max-pages", | |
| type=int, | |
| default=None, | |
| help="Maximum pages to fetch in phase 1 (for testing)", | |
| ) | |
| args = parser.parse_args() | |
| if args.phase in ("1", "both"): | |
| phase1_collect_urls(max_pages=args.max_pages) | |
| if args.phase in ("2", "both"): | |
| phase2_extract_trades() | |
| if __name__ == "__main__": | |
| main() | |