feat: added docker-compose and update app

This commit is contained in:
Rubén Castrelo Suárez
2026-08-30 23:37:07 +02:00
parent ae23d9eee8
commit 468bbd6282
32 changed files with 2062 additions and 192 deletions
+17
View File
@@ -0,0 +1,17 @@
.git
.github
.venv
venv
__pycache__
*.pyc
*.pyo
*.pyd
.env
*.env
tests
data
*.db
*.db-*
.DS_Store
.idea
.vscode
+7 -1
View File
@@ -12,4 +12,10 @@ venv/
.idea/
# OS
.DS_Store
.DS_Store
# Database
*.db
*.db-*
data/
tests/_test_entries.db
+55
View File
@@ -0,0 +1,55 @@
# instagram-sorteo
A personal tool that scans Instagram for giveaways, records the accounts it enters, tracks who the host asks it to tag or mention, and notes when each giveaway's winner is announced — surfaced through a web dashboard.
## Language
**Giveaway** (Sorteo):
An Instagram post that offers a prize and requires one or more actions to enter.
_Avoid_: contest, raffle (when referring to the post itself)
**Host** (Creator):
The Instagram account running the giveaway.
_Avoid_: account, page, poster
**Entry** (Inscripción):
The formal act of participating in a giveaway, following its rules.
_Avoid_: inscription record, signup
**Rule** (Requerimiento):
A single action a giveaway demands for an entry to count (e.g. follow, like, comment, tag friends).
_Avoid_: condition, step
**Mention** (Etiqueta / Mencionar):
An Instagram account the host instructs an entrant to tag, usually to spread the giveaway.
_Avoid_: handle, tag (when the type of action is meant)
**Prize** (Premio):
What the giveaway awards to its winner.
_Avoid_: reward, gift
**Draw Date** (Fecha del sorteo):
The day and time the host announces the winner.
_Avoid_: celebration date, end date (unless the code uses it)
## Relationships
- A **Giveaway** is run by one **Host**
- A **Giveaway** offers one or more **Prizes**
- A **Giveaway** imposes one or more **Rules**
- A **Rule** can require tagging one or more **Mentions**
- An **Entry** belongs to exactly one **Giveaway**
- A **Giveaway** has exactly one **Draw Date**
## Example dialogue
> **Dev:** "When we detect a **Giveaway**, do we always create the **Entry** immediately?"
> **Domain expert:** "No — an **Entry** only exists once we've satisfied the **Rules** and the user has confirmed, then we record it against the **Giveaway**."
> **Dev:** "What's a **Mention** versus a **Rule**?"
> **Domain expert:** "A **Rule** is the demand ('tag two friends'); the **Mentions** are the specific accounts we actually tag to fulfil it."
## Flagged ambiguities
- "sorteo" and "giveaway" both refer to the same concept; **Giveaway** is canonical.
- "fecha del sorteo" could read as the entry deadline, but it means the **Draw Date** when the winner is announced. Use **Draw Date** to avoid confusion with entry deadlines.
+5 -1
View File
@@ -8,6 +8,10 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data
ENV INSTAGRAM_SORTEO_DB=/app/data/entries.db
EXPOSE 8080
CMD ["python3", "main.py"]
CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "8080"]
+123 -42
View File
@@ -1,40 +1,51 @@
"""Analizador de IA local simple para detección de sorteos."""
"""Rule-based analyzer that detects giveaways and structures their details."""
import re
import sys
import os
from datetime import date, datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import GIVEAWAY_KEYWORDS, AI_MODE
# Date patterns used to parse the Draw Date (fecha del sorteo) from a caption.
_MONTHS_ES = {
"enero": 1, "febrero": 2, "marzo": 3, "abril": 4, "mayo": 5, "junio": 6,
"julio": 7, "agosto": 8, "septiembre": 9, "octubre": 10, "noviembre": 11,
"diciembre": 12,
}
_MONTHS_EN = {
"january": 1, "jan": 1, "february": 2, "feb": 2, "march": 3, "mar": 3,
"april": 4, "apr": 4, "may": 5, "june": 6, "jun": 6, "july": 7,
"jul": 7, "august": 8, "aug": 8, "september": 9, "sep": 9, "sept": 9,
"october": 10, "oct": 10, "november": 11, "nov": 11, "december": 12,
"dec": 12,
}
_DATE_PATTERNS = [
(re.compile(r"(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})"), "%d/%m/%Y"),
(re.compile(r"(\d{1,2}) de ([a-záéíóú]+)(?: de (\d{4}))?"), "day_month"),
]
class GiveawayAnalyzer:
"""Analizador simple basado en reglas para detectar sorteos en texto."""
"""Simple rule-based analyzer to detect giveaways and extract details."""
def __init__(self, mode="rule_based"):
self.mode = mode
self.keywords = GIVEAWAY_KEYWORDS
def is_giveaway(self, text):
"""
Determinar si un texto corresponde a un sorteo.
Args:
text: Texto de la descripción o caption de Instagram
Returns:
bool: True si parece un sorteo
"""
"""Return True if the caption appears to be a giveaway."""
if not text:
return False
text_lower = text.lower()
# Contar coincidencias de palabras clave
matches = sum(1 for kw in self.keywords if kw.lower() in text_lower)
# Heurística: si hay al menos 2 palabras clave, es probable que sea un sorteo
if matches >= 2:
return True
# Patrones más específicos
giveaway_patterns = [
"#sorteo" in text_lower,
"#giveaway" in text_lower,
@@ -43,34 +54,91 @@ class GiveawayAnalyzer:
"etiqueta" in text_lower or "tag" in text_lower,
"comenta" in text_lower,
]
return any(giveaway_patterns)
if any(giveaway_patterns):
return True
def extract_mentions(self, text):
"""Return a list of @usernames appearing in the caption.
return False
Mentions are the accounts a host may require you to tag, so we collect
every distinct handle rather than just the first.
"""
if not text:
return []
seen = []
for match in re.findall(r"@([a-zA-Z0-9_.]+)", text):
username = match.rstrip(".")
if username and username not in seen:
seen.append(username)
return seen
def extract_draw_date(self, text):
"""Parse the Draw Date (winner announcement) from the caption.
Returns (date_or_None, raw_and_matches_found).
"""
if not text:
return None, []
found = []
# Numeric day/month/year e.g. "15/08/2026", "15-08-2026".
for pattern, fmt in _DATE_PATTERNS[:1]:
for m in pattern.finditer(text):
raw = m.group(0)
try:
if fmt == "%d/%m/%Y":
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
if y < 100:
y += 2000
dt = date(y, mo, d)
found.append((raw, dt))
except ValueError:
continue
# Spoken month e.g. "15 de agosto", "15 de agosto de 2026".
for m in re.finditer(r"(\d{1,2})\s+de\s+([a-záéíóú]+)(?:\s+de\s+(\d{4}))?", text, re.IGNORECASE):
raw = m.group(0)
day = int(m.group(1))
month_name = m.group(2).lower()
year = int(m.group(3)) if m.group(3) else datetime.now().year
month = _MONTHS_ES.get(month_name) or _MONTHS_EN.get(month_name)
if month is None:
continue
try:
dt = date(year, month, day)
found.append((raw, dt))
except ValueError:
continue
if not found:
return None, []
# Prefer the latest plausible date as the announcement.
found.sort(key=lambda item: item[1])
best_raw, best_date = found[-1]
return best_date, [raw for raw, _ in found]
def extract_requirements(self, text):
"""
Extraer los requisitos del sorteo del texto.
Args:
text: Texto del sorteo
Returns:
dict: Diccionario con requisitos extraídos
"""
"""Extract the rules and structured details of a giveaway caption."""
requirements = {
"follow": False,
"like": False,
"comment": False,
"tag_friends": False,
"hashtag": None,
"username": None
"username": None,
"mentions": [],
"draw_date": None,
"draw_date_raw": None,
"prize": None,
}
if not text:
return requirements
text_lower = text.lower()
if "follow" in text_lower or "seguir" in text_lower:
if "follow" in text_lower or "seguir" in text_lower or "síguenos" in text_lower or "siguenos" in text_lower:
requirements["follow"] = True
if "like" in text_lower or "dar like" in text_lower or "corazón" in text_lower:
requirements["like"] = True
@@ -79,31 +147,44 @@ class GiveawayAnalyzer:
if "tag" in text_lower or "etiqueta" in text_lower or "menciona" in text_lower:
requirements["tag_friends"] = True
# Buscar hashtag
import re
hashtag_match = re.search(r"#(\w+)", text)
if hashtag_match:
requirements["hashtag"] = hashtag_match.group(1)
# Buscar usuario (@usuario)
user_match = re.search(r"@(\w+)", text)
if user_match:
requirements["username"] = user_match.group(1)
requirements["mentions"] = self.extract_mentions(text)
draw_date, raws = self.extract_draw_date(text)
requirements["draw_date"] = draw_date
requirements["draw_date_raw"] = raws[0] if raws else None
requirements["prize"] = self.extract_prize(text)
return requirements
def extract_prize(self, text):
"""Best-effort extraction of the prize description from a caption."""
if not text:
return None
text_lower = text.lower()
for marker in ("premio:", "prize:", "gana ", "win "):
idx = text_lower.find(marker)
if idx >= 0:
start = idx + len(marker)
segment = text[start:].strip()
# Cut at common sentence boundaries.
segment = re.split(r"[.\n;]", segment)[0].strip()
if segment:
return segment
return None
def analyze(self, text):
"""
Análisis completo de un texto de sorteo.
Args:
text: Texto a analizar
Returns:
dict: Resultado del análisis
"""
"""Full analysis of a giveaway caption into structured data."""
return {
"is_giveaway": self.is_giveaway(text),
"requirements": self.extract_requirements(text),
"text": text[:100] + "..." if len(text) > 100 else text
}
"text": text[:100] + "..." if len(text) > 100 else text,
}
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
"""Persistence package for instagram-sorteo."""
+60
View File
@@ -0,0 +1,60 @@
"""Engine and session management for the SQLite store."""
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from db.models import Base
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.getenv("INSTAGRAM_SORTEO_DATA_DIR", os.path.join(_BASE_DIR, "data"))
DEFAULT_DB_PATH = os.getenv(
"INSTAGRAM_SORTEO_DB",
os.path.join(DATA_DIR, "entries.db"),
)
def _ensure_parent_dir(path: str) -> None:
parent = os.path.dirname(os.path.abspath(path))
if parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
def _db_url(path: str) -> str:
if path == ":memory:":
return "sqlite:///:memory:"
return f"sqlite:///{os.path.abspath(path)}"
def make_engine(path: str = DEFAULT_DB_PATH):
if path != ":memory:":
_ensure_parent_dir(path)
return create_engine(
_db_url(path),
connect_args={"check_same_thread": False},
)
def init_db(path: str = DEFAULT_DB_PATH) -> None:
"""Create all tables if they do not yet exist."""
engine = make_engine(path)
Base.metadata.create_all(engine)
return engine
def make_session_factory(path: str = DEFAULT_DB_PATH):
return sessionmaker(bind=make_engine(path), expire_on_commit=False)
# Default application session factory (points at entries.db unless overridden).
SessionLocal = make_session_factory(DEFAULT_DB_PATH)
def get_db():
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
+130
View File
@@ -0,0 +1,130 @@
"""Database models mapped to the instagram-sorteo domain glossary."""
from datetime import date, datetime, timezone
from sqlalchemy import (
Boolean,
Column,
Date,
DateTime,
ForeignKey,
Integer,
String,
Table,
)
from sqlalchemy.orm import DeclarativeBase, relationship
def _utcnow():
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
pass
# Association table: a Rule can require tagging one or more Mentions (M2M
# vs. storing the same mention across many rules).
rule_mentions = Table(
"rule_mentions",
Base.metadata,
Column("rule_id", Integer, ForeignKey("rules.id"), primary_key=True),
Column("mention_id", Integer, ForeignKey("mentions.id"), primary_key=True),
)
class Host(Base):
"""The Instagram Creator running the Giveaway."""
__tablename__ = "hosts"
id = Column(Integer, primary_key=True)
username = Column(String, unique=True, nullable=False, index=True)
display_name = Column(String, nullable=True)
giveaways = relationship("Giveaway", back_populates="host")
class Giveaway(Base):
"""An Instagram post offering a prize that requires actions to enter."""
__tablename__ = "giveaways"
id = Column(Integer, primary_key=True)
url = Column(String, unique=True, nullable=False, index=True)
caption = Column(String, nullable=True)
host_id = Column(Integer, ForeignKey("hosts.id"), nullable=False)
host = relationship("Host", back_populates="giveaways")
prizes = relationship("Prize", back_populates="giveaway", cascade="all, delete-orphan")
rules = relationship("Rule", back_populates="giveaway", cascade="all, delete-orphan")
draw_dates = relationship("DrawDate", back_populates="giveaway", cascade="all, delete-orphan")
entries = relationship("Entry", back_populates="giveaway", cascade="all, delete-orphan")
class Prize(Base):
"""What the Giveaway awards to its winner."""
__tablename__ = "prizes"
id = Column(Integer, primary_key=True)
description = Column(String, nullable=True)
giveaway_id = Column(Integer, ForeignKey("giveaways.id"), nullable=False)
giveaway = relationship("Giveaway", back_populates="prizes")
class Rule(Base):
"""A single action a Giveaway demands for an entry to count."""
__tablename__ = "rules"
id = Column(Integer, primary_key=True)
kind = Column(String, nullable=False) # follow | like | comment | tag
description = Column(String, nullable=True)
giveaway_id = Column(Integer, ForeignKey("giveaways.id"), nullable=False)
giveaway = relationship("Giveaway", back_populates="rules")
mentions = relationship(
"Mention", secondary=rule_mentions, back_populates="rules"
)
class Mention(Base):
"""An Instagram account a Rule instructs the entrant to tag."""
__tablename__ = "mentions"
id = Column(Integer, primary_key=True)
username = Column(String, nullable=False, index=True)
count = Column(Integer, default=1)
rules = relationship("Rule", secondary=rule_mentions, back_populates="mentions")
class Entry(Base):
"""The formal act of participating in a Giveaway."""
__tablename__ = "entries"
id = Column(Integer, primary_key=True)
status = Column(String, nullable=False, default="draft", index=True) # draft | confirmed | rejected
prepared_comment = Column(String, nullable=True)
fullfilled_rules = Column(Boolean, default=False)
created_at = Column(DateTime, default=_utcnow)
giveaway_id = Column(Integer, ForeignKey("giveaways.id"), nullable=False)
giveaway = relationship("Giveaway", back_populates="entries")
class DrawDate(Base):
"""The day and time the Host announces the Giveaway winner."""
__tablename__ = "draw_dates"
id = Column(Integer, primary_key=True)
date = Column(Date, nullable=True)
raw = Column(String, nullable=True)
giveaway_id = Column(Integer, ForeignKey("giveaways.id"), nullable=False)
giveaway = relationship("Giveaway", back_populates="draw_dates")
+11
View File
@@ -0,0 +1,11 @@
services:
web:
build: .
container_name: instagram-sorteo
ports:
- "8080:8080"
volumes:
- ./data:/app/data
environment:
INSTAGRAM_SORTEO_DB: /app/data/entries.db
restart: unless-stopped
@@ -0,0 +1,9 @@
# Persist entries in SQLite via SQLAlchemy
The current app is a CLI tool that analyzes Instagram posts and prints results but stores nothing. The web dashboard (which must list entered giveaways, the required mentions, and each draw date) needs queryable history. We chose SQLAlchemy ORM over SQLite as the persistence layer.
- **Status**: accepted
- **Considered Options**:
- PostgreSQL — production-grade and scalable, but adds server infrastructure that is overkill for a single-user personal tool.
- Plain JSON files — simplest to write, but not queryable and awkward to filter by giveaway, mention, or draw date.
- **Consequences**: A local `entries.db` gives us a relational model to drive the dashboard views across giveaways, rules, mentions, and draw dates without introducing external services. The SQLite file is the single source of truth for the web UI and the inscriber alike.
@@ -0,0 +1,9 @@
# Semi-automated inscription to respect Instagram ToS
The current inscriber prints a simulated comment instead of really posting. Fully automatic commenting and tagging — especially auto-tagging the accounts a host requires — violates Instagram's Terms of Service and risks account bans or blocks. We decided to automate detection and drafting, but require human review before any comment or mention is actually sent.
- **Status**: accepted
- **Considered Options**:
- Full automation — accepts the ban/block risk in exchange for zero-touch entry. Rejected: losing the account defeats the purpose of a personal inscriber.
- No automation — detect giveaways only, hand every action to the user. Rejected: loses the convenience the tool exists to provide.
- **Consequences**: The pipeline produces a ready-to-send entry (comment text plus the required mentions) into a review queue in the dashboard. The user confirms each one before the inscriber posts it. This keeps the tool usable while keeping the account safe and the behaviour within Instagram's ToS.
@@ -0,0 +1,10 @@
# Web monitoring layer on FastAPI
The vision requires a web interface to see which giveaways were entered, who each one asks to be tagged, and when each draw takes place. We decided to build a separate web layer — with FastAPI — that reads from the SQLite store and exposes this dashboard to the user, decoupled from the CLI inscriber.
- **Status**: accepted
- **Considered Options**:
- Flask — simpler and battle-tested, but synchronous and with no auto-generated API documentation.
- Django — heavier (ORM, admin, auth) than this single-user dashboard needs.
- Read the DB directly from the CLI process — mixes concerns and offers no API surface for a browser UI.
- **Consequences**: FastAPI gives an async JSON API with automatic OpenAPI docs, letting the dashboard list entries, mentions, and draw dates cleanly. The web layer is a consumer of the same SQLite store as the inscriber, so no duplication and a consistent single source of truth.
+151 -97
View File
@@ -1,115 +1,169 @@
"""Módulo para inscribirse automáticamente en sorteos."""
import time
import random
from bs4 import BeautifulSoup
"""Inscriber: analyze giveaways, persist them, and queue drafts for review.
Per ADR-0002 (semi-automated inscription), this module detects giveaways and
records an Entry as a DRAFT ready for review. It does NOT post directly to
Instagram — the user confirms each entry through the dashboard before it is
sent, keeping behaviour within Instagram's Terms of Service.
"""
from sqlalchemy.orm import Session
from db.models import DrawDate, Entry, Giveaway, Host, Mention, Prize, Rule
from db.database import SessionLocal
class GiveawayInscriber:
"""Clase para inscribir automáticamente en sorteos de Instagram."""
"""Analyze posts and persist draft entries for review."""
def __init__(self, scraper, analyzer, comment="¡Participo! #sorteo"):
def __init__(self, scraper=None, analyzer=None, comment="¡Participo! #sorteo", session_factory=None):
self.scraper = scraper
self.analyzer = analyzer
self.comment = comment
self.session_factory = session_factory or SessionLocal
self.successful_inscriptions = 0
self.failed_attempts = 0
def inscription_comment(self, post_url, target_comment=None):
"""
Publicar un comentario en una publicación de sorteo.
Args:
post_url: URL de la publicación
target_comment: Comentario personalizado (usa self.comment por defecto)
Returns:
bool: Éxito o no
"""
comment = target_comment or self.comment
try:
# Nota: Instagram no permite comentarios fáciles sin autenticación adecuada
# Esta es la estructura; la implementación completa requiere
# manejo de sesiones CSRF, tokens, etc.
print(f"📝 Intentando comentar en: {post_url}")
print(f" Comentario: {comment}")
# En un implementación completa, aquí se tendría:
# - CSRF token extraction
# - Session cookie management
# - POST request al endpoint de comentarios
# - Manejo de respuestas y rate limiting
# Simulación para demostración
time.sleep(random.uniform(1, 3))
self.successful_inscriptions += 1
print("✅ Comentario publicado (simulación)")
return True
except Exception as e:
self.failed_attempts += 1
print(f"❌ Error al comentar: {e}")
return False
def _get_or_create_host(self, db: Session, username: str) -> Host:
host = db.query(Host).filter(Host.username == username).one_or_none()
if host is None:
host = Host(username=username)
db.add(host)
return host
def persist_entry(self, db: Session, url: str, caption: str, analysis: dict) -> Entry:
"""Persist a giveaway + draft entry from an analysis result."""
reqs = analysis["requirements"]
# Derive the host username from the URL if present, else the first mention.
host_name = self._username_from_url(url) or (reqs.get("username") or "unknown")
host = self._get_or_create_host(db, host_name)
giveaway = (
db.query(Giveaway).filter(Giveaway.url == url).one_or_none()
)
if giveaway is None:
giveaway = Giveaway(url=url, caption=caption, host=host)
db.add(giveaway)
db.flush()
self._add_rules(db, giveaway, reqs)
self._add_prizes(db, giveaway, reqs)
self._add_draw_date(db, giveaway, reqs)
else:
db.flush()
# Per ADR-0002: create the Entry as a draft queued for review.
entry = Entry(
giveaway=giveaway,
status="draft",
prepared_comment=self._build_comment(reqs),
fullfilled_rules=False,
)
db.add(entry)
db.commit()
return entry
def _username_from_url(self, url: str) -> str | None:
if not url or "instagram.com" not in url:
return None
parts = url.replace("https://", "").replace("http://", "").split("/")
# https://www.instagram.com/{username}/p/{code}/
for part in parts:
if part in ("www.instagram.com", "instagram.com"):
continue
if part and part not in ("p", "reel", "tv"):
return part.rstrip(".")
return None
def _add_rules(self, db: Session, giveaway: Giveaway, reqs: dict) -> None:
mapping = {
"follow": ("follow", None),
"like": ("like", None),
"comment": ("comment", None),
"tag_friends": ("tag", None),
}
for req_key, (kind, desc) in mapping.items():
if reqs.get(req_key):
db.add(Rule(giveaway=giveaway, kind=kind, description=desc))
mentions = reqs.get("mentions") or []
if mentions:
tag_rule = db.query(Rule).filter(
Rule.giveaway_id == giveaway.id, Rule.kind == "tag"
).one_or_none()
if tag_rule is None:
tag_rule = Rule(giveaway=giveaway, kind="tag")
db.add(tag_rule)
for username in mentions:
mention = (
db.query(Mention)
.filter(Mention.username == username)
.one_or_none()
)
if mention is None:
mention = Mention(username=username)
db.add(mention)
tag_rule.mentions.append(mention)
def _add_prizes(self, db: Session, giveaway: Giveaway, reqs: dict) -> None:
if reqs.get("prize"):
db.add(Prize(giveaway=giveaway, description=reqs["prize"]))
def _add_draw_date(self, db: Session, giveaway: Giveaway, reqs: dict) -> None:
draw_date = reqs.get("draw_date")
if draw_date:
db.add(DrawDate(
giveaway=giveaway,
date=draw_date,
raw=reqs.get("draw_date_raw"),
))
def _build_comment(self, reqs: dict) -> str:
comment = self.comment
mentions = reqs.get("mentions") or []
if mentions:
tag_part = " ".join(f"@{m}" for m in mentions)
comment = f"{comment} {tag_part}"
return comment.strip()
def auto_enter(self, posts, min_keyword_match=2):
"""
Analizar publicaciones e inscribirse en las que son sorteos.
Args:
posts: Lista de publicaciones (diccionarios con 'caption', 'url', etc.)
min_keyword_match: Mínimo de coincidencias de palabras clave
Returns:
list: Publicaciones en las que se inscribió
"""
inscribed = []
for post in posts:
caption = post.get("caption", "")
url = post.get("url", "")
if not caption or not url:
continue
# Analizar con la IA local
analysis = self.analyzer.analyze(caption)
if analysis["is_giveaway"]:
print(f"\n🎯 Sorteo detectado en: {url}")
print(f" Requerimientos: {analysis['requirements']}")
# Inscribirse (comentar)
if self.inscription_comment(url):
inscribed.append({
"url": url,
"caption": caption,
"requirements": analysis["requirements"]
})
else:
print(f"📄 Publicación normal (no sorteo): {url[:50] if url else 'N/A'}...")
return inscribed
"""Analyze posts, persist draft entries for any giveaways found."""
db = self.session_factory()
try:
inscribed = []
for post in posts:
caption = post.get("caption", "")
url = post.get("url", "")
if not caption or not url:
continue
analysis = self.analyzer.analyze(caption)
if analysis["is_giveaway"]:
try:
entry = self.persist_entry(db, url, caption, analysis)
self.successful_inscriptions += 1
inscribed.append({
"entry_id": entry.id,
"url": url,
"caption": caption,
"requirements": analysis["requirements"],
})
except Exception as e: # pragma: no cover - defensive
self.failed_attempts += 1
db.rollback()
print(f"❌ Error persistiendo {url}: {e}")
return inscribed
finally:
db.close()
def run_on_hashtag(self, hashtag, post_count=20):
"""
Ejecutar en un hashtag específico.
Args:
hashtag: Hashtag a buscar (ej: "sorteo")
post_count: Número de publicaciones a revisar
"""
"""Run the inscription flow over a hashtag search."""
if self.scraper is None:
raise RuntimeError("No scraper configured; run_on_hashtag needs one.")
print(f"🔍 Buscando sorteos con hashtag: #{hashtag}")
posts = self.scraper.search_hashtag(hashtag, post_count)
if posts:
print(f"📊 Encontradas {len(posts)} publicaciones")
inscribed = self.auto_enter(posts)
print(f"\n✅ Inscripciones completadas: {len(inscribed)}")
return inscribed
else:
if not posts:
print("❌ No se encontraron publicaciones")
return []
if __name__ == "__main__":
print("🐍 Módulo de inscripción directo")
print("Usa: from inscriber import GiveawayInscriber")
inscribed = self.auto_enter(posts)
print(f"\n✅ Inscripciones en cola (drafts): {len(inscribed)}")
return inscribed
+64 -50
View File
@@ -1,88 +1,102 @@
"""Aplicación principal - Instagram Giveaway Sorteo Analyzer & Inscriber."""
import sys
"""Aplicación principal - Instagram Giveaway Sorteo Analyzer & Inscriber.
Genera drafts (ENTRY) en la base de datos para revisar después desde el
dashboard web (ADR-0002: revisión humana antes de enviar).
"""
import os
from dotenv import load_dotenv
# Cargar variables de entorno
load_dotenv()
from scraping.instagram import InstagramScraper
from ai.analyzer import GiveawayAnalyzer
from inscriber import GiveawayInscriber
def main():
def banner():
print("=" * 60)
print("🎯 Instagram Sorteo Analyzer & Auto-Inscriber")
print("🎯 Instagram Sorteo Analyzer & Inscriber")
print(" (los drafts se revisan en el dashboard web)")
print("=" * 60)
# Verificar configuración
username = os.getenv("INSTAGRAM_USERNAME")
password = os.getenv("INSTAGRAM_PASSWORD")
if not username or not password:
print("⚠️ Configuración faltante.")
print(" Copia .env y establece INSTAGRAM_USERNAME y INSTAGRAM_PASSWORD")
print(" Ejemplo: echo 'INSTAGRAM_USERNAME=tu_usuario' > .env")
print(" echo 'INSTAGRAM_PASSWORD=tu_contraseña' >> .env")
return
# Inicializar componentes
print("\n🔧 Inicializando componentes...")
scraper = InstagramScraper(username=username, password=password)
def make_inscriber():
analyzer = GiveawayAnalyzer(mode="rule_based")
inscriber = GiveawayInscriber(scraper=scraper, analyzer=analyzer)
print("✅ Componentes listos")
# Modo de operación
return GiveawayInscriber(analyzer=analyzer)
def has_credentials():
return bool(os.getenv("INSTAGRAM_USERNAME") and os.getenv("INSTAGRAM_PASSWORD"))
def main():
banner()
if not has_credentials():
print("ℹ️ Sin credenciales de Instagram: solo disponible la opción 3 (ingestión manual).")
print("\n📋 Modos disponibles:")
print(" 1. Buscar por hashtag (#sorteo, #giveaway)")
print(" 2. Revisar feed personalizado")
print(" 3. Analizar publicaciones existentes")
print(" 1. Buscar por hashtag (#sorteo, #giveaway) [requiere credenciales]")
print(" 2. Revisar feed personalizado [requiere credenciales]")
print(" 3. Ingestión manual de captions/URLs [sin credenciales]")
choice = input("\nElige una opción (1-3): ").strip()
inscriber = make_inscriber()
if choice in ("1", "2") and not has_credentials():
print("❌ Falta INSTAGRAM_USERNAME / INSTAGRAM_PASSWORD en el .env")
return
if choice == "1":
hashtag = input("Hashtag a buscar (ej: sorteo): ").strip() or "sorteo"
from scraping.instagram import InstagramScraper
scraper = InstagramScraper(
username=os.getenv("INSTAGRAM_USERNAME"),
password=os.getenv("INSTAGRAM_PASSWORD"),
)
inscriber.scraper = scraper
hashtag = input("Hashtag a buscar (default: sorteo): ").strip() or "sorteo"
count = int(input("Número de publicaciones a revisar (default 20): ").strip() or "20")
print(f"\n🚀 Iniciando búsqueda con hashtag: #{hashtag}")
print(f"\n🚀 Buscando: #{hashtag}")
inscriber.run_on_hashtag(hashtag, count)
scraper.close()
elif choice == "2":
from scraping.instagram import InstagramScraper
scraper = InstagramScraper(
username=os.getenv("INSTAGRAM_USERNAME"),
password=os.getenv("INSTAGRAM_PASSWORD"),
)
inscriber.scraper = scraper
print("\n📱 Obteniendo feed...")
feed = scraper.get_feed(count=15)
if feed:
print(f"📊 {len(feed)} publicaciones obtenidas del feed")
print(f"📊 {len(feed)} publicaciones obtenidas")
inscriber.auto_enter(feed)
else:
print("❌ No se pudieron obtener publicaciones del feed")
scraper.close()
elif choice == "3":
# Permitir analizar publicaciones manualmente proporcionadas
print("\n📝 Ingresa URLs o captions para analizar (enter vacío para terminar):")
print("\n📝 Ingresa captions o URLs a analizar (enter vacío para terminar):")
posts = []
while True:
entry = input("> ").strip()
if not entry:
break
posts.append({"caption": entry, "url": entry})
if posts:
print("\n🔍 Analizando publicaciones...")
print("\n🔍 Analizando...")
inscribed = inscriber.auto_enter(posts)
print(f"\n✅ Total inscripciones: {len(inscribed)}")
print(f"\n✅ Drafts creados para revisión: {len(inscribed)}")
else:
print("❌ No ingresaste publicaciones")
else:
print("❌ Opción no válida")
# Cerrar scraper
scraper.close()
print("\n" + "=" * 60)
print("¡Gracias por usar Instagram Sorteo Analyzer!")
print("Revisa los drafts en el dashboard web (uvicorn web.app:app)")
print("=" * 60)
if __name__ == "__main__":
main()
main()
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+57
View File
@@ -0,0 +1,57 @@
from logging.config import fileConfig
from sqlalchemy import create_engine, engine_from_config, pool
from alembic import context
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from db.models import Base
from db.database import _db_url, DEFAULT_DB_PATH
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = _db_url(DEFAULT_DB_PATH)
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = _db_url(DEFAULT_DB_PATH)
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,106 @@
"""initial schema
Revision ID: 425a49281f60
Revises:
Create Date: 2026-08-30 23:31:15.952471
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '425a49281f60'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('hosts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_hosts_username'), 'hosts', ['username'], unique=True)
op.create_table('mentions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('count', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mentions_username'), 'mentions', ['username'], unique=False)
op.create_table('giveaways',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('url', sa.String(), nullable=False),
sa.Column('caption', sa.String(), nullable=True),
sa.Column('host_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['host_id'], ['hosts.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_giveaways_url'), 'giveaways', ['url'], unique=True)
op.create_table('draw_dates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=True),
sa.Column('raw', sa.String(), nullable=True),
sa.Column('giveaway_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['giveaway_id'], ['giveaways.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('entries',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('prepared_comment', sa.String(), nullable=True),
sa.Column('fullfilled_rules', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('giveaway_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['giveaway_id'], ['giveaways.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_entries_status'), 'entries', ['status'], unique=False)
op.create_table('prizes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('giveaway_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['giveaway_id'], ['giveaways.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('rules',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('giveaway_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['giveaway_id'], ['giveaways.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('rule_mentions',
sa.Column('rule_id', sa.Integer(), nullable=False),
sa.Column('mention_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['mention_id'], ['mentions.id'], ),
sa.ForeignKeyConstraint(['rule_id'], ['rules.id'], ),
sa.PrimaryKeyConstraint('rule_id', 'mention_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('rule_mentions')
op.drop_table('rules')
op.drop_table('prizes')
op.drop_index(op.f('ix_entries_status'), table_name='entries')
op.drop_table('entries')
op.drop_table('draw_dates')
op.drop_index(op.f('ix_giveaways_url'), table_name='giveaways')
op.drop_table('giveaways')
op.drop_index(op.f('ix_mentions_username'), table_name='mentions')
op.drop_table('mentions')
op.drop_index(op.f('ix_hosts_username'), table_name='hosts')
op.drop_table('hosts')
# ### end Alembic commands ###
+89
View File
@@ -0,0 +1,89 @@
"""Draw Date reminders for instagram-sorteo.
Finds giveaways whose Draw Date falls within a window and emits a reminder
through a transport. The default transport prints to stdout; set
IG_REMINDER_EMAIL_* to send an email instead (or extend with another).
"""
import os
import smtplib
from datetime import date, timedelta
from email.message import EmailMessage
from sqlalchemy.orm import joinedload
from db.database import SessionLocal
from db.models import DrawDate, Giveaway
def upcoming_draw_dates(
session,
horizon_days: int | None = None,
as_of: date | None = None,
):
"""Return DrawDate rows whose date is >= today and <= today+horizon.
If horizon_days is None, return all future draw dates.
"""
as_of = as_of or date.today()
query = (
session.query(DrawDate)
.options(joinedload(DrawDate.giveaway).joinedload(Giveaway.host))
.filter(DrawDate.date >= as_of)
)
if horizon_days is not None:
query = query.filter(DrawDate.date <= as_of + timedelta(days=horizon_days))
return query.order_by(DrawDate.date.asc()).all()
def _fmt_draws(draws):
lines = []
for d in draws:
host = d.giveaway.host.username if d.giveaway.host else "?"
lines.append(f"- Sorteo de @{host} el {d.date} — {d.giveaway.url}")
return "\n".join(lines)
def _send_email(subject: str, body: str) -> None:
to = os.getenv("IG_REMINDER_EMAIL_TO")
username = os.getenv("IG_REMINDER_EMAIL_USERNAME")
password = os.getenv("IG_REMINDER_EMAIL_PASSWORD")
smtp_host = os.getenv("IG_REMINDER_EMAIL_SMTP", "smtp.gmail.com")
smtp_port = int(os.getenv("IG_REMINDER_EMAIL_SMTP_PORT", "587"))
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = username
msg["To"] = to
msg.set_content(body)
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(username, password)
server.send_message(msg)
def notify_upcoming_draws(horizon_days: int = 7) -> list:
"""Find draws within horizon_days and send a reminder for each batch."""
db = SessionLocal()
try:
draws = upcoming_draw_dates(db, horizon_days=horizon_days)
if not draws:
return []
subject = f"[instagram-sorteo] {len(draws)} sorteo(s) próximos"
body = f"Se celebran en los próximos {horizon_days} días:\n\n{_fmt_draws(draws)}"
if os.getenv("IG_REMINDER_EMAIL_TO"):
_send_email(subject, body)
else:
print(f"{subject}\n\n{body}")
return [serialize_draw(d) for d in draws]
finally:
db.close()
def serialize_draw(d: DrawDate) -> dict:
return {
"given_to": d.giveaway.host.username if d.giveaway.host else None,
"url": d.giveaway.url,
"date": d.date.isoformat() if d.date else None,
"raw": d.raw,
}
+23
View File
@@ -0,0 +1,23 @@
"""CLI entry point to check Draw Date reminders.
Usage:
python remind.py [horizon_days] # default 7
Sends an email if IG_REMINDER_EMAIL_TO is set, otherwise prints. Intended to be
run periodically (cron / CI / a scheduler).
"""
import sys
from notifications import notify_upcoming_draws
def main() -> None:
horizon = 7
if len(sys.argv) > 1:
horizon = int(sys.argv[1])
draws = notify_upcoming_draws(horizon_days=horizon)
print(f"Draws within {horizon} days: {len(draws)}")
if __name__ == "__main__":
main()
+7 -1
View File
@@ -1,10 +1,16 @@
beautifulsoup4==4.15.0
alembic==1.14.0
certifi==2026.7.22
charset-normalizer==3.5.1
fastapi==0.115.6
httpx==0.28.1
idna==3.19
lxml==6.1.2
python-dotenv==1.2.3
pytest==8.3.4
requests==2.34.2
soupsieve==2.9.2
sqlalchemy==2.0.36
typing-extensions==4.16.0
urllib3==2.7.0
urllib3==2.7.0
uvicorn==0.34.0
+56
View File
@@ -0,0 +1,56 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Keep the app's startup init_db() out of the repo data dir during tests.
os.environ.setdefault("INSTAGRAM_SORTEO_DB", os.path.join(
os.path.dirname(os.path.abspath(__file__)), "_test_entries.db"
))
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from fastapi.testclient import TestClient
from db.models import Base
from db.database import get_db
from web.app import app
@pytest.fixture()
def session_factory():
# StaticPool keeps a single shared connection so every session sees the
# same in-memory database (sqlite:///:memory: is otherwise per-connection).
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
yield factory
engine.dispose()
@pytest.fixture()
def db(session_factory):
session = session_factory()
yield session
session.close()
@pytest.fixture()
def client(session_factory):
def _get_db():
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
+73
View File
@@ -0,0 +1,73 @@
import pytest
from ai.analyzer import GiveawayAnalyzer
@pytest.fixture()
def analyzer():
return GiveawayAnalyzer(mode="rule_based")
def test_detects_giveaway_by_keywords(analyzer):
assert analyzer.is_giveaway("Sorteo! Gana un premio. Comparte y etiqueta amigos")
def test_does_not_detect_normal_post(analyzer):
assert not analyzer.is_giveaway("Qué bonito día en la playa con mis amigos")
def test_requires_over_two_keywords(analyzer):
# Only one keyword -> not a giveaway by the count heuristic.
assert not analyzer.is_giveaway("Comparte esta foto")
def test_extracts_mentions_list(analyzer):
reqs = analyzer.extract_requirements(
"Etiqueta a @amiga1 y @amiga2 para participar"
)
assert reqs["mentions"] == ["amiga1", "amiga2"]
def test_mentions_deduplicated(analyzer):
reqs = analyzer.extract_requirements("Menciona a @amiga @amiga @otra")
assert reqs["mentions"] == ["amiga", "otra"]
def test_extract_rule_flags(analyzer):
reqs = analyzer.extract_requirements("Síguenos, dale like y comenta #sorteo")
assert reqs["follow"] is True
assert reqs["like"] is True
assert reqs["comment"] is True
assert reqs["hashtag"] == "sorteo"
def test_extract_draw_date_numeric(analyzer):
draw, raws = analyzer.extract_draw_date("Sorteo termina el 15/08/2026")
assert draw is not None
assert draw.year == 2026 and draw.month == 8 and draw.day == 15
assert "15/08/2026" in raws
def test_extract_draw_date_spoken_es(analyzer):
draw, raws = analyzer.extract_draw_date("Anunciamos ganador el 15 de agosto")
assert draw is not None
assert draw.month == 8 and draw.day == 15
def test_no_draw_date(analyzer):
draw, raws = analyzer.extract_draw_date("Sigue y comenta para participar")
assert draw is None
def test_extract_prize(analyzer):
reqs = analyzer.extract_requirements("Premio: iPhone 15. Síguenos para participar")
assert reqs["prize"] == "iPhone 15"
def test_analyze_full(analyzer):
result = analyzer.analyze(
"Sorteo! Gana un premio. Etiqueta a @amiga y comenta. Sorteo 01/09/2026"
)
assert result["is_giveaway"] is True
assert result["requirements"]["mentions"] == ["amiga"]
assert result["requirements"]["comment"] is True
+71
View File
@@ -0,0 +1,71 @@
from datetime import date
from ai.analyzer import GiveawayAnalyzer
from db.models import Entry, Giveaway
from inscriber import GiveawayInscriber
def _make_inscriber(session_factory):
analyzer = GiveawayAnalyzer(mode="rule_based")
return GiveawayInscriber(analyzer=analyzer, session_factory=session_factory)
def test_auto_enter_persists_draft(session_factory):
inscriber = _make_inscriber(session_factory)
posts = [
{
"url": "https://www.instagram.com/host_user/p/abc/",
"caption": "Sorteo! Gana un premio. Etiqueta a @amiga y @amigo. Comenta. Sorteo 01/09/2026",
}
]
result = inscriber.auto_enter(posts)
assert len(result) == 1
db = session_factory()
entry = db.query(Entry).one()
assert entry.status == "draft"
assert entry.giveaway.host.username == "host_user"
assert entry.giveaway.url == posts[0]["url"]
assert set(r.kind for r in entry.giveaway.rules) == {"tag", "comment"}
assert entry.giveaway.draw_dates[0].date == date(2026, 9, 1)
assert entry.prepared_comment == "¡Participo! #sorteo @amiga @amigo"
db.close()
def test_auto_enter_skips_non_giveaway(session_factory):
inscriber = _make_inscriber(session_factory)
result = inscriber.auto_enter(
[{"url": "https://www.instagram.com/x/p/1/", "caption": "foto normal de la playa"}]
)
assert result == []
assert inscriber.successful_inscriptions == 0
def test_does_not_post_directly(session_factory):
# Per ADR-0002, entries are queued as drafts, never auto-posted.
inscriber = _make_inscriber(session_factory)
inscriber.auto_enter(
[{"url": "https://www.instagram.com/host_user/p/abc/", "caption": "Sorteo! Gana premio. Comenta"}]
)
db = session_factory()
entry = db.query(Entry).one()
assert entry.status == "draft"
assert entry.fullfilled_rules is False
db.close()
def test_same_giveaway_single_record(session_factory):
inscriber = _make_inscriber(session_factory)
post = {
"url": "https://www.instagram.com/host_user/p/abc/",
"caption": "Sorteo! Gana premio. Comenta",
}
inscriber.auto_enter([post])
inscriber.auto_enter([post])
db = session_factory()
giveaways = db.query(Giveaway).all()
assert len(giveaways) == 1
assert len(db.query(Entry).all()) == 2
db.close()
+68
View File
@@ -0,0 +1,68 @@
from datetime import date
from db.models import DrawDate, Entry, Giveaway, Host, Mention, Prize, Rule
def test_build_full_graph(db):
host = Host(username="host_user")
db.add(host)
db.flush()
giveaway = Giveaway(url="https://www.instagram.com/host_user/p/abc/", host=host)
db.add(giveaway)
db.flush()
prize = Prize(giveaway=giveaway, description="iPhone 15")
db.add(prize)
rule = Rule(giveaway=giveaway, kind="tag")
db.add(rule)
mention = Mention(username="amiga")
db.add(mention)
rule.mentions.append(mention)
entry = Entry(giveaway=giveaway, status="draft", prepared_comment="¡Participo! @amiga")
draw = DrawDate(giveaway=giveaway, date=date(2026, 9, 15), raw="15/09/2026")
db.add(entry)
db.add(draw)
db.commit()
g = db.query(Giveaway).one()
assert g.host.username == "host_user"
assert [p.description for p in g.prizes] == ["iPhone 15"]
assert g.rules[0].kind == "tag"
assert [m.username for m in g.rules[0].mentions] == ["amiga"]
assert len(g.entries) == 1
assert g.entries[0].status == "draft"
assert g.draw_dates[0].date == date(2026, 9, 15)
def test_entry_status_transition(db):
host = Host(username="host_user")
giveaway = Giveaway(url="u", host=host)
entry = Entry(giveaway=giveaway, status="draft")
db.add_all([host, giveaway, entry])
db.commit()
entry.status = "confirmed"
db.commit()
assert db.query(Entry).one().status == "confirmed"
def test_host_unique(db):
db.add_all([Host(username="dupe"), Host(username="dupe")])
import pytest
with pytest.raises(Exception):
db.commit()
db.rollback()
def test_draw_date_relationship(db):
host = Host(username="h")
giveaway = Giveaway(url="u", host=host)
db.add_all([host, giveaway])
db.add(DrawDate(giveaway=giveaway, raw="1 de octubre"))
db.commit()
assert giveaway.draw_dates[0].raw == "1 de octubre"
+83
View File
@@ -0,0 +1,83 @@
from datetime import date, timedelta
import pytest
from ai.analyzer import GiveawayAnalyzer
from db.models import DrawDate, Entry, Giveaway, Host
from inscriber import GiveawayInscriber
def _seed_giveaway(session_factory, host="host_user", url="https://www.instagram.com/host_user/p/abc/"):
db = session_factory()
h = Host(username=host)
g = Giveaway(url=url, host=h)
db.add_all([h, g])
db.commit()
db.close()
return g.id
@pytest.mark.parametrize(
"draw_on,horizon,today,expected",
[
# within horizon -> included
(date(2026, 9, 5), 7, date(2026, 9, 1), 1),
# past -> excluded
(date(2026, 8, 25), 7, date(2026, 9, 1), 0),
# beyond horizon -> excluded
(date(2026, 9, 30), 7, date(2026, 9, 1), 0),
# today -> included
(date(2026, 9, 1), 7, date(2026, 9, 1), 1),
],
)
def test_upcoming_draw_dates(session_factory, draw_on, horizon, today, expected):
from notifications import upcoming_draw_dates
gid = _seed_giveaway(session_factory)
db = session_factory()
db.add(DrawDate(giveaway_id=gid, date=draw_on, raw="x"))
db.commit()
result = upcoming_draw_dates(db, horizon_days=horizon, as_of=today)
assert len(result) == expected
db.close()
def test_upcoming_none_horizon_returns_all_future(session_factory):
from notifications import upcoming_draw_dates
gid = _seed_giveaway(session_factory)
db = session_factory()
db.add_all([
DrawDate(giveaway_id=gid, date=date(2026, 9, 5)),
DrawDate(giveaway_id=gid, date=date(2026, 12, 1)),
])
db.commit()
result = upcoming_draw_dates(db, horizon_days=None, as_of=date(2026, 9, 1))
assert len(result) == 2
db.close()
def test_notify_upcoming_draws_prints(capsys, session_factory, monkeypatch):
import notifications
gid = _seed_giveaway(session_factory, host="host_user")
db = session_factory()
db.add(DrawDate(giveaway_id=gid, date=date.today() + timedelta(days=2)))
db.commit()
db.close()
monkeypatch.setattr(notifications, "SessionLocal", session_factory)
result = notifications.notify_upcoming_draws(horizon_days=7)
assert len(result) == 1
assert result[0]["given_to"] == "host_user"
assert "próximos" in capsys.readouterr().out
def test_notify_upcoming_draws_empty(session_factory, monkeypatch):
import notifications
_seed_giveaway(session_factory)
monkeypatch.setattr(notifications, "SessionLocal", session_factory)
assert notifications.notify_upcoming_draws(horizon_days=7) == []
+192
View File
@@ -0,0 +1,192 @@
from ai.analyzer import GiveawayAnalyzer
from inscriber import GiveawayInscriber
def _seed(client, session_factory):
inscriber = GiveawayInscriber(
analyzer=GiveawayAnalyzer(),
session_factory=session_factory,
)
inscriber.auto_enter(
[
{
"url": "https://www.instagram.com/host_user/p/abc/",
"caption": "Sorteo! Gana iPhone 15. Etiqueta a @amiga y @amigo. "
"Comenta. Sorteo 15/08/2026",
}
]
)
def test_health(client):
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_entries_lists_draft(client, session_factory):
_seed(client, session_factory)
r = client.get("/entries")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
entry = data[0]
assert entry["status"] == "draft"
assert entry["giveaway"]["host"] == "host_user"
assert entry["giveaway"]["prizes"][0]["description"] == "iPhone 15"
mentions = [
m["username"]
for rule in entry["giveaway"]["rules"]
for m in rule["mentions"]
]
assert set(mentions) == {"amiga", "amigo"}
def test_mentions_endpoint(client, session_factory):
_seed(client, session_factory)
r = client.get("/mentions")
assert r.status_code == 200
usernames = {m["username"] for m in r.json()}
assert usernames == {"amiga", "amigo"}
def test_draw_dates_endpoint(client, session_factory):
_seed(client, session_factory)
r = client.get("/draw-dates")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["given_to"] == "host_user"
assert data[0]["date"] == "2026-08-15"
def test_confirm_entry(client, session_factory):
_seed(client, session_factory)
db = session_factory()
from db.models import Entry
entry_id = db.query(Entry).one().id
db.close()
r = client.post(f"/entries/{entry_id}/confirm")
assert r.status_code == 200
assert r.json()["status"] == "confirmed"
db = session_factory()
assert db.query(Entry).one().status == "confirmed"
assert db.query(Entry).one().fullfilled_rules is True
db.close()
def test_reject_entry(client, session_factory):
_seed(client, session_factory)
db = session_factory()
from db.models import Entry
entry_id = db.query(Entry).one().id
db.close()
r = client.post(f"/entries/{entry_id}/reject")
assert r.status_code == 200
assert r.json()["status"] == "rejected"
def test_confirm_missing_entry_404(client):
r = client.post("/entries/9999/confirm")
assert r.status_code == 404
def test_ingest_creates_draft(client, session_factory):
r = client.post(
"/ingest",
json={
"url": "https://www.instagram.com/vendedor/p/xyz/",
"caption": "SORTEO! Ganate un viaje. Etiqueta a @amiga y comenta. "
"Sorteo 01/10/2026",
},
)
assert r.status_code == 200
body = r.json()
assert body["ingested"] is True
assert body["status"] == "draft"
db = session_factory()
from db.models import Entry
entry = db.query(Entry).one()
assert entry.giveaway.host.username == "vendedor"
assert entry.status == "draft"
db.close()
def test_ingest_non_giveaway(client, session_factory):
r = client.post(
"/ingest",
json={"url": "https://www.instagram.com/x/p/1/", "caption": "foto de un atardecer"},
)
assert r.status_code == 200
assert r.json()["ingested"] is False
assert r.json()["reason"] == "not_a_giveaway"
db = session_factory()
from db.models import Entry
assert db.query(Entry).count() == 0
db.close()
def test_dashboard_index_serves_html(client):
r = client.get("/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "instagram-sorteo" in r.text
assert "Menciones" in r.text
def test_draw_dates_upcoming(client, session_factory):
from datetime import date, timedelta
from db.models import DrawDate, Giveaway, Host
db = session_factory()
h = Host(username="host")
g = Giveaway(url="https://www.instagram.com/host/p/1/", host=h)
db.add_all([h, g])
db.add(DrawDate(giveaway=g, date=date.today() + timedelta(days=3)))
db.commit()
db.close()
r = client.get("/draw-dates/upcoming?horizon_days=7")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["given_to"] == "host"
def test_draw_dates_upcoming_none(client, session_factory):
r = client.get("/draw-dates/upcoming?horizon_days=7")
assert r.status_code == 200
assert r.json() == []
def test_auth_enabled_rejects_without_token(client, monkeypatch):
import web.auth as auth_module
monkeypatch.setattr(auth_module, "_token", "sekret")
# /health stays open
assert client.get("/health").status_code == 200
# protected endpoints require the token
assert client.get("/entries").status_code == 401
assert client.get("/entries", headers={"Authorization": "Bearer wrong"}).status_code == 401
def test_auth_enabled_allows_with_token(client, monkeypatch):
import web.auth as auth_module
monkeypatch.setattr(auth_module, "_token", "sekret")
r = client.get("/entries", headers={"Authorization": "Bearer sekret"})
assert r.status_code == 200
def test_auth_disabled_allows_by_default(client):
# conftest does not set a token, so auth is open
assert client.get("/entries").status_code == 200
+1
View File
@@ -0,0 +1 @@
"""Web layer package for instagram-sorteo."""
+189
View File
@@ -0,0 +1,189 @@
"""FastAPI dashboard for instagram-sorteo (ADR-0003).
Exposes the review queue and lets the user see which giveaways were entered,
who each one asks to be tagged, and when each draw takes place.
"""
from contextlib import asynccontextmanager
import os
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session, joinedload
from ai.analyzer import GiveawayAnalyzer
from db.database import get_db, init_db
from db.models import DrawDate, Entry, Giveaway, Mention, Rule
from inscriber import GiveawayInscriber
from notifications import serialize_draw, upcoming_draw_dates
from web.auth import require_auth
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title="instagram-sorteo", version="0.1.0", lifespan=lifespan)
_TEMPLATE = os.path.join(os.path.dirname(__file__), "templates", "index.html")
@app.get("/", include_in_schema=False)
def index():
return FileResponse(_TEMPLATE)
def _serialize_mention(m: Mention) -> dict:
return {"username": m.username, "count": m.count}
def _serialize_rule(r: Rule) -> dict:
return {
"kind": r.kind,
"description": r.description,
"mentions": [_serialize_mention(m) for m in r.mentions],
}
def _serialize_entry(entry: Entry) -> dict:
giveaway = entry.giveaway
return {
"id": entry.id,
"status": entry.status,
"prepared_comment": entry.prepared_comment,
"created_at": entry.created_at.isoformat() if entry.created_at else None,
"giveaway": {
"id": giveaway.id,
"url": giveaway.url,
"host": giveaway.host.username if giveaway.host else None,
"prizes": [
{"description": p.description} for p in giveaway.prizes
],
"rules": [_serialize_rule(r) for r in giveaway.rules],
"draw_date": [
{"date": d.date.isoformat() if d.date else None, "raw": d.raw}
for d in giveaway.draw_dates
],
},
}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/entries", dependencies=[Depends(require_auth)])
def list_entries(db: Session = Depends(get_db)):
entries = (
db.query(Entry)
.options(
joinedload(Entry.giveaway)
.joinedload(Giveaway.host),
joinedload(Entry.giveaway).joinedload(Giveaway.prizes),
joinedload(Entry.giveaway)
.joinedload(Giveaway.rules)
.joinedload(Rule.mentions),
joinedload(Entry.giveaway).joinedload(Giveaway.draw_dates),
)
.order_by(Entry.created_at.desc())
.all()
)
return [_serialize_entry(e) for e in entries]
@app.get("/mentions", dependencies=[Depends(require_auth)])
def list_mentions(db: Session = Depends(get_db)):
rules = (
db.query(Rule)
.options(joinedload(Rule.mentions), joinedload(Rule.giveaway))
.all()
)
result = []
for rule in rules:
for mention in rule.mentions:
result.append(
{
"username": mention.username,
"num_required": mention.count,
"giveaway_url": rule.giveaway.url,
"host": (
rule.giveaway.host.username
if rule.giveaway.host
else None
),
}
)
return result
@app.get("/draw-dates", dependencies=[Depends(require_auth)])
def list_draw_dates(db: Session = Depends(get_db)):
draw_dates = (
db.query(DrawDate)
.options(joinedload(DrawDate.giveaway).joinedload(Giveaway.host))
.all()
)
return [serialize_draw(d) for d in draw_dates]
@app.get("/draw-dates/upcoming", dependencies=[Depends(require_auth)])
def upcoming(horizon_days: int = 7, db: Session = Depends(get_db)):
"""Draw dates within the next horizon_days (used for dashboard + reminders)."""
return [
serialize_draw(d)
for d in upcoming_draw_dates(db, horizon_days=horizon_days)
]
@app.post("/entries/{entry_id}/confirm", dependencies=[Depends(require_auth)])
def confirm_entry(entry_id: int, db: Session = Depends(get_db)):
entry = (
db.query(Entry)
.options(joinedload(Entry.giveaway))
.filter(Entry.id == entry_id)
.one_or_none()
)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
entry.status = "confirmed"
entry.fullfilled_rules = True
db.commit()
return {"id": entry.id, "status": entry.status}
@app.post("/entries/{entry_id}/reject", dependencies=[Depends(require_auth)])
def reject_entry(entry_id: int, db: Session = Depends(get_db)):
entry = db.query(Entry).filter(Entry.id == entry_id).one_or_none()
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
entry.status = "rejected"
db.commit()
return {"id": entry.id, "status": entry.status}
class IngestRequest(BaseModel):
url: str
caption: str = ""
@app.post("/ingest", dependencies=[Depends(require_auth)])
def ingest(request: IngestRequest, db: Session = Depends(get_db)):
"""Analyze a post and record a draft entry for review (like the CLI mode 3)."""
analyzer = GiveawayAnalyzer(mode="rule_based")
inscriber = GiveawayInscriber(analyzer=analyzer)
analysis = analyzer.analyze(request.caption)
if not analysis["is_giveaway"]:
return {"ingested": False, "reason": "not_a_giveaway"}
entry = inscriber.persist_entry(db, request.url, request.caption, analysis)
return {
"ingested": True,
"entry_id": entry.id,
"status": entry.status,
"prepared_comment": entry.prepared_comment,
"requirements": analysis["requirements"],
}
+25
View File
@@ -0,0 +1,25 @@
"""Opt-in bearer-token auth for the web dashboard.
If INSTAGRAM_SORTEO_TOKEN is set, protected endpoints require
`Authorization: Bearer <token>`. When unset, access is open (for local use).
"""
import os
import secrets
from fastapi import Header, HTTPException
_token = os.getenv("INSTAGRAM_SORTEO_TOKEN", "")
def _tokens_equal(a: str, b: str) -> bool:
return secrets.compare_digest(a.encode(), b.encode())
def require_auth(authorization: str | None = Header(default=None)):
if not _token:
return
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token")
provided = authorization.split(" ", 1)[1].strip()
if not _tokens_equal(provided, _token):
raise HTTPException(status_code=401, detail="Invalid token")
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>instagram-sorteo · Dashboard</title>
<style>
:root {
--bg: #1a1b26; --panel: #24283b; --border: #3b4261;
--text: #c0caf5; --muted: #8589aa; --accent: #7aa2f7;
--ok: #9ece6a; --warn: #e0af68; --bad: #f7768e;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, sans-serif;
background: var(--bg); color: var(--text);
}
header {
padding: 20px 28px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 16px;
}
header h1 { font-size: 20px; margin: 0; }
header .badge { color: var(--muted); font-size: 13px; }
.tabs { display: flex; gap: 6px; padding: 14px 28px 0; }
.tab {
padding: 8px 16px; border: 1px solid var(--border); border-bottom: none;
background: var(--panel); color: var(--muted); cursor: pointer;
border-radius: 8px 8px 0 0; font-size: 14px;
}
.tab.active { color: var(--text); border-color: var(--accent); }
main { padding: 20px 28px 40px; }
.panel { display: none; }
.panel.active { display: block; }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 16px 18px; margin-bottom: 14px;
}
.card h3 { margin: 0 0 6px; font-size: 16px; }
.muted { color: var(--muted); font-size: 13px; }
code { background: #16161e; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.status { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
.status.draft { background: #2f3b54; color: var(--accent); }
.status.confirmed { background: #25402c; color: var(--ok); }
.status.rejected { background: #462330; color: var(--bad); }
.tag { color: var(--warn); }
.btn {
border: 1px solid var(--border); background: transparent; color: var(--text);
padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.btn.ok { border-color: var(--ok); color: var(--ok); }
.btn.bad { border-color: var(--bad); color: var(--bad); }
.empty { color: var(--muted); padding: 24px 0; }
</style>
</head>
<body>
<header>
<h1>instagram-sorteo</h1>
<span class="badge" id="count"></span>
<span style="flex:1"></span>
<input id="token" type="password" placeholder="Token (opcional)"
style="background:#16161e;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 10px;width:170px;font-size:13px" />
<button class="btn" onclick="saveToken()">Guardar</button>
</header>
<div class="tabs">
<button class="tab active" data-tab="entries">Entradas</button>
<button class="tab" data-tab="mentions">Menciones</button>
<button class="tab" data-tab="draws">Fechas de sorteo</button>
</div>
<main>
<section id="panel-entries" class="panel active"></section>
<section id="panel-mentions" class="panel"></section>
<section id="panel-draws" class="panel"></section>
</main>
<script>
const state = { entries: [], mentions: [], draws: [] };
function token() {
return localStorage.getItem("igs_token") || "";
}
function saveToken() {
localStorage.setItem("igs_token", document.getElementById("token").value.trim());
document.getElementById("token").value = "";
load();
}
async function get(path) {
const headers = {};
const t = token();
if (t) headers["Authorization"] = "Bearer " + t;
const r = await fetch(path, { headers });
if (r.status === 401) throw new Error("No autorizado: revisa el token");
if (!r.ok) throw new Error(path + " -> " + r.status);
return r.json();
}
async function load() {
try {
const [entries, mentions, draws] = await Promise.all([
get("/entries"), get("/mentions"), get("/draw-dates"),
]);
state.entries = entries;
state.mentions = mentions;
state.draws = draws;
render();
} catch (e) {
document.getElementById("panel-entries").innerHTML =
`<div class="empty">Error cargando datos: ${esc(e.message)}</div>`;
}
}
function esc(s) {
const div = document.createElement("div");
div.textContent = s == null ? "" : String(s);
return div.innerHTML;
}
async function act(id, action) {
const headers = {};
const t = token();
if (t) headers["Authorization"] = "Bearer " + t;
await fetch(`/entries/${id}/${action}`, { method: "POST", headers });
await load();
}
function entryCard(e) {
const g = e.giveaway;
const mentions = (g.rules || []).flatMap(r =>
(r.mentions || []).map(m => m.username)
);
const draw = (g.draw_date || []).map(d => esc(d.date)).join(", ");
const prizes = (g.prizes || []).map(p => esc(p.description)).join(", ");
return `<div class="card">
<h3>@${esc(g.host)} <span class="status ${esc(e.status)}">${esc(e.status)}</span></h3>
<div class="muted"><a href="${esc(g.url)}" target="_blank" rel="noopener">URL del sorteo</a></div>
<p>
${prizes ? `<span>Premio: <strong>${prizes}</strong></span><br>` : ""}
${draw ? `<span>Fecha de sorteo: <strong>${draw}</strong></span><br>` : ""}
${mentions.length ? `<span>Menciones: <span class="tag">@${esc(mentions.join(" @"))}</span></span><br>` : ""}
<span>Comentario: <code>${esc(e.prepared_comment)}</code></span>
</p>
${e.status === "draft"
? `<button class="btn ok" onclick="act(${e.id},'confirm')">Confirmar</button>
<button class="btn bad" onclick="act(${e.id},'reject')">Rechazar</button>`
: ""}
</div>`;
}
function render() {
document.getElementById("count").textContent =
`${state.entries.length} entradas registradas`;
const ep = document.getElementById("panel-entries");
ep.innerHTML = state.entries.length
? state.entries.map(entryCard).join("")
: `<div class="empty">Aún no hay entradas. Añádelas por la CLI (<code>python main.py</code>) o con <code>POST /ingest</code>.</div>`;
const mp = document.getElementById("panel-mentions");
mp.innerHTML = state.mentions.length
? state.mentions.map(m => `<div class="card">
<h3>@${esc(m.username)}</h3>
<div class="muted">Sorteo de @${esc(m.host)}</div>
<div><a href="${esc(m.giveaway_url)}" target="_blank" rel="noopener">Ver post</a></div>
</div>`).join("")
: `<div class="empty">No hay menciones registradas.</div>`;
const dp = document.getElementById("panel-draws");
dp.innerHTML = state.draws.length
? state.draws.map(d => `<div class="card">
<h3>${esc(d.date || "Sin fecha")}</h3>
<div class="muted">Sorteo de @${esc(d.given_to)}</div>
<div><a href="${esc(d.url)}" target="_blank" rel="noopener">Ver post</a></div>
</div>`).join("")
: `<div class="empty">No hay fechas de sorteo registradas.</div>`;
}
document.querySelectorAll(".tab").forEach(t => {
t.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach(x => x.classList.remove("active"));
document.querySelectorAll(".panel").forEach(x => x.classList.remove("active"));
t.classList.add("active");
document.getElementById("panel-" + t.dataset.tab).classList.add("active");
});
});
load();
</script>
</body>
</html>