Initial commit: StockDocs RSS scraper

Carved out from the StockDocs monorepo. Collects financial news
from 60+ outlets via RSS into a structured article corpus.
This commit is contained in:
Jarian Cottingham 2026-08-21 18:33:59 +00:00
commit 724be90e7c
12 changed files with 1644 additions and 0 deletions

90
README.md Normal file
View File

@ -0,0 +1,90 @@
# StockDocs — Scraper
RSS scraper for the StockDocs financial news platform: collects financial news from 60+ outlets (Reuters, Bloomberg, Forbes, FT, WSJ...) and stores articles in a structured directory hierarchy for the processing pipeline.
Part of the StockDocs project family:
| Repo | What it is |
|------|------------|
| [StockDocs](https://git.jarianc.com/jarianc/StockDocs) | Processing core — article server, NLP analysis, embeddings |
| [stockdocs-mcp](https://git.jarianc.com/jarianc/stockdocs-mcp) | MCP server exposing the processed data to LLM clients |
## Project Structure
```
.
├── rss_feeds.json # Configuration file with RSS feed URLs
├── scraper.py # Main scraping logic
├── cron_scraper.py # Scheduled scraping entry point
├── run_scraper.sh # Run helper
├── requirements.txt # Python dependencies
├── dockerfile # Docker configuration
├── dockerfile-selenium # Dockerfile for selenium-based scraping
├── articles/ # Directory where scraped articles are stored
│ ├── Reuters Business News/
│ │ ├── article1.txt
│ │ └── ...
│ └── ...
└── tests/ # Unit tests (article-processing cache)
```
## Features
- **RSS Feed Integration**: Supports multiple financial news sources through RSS feeds
- **Automated Scraping**: Regularly fetches and processes new articles from configured feeds
- **Structured Storage**: Organizes articles in a directory structure by news outlet
- **Duplicate Detection**: Prevents re-processing of already collected articles
- **Caching Mechanism**: Maintains a cache of processed articles to optimize performance
## RSS Feed Sources
The scraper supports 60+ news outlets including:
- Reuters Business News
- Associated Press Business
- Financial Times
- Forbes Real-Time
- Wall Street Journal U.S. Business
- Bloomberg Surveillance Podcast
- CNN Money
- BBC News Business
- And many more...
## Usage
### Running the Scraper
```bash
python scraper.py
```
### Configuration
The scraper can be configured by modifying `rss_feeds.json` to:
- Add new news sources
- Update existing RSS feed URLs
- Remove sources that are no longer active
### Article Storage
Articles are stored in `articles/` directory with the following structure:
```
articles/
└── <News Outlet Name>/
├── article1.txt
├── article2.txt
└── ...
```
Where each article file contains the full text content of that news article.
## Requirements
- Python 3.6+
- Selenium WebDriver (for certain scraping operations)
- Additional dependencies listed in `requirements.txt`
## License
This project is licensed under the MIT License.

488
cron_scraper.py Normal file
View File

@ -0,0 +1,488 @@
#!/usr/bin/env python3
"""
Cron-based scraper for downloading articles from RSS feeds.
This version replaces the infinite while loop with a single execution
that can be scheduled via cron job.
"""
import newspaper
import json
import feedparser
import time
import os
import requests
import logging
import random
from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from concurrent.futures import ThreadPoolExecutor, as_completed
import nltk
from nltk.downloader import Downloader
# Setup logging with timestamps
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# Rotating User-Agents to bypass bot detection (Reuters, etc.)
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
]
def get_random_ua():
return random.choice(USER_AGENTS)
# Robust file path handling - try multiple locations
def get_feed_file_path():
"""Get the RSS feed file path, trying multiple locations."""
possible_paths = [
"./rss_feeds.json", # Current directory
"../rss_feeds.json", # Parent directory
"/app/rss_feeds.json", # Docker path
"./scraper/rss_feeds.json" # Scraper subdirectory
]
for path in possible_paths:
if os.path.exists(path):
print(f"Found feed file at: {path}")
return path
# If no file found, exit the program
print("Error: RSS feed file not found in any expected location")
print("Exiting program...")
exit(1)
# Get the feed file path
FEED_FILE = get_feed_file_path()
# Ensure necessary NLTK resources are downloaded
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.
"""
logger.info(f"Loading RSS feed sources from {feed_file}...")
# Debug: Print current working directory
logger.debug(f"Current working directory: {os.getcwd()}")
try:
with open(feed_file, "r", encoding="utf-8") as f:
data = json.load(f)
logger.info(f"Successfully loaded {feed_file}")
logger.debug(f"Data type: {type(data)}")
if isinstance(data, dict) and "rss_feeds" in data:
logger.info(f"Found rss_feeds section with {len(data['rss_feeds'])} sources")
return data
else:
logger.warning(f"Unexpected data structure. Data keys: {list(data.keys()) if isinstance(data, dict) else 'Not a dict'}")
return {}
except FileNotFoundError:
logger.error(f"{feed_file} not found, returning empty dict.")
return {}
except json.JSONDecodeError as e:
logger.error(f"Error decoding {feed_file}: {e}, returning empty dict.")
return {}
except Exception as e:
logger.error(f"Unexpected error loading {feed_file}: {e}")
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 = []
# Check if rss_feed_sources is a valid dict with rss_feeds key
if not isinstance(rss_feed_sources, dict):
logger.warning(f"rss_feed_sources is not a dict, it's {type(rss_feed_sources)}")
return all_links
if "rss_feeds" not in rss_feed_sources:
logger.warning("rss_feeds key not found in rss_feed_sources")
return all_links
sources = rss_feed_sources["rss_feeds"]
for site, data in sources.items():
logger.info(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:
logger.error(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)
# Only log when a new file is actually created (not cached)
logger.info(f"New article saved: {safe_filename} from {source}")
def get_article_with_selenium(url):
"""
Gets article text using Selenium Firefox driver with proper error handling,
cleanup, and bot-detection evasion.
"""
driver = None
try:
# Configure Firefox options with bot-detection evasion
options = FirefoxOptions()
options.add_argument("--headless")
options.set_preference("dom.ipc.processCount", 1)
options.set_preference("general.useragent.override", get_random_ua())
options.set_preference("permissions.default.image", 2)
options.set_preference("dom.webnotifications.enabled", False)
# Initialize driver with timeout
driver = webdriver.Firefox(options=options)
driver.set_page_load_timeout(30)
# Navigate to URL
driver.get(url)
# Wait for page to load (explicit wait instead of sleep)
try:
WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.TAG_NAME, "body"))
)
except Exception:
pass
time.sleep(random.uniform(1, 3))
html = driver.page_source
# Parse with Newspaper4k
article = newspaper.article(url, input_html=html, language="en")
article.nlp()
logger.info(f"Successfully extracted article with Selenium from {url}")
return article.text
except Exception as e:
logger.error(f"Selenium failed for {url}: {str(e)}")
return ""
finally:
if driver:
try:
driver.quit()
except Exception:
pass
def get_article_with_playwright(url):
"""
Gets article text using Playwright with proper bot-detection evasion.
"""
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, timeout=30000)
context = browser.new_context(
user_agent=get_random_ua(),
viewport={"width": 1920, "height": 1080},
locale="en-US",
timezone_id="America/New_York",
)
page = context.new_page()
page.set_extra_http_headers({
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
})
page.goto(url, wait_until="domcontentloaded", timeout=30000)
time.sleep(random.uniform(2, 4))
html = page.content()
context.close()
browser.close()
article = newspaper.article(url, input_html=html, language="en")
article.nlp()
logger.info(f"Successfully extracted article with Playwright from {url}")
return article.text
except Exception as e:
logger.error(f"Playwright failed for {url}: {str(e)}")
return ""
def pull_article(link, source, title=None, save_to_file=True):
"""
Pulls an article from a given link with fallback mechanisms.
"""
filename = title if title else link
safe_filename = generate_filename_from_url(filename)
# Check if already cached
if os.path.exists(os.path.join("articles", source, safe_filename)):
logger.info(f"Article already cached: {filename}")
with open(
os.path.join("articles", source, safe_filename), "r", encoding="utf-8"
) as f:
return f.read()
# Random delay before fetching to avoid rate-limiting / bot detection
time.sleep(random.uniform(0.5, 2))
text = ""
try:
# Try newspaper4k first with proper User-Agent to bypass bot detection
ua = get_random_ua()
article = newspaper.article(link, browser_user_agent=ua)
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."
)
logger.info(f"Successfully pulled article with newspaper4k from {link}")
except Exception as e:
logger.warning(
f"newspaper4k extraction failed for {link}: {e}, falling back to Playwright."
)
try:
text = get_article_with_playwright(link)
logger.info(f"Successfully pulled article from {link} with Playwright")
if not text or len(text) < 200:
logger.warning("Playwright article too short, falling back to Selenium.")
# Fallback to Selenium with better error handling
text = get_article_with_selenium(link)
logger.info(f"Successfully pulled article from {link} with Selenium")
except Exception as e:
logger.error(f"Playwright failed for {link}: {e}")
# Fallback to Selenium
try:
text = get_article_with_selenium(link)
logger.info(f"Successfully pulled article from {link} with Selenium")
except Exception as e:
logger.error(f"Selenium failed for {link}: {e}")
return ""
if save_to_file:
save_article_to_file(text, filename, source)
return text
def safe_pull_articles(article_list):
"""
Safely pull articles with improved error handling and reduced parallelism.
"""
results = []
errors = []
# Process in smaller batches to reduce resource strain
batch_size = 5
for i in range(0, len(article_list), batch_size):
batch = article_list[i : i + batch_size]
logger.info(f"Processing batch {i // batch_size + 1} with {len(batch)} articles")
# Use ThreadPoolExecutor instead of ProcessPoolExecutor to avoid
# process termination issues with browser automation
with ThreadPoolExecutor(max_workers=3) as executor: # Reduced workers
futures = [
executor.submit(pull_article, link, source, title)
for source, title, link in batch
]
for future in as_completed(futures):
try:
result = future.result(timeout=120) # 2 minute timeout
results.append(result)
except Exception as e:
logger.error(f"Error in pull_article: {e}")
errors.append(e)
# Add a small delay between batches to reduce system load
time.sleep(5)
return results, errors
def gather_new_articles():
"""
Gather list of all newly downloaded articles and format them for webhook.
"""
new_articles = []
# Walk through all article directories
for root, dirs, files in os.walk("articles"):
for file in files:
if file != "processed_articles_cache.json": # Skip cache file
# Get the full file path
file_path = os.path.join(root, file)
# Get the outlet name from the directory path
outlet = os.path.basename(root)
# Create the relative path for the article
relative_path = os.path.relpath(file_path, "scraper")
# Create article data structure
article_data = {
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S.%f", time.localtime(os.path.getctime(file_path))),
"name": file,
"outlet": outlet,
"path": f"../{relative_path}"
}
new_articles.append(article_data)
return new_articles
def send_to_webhook(articles):
"""
Send list of articles to the webhook URL.
"""
webhook_url = "http://agents.example.com/webhook/49c5b169-c68c-4f8c-90c2-0fcca6e2d387"
headers = {
"StockDocsN8NAuthToken": "ganvT4gsgRjWpGE8FMw9uCzFjZrTx8RZCoVm2Dh7skbZecov"
}
try:
response = requests.post(webhook_url, json=articles, headers=headers, timeout=30)
if response.status_code == 200:
print(f"Successfully sent {len(articles)} articles to webhook")
else:
print(f"Webhook request failed with status code: {response.status_code}")
print(f"Response: {response.text}")
except Exception as e:
print(f"Error sending to webhook: {e}")
def main():
"""
Main scraping function for cron execution.
This replaces the infinite while loop with a single execution.
"""
logger.info("=========================================")
logger.info("Starting new scraping iteration...")
try:
# 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)
logger.info(f"Found {len(rss_feed_links)} articles to process")
if not rss_feed_links:
logger.info("No articles found")
return
# Process articles with better error handling and resource management
results, errors = safe_pull_articles(rss_feed_links)
logger.info(f"Attempted to Pull {len(results)} articles in parallel.")
logger.info(
f"Encountered {len(errors)} errors during article pulling. "
+ "Outputting errors to a local file."
)
# Output 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")
logger.info("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")
logger.info("All articles pulled successfully.")
# Gather and send new articles to webhook
new_articles = gather_new_articles()
if new_articles:
send_to_webhook(new_articles)
else:
logger.info("No new articles to send to webhook")
except Exception as e:
logger.error(f"Major error in main execution: {e}")
import traceback
traceback.print_exc()
raise # Re-raise to ensure the script exits with error code
logger.info("Scraping completed successfully.")
if __name__ == "__main__":
main()

20
dockerfile Normal file
View File

@ -0,0 +1,20 @@
# --- Dockerfile.local ---
FROM python:3.13.5
# Firefox + GeckoDriver
RUN apt-get update && apt-get install -y --no-install-recommends \
firefox-esr wget ca-certificates gnupg2 \
&& GECKO=v0.36.0 && \
wget -qO- "https://github.com/mozilla/geckodriver/releases/download/${GECKO}/geckodriver-${GECKO}-linux64.tar.gz" \
| tar -xz -C /usr/local/bin geckodriver \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install
RUN playwright install-deps
RUN python3 -m nltk.downloader punkt_tab # Download NLTK data and bake into image
COPY . .
CMD ["python3", "scraper.py"]

22
dockerfile-selenium Normal file
View File

@ -0,0 +1,22 @@
# Use an official Selenium Firefox standalone as a base image
FROM selenium/standalone-firefox:latest
USER root
# Set the working directory in the container
WORKDIR /app
# Install Python dependencies
COPY requirements.txt /app/
RUN pip install --break-system-packages --no-cache-dir -r requirements.txt && \
playwright install
# Copy the current directory contents into the container at /app
COPY . /app
RUN mkdir -p /app/articles && \
chown seluser:seluser /app/articles && \
chmod 755 /app/articles
USER seluser
# Run the Flask app
CMD ["python3", "scraper.py"]

43
requirements.txt Normal file
View File

@ -0,0 +1,43 @@
attrs
beautifulsoup4
certifi
charset-normalizer
click
dnspython
feedparser
filelock
gnews
greenlet
h11
idna
joblib
lxml
lxml-html-clean
newspaper4k
nltk
numpy
outcome
pandas
pillow
playwright
pyee
pysocks
python-dateutil
pytz
pyyaml
regex
requests
requests-file
selenium
sgmllib3k
six
sniffio
sortedcontainers
soupsieve
tldextract
tqdm
trio
trio-websocket
tzdata
websocket-client
wsproto

43
requirements_clean.txt Normal file
View File

@ -0,0 +1,43 @@
attrs
beautifulsoup4
certifi
charset-normalizer
click
dnspython
feedparser
filelock
gnews
greenlet
h11
idna
joblib
lxml
lxml-html-clean
newspaper4k
nltk
numpy
outcome
pandas
pillow
playwright
pyee
pysocks
python-dateutil
pytz
pyyaml
regex
requests
requests-file
selenium
sgmllib3k
six
sniffio
sortedcontainers
soupsieve
tldextract
tqdm
trio
trio-websocket
tzdata
websocket-client
wsproto

196
rss_feeds.json Normal file
View File

@ -0,0 +1,196 @@
{
"rss_feeds": {
"Reuters Business News": {
"source_website": "reuters.com",
"rss_url": "https://www.reutersagency.com/feed/"
},
"Associated Press Business": {
"source_website": "apnews.com",
"rss_url": "https://rsshub.app/apnews/topics/apf-topnews"
},
"Financial Times": {
"source_website": "ft.com",
"rss_url": "https://www.ft.com/rss/home"
},
"Fortune Top Stories": {
"source_website": "fortune.com",
"rss_url": "https://fortune.com/feed/fortune-feeds/?id=3230629"
},
"Seeking Alpha Market News": {
"source_website": "seekingalpha.com",
"rss_url": "https://seekingalpha.com/feed.xml"
},
"The Motley Fool Stock News & Analysis": {
"source_website": "fool.com",
"rss_url": "https://www.fool.com/a/feeds/partner/googlechromefollow?apikey=5e092c1f-c5f9-4428-9219-908a47d2e2de"
},
"Business Standard Latest News": {
"source_website": "business-standard.com",
"rss_url": "https://www.business-standard.com/rss/latest.rss"
},
"TheStreet Full Articles": {
"source_website": "thestreet.com",
"rss_url": "https://www.thestreet.com/.rss/full"
},
"Benzinga Financial News": {
"source_website": "benzinga.com",
"rss_url": "https://feeds.benzinga.com/benzinga"
},
"MarketBeat Market News": {
"source_website": "marketbeat.com",
"rss_url": "https://www.marketbeat.com/feed/"
},
"Money (Time) Personal Finance": {
"source_website": "money.com",
"rss_url": "https://money.com/money/feed/"
},
"Global Finance Magazine": {
"source_website": "gfmag.com",
"rss_url": "https://www.gfmag.com/feed"
},
"Financial Samurai": {
"source_website": "financialsamurai.com",
"rss_url": "https://www.financialsamurai.com/feed/"
},
"MoneyWeek": {
"source_website": "moneyweek.com",
"rss_url": "https://moneyweek.com/feed/all"
},
"Finance Monthly": {
"source_website": "finance-monthly.com",
"rss_url": "https://www.finance-monthly.com/feed/"
},
"European Financial Review": {
"source_website": "europeanfinancialreview.com",
"rss_url": "https://www.europeanfinancialreview.com/feed"
},
"Money Morning": {
"source_website": "moneymorning.com",
"rss_url": "https://moneymorning.com/feed"
},
"Dealbreaker": {
"source_website": "dealbreaker.com",
"rss_url": "https://dealbreaker.com/.rss/full"
},
"World Finance": {
"source_website": "worldfinance.com",
"rss_url": "https://www.worldfinance.com/feed"
},
"Fox Business Headlines": {
"source_website": "foxbusiness.com",
"rss_url": "https://moxie.foxbusiness.com/google-publisher/latest.xml"
},
"FinanceAsia": {
"source_website": "financeasia.com",
"rss_url": "https://www.financeasia.com/rss/latest"
},
"CNBC Business": {
"source_website": "cnbc.com",
"rss_url": "https://www.cnbc.com/id/100003114/device/rss/rss.html"
},
"CNN Money": {
"source_website": "cnn.com",
"rss_url": "http://rss.cnn.com/rss/money_topstories.rss"
},
"Markets Insider": {
"source_website": "markets.businessinsider.com",
"rss_url": "https://markets.businessinsider.com/rss/news"
},
"The Economist Business & Finance": {
"source_website": "economist.com",
"rss_url": "https://www.economist.com/business/rss.xml"
},
"Barchart News": {
"source_website": "barchart.com",
"rss_url": "http://feeds.feedburner.com/BarchartNews"
},
"The Guardian Business": {
"source_website": "theguardian.com",
"rss_url": "http://feeds.theguardian.com/theguardian/uk/business/rss"
},
"Economy Watch": {
"source_website": "economywatch.com",
"rss_url": "https://www.economywatch.com/feed"
},
"CFI.co": {
"source_website": "cfi.co",
"rss_url": "https://cfi.co/feed"
},
"BBC News Business": {
"source_website": "bbc.co.uk",
"rss_url": "http://feeds.bbci.co.uk/news/business/rss.xml"
},
"Investors Business Daily": {
"source_website": "investors.com",
"rss_url": "https://www.investors.com/feed/"
},
"Forbes Real-Time": {
"source_website": "forbes.com",
"rss_url": "https://www.forbes.com/real-time/feed2/"
},
"The Financial Express": {
"source_website": "financialexpress.com",
"rss_url": "https://www.financialexpress.com/feed/"
},
"MarketWatch Top Stories": {
"source_website": "marketwatch.com",
"rss_url": "http://feeds.marketwatch.com/marketwatch/topstories/"
},
"Wall Street Journal U.S. Business": {
"source_website": "wsj.com",
"rss_url": "https://feeds.a.dj.com/rss/WSJcomUSBusiness.xml"
},
"Sky News Business": {
"source_website": "news.sky.com",
"rss_url": "http://news.sky.com/feeds/rss/business.xml"
},
"Bloomberg Surveillance Podcast": {
"source_website": "bloomberg.com",
"rss_url": "https://www.bloomberg.com/feed/podcast/bloomberg-surveillance.xml"
},
"Barrons Markets": {
"source_website": "barrons.com",
"rss_url": "https://www.barrons.com/xml/rss/markets.xml"
},
"Yahoo Finance": {
"source_website": "finance.yahoo.com",
"rss_url": "https://www.yahoo.com/news/rss/finance"
},
"Investing.com News": {
"source_website": "investing.com",
"rss_url": "https://www.investing.com/rss/news.rss"
},
"Investopedia Headlines": {
"source_website": "investopedia.com",
"rss_url": "https://www.investopedia.com/feedbuilder/feed/getfeed/?feedName=rss_headline"
},
"NerdWallet Finance": {
"source_website": "nerdwallet.com",
"rss_url": "https://www.nerdwallet.com/news/finance/feed"
},
"Newsmax Finance": {
"source_website": "newsmax.com",
"rss_url": "https://www.newsmax.com/rss/finance"
},
"Bankrate News": {
"source_website": "bankrate.com",
"rss_url": "https://www.bankrate.com/rss/"
},
"Morningstar Articles": {
"source_website": "morningstar.com",
"rss_url": "https://www.morningstar.com/articles.rss"
},
"Kiplinger": {
"source_website": "kiplinger.com",
"rss_url": "https://www.kiplinger.com/kiplinger.rss"
},
"International Business Times": {
"source_website": "ibtimes.com",
"rss_url": "https://www.ibtimes.com/rss"
},
"Policygenius News": {
"source_website": "policygenius.com",
"rss_url": "https://www.policygenius.com/news/feed/"
}
}
}

8
rss_short_feed.json Normal file
View File

@ -0,0 +1,8 @@
{
"rss_feeds": {
"Associated Press Business": {
"source_website": "apnews.com",
"rss_url": "https://news.google.com/rss/search?q=site:apnews.com&hl=en-US&gl=US&ceid=US:en"
}
}
}

14
run_scraper.sh Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------
# run_scraper.sh
#
# Activates the venv *implicitly* by calling the venvs Python binary.
# ------------------------------------------------------------------
# Absolute path to the venv change only if you move the venv.
VENV_DIR="/home/user/StockDocs/scraper/venv"
# Absolute path to the script you want to run.
SCRIPT="/home/user/StockDocs/scraper/cron_scraper.py"
# Invoke the venvs Python directly.
"${VENV_DIR}/bin/python" "${SCRIPT}" # output is redirected by cron

568
scraper.py Normal file
View File

@ -0,0 +1,568 @@
import newspaper
import json
import feedparser
import time
import os
import logging
import random
from datetime import datetime
from selenium import webdriver
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
import nltk
from nltk.downloader import Downloader
# Setup logging with timestamps
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
FEED_FILE = os.getenv("FEED_FILE", "./rss_short_feed.json")
MAX_FEED_WORKERS = int(os.getenv("MAX_FEED_WORKERS", "10"))
MAX_ARTICLE_WORKERS = int(os.getenv("MAX_ARTICLE_WORKERS", "10"))
BATCH_SIZE = int(os.getenv("BATCH_SIZE", "50")) # Batch processing size
# Rotating User-Agents to bypass bot detection (Reuters, etc.)
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
]
def get_random_ua():
return random.choice(USER_AGENTS)
# Ensure necessary NLTK resources are downloaded
d = Downloader()
if not d.is_installed("punkt_tab"):
nltk.download("punkt_tab")
articles = []
# Enhanced cache system
def load_processed_cache():
"""Load the processed articles cache with enhanced tracking"""
cache_path = "articles/processed_articles_cache.json"
try:
if os.path.exists(cache_path):
with open(cache_path, 'r', encoding='utf-8') as f:
return json.load(f)
else:
return {}
except Exception as e:
logger.error(f"Error loading cache: {e}")
return {}
def save_processed_cache(cache_data):
"""Save the processed articles cache with enhanced tracking"""
cache_path = "articles/processed_articles_cache.json"
try:
with open(cache_path, 'w', encoding='utf-8') as f:
json.dump(cache_data, f, indent=2, ensure_ascii=False)
logger.info(f"Cache saved with {len(cache_data)} entries")
except Exception as e:
logger.error(f"Error saving cache: {e}")
def is_article_processed(article_path, cache_data):
"""Check if an article has been processed"""
return article_path in cache_data
def mark_article_processed(article_path, status="completed", embedding_status="pending"):
"""Mark an article as processed with detailed status tracking"""
cache_data = load_processed_cache()
cache_data[article_path] = {
"processed_date": datetime.now().isoformat(),
"status": status,
"embedding_status": embedding_status,
"last_updated": datetime.now().isoformat()
}
save_processed_cache(cache_data)
def get_processing_progress():
"""Get overall processing progress"""
cache_data = load_processed_cache()
total_articles = len(cache_data)
completed_articles = sum(1 for data in cache_data.values() if data.get('status') == 'completed')
embedded_articles = sum(1 for data in cache_data.values() if data.get('embedding_status') == 'completed')
return {
"total_articles": total_articles,
"completed_articles": completed_articles,
"embedded_articles": embedded_articles,
"completion_rate": (completed_articles / total_articles * 100) if total_articles > 0 else 0
}
def load_rss_feed_sources(feed_file=FEED_FILE):
"""
Loads the RSS feed sources from a JSON file.
"""
logger.info(f"Loading RSS feed sources from {feed_file}...")
try:
with open(feed_file, "r", encoding="utf-8") as f:
data = json.load(f)
logger.info(f"Successfully loaded {feed_file}")
return data
except FileNotFoundError:
logger.error(f"{feed_file} not found, returning empty list.")
return []
except json.JSONDecodeError:
logger.error(f"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 = []
# Check if rss_feed_sources is a valid dict with rss_feeds key
if not isinstance(rss_feed_sources, dict):
logger.warning(f"rss_feed_sources is not a dict, it's {type(rss_feed_sources)}")
return all_links
if "rss_feeds" not in rss_feed_sources:
logger.warning("rss_feeds key not found in rss_feed_sources")
return all_links
sources = rss_feed_sources["rss_feeds"]
# Parse RSS feeds in parallel for better performance
def parse_single_feed(site, data):
"""Parse a single RSS feed with timeout and error handling"""
try:
logger.info(f"Parsing RSS feed: {data['rss_url']}")
# Add more aggressive timeout settings with fallback
# Use a wrapper to ensure we don't hang indefinitely
import signal
def timeout_handler(signum, frame):
raise TimeoutError(f"Timeout parsing feed: {site}")
# Set up signal-based timeout (this is a fallback for truly hanging requests)
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(10) # 10 second alarm
feed = feedparser.parse(data["rss_url"], timeout=8) # 8 second timeout
signal.alarm(0) # Cancel the alarm
signal.signal(signal.SIGALRM, old_handler)
feed_entries = feed.entries[:limit] if limit else feed.entries
entries = []
for entry in feed_entries:
if "link" in entry and "title" in entry:
entries.append((site, entry.title, entry.link))
return entries
except TimeoutError as e:
logger.error(f"Timeout parsing RSS feed: {site} Error: {str(e)}")
return []
except Exception as e:
error_str = str(e).lower()
# Handle specific network connection issues
if "remote end closed connection" in error_str or "connection closed" in error_str:
logger.warning(f"Network connection closed by remote end for feed: {site} - {str(e)}")
logger.info(f"Skipping problematic feed: {site}")
return []
elif "timeout" in error_str:
logger.error(f"Timeout parsing RSS feed: {site} Error: {str(e)}")
return []
else:
logger.error(f"Error parsing RSS feed: {site} Error: {str(e)}")
return []
# Use ThreadPoolExecutor for parallel RSS feed parsing
from concurrent.futures import ThreadPoolExecutor, as_completed
max_workers = min(10, len(sources)) # Limit concurrent workers
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all feed parsing tasks
future_to_site = {
executor.submit(parse_single_feed, site, data): site
for site, data in sources.items()
}
# Collect results as they complete
for future in as_completed(future_to_site, timeout=30): # 30 second overall timeout
try:
entries = future.result()
all_links.extend(entries)
except Exception as e:
site = future_to_site[future]
logger.error(f"Error processing feed for {site}: {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
filename = url.replace("https://", "").replace("http://", "").replace("/", "_")
# Sanitize filename to remove/replace invalid characters
import re
filename = re.sub(r'[\\/*?:"<>|]', "_", filename)
return filename
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)
# Only log when a new file is actually created (not cached)
logger.info(f"New article saved: {safe_filename} from {source}")
def get_article_with_selenium(url):
"""
Gets article text using Selenium Firefox driver with proper error handling,
cleanup, and bot-detection evasion.
"""
driver = None
try:
# Configure Firefox options with bot-detection evasion
options = FirefoxOptions()
options.add_argument("--headless")
options.set_preference("dom.ipc.processCount", 1)
options.set_preference("general.useragent.override", get_random_ua())
options.set_preference("permissions.default.image", 2) # Block images for speed
options.set_preference("dom.webnotifications.enabled", False)
# Try to initialize driver with explicit path to Firefox
try:
driver = webdriver.Firefox(options=options)
except Exception as e:
# If that fails, try with explicit Firefox path
if "binary is not a firefox executable" in str(e).lower():
logger.info("Attempting to use Firefox at /usr/bin/firefox")
options.binary_location = "/usr/bin/firefox"
driver = webdriver.Firefox(options=options)
else:
raise e
driver.set_page_load_timeout(30) # 30 seconds timeout
# Navigate to URL
driver.get(url)
# Wait for page to load (explicit wait instead of sleep)
try:
WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.TAG_NAME, "body"))
)
except Exception:
pass # Continue even if wait times out
time.sleep(random.uniform(1, 3)) # Random wait to mimic human behavior
html = driver.page_source
# Parse with Newspaper4k
article = newspaper.article(url, input_html=html, language="en")
article.nlp()
logger.info(f"Successfully extracted article with Selenium from {url}")
return article.text
except Exception as e:
# Check if this is a Firefox binary not found error
error_str = str(e).lower()
if "binary is not a firefox executable" in error_str or "firefox" in error_str:
logger.error(f"Firefox not found or not properly configured for {url}: {str(e)}")
logger.error("Firefox is installed at /usr/bin/firefox but may not be accessible. Check PATH or permissions.")
else:
logger.error(f"Selenium failed for {url}: {str(e)}")
return ""
finally:
# Always quit the driver
if driver:
try:
driver.quit()
except Exception:
pass # Ignore errors in cleanup
def get_article_with_playwright(url):
"""
Gets article text using Playwright with proper bot-detection evasion.
"""
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# Use Chromium with full browser context for UA spoofing
browser = p.chromium.launch(headless=True, timeout=30000)
context = browser.new_context(
user_agent=get_random_ua(),
viewport={"width": 1920, "height": 1080},
locale="en-US",
timezone_id="America/New_York",
)
page = context.new_page()
# Additional headers for legitimacy
page.set_extra_http_headers({
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
})
page.goto(url, wait_until="domcontentloaded", timeout=30000)
# Wait for content to load
time.sleep(random.uniform(2, 4))
html = page.content()
context.close()
browser.close()
# Parse with Newspaper4k
article = newspaper.article(url, input_html=html, language="en")
article.nlp()
logger.info(f"Successfully extracted article with Playwright from {url}")
return article.text
except Exception as e:
logger.error(f"Playwright failed for {url}: {str(e)}")
return ""
def pull_article(link, source, title=None, save_to_file=True):
"""
Pulls an article from a given link with fallback mechanisms.
"""
filename = title if title else link
safe_filename = generate_filename_from_url(filename)
# Check if already cached
if os.path.exists(os.path.join("articles", source, safe_filename)):
logger.info(f"Article already cached: {filename}")
with open(
os.path.join("articles", source, safe_filename), "r", encoding="utf-8"
) as f:
return f.read()
# Random delay before fetching to avoid rate-limiting / bot detection
time.sleep(random.uniform(0.5, 2))
text = ""
try:
# Try newspaper4k first with proper User-Agent to bypass bot detection
ua = get_random_ua()
article = newspaper.article(link, browser_user_agent=ua)
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."
)
logger.info(f"Successfully pulled article with newspaper4k from {link}")
except Exception as e:
logger.warning(
f"newspaper4k extraction failed for {link}: {e}, falling back to Playwright."
)
try:
text = get_article_with_playwright(link)
logger.info(f"Successfully pulled article from {link} with Playwright")
if not text or len(text) < 200:
logger.warning("Playwright article too short, falling back to Selenium.")
# Fallback to Selenium with better error handling
text = get_article_with_selenium(link)
logger.info(f"Successfully pulled article from {link} with Selenium")
except Exception as e:
logger.error(f"Playwright failed for {link}: {e}")
# Fallback to Selenium
try:
text = get_article_with_selenium(link)
logger.info(f"Successfully pulled article from {link} with Selenium")
except Exception as e:
logger.error(f"Selenium failed for {link}: {e}")
return ""
if save_to_file:
save_article_to_file(text, filename, source)
return text
def is_article_downloaded(source, title, link):
"""
Check if an article is already downloaded by checking if the file exists.
"""
# Generate the same filename that would be used for saving
filename = title if title else link
safe_filename = generate_filename_from_url(filename)
# Check if file exists in the articles directory
file_path = os.path.join("articles", source, safe_filename)
return os.path.exists(file_path)
def safe_pull_articles(article_list):
"""
Safely pull articles with improved error handling and increased parallelism.
"""
if not article_list:
return [], []
# Filter out articles that are already downloaded
filtered_article_list = []
total_articles = len(article_list)
for source, title, link in article_list:
if not is_article_downloaded(source, title, link):
filtered_article_list.append((source, title, link))
else:
logger.info(f"Skipping already downloaded article: {title[:50]}... from {source}")
logger.info(f"Filtered out {total_articles - len(filtered_article_list)} articles that were already downloaded")
logger.info(f"Processing {len(filtered_article_list)} remaining articles")
if not filtered_article_list:
logger.info("No new articles to process")
return [], []
results = []
errors = []
# Use ThreadPoolExecutor for parallel article pulling with higher concurrency
# Use configurable worker setting
max_workers = min(MAX_ARTICLE_WORKERS, len(filtered_article_list)) # Cap at configured workers, but don't exceed article count
batch_size = max(1, min(20, len(filtered_article_list) // 4)) # Dynamic batch size
logger.info(f"Starting parallel article pulling with {max_workers} workers and batch size {batch_size}")
# Process all articles in parallel with proper error handling
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all tasks at once for maximum parallelism
futures = [
executor.submit(pull_article, link, source, title)
for source, title, link in filtered_article_list
]
# Collect results as they complete
for i, future in enumerate(as_completed(futures, timeout=300)): # 5 minute timeout total
try:
result = future.result(timeout=120) # 2 minute timeout per article
if result: # Only count non-empty results
results.append(result)
# Log progress every 100 articles with proper batch information
if (i + 1) % 100 == 0:
logger.info(f"Processed {i + 1} articles out of {len(filtered_article_list)}")
except Exception as e:
logger.error(f"Error processing article: {e}")
errors.append(e)
return results, errors
def main():
"""
Main scraping loop.
"""
while True:
logger.info("=========================================")
logger.info("Starting new scraping iteration...")
try:
# 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)
logger.info(f"Found {len(rss_feed_links)} articles to process")
if not rss_feed_links:
logger.info("No articles found, sleeping for 15 minutes")
time.sleep(15 * 60)
continue
# Process articles with better error handling and resource management
results, errors = safe_pull_articles(rss_feed_links)
logger.info(f"Attempted to Pull {len(results)} articles in parallel.")
logger.info(
f"Encountered {len(errors)} errors during article pulling. "
+ "Outputting errors to a local file."
)
# Output 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")
logger.info("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")
logger.info("All articles pulled successfully.")
# Log processing progress
progress = get_processing_progress()
logger.info(f"Processing progress - Total: {progress['total_articles']}, "
f"Completed: {progress['completed_articles']}, "
f"Embedded: {progress['embedded_articles']}, "
f"Completion rate: {progress['completion_rate']:.1f}%")
except Exception as e:
logger.error(f"Major error in main loop: {e}")
# Continue to next iteration even if there's a major error
# Sleep for a while before the next iteration
logger.info("Sleeping for 15 minutes before the next iteration...")
time.sleep(15 * 60) # Sleep for 15 minutes
if __name__ == "__main__":
main()

91
setup_scraper.sh Executable file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------
# setup_scraper.sh
#
# Installs a virtualenv, a tiny wrapper, optional dependencies
# and an hourly cron job *always using the venvs python/pip*.
# ------------------------------------------------------------------
set -euo pipefail
# ---------- Configuration ----------
SCRAPER_DIR="/home/user/StockDocs/scraper"
VENV_DIR="${SCRAPER_DIR}/venv"
SCRIPT_PATH="${SCRAPER_DIR}/cron_scraper.py"
WRAPPER_PATH="${SCRAPER_DIR}/run_scraper.sh"
LOG_PATH="${SCRAPER_DIR}/cron.log"
REQUIREMENTS="${SCRAPER_DIR}/requirements.txt"
# Cron line this will call the wrapper, which in turn calls the
# venvs Python interpreter.
CRON_LINE="0 * * * * ${WRAPPER_PATH} >> ${LOG_PATH} 2>&1"
# ---------- Helper functions ----------
log() { printf '[setup_scraper] %s\n' "$*"; }
error_exit() { printf '[setup_scraper] ERROR: %s\n' "$*" >&2; exit 1; }
# ---------- 1⃣ Create the venv if its missing ----------
if [[ ! -d "$VENV_DIR" ]]; then
log "Creating virtualenvironment at ${VENV_DIR}"
python3 -m venv "$VENV_DIR" || error_exit "Failed to create venv"
else
log "Virtualenvironment already exists at ${VENV_DIR}"
fi
# ---------- 2⃣ Activate the venv *for the rest of this script* ----------
# This changes PATH for the current shell only it does NOT touch the
# system Python. The next line will confirm that were really in the venv.
# shellcheck source=/dev/null
source "${VENV_DIR}/bin/activate"
# Quick sanitycheck: make sure were using the venvs python and pip.
log "Current python: $(python -c 'import sys;print(sys.executable)')"
log "pip version: $(pip --version)"
# ---------- 3⃣ Install dependencies (optional) ----------
if [[ -f "$REQUIREMENTS" ]]; then
log "Installing Python packages from ${REQUIREMENTS}"
# Use the *venvs* pip explicitly this guarantees no system installs.
"${VENV_DIR}/bin/pip" install --upgrade pip
"${VENV_DIR}/bin/pip" install -r "$REQUIREMENTS" || error_exit "pip install failed"
else
log "No requirements.txt found skipping dependency install."
fi
# ---------- 4⃣ Create the wrapper script ----------
# The wrapper *does NOT* source the venv any more it calls the venvs
# Python binary directly. This eliminates the subtle “activate” pitfall.
if [[ ! -f "$WRAPPER_PATH" ]]; then
log "Creating wrapper script at ${WRAPPER_PATH}"
cat > "$WRAPPER_PATH" <<'EOF'
#!/usr/bin/env bash
# ------------------------------------------------------------------
# run_scraper.sh
#
# Activates the venv *implicitly* by calling the venvs Python binary.
# ------------------------------------------------------------------
# Absolute path to the venv change only if you move the venv.
VENV_DIR="/home/user/StockDocs/scraper/venv"
# Absolute path to the script you want to run.
SCRIPT="/home/user/StockDocs/scraper/cron_scraper.py"
# Invoke the venvs Python directly.
"${VENV_DIR}/bin/python" "${SCRIPT}" # output is redirected by cron
EOF
chmod +x "$WRAPPER_PATH"
else
log "Wrapper script already exists at ${WRAPPER_PATH}"
fi
# ---------- 5⃣ Install the cron job ----------
CURRENT_CRON=$(crontab -l 2>/dev/null || true)
if echo "$CURRENT_CRON" | grep -Fqx "$CRON_LINE"; then
log "Crontab entry already present nothing to do."
else
log "Adding new cron entry."
# Append the new line and reinstall the crontab.
(printf '%s\n' "$CURRENT_CRON" ; printf '%s\n' "$CRON_LINE") | crontab -
fi
log "✅ Setup complete! ${SCRIPT_PATH} will run every hour via the venv."

View File

@ -0,0 +1,61 @@
"""Tests for the scraper article-processing cache."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scraper.scraper import ( # noqa: E402
get_processing_progress,
load_processed_cache,
mark_article_processed,
save_processed_cache,
)
@pytest.fixture()
def cache_dir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "articles").mkdir()
return tmp_path
def test_load_missing_cache_returns_empty(cache_dir):
assert load_processed_cache() == {}
def test_save_and_load_roundtrip(cache_dir):
save_processed_cache({"a.html": {"status": "completed"}})
cache = load_processed_cache()
assert cache["a.html"]["status"] == "completed"
def test_mark_article_processed(cache_dir):
mark_article_processed("articles/x/article.html")
cache = load_processed_cache()
entry = cache["articles/x/article.html"]
assert entry["status"] == "completed"
assert entry["embedding_status"] == "pending"
assert "processed_date" in entry
assert "last_updated" in entry
def test_processing_progress_empty(cache_dir):
progress = get_processing_progress()
assert progress["total_articles"] == 0
assert progress["completed_articles"] == 0
assert progress["embedded_articles"] == 0
assert progress["completion_rate"] == 0
def test_processing_progress_mixed(cache_dir):
mark_article_processed("a.html")
mark_article_processed("b.html", status="failed")
mark_article_processed("c.html", embedding_status="completed")
progress = get_processing_progress()
assert progress["total_articles"] == 3
assert progress["completed_articles"] == 2
assert progress["embedded_articles"] == 1
assert progress["completion_rate"] == pytest.approx(2 / 3 * 100)