From 43639e84afed149de955932e0c203407d9c2ced0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:51:52 +0000 Subject: [PATCH 1/4] Initial plan From ac6a063f0edd4f1cbea0b4bd208a4ab28e5c5bc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 12:03:20 +0000 Subject: [PATCH 2/4] Add Docker Compose infrastructure and environment configuration Co-authored-by: marcelo-m7 <117441129+marcelo-m7@users.noreply.github.com> --- .env.example | 21 ++ .gitignore | 7 + Makefile | 25 ++- docker-compose.yml | 79 ++++++++ docker/api.Dockerfile | 29 +++ docker/facodi.Dockerfile | 24 +++ docker/nginx.conf | 42 ++++ docker/scraper.Dockerfile | 30 +++ docs/INTEGRATION.md | 405 ++++++++++++++++++++++++++++++++++++++ scripts/export_to_hugo.py | 309 +++++++++++++++++++++++++++++ src/api.py | 213 +++++++++++++++++++- src/config.py | 20 +- 12 files changed, 1198 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml create mode 100644 docker/api.Dockerfile create mode 100644 docker/facodi.Dockerfile create mode 100644 docker/nginx.conf create mode 100644 docker/scraper.Dockerfile create mode 100644 docs/INTEGRATION.md create mode 100644 scripts/export_to_hugo.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e43d824 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# UAlg Scraper Configuration +UALG_BASE_URL=https://www.ualg.pt + +# Database Configuration +DB_PATH=/data/ualg_courses.db + +# Request Configuration +MAX_RETRIES=3 +REQUEST_DELAY=0.5 +REQUEST_TIMEOUT=30 + +# API Configuration +API_HOST=0.0.0.0 +API_PORT=8000 + +# FACODI Configuration +FACODI_PORT=3000 + +# Scraper Schedule (cron format) +# Example: 0 2 * * 1 = Every Monday at 2am +SCRAPER_SCHEDULE=0 2 * * 1 diff --git a/.gitignore b/.gitignore index 17d3134..465dd11 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,10 @@ Thumbs.db ualg_courses.db data/ *.db + +# Docker +.env +docker-compose.override.yml + +# FACODI clone +facodi-clone/ diff --git a/Makefile b/Makefile index 28730d1..a847445 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev test lint format clean run run-scraper run-api init-db demo +.PHONY: help install install-dev test lint format clean run run-scraper run-api init-db demo docker-build docker-up docker-down docker-scraper export-hugo help: @echo "Available targets:" @@ -13,6 +13,13 @@ help: @echo " run-api - Run the API server" @echo " init-db - Initialize the database" @echo " demo - Populate database with demo data" + @echo " export-hugo - Export database to Hugo markdown files" + @echo "" + @echo "Docker targets:" + @echo " docker-build - Build all Docker images" + @echo " docker-up - Start all Docker services (API + FACODI)" + @echo " docker-down - Stop all Docker services" + @echo " docker-scraper - Run scraper in Docker container" install: pip install -r requirements.txt @@ -53,3 +60,19 @@ init-db: demo: python scripts/populate_demo_data.py + +export-hugo: + python scripts/export_to_hugo.py + +# Docker targets +docker-build: + docker-compose build + +docker-up: + docker-compose up -d + +docker-down: + docker-compose down + +docker-scraper: + docker-compose --profile scraper up scraper diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..98bf2e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +version: '3.8' + +services: + # Service 1: API Python + SQLite + api: + build: + context: . + dockerfile: docker/api.Dockerfile + container_name: ualg-api + ports: + - "${API_PORT:-8000}:8000" + volumes: + - ./data:/data + - ./templates:/app/templates + environment: + - UALG_BASE_URL=${UALG_BASE_URL:-https://www.ualg.pt} + - DB_PATH=${DB_PATH:-/data/ualg_courses.db} + - MAX_RETRIES=${MAX_RETRIES:-3} + - REQUEST_DELAY=${REQUEST_DELAY:-0.5} + - REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-30} + networks: + - ualg-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Service 2: Scraper Service (runs periodically) + scraper: + build: + context: . + dockerfile: docker/scraper.Dockerfile + container_name: ualg-scraper + volumes: + - ./data:/data + environment: + - UALG_BASE_URL=${UALG_BASE_URL:-https://www.ualg.pt} + - DB_PATH=${DB_PATH:-/data/ualg_courses.db} + - MAX_RETRIES=${MAX_RETRIES:-3} + - REQUEST_DELAY=${REQUEST_DELAY:-0.5} + - REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-30} + networks: + - ualg-network + depends_on: + api: + condition: service_healthy + restart: "no" # Run once, or use cron for periodic runs + profiles: + - scraper # Only start when explicitly requested + + # Service 3: FACODI Frontend (Hugo + nginx) + facodi: + build: + context: . + dockerfile: docker/facodi.Dockerfile + container_name: ualg-facodi + ports: + - "${FACODI_PORT:-3000}:80" + networks: + - ualg-network + depends_on: + - api + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + ualg-network: + driver: bridge + +volumes: + data: + driver: local diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile new file mode 100644 index 0000000..b7761b1 --- /dev/null +++ b/docker/api.Dockerfile @@ -0,0 +1,29 @@ +# API Service Dockerfile +FROM python:3.10-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY src/ ./src/ +COPY templates/ ./templates/ +COPY schema.sql . + +# Create data directory +RUN mkdir -p /data + +# Expose API port +EXPOSE 8000 + +# Run API server +CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/facodi.Dockerfile b/docker/facodi.Dockerfile new file mode 100644 index 0000000..6c8cdce --- /dev/null +++ b/docker/facodi.Dockerfile @@ -0,0 +1,24 @@ +# FACODI Hugo Site Dockerfile +FROM klakegg/hugo:0.111.3-ext-alpine AS builder + +WORKDIR /src + +# Copy Hugo site +COPY facodi-clone/ . + +# Build Hugo site +RUN hugo --minify + +# Production stage - serve with nginx +FROM nginx:alpine + +# Copy built site +COPY --from=builder /src/public /usr/share/nginx/html + +# Copy nginx configuration +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..f6424ac --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,42 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript + application/x-javascript application/xml+rss + application/javascript application/json; + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Main location block + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy (optional - for direct API access from FACODI) + location /api/ { + proxy_pass http://api:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health check + location /health { + access_log off; + return 200 "OK\n"; + add_header Content-Type text/plain; + } +} diff --git a/docker/scraper.Dockerfile b/docker/scraper.Dockerfile new file mode 100644 index 0000000..c081c0d --- /dev/null +++ b/docker/scraper.Dockerfile @@ -0,0 +1,30 @@ +# Scraper Service Dockerfile +FROM python:3.10-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY src/ ./src/ +COPY schema.sql . +COPY scripts/ ./scripts/ + +# Create data directory +RUN mkdir -p /data/docs + +# Set environment variables +ENV PYTHONUNBUFFERED=1 + +# Run scraper +CMD ["python", "-m", "src.scrape_ualg"] diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..ab352a7 --- /dev/null +++ b/docs/INTEGRATION.md @@ -0,0 +1,405 @@ +# FACODI Integration Guide + +This document explains how to integrate the UAlg Scraper with the FACODI.pt Hugo site. + +## Architecture Overview + +The system consists of 4 Docker services: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Docker Compose Stack │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ API │ │ Scraper │ │ FACODI │ │ +│ │ (FastAPI) │ │ (Python) │ │(Hugo+nginx) │ │ +│ │ Port 8000 │ │ (On-demand)│ │ Port 3000 │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────────┴────────────────┘ │ +│ │ │ +│ ┌──────▼──────┐ │ +│ │ SQLite │ │ +│ │ Database │ │ +│ │ (Shared) │ │ +│ └─────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Prerequisites + +1. **Docker and Docker Compose installed** + ```bash + docker --version + docker-compose --version + ``` + +2. **Clone the FACODI.pt repository** + ```bash + git clone https://github.com/Monynha-Softwares/facodi.pt.git facodi-clone + ``` + +3. **Environment configuration** + ```bash + cp .env.example .env + # Edit .env to customize settings + ``` + +## Quick Start + +### 1. Build Docker Images + +```bash +make docker-build +# or +docker-compose build +``` + +This builds: +- `ualg-api`: FastAPI service with database access +- `ualg-scraper`: Scraper service (runs on-demand) +- `ualg-facodi`: Hugo static site served by nginx + +### 2. Start Services + +```bash +make docker-up +# or +docker-compose up -d +``` + +This starts: +- **API service** at `http://localhost:8000` +- **FACODI site** at `http://localhost:3000` + +### 3. Run Scraper (Optional) + +The scraper can be run manually to populate the database: + +```bash +make docker-scraper +# or +docker-compose --profile scraper up scraper +``` + +### 4. Export to Hugo + +To export database content to Hugo markdown files: + +```bash +python scripts/export_to_hugo.py +``` + +Or inside Docker: + +```bash +docker-compose run --rm api python scripts/export_to_hugo.py +``` + +## Data Flow + +### 1. Scraping Phase + +``` +UAlg Website → Scraper → SQLite Database + ↓ + data/docs/ +``` + +The scraper: +- Fetches course pages from www.ualg.pt +- Parses HTML to extract course information +- Saves to SQLite database +- Downloads PDFs/documents to `data/docs/` + +### 2. API Serving Phase + +``` +SQLite Database → API (FastAPI) → JSON Responses + ↓ + Frontend Applications +``` + +The API provides: +- `/courses` - List courses with filters +- `/courses/{id}` - Course details +- `/api/v1/courses/export` - Export all courses +- `/api/v1/course/{id}/markdown` - Get course as markdown + +### 3. Hugo Export Phase + +``` +SQLite Database → export_to_hugo.py → Hugo Markdown Files + ↓ + facodi-clone/content/courses/ + ↓ + Hugo Build + ↓ + Static HTML Site +``` + +The exporter: +- Reads all courses from database +- Generates markdown files with Hugo front matter +- Copies documents to `facodi-clone/static/docs/` +- Creates proper directory structure + +## Hugo Front Matter Format + +Each course is exported as `facodi-clone/content/courses/{slug}/index.md`: + +```yaml +--- +title: "Engenharia Informática" +date: 2025-01-27 +code: "L1478" +level: "Licenciatura" +school: "Faculdade de Ciências e Tecnologia" +language: "PT" +regime: "Diurno" +modality: "Presencial" +url_source: "https://www.ualg.pt/curso/1478" +areas: + - "Tecnologias da Informação" + - "Engenharia" +type: "course" +draft: false +--- + +## Descrição + +...course description... + +## Unidades Curriculares + +### Ano 1 - Semestre 1 + +- **Programação I** `14591001` (6 ECTS) +- **Matemática Discreta** `14591002` (6 ECTS) + +... + +## Documentos + +- [Plano de Estudos](/docs/l1478/plano.pdf) +``` + +## Hugo Layouts + +Create layouts in `facodi-clone/layouts/courses/`: + +### `list.html` - Course Listing Page + +```html +{{ define "main" }} +
{{ .Params.level }} - {{ .Params.school }}
+${course.level_name || ''} - ${course.school_name || ''}
+ `; + container.appendChild(card); + }); +} + +// Load on page ready +document.addEventListener('DOMContentLoaded', loadCourses); +``` + +## Workflow + +### Development Workflow + +1. **Make changes to scraper/API** +2. **Rebuild Docker images** + ```bash + docker-compose build api scraper + ``` +3. **Restart services** + ```bash + docker-compose restart api + ``` +4. **Test changes** + ```bash + curl http://localhost:8000/api + ``` + +### Production Workflow + +1. **Run scraper periodically** (e.g., weekly via cron) + ```bash + 0 2 * * 1 cd /path/to/repo && docker-compose --profile scraper up scraper + ``` + +2. **Export to Hugo** after scraping + ```bash + docker-compose run --rm api python scripts/export_to_hugo.py + ``` + +3. **Rebuild Hugo site** + ```bash + cd facodi-clone && hugo + ``` + +4. **Deploy static site** + ```bash + # Deploy facodi-clone/public/ to hosting + ``` + +## Environment Variables + +Configure in `.env`: + +```bash +# Base URL for scraping +UALG_BASE_URL=https://www.ualg.pt + +# Database path (inside container) +DB_PATH=/data/ualg_courses.db + +# Request configuration +MAX_RETRIES=3 +REQUEST_DELAY=0.5 +REQUEST_TIMEOUT=30 + +# API configuration +API_PORT=8000 + +# FACODI configuration +FACODI_PORT=3000 + +# Hugo export paths +HUGO_PATH=facodi-clone +DOCS_SOURCE=data/docs +``` + +## Troubleshooting + +### Problem: FACODI container fails to build + +**Solution:** Ensure `facodi-clone/` directory exists: +```bash +git clone https://github.com/Monynha-Softwares/facodi.pt.git facodi-clone +``` + +### Problem: Database not found + +**Solution:** Initialize database first: +```bash +docker-compose run --rm api python -c "from src.scrape_ualg import UAlgCourseScraper; s = UAlgCourseScraper(); s.init_db()" +``` + +Or locally: +```bash +make init-db +``` + +### Problem: API returns empty results + +**Solution:** Run scraper to populate database: +```bash +make docker-scraper +``` + +### Problem: Can't access API from FACODI + +**Solution:** Check network configuration in `docker-compose.yml`. All services should be on `ualg-network`. + +## Testing + +### Test API Endpoints + +```bash +# Get API info +curl http://localhost:8000/api + +# List courses +curl http://localhost:8000/courses + +# Export all courses +curl http://localhost:8000/api/v1/courses/export + +# Get course as markdown +curl http://localhost:8000/api/v1/course/1/markdown +``` + +### Test FACODI Site + +```bash +# Access FACODI +curl http://localhost:3000/ + +# Check health +curl http://localhost:3000/health +``` + +## Next Steps + +1. **Customize Hugo templates** in `facodi-clone/layouts/courses/` +2. **Add CSS styling** in `facodi-clone/static/css/` +3. **Configure Hugo** in `facodi-clone/config.toml` +4. **Set up CI/CD** for automatic scraping and deployment +5. **Add search functionality** using Hugo's built-in search or external service + +## Additional Resources + +- [Hugo Documentation](https://gohugo.io/documentation/) +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [Docker Compose Documentation](https://docs.docker.com/compose/) diff --git a/scripts/export_to_hugo.py b/scripts/export_to_hugo.py new file mode 100644 index 0000000..edad032 --- /dev/null +++ b/scripts/export_to_hugo.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +Export UAlg courses from SQLite database to Hugo markdown files. + +This script reads the ualg_courses.db database and generates: +- Markdown files with Hugo front matter for each course +- Copies associated documents to Hugo static directory +- Creates proper directory structure for Hugo +""" + +import sqlite3 +import os +import shutil +import sys +from pathlib import Path +from datetime import datetime +import logging + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +# Default paths +DEFAULT_DB_PATH = os.getenv("DB_PATH", "ualg_courses.db") +DEFAULT_HUGO_PATH = os.getenv("HUGO_PATH", "facodi-clone") +DEFAULT_DOCS_SOURCE = os.getenv("DOCS_SOURCE", "data/docs") + + +def get_db_connection(db_path: str) -> sqlite3.Connection: + """Create database connection with row factory.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + + +def sanitize_filename(filename: str) -> str: + """Sanitize filename for filesystem safety.""" + # Replace unsafe characters + unsafe_chars = '<>:"/\\|?*' + for char in unsafe_chars: + filename = filename.replace(char, "-") + return filename.strip() + + +def generate_course_slug(code: str, title: str) -> str: + """Generate URL-friendly slug for course.""" + if code: + return sanitize_filename(code.lower().replace(" ", "-")) + return sanitize_filename(title.lower().replace(" ", "-"))[:50] + + +def format_front_matter(course_data: dict) -> str: + """Generate Hugo front matter YAML for a course.""" + front_matter = ["---"] + + # Required fields + front_matter.append(f'title: "{course_data["title"]}"') + front_matter.append(f'date: {datetime.now().strftime("%Y-%m-%d")}') + + # Optional fields + if course_data.get("code"): + front_matter.append(f'code: "{course_data["code"]}"') + + if course_data.get("level"): + front_matter.append(f'level: "{course_data["level"]}"') + + if course_data.get("school"): + front_matter.append(f'school: "{course_data["school"]}"') + + if course_data.get("language"): + front_matter.append(f'language: "{course_data["language"]}"') + + if course_data.get("regime"): + front_matter.append(f'regime: "{course_data["regime"]}"') + + if course_data.get("modality"): + front_matter.append(f'modality: "{course_data["modality"]}"') + + if course_data.get("url"): + front_matter.append(f'url_source: "{course_data["url"]}"') + + # Areas (as array) + if course_data.get("areas"): + front_matter.append("areas:") + for area in course_data["areas"]: + front_matter.append(f' - "{area}"') + + # Metadata + front_matter.append('type: "course"') + front_matter.append("draft: false") + + front_matter.append("---") + front_matter.append("") # Empty line after front matter + + return "\n".join(front_matter) + + +def generate_course_content(course_data: dict, modules: list, documents: list) -> str: + """Generate markdown content for course page.""" + content = [] + + # Description + if course_data.get("description"): + content.append("## Descrição") + content.append("") + content.append(course_data["description"]) + content.append("") + + # Modules/Curricular Units + if modules: + content.append("## Unidades Curriculares") + content.append("") + + # Group by year and semester + modules_by_year = {} + for module in modules: + year = module.get("year", "N/A") + semester = module.get("semester", "N/A") + key = (year, semester) + if key not in modules_by_year: + modules_by_year[key] = [] + modules_by_year[key].append(module) + + # Output modules organized by year/semester + for (year, semester), mods in sorted(modules_by_year.items()): + content.append(f"### Ano {year} - Semestre {semester}") + content.append("") + + for mod in mods: + ects = f" ({mod['ects']} ECTS)" if mod.get("ects") else "" + code = f" `{mod['code']}`" if mod.get("code") else "" + content.append(f"- **{mod['title']}**{code}{ects}") + + content.append("") + + # Documents + if documents: + content.append("## Documentos") + content.append("") + for doc in documents: + title = doc.get("title", "Documento") + local_path = doc.get("local_path", "") + if local_path: + # Convert to Hugo static path + static_path = local_path.replace("data/docs/", "/docs/") + content.append(f"- [{title}]({static_path})") + content.append("") + + return "\n".join(content) + + +def export_course( + conn: sqlite3.Connection, course_id: int, hugo_content_dir: Path, hugo_static_dir: Path, docs_source_dir: Path +) -> bool: + """Export a single course to Hugo format.""" + try: + # Fetch course data + cursor = conn.execute( + """ + SELECT c.*, l.name as level, s.name as school + FROM courses c + LEFT JOIN levels l ON c.level_id = l.id + LEFT JOIN schools s ON c.school_id = s.id + WHERE c.id = ? + """, + (course_id,), + ) + + course_row = cursor.fetchone() + if not course_row: + logger.warning(f"Course ID {course_id} not found") + return False + + course_data = dict(course_row) + + # Fetch areas + cursor = conn.execute( + """ + SELECT a.name + FROM areas a + JOIN course_area ca ON a.id = ca.area_id + WHERE ca.course_id = ? + """, + (course_id,), + ) + course_data["areas"] = [row["name"] for row in cursor.fetchall()] + + # Fetch modules + cursor = conn.execute( + """ + SELECT m.* + FROM modules m + JOIN module_course mc ON m.id = mc.module_id + WHERE mc.course_id = ? + ORDER BY m.year, m.semester, m.title + """, + (course_id,), + ) + modules = [dict(row) for row in cursor.fetchall()] + + # Fetch documents + cursor = conn.execute( + """ + SELECT * + FROM course_documents + WHERE course_id = ? + """, + (course_id,), + ) + documents = [dict(row) for row in cursor.fetchall()] + + # Generate slug and create directory + slug = generate_course_slug(course_data.get("code", ""), course_data["title"]) + course_dir = hugo_content_dir / "courses" / slug + course_dir.mkdir(parents=True, exist_ok=True) + + # Generate markdown file + md_file = course_dir / "index.md" + with open(md_file, "w", encoding="utf-8") as f: + f.write(format_front_matter(course_data)) + f.write(generate_course_content(course_data, modules, documents)) + + logger.info(f"Exported course: {course_data['title']} -> {md_file}") + + # Copy documents + for doc in documents: + if doc.get("local_path"): + source = docs_source_dir / doc["local_path"].replace("data/docs/", "") + if source.exists(): + dest_dir = hugo_static_dir / "docs" / slug + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / source.name + shutil.copy2(source, dest) + logger.info(f"Copied document: {source.name}") + + return True + + except Exception as e: + logger.error(f"Error exporting course {course_id}: {e}") + return False + + +def export_all_courses(db_path: str, hugo_path: str, docs_source: str): + """Export all courses from database to Hugo.""" + logger.info(f"Starting export from {db_path} to {hugo_path}") + + # Validate paths + db_path_obj = Path(db_path) + if not db_path_obj.exists(): + logger.error(f"Database not found: {db_path}") + sys.exit(1) + + hugo_path_obj = Path(hugo_path) + if not hugo_path_obj.exists(): + logger.error(f"Hugo directory not found: {hugo_path}") + logger.info("Please clone the facodi.pt repository first:") + logger.info(" git clone https://github.com/Monynha-Softwares/facodi.pt.git facodi-clone") + sys.exit(1) + + # Setup directories + hugo_content_dir = hugo_path_obj / "content" + hugo_static_dir = hugo_path_obj / "static" + docs_source_dir = Path(docs_source) + + hugo_content_dir.mkdir(parents=True, exist_ok=True) + hugo_static_dir.mkdir(parents=True, exist_ok=True) + + # Connect to database + conn = get_db_connection(str(db_path_obj)) + + try: + # Get all course IDs + cursor = conn.execute("SELECT id FROM courses ORDER BY id") + course_ids = [row["id"] for row in cursor.fetchall()] + + logger.info(f"Found {len(course_ids)} courses to export") + + # Export each course + success_count = 0 + for course_id in course_ids: + if export_course(conn, course_id, hugo_content_dir, hugo_static_dir, docs_source_dir): + success_count += 1 + + logger.info(f"Export complete: {success_count}/{len(course_ids)} courses exported successfully") + + finally: + conn.close() + + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Export UAlg courses to Hugo markdown files") + parser.add_argument("--db", default=DEFAULT_DB_PATH, help=f"Path to SQLite database (default: {DEFAULT_DB_PATH})") + parser.add_argument( + "--hugo", default=DEFAULT_HUGO_PATH, help=f"Path to Hugo site directory (default: {DEFAULT_HUGO_PATH})" + ) + parser.add_argument( + "--docs", default=DEFAULT_DOCS_SOURCE, help=f"Path to documents source directory (default: {DEFAULT_DOCS_SOURCE})" + ) + + args = parser.parse_args() + + export_all_courses(args.db, args.hugo, args.docs) + + +if __name__ == "__main__": + main() diff --git a/src/api.py b/src/api.py index bb1cf93..4715689 100644 --- a/src/api.py +++ b/src/api.py @@ -10,11 +10,12 @@ from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, PlainTextResponse import sqlite3 import os from typing import List, Optional, Dict, Any from pydantic import BaseModel +from datetime import datetime # Configurações DB_PATH = "ualg_courses.db" @@ -354,6 +355,216 @@ def get_stats(): conn.close() +@app.get("/api/v1/courses/export", tags=["export"]) +def export_courses(): + """ + Exporta todos os cursos em formato adequado para Hugo. + + Retorna lista completa de cursos com todas as informações, + módulos, documentos e áreas. + """ + conn = get_db_connection() + + try: + # Buscar todos os cursos + cur = conn.execute( + """ + SELECT c.*, l.name as level_name, s.name as school_name + FROM courses c + LEFT JOIN levels l ON l.id = c.level_id + LEFT JOIN schools s ON s.id = c.school_id + ORDER BY c.id + """ + ) + + courses = [] + for row in cur.fetchall(): + course = dict_from_row(row) + course_id = course["id"] + + # Buscar módulos + mod_cur = conn.execute( + """ + SELECT m.*, mc.mandatory + FROM modules m + JOIN module_course mc ON mc.module_id = m.id + WHERE mc.course_id = ? + ORDER BY m.year, m.semester, m.title + """, + (course_id,), + ) + course["modules"] = [dict_from_row(r) for r in mod_cur.fetchall()] + + # Buscar documentos + doc_cur = conn.execute( + """ + SELECT id, title, url, local_path, filetype + FROM course_documents + WHERE course_id = ? + """, + (course_id,), + ) + course["documents"] = [dict_from_row(r) for r in doc_cur.fetchall()] + + # Buscar áreas + area_cur = conn.execute( + """ + SELECT a.name + FROM areas a + JOIN course_area ca ON ca.area_id = a.id + WHERE ca.course_id = ? + """, + (course_id,), + ) + course["areas"] = [r["name"] for r in area_cur.fetchall()] + + courses.append(course) + + return {"total": len(courses), "exported_at": datetime.now().isoformat(), "courses": courses} + + finally: + conn.close() + + +@app.get("/api/v1/course/{course_id}/markdown", response_class=PlainTextResponse, tags=["export"]) +def get_course_markdown(course_id: int): + """ + Obtém um curso em formato Markdown com front matter Hugo. + + Retorna o arquivo markdown completo pronto para ser usado no Hugo. + """ + conn = get_db_connection() + + try: + # Buscar curso + cur = conn.execute( + """ + SELECT c.*, l.name as level_name, s.name as school_name + FROM courses c + LEFT JOIN levels l ON l.id = c.level_id + LEFT JOIN schools s ON s.id = c.school_id + WHERE c.id = ? + """, + (course_id,), + ) + + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Curso não encontrado") + + course = dict_from_row(row) + + # Buscar módulos + mod_cur = conn.execute( + """ + SELECT m.*, mc.mandatory + FROM modules m + JOIN module_course mc ON mc.module_id = m.id + WHERE mc.course_id = ? + ORDER BY m.year, m.semester, m.title + """, + (course_id,), + ) + modules = [dict_from_row(r) for r in mod_cur.fetchall()] + + # Buscar documentos + doc_cur = conn.execute( + """ + SELECT id, title, url, local_path, filetype + FROM course_documents + WHERE course_id = ? + """, + (course_id,), + ) + documents = [dict_from_row(r) for r in doc_cur.fetchall()] + + # Buscar áreas + area_cur = conn.execute( + """ + SELECT a.name + FROM areas a + JOIN course_area ca ON ca.area_id = a.id + WHERE ca.course_id = ? + """, + (course_id,), + ) + areas = [r["name"] for r in area_cur.fetchall()] + + # Gerar front matter + front_matter = ["---"] + front_matter.append(f'title: "{course["title"]}"') + front_matter.append(f'date: {datetime.now().strftime("%Y-%m-%d")}') + + if course.get("code"): + front_matter.append(f'code: "{course["code"]}"') + if course.get("level_name"): + front_matter.append(f'level: "{course["level_name"]}"') + if course.get("school_name"): + front_matter.append(f'school: "{course["school_name"]}"') + if course.get("language"): + front_matter.append(f'language: "{course["language"]}"') + if course.get("regime"): + front_matter.append(f'regime: "{course["regime"]}"') + if course.get("modality"): + front_matter.append(f'modality: "{course["modality"]}"') + if course.get("url"): + front_matter.append(f'url_source: "{course["url"]}"') + + if areas: + front_matter.append("areas:") + for area in areas: + front_matter.append(f' - "{area}"') + + front_matter.append('type: "course"') + front_matter.append("draft: false") + front_matter.append("---") + front_matter.append("") + + # Gerar conteúdo + content = ["\n".join(front_matter)] + + if course.get("description"): + content.append("## Descrição\n") + content.append(course["description"]) + content.append("") + + if modules: + content.append("## Unidades Curriculares\n") + + # Agrupar por ano e semestre + modules_by_year = {} + for mod in modules: + year = mod.get("year", "N/A") + semester = mod.get("semester", "N/A") + key = (year, semester) + if key not in modules_by_year: + modules_by_year[key] = [] + modules_by_year[key].append(mod) + + for (year, semester), mods in sorted(modules_by_year.items()): + content.append(f"### Ano {year} - Semestre {semester}\n") + for mod in mods: + ects = f" ({mod['ects']} ECTS)" if mod.get("ects") else "" + code = f" `{mod['code']}`" if mod.get("code") else "" + content.append(f"- **{mod['title']}**{code}{ects}") + content.append("") + + if documents: + content.append("## Documentos\n") + for doc in documents: + title = doc.get("title", "Documento") + local_path = doc.get("local_path", "") + if local_path: + static_path = local_path.replace("data/docs/", "/docs/") + content.append(f"- [{title}]({static_path})") + content.append("") + + return "\n".join(content) + + finally: + conn.close() + + # Executar com: uvicorn api:app --reload if __name__ == "__main__": import uvicorn diff --git a/src/config.py b/src/config.py index 8b66687..d5e7699 100644 --- a/src/config.py +++ b/src/config.py @@ -13,7 +13,12 @@ class Config: """Configuration class for UAlg scraper.""" def __init__( - self, base_url: Optional[str] = None, timeout: int = 30, user_agent: Optional[str] = None, max_retries: int = 3 + self, + base_url: Optional[str] = None, + timeout: int = 30, + user_agent: Optional[str] = None, + max_retries: int = 3, + request_delay: Optional[float] = None, ): """ Initialize configuration. @@ -23,16 +28,23 @@ def __init__( timeout: Request timeout in seconds. Default is 30. user_agent: User agent string. Defaults to a standard browser UA. max_retries: Maximum number of retry attempts. Default is 3. + request_delay: Delay between requests in seconds. Defaults to environment variable or 0.5. """ self.base_url = base_url or os.getenv("UALG_BASE_URL", "https://www.ualg.pt") - self.timeout = timeout + self.timeout = int(os.getenv("REQUEST_TIMEOUT", timeout)) self.user_agent = user_agent or ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ) - self.max_retries = max_retries + self.max_retries = int(os.getenv("MAX_RETRIES", max_retries)) + self.request_delay = ( + request_delay if request_delay is not None else float(os.getenv("REQUEST_DELAY", "0.5")) + ) def __repr__(self) -> str: """String representation of Config.""" - return f"Config(base_url='{self.base_url}', timeout={self.timeout}, max_retries={self.max_retries})" + return ( + f"Config(base_url='{self.base_url}', timeout={self.timeout}, " + f"max_retries={self.max_retries}, request_delay={self.request_delay})" + ) From 89e623742f0552563aae23532c4034d7c249625a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 12:09:03 +0000 Subject: [PATCH 3/4] Update README with Docker documentation and add Hugo integration examples Co-authored-by: marcelo-m7 <117441129+marcelo-m7@users.noreply.github.com> --- README.md | 202 ++++++++++++++++++- docs/HUGO_LAYOUTS_EXAMPLE.md | 369 +++++++++++++++++++++++++++++++++++ scripts/sync_with_facodi.sh | 54 +++++ 3 files changed, 618 insertions(+), 7 deletions(-) create mode 100644 docs/HUGO_LAYOUTS_EXAMPLE.md create mode 100755 scripts/sync_with_facodi.sh diff --git a/README.md b/README.md index fa5c3cb..07b4a4f 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,64 @@ Sistema completo de scraping e API REST para cursos da Universidade do Algarve ( - 🏷️ **Áreas de Conhecimento**: Organiza cursos por áreas temáticas - 🔧 **Configurável**: Timeouts, retries, user agents personalizáveis - 📦 **Modular**: Código organizado e separado em módulos -- ✅ **Testes Completos**: Cobertura abrangente de testes (36 testes) +- ✅ **Testes Completos**: Cobertura abrangente de testes (46 testes) - 🔄 **Retry Automático**: Mecanismo de retry para requisições falhadas - 📝 **Logging Detalhado**: Logs para debugging e monitoramento - ⚡ **Rate Limiting**: Respeita limites do servidor com delays entre requisições +- 🐳 **Docker Ready**: Arquitetura completa com Docker Compose para produção +- 🔗 **Integração FACODI**: Exportação automática para Hugo/FACODI.pt ## Instalação +### Opção 1: Docker (Recomendado para Produção) + +**Pré-requisitos:** +- Docker e Docker Compose instalados + +**Setup rápido:** + +1. Clone o repositório: +```bash +git clone https://github.com/Monynha-Softwares/Scrape-UAlg-Courses.git +cd Scrape-UAlg-Courses +``` + +2. Configure variáveis de ambiente: +```bash +cp .env.example .env +# Edite .env conforme necessário +``` + +3. (Opcional) Clone o repositório FACODI para integração: +```bash +git clone https://github.com/Monynha-Softwares/facodi.pt.git facodi-clone +``` + +4. Inicie os serviços: +```bash +make docker-up +# ou +docker-compose up -d +``` + +Serviços disponíveis: +- **API**: http://localhost:8000 +- **FACODI**: http://localhost:3000 (se facodi-clone existe) + +5. Execute o scraper (opcional): +```bash +make docker-scraper +# ou +docker-compose --profile scraper up scraper +``` + +6. Exporte para Hugo (opcional): +```bash +docker-compose run --rm api python scripts/export_to_hugo.py +``` + +### Opção 2: Instalação Local (Desenvolvimento) + ### Pré-requisitos - Python 3.10 ou superior @@ -272,6 +323,13 @@ Scrape-UAlg-Courses/ │ │ ├── bug_report.md # Template para reportar bugs │ │ └── feature_request.md # Template para solicitar features │ └── PULL_REQUEST_TEMPLATE.md # Template para pull requests +├── docker/ +│ ├── api.Dockerfile # Dockerfile para serviço API +│ ├── scraper.Dockerfile # Dockerfile para serviço scraper +│ ├── facodi.Dockerfile # Dockerfile para FACODI/Hugo +│ └── nginx.conf # Configuração nginx para FACODI +├── docs/ +│ └── INTEGRATION.md # Guia de integração FACODI ├── src/ │ ├── __init__.py # Inicialização do pacote │ ├── config.py # Gerenciamento de configuração @@ -280,6 +338,9 @@ Scrape-UAlg-Courses/ │ └── api.py # API REST FastAPI ├── templates/ │ └── index.html # Interface web moderna +├── scripts/ +│ ├── populate_demo_data.py # Popular BD com dados demo +│ └── export_to_hugo.py # Exportar BD para Hugo/FACODI ├── tests/ │ ├── __init__.py │ ├── test_scraper.py # Testes do scraper básico @@ -287,7 +348,10 @@ Scrape-UAlg-Courses/ │ └── test_api.py # Testes da API ├── data/ │ └── docs/ # Documentos baixados (PDFs) +├── facodi-clone/ # Clone do facodi.pt (opcional) +├── .env.example # Exemplo de variáveis de ambiente ├── .gitignore # Regras do git ignore +├── docker-compose.yml # Orquestração Docker ├── README.md # Este arquivo ├── LICENSE # Licença MIT ├── CONTRIBUTING.md # Guia de contribuição @@ -298,6 +362,92 @@ Scrape-UAlg-Courses/ └── Makefile # Automação de tarefas comuns ``` +## Docker Compose + +### Arquitetura de Serviços + +O sistema utiliza Docker Compose com 4 serviços isolados: + +1. **API Service (ualg-api)** + - FastAPI servindo endpoints REST + - Acesso ao SQLite compartilhado + - Interface web de visualização + - Porta: 8000 + +2. **Scraper Service (ualg-scraper)** + - Executa scraping sob demanda + - Popula banco de dados SQLite + - Baixa documentos para volume compartilhado + - Perfil: `scraper` (não inicia automaticamente) + +3. **FACODI Frontend (ualg-facodi)** + - Site estático Hugo + - Servido via nginx + - Consome API para dados dinâmicos + - Porta: 3000 + +### Comandos Docker + +**Construir imagens:** +```bash +make docker-build +``` + +**Iniciar serviços (API + FACODI):** +```bash +make docker-up +``` + +**Parar serviços:** +```bash +make docker-down +``` + +**Executar scraper:** +```bash +make docker-scraper +``` + +**Exportar para Hugo:** +```bash +docker-compose run --rm api python scripts/export_to_hugo.py +``` + +**Ver logs:** +```bash +docker-compose logs -f api +docker-compose logs -f scraper +``` + +### Integração FACODI + +Para integração completa com o FACODI.pt, consulte [docs/INTEGRATION.md](docs/INTEGRATION.md). + +**Passo a passo:** + +1. Clone o repositório FACODI: +```bash +git clone https://github.com/Monynha-Softwares/facodi.pt.git facodi-clone +``` + +2. Execute o scraper para popular dados: +```bash +make docker-scraper +``` + +3. Exporte dados para Hugo: +```bash +python scripts/export_to_hugo.py +``` + +4. Reconstrua o serviço FACODI: +```bash +docker-compose build facodi +docker-compose up -d facodi +``` + +5. Acesse o site em http://localhost:3000 + ## Desenvolvimento ### Executar Testes @@ -348,10 +498,19 @@ O scraper pode ser configurado usando a classe `Config` ou variáveis de ambient - `timeout`: Timeout de requisição em segundos (padrão: 30) - `user_agent`: String de user agent para requisições - `max_retries`: Número máximo de tentativas de retry (padrão: 3) +- `request_delay`: Delay entre requisições em segundos (padrão: 0.5) ### Variáveis de Ambiente -- `UALG_BASE_URL`: Substituir a URL base padrão +Copie `.env.example` para `.env` e configure conforme necessário: + +- `UALG_BASE_URL`: URL base para scraping (padrão: `https://www.ualg.pt`) +- `DB_PATH`: Caminho para o banco de dados SQLite (padrão: `ualg_courses.db`) +- `MAX_RETRIES`: Número máximo de tentativas (padrão: 3) +- `REQUEST_DELAY`: Delay entre requisições em segundos (padrão: 0.5) +- `REQUEST_TIMEOUT`: Timeout de requisições em segundos (padrão: 30) +- `API_PORT`: Porta da API (padrão: 8000) +- `FACODI_PORT`: Porta do FACODI (padrão: 3000) ## Endpoints da API @@ -365,6 +524,8 @@ O scraper pode ser configurado usando a classe `Config` ou variáveis de ambient | GET | `/schools` | Listar escolas/faculdades | | GET | `/areas` | Listar áreas de conhecimento | | GET | `/stats` | Obter estatísticas gerais | +| GET | `/api/v1/courses/export` | Exportar todos os cursos em formato Hugo | +| GET | `/api/v1/course/{id}/markdown` | Obter curso específico em formato Markdown | ### Parâmetros de Filtro (GET /courses) @@ -374,6 +535,27 @@ O scraper pode ser configurado usando a classe `Config` ou variáveis de ambient - `limit`: Número máximo de resultados (padrão: 100, máx: 500) - `offset`: Offset para paginação (padrão: 0) +### Novos Endpoints de Exportação + +**GET /api/v1/courses/export** +- Exporta todos os cursos com módulos, documentos e áreas +- Formato JSON adequado para processamento em Hugo +- Inclui timestamp de exportação + +**GET /api/v1/course/{id}/markdown** +- Retorna um curso específico em formato Markdown +- Inclui front matter YAML para Hugo +- Pronto para ser salvo como arquivo `.md` + +Exemplo: +```bash +# Exportar todos os cursos +curl http://localhost:8000/api/v1/courses/export > courses.json + +# Obter curso como markdown +curl http://localhost:8000/api/v1/course/1/markdown > curso.md +``` + ## Boas Práticas e Considerações ### Rate Limiting @@ -423,15 +605,21 @@ Se encontrar problemas ou tiver dúvidas: - [x] Banco de dados SQLite normalizado - [x] API REST completa - [x] Download automático de documentos -- [x] Testes completos (36 testes) +- [x] Testes completos (46 testes) - [x] Extração de módulos/UCs com ECTS, ano, semestre - [x] Extração e organização por áreas de conhecimento - [x] Interface web moderna e responsiva - [x] Dashboard com estatísticas e gráficos - [x] Filtros interativos por nível, escola e área +- [x] Arquitetura Docker Compose com 4 serviços +- [x] Exportação de dados para Hugo/FACODI +- [x] API endpoints para exportação (JSON, Markdown) +- [x] Rate limiting configurável +- [x] Documentação de integração FACODI +- [ ] Layouts Hugo para páginas de cursos - [ ] Interface de linha de comando (CLI) -- [ ] Exportação de dados (JSON, CSV, Excel) -- [ ] Suporte para agendamento automático -- [ ] Dashboard web para visualização de dados +- [ ] Exportação adicional (CSV, Excel) +- [ ] Suporte para agendamento automático (cron/GitHub Actions) - [ ] Notificações de mudanças em cursos -- [ ] Parser de PDFs para extrair planos curriculares detalhados \ No newline at end of file +- [ ] Parser de PDFs para extrair planos curriculares detalhados +- [ ] CI/CD para build e deploy automático \ No newline at end of file diff --git a/docs/HUGO_LAYOUTS_EXAMPLE.md b/docs/HUGO_LAYOUTS_EXAMPLE.md new file mode 100644 index 0000000..36f3468 --- /dev/null +++ b/docs/HUGO_LAYOUTS_EXAMPLE.md @@ -0,0 +1,369 @@ +# Exemplo de Layout Hugo para Cursos + +Estes são exemplos de layouts que podem ser criados em `facodi-clone/layouts/courses/`. + +## list.html - Página de Listagem de Cursos + +```html +{{ define "main" }} +{{ . }}
+ {{ end }} + + Ver mais → +