
harvard-art-museums-etl-analytics
Build ETL pipelines and analytics dashboards using Harvard Art Museums API with Python, SQL, and Streamlit
- Installs
- 2K
- Last 24h
- —
- Trending rank
- —
- Security
- Not audited
Install
Works with every agent the skills CLI supports.
npx skills add https://github.com/reason-machines/data-skills --skill harvard-art-museums-etl-analytics- 01
Install
Run the command in your project. The CLI asks which agents to install for.
- 02
Describe the task
Ask your agent for something this skill covers. It loads harvard-art-museums-etl-analytics automatically when relevant.
- 03
Review the result
The agent follows the SKILL.md steps, scripts and references bundled with the skill.
SKILL.md
The instructions your agent reads when the skill is triggered.
Harvard Art Museums ETL Analytics
Skill by ara.so — Data Skills collection.
This skill enables AI coding agents to build end-to-end data engineering and analytics applications using the Harvard Art Museums API. The project demonstrates real-world ETL pipelines, SQL analytics, and interactive data visualization using Streamlit.
What This Project Does
The Harvard-Artifacts-Collection-Data-Engineering-Analytics-App provides:
- API Integration: Collect artifact data from Harvard Art Museums API with pagination and rate limiting
- ETL Pipeline: Extract, transform, and load artifact metadata, media details, and color data
- SQL Storage: Store structured data in MySQL/TiDB Cloud with proper relational schema
- Analytics Engine: Execute 20+ predefined analytical SQL queries
- Interactive Dashboards: Visualize query results using Streamlit and Plotly
The architecture follows: API → ETL → SQL → Analytics → Visualization
Installation
# Clone the repository
git clone https://github.com/Manali0711/Harvard-Artifacts-Collection-Data-Engineering-Analytics-App.git
cd Harvard-Artifacts-Collection-Data-Engineering-Analytics-App
# Install dependencies
pip install -r requirements.txt
# Required packages
pip install streamlit pandas requests mysql-connector-python plotly python-dotenv
Configuration
Environment Variables
Create a .env file in the project root:
# Harvard Art Museums API
HARVARD_API_KEY=your_api_key_here
# Database Configuration
DB_HOST=your_database_host
DB_PORT=3306
DB_USER=your_database_user
DB_PASSWORD=your_database_password
DB_NAME=harvard_artifacts
Database Setup
import mysql.connector
from mysql.connector import Error
def create_database_schema():
"""Initialize database schema for Harvard artifacts"""
connection = mysql.connector.connect(
host=os.getenv('DB_HOST'),
port=os.getenv('DB_PORT'),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD')
)
cursor = connection.cursor()
# Create database
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {os.getenv('DB_NAME')}")
cursor.execute(f"USE {os.getenv('DB_NAME')}")
# Create artifact metadata table
cursor.execute("""
CREATE TABLE IF NOT EXISTS artifactmetadata (
id INT PRIMARY KEY,
title VARCHAR(500),
culture VARCHAR(255),
century VARCHAR(100),
classification VARCHAR(255),
department VARCHAR(255),
dated VARCHAR(255),
period VARCHAR(255),
technique VARCHAR(500),
description TEXT,
url VARCHAR(500),
dated_end INT,
dated_begin INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create artifact media table
cursor.execute("""
CREATE TABLE IF NOT EXISTS artifactmedia (
media_id INT AUTO_INCREMENT PRIMARY KEY,
artifact_id INT,
image_url VARCHAR(500),
media_type VARCHAR(100),
FOREIGN KEY (artifact_id) REFERENCES artifactmetadata(id)
)
""")
# Create artifact colors table
cursor.execute("""
CREATE TABLE IF NOT EXISTS artifactcolors (
color_id INT AUTO_INCREMENT PRIMARY KEY,
artifact_id INT,
color_hex VARCHAR(10),
color_percentage FLOAT,
FOREIGN KEY (artifact_id) REFERENCES artifactmetadata(id)
)
""")
connection.commit()
cursor.close()
connection.close()
ETL Pipeline Implementation
Extract: Fetch Data from API
import requests
import os
from typing import List, Dict
def fetch_artifacts(page: int = 1, size: int = 100) -> Dict:
"""
Fetch artifacts from Harvard Art Museums API with pagination
Args:
page: Page number (1-indexed)
size: Number of records per page (max 100)
Returns:
JSON response containing artifact records
"""
api_key = os.getenv('HARVARD_API_KEY')
base_url = "https://api.harvardartmuseums.org/object"
params = {
'apikey': api_key,
'page': page,
'size': size,
'hasimage': 1 # Only fetch artifacts with images
}
response = requests.get(base_url, params=params)
response.raise_for_status()
return response.json()
def extract_all_artifacts(max_records: int = 1000) -> List[Dict]:
"""
Extract multiple pages of artifacts with rate limiting
Args:
max_records: Maximum number of records to fetch
Returns:
List of artifact dictionaries
"""
import time
artifacts = []
page = 1
page_size = 100
while len(artifacts) < max_records:
try:
data = fetch_artifacts(page=page, size=page_size)
if 'records' not in data or len(data['records']) == 0:
break
artifacts.extend(data['records'])
page += 1
# Rate limiting: Harvard API allows 2500 requests/day
time.sleep(0.5)
print(f"Fetched {len(artifacts)} artifacts...")
except requests.exceptions.RequestException as e:
print(f"Error fetching page {page}: {e}")
break
return artifacts[:max_records]
Transform: Clean and Structure Data
import pandas as pd
def transform_artifacts(raw_artifacts: List[Dict]) -> tuple:
"""
Transform raw API data into structured dataframes
Returns:
Tuple of (metadata_df, media_df, colors_df)
"""
metadata_records = []
media_records = []
color_records = []
for artifact in raw_artifacts:
# Extract metadata
metadata = {
'id': artifact.get('id'),
'title': artifact.get('title', ''),
'culture': artifact.get('culture', ''),
'century': artifact.get('century', ''),
'classification': artifact.get('classification', ''),
'department': artifact.get('department', ''),
'dated': artifact.get('dated', ''),
'period': artifact.get('period', ''),
'technique': artifact.get('technique', ''),
'description': artifact.get('description', ''),
'url': artifact.get('url', ''),
'dated_begin': artifact.get('datebegin'),
'dated_end': artifact.get('dateend')
}
metadata_records.append(metadata)
# Extract media/images
if 'images' in artifact and artifact['images']:
for image in artifact['images']:
media_records.append({
'artifact_id': artifact.get('id'),
'image_url': image.get('baseimageurl', ''),
'media_type': 'image'
})
# Extract color data
if 'colors' in artifact and artifact['colors']:
for color in artifact['colors']:
color_records.append({
'artifact_id': artifact.get('id'),
'color_hex': color.get('hex', ''),
'color_percentage': color.get('percent', 0.0)
})
metadata_df = pd.DataFrame(metadata_records)
media_df = pd.DataFrame(media_records)
colors_df = pd.DataFrame(color_records)
return metadata_df, media_df, colors_df
Load: Insert into SQL Database
def load_to_database(metadata_df: pd.DataFrame,
media_df: pd.DataFrame,
colors_df: pd.DataFrame):
"""
Load transformed data into MySQL database using batch inserts
"""
connection = mysql.connector.connect(
host=os.getenv('DB_HOST'),
port=os.getenv('DB_PORT'),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD'),
database=os.getenv('DB_NAME')
)
cursor = connection.cursor()
# Load metadata (batch insert)
metadata_query = """
INSERT INTO artifactmetadata
(id, title, culture, century, classification, department,
dated, period, technique, description, url, dated_begin, dated_end)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
title=VALUES(title), culture=VALUES(culture)
"""
metadata_values = [tuple(row) for row in metadata_df.values]
cursor.executemany(metadata_query, metadata_values)
# Load media
if not media_df.empty:
media_query = """
INSERT INTO artifactmedia (artifact_id, image_url, media_type)
VALUES (%s, %s, %s)
"""
media_values = [tuple(row) for row in media_df.values]
cursor.executemany(media_query, media_values)
# Load colors
if not colors_df.empty:
colors_query = """
INSERT INTO artifactcolors (artifact_id, color_hex, color_percentage)
VALUES (%s, %s, %s)
"""
colors_values = [tuple(row) for row in colors_df.values]
cursor.executemany(colors_query, colors_values)
connection.commit()
cursor.close()
connection.close()
print(f"Loaded {len(metadata_df)} artifacts to database")
Analytics Queries
Sample SQL Queries
ANALYTICS_QUERIES = {
"artifacts_by_culture": """
SELECT culture, COUNT(*) as artifact_count
FROM artifactmetadata
WHERE culture IS NOT NULL AND culture != ''
GROUP BY culture
ORDER BY artifact_count DESC
LIMIT 15
""",
"artifacts_by_century": """
SELECT century, COUNT(*) as artifact_count
FROM artifactmetadata
WHERE century IS NOT NULL AND century != ''
GROUP BY century
ORDER BY artifact_count DESC
""",
"department_distribution": """
SELECT department, COUNT(*) as artifact_count
FROM artifactmetadata
WHERE department IS NOT NULL
GROUP BY department
ORDER BY artifact_count DESC
""",
"top_colors": """
SELECT color_hex, COUNT(*) as usage_count,
AVG(color_percentage) as avg_percentage
FROM artifactcolors
GROUP BY color_hex
ORDER BY usage_count DESC
LIMIT 20
""",
"media_availability": """
SELECT
COUNT(DISTINCT m.artifact_id) as artifacts_with_media,
COUNT(*) as total_media_items,
AVG(images_per_artifact) as avg_images_per_artifact
FROM (
SELECT artifact_id, COUNT(*) as images_per_artifact
FROM artifactmedia
GROUP BY artifact_id
) as images_count
JOIN artifactmedia m ON m.artifact_id = images_count.artifact_id
""",
"temporal_analysis": """
SELECT
FLOOR(dated_begin / 100) * 100 as century_start,
COUNT(*) as artifact_count
FROM artifactmetadata
WHERE dated_begin IS NOT NULL
GROUP BY century_start
ORDER BY century_start
"""
}
def execute_analytics_query(query_name: str) -> pd.DataFrame:
"""Execute a predefined analytics query and return results"""
connection = mysql.connector.connect(
host=os.getenv('DB_HOST'),
database=os.getenv('DB_NAME'),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD')
)
query = ANALYTICS_QUERIES.get(query_name)
df = pd.read_sql(query, connection)
connection.close()
return df
Streamlit Application
Main Application Structure
import streamlit as st
import plotly.express as px
import plotly.graph_objects as go
def main():
st.set_page_config(
page_title="Harvard Art Museums Analytics",
page_icon="🏛️",
layout="wide"
)
st.title("🏛️ Harvard Art Museums - Data Analytics Dashboard")
st.markdown("---")
# Sidebar navigation
page = st.sidebar.selectbox(
"Choose a page",
["Data Collection", "Analytics Dashboard", "Query Explorer"]
)
if page == "Data Collection":
show_data_collection_page()
elif page == "Analytics Dashboard":
show_analytics_dashboard()
else:
show_query_explorer()
def show_data_collection_page():
"""Page for triggering ETL pipeline"""
st.header("📥 Data Collection")
num_records = st.number_input(
"Number of artifacts to collect",
min_value=100,
max_value=5000,
value=500,
step=100
)
if st.button("Start ETL Pipeline"):
with st.spinner("Extracting data from API..."):
raw_data = extract_all_artifacts(max_records=num_records)
st.success(f"✅ Extracted {len(raw_data)} artifacts")
with st.spinner("Transforming data..."):
metadata_df, media_df, colors_df = transform_artifacts(raw_data)
st.success(f"✅ Transformed into {len(metadata_df)} metadata records")
with st.spinner("Loading into database..."):
load_to_database(metadata_df, media_df, colors_df)
st.success("✅ Data loaded successfully!")
# Show sample data
st.subheader("Sample Data Preview")
st.dataframe(metadata_df.head(10))
def show_analytics_dashboard():
"""Pre-built analytics visualizations"""
st.header("📊 Analytics Dashboard")
col1, col2 = st.columns(2)
with col1:
st.subheader("Artifacts by Culture")
df = execute_analytics_query("artifacts_by_culture")
fig = px.bar(df, x='culture', y='artifact_count',
color='artifact_count', color_continuous_scale='viridis')
st.plotly_chart(fig, use_container_width=True)
with col2:
st.subheader("Department Distribution")
df = execute_analytics_query("department_distribution")
fig = px.pie(df, names='department', values='artifact_count')
st.plotly_chart(fig, use_container_width=True)
st.subheader("Temporal Distribution")
df = execute_analytics_query("temporal_analysis")
fig = px.line(df, x='century_start', y='artifact_count',
markers=True, line_shape='spline')
st.plotly_chart(fig, use_container_width=True)
st.subheader("Top Colors in Collection")
df = execute_analytics_query("top_colors")
fig = go.Figure(data=[go.Bar(
x=df['color_hex'],
y=df['usage_count'],
marker_color=['#' + hex for hex in df['color_hex']]
)])
st.plotly_chart(fig, use_container_width=True)
def show_query_explorer():
"""Custom query execution interface"""
st.header("🔍 Query Explorer")
query_choice = st.selectbox(
"Select a query",
list(ANALYTICS_QUERIES.keys())
)
st.code(ANALYTICS_QUERIES[query_choice], language='sql')
if st.button("Execute Query"):
df = execute_analytics_query(query_choice)
st.dataframe(df)
# Auto-generate visualization if appropriate
if len(df.columns) == 2 and df.shape[0] > 0:
col1_name, col2_name = df.columns
fig = px.bar(df, x=col1_name, y=col2_name)
st.plotly_chart(fig, use_container_width=True)
if __name__ == "__main__":
main()
Running the Application
# Start the Streamlit app
streamlit run app.py
# Access at http://localhost:8501
Common Patterns
Rate-Limited API Calls
import time
from functools import wraps
def rate_limit(calls_per_minute=120):
"""Decorator to rate limit API calls"""
min_interval = 60.0 / calls_per_minute
def decorator(func):
last_called = [0.0]
@wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
wait_time = min_interval - elapsed
if wait_time > 0:
time.sleep(wait_time)
result = func(*args, **kwargs)
last_called[0] = time.time()
return result
return wrapper
return decorator
@rate_limit(calls_per_minute=100)
def fetch_artifact_by_id(artifact_id: int) -> Dict:
"""Fetch single artifact with rate limiting"""
api_key = os.getenv('HARVARD_API_KEY')
url = f"https://api.harvardartmuseums.org/object/{artifact_id}"
response = requests.get(url, params={'apikey': api_key})
return response.json()
Incremental Data Loading
def get_last_loaded_id() -> int:
"""Get the highest artifact ID already in database"""
connection = mysql.connector.connect(
host=os.getenv('DB_HOST'),
database=os.getenv('DB_NAME'),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD')
)
cursor = connection.cursor()
cursor.execute("SELECT MAX(id) FROM artifactmetadata")
result = cursor.fetchone()[0]
connection.close()
return result if result else 0
def incremental_etl():
"""Load only new artifacts since last run"""
last_id = get_last_loaded_id()
# Fetch artifacts with ID > last_id
new_artifacts = fetch_artifacts_after_id(last_id)
if new_artifacts:
metadata_df, media_df, colors_df = transform_artifacts(new_artifacts)
load_to_database(metadata_df, media_df, colors_df)
print(f"Loaded {len(new_artifacts)} new artifacts")
else:
print("No new artifacts to load")
Troubleshooting
API Key Issues
# Verify API key is loaded
import os
from dotenv import load_dotenv
load_dotenv()
api_key = os.getenv('HARVARD_API_KEY')
if not api_key:
raise ValueError("HARVARD_API_KEY not found in environment variables")
# Test API connection
response = requests.get(
"https://api.harvardartmuseums.org/object",
params={'apikey': api_key, 'size': 1}
)
print(f"API Status: {response.status_code}")
Database Connection Errors
def test_database_connection():
"""Test database connectivity"""
try:
connection = mysql.connector.connect(
host=os.getenv('DB_HOST'),
port=int(os.getenv('DB_PORT', 3306)),
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD'),
database=os.getenv('DB_NAME'),
connect_timeout=10
)
cursor = connection.cursor()
cursor.execute("SELECT 1")
result = cursor.fetchone()
cursor.close()
connection.close()
print("✅ Database connection successful")
return True
except Error as e:
print(f"❌ Database connection failed: {e}")
return False
Handling Missing Data
def safe_transform(artifact: Dict) -> Dict:
"""Transform with null handling"""
return {
'id': artifact.get('id'),
'title': artifact.get('title') or 'Untitled',
'culture': artifact.get('culture') or 'Unknown',
'century': artifact.get('century') or 'Unknown',
'classification': artifact.get('classification') or 'Unclassified',
'department': artifact.get('department') or 'General Collection',
'description': (artifact.get('description') or '')[:5000], # Truncate long text
'dated_begin': artifact.get('datebegin') if isinstance(artifact.get('datebegin'), int) else None,
'dated_end': artifact.get('dateend') if isinstance(artifact.get('dateend'), int) else None
}
Memory Management for Large Datasets
def chunked_etl(total_records: int, chunk_size: int = 500):
"""Process large datasets in chunks to manage memory"""
num_chunks = (total_records + chunk_size - 1) // chunk_size
for chunk_num in range(num_chunks):
start_idx = chunk_num * chunk_size
end_idx = min(start_idx + chunk_size, total_records)
print(f"Processing chunk {chunk_num + 1}/{num_chunks}")
# Extract chunk
artifacts = extract_all_artifacts_range(start_idx, end_idx)
# Transform and load immediately
metadata_df, media_df, colors_df = transform_artifacts(artifacts)
load_to_database(metadata_df, media_df, colors_df)
# Clear memory
del artifacts, metadata_df, media_df, colors_df
This skill provides comprehensive guidance for building ETL pipelines and analytics applications with the Harvard Art Museums API using Python, SQL, and Streamlit.
FAQ
What does the harvard-art-museums-etl-analytics skill do?
Build ETL pipelines and analytics dashboards using Harvard Art Museums API with Python, SQL, and Streamlit
How do I install harvard-art-museums-etl-analytics?
Run npx skills add https://github.com/reason-machines/data-skills --skill harvard-art-museums-etl-analytics in your project. The skills CLI detects the agents you use and copies the skill into the right folder. For Claude Code you can also place the skill folder at ~/.claude/skills/harvard-art-museums-etl-analytics/ (personal) or .claude/skills/harvard-art-museums-etl-analytics/ (project).
Which AI agents can use harvard-art-museums-etl-analytics?
Any agent that supports the open Agent Skills format (a folder with a SKILL.md file), including Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf, Cline, OpenCode and more.
Is harvard-art-museums-etl-analytics safe to install?
No partner security audit is available yet. Skills can run commands and scripts, so read the SKILL.md and any bundled files before installing.
Who maintains harvard-art-museums-etl-analytics?
It is published by reason-machines in reason-machines/data-skills. It has 2,018 recorded installs.
More from reason-machines/data-skills
apache-airflow-orchestration
reason-machines/data-skills
Expert knowledge of Apache Airflow for building, scheduling, and monitoring data pipelines and workflows
datatalks-data-engineering-zoomcamp
reason-machines/data-skills
Free 9-week data engineering course covering Docker, Terraform, Kestra, BigQuery, dbt, Spark, and Kafka with hands-on projects
llm-public-opinion-analytics-assistant
reason-machines/data-skills
Multi-platform hot search crawler and LLM-powered public opinion analysis system with clustering, sentiment analysis, and multi-channel push notifications
roblox-mm2-analytics-toolkit
reason-machines/data-skills
Analytics and inventory management toolkit for Roblox Murder Mystery 2 gameplay optimization
mm2-analytics-roblox-tracker
reason-machines/data-skills
Analyze Murder Mystery 2 gameplay data, track inventory, and optimize strategy using this Roblox analytics toolkit
mm2-roblox-analytics-toolkit
reason-machines/data-skills
Murder Mystery 2 gameplay analytics, inventory tracking, and strategy optimization toolkit for Roblox
Popular data & databases skills
supabase-postgres-best-practices
supabase/agent-skills
Postgres best practices maintained by Supabase, for Postgres running anywhere. Load this skill BEFORE writing or changing anything that lives in a Postgres database: creating or altering tables and columns (including choosing column types), schema design, migrations and declarative schema files, RLS policies and the tests that verify them, indexes, triggers, database functions, queues and scheduled jobs (pgcron, pgmq), vector/semantic search (pgvector), and restoring dumps (pgrestore) or importing data. Also load it when diagnosing slow queries, high CPU, timeouts, EXPLAIN plans, connection exhaustion, locking, bloat, or rows visible to the wrong user or tenant. This is not just a performance guide — schema, migration, security, and SQL authoring tasks need these rules too, even for a one-column change or a single query.
prisma-client-api
prisma/skills
Prisma Client API reference covering model queries, filters, operators, and client methods. Use when writing database queries, using CRUD operations, filtering data, or configuring Prisma Client. Triggers on "prisma query", "findMany", "create", "update", "delete", "$transaction".
prisma-database-setup
prisma/skills
Deprecated compatibility name for prisma-orm-setup. Use only when a user or existing integration explicitly invokes prisma-database-setup; otherwise select prisma-orm-setup.
prisma-cli
prisma/skills
Prisma ORM 7 CLI reference for existing Prisma 7 applications or explicitly selected Prisma 7 workflows. Covers prisma init, generate, migrate, db, dev, studio, and mcp. For setup, connection repair, or Prisma 8, use prisma-orm-setup; not the Prisma Platform CLI.
prisma-postgres
prisma/skills
Deprecated compatibility name for prisma-postgres-setup. Use only when a user or existing integration explicitly invokes prisma-postgres; otherwise select prisma-postgres-setup.
prisma-driver-adapter-implementation
prisma/skills
Required reference for Prisma ORM 7 SQL driver adapter work. Use when implementing or modifying adapters, adding database drivers, or touching SqlDriverAdapter, Transaction, savepoint, result mapping, or DriverAdapterError behavior. Covers current transaction lifecycle, optional savepoint hooks, original database-error preservation, and verification.