diff --git a/.envExample b/.envExample index f951550..2189da6 100644 --- a/.envExample +++ b/.envExample @@ -1,9 +1,75 @@ +# ============================================================================= +# DISCORD +# ============================================================================= + +# Token de autenticação do bot Discord. +# Obtido em: https://discord.com/developers/applications → Bot → Token API_KEY = token_bot_discord -CANAL_LANCAMENTOS = channel_id -CANAL_TESTES = channel_id_tests -CANAL_TAGS = channel_id_tags -API_ID_PAGINA_FACEBOOK = api_facebook -API_TOKEN_PAGINA = token_facebook_page +# ID do canal Discord onde as postagens de lançamentos reais serão enviadas. +# Para obter o ID: ative o Modo Desenvolvedor no Discord, clique com botão direito +# no canal → Copiar ID +CANAL_LANCAMENTOS = channel_id_canal_lancamentos + +# ID do canal Discord usado para postagens em modo de teste. +# As mensagens de teste são enviadas aqui em vez do canal de produção. +CANAL_TESTES = channel_id_canal_testes + +# ID do canal Discord onde as postagens com tags/menções de obras são enviadas. +CANAL_TAGS = channel_id_canal_tags + +# ============================================================================= +# MONGODB ATLAS +# ============================================================================= + +# URI de conexão com o cluster do MongoDB Atlas. +# Obtida em: Atlas Console → Database → Connect → Drivers +# Formato: mongodb+srv://:@.mongodb.net/?retryWrites=true&w=majority +URI_ATLAS = mongodb+srv://usuario:senha@cluster.mongodb.net/?retryWrites=true&w=majority + +# ============================================================================= +# FACEBOOK (Produção) +# ============================================================================= + +# ID numérico da página do Facebook onde os anúncios serão publicados. +# Obtido em: Configurações da Página → Informações da Página → ID da Página +API_ID_PAGINA_FACEBOOK = id_numerico_da_pagina_facebook + +# Token de acesso de longa duração da página do Facebook (produção). +# Gerado via: https://developers.facebook.com/tools/explorer/ +# Permissões necessárias: pages_manage_posts, pages_read_engagement +API_TOKEN_PAGINA = token_de_acesso_da_pagina_facebook + +# ============================================================================= +# FACEBOOK (Testes) +# Usadas apenas quando ENABLE_FB_TEST=true. Permitem testar a integração com o +# Facebook sem publicar na página de produção. +# ============================================================================= + +# ID numérico da página do Facebook de testes. +API_ID_PAGINA_FACEBOOK_TESTE = id_numerico_da_pagina_de_teste + +# Token de acesso de longa duração da página do Facebook de testes. +API_TOKEN_PAGINA_TESTE = token_de_acesso_da_pagina_de_teste + +# Ativa o modo de teste do Facebook. +# Quando "true", as postagens são feitas na página de teste (acima) em vez da produção. +# Quando "false" ou ausente, usa as credenciais de produção. +# Padrão: false +ENABLE_FB_TEST = false + +# ============================================================================= +# GOOGLE GEMINI +# ============================================================================= + +# Chave de API do Google Gemini, usada para geração de descrições das obras. +# Obtida em: https://aistudio.google.com/app/apikey +GEMINI_API = chave_de_api_do_gemini + +# ============================================================================= +# WEB SCRAPER +# ============================================================================= -URL_SITE = url_do_site_sem_https//: \ No newline at end of file +# URL base do site alvo do scraper, sem protocolo (http:// ou https://). +# Exemplo: tsundoku.com.br +URL_SITE = url_do_site_sem_protocolo \ No newline at end of file diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md new file mode 100644 index 0000000..df688f8 --- /dev/null +++ b/.github/instructions/python.instructions.md @@ -0,0 +1,56 @@ +--- +description: 'Python coding conventions and guidelines' +applyTo: '**/*.py' +--- + +# Python Coding Conventions + +## Python Instructions + +- Write clear and concise comments for each function. +- Ensure functions have descriptive names and include type hints. +- Provide docstrings following PEP 257 conventions. +- Use the `typing` module for type annotations (e.g., `List[str]`, `Dict[str, int]`). +- Break down complex functions into smaller, more manageable functions. + +## General Instructions + +- Always prioritize readability and clarity. +- For algorithm-related code, include explanations of the approach used. +- Write code with good maintainability practices, including comments on why certain design decisions were made. +- Handle edge cases and write clear exception handling. +- For libraries or external dependencies, mention their usage and purpose in comments. +- Use consistent naming conventions and follow language-specific best practices. +- Write concise, efficient, and idiomatic code that is also easily understandable. + +## Code Style and Formatting + +- Follow the **PEP 8** style guide for Python. +- Maintain proper indentation (use 4 spaces for each level of indentation). +- Ensure lines do not exceed 79 characters. +- Place function and class docstrings immediately after the `def` or `class` keyword. +- Use blank lines to separate functions, classes, and code blocks where appropriate. + +## Edge Cases and Testing + +- Always include test cases for critical paths of the application. +- Account for common edge cases like empty inputs, invalid data types, and large datasets. +- Include comments for edge cases and the expected behavior in those cases. +- Write unit tests for functions and document them with docstrings explaining the test cases. + +## Example of Proper Documentation + +```python +def calculate_area(radius: float) -> float: + """ + Calculate the area of a circle given the radius. + + Parameters: + radius (float): The radius of the circle. + + Returns: + float: The area of the circle, calculated as π * radius^2. + """ + import math + return math.pi * radius ** 2 +``` \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..91e0711 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Gerar Release Notes e publicar Release + runs-on: ubuntu-latest + steps: + - name: Checkout (histórico completo para o git-cliff) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Gerar CHANGELOG com git-cliff + uses: orhun/git-cliff-action@v3 + id: git_cliff + with: + config: cliff.toml + args: --verbose --latest --strip header + env: + OUTPUT: CHANGELOG_RELEASE.md + GITHUB_REPO: ${{ github.repository }} + + - name: Criar GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + body_path: CHANGELOG_RELEASE.md + draft: false + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-pytest.yaml b/.github/workflows/test-pytest.yaml index 34ffaaa..e382189 100644 --- a/.github/workflows/test-pytest.yaml +++ b/.github/workflows/test-pytest.yaml @@ -35,13 +35,24 @@ jobs: - name: Run Pytest with secrets env: - API_ID_PAGINA_FACEBOOK: ${{ secrets.API_ID_PAGINA_FACEBOOK }} + # Discord API_KEY: ${{ secrets.API_KEY }} - API_TOKEN_PAGINA: ${{ secrets.API_TOKEN_PAGINA }} CANAL_LANCAMENTOS: ${{ secrets.CANAL_LANCAMENTOS }} - CANAL_TAGS: ${{ secrets.CANAL_TAGS }} CANAL_TESTES: ${{ secrets.CANAL_TESTES }} + CANAL_TAGS: ${{ secrets.CANAL_TAGS }} + # MongoDB Atlas URI_ATLAS: ${{ secrets.URI_ATLAS }} + # Facebook — produção + API_ID_PAGINA_FACEBOOK: ${{ secrets.API_ID_PAGINA_FACEBOOK }} + API_TOKEN_PAGINA: ${{ secrets.API_TOKEN_PAGINA }} + # Facebook — testes de integração (desabilitado na CI por padrão) + API_ID_PAGINA_FACEBOOK_TESTE: ${{ secrets.API_ID_PAGINA_FACEBOOK_TESTE }} + API_TOKEN_PAGINA_TESTE: ${{ secrets.API_TOKEN_PAGINA_TESTE }} + ENABLE_FB_TEST: "false" + # Google Gemini + GEMINI_API: ${{ secrets.GEMINI_API }} + # Web scraper + URL_SITE: ${{ secrets.URL_SITE }} run: | export PYTHONPATH=$(pwd) LC_ALL=pt_BR.UTF-8 LANG=pt_BR.UTF-8 python -m pytest -v diff --git a/.gitignore b/.gitignore index 8498f4e..0346373 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,10 @@ node_modules/ # Credenciais de Ambiente .env +.env.dev ## cache python __pycache__/ *.py[cod] +*.pyc diff --git a/README.md b/README.md index b30a3b7..29b81cd 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,212 @@ # Bot de notificações no Discord -**Branch: developer** -## Instruções Para Dev -A versão do Python utilizada é: `python:3.10` +Bot que monitora lançamentos de capítulos em um site de mangás/webtoons e realiza postagens automáticas de anúncio em canais do Discord. + +--- + +## Arquitetura do Projeto + +```mermaid +flowchart TD + MAIN["Main.py\n(Ponto de entrada)"] + + subgraph classes_io ["classes_io"] + GESTOR["GestorTXT\nLê/grava data anterior\ne modo de teste (TXT)"] + DOWNLOAD["DownloadImagens\nBaixa imagens das obras\nregistradas no Atlas"] + end + + subgraph controller ["controller"] + CTRL_POST["ControllerPostagem\nOrquestra o fluxo\nde postagem"] + CTRL_OBRA["ControllerObras\nFiltra e valida\nlistas de obras"] + end + + subgraph dao ["dao"] + ATLAS["AtlasDAO\nMongoDB Atlas\n(obras registradas,\nanunciadas, etc.)"] + SCRAPER["WebScreperSite\nWeb scraping dos\ncapítulos diários do site"] + DISCORD["ConexaoDiscord\nEnvia embeds para\ncanais do Discord"] + GEMINI["GeminiDAO\nIntegração com\nGoogle Gemini AI"] + end + + subgraph model ["model"] + OBRA["Obra"] + CAPITULO["Capitulo"] + POST_DC["PostDiscord\nMonta o embed\ndo anúncio"] + POST_FB["PostFacebook"] + MSGS["Mensagens\nLogs e prints"] + LOGGER["LoggerConfig\nConfiguração de logs"] + end + + subgraph assets ["assets (volume)"] + TXT_DATA["registro_horario/\ndata_anterior.txt"] + TXT_MODE["config/\ntest_mode.txt"] + IMGS["imagens/\n(cache local de imagens)"] + end + + MAIN --> GESTOR + MAIN --> DOWNLOAD + MAIN --> CTRL_POST + MAIN --> LOGGER + + GESTOR --> TXT_DATA + GESTOR --> TXT_MODE + + DOWNLOAD --> ATLAS + DOWNLOAD --> IMGS + + CTRL_POST --> ATLAS + CTRL_POST --> SCRAPER + CTRL_POST --> CTRL_OBRA + CTRL_POST --> POST_DC + CTRL_POST --> DISCORD + + SCRAPER --> OBRA + SCRAPER --> CAPITULO + + POST_DC --> OBRA + POST_DC --> CAPITULO + + CTRL_OBRA --> OBRA +``` + +### Fluxo principal + +1. **GestorTXT** lê a data da última execução e o modo de teste. +2. **DownloadImagens** sincroniza o cache local de imagens com os dados do MongoDB Atlas. +3. **WebScreperSite** faz scraping do site e retorna a lista de obras com capítulos lançados no dia. +4. **ControllerObras** filtra obras não registradas no Atlas e remove capítulos já anunciados. +5. **ControllerPostagem** itera sobre as obras restantes, monta um `PostDiscord` e envia o embed via **ConexaoDiscord**. +6. O registro de obras anunciadas é persistido no MongoDB Atlas para evitar duplicatas. + +### Variáveis de ambiente (`.env`) + +#### Discord + +| Variável | Descrição | +|---|---| +| `API_KEY` | Token do bot Discord. Obtido em https://discord.com/developers/applications → Bot → Token | +| `CANAL_LANCAMENTOS` | ID do canal onde os anúncios de lançamentos são postados em produção | +| `CANAL_TESTES` | ID do canal usado quando o bot é executado em modo de teste | +| `CANAL_TAGS` | ID do canal de tags/cargos, usado para menções nos embeds | + +#### MongoDB Atlas + +| Variável | Descrição | +|---|---| +| `URI_ATLAS` | URI de conexão com o cluster MongoDB Atlas. Formato: `mongodb+srv://:@.mongodb.net/` | + +#### Google Gemini + +| Variável | Descrição | +|---|---| +| `GEMINI_API` | Chave da API do Google Gemini AI. Obtida em https://aistudio.google.com/app/apikey | + +#### Facebook + +| Variável | Descrição | +|---|---| +| `API_ID_PAGINA_FACEBOOK` | ID numérico da página do Facebook de produção onde os posts serão publicados | +| `API_TOKEN_PAGINA` | Page Access Token de longa duração da página de produção. Veja [configuracao_facebook.md](docs/configuracao_facebook.md) para instruções de geração | +| `API_ID_PAGINA_FACEBOOK_TESTE` | ID numérico da página de teste (usada apenas quando `ENABLE_FB_TEST=true`) | +| `API_TOKEN_PAGINA_TESTE` | Page Access Token da página de teste | +| `ENABLE_FB_TEST` | `"true"` para habilitar os testes unitários de postagem no Facebook. Por padrão `"false"` para evitar postagens acidentais | + +--- + +## Configuração do Facebook + +Consulte o guia completo em [docs/configuracao_facebook.md](docs/configuracao_facebook.md). + +--- + +## Estrutura de diretórios -Para criar o ambiente, utilize: ``` +bot-notification/ +├── src/ +│ ├── Main.py # Ponto de entrada +│ ├── classes_io/ +│ │ ├── download_imagens.py # Download de imagens das obras +│ │ └── gestor_txt.py # Leitura/escrita de arquivos TXT +│ ├── controller/ +│ │ ├── controller_obras.py # Filtragem e validação de obras +│ │ └── controller_postagem.py # Orquestração do fluxo de postagem +│ ├── dao/ +│ │ ├── atlas_dao.py # Acesso ao MongoDB Atlas +│ │ ├── conexao_discord.py # Envio de mensagens ao Discord +│ │ ├── conexao_facebook.py # Integração Facebook (Graph API v25.0) +│ │ ├── gemini_dao.py # Integração Google Gemini AI +│ │ └── web_screper_site.py # Web scraping do site de mangás +│ ├── endpoint/ +│ │ ├── bot_endpoint.py +│ │ └── pagination.py +│ └── model/ +│ ├── capitulo.py # Entidade Capítulo +│ ├── logger_config.py # Configuração de loggers +│ ├── mensagens.py # Mensagens de log/console +│ ├── obra.py # Entidade Obra +│ └── posts/ +│ ├── post_discord.py # Modelo do embed Discord +│ └── post_facebook.py # Modelo do post Facebook +├── assets/ # Volume Docker (persistente) +│ ├── config/ +│ │ └── test_mode.txt # "true" para modo de teste +│ ├── imagens/ # Cache local de capas das obras +│ └── registro_horario/ +│ └── data_anterior.txt # Data da última execução +├── tests/ # Testes unitários (pytest) +├── Dockerfile +├── Dockerfile-dev +└── requirements.txt +``` + +--- + +## Instruções para desenvolvimento + +A versão do Python utilizada é: `python:3.10` + +Para criar o ambiente, utilize: +```bash python3 -m venv venv source venv/bin/activate ``` Para instalar as dependências do projeto: -``` +```bash pip install -r requirements.txt ``` Para executar os testes: -``` +```bash pytest ``` -## Para buildar a imagem com docker -A imagem utiliza volume, então é necessário criar um: -``` -docker volume create -``` +--- + +## Docker + +A imagem utiliza volume para persistir os dados em `assets/`. Crie o volume antes de rodar: -Após isso, para criar a imagem: +```bash +docker volume create ``` + +Construir a imagem: +```bash docker build -t bot-notif: . ``` -E por fim, para executar o container: -``` +Executar o container: +```bash docker run -d -v :/home/project/assets bot-notif: ``` -Caso queira acompanhar a saída do bot, pode usar: -``` +Acompanhar a saída do bot: +```bash docker attach ``` -Pode encontrar o ID do container a partir do comando: `docker ps` \ No newline at end of file +Encontrar o ID do container: +```bash +docker ps +``` \ No newline at end of file diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..c632667 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,49 @@ +[changelog] +header = """ +# Changelog\n +Todas as mudanças relevantes deste projeto estão documentadas aqui.\n +""" +body = """ +{% if version %}\ +## [{{ version | trim_start_matches(pat="v") }}] — {{ timestamp | date(format="%d/%m/%Y") }} +{% else %}\ +## [Não versionado] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group }} +{% for commit in commits %} +- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}\ +{% if commit.breaking %} ⚠️ BREAKING CHANGE{% endif %}\ + +{% endfor %} +{% endfor %}\n +""" +footer = "" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false + +# Ordenação dos commits dentro de cada grupo +commit_parsers = [ + { message = "^feat", group = "🚀 Novidades" }, + { message = "^fix", group = "🐛 Correções" }, + { message = "^refactor", group = "♻️ Refatorações" }, + { message = "^perf", group = "⚡ Performance" }, + { message = "^test", group = "🧪 Testes" }, + { message = "^docs?", group = "📝 Documentação" }, + { message = "^chore", group = "🔧 Manutenção" }, + { message = "^ci", group = "⚙️ CI/CD" }, + { message = "^build", group = "📦 Build" }, + # ignora commits de merge e bump de versão + { message = "^Merge", skip = true }, + { message = "^bump", skip = true }, +] + +protect_breaking_commits = false +filter_commits = true +tag_pattern = "v[0-9].*" +topo_order = false +sort_commits = "newest" diff --git a/dev.bat b/dev.bat new file mode 100644 index 0000000..5aebf43 --- /dev/null +++ b/dev.bat @@ -0,0 +1,126 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +:: ============================================================================= +:: dev.bat — Utilitários de desenvolvimento +:: ============================================================================= + +:MENU +cls +echo. +echo ======================================== +echo Bot Notification — Dev Utils +echo ======================================== +echo 1. Criar ambiente virtual e instalar dependencias +echo 2. Subir MongoDB local (Docker) +echo 3. Criar colecoes e indices no banco local +echo 4. Executar testes +echo 5. Subir aplicacao completa (Docker Compose) +echo 6. Derrubar containers +echo 7. Construir imagem Docker de producao +echo 0. Sair +echo ======================================== +set /p opcao="Escolha uma opcao: " + +if "%opcao%"=="1" goto VENV +if "%opcao%"=="2" goto MONGO_UP +if "%opcao%"=="3" goto SEED +if "%opcao%"=="4" goto TESTS +if "%opcao%"=="5" goto COMPOSE_UP +if "%opcao%"=="6" goto COMPOSE_DOWN +if "%opcao%"=="7" goto BUILD_PROD +if "%opcao%"=="0" goto SAIR + +echo Opcao invalida. +pause +goto MENU + +:: ----------------------------------------------------------------------------- +:VENV +echo. +echo ^>^> Criando ambiente virtual... +python -m venv venv +call venv\Scripts\activate.bat +echo ^>^> Instalando dependencias... +pip install -r requirements.txt +echo ^>^> Pronto! O venv ja esta ativado nesta sessao. +pause +goto MENU + +:: ----------------------------------------------------------------------------- +:MONGO_UP +echo. +echo ^>^> Subindo MongoDB local... +docker compose up mongo -d +echo ^>^> MongoDB disponivel em localhost:27017 +pause +goto MENU + +:: ----------------------------------------------------------------------------- +:SEED +echo. +if not exist venv\Scripts\activate.bat ( + echo Venv nao encontrado. Execute a opcao 1 primeiro. + pause + goto MENU +) +call venv\Scripts\activate.bat +echo ^>^> Criando colecoes e indices no banco local... +python scripts\seed_dev_db.py +pause +goto MENU + +:: ----------------------------------------------------------------------------- +:TESTS +echo. +if not exist venv\Scripts\activate.bat ( + echo Venv nao encontrado. Execute a opcao 1 primeiro. + pause + goto MENU +) +call venv\Scripts\activate.bat +echo ^>^> Executando testes... +pytest +pause +goto MENU + +:: ----------------------------------------------------------------------------- +:COMPOSE_UP +echo. +echo ^>^> Criando volume de assets (se nao existir)... +docker volume create assets_dev +echo ^>^> Subindo aplicacao completa via Docker Compose... +docker compose up -d +echo ^>^> Containers rodando. Acompanhe com: docker attach bot-notification-app +pause +goto MENU + +:: ----------------------------------------------------------------------------- +:COMPOSE_DOWN +echo. +echo ^>^> Derrubando containers... +docker compose down +pause +goto MENU + +:: ----------------------------------------------------------------------------- +:BUILD_PROD +echo. +set /p versao="Informe a versao da imagem (ex: 1.0.0): " +if "%versao%"=="" ( + echo Versao nao informada. Operacao cancelada. + pause + goto MENU +) +echo ^>^> Construindo imagem bot-notif:%versao%... +docker build -t "bot-notif:%versao%" . +echo ^>^> Imagem criada: bot-notif:%versao% +pause +goto MENU + +:: ----------------------------------------------------------------------------- +:SAIR +echo Saindo... +endlocal +exit /b 0 diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..94878e4 --- /dev/null +++ b/dev.sh @@ -0,0 +1,112 @@ +#!/bin/sh +# ============================================================================= +# dev.sh — Utilitários de desenvolvimento +# ============================================================================= + +set -e + +# Cores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +print_menu() { + printf "\n${CYAN}========================================${NC}\n" + printf "${CYAN} Bot Notification — Dev Utils${NC}\n" + printf "${CYAN}========================================${NC}\n" + printf " ${YELLOW}1.${NC} Criar ambiente virtual e instalar dependências\n" + printf " ${YELLOW}2.${NC} Subir MongoDB local (Docker)\n" + printf " ${YELLOW}3.${NC} Criar coleções e índices no banco local\n" + printf " ${YELLOW}4.${NC} Executar testes\n" + printf " ${YELLOW}5.${NC} Subir aplicação completa (Docker Compose)\n" + printf " ${YELLOW}6.${NC} Derrubar containers\n" + printf " ${YELLOW}7.${NC} Construir imagem Docker de produção\n" + printf " ${YELLOW}0.${NC} Sair\n" + printf "${CYAN}========================================${NC}\n" + printf "Escolha uma opção: " +} + +cmd_venv() { + printf "${GREEN}>> Criando ambiente virtual...${NC}\n" + python3 -m venv venv + . venv/bin/activate + printf "${GREEN}>> Instalando dependências...${NC}\n" + pip install -r requirements.txt + printf "${GREEN}>> Pronto! Ative o venv com: source venv/bin/activate${NC}\n" +} + +_ativar_venv() { + if [ -f "venv/bin/activate" ]; then + . venv/bin/activate + else + printf "${RED}Venv não encontrado. Execute a opção 1 primeiro.${NC}\n" + return 1 + fi +} + +cmd_mongo_up() { + printf "${GREEN}>> Subindo MongoDB local...${NC}\n" + docker compose up mongo -d + printf "${GREEN}>> MongoDB disponível em localhost:27017${NC}\n" +} + +cmd_seed() { + _ativar_venv || return + printf "${GREEN}>> Criando coleções e índices no banco local...${NC}\n" + python3 scripts/seed_dev_db.py +} + +cmd_tests() { + _ativar_venv || return + printf "${GREEN}>> Executando testes...${NC}\n" + pytest +} + +cmd_compose_up() { + printf "${GREEN}>> Criando volume de assets (se não existir)...${NC}\n" + docker volume create assets_dev + printf "${GREEN}>> Subindo aplicação completa via Docker Compose...${NC}\n" + docker compose up -d + printf "${GREEN}>> Containers rodando. Acompanhe com: docker attach bot-notification-app${NC}\n" +} + +cmd_compose_down() { + printf "${YELLOW}>> Derrubando containers...${NC}\n" + docker compose down +} + +cmd_build_prod() { + printf "Informe a versão da imagem (ex: 1.0.0): " + read -r versao + if [ -z "$versao" ]; then + printf "${RED}Versão não informada. Operação cancelada.${NC}\n" + return + fi + printf "${GREEN}>> Construindo imagem bot-notif:${versao}...${NC}\n" + docker build -t "bot-notif:${versao}" . + printf "${GREEN}>> Imagem criada: bot-notif:${versao}${NC}\n" +} + +# Loop principal +while true; do + print_menu + read -r opcao + case "$opcao" in + 1) cmd_venv ;; + 2) cmd_mongo_up ;; + 3) cmd_seed ;; + 4) cmd_tests ;; + 5) cmd_compose_up ;; + 6) cmd_compose_down ;; + 7) cmd_build_prod ;; + 0) + printf "${CYAN}Saindo...${NC}\n" + exit 0 + ;; + *) + printf "${RED}Opção inválida.${NC}\n" + ;; + esac +done diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a13e803 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + mongo: + image: mongo:7 + container_name: bot-notification-mongo + restart: unless-stopped + ports: + - "27017:27017" + volumes: + - mongo_dev_data:/data/db + environment: + MONGO_INITDB_DATABASE: DadosPostagem + + app: + build: + context: . + dockerfile: Dockerfile-dev + container_name: bot-notification-app + restart: unless-stopped + depends_on: + - mongo + env_file: + - .env.dev + environment: + # Sobrescreve a URI para usar o serviço 'mongo' do compose + URI_ATLAS: mongodb://mongo:27017/ + volumes: + - assets_dev:/home/project/assets + +volumes: + mongo_dev_data: + assets_dev: diff --git a/docs/configuracao_facebook.md b/docs/configuracao_facebook.md new file mode 100644 index 0000000..637bea6 --- /dev/null +++ b/docs/configuracao_facebook.md @@ -0,0 +1,190 @@ +# Configuração da Integração com o Facebook + +Este guia cobre o processo completo para criar um app Meta, configurar as permissões necessárias e gerar o Page Access Token para publicações automáticas na página do Facebook. + +--- + +## Pré-requisitos + +- Conta no [Meta for Developers](https://developers.facebook.com) (pode usar conta Facebook pessoal) +- Página do Facebook que você **administra** (onde os posts serão publicados) + +--- + +## 1. Criar o App no Meta for Developers + +1. Acesse https://developers.facebook.com/apps +2. Clique em **Criar App** +3. Em **Qual é o caso de uso do seu app?**, selecione **"Outros"** e clique em Próximo +4. Selecione o tipo **Business** e clique em Próximo +5. Preencha o **Nome de exibição do app** e o e-mail de contato +6. Clique em **Criar App** + +--- + +## 2. Adicionar o caso de uso "Gerenciar tudo na sua Página" + +1. Após criar o app, você será redirecionado para o painel **Adicionar produtos ao seu app** +2. Localize o caso de uso **"Gerenciar tudo na sua Página"** (_Manage everything on your Page_) +3. Clique em **Configurar** nesse caso de uso + +--- + +## 3. Configurar as permissões + +1. No menu lateral, vá em **Casos de uso** (ou _Use Cases_) +2. Localize **"Gerenciar tudo na sua Página"** e clique em **Personalizar** +3. Na aba **Permissões**, clique em **Adicionar** para cada uma das permissões abaixo: + + | Permissão | Finalidade | + |---|---| + | `pages_show_list` | Listar as páginas gerenciadas pela conta | + | `pages_read_engagement` | Ler métricas e engajamento da página | + | `pages_read_user_content` | Ler conteúdo publicado por usuários na página | + | `pages_manage_posts` | Criar, editar e excluir posts na página | + | `pages_manage_engagement` | Gerenciar comentários e interações da página | + +4. Após adicionar todas as permissões, confirme que as cinco aparecem listadas na seção **Permissões** da personalização do caso de uso + +> ℹ️ Não é necessário submeter o app para revisão da Meta enquanto você for **Administrador** da página e estiver testando com contas de desenvolvedor listadas no app. + +--- + +## 4. Gerar o Page Access Token via `/me/accounts` + +O token necessário é um **Page Access Token** (não User Token). A forma mais simples de obtê-lo é pela rota `/me/accounts`: + +1. Acesse o [Graph API Explorer](https://developers.facebook.com/tools/explorer/?method=GET&path=me%2Faccounts&version=v25.0) +2. No dropdown **"App da Meta"**, selecione o app que você criou +3. Certifique-se de que **"User Token"** está selecionado em **"User or Page"** +4. Certifique-se de que as seguintes permissões estão marcadas no campo **Permissões**: + - `pages_show_list` + - `pages_read_engagement` + - `pages_read_user_content` + - `pages_manage_posts` + - `pages_manage_engagement` +5. Clique em **Gerar token de acesso** e autorize no popup +6. O caminho `/me/accounts` já estará preenchido — clique em **Enviar** + +### Obtendo o ID e o token da página + +O `access_token` e o `id` corretos são os da **página**, não do usuário. Para obtê-los: + +1. No [Graph API Explorer](https://developers.facebook.com/tools/explorer/?method=GET&path=me%2Faccounts&version=v25.0), certifique-se de que o **User Access Token** está selecionado (não Page Token) +2. O caminho `/me/accounts` já estará preenchido — clique em **Enviar** +3. A resposta listará todas as páginas que você administra: + ```json + { + "data": [ + { + "access_token": "EAACxxx...", + "category": "Entretenimento", + "name": "Nome da Página", + "id": "110041288738055", + "tasks": ["ANALYZE", "ADVERTISE", "MODERATE", "CREATE_CONTENT"] + } + ] + } + ``` +4. Copie o `access_token` e o `id` do objeto correspondente à sua página + - `id` → valor de `API_ID_PAGINA_FACEBOOK` + - `access_token` → valor de `API_TOKEN_PAGINA` + +> ⚠️ **Importante:** use o `access_token` que vem dentro de `data[]` (token da página), não o User Access Token exibido no topo do Explorer. Tokens de usuário não têm permissão para publicar na página. + +--- + +## 5. Obter token permanente de página (produção) + +O token de Usuário gerado pelo Explorer tem validade de **~1 hora**. Para uso em produção você precisa de um **Page Access Token permanente** (sem expiração). Há duas formas de obtê-lo: + +### Opção 1: Via chamada OAuth direta + +**Passo 1 — Converter o User Token em token de longa duração (~60 dias):** + +``` +GET https://graph.facebook.com/v25.0/oauth/access_token + ?grant_type=fb_exchange_token + &client_id={APP_ID} + &client_secret={APP_SECRET} + &fb_exchange_token={TOKEN_CURTA_DURACAO} +``` + +- `APP_ID` e `APP_SECRET`: encontrados em **Painel do App → Configurações → Básico** +- Substitua `{TOKEN_CURTA_DURACAO}` pelo User Token gerado no Explorer + +**Passo 2 — Obter o Page Token permanente a partir do token longo:** + +Com o token de 60 dias em mãos, repita a chamada da seção 4 (`GET /me/accounts`) usando esse token no cabeçalho de autorização. O `access_token` retornado dentro de `data[]` para a sua página será **permanente** (sem data de expiração). + +Para renovação automática, consulte a [documentação de tokens da Meta](https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived/). + +--- + +### Opção 2: Via Ferramenta de Token do Facebook (sem código — mais rápido) + +O Facebook possui um botão "escondido" que executa toda a troca OAuth por você direto na interface do Explorador de API. + +1. No **Explorador de API do Graph**, gere um User Token curto como descrito na seção 4. +2. Clique no **ícone azul de informação (ⓘ)** que fica logo ao lado do token exibido na caixa azul. +3. No balão que abrir, clique em **"Abrir na Ferramenta de Token de Acesso"**. +4. Uma nova aba será aberta. Role até o final da página e clique no botão azul **"Estender token de acesso"**. +5. O token de 60 dias será gerado na hora — **copie-o**. +6. Volte para o Explorador de API, **cole esse token longo** na caixa azul (substituindo o token antigo). +7. No seletor **"Usuário ou Página"**, escolha a sua Página. +8. O token gerado após a seleção da página é o **Page Access Token permanente** (sem expiração). Copie-o. + +> ℹ️ Esse é o mesmo token que a Opção 1 produziria — a ferramenta apenas automatiza a chamada `oauth/access_token` por baixo dos panos. + +--- + +## 6. Atualizar o `.env` + +Adicione as variáveis ao arquivo `.env` na raiz do projeto: + +```ini +# Facebook — Produção +API_ID_PAGINA_FACEBOOK = "110041288738055" +API_TOKEN_PAGINA = "EAACxxx..." + +# Facebook — Testes (opcional, necessário apenas para rodar testes de integração) +API_ID_PAGINA_FACEBOOK_TESTE = "ID_DA_PAGINA_DE_TESTE" +API_TOKEN_PAGINA_TESTE = "EAACxxx..." +ENABLE_FB_TEST = "false" +``` + +> ⚠️ Nunca commite o `.env` com tokens reais. O arquivo `.env` já está no `.gitignore`. + +--- + +## 7. Testar a integração + +Para rodar os testes de integração com Facebook (faz postagens reais na página de teste): + +```bash +# No .env, habilite o modo de teste: +ENABLE_FB_TEST = "true" + +# Execute apenas os testes de Facebook: +venv/bin/python -m pytest tests/test_dao_conexao_facebook.py -v +``` + +> Após os testes, retorne `ENABLE_FB_TEST = "false"` para evitar postagens acidentais em execuções futuras do pytest. + +--- + +## Whitelist de obras permitidas no Facebook + +Nem todas as obras são postadas no Facebook — apenas as que estiverem na coleção `obrasPermitidasFB` do MongoDB Atlas. + +Para adicionar uma obra via bot Discord, use o comando: +``` +/adicionar_obra_permitida_fb +``` + +Para listar as obras atualmente permitidas: +``` +/listar_obras_permitidas_fb +``` + +O nome deve ser **exatamente igual** ao `titulo_obra` registrado no Atlas. diff --git a/requirements.txt b/requirements.txt index 8836f72..801b3b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,6 @@ certifi==2023.5.7 charset-normalizer==3.1.0 discord.py==2.3.1 dnspython==1.16.0 -facebook-sdk==3.1.0 frozenlist==1.3.3 google-ai-generativelanguage==0.6.2 google-api-core==2.19.0 diff --git a/scripts/seed_dev_db.py b/scripts/seed_dev_db.py new file mode 100644 index 0000000..0d888ee --- /dev/null +++ b/scripts/seed_dev_db.py @@ -0,0 +1,113 @@ +""" +seed_dev_db.py +============== +Script de setup para ambiente de desenvolvimento local. + +Cria as coleções do banco 'DadosPostagem' no MongoDB local com +os índices necessários, espelhando a estrutura usada em produção +pelo AtlasDAO. Nenhum dado de exemplo é inserido. + +Uso: + # Sobe apenas o MongoDB local: + docker compose up mongo -d + + # Executa o setup: + python scripts/seed_dev_db.py + +Coleções criadas: + - dadosObras → catálogo de obras + - registroObrasPostadas → controle de capítulos já anunciados + - obrasPermitidasFB → whitelist de obras para postagem no Facebook +""" + +import os +import sys +import logging +from typing import Any + +from pymongo import MongoClient, ASCENDING +from dotenv import load_dotenv + +# --------------------------------------------------------------------------- +# Configuração básica de log +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(levelname)s | %(message)s", +) +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Funções de setup +# --------------------------------------------------------------------------- + +def _criar_indices(db: Any) -> None: + """ + Cria índices nas coleções para garantir unicidade e performance, + replicando o comportamento esperado em produção. + + Parameters: + db: Instância do banco de dados MongoDB. + """ + db.dadosObras.create_index( + [("titulo", ASCENDING)], unique=True, name="titulo_unico" + ) + logger.info("Índice 'titulo_unico' criado em 'dadosObras'.") + + db.registroObrasPostadas.create_index( + [("titulo_obra", ASCENDING)], unique=True, name="titulo_obra_unico" + ) + logger.info( + "Índice 'titulo_obra_unico' criado em 'registroObrasPostadas'." + ) + + db.obrasPermitidasFB.create_index( + [("titulo_obra", ASCENDING)], unique=True, name="titulo_obra_fb_unico" + ) + logger.info( + "Índice 'titulo_obra_fb_unico' criado em 'obrasPermitidasFB'." + ) + + + + +def seed(uri: str) -> None: + """ + Cria as coleções e índices no banco 'DadosPostagem'. + + Conecta ao MongoDB pelo URI fornecido e cria os índices necessários + nas coleções. As coleções são criadas automaticamente pelo MongoDB + ao receber o primeiro índice. + + Parameters: + uri (str): URI de conexão com o MongoDB. + """ + logger.info("Conectando ao MongoDB: %s", uri) + client = MongoClient(uri) + + try: + client.admin.command("ping") + logger.info("Conexão com MongoDB estabelecida com sucesso.") + except Exception as exc: + logger.error("Falha ao conectar com MongoDB: %s", exc) + sys.exit(1) + + db = client.DadosPostagem + + _criar_indices(db) + + client.close() + logger.info("Setup concluído.") + + +# --------------------------------------------------------------------------- +# Ponto de entrada +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Carrega o .env.dev se existir; caso contrário usa as variáveis do ambiente + load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env.dev")) + + mongo_uri = os.getenv("URI_ATLAS", "mongodb://localhost:27017/") + seed(mongo_uri) diff --git a/src/__pycache__/__init__.cpython-310.pyc b/src/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 469893b..0000000 Binary files a/src/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 7b422db..0000000 Binary files a/src/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/Download_Imagens.cpython-310.pyc b/src/classes_io/__pycache__/Download_Imagens.cpython-310.pyc deleted file mode 100644 index 91335d9..0000000 Binary files a/src/classes_io/__pycache__/Download_Imagens.cpython-310.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/Download_Imagens.cpython-311.pyc b/src/classes_io/__pycache__/Download_Imagens.cpython-311.pyc deleted file mode 100644 index 64f3a2b..0000000 Binary files a/src/classes_io/__pycache__/Download_Imagens.cpython-311.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/Gestor_JSON.cpython-310.pyc b/src/classes_io/__pycache__/Gestor_JSON.cpython-310.pyc deleted file mode 100644 index 3186f4f..0000000 Binary files a/src/classes_io/__pycache__/Gestor_JSON.cpython-310.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/Gestor_JSON.cpython-311.pyc b/src/classes_io/__pycache__/Gestor_JSON.cpython-311.pyc deleted file mode 100644 index 7ec55bd..0000000 Binary files a/src/classes_io/__pycache__/Gestor_JSON.cpython-311.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/Gestor_TXT.cpython-310.pyc b/src/classes_io/__pycache__/Gestor_TXT.cpython-310.pyc deleted file mode 100644 index d032b0c..0000000 Binary files a/src/classes_io/__pycache__/Gestor_TXT.cpython-310.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/Gestor_TXT.cpython-311.pyc b/src/classes_io/__pycache__/Gestor_TXT.cpython-311.pyc deleted file mode 100644 index 89d3060..0000000 Binary files a/src/classes_io/__pycache__/Gestor_TXT.cpython-311.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/Gestor_TXT.cpython-38.pyc b/src/classes_io/__pycache__/Gestor_TXT.cpython-38.pyc deleted file mode 100644 index b02f124..0000000 Binary files a/src/classes_io/__pycache__/Gestor_TXT.cpython-38.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/__init__.cpython-310.pyc b/src/classes_io/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index d288220..0000000 Binary files a/src/classes_io/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/__init__.cpython-311.pyc b/src/classes_io/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index a8e4e40..0000000 Binary files a/src/classes_io/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/download_imagens.cpython-311.pyc b/src/classes_io/__pycache__/download_imagens.cpython-311.pyc deleted file mode 100644 index 4c7faff..0000000 Binary files a/src/classes_io/__pycache__/download_imagens.cpython-311.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/gestor_txt.cpython-310.pyc b/src/classes_io/__pycache__/gestor_txt.cpython-310.pyc deleted file mode 100644 index 72e379b..0000000 Binary files a/src/classes_io/__pycache__/gestor_txt.cpython-310.pyc and /dev/null differ diff --git a/src/classes_io/__pycache__/gestor_txt.cpython-311.pyc b/src/classes_io/__pycache__/gestor_txt.cpython-311.pyc deleted file mode 100644 index 9c52b2a..0000000 Binary files a/src/classes_io/__pycache__/gestor_txt.cpython-311.pyc and /dev/null differ diff --git a/src/controller/__pycache__/Controller_IO.cpython-310.pyc b/src/controller/__pycache__/Controller_IO.cpython-310.pyc deleted file mode 100644 index 60bc7d3..0000000 Binary files a/src/controller/__pycache__/Controller_IO.cpython-310.pyc and /dev/null differ diff --git a/src/controller/__pycache__/Controller_IO.cpython-311.pyc b/src/controller/__pycache__/Controller_IO.cpython-311.pyc deleted file mode 100644 index 1ce9df8..0000000 Binary files a/src/controller/__pycache__/Controller_IO.cpython-311.pyc and /dev/null differ diff --git a/src/controller/__pycache__/Controller_Postagem.cpython-310.pyc b/src/controller/__pycache__/Controller_Postagem.cpython-310.pyc deleted file mode 100644 index 17ceabe..0000000 Binary files a/src/controller/__pycache__/Controller_Postagem.cpython-310.pyc and /dev/null differ diff --git a/src/controller/__pycache__/Controller_Postagem.cpython-311.pyc b/src/controller/__pycache__/Controller_Postagem.cpython-311.pyc deleted file mode 100644 index ddc5fcd..0000000 Binary files a/src/controller/__pycache__/Controller_Postagem.cpython-311.pyc and /dev/null differ diff --git a/src/controller/__pycache__/__init__.cpython-310.pyc b/src/controller/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 4c09486..0000000 Binary files a/src/controller/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/controller/__pycache__/__init__.cpython-311.pyc b/src/controller/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 06400a1..0000000 Binary files a/src/controller/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/controller/__pycache__/controller_obras.cpython-310.pyc b/src/controller/__pycache__/controller_obras.cpython-310.pyc deleted file mode 100644 index a0edcb9..0000000 Binary files a/src/controller/__pycache__/controller_obras.cpython-310.pyc and /dev/null differ diff --git a/src/controller/__pycache__/controller_obras.cpython-311.pyc b/src/controller/__pycache__/controller_obras.cpython-311.pyc deleted file mode 100644 index 208fb00..0000000 Binary files a/src/controller/__pycache__/controller_obras.cpython-311.pyc and /dev/null differ diff --git a/src/controller/__pycache__/controller_postagem.cpython-311.pyc b/src/controller/__pycache__/controller_postagem.cpython-311.pyc deleted file mode 100644 index 9d57d12..0000000 Binary files a/src/controller/__pycache__/controller_postagem.cpython-311.pyc and /dev/null differ diff --git a/src/controller/controller_obras.py b/src/controller/controller_obras.py index 955d43e..09342f6 100644 --- a/src/controller/controller_obras.py +++ b/src/controller/controller_obras.py @@ -30,22 +30,33 @@ def remove_obras_nao_registradas(lista_de_obras_recebidas, dados_unicos_obras): return obras_registradas - """ - Remove obras que não podem ser postadas. - Args: - lista_de_obras_para_postar (list): Lista de obras para postar. - lista_de_obras_nao_permitidas (list): Lista de obras não permitidas. - Returns: - list: Lista de obras filtradas, sem as obras não permitidas. - """ - def remover_obras_que_nao_pode_postar(lista_de_obras_para_postar, lista_de_obras_nao_permitidas): - titulos_obras_nao_permitidas = [obra['titulo_obra'] for obra in lista_de_obras_nao_permitidas] + def filtrar_obras_permitidas_fb( + lista_de_obras_para_postar: list, + lista_de_obras_permitidas: list + ) -> list: + """ + Retorna apenas as obras que possuem permissão explícita de + postagem no Facebook (whitelist). + + Obras que não estiverem na lista de permitidas são removidas, + independente de qualquer outro critério. + + Args: + lista_de_obras_para_postar (list): obras candidatas à postagem. + lista_de_obras_permitidas (list): documentos com 'titulo_obra' + das obras autorizadas. + + Returns: + list: obras presentes em lista_de_obras_permitidas. + """ + titulos_permitidos = { + obra['titulo_obra'] for obra in lista_de_obras_permitidas + } - obras_filtradas = [ + return [ obra for obra in lista_de_obras_para_postar - if obra.titulo_obra not in titulos_obras_nao_permitidas + if obra.titulo_obra in titulos_permitidos ] - return obras_filtradas """ diff --git a/src/controller/controller_postagem.py b/src/controller/controller_postagem.py index fb8df3a..0546787 100644 --- a/src/controller/controller_postagem.py +++ b/src/controller/controller_postagem.py @@ -3,9 +3,10 @@ from src.dao.web_screper_site import WebScreperSite from src.dao.conexao_discord import ConexaoDiscord from src.dao.atlas_dao import AtlasDAO -#from src.dao.conexao_facebook import ConexaoFacebook +from src.dao.conexao_facebook import ConexaoFacebook from src.controller.controller_obras import ControllerObras from src.model.posts.post_discord import PostDiscord +from src.model.posts.post_facebook import PostFacebook from src.classes_io.gestor_txt import GestorTXT from model.mensagens import Mensagens @@ -65,21 +66,22 @@ def execucao_principal(): time.sleep(10) - """ Postagem do FB - lista_de_obras_nao_permitidas = atlas_dao.listar_obras_nao_permitidas_fb() + # Postagem do FB — apenas obras com permissão explícita (whitelist) + lista_de_obras_permitidas_fb = atlas_dao.listar_obras_permitidas_fb() - lista_de_obras_facebook = ControllerObras.remover_obras_que_nao_pode_postar(lista_de_obras_atualizada,lista_de_obras_nao_permitidas) + lista_de_obras_facebook = ControllerObras.filtrar_obras_permitidas_fb( + lista_de_obras_atualizada, lista_de_obras_permitidas_fb + ) for obra in lista_de_obras_facebook: try: Mensagens.post_facebook(obra.titulo_obra) post_obra_Facebook = PostFacebook(obra, atlas_dao.receber_obras()) - #Conexao_Facebook.postar_anuncio_facebook(post_obra_Facebook) - + ConexaoFacebook.postar_anuncio_facebook(post_obra_Facebook) + except Exception as e: Mensagens.erro_no_codigo(e) Mensagens.nao_foi_possivel_postar_facebook() Mensagens.mensagem_nao_foi_possivel_postar_obra(obra.titulo_obra) time.sleep(10) - """ diff --git a/src/dao/__pycache__/Atlas_Dao.cpython-310.pyc b/src/dao/__pycache__/Atlas_Dao.cpython-310.pyc deleted file mode 100644 index 42674c8..0000000 Binary files a/src/dao/__pycache__/Atlas_Dao.cpython-310.pyc and /dev/null differ diff --git a/src/dao/__pycache__/Conexao_Discord.cpython-310.pyc b/src/dao/__pycache__/Conexao_Discord.cpython-310.pyc deleted file mode 100644 index 2e9b787..0000000 Binary files a/src/dao/__pycache__/Conexao_Discord.cpython-310.pyc and /dev/null differ diff --git a/src/dao/__pycache__/Conexao_Discord.cpython-311.pyc b/src/dao/__pycache__/Conexao_Discord.cpython-311.pyc deleted file mode 100644 index f7d6349..0000000 Binary files a/src/dao/__pycache__/Conexao_Discord.cpython-311.pyc and /dev/null differ diff --git a/src/dao/__pycache__/Conexao_Facebook.cpython-310.pyc b/src/dao/__pycache__/Conexao_Facebook.cpython-310.pyc deleted file mode 100644 index 07f5d4a..0000000 Binary files a/src/dao/__pycache__/Conexao_Facebook.cpython-310.pyc and /dev/null differ diff --git a/src/dao/__pycache__/Conexao_Facebook.cpython-311.pyc b/src/dao/__pycache__/Conexao_Facebook.cpython-311.pyc deleted file mode 100644 index 9cba802..0000000 Binary files a/src/dao/__pycache__/Conexao_Facebook.cpython-311.pyc and /dev/null differ diff --git a/src/dao/__pycache__/Web_Screper_Site.cpython-310.pyc b/src/dao/__pycache__/Web_Screper_Site.cpython-310.pyc deleted file mode 100644 index 41416b8..0000000 Binary files a/src/dao/__pycache__/Web_Screper_Site.cpython-310.pyc and /dev/null differ diff --git a/src/dao/__pycache__/Web_Screper_Site.cpython-311.pyc b/src/dao/__pycache__/Web_Screper_Site.cpython-311.pyc deleted file mode 100644 index 7ad0c6b..0000000 Binary files a/src/dao/__pycache__/Web_Screper_Site.cpython-311.pyc and /dev/null differ diff --git a/src/dao/__pycache__/Web_Screper_Site.cpython-38.pyc b/src/dao/__pycache__/Web_Screper_Site.cpython-38.pyc deleted file mode 100644 index 75a0d17..0000000 Binary files a/src/dao/__pycache__/Web_Screper_Site.cpython-38.pyc and /dev/null differ diff --git a/src/dao/__pycache__/__init__.cpython-310.pyc b/src/dao/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 17b4aef..0000000 Binary files a/src/dao/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/dao/__pycache__/__init__.cpython-311.pyc b/src/dao/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 8e1e39f..0000000 Binary files a/src/dao/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/dao/__pycache__/atlas_dao.cpython-310.pyc b/src/dao/__pycache__/atlas_dao.cpython-310.pyc deleted file mode 100644 index 7248175..0000000 Binary files a/src/dao/__pycache__/atlas_dao.cpython-310.pyc and /dev/null differ diff --git a/src/dao/__pycache__/atlas_dao.cpython-311.pyc b/src/dao/__pycache__/atlas_dao.cpython-311.pyc deleted file mode 100644 index 93c2c5e..0000000 Binary files a/src/dao/__pycache__/atlas_dao.cpython-311.pyc and /dev/null differ diff --git a/src/dao/__pycache__/conexao_discord.cpython-310.pyc b/src/dao/__pycache__/conexao_discord.cpython-310.pyc deleted file mode 100644 index b20a1ed..0000000 Binary files a/src/dao/__pycache__/conexao_discord.cpython-310.pyc and /dev/null differ diff --git a/src/dao/__pycache__/conexao_discord.cpython-311.pyc b/src/dao/__pycache__/conexao_discord.cpython-311.pyc deleted file mode 100644 index b88a753..0000000 Binary files a/src/dao/__pycache__/conexao_discord.cpython-311.pyc and /dev/null differ diff --git a/src/dao/__pycache__/gemini_dao.cpython-311.pyc b/src/dao/__pycache__/gemini_dao.cpython-311.pyc deleted file mode 100644 index c21cefa..0000000 Binary files a/src/dao/__pycache__/gemini_dao.cpython-311.pyc and /dev/null differ diff --git a/src/dao/__pycache__/web_screper_site.cpython-310.pyc b/src/dao/__pycache__/web_screper_site.cpython-310.pyc deleted file mode 100644 index 2133a17..0000000 Binary files a/src/dao/__pycache__/web_screper_site.cpython-310.pyc and /dev/null differ diff --git a/src/dao/__pycache__/web_screper_site.cpython-311.pyc b/src/dao/__pycache__/web_screper_site.cpython-311.pyc deleted file mode 100644 index 972a844..0000000 Binary files a/src/dao/__pycache__/web_screper_site.cpython-311.pyc and /dev/null differ diff --git a/src/dao/atlas_dao.py b/src/dao/atlas_dao.py index 2d6986b..93205cf 100644 --- a/src/dao/atlas_dao.py +++ b/src/dao/atlas_dao.py @@ -225,15 +225,22 @@ def remover_capitulos_de_uma_obra(self, titulo_obra, lista_de_capitulos): Mensagens.erro_no_banco_de_dados(f"Erro ao remover registros do MongoDB: {e}") - def inserir_obra_nao_permitida_fb(self, dicionario_obra): + def inserir_obra_permitida_fb(self, dicionario_obra: dict) -> None: + """ + Insere uma obra na whitelist de postagem do Facebook. + + Parameters: + dicionario_obra (dict): documento com a chave 'titulo_obra'. + """ db = self.client.DadosPostagem - colecao = db.obrasNaoPermitidasFB + colecao = db.obrasPermitidasFB - # Inserir os dados na coleção try: colecao.insert_one(dicionario_obra) except Exception as e: - Mensagens.erro_no_banco_de_dados(f'Erro ao inserir obra não permitida: {e}') + Mensagens.erro_no_banco_de_dados( + f'Erro ao inserir obra permitida no FB: {e}' + ) def receber_obras(self): @@ -252,21 +259,27 @@ def receber_obras(self): return [] - def listar_obras_nao_permitidas_fb(self): + def listar_obras_permitidas_fb(self): + """ + Retorna a lista de obras com permissão explícita de postagem + no Facebook, lida da coleção 'obrasPermitidasFB'. + + Returns: + list: lista de documentos com a chave 'titulo_obra'. + """ try: db = self.client.DadosPostagem - colecao = db.obrasNaoPermitidasFB + colecao = db.obrasPermitidasFB projection = {'_id': 0} todos_documentos = colecao.find({}, projection=projection).sort('titulo_obra', 1) - - # Convertendo o cursor em uma lista - lista_documentos = list(todos_documentos) - return lista_documentos + return list(todos_documentos) except Exception as e: - Mensagens.erro_no_banco_de_dados(f"Erro ao listar obras não permitidas do MongoDB: {e}") + Mensagens.erro_no_banco_de_dados( + f"Erro ao listar obras permitidas do MongoDB: {e}" + ) return [] diff --git a/src/dao/conexao_facebook.py b/src/dao/conexao_facebook.py index 9076716..e580f68 100644 --- a/src/dao/conexao_facebook.py +++ b/src/dao/conexao_facebook.py @@ -1,27 +1,137 @@ +import logging import os +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry from dotenv import load_dotenv -import facebook + +_TIMEOUT_SEGUNDOS = 30 +_MAX_RETRIES = 3 + + +def _criar_sessao_com_retry() -> requests.Session: + """Cria uma Session com retry automático e backoff exponencial.""" + retry = Retry( + total=_MAX_RETRIES, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["POST"], + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry) + session = requests.Session() + session.mount("https://", adapter) + return session + class ConexaoFacebook: def postar_anuncio_facebook(post_obra): - def receber_caminho_imagem(titulo_obra): - diretorio_imagens = "imagens" - caminho_imagem = os.path.join(diretorio_imagens, f"{titulo_obra}.png") - - return caminho_imagem + """ + Publica uma foto com legenda na página do Facebook via Graph API v25.0. + + Parameters: + post_obra: objeto com titulo_obra, imagem_obra e método + retornar_mensagem_post(). + + Raises: + requests.HTTPError: se alguma chamada à Graph API retornar erro. + """ + logger_infos = logging.getLogger("logger_infos") + logger_erros = logging.getLogger("logger_erros") + + load_dotenv(override=True) + + # Em modo de teste, usa credenciais de teste se ENABLE_FB_TEST=true + is_test_mode = ( + os.getenv("ENABLE_FB_TEST", "false").lower() == "true" + ) + + if is_test_mode: + token_de_acesso_fb = os.getenv("API_TOKEN_PAGINA_TESTE") + id_pagina_fb = os.getenv("API_ID_PAGINA_FACEBOOK_TESTE") + if not token_de_acesso_fb or not id_pagina_fb: + logger_infos.info( + "[MODO TESTE] Credenciais de teste ausentes. " + "Postagem ignorada." + ) + return + logger_infos.info( + "[MODO TESTE] Usando credenciais de teste do Facebook" + ) + else: + token_de_acesso_fb = os.getenv("API_TOKEN_PAGINA") + id_pagina_fb = os.getenv("API_ID_PAGINA_FACEBOOK") + if not token_de_acesso_fb or not id_pagina_fb: + logger_erros.error( + "[MODO PRODUÇÃO] Credenciais do Facebook ausentes " + "(API_TOKEN_PAGINA / API_ID_PAGINA_FACEBOOK). " + "Postagem abortada." + ) + raise EnvironmentError( + "Variáveis API_TOKEN_PAGINA e/ou API_ID_PAGINA_FACEBOOK " + "não estão definidas no ambiente." + ) + + logger_infos.info(f"Postando no Facebook: {post_obra.titulo_obra}") + + session = _criar_sessao_com_retry() + + # Passo 1: envia a URL da imagem como foto não publicada → obtém photo_id. + # Usar URL em vez de upload binário evita criar entradas duplicadas + # na galeria da página a cada nova postagem da mesma obra. + url_photos = ( + f"https://graph.facebook.com/v25.0/{id_pagina_fb}/photos" + ) + + response_upload = session.post( + url_photos, + params={"access_token": token_de_acesso_fb}, + data={ + "url": post_obra.imagem_obra, + "published": "false", + }, + timeout=_TIMEOUT_SEGUNDOS, + ) + + if not response_upload.ok: + logger_erros.error( + f"Erro no upload da imagem: " + f"{response_upload.status_code} — {response_upload.text}" + ) + response_upload.raise_for_status() - load_dotenv() - token_de_acesso_fb = os.getenv('API_TOKEN_PAGINA') - id_pagina_fb = os.getenv("API_ID_PAGINA_FACEBOOK") + photo_id = response_upload.json().get("id") + if not photo_id or not isinstance(photo_id, str): + logger_erros.error( + f"Resposta inesperada no upload da imagem " + f"(photo_id ausente): {response_upload.text}" + ) + raise ValueError( + f"photo_id inválido após upload: {photo_id!r}" + ) + logger_infos.info(f"Imagem enviada. photo_id: {photo_id}") - graph = facebook.GraphAPI(access_token=token_de_acesso_fb, version="3.0") + # Passo 2: publica no feed com a imagem anexada via photo_id + url_feed = f"https://graph.facebook.com/v25.0/{id_pagina_fb}/feed" - # Faz a postagem - # graph.put_object(parent_object=id_pagina_fb, connection_name='feed', message=post_obra.retornar_mensagem_post()) + response_post = session.post( + url_feed, + params={"access_token": token_de_acesso_fb}, + json={ + "message": post_obra.retornar_mensagem_post(), + "attached_media": [{"media_fbid": photo_id}], + }, + timeout=_TIMEOUT_SEGUNDOS, + ) - caminho_imagem = receber_caminho_imagem(post_obra.titulo_obra) - with open(caminho_imagem, 'rb') as imagem: - graph.put_photo(image=imagem, message=post_obra.retornar_mensagem_post()) + if not response_post.ok: + logger_erros.error( + f"Erro ao publicar post: " + f"{response_post.status_code} — {response_post.text}" + ) + response_post.raise_for_status() - return + logger_infos.info( + f"Postagem no Facebook concluída: {post_obra.titulo_obra}" + ) diff --git a/src/dao/web_screper_site.py b/src/dao/web_screper_site.py index 7066667..47195ae 100644 --- a/src/dao/web_screper_site.py +++ b/src/dao/web_screper_site.py @@ -19,8 +19,16 @@ def __init__(self): logger_infos = logging.getLogger('logger_infos') logger_infos.info(" Iniciando Web Screper ") - # Definir o locale como "pt_BR.UTF-8" - locale.setlocale(locale.LC_ALL, 'pt_BR.UTF-8') + # Definir o locale como "pt_BR.UTF-8". + # Em ambientes sem o locale instalado (ex: WSL sem pt_BR gerado) + # a configuração é ignorada com um aviso — o Docker tem o locale. + try: + locale.setlocale(locale.LC_ALL, 'pt_BR.UTF-8') + except locale.Error: + logger_infos.warning( + "Locale pt_BR.UTF-8 não disponível neste ambiente. " + "Datas podem não ser formatadas em português." + ) def recebe_capitulos_diarios(self): diff --git a/src/endpoint/__pycache__/pagination.cpython-310.pyc b/src/endpoint/__pycache__/pagination.cpython-310.pyc deleted file mode 100644 index 0b4b77c..0000000 Binary files a/src/endpoint/__pycache__/pagination.cpython-310.pyc and /dev/null differ diff --git a/src/endpoint/__pycache__/pagination.cpython-311.pyc b/src/endpoint/__pycache__/pagination.cpython-311.pyc deleted file mode 100644 index 7399fcc..0000000 Binary files a/src/endpoint/__pycache__/pagination.cpython-311.pyc and /dev/null differ diff --git a/src/endpoint/bot_endpoint.py b/src/endpoint/bot_endpoint.py index 0664e45..213c246 100644 --- a/src/endpoint/bot_endpoint.py +++ b/src/endpoint/bot_endpoint.py @@ -127,16 +127,16 @@ async def atualizar_mensagem_obra(interaction: discord.Interaction, titulo: str, @app_commands.describe( titulo='Titulo que esta no site' ) -async def adicionar_obra_nao_permitida_fb(interaction: discord.Interaction, titulo: str): - """Obras nessa lista não são postadas no Facebook, por questões de direitos autorais.""" +async def adicionar_obra_permitida_fb(interaction: discord.Interaction, titulo: str): + """Obras nessa lista têm permissão explícita para serem postadas no Facebook.""" try: dicionario_obra = { - "titulo_obra" : titulo + "titulo_obra": titulo } - atlas_dao.inserir_obra_nao_permitida_fb(dicionario_obra) + atlas_dao.inserir_obra_permitida_fb(dicionario_obra) - await interaction.response.send_message(f'{titulo} adicionada com sucesso!') + await interaction.response.send_message(f'{titulo} adicionada à whitelist do FB com sucesso!') except Exception as e: await interaction.response.send_message(f'Erro ao adicionar {titulo}: {e}') @@ -183,16 +183,15 @@ async def get_page(page: int): #Comando para listar obras que estão no banco @client.tree.command() -async def listar_obras_nao_permitidas_fb(interaction: discord.Interaction): - """Listar obras não permitidas para postagem no FB.""" +async def listar_obras_permitidas_fb(interaction: discord.Interaction): + """Listar obras com permissão para postagem no FB (whitelist).""" - cursor_obras = atlas_dao.listar_obras_nao_permitidas_fb() - colecao_obras = list(cursor_obras) # Convertendo o cursor para uma lista de obras + colecao_obras = atlas_dao.listar_obras_permitidas_fb() max_docs_por_pagina = 10 async def get_page(page: int): - emb = discord.Embed(title="Obras Não Permitidas FB", description="Obras que estão salvas no banco de dados.") + emb = discord.Embed(title="Obras Permitidas no FB", description="Obras com permissão explícita de postagem no Facebook.") offset = (page - 1) * max_docs_por_pagina parte_documentos = colecao_obras[offset:offset + max_docs_por_pagina] @@ -202,7 +201,7 @@ async def get_page(page: int): total_pages = Pagination.compute_total_pages(len(colecao_obras), max_docs_por_pagina) emb.set_footer(text=f"Página {page} de {total_pages}") - + return emb, total_pages await Pagination(interaction, get_page).navegate() @@ -272,7 +271,7 @@ async def informacoes_postagem(interaction: discord.Interaction): embed.add_field(name="Horários de verificação", value="12h, 16h, 18h, 20h e 22h", inline=False) embed.add_field(name="Adicionar obras novas", value="Use /adicionar_obra(Também atualiza os já adicionados se o título for o mesmo), /listar_obras e /remover_obra.", inline=False) embed.add_field(name="Gerenciar Postagem", value="Use /forcar_postagem e /excluir_registros.", inline=False) - embed.add_field(name="Gerenciar obras não permitidas no FB", value="Use /adicionar_obra_nao_permitida_fb e /listar_obra_nao_permitida_fb .", inline=False) + embed.add_field(name="Gerenciar whitelist do FB", value="Use /adicionar_obra_permitida_fb e /listar_obras_permitidas_fb .", inline=False) embed.add_field(name="Comandos utilitários", value="/ping, /joined e /informacoes_postagem .", inline=False) embed.add_field(name="Versão em execução", value="3.0.0", inline=False) diff --git a/src/model/__pycache__/Capitulo.cpython-310.pyc b/src/model/__pycache__/Capitulo.cpython-310.pyc deleted file mode 100644 index 7b57b1c..0000000 Binary files a/src/model/__pycache__/Capitulo.cpython-310.pyc and /dev/null differ diff --git a/src/model/__pycache__/Capitulo.cpython-311.pyc b/src/model/__pycache__/Capitulo.cpython-311.pyc deleted file mode 100644 index cd58417..0000000 Binary files a/src/model/__pycache__/Capitulo.cpython-311.pyc and /dev/null differ diff --git a/src/model/__pycache__/Logger_Config.cpython-310.pyc b/src/model/__pycache__/Logger_Config.cpython-310.pyc deleted file mode 100644 index b459a5f..0000000 Binary files a/src/model/__pycache__/Logger_Config.cpython-310.pyc and /dev/null differ diff --git a/src/model/__pycache__/Mensagens.cpython-310.pyc b/src/model/__pycache__/Mensagens.cpython-310.pyc deleted file mode 100644 index 43d179a..0000000 Binary files a/src/model/__pycache__/Mensagens.cpython-310.pyc and /dev/null differ diff --git a/src/model/__pycache__/Mensagens.cpython-311.pyc b/src/model/__pycache__/Mensagens.cpython-311.pyc deleted file mode 100644 index c25b4ef..0000000 Binary files a/src/model/__pycache__/Mensagens.cpython-311.pyc and /dev/null differ diff --git a/src/model/__pycache__/Mensagens.cpython-38.pyc b/src/model/__pycache__/Mensagens.cpython-38.pyc deleted file mode 100644 index be56f0e..0000000 Binary files a/src/model/__pycache__/Mensagens.cpython-38.pyc and /dev/null differ diff --git a/src/model/__pycache__/Obra.cpython-310.pyc b/src/model/__pycache__/Obra.cpython-310.pyc deleted file mode 100644 index 1651772..0000000 Binary files a/src/model/__pycache__/Obra.cpython-310.pyc and /dev/null differ diff --git a/src/model/__pycache__/Obra.cpython-311.pyc b/src/model/__pycache__/Obra.cpython-311.pyc deleted file mode 100644 index 4a98515..0000000 Binary files a/src/model/__pycache__/Obra.cpython-311.pyc and /dev/null differ diff --git a/src/model/__pycache__/__init__.cpython-310.pyc b/src/model/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 327b6f9..0000000 Binary files a/src/model/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/model/__pycache__/__init__.cpython-311.pyc b/src/model/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 6af13e1..0000000 Binary files a/src/model/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/model/__pycache__/capitulo.cpython-310.pyc b/src/model/__pycache__/capitulo.cpython-310.pyc deleted file mode 100644 index ee143bf..0000000 Binary files a/src/model/__pycache__/capitulo.cpython-310.pyc and /dev/null differ diff --git a/src/model/__pycache__/capitulo.cpython-311.pyc b/src/model/__pycache__/capitulo.cpython-311.pyc deleted file mode 100644 index 13b86b0..0000000 Binary files a/src/model/__pycache__/capitulo.cpython-311.pyc and /dev/null differ diff --git a/src/model/__pycache__/logger_config.cpython-311.pyc b/src/model/__pycache__/logger_config.cpython-311.pyc deleted file mode 100644 index a1a73c6..0000000 Binary files a/src/model/__pycache__/logger_config.cpython-311.pyc and /dev/null differ diff --git a/src/model/__pycache__/mensagens.cpython-310.pyc b/src/model/__pycache__/mensagens.cpython-310.pyc deleted file mode 100644 index 698961f..0000000 Binary files a/src/model/__pycache__/mensagens.cpython-310.pyc and /dev/null differ diff --git a/src/model/__pycache__/mensagens.cpython-311.pyc b/src/model/__pycache__/mensagens.cpython-311.pyc deleted file mode 100644 index 7debbd6..0000000 Binary files a/src/model/__pycache__/mensagens.cpython-311.pyc and /dev/null differ diff --git a/src/model/__pycache__/obra.cpython-310.pyc b/src/model/__pycache__/obra.cpython-310.pyc deleted file mode 100644 index 06d911f..0000000 Binary files a/src/model/__pycache__/obra.cpython-310.pyc and /dev/null differ diff --git a/src/model/__pycache__/obra.cpython-311.pyc b/src/model/__pycache__/obra.cpython-311.pyc deleted file mode 100644 index 8693fb3..0000000 Binary files a/src/model/__pycache__/obra.cpython-311.pyc and /dev/null differ diff --git a/src/model/posts/__pycache__/Post_Discord.cpython-310.pyc b/src/model/posts/__pycache__/Post_Discord.cpython-310.pyc deleted file mode 100644 index a84c9cc..0000000 Binary files a/src/model/posts/__pycache__/Post_Discord.cpython-310.pyc and /dev/null differ diff --git a/src/model/posts/__pycache__/Post_Discord.cpython-311.pyc b/src/model/posts/__pycache__/Post_Discord.cpython-311.pyc deleted file mode 100644 index e15c641..0000000 Binary files a/src/model/posts/__pycache__/Post_Discord.cpython-311.pyc and /dev/null differ diff --git a/src/model/posts/__pycache__/Post_Facebook.cpython-310.pyc b/src/model/posts/__pycache__/Post_Facebook.cpython-310.pyc deleted file mode 100644 index 3fa9a8c..0000000 Binary files a/src/model/posts/__pycache__/Post_Facebook.cpython-310.pyc and /dev/null differ diff --git a/src/model/posts/__pycache__/Post_Facebook.cpython-311.pyc b/src/model/posts/__pycache__/Post_Facebook.cpython-311.pyc deleted file mode 100644 index f780441..0000000 Binary files a/src/model/posts/__pycache__/Post_Facebook.cpython-311.pyc and /dev/null differ diff --git a/src/model/posts/__pycache__/__init__.cpython-310.pyc b/src/model/posts/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 42fe3d8..0000000 Binary files a/src/model/posts/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/model/posts/__pycache__/__init__.cpython-311.pyc b/src/model/posts/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index e149f77..0000000 Binary files a/src/model/posts/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/model/posts/__pycache__/post_discord.cpython-310.pyc b/src/model/posts/__pycache__/post_discord.cpython-310.pyc deleted file mode 100644 index 34c5c1d..0000000 Binary files a/src/model/posts/__pycache__/post_discord.cpython-310.pyc and /dev/null differ diff --git a/src/model/posts/__pycache__/post_discord.cpython-311.pyc b/src/model/posts/__pycache__/post_discord.cpython-311.pyc deleted file mode 100644 index 9029bea..0000000 Binary files a/src/model/posts/__pycache__/post_discord.cpython-311.pyc and /dev/null differ diff --git a/src/model/posts/post_facebook.py b/src/model/posts/post_facebook.py index bce1d2b..8f02920 100644 --- a/src/model/posts/post_facebook.py +++ b/src/model/posts/post_facebook.py @@ -11,9 +11,22 @@ def __init__(self, obra, dados_unicos_obras): self.lista_de_capitulos = obra.lista_de_capitulos - self.imagem_obra = dados_unicos_obras[self.titulo_obra]['url_imagem'] - - self.logger_infos.info(f"Construindo Post {self.titulo_obra }...") + # Itera sobre a lista de obras (mesmo padrão do PostDiscord) em vez de + # indexar como dict, o que causaria TypeError pois dados_unicos_obras é list. + obra_encontrada = None + for dados_obra in dados_unicos_obras: + if dados_obra.get('titulo') == self.titulo_obra: + obra_encontrada = dados_obra + break + + if obra_encontrada: + self.imagem_obra = obra_encontrada['url_imagem'] + else: + self.logger_infos.warning( + f"Obra '{self.titulo_obra}' não encontrada em dados_unicos_obras." + ) + + self.logger_infos.info(f"Construindo Post {self.titulo_obra}...") def retornar_mensagem_post(self): diff --git a/tests/__pycache__/__init__.cpython-310.pyc b/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 51d319d..0000000 Binary files a/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/tests/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index a66af8d..0000000 Binary files a/tests/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/tests/__pycache__/test_conexao_discord.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_conexao_discord.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index 50e7e4c..0000000 Binary files a/tests/__pycache__/test_conexao_discord.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_controller_obras.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_controller_obras.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index 79b2de9..0000000 Binary files a/tests/__pycache__/test_controller_obras.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_controller_obras.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_controller_obras.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index 12867b4..0000000 Binary files a/tests/__pycache__/test_controller_obras.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_dao_atlas.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_dao_atlas.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index b597bae..0000000 Binary files a/tests/__pycache__/test_dao_atlas.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_dao_atlas.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_dao_atlas.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index d4d9ee5..0000000 Binary files a/tests/__pycache__/test_dao_atlas.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_dao_conexao_discord.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_dao_conexao_discord.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index 22620fb..0000000 Binary files a/tests/__pycache__/test_dao_conexao_discord.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_dao_conexao_discord.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_dao_conexao_discord.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index 38a4cc9..0000000 Binary files a/tests/__pycache__/test_dao_conexao_discord.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_dao_web_screper.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_dao_web_screper.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index b7e4749..0000000 Binary files a/tests/__pycache__/test_dao_web_screper.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_dao_web_screper.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_dao_web_screper.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index 9e35f6d..0000000 Binary files a/tests/__pycache__/test_dao_web_screper.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_gestor_txt.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_gestor_txt.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index f3c00f0..0000000 Binary files a/tests/__pycache__/test_gestor_txt.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_model.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_model.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index af323e8..0000000 Binary files a/tests/__pycache__/test_model.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_model_capitulo.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_model_capitulo.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index 4adca9a..0000000 Binary files a/tests/__pycache__/test_model_capitulo.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_model_capitulo.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_model_capitulo.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index ea3ed98..0000000 Binary files a/tests/__pycache__/test_model_capitulo.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_model_obra.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_model_obra.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index 747111c..0000000 Binary files a/tests/__pycache__/test_model_obra.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_model_obra.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_model_obra.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index 4667856..0000000 Binary files a/tests/__pycache__/test_model_obra.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_model_post.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_model_post.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index af18eee..0000000 Binary files a/tests/__pycache__/test_model_post.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_model_posts.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_model_posts.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index 167ad49..0000000 Binary files a/tests/__pycache__/test_model_posts.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_model_posts.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_model_posts.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index 2c9d30b..0000000 Binary files a/tests/__pycache__/test_model_posts.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_postagem.cpython-311-pytest-7.1.2.pyc b/tests/__pycache__/test_postagem.cpython-311-pytest-7.1.2.pyc deleted file mode 100644 index dd82af8..0000000 Binary files a/tests/__pycache__/test_postagem.cpython-311-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_web_screper.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_web_screper.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index daade31..0000000 Binary files a/tests/__pycache__/test_web_screper.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/test_controller_obras.py b/tests/test_controller_obras.py index 63cbb19..a0bec18 100644 --- a/tests/test_controller_obras.py +++ b/tests/test_controller_obras.py @@ -25,21 +25,24 @@ def test_remove_obras_nao_registradas(self): def test_remover_obras_que_nao_pode_postar(self): - # Mock data + """ + Valida a lógica de whitelist: apenas obras presentes na lista + de permitidas devem ser retornadas. + """ lista_de_obras_para_postar = [ Obra("Obra Teste 1", "Teste", "Teste"), Obra("Obra Teste 2", "Teste", "Teste"), Obra("Obra Teste 3", "Teste", "Teste") ] - lista_de_obras_nao_permitidas = [ - {'titulo_obra': 'Obra Teste 2'}, - {'titulo_obra': 'Obra Teste 3'} + # Apenas Obra Teste 1 está na whitelist + lista_de_obras_permitidas = [ + {'titulo_obra': 'Obra Teste 1'}, ] - # Call the function - obras_filtradas = ControllerObras.remover_obras_que_nao_pode_postar(lista_de_obras_para_postar, lista_de_obras_nao_permitidas) + obras_filtradas = ControllerObras.filtrar_obras_permitidas_fb( + lista_de_obras_para_postar, lista_de_obras_permitidas + ) - # Assert the results assert len(obras_filtradas) == 1 assert obras_filtradas[0].titulo_obra == 'Obra Teste 1' diff --git a/tests/test_dao_conexao_facebook.py b/tests/test_dao_conexao_facebook.py new file mode 100644 index 0000000..8dc0720 --- /dev/null +++ b/tests/test_dao_conexao_facebook.py @@ -0,0 +1,292 @@ +import os +import pytest +from unittest.mock import patch, MagicMock +from dotenv import load_dotenv +from src.dao.conexao_facebook import ConexaoFacebook +from src.dao.atlas_dao import AtlasDAO +from src.controller.controller_obras import ControllerObras +from src.model.capitulo import Capitulo +from src.model.obra import Obra +from src.model.posts.post_facebook import PostFacebook + + +class TestRegraDeNegocioFacebook: + """ + Testes de regra de negócio para postagem no Facebook (whitelist). + Estes testes não fazem chamadas à API — sempre executam. + """ + + def test_somente_obras_permitidas_sao_retornadas(self): + """ + Apenas obras cujo título consta na whitelist devem ser retornadas. + """ + lista_para_postar = [ + Obra("Obra A", "http://img.com/a.png", "http://site.com/a"), + Obra("Obra B", "http://img.com/b.png", "http://site.com/b"), + Obra("Obra C", "http://img.com/c.png", "http://site.com/c"), + ] + permitidas = [ + {"titulo_obra": "Obra A"}, + ] + + resultado = ControllerObras.filtrar_obras_permitidas_fb( + lista_para_postar, permitidas + ) + + assert len(resultado) == 1 + assert resultado[0].titulo_obra == "Obra A" + + def test_whitelist_vazia_retorna_lista_vazia(self): + """ + Com a whitelist vazia, nenhuma obra deve ser retornada. + """ + lista_para_postar = [ + Obra("Obra A", "http://img.com/a.png", "http://site.com/a"), + Obra("Obra B", "http://img.com/b.png", "http://site.com/b"), + ] + + resultado = ControllerObras.filtrar_obras_permitidas_fb( + lista_para_postar, [] + ) + + assert len(resultado) == 0 + + def test_todas_obras_permitidas_retorna_lista_completa(self): + """ + Se todas as obras estiverem na whitelist, todas devem ser retornadas. + """ + lista_para_postar = [ + Obra("Obra A", "http://img.com/a.png", "http://site.com/a"), + Obra("Obra B", "http://img.com/b.png", "http://site.com/b"), + ] + permitidas = [ + {"titulo_obra": "Obra A"}, + {"titulo_obra": "Obra B"}, + ] + + resultado = ControllerObras.filtrar_obras_permitidas_fb( + lista_para_postar, permitidas + ) + + assert len(resultado) == 2 + + def test_obra_nao_listada_na_whitelist_e_removida(self): + """ + Obra ausente da whitelist não deve aparecer no resultado, + mesmo que não esteja em nenhuma lista de bloqueio. + """ + lista_para_postar = [ + Obra("Obra Permitida", "http://img.com/p.png", "http://site.com/p"), + Obra("Obra Sem Permissao", "http://img.com/s.png", "http://site.com/s"), + ] + permitidas = [{"titulo_obra": "Obra Permitida"}] + + resultado = ControllerObras.filtrar_obras_permitidas_fb( + lista_para_postar, permitidas + ) + + assert len(resultado) == 1 + assert resultado[0].titulo_obra == "Obra Permitida" + + +class TestFlagEnableFbTest: + """ + Testes que validam o comportamento da flag ENABLE_FB_TEST. + + Quando true sem credenciais de teste: não posta em lugar nenhum. + Quando true com credenciais de teste: posta na página de teste. + """ + + def test_sem_credenciais_de_teste_nao_faz_chamada_api( + self, monkeypatch + ): + """ + Quando ENABLE_FB_TEST=true mas as variáveis de ambiente da + página de teste estão ausentes, nenhuma chamada à API deve + ser feita. + """ + monkeypatch.setenv("ENABLE_FB_TEST", "true") + monkeypatch.delenv("API_TOKEN_PAGINA_TESTE", raising=False) + monkeypatch.delenv("API_ID_PAGINA_FACEBOOK_TESTE", raising=False) + + obra = Obra("Teste", "http://img.com/a.png", "http://site.com/a") + post_facebook = PostFacebook(obra, []) + capitulo = Capitulo("1", "http://site.com/cap1", "março 7, 2026") + post_facebook.lista_de_capitulos.append(capitulo) + + with patch("src.dao.conexao_facebook.requests.post") as mock_post: + with patch("src.dao.conexao_facebook.load_dotenv"): + ConexaoFacebook.postar_anuncio_facebook(post_facebook) + + assert mock_post.call_count == 0 + + +class TestWhitelistFluxoPostagem: + """ + Testes que validam o fluxo completo: whitelist do Atlas → postagem. + + Simula o trecho do ControllerPostagem que consulta a whitelist e + chama ConexaoFacebook, garantindo que apenas obras autorizadas + chegam à API do Facebook. + """ + + def _simular_fluxo_fb( + self, + lista_obras: list, + whitelist: list, + mock_fb, + ) -> list: + """ + Reproduz o trecho de postagem do FB de execucao_principal, + retornando os títulos das obras que foram efetivamente postadas. + """ + obras_postadas = [] + + obras_permitidas = ControllerObras.filtrar_obras_permitidas_fb( + lista_obras, whitelist + ) + + for obra in obras_permitidas: + post = PostFacebook(obra, []) + cap = Capitulo("1", "http://site.com/cap1", "março 7, 2026") + post.lista_de_capitulos.append(cap) + mock_fb(post) + obras_postadas.append(obra.titulo_obra) + + return obras_postadas + + def test_obra_fora_da_whitelist_nao_e_postada(self): + """ + Obra ausente da whitelist do Atlas não deve gerar chamada ao + Facebook, mesmo que tenha capítulos novos. + """ + lista_obras = [ + Obra("Obra Sem Permissao", "http://img.com/s.png", "http://site.com/s"), + ] + whitelist = [] # Atlas retorna vazio — nenhuma obra autorizada + + mock_fb = MagicMock() + postadas = self._simular_fluxo_fb(lista_obras, whitelist, mock_fb) + + assert mock_fb.call_count == 0 + assert postadas == [] + + def test_obra_na_whitelist_e_postada(self): + """ + Obra presente na whitelist do Atlas deve ser postada no Facebook. + """ + lista_obras = [ + Obra("Obra Permitida", "http://img.com/p.png", "http://site.com/p"), + ] + whitelist = [{"titulo_obra": "Obra Permitida"}] + + mock_fb = MagicMock() + postadas = self._simular_fluxo_fb(lista_obras, whitelist, mock_fb) + + assert mock_fb.call_count == 1 + assert postadas == ["Obra Permitida"] + + def test_apenas_obras_da_whitelist_sao_postadas(self): + """ + Com múltiplas obras, apenas as presentes na whitelist devem + ser postadas; as demais devem ser ignoradas. + """ + lista_obras = [ + Obra("Obra A", "http://img.com/a.png", "http://site.com/a"), + Obra("Obra B", "http://img.com/b.png", "http://site.com/b"), + Obra("Obra C", "http://img.com/c.png", "http://site.com/c"), + ] + # Apenas A e C estão autorizadas + whitelist = [ + {"titulo_obra": "Obra A"}, + {"titulo_obra": "Obra C"}, + ] + + mock_fb = MagicMock() + postadas = self._simular_fluxo_fb(lista_obras, whitelist, mock_fb) + + assert mock_fb.call_count == 2 + assert "Obra A" in postadas + assert "Obra C" in postadas + assert "Obra B" not in postadas + + +class TestConexaoFacebook: + """ + Testes para postagem no Facebook. + + Por padrão, os testes estão DESABILITADOS para evitar postagens acidentais. + Para habilitar, defina ENABLE_FB_TEST=true no .env e execute: + pytest tests/test_dao_conexao_facebook.py + """ + + def test_postagem_facebook_esta_funcionando(self): + """ + Testa a postagem de dois capítulos no Facebook. + Requer ENABLE_FB_TEST=true no .env para executar. + """ + load_dotenv(override=True) + if os.getenv("ENABLE_FB_TEST", "false").lower() != "true": + pytest.skip("ENABLE_FB_TEST não está true no .env") + atlas_dao = AtlasDAO() + + # Cria uma obra de teste + obra = Obra( + "Teste de Postagem", + "https://tsundoku.com.br/wp-content/uploads/2022/02/Gosu_The_Master1.png", + "https://tsundoku.com.br/manga/teste-de-postagem/" + ) + + post_facebook = PostFacebook(obra, atlas_dao.receber_obras()) + + # Adiciona capítulos de teste + capitulo_um = Capitulo( + "9998", + "https://tsundoku.com.br/teste-cap-9998/", + "março 4, 2026" + ) + + capitulo_dois = Capitulo( + "9999", + "https://tsundoku.com.br/teste-cap-9999/", + "março 4, 2026" + ) + + post_facebook.lista_de_capitulos += [capitulo_um, capitulo_dois] + + # Executa a postagem + ConexaoFacebook.postar_anuncio_facebook(post_facebook) + + # Se chegou aqui sem exceção, o teste passou + assert True + + def test_postagem_facebook_um_capitulo(self): + """ + Testa a postagem de um único capítulo no Facebook. + Requer ENABLE_FB_TEST=true no .env para executar. + """ + load_dotenv(override=True) + if os.getenv("ENABLE_FB_TEST", "false").lower() != "true": + pytest.skip("ENABLE_FB_TEST não está true no .env") + atlas_dao = AtlasDAO() + + obra = Obra( + "Teste de Postagem", + "https://tsundoku.com.br/wp-content/uploads/2022/02/Gosu_The_Master1.png", + "https://tsundoku.com.br/manga/teste-de-postagem/" + ) + + post_facebook = PostFacebook(obra, atlas_dao.receber_obras()) + + capitulo = Capitulo( + "100", + "https://tsundoku.com.br/teste-cap-100/", + "março 4, 2026" + ) + + post_facebook.lista_de_capitulos.append(capitulo) + + # Executa a postagem + ConexaoFacebook.postar_anuncio_facebook(post_facebook) + + assert True diff --git a/tests/test_dao_web_screper.py b/tests/test_dao_web_screper.py index 36f6e77..db35763 100644 --- a/tests/test_dao_web_screper.py +++ b/tests/test_dao_web_screper.py @@ -2,14 +2,23 @@ import datetime import logging +from dotenv import load_dotenv from src.dao.web_screper_site import WebScreperSite +load_dotenv() + +MESES_PORTUGUES = [ + 'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', + 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro' +] + + class TestWebScreper: def test_valida_se_esta_recebendo_dados_obras(self): web_screper = WebScreperSite() dados_obra = web_screper.receber_conteudo(5) - [print(dado) for dado in dados_obra ] + [print(dado) for dado in dados_obra] assert len(dados_obra[0]) > 0 assert len(dados_obra[1]) > 0 @@ -17,39 +26,30 @@ def test_valida_se_esta_recebendo_dados_obras(self): assert len(dados_obra[3]) > 0 assert len(dados_obra[4]) > 0 - def test_valida_se_esta_recebendo_capitulos_obras(self): web_screper = WebScreperSite() dados_obra = web_screper.recebe_capitulos_diarios() - [print(obra) for obra in dados_obra ] + [print(obra) for obra in dados_obra] assert len(dados_obra) > 0 - def test_verifica_se_data_esta_em_portugues(self): + """ + Verifica se as datas retornadas pelo scraper estão no formato + em português (ex: 'março 7, 2026'). + Usa lista fixa de meses para não depender do locale do sistema. + """ web_screper = WebScreperSite() datas = web_screper.receber_datas() logger_infos = logging.getLogger('logger_infos') logger_infos.info(datas) - locale.setlocale(locale.LC_TIME, 'pt_BR.utf8') - for data in datas: - try: - # Tenta fazer o parse da data - dt = datetime.datetime.strptime(data, '%B %d, %Y') - logger_infos.info(f"A data {dt} está em um formato válido") - - except ValueError: - assert False, f"A data {data} está em um formato inválido" - - # Verifica se o nome do mês está em português - mes_numero = dt.month - nome_mes_portugues = datetime.date(1900, mes_numero, 1).strftime('%B').lower() - - assert nome_mes_portugues in [ - 'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', - 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro' - ], f"A data {data} não está em português" \ No newline at end of file + partes = data.split() + assert len(partes) >= 1, f"Formato de data inesperado: {data}" + mes = partes[0].lower().rstrip(',') + assert mes in MESES_PORTUGUES, ( + f"Mês '{mes}' não está em português na data '{data}'" + ) \ No newline at end of file diff --git a/tests/test_postagem.py b/tests/test_postagem.py index 2da74be..38a08e6 100644 --- a/tests/test_postagem.py +++ b/tests/test_postagem.py @@ -1,9 +1,12 @@ +from dotenv import load_dotenv from src.dao.atlas_dao import AtlasDAO from src.model.obra import Obra from src.model.capitulo import Capitulo from src.controller.controller_obras import ControllerObras from src.dao.web_screper_site import WebScreperSite +load_dotenv() + class TestPostagem: def test_verifica_remocao_obras_nao_registradas(self): atlas_dao = AtlasDAO()