StockDocs/scraper/scraper.py
Jarian Cottingham c534c5a159 user/jarian/addingScraperDocker (#2)
This fully implements the Stock Docs Project with full Docker Containerization support. This is a working prototype that is actively running on the Media Server. There's a few issues noted, including the following:
- Support for some sites could be improved. Reuters has many articles behind an adblock and some websites present banners that don't need to be processed by our AI engine
- Some caching could be smarter. As the size of files grows, it will get expensive to search through all files to be sure we've not scraped it, ai proccessed it or embedded it.
- Logging could be improved to be much better than just print statements and telemetry could be sent for dashboard monitoring if this were ever to become a full service where we cared about reliability.
- MCP server has been noted to return some poorly matching results. Would be better if it returned nothing at all. And should never really return banners or ads as that provides awful input for the model. Perhaps the model could be told to not care about this, but it's better to just never show irrelevant info to the model

I think this is an overall really good jumping off point, and we've already gotten to see the max capabilities of our system so far. It's a major win to have the Scraper for instance running at all times getting articles from across the web. I look forward to expending this scraper in the near future for projects like scraping all local news websites in the US or general scraping and monitoring of websites.

Co-authored-by: Jarian Cottingham <jariancottingham@dev-machine.local>
Co-authored-by: jarianc <user@example.com>
Reviewed-on: http://git.example.com/jarianc/StockDocs/pulls/2
2025-07-16 01:06:34 -05:00

230 lines
8.1 KiB
Python

import newspaper
import json
import feedparser
import time
import os
from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from newspaper.google_news import GoogleNewsSource
from concurrent.futures import ProcessPoolExecutor
import nltk
from nltk.downloader import Downloader
FEED_FILE = os.getenv("FEED_FILE")
# Ensure necessary NLTK resources are downloaded / Needed for selenium + newspaper4k article parsing
d = Downloader()
if not d.is_installed('punkt_tab'):
nltk.download('punkt_tab')
articles = []
def load_rss_feed_sources(feed_file=FEED_FILE):
"""
Loads the RSS feed sources from a JSON file.
"""
print("Loading RSS feed sources from rss_feeds.json...")
try:
with open(FEED_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
print(FEED_FILE + " not found, returning empty list.")
return []
except json.JSONDecodeError:
print("Error decoding " + FEED_FILE + " , returning empty list.")
return []
def mine_all_articles(rss_feed_sources, limit=None):
"""
Mines all articles from the given RSS feed sources.
Returns a list of (site, title, link) tuples.
"""
all_links = []
sources = rss_feed_sources['rss_feeds']
for site, data in sources.items():
print(f"Parsing RSS feed: {data['rss_url']}")
try:
feed = feedparser.parse(data["rss_url"])
feed_entries = feed.entries[:limit] if limit else feed.entries
for entry in feed_entries:
if "link" in entry and "title" in entry:
all_links.append((site, entry.title, entry.link))
except Exception as e:
print(f"Error parsing RSS feed: {site} Error: {str(e)}")
return all_links
def generate_filename_from_url(url):
"""
Generates a filename from the given URL by replacing slashes with underscores.
"""
# Use only the last part of the URL or replace slashes
return url.replace("https://", "").replace("http://", "").replace("/", "_")
def generate_safe_filename(name):
# Remove/replace characters not allowed in filenames
import re
safe = re.sub(r'[\\/*?:"<>|]', "_", name)
return safe
def save_article_to_file(article, filename, source="Unfiltered"):
"""
Saves the given article text to a file with the specified filename.
"""
# articles dir should already be there
# os.makedirs("articles", exist_ok=True)
outputDir = "articles/"+source
os.makedirs(outputDir, exist_ok=True) if source else None
# Sanitize filename: use only the last part of the URL or replace slashes
safe_filename = generate_filename_from_url(filename)
file_path = os.path.join(outputDir, safe_filename)
# Save the source as the first line in the file for later retrieval
with open(file_path, "w", encoding="utf-8") as f:
f.write(f"SOURCE:{source}\n")
f.write(article)
print(f"Article saved to {file_path}")
def get_article_with_selenium(url):
options = FirefoxOptions()
options.add_argument("--headless")
driver = webdriver.Firefox(options=options)
driver.set_page_load_timeout(30) # 30 seconds timeout
#driver = webdriver.Remote(
#command_executor='http://localhost:4444/wd/hub',
#options=options)
try:
driver.get(url)
time.sleep(5) # Wait for JS to load
html = driver.page_source
# Parse with Newspaper4k
article = newspaper.article(url, input_html=html, language='en')
article.nlp()
return article.text
finally:
driver.quit()
def get_article_with_playwright(url):
import asyncio
from playwright.sync_api import sync_playwright
from newspaper import Article
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url)
# Optional: wait for specific content to load
time.sleep(5) # Adjust as needed for the page to load completely
html = page.content()
browser.close()
# Parse with Newspaper4k
article = newspaper.article(url, input_html=html, language='en')
article.nlp()
return article.text
def pull_article(link, source, title=None, save_to_file=True):
filename = title if title else link
safe_filename = generate_filename_from_url(filename)
if os.path.exists(os.path.join("articles", source, safe_filename)):
print(f"Article already cached: {filename}")
with open(os.path.join("articles", source, safe_filename), "r", encoding="utf-8") as f:
return f.read()
text = ""
time.sleep(5) # Since we spawn lots of processes, we need to sleep at the start
try:
article = newspaper.article(link)
article.download()
article.parse()
text = article.text
if not text or len(text) < 200:
raise ValueError("\tArticle text too short, falling back to Playwright/Selenium.")
print(f"\tSuccessfully pulled article with newspaper4k from {link}")
except Exception as e:
print(f"\tnewspaper4k extraction failed for {link}: {e}, falling back to Playwright.")
try:
text = get_article_with_playwright(link)
print(f"\t\tSuccessfully pulled article from {link} with Playwright")
if not text or len(text) < 200:
print(f"\t\tPlaywright article too short, falling back to Selenium.")
try:
text = get_article_with_selenium(link)
print(f"\t\t\tSuccessfully pulled article from {link} with Selenium")
except Exception as e:
print(f"\t\t\tSelenium failed for {link}: {e}")
return ""
except Exception as e:
print(f"\t\tPlaywright failed for {link}: {e}")
try:
text = get_article_with_selenium(link)
print(f"\t\tSuccessfully pulled article from {link} with Selenium")
except Exception as e:
print(f"\t\tSelenium failed for {link}: {e}")
return ""
if save_to_file:
save_article_to_file(text, filename, source)
return text
while True:
print("=========================================")
print("Starting new scraping iteration...")
# Pull the RSS feed sources from the JSON file
rss_feed_sources = load_rss_feed_sources()
# Mine all articles from the RSS feed sources
rss_feed_links = mine_all_articles(rss_feed_sources)
# Randomize the order of the links to help with load balancing
import random
random.shuffle(rss_feed_links)
link_list = [link for _, title, link in rss_feed_links]
source_list = [source for source, _, _ in rss_feed_links]
title_list = [title for _, title, link in rss_feed_links]
with ProcessPoolExecutor() as executor:
futures = [executor.submit(pull_article, link, source, title) for link, source, title in zip(link_list, source_list, title_list)]
results = []
errors = []
for future in futures:
try:
results.append(future.result(timeout=60)) # seconds
except Exception as e:
print(f"Error in pull_article: {e}")
errors.append(e)
print(f"Attempted to Pull {len(results)} articles in parallel.")
print(f"Encountered {len(errors)} errors during article pulling. " +\
"Outputting errors to a local file.")
# Ouput errors to a local file
if errors:
with open("errors.txt", "w", encoding="utf-8") as f:
for error in errors:
f.write(str(error) + "\n")
print(f"Errors logged to errors.txt")
# Print all results to a log file
with open("results.txt", "w", encoding="utf-8") as f:
for result in results:
f.write(result + "\n")
print("All articles pulled successfully.")
# Sleep for a while before the next iteration
print("Sleeping for 15 minutes before the next iteration...")
time.sleep(15 * 60) # Sleep for 15 minutes