From de892149c4d6357268f27710ac0e8b027c7d623b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 09:05:07 +0000 Subject: [PATCH 1/3] Initial plan From 6309ee271a295b1c640e4734f7efc1a1afe477de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 09:23:33 +0000 Subject: [PATCH 2/3] Adiciona scraper completo, banco de dados SQLite e API REST FastAPI Co-authored-by: marcelo-m7 <117441129+marcelo-m7@users.noreply.github.com> --- .gitignore | 5 + Makefile | 18 +- README.md | 362 ++++++++++++++++++++++++++--------- requirements-dev.txt | 1 + requirements.txt | 3 + schema.sql | 91 +++++++++ src/api.py | 335 ++++++++++++++++++++++++++++++++ src/config.py | 17 +- src/scrape_ualg.py | 390 ++++++++++++++++++++++++++++++++++++++ src/scraper.py | 27 +-- tests/test_api.py | 188 ++++++++++++++++++ tests/test_scrape_ualg.py | 212 +++++++++++++++++++++ tests/test_scraper.py | 22 +-- 13 files changed, 1536 insertions(+), 135 deletions(-) create mode 100644 schema.sql create mode 100644 src/api.py create mode 100644 src/scrape_ualg.py create mode 100644 tests/test_api.py create mode 100644 tests/test_scrape_ualg.py diff --git a/.gitignore b/.gitignore index f4daebb..17d3134 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,8 @@ dmypy.json # OS .DS_Store Thumbs.db + +# Scraper específico +ualg_courses.db +data/ +*.db diff --git a/Makefile b/Makefile index 11ddd7f..e088d0c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev test lint format clean run +.PHONY: help install install-dev test lint format clean run run-scraper run-api init-db help: @echo "Available targets:" @@ -8,7 +8,10 @@ help: @echo " lint - Run linting with flake8" @echo " format - Format code with black" @echo " clean - Remove build artifacts and cache files" - @echo " run - Run the scraper" + @echo " run - Run the scraper (alias for run-scraper)" + @echo " run-scraper - Run the scraper" + @echo " run-api - Run the API server" + @echo " init-db - Initialize the database" install: pip install -r requirements.txt @@ -36,4 +39,13 @@ clean: find . -type d -name "htmlcov" -exec rm -rf {} + run: - python -m src.scraper + python -m src.scrape_ualg + +run-scraper: + python -m src.scrape_ualg + +run-api: + uvicorn src.api:app --reload --host 0.0.0.0 --port 8000 + +init-db: + python -c "from src.scrape_ualg import UAlgCourseScraper; s = UAlgCourseScraper(); s.init_db()" diff --git a/README.md b/README.md index 3679c6c..fc4b845 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,93 @@ # Scrape-UAlg-Courses -A Python-based web scraper for collecting course information from the University of Algarve (UAlg) website. +Sistema completo de scraping e API REST para cursos da Universidade do Algarve (UAlg). -## Features +## Características -- 🚀 Easy-to-use API for scraping UAlg course data -- 🔧 Configurable scraper settings (timeouts, retries, user agents) -- 📦 Modular design with separate configuration and scraping logic -- ✅ Comprehensive test coverage -- 🔄 Automatic retry mechanism for failed requests -- 📝 Detailed logging for debugging +- 🚀 **Scraper Completo**: Extrai informações detalhadas de cursos da UAlg +- 💾 **Banco de Dados SQLite**: Armazena dados em schema normalizado +- 🌐 **API REST com FastAPI**: Endpoints para consultar cursos, níveis, escolas e áreas +- 📄 **Download de Documentos**: Faz download automático de PDFs e documentos +- 🔧 **Configurável**: Timeouts, retries, user agents personalizáveis +- 📦 **Modular**: Código organizado e separado em módulos +- ✅ **Testes Completos**: Cobertura abrangente de 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 -## Installation +## Instalação -### Prerequisites +### Pré-requisitos -- Python 3.10 or higher -- pip (Python package manager) +- Python 3.10 ou superior +- pip (gerenciador de pacotes Python) -### Setup +### Configuração -1. Clone the repository: +1. Clone o repositório: ```bash git clone https://github.com/Monynha-Softwares/Scrape-UAlg-Courses.git cd Scrape-UAlg-Courses ``` -2. Install dependencies: +2. Instale as dependências: ```bash make install ``` -Or manually: +Ou manualmente: ```bash pip install -r requirements.txt ``` -### Development Setup +### Configuração para Desenvolvimento -For development, install additional dependencies: +Para desenvolvimento, instale dependências adicionais: ```bash make install-dev ``` -Or manually: +Ou manualmente: ```bash pip install -r requirements-dev.txt ``` -## Usage +## Estrutura do Banco de Dados -### Basic Usage +O sistema utiliza SQLite com as seguintes tabelas normalizadas: + +- **levels**: Níveis de curso (Licenciatura, Mestrado, Doutoramento) +- **schools**: Escolas/Faculdades +- **areas**: Áreas de conhecimento +- **courses**: Informações dos cursos +- **modules**: Unidades curriculares/módulos +- **course_documents**: Documentos e PDFs associados +- **course_area**: Relação many-to-many entre cursos e áreas +- **module_course**: Relação many-to-many entre módulos e cursos + +### Inicializar Banco de Dados + +```bash +make init-db +``` + +Ou com Python: +```python +from src.scrape_ualg import UAlgCourseScraper + +scraper = UAlgCourseScraper() +scraper.init_db() +``` + +## Uso + +### Scraper Básico (Original) ```python from src.scraper import UAlgScraper from src.config import Config -# Using default configuration +# Usando configuração padrão scraper = UAlgScraper() courses = scraper.scrape_courses() @@ -66,13 +97,122 @@ for course in courses: scraper.close() ``` -### Using Custom Configuration +### Scraper Completo com Banco de Dados + +```python +from src.scrape_ualg import UAlgCourseScraper + +# Criar scraper +scraper = UAlgCourseScraper() + +# Inicializar banco de dados +scraper.init_db() + +# Fazer scraping de todos os cursos (ou limitar com limit=N) +scraper.scrape_all_courses(limit=10) + +scraper.close() +``` + +### Executar Scraper via Linha de Comando + +```bash +# Rodar scraper completo +make run-scraper + +# Ou diretamente com Python +python -m src.scrape_ualg +``` + +### API REST + +#### Iniciar Servidor API + +```bash +# Usando make +make run-api + +# Ou diretamente com uvicorn +uvicorn src.api:app --reload --host 0.0.0.0 --port 8000 +``` + +A API estará disponível em `http://localhost:8000` + +#### Documentação Interativa da API + +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +#### Exemplos de Uso da API + +**Listar todos os cursos:** +```bash +curl http://localhost:8000/courses +``` + +**Obter detalhes de um curso:** +```bash +curl http://localhost:8000/courses/1 +``` + +**Filtrar cursos por nível:** +```bash +curl "http://localhost:8000/courses?level=Licenciatura" +``` + +**Filtrar cursos por escola:** +```bash +curl "http://localhost:8000/courses?school=Faculdade%20de%20Ciências" +``` + +**Obter estatísticas:** +```bash +curl http://localhost:8000/stats +``` + +**Listar níveis disponíveis:** +```bash +curl http://localhost:8000/levels +``` + +**Listar escolas:** +```bash +curl http://localhost:8000/schools +``` + +**Listar áreas:** +```bash +curl http://localhost:8000/areas +``` + +### Uso com Python (Cliente da API) + +```python +import requests + +# Listar cursos +response = requests.get("http://localhost:8000/courses") +courses = response.json() + +# Obter curso específico +response = requests.get("http://localhost:8000/courses/1") +course = response.json() +print(f"Curso: {course['title']}") +print(f"Módulos: {len(course['modules'])}") +print(f"Documentos: {len(course['documents'])}") + +# Filtrar por nível +response = requests.get("http://localhost:8000/courses", params={"level": "Mestrado"}) +mestrados = response.json() +``` + +### Configuração Personalizada ```python from src.scraper import UAlgScraper from src.config import Config -# Custom configuration +# Configuração personalizada config = Config( base_url="https://www.ualg.pt", timeout=60, @@ -84,123 +224,169 @@ courses = scraper.scrape_courses() scraper.close() ``` -### Using Context Manager - -```python -from src.scraper import UAlgScraper - -with UAlgScraper() as scraper: - courses = scraper.scrape_courses() - # Process courses... -``` - -## Project Structure +## Estrutura do Projeto ``` -monynha-backend-scraper/ +Scrape-UAlg-Courses/ ├── .github/ │ ├── workflows/ │ │ └── python-app.yml # GitHub Actions CI/CD │ ├── ISSUE_TEMPLATE/ -│ │ ├── bug_report.md # Bug report template -│ │ └── feature_request.md # Feature request template -│ └── PULL_REQUEST_TEMPLATE.md # PR template +│ │ ├── bug_report.md # Template para reportar bugs +│ │ └── feature_request.md # Template para solicitar features +│ └── PULL_REQUEST_TEMPLATE.md # Template para pull requests ├── src/ -│ ├── __init__.py # Package initialization -│ ├── scraper.py # Main scraper logic -│ └── config.py # Configuration management +│ ├── __init__.py # Inicialização do pacote +│ ├── config.py # Gerenciamento de configuração +│ ├── scraper.py # Scraper básico (original) +│ ├── scrape_ualg.py # Scraper completo com BD +│ └── api.py # API REST FastAPI ├── tests/ │ ├── __init__.py -│ └── test_scraper.py # Unit tests -├── .gitignore # Git ignore rules -├── README.md # This file -├── LICENSE # MIT License -├── CONTRIBUTING.md # Contribution guidelines -├── CODE_OF_CONDUCT.md # Code of conduct -├── requirements.txt # Production dependencies -├── requirements-dev.txt # Development dependencies -└── Makefile # Common tasks automation +│ ├── test_scraper.py # Testes do scraper básico +│ ├── test_scrape_ualg.py # Testes do scraper completo +│ └── test_api.py # Testes da API +├── data/ +│ └── docs/ # Documentos baixados (PDFs) +├── .gitignore # Regras do git ignore +├── README.md # Este arquivo +├── LICENSE # Licença MIT +├── CONTRIBUTING.md # Guia de contribuição +├── CODE_OF_CONDUCT.md # Código de conduta +├── schema.sql # Schema do banco de dados +├── requirements.txt # Dependências de produção +├── requirements-dev.txt # Dependências de desenvolvimento +└── Makefile # Automação de tarefas comuns ``` -## Development +## Desenvolvimento -### Running Tests +### Executar Testes -Run all tests: +Rodar todos os testes: ```bash make test ``` -Or using pytest directly: +Ou usando pytest diretamente: ```bash pytest tests/ -v ``` +Com cobertura: +```bash +pytest tests/ -v --cov=src --cov-report=html +``` + ### Linting -Check code style: +Verificar estilo de código: ```bash make lint ``` -### Formatting +### Formatação -Format code with black: +Formatar código com black: ```bash make format ``` -### Cleaning +### Limpeza -Remove build artifacts and cache files: +Remover arquivos de build e cache: ```bash make clean ``` -## Configuration +## Configuração + +O scraper pode ser configurado usando a classe `Config` ou variáveis de ambiente: + +### Opções de Configuração + +- `base_url`: URL base do site da UAlg (padrão: `https://www.ualg.pt`) +- `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) + +### Variáveis de Ambiente + +- `UALG_BASE_URL`: Substituir a URL base padrão + +## Endpoints da API + +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| GET | `/` | Informações da API | +| GET | `/courses` | Listar cursos (com filtros opcionais) | +| GET | `/courses/{id}` | Obter detalhes de um curso | +| GET | `/levels` | Listar níveis de curso | +| GET | `/schools` | Listar escolas/faculdades | +| GET | `/areas` | Listar áreas de conhecimento | +| GET | `/stats` | Obter estatísticas gerais | + +### Parâmetros de Filtro (GET /courses) + +- `level`: Filtrar por nível (ex: "Licenciatura", "Mestrado") +- `school`: Filtrar por escola +- `area`: Filtrar por área de conhecimento +- `limit`: Número máximo de resultados (padrão: 100) +- `offset`: Offset para paginação (padrão: 0) + +## Boas Práticas e Considerações + +### Rate Limiting + +O scraper implementa delays entre requisições (1.5 segundos) para respeitar o servidor da UAlg. **Não modifique** estes valores sem considerar o impacto no servidor. + +### Robots.txt -The scraper can be configured using the `Config` class or environment variables: +Antes de executar o scraper em produção, verifique e respeite as regras do `robots.txt` da UAlg. -### Configuration Options +### User Agent -- `base_url`: Base URL for UAlg website (default: `https://www.ualg.pt`) -- `timeout`: Request timeout in seconds (default: 30) -- `user_agent`: User agent string for requests -- `max_retries`: Maximum number of retry attempts (default: 3) +O scraper usa um User-Agent amigável que identifica a ferramenta. Mantenha isso para facilitar a identificação. -### Environment Variables +### Armazenamento de Documentos -- `UALG_BASE_URL`: Override the default base URL +Documentos (PDFs) são baixados para `data/docs/`. Este diretório é excluído do git via `.gitignore`. -## Contributing +## Contribuindo -Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. +Contribuições são bem-vindas! Por favor, leia [CONTRIBUTING.md](CONTRIBUTING.md) para detalhes sobre o código de conduta e o processo para enviar pull requests. -## License +## Licença -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +Este projeto está licenciado sob a Licença MIT - veja o arquivo [LICENSE](LICENSE) para detalhes. -## Code of Conduct +## Código de Conduta -Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details on our code of conduct. +Por favor, leia [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) para detalhes sobre o código de conduta. -## Support +## Suporte -If you encounter any issues or have questions: +Se encontrar problemas ou tiver dúvidas: -1. Check the [Issues](https://github.com/Monynha-Softwares/Scrape-UAlg-Courses/issues) page -2. Create a new issue using the appropriate template -3. Provide as much detail as possible +1. Verifique a página de [Issues](https://github.com/Monynha-Softwares/Scrape-UAlg-Courses/issues) +2. Crie uma nova issue usando o template apropriado +3. Forneça o máximo de detalhes possível -## Acknowledgments +## Agradecimentos -- Built with [Requests](https://requests.readthedocs.io/) and [Beautiful Soup](https://www.crummy.com/software/BeautifulSoup/) -- Inspired by the need for easy access to UAlg course information +- Construído com [Requests](https://requests.readthedocs.io/), [Beautiful Soup](https://www.crummy.com/software/BeautifulSoup/) e [FastAPI](https://fastapi.tiangolo.com/) +- Inspirado pela necessidade de fácil acesso às informações de cursos da UAlg ## Roadmap -- [ ] Add support for more data fields -- [ ] Implement caching mechanism -- [ ] Add CLI interface -- [ ] Create data export functionality (JSON, CSV) -- [ ] Add scheduling support for periodic scraping \ No newline at end of file +- [x] Scraper básico de cursos +- [x] Banco de dados SQLite normalizado +- [x] API REST completa +- [x] Download automático de documentos +- [x] Testes completos +- [ ] 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 +- [ ] Notificações de mudanças em cursos +- [ ] Parser de PDFs para extrair planos curriculares detalhados \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 5596cd4..2314da5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ pytest-mock>=3.11.0 flake8>=6.0.0 black>=23.7.0 mypy>=1.4.0 +httpx>=0.24.0 diff --git a/requirements.txt b/requirements.txt index 4cca127..766961b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ requests>=2.31.0 beautifulsoup4>=4.12.0 lxml>=4.9.0 +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +pydantic>=2.0.0 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..6e6e51e --- /dev/null +++ b/schema.sql @@ -0,0 +1,91 @@ +-- Schema SQL para banco de dados de cursos da UAlg +-- Tabelas normalizadas para armazenar informações de cursos, módulos e documentos + +PRAGMA foreign_keys = ON; + +-- Tabela de níveis de curso (Licenciatura, Mestrado, Doutoramento, etc.) +CREATE TABLE IF NOT EXISTS levels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL +); + +-- Tabela de escolas/faculdades +CREATE TABLE IF NOT EXISTS schools ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL +); + +-- Tabela de áreas de conhecimento +CREATE TABLE IF NOT EXISTS areas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL +); + +-- Tabela principal de cursos +CREATE TABLE IF NOT EXISTS courses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT, + title TEXT NOT NULL, + description TEXT, + level_id INTEGER, + school_id INTEGER, + language TEXT, + regime TEXT, + modality TEXT, + url TEXT UNIQUE, + last_scraped TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(level_id) REFERENCES levels(id), + FOREIGN KEY(school_id) REFERENCES schools(id) +); + +-- Tabela de relação many-to-many entre cursos e áreas +CREATE TABLE IF NOT EXISTS course_area ( + course_id INTEGER, + area_id INTEGER, + PRIMARY KEY(course_id, area_id), + FOREIGN KEY(course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY(area_id) REFERENCES areas(id) ON DELETE CASCADE +); + +-- Tabela de módulos/unidades curriculares +CREATE TABLE IF NOT EXISTS modules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT, + title TEXT NOT NULL, + description TEXT, + ects REAL, + year INTEGER, + semester INTEGER, + UNIQUE(code, title) +); + +-- Tabela de relação many-to-many entre módulos e cursos +CREATE TABLE IF NOT EXISTS module_course ( + module_id INTEGER, + course_id INTEGER, + mandatory BOOLEAN DEFAULT 1, + PRIMARY KEY(module_id, course_id), + FOREIGN KEY(module_id) REFERENCES modules(id) ON DELETE CASCADE, + FOREIGN KEY(course_id) REFERENCES courses(id) ON DELETE CASCADE +); + +-- Tabela de documentos associados aos cursos (PDFs, etc.) +CREATE TABLE IF NOT EXISTS course_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER, + title TEXT, + url TEXT, + local_path TEXT, + filetype TEXT, + FOREIGN KEY(course_id) REFERENCES courses(id) ON DELETE CASCADE +); + +-- Índices para melhorar performance de consultas +CREATE INDEX IF NOT EXISTS idx_courses_level ON courses(level_id); +CREATE INDEX IF NOT EXISTS idx_courses_school ON courses(school_id); +CREATE INDEX IF NOT EXISTS idx_courses_url ON courses(url); +CREATE INDEX IF NOT EXISTS idx_course_area_course ON course_area(course_id); +CREATE INDEX IF NOT EXISTS idx_course_area_area ON course_area(area_id); +CREATE INDEX IF NOT EXISTS idx_module_course_course ON module_course(course_id); +CREATE INDEX IF NOT EXISTS idx_module_course_module ON module_course(module_id); +CREATE INDEX IF NOT EXISTS idx_course_documents_course ON course_documents(course_id); diff --git a/src/api.py b/src/api.py new file mode 100644 index 0000000..7d7e6aa --- /dev/null +++ b/src/api.py @@ -0,0 +1,335 @@ +""" +API REST usando FastAPI para consultar dados de cursos da UAlg. + +Esta API fornece endpoints para: +- Listar cursos com filtros +- Obter detalhes de um curso específico +- Consultar módulos e documentos +""" + +from fastapi import FastAPI, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware +import sqlite3 +from typing import List, Optional, Dict, Any +from pydantic import BaseModel + +# Configurações +DB_PATH = "ualg_courses.db" + +# Criar aplicação FastAPI +app = FastAPI( + title="API de Cursos UAlg", + description="API REST para consultar informações sobre cursos da Universidade do Algarve", + version="1.0.0", +) + +# Configurar CORS para permitir acesso do frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Modelos Pydantic +class Course(BaseModel): + """Modelo de curso.""" + + id: int + code: Optional[str] = None + title: str + description: Optional[str] = None + level_id: Optional[int] = None + school_id: Optional[int] = None + language: Optional[str] = None + regime: Optional[str] = None + modality: Optional[str] = None + url: Optional[str] = None + last_scraped: Optional[str] = None + + +class CourseDetail(Course): + """Modelo detalhado de curso com módulos e documentos.""" + + modules: List[Dict[str, Any]] = [] + documents: List[Dict[str, Any]] = [] + level_name: Optional[str] = None + school_name: Optional[str] = None + areas: List[str] = [] + + +class Level(BaseModel): + """Modelo de nível de curso.""" + + id: int + name: str + + +class School(BaseModel): + """Modelo de escola.""" + + id: int + name: str + + +class Area(BaseModel): + """Modelo de área.""" + + id: int + name: str + + +# Funções auxiliares +def get_db_connection(): + """Cria e retorna conexão com o banco de dados.""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def dict_from_row(row: sqlite3.Row) -> Dict: + """Converte sqlite3.Row em dicionário.""" + return {key: row[key] for key in row.keys()} + + +# Endpoints +@app.get("/", tags=["root"]) +def read_root(): + """Endpoint raiz com informações da API.""" + return { + "message": "API de Cursos da UAlg", + "version": "1.0.0", + "endpoints": { + "courses": "/courses", + "course_detail": "/courses/{course_id}", + "levels": "/levels", + "schools": "/schools", + "areas": "/areas", + }, + } + + +@app.get("/courses", response_model=List[Course], tags=["courses"]) +def list_courses( + level: Optional[str] = Query(None, description="Filtrar por nome do nível"), + school: Optional[str] = Query(None, description="Filtrar por nome da escola"), + area: Optional[str] = Query(None, description="Filtrar por nome da área"), + limit: int = Query(100, ge=1, le=500, description="Número máximo de resultados"), + offset: int = Query(0, ge=0, description="Offset para paginação"), +): + """ + Lista cursos com filtros opcionais. + + - **level**: Filtrar por nível (ex: "Licenciatura", "Mestrado") + - **school**: Filtrar por escola + - **area**: Filtrar por área de conhecimento + - **limit**: Número máximo de resultados (padrão: 100) + - **offset**: Offset para paginação (padrão: 0) + """ + conn = get_db_connection() + + try: + query = "SELECT c.* FROM courses c" + joins = [] + wheres = [] + params = [] + + # Adicionar joins e filtros conforme necessário + if level: + joins.append("JOIN levels l ON l.id = c.level_id") + wheres.append("l.name = ?") + params.append(level) + + if school: + joins.append("JOIN schools s ON s.id = c.school_id") + wheres.append("s.name = ?") + params.append(school) + + if area: + joins.append("JOIN course_area ca ON ca.course_id = c.id") + joins.append("JOIN areas a ON a.id = ca.area_id") + wheres.append("a.name = ?") + params.append(area) + + # Construir query completa + if joins: + query += " " + " ".join(joins) + if wheres: + query += " WHERE " + " AND ".join(wheres) + + query += f" LIMIT {limit} OFFSET {offset}" + + cur = conn.execute(query, params) + rows = cur.fetchall() + + return [dict_from_row(row) for row in rows] + + finally: + conn.close() + + +@app.get("/courses/{course_id}", response_model=CourseDetail, tags=["courses"]) +def get_course(course_id: int): + """ + Obtém detalhes completos de um curso específico. + + Inclui: + - Informações básicas do curso + - Módulos/unidades curriculares + - Documentos associados + - Nome do nível e escola + - Áreas de conhecimento + """ + 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 + cur = conn.execute( + """ + SELECT m.*, mc.mandatory + FROM modules m + JOIN module_course mc ON mc.module_id = m.id + WHERE mc.course_id = ? + """, + (course_id,), + ) + course["modules"] = [dict_from_row(r) for r in cur.fetchall()] + + # Buscar documentos + 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 cur.fetchall()] + + # Buscar áreas + 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"] = [row["name"] for row in cur.fetchall()] + + return course + + finally: + conn.close() + + +@app.get("/levels", response_model=List[Level], tags=["metadata"]) +def list_levels(): + """Lista todos os níveis de curso disponíveis.""" + conn = get_db_connection() + + try: + cur = conn.execute("SELECT * FROM levels ORDER BY name") + return [dict_from_row(row) for row in cur.fetchall()] + finally: + conn.close() + + +@app.get("/schools", response_model=List[School], tags=["metadata"]) +def list_schools(): + """Lista todas as escolas/faculdades disponíveis.""" + conn = get_db_connection() + + try: + cur = conn.execute("SELECT * FROM schools ORDER BY name") + return [dict_from_row(row) for row in cur.fetchall()] + finally: + conn.close() + + +@app.get("/areas", response_model=List[Area], tags=["metadata"]) +def list_areas(): + """Lista todas as áreas de conhecimento disponíveis.""" + conn = get_db_connection() + + try: + cur = conn.execute("SELECT * FROM areas ORDER BY name") + return [dict_from_row(row) for row in cur.fetchall()] + finally: + conn.close() + + +@app.get("/stats", tags=["metadata"]) +def get_stats(): + """Retorna estatísticas gerais sobre os dados.""" + conn = get_db_connection() + + try: + stats = {} + + # Contar cursos + cur = conn.execute("SELECT COUNT(*) as count FROM courses") + stats["total_courses"] = cur.fetchone()["count"] + + # Contar módulos + cur = conn.execute("SELECT COUNT(*) as count FROM modules") + stats["total_modules"] = cur.fetchone()["count"] + + # Contar documentos + cur = conn.execute("SELECT COUNT(*) as count FROM course_documents") + stats["total_documents"] = cur.fetchone()["count"] + + # Contar por nível + cur = conn.execute( + """ + SELECT l.name, COUNT(c.id) as count + FROM levels l + LEFT JOIN courses c ON c.level_id = l.id + GROUP BY l.id, l.name + """ + ) + stats["courses_by_level"] = [dict_from_row(row) for row in cur.fetchall()] + + # Contar por escola + cur = conn.execute( + """ + SELECT s.name, COUNT(c.id) as count + FROM schools s + LEFT JOIN courses c ON c.school_id = s.id + GROUP BY s.id, s.name + """ + ) + stats["courses_by_school"] = [dict_from_row(row) for row in cur.fetchall()] + + return stats + + finally: + conn.close() + + +# Executar com: uvicorn api:app --reload +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/config.py b/src/config.py index 5dc6c90..c45bb6f 100644 --- a/src/config.py +++ b/src/config.py @@ -13,11 +13,7 @@ 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 ): """ Initialize configuration. @@ -28,10 +24,7 @@ def __init__( user_agent: User agent string. Defaults to a standard browser UA. max_retries: Maximum number of retry attempts. Default is 3. """ - self.base_url = base_url or os.getenv( - "UALG_BASE_URL", - "https://www.ualg.pt" - ) + self.base_url = base_url or os.getenv("UALG_BASE_URL", "https://www.ualg.pt") self.timeout = timeout self.user_agent = user_agent or ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " @@ -42,8 +35,4 @@ def __init__( def __repr__(self) -> str: """String representation of Config.""" - return ( - f"Config(base_url='{self.base_url}', " - f"timeout={self.timeout}, " - f"max_retries={self.max_retries})" - ) + return f"Config(base_url='{self.base_url}', " f"timeout={self.timeout}, " f"max_retries={self.max_retries})" diff --git a/src/scrape_ualg.py b/src/scrape_ualg.py new file mode 100644 index 0000000..41e3667 --- /dev/null +++ b/src/scrape_ualg.py @@ -0,0 +1,390 @@ +""" +Scraper completo para cursos da UAlg com suporte a banco de dados SQLite. + +Este módulo implementa um scraper robusto que: +- Navega pela listagem paginada de cursos +- Extrai informações detalhadas de cada curso +- Faz download de documentos (PDFs) +- Armazena dados em banco SQLite normalizado +- Respeita rate limiting e boas práticas +""" + +import requests +from bs4 import BeautifulSoup +import sqlite3 +import os +import time +import logging +from urllib.parse import urljoin, urlparse +from typing import Optional, List, Dict +from .config import Config + +# Configurar logging +logger = logging.getLogger(__name__) + +# Configurações padrão +BASE_URL = "https://www.ualg.pt" +START_URL = "https://www.ualg.pt/oferta-formativa" +DB_PATH = "ualg_courses.db" +DOC_DIR = "data/docs" + + +class UAlgCourseScraper: + """Scraper completo para cursos da UAlg com suporte a banco de dados.""" + + def __init__(self, config: Optional[Config] = None, db_path: str = DB_PATH): + """ + Inicializa o scraper. + + Args: + config: Configuração do scraper. Se None, usa configuração padrão. + db_path: Caminho para o arquivo do banco de dados SQLite. + """ + self.config = config or Config() + self.db_path = db_path + self.session = requests.Session() + self.session.headers.update({"User-Agent": self.config.user_agent}) + + # Criar diretório para documentos + os.makedirs(DOC_DIR, exist_ok=True) + + logger.info(f"Scraper inicializado com base URL: {self.config.base_url}") + + def init_db(self) -> None: + """Inicializa o banco de dados usando o schema.sql.""" + schema_path = os.path.join(os.path.dirname(__file__), "..", "schema.sql") + + if not os.path.exists(schema_path): + logger.error(f"Arquivo schema.sql não encontrado em {schema_path}") + raise FileNotFoundError("schema.sql não encontrado") + + with open(schema_path, "r", encoding="utf-8") as f: + schema = f.read() + + conn = sqlite3.connect(self.db_path) + conn.executescript(schema) + conn.commit() + conn.close() + + logger.info(f"Banco de dados inicializado em {self.db_path}") + + def get_soup(self, url: str) -> Optional[BeautifulSoup]: + """ + Faz requisição HTTP e retorna objeto BeautifulSoup. + + Args: + url: URL para fazer requisição. + + Returns: + BeautifulSoup object ou None em caso de erro. + """ + for attempt in range(self.config.max_retries): + try: + logger.info(f"Requisitando {url} (tentativa {attempt + 1}/{self.config.max_retries})") + response = self.session.get(url, timeout=self.config.timeout) + response.raise_for_status() + return BeautifulSoup(response.text, "html.parser") + except requests.RequestException as e: + logger.warning(f"Tentativa {attempt + 1} falhou: {e}") + if attempt == self.config.max_retries - 1: + logger.error(f"Falha ao requisitar {url} após {self.config.max_retries} tentativas") + return None + time.sleep(2**attempt) # Exponential backoff + return None + + def upsert_level(self, conn: sqlite3.Connection, name: str) -> int: + """Insere ou busca ID de um nível de curso.""" + if not name or name == "N/A": + return None + + cur = conn.cursor() + cur.execute("INSERT OR IGNORE INTO levels(name) VALUES(?)", (name,)) + conn.commit() + cur.execute("SELECT id FROM levels WHERE name=?", (name,)) + result = cur.fetchone() + return result[0] if result else None + + def upsert_school(self, conn: sqlite3.Connection, name: str) -> int: + """Insere ou busca ID de uma escola.""" + if not name or name == "N/A": + return None + + cur = conn.cursor() + cur.execute("INSERT OR IGNORE INTO schools(name) VALUES(?)", (name,)) + conn.commit() + cur.execute("SELECT id FROM schools WHERE name=?", (name,)) + result = cur.fetchone() + return result[0] if result else None + + def upsert_area(self, conn: sqlite3.Connection, name: str) -> int: + """Insere ou busca ID de uma área.""" + if not name or name == "N/A": + return None + + cur = conn.cursor() + cur.execute("INSERT OR IGNORE INTO areas(name) VALUES(?)", (name,)) + conn.commit() + cur.execute("SELECT id FROM areas WHERE name=?", (name,)) + result = cur.fetchone() + return result[0] if result else None + + def save_course(self, conn: sqlite3.Connection, data: Dict) -> int: + """ + Salva informações de um curso no banco de dados. + + Args: + conn: Conexão com o banco de dados. + data: Dicionário com dados do curso. + + Returns: + ID do curso inserido/atualizado. + """ + cur = conn.cursor() + cur.execute( + """ + INSERT OR IGNORE INTO courses(url, code, title, description, language, regime, modality, level_id, school_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + data.get("url"), + data.get("code"), + data.get("title"), + data.get("description"), + data.get("language"), + data.get("regime"), + data.get("modality"), + data.get("level_id"), + data.get("school_id"), + ), + ) + conn.commit() + + cur.execute("SELECT id FROM courses WHERE url=?", (data["url"],)) + result = cur.fetchone() + return result[0] if result else None + + def save_document(self, conn: sqlite3.Connection, course_id: int, title: str, url: str) -> None: + """ + Salva documento e faz download do PDF. + + Args: + conn: Conexão com o banco de dados. + course_id: ID do curso. + title: Título do documento. + url: URL do documento. + """ + parsed = urlparse(url) + filename = os.path.basename(parsed.path) or f"doc_{int(time.time())}.pdf" + local_path = os.path.join(DOC_DIR, filename) + + # Fazer download do arquivo + try: + logger.info(f"Baixando documento: {url}") + response = self.session.get(url, timeout=30) + response.raise_for_status() + + with open(local_path, "wb") as f: + f.write(response.content) + + logger.info(f"Documento salvo em {local_path}") + except Exception as e: + logger.error(f"Erro ao baixar {url}: {e}") + local_path = None + + # Salvar referência no banco + cur = conn.cursor() + filetype = os.path.splitext(filename)[1].lower() + cur.execute( + """ + INSERT INTO course_documents(course_id, title, url, local_path, filetype) + VALUES (?, ?, ?, ?, ?) + """, + (course_id, title, url, local_path, filetype), + ) + conn.commit() + + def parse_course_page(self, soup: BeautifulSoup, url: str) -> Dict: + """ + Extrai informações de uma página de curso. + + Args: + soup: BeautifulSoup object da página. + url: URL da página do curso. + + Returns: + Dicionário com informações do curso. + """ + data = {"url": url} + + # Título do curso + h1 = soup.find("h1") + data["title"] = h1.get_text(strip=True) if h1 else "Título não encontrado" + + # Descrição + desc = soup.select_one(".field--name-body, .text, .course-description") + data["description"] = desc.get_text(separator="\n", strip=True) if desc else None + + # Código do curso + code_elem = soup.select_one(".field--name-field-code, .course-code") + data["code"] = code_elem.get_text(strip=True) if code_elem else None + + # Nível (Licenciatura, Mestrado, etc.) + level_elem = soup.select_one(".field--name-field-level, .course-level") + data["level"] = level_elem.get_text(strip=True) if level_elem else None + + # Escola/Faculdade + school_elem = soup.select_one(".field--name-field-school, .course-school") + data["school"] = school_elem.get_text(strip=True) if school_elem else None + + # Idioma + lang_elem = soup.select_one(".field--name-field-language, .course-language") + data["language"] = lang_elem.get_text(strip=True) if lang_elem else None + + # Regime + regime_elem = soup.select_one(".field--name-field-regime, .course-regime") + data["regime"] = regime_elem.get_text(strip=True) if regime_elem else None + + # Modalidade + modality_elem = soup.select_one(".field--name-field-modality, .course-modality") + data["modality"] = modality_elem.get_text(strip=True) if modality_elem else None + + # Documentos (PDFs) + documents = [] + for link in soup.find_all("a", href=True): + href = urljoin(url, link["href"]) + if href.lower().endswith(".pdf"): + title = link.get_text(strip=True) or "Documento" + documents.append((title, href)) + + data["documents"] = documents + + logger.info(f"Curso parseado: {data['title']}") + return data + + def get_course_links(self, soup: BeautifulSoup) -> List[str]: + """ + Extrai links de cursos de uma página de listagem. + + Args: + soup: BeautifulSoup object da página. + + Returns: + Lista de URLs de cursos. + """ + course_links = set() + + for link in soup.select("a"): + href = link.get("href") + if not href: + continue + + full_url = urljoin(BASE_URL, href) + + # Heurística: links de cursos geralmente contêm estas palavras + if any(keyword in full_url for keyword in ["/oferta-formativa/", "/curso/", "/cursos/"]): + if full_url.startswith(BASE_URL): + course_links.add(full_url) + + return list(course_links) + + def scrape_all_courses(self, limit: Optional[int] = None) -> None: + """ + Scraping completo de todos os cursos. + + Args: + limit: Número máximo de cursos a processar (None = todos). + """ + conn = sqlite3.connect(self.db_path) + conn.execute("PRAGMA foreign_keys = ON") + + try: + # Obter página inicial de listagem + logger.info(f"Iniciando scraping de {START_URL}") + soup = self.get_soup(START_URL) + + if not soup: + logger.error("Falha ao obter página inicial") + return + + # Extrair links de cursos + course_links = self.get_course_links(soup) + logger.info(f"Encontrados {len(course_links)} links de cursos") + + if limit: + course_links = course_links[:limit] + logger.info(f"Limitando a {limit} cursos") + + # Processar cada curso + for idx, link in enumerate(course_links, 1): + logger.info(f"Processando curso {idx}/{len(course_links)}: {link}") + + try: + course_soup = self.get_soup(link) + if not course_soup: + logger.warning(f"Falha ao obter página do curso: {link}") + continue + + # Parsear informações do curso + course_data = self.parse_course_page(course_soup, link) + + # Upsert nível, escola, etc. + level_id = self.upsert_level(conn, course_data.get("level")) + school_id = self.upsert_school(conn, course_data.get("school")) + + course_data["level_id"] = level_id + course_data["school_id"] = school_id + + # Salvar curso + course_id = self.save_course(conn, course_data) + + if course_id: + # Salvar documentos + for title, doc_url in course_data.get("documents", []): + self.save_document(conn, course_id, title, doc_url) + + # Rate limiting - respeitar o servidor + time.sleep(1.5) + + except Exception as e: + logger.error(f"Erro ao processar curso {link}: {e}") + continue + + logger.info(f"Scraping concluído. Processados {len(course_links)} cursos") + + finally: + conn.close() + + def close(self) -> None: + """Fecha a sessão HTTP.""" + self.session.close() + logger.info("Sessão fechada") + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + +def main(): + """Função principal para executar o scraper.""" + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") + + scraper = UAlgCourseScraper() + + # Inicializar banco de dados + logger.info("Inicializando banco de dados...") + scraper.init_db() + + # Executar scraping (limitar a 5 cursos para teste) + logger.info("Iniciando scraping...") + scraper.scrape_all_courses(limit=5) + + scraper.close() + logger.info("Scraping finalizado") + + +if __name__ == "__main__": + main() diff --git a/src/scraper.py b/src/scraper.py index 0dedac7..0cfdb63 100644 --- a/src/scraper.py +++ b/src/scraper.py @@ -14,10 +14,7 @@ # Set up logging - only configure if not already configured if not logging.getLogger().handlers: - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) @@ -33,9 +30,7 @@ def __init__(self, config: Optional[Config] = None): """ self.config = config or Config() self.session = requests.Session() - self.session.headers.update({ - 'User-Agent': self.config.user_agent - }) + self.session.headers.update({"User-Agent": self.config.user_agent}) logger.info(f"Initialized UAlgScraper with base URL: {self.config.base_url}") def fetch_page(self, url: str) -> Optional[str]: @@ -51,10 +46,7 @@ def fetch_page(self, url: str) -> Optional[str]: for attempt in range(self.config.max_retries): try: logger.info(f"Fetching {url} (attempt {attempt + 1}/{self.config.max_retries})") - response = self.session.get( - url, - timeout=self.config.timeout - ) + response = self.session.get(url, timeout=self.config.timeout) response.raise_for_status() logger.info(f"Successfully fetched {url}") return response.text @@ -75,19 +67,20 @@ def parse_courses(self, html: str) -> List[Dict[str, str]]: Returns: List of dictionaries containing course information. """ - soup = BeautifulSoup(html, 'html.parser') + soup = BeautifulSoup(html, "html.parser") courses = [] # This is a placeholder implementation # Actual parsing logic would depend on the website structure - course_elements = soup.find_all('div', class_='course') + course_elements = soup.find_all("div", class_="course") for element in course_elements: course = { - 'name': element.find('h2').text.strip() if element.find('h2') else '', - 'code': element.find('span', class_='code').text.strip() if element.find('span', class_='code') else '', - 'description': (element.find('p', class_='description').text.strip() - if element.find('p', class_='description') else '') + "name": element.find("h2").text.strip() if element.find("h2") else "", + "code": element.find("span", class_="code").text.strip() if element.find("span", class_="code") else "", + "description": ( + element.find("p", class_="description").text.strip() if element.find("p", class_="description") else "" + ), } courses.append(course) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..7bb5cac --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,188 @@ +""" +Testes para a API FastAPI de cursos da UAlg. +""" + +import pytest +import sqlite3 +from fastapi.testclient import TestClient + +from src.api import app + + +@pytest.fixture +def test_db(tmp_path): + """Fixture para criar um banco de dados de teste.""" + db_path = tmp_path / "test_api.db" + + # Criar schema + schema = """ + PRAGMA foreign_keys = ON; + + CREATE TABLE levels (id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL); + CREATE TABLE schools (id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL); + CREATE TABLE areas (id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL); + CREATE TABLE courses ( + id INTEGER PRIMARY KEY, + code TEXT, + title TEXT NOT NULL, + description TEXT, + level_id INTEGER, + school_id INTEGER, + language TEXT, + regime TEXT, + modality TEXT, + url TEXT UNIQUE, + last_scraped TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE course_area ( + course_id INTEGER, + area_id INTEGER, + PRIMARY KEY(course_id, area_id) + ); + CREATE TABLE modules ( + id INTEGER PRIMARY KEY, + code TEXT, + title TEXT NOT NULL, + description TEXT, + ects REAL, + year INTEGER, + semester INTEGER + ); + CREATE TABLE module_course ( + module_id INTEGER, + course_id INTEGER, + mandatory BOOLEAN DEFAULT 1, + PRIMARY KEY(module_id, course_id) + ); + CREATE TABLE course_documents ( + id INTEGER PRIMARY KEY, + course_id INTEGER, + title TEXT, + url TEXT, + local_path TEXT, + filetype TEXT + ); + """ + + conn = sqlite3.connect(str(db_path)) + conn.executescript(schema) + + # Inserir dados de teste + conn.execute("INSERT INTO levels (id, name) VALUES (1, 'Licenciatura')") + conn.execute("INSERT INTO schools (id, name) VALUES (1, 'Escola de Tecnologia')") + conn.execute("INSERT INTO areas (id, name) VALUES (1, 'Informática')") + + conn.execute( + """ + INSERT INTO courses (id, code, title, description, level_id, school_id, language, url) + VALUES (1, 'CS101', 'Ciência da Computação', 'Curso de informática', 1, 1, 'Português', 'https://test.com/cs101') + """ + ) + + conn.execute( + """ + INSERT INTO courses (id, code, title, description, level_id, school_id, language, url) + VALUES (2, 'ENG201', 'Engenharia Informática', 'Curso de engenharia', 1, 1, 'Português', 'https://test.com/eng201') + """ + ) + + conn.execute("INSERT INTO course_area (course_id, area_id) VALUES (1, 1)") + + conn.commit() + conn.close() + + return str(db_path) + + +@pytest.fixture +def client(test_db, monkeypatch): + """Fixture para criar cliente de teste da API.""" + # Substituir caminho do banco de dados + monkeypatch.setattr("src.api.DB_PATH", test_db) + + client = TestClient(app) + return client + + +class TestAPI: + """Testes para endpoints da API.""" + + def test_read_root(self, client): + """Testa endpoint raiz.""" + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert "message" in data + assert "endpoints" in data + + def test_list_courses(self, client): + """Testa listagem de cursos.""" + response = client.get("/courses") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 2 + + def test_list_courses_with_filter(self, client): + """Testa listagem de cursos com filtro.""" + response = client.get("/courses?level=Licenciatura") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_list_courses_with_limit(self, client): + """Testa listagem de cursos com limite.""" + response = client.get("/courses?limit=1") + assert response.status_code == 200 + data = response.json() + assert len(data) <= 1 + + def test_get_course(self, client): + """Testa obtenção de curso específico.""" + response = client.get("/courses/1") + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + assert data["title"] == "Ciência da Computação" + assert "modules" in data + assert "documents" in data + + def test_get_course_not_found(self, client): + """Testa obtenção de curso inexistente.""" + response = client.get("/courses/9999") + assert response.status_code == 404 + + def test_list_levels(self, client): + """Testa listagem de níveis.""" + response = client.get("/levels") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + assert data[0]["name"] == "Licenciatura" + + def test_list_schools(self, client): + """Testa listagem de escolas.""" + response = client.get("/schools") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + + def test_list_areas(self, client): + """Testa listagem de áreas.""" + response = client.get("/areas") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + + def test_get_stats(self, client): + """Testa obtenção de estatísticas.""" + response = client.get("/stats") + assert response.status_code == 200 + data = response.json() + assert "total_courses" in data + assert "total_modules" in data + assert "total_documents" in data + assert data["total_courses"] >= 2 diff --git a/tests/test_scrape_ualg.py b/tests/test_scrape_ualg.py new file mode 100644 index 0000000..46b01e8 --- /dev/null +++ b/tests/test_scrape_ualg.py @@ -0,0 +1,212 @@ +""" +Testes para o scraper completo de cursos da UAlg. +""" + +import pytest +import sqlite3 +import os +from unittest.mock import Mock, patch +from bs4 import BeautifulSoup + +from src.scrape_ualg import UAlgCourseScraper +from src.config import Config + + +@pytest.fixture +def test_db_path(tmp_path): + """Fixture para criar um banco de dados temporário.""" + db_path = tmp_path / "test_courses.db" + return str(db_path) + + +@pytest.fixture +def scraper(test_db_path): + """Fixture para criar um scraper de teste.""" + config = Config(base_url="https://test.com", max_retries=2) + scraper = UAlgCourseScraper(config=config, db_path=test_db_path) + return scraper + + +class TestUAlgCourseScraper: + """Testes para a classe UAlgCourseScraper.""" + + def test_scraper_initialization(self, scraper, test_db_path): + """Testa inicialização do scraper.""" + assert scraper.db_path == test_db_path + assert scraper.config is not None + assert scraper.session is not None + + def test_init_db(self, scraper): + """Testa inicialização do banco de dados.""" + scraper.init_db() + + # Verificar se o banco foi criado + assert os.path.exists(scraper.db_path) + + # Verificar se as tabelas foram criadas + conn = sqlite3.connect(scraper.db_path) + cur = conn.cursor() + + # Verificar tabelas + cur.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cur.fetchall()] + + expected_tables = [ + "levels", + "schools", + "areas", + "courses", + "course_area", + "modules", + "module_course", + "course_documents", + ] + + for table in expected_tables: + assert table in tables, f"Tabela {table} não encontrada" + + conn.close() + + @patch("src.scrape_ualg.requests.Session.get") + def test_get_soup_success(self, mock_get, scraper): + """Testa obtenção bem-sucedida de BeautifulSoup.""" + mock_response = Mock() + mock_response.text = "
Descrição do curso de informática
+