A Streamlit dashboard that recommends beauty products based on exact ingredient overlap. Given any product in the dataset, it finds the most ingredient-similar products in the same category — ranked by how many ingredients they share.
Product_Recommender/
├── data/
│ ├── raw/
│ │ ├── sephora_products.csv
│ │ ├── ulta_products.csv
│ │ ├── scraped_brand_slugs.txt # Sephora resume state
│ │ └── ulta_scraped_brand_slugs.txt # Ulta resume state
│ └── processed/
│ ├── sephora_products_clean.csv
│ ├── ulta_products_clean.csv
│ └── combined_products.csv
├── src/
│ ├── scrape_sephora.py
│ ├── scrape_ulta.py
│ ├── data_cleaning.py
│ ├── combine_datasets.py
│ └── app.py
├── requirements.txt
└── README.md
python3 -m venv venv_ingredients
source venv_ingredients/bin/activatepip install -r requirements.txtBoth scrapers use undetected-chromedriver, which requires Google Chrome to be installed on your machine. The driver version is matched automatically.
Run the scripts in order.
Collects product data from sephora.com by crawling brand pages.
python src/scrape_sephora.pyWhat it does:
- Fetches all brand URLs from
/brands-list - Visits each brand page, scrolls to load the full product grid, and collects product URLs
- Visits each product page and extracts: brand, product name, category (from breadcrumb), price, rating, and ingredients
- Ingredients are extracted from the
__NEXT_DATA__JSON blob embedded in the page, with DOM accordion and regex fallbacks - Saves incrementally after every brand — safe to interrupt and resume
- Rotates the browser session every 8 brands and inserts randomised delays to avoid detection
Key config constants (top of file):
| Constant | Default | Description |
|---|---|---|
BRAND_LIMIT |
500 | Max brands to crawl |
PRODUCTS_PER_BRAND |
50 | Max product URLs per brand |
PRODUCT_LIMIT_TOTAL |
50000 | Global product cap |
BRANDS_PER_SESSION |
8 | Browser session rotation frequency |
Resume behaviour: Scraped brand slugs are written to data/raw/scraped_brand_slugs.txt. Already-seen product IDs are loaded from any existing sephora_products*.csv files. Re-running the script skips both.
Output: data/raw/sephora_products.csv
Collects product data from ulta.com by crawling brand pages.
python src/scrape_ulta.pyWhat it does:
- Fetches all brand URLs from
/brand/all - Visits each brand page, scrolls to load the full product grid, and collects product URLs (identified by
pimprodID in the URL) - Visits each product page and extracts: brand, product name, category, price, rating, and ingredients
- Ingredients are extracted from
__NEXT_DATA__first, with DOM and regex fallbacks - Saves incrementally after every brand — safe to interrupt and resume
Key config constants (top of file):
| Constant | Default | Description |
|---|---|---|
BRAND_LIMIT |
5000 | Max brands to crawl |
PRODUCTS_PER_BRAND |
50 | Max product URLs per brand |
PRODUCT_LIMIT_TOTAL |
50000 | Global product cap |
Resume behaviour: Scraped brand slugs are written to data/raw/ulta_scraped_brand_slugs.txt. Already-seen product IDs are loaded from any existing ulta_products.csv file. Re-running the script skips both.
Output: data/raw/ulta_products.csv
Cleans both raw CSVs and writes them to data/processed/.
python src/data_cleaning.pyWhat it does:
- Drops products with missing ingredients
- Fixes mixed line-terminator issues in raw scraped files (reads raw bytes, normalises before parsing)
- Ensures the ingredients field is always quoted correctly (some rows omit quotes despite ingredients containing commas)
- Removes marketing blurbs (e.g.
- Rich in fatty acids, helps nourish dry skin.) - Strips shade/colour-name prefixes (e.g.
01 Always Red - Isododecane, ...) - Normalises multi-line ingredient cells
- Lowercases all ingredient strings
Output: data/processed/sephora_products_clean.csv, data/processed/ulta_products_clean.csv
Merges both cleaned files into a single dataset.
python src/combine_datasets.pyWhat it does:
- Tags each product with its source retailer (
sephora/ulta) - Normalises all 261 raw category names down to ~55 canonical categories
- Uses fuzzy matching (
rapidfuzztoken sort ratio, threshold 85) on brand + product name to identify duplicates across retailers - Keeps the Ulta copy on a match (fuller ingredient lists), drops the Sephora duplicate
Output: data/processed/combined_products.csv
streamlit run src/app.pyThen open http://localhost:8501 in your browser.
- Select a category and search for a product in the sidebar
- The app parses the selected product's ingredient list into a set of individual ingredient names
- Every other product in the same category is scored by exact ingredient overlap — no fuzzy matching or standardisation, ingredient names must match exactly
- Results are ranked by:
- Primary: number of shared ingredients (descending)
- Tiebreak: Jaccard similarity — shared / union — to penalise products that share ingredients only because they have very long lists
- Secondary tiebreak: rating (descending)
- Each result card shows the match percentage, a visual match bar, and the full list of shared ingredients
Up to 20 results are returned, all within the same category as the query product.
- All ingredient matching is case-insensitive (ingredients are lowercased during cleaning)
- Products without ingredients are excluded entirely from the dataset
- The
sourcecolumn in the combined file indicates which retailer the product came from (sephoraorulta) - Both scrapers run with
headless=Falseby default so the browser window is visible; set toTrueinmake_driver()to run headlessly