From 468bbd6282aed8544231c412d71753815a463583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Castrelo=20Su=C3=A1rez?= Date: Sun, 30 Aug 2026 23:37:07 +0200 Subject: [PATCH] feat: added docker-compose and update app --- .dockerignore | 17 ++ .gitignore | 8 +- CONTEXT.md | 55 ++++ Dockerfile | 6 +- ai/analyzer.py | 165 +++++++++--- alembic.ini | 149 +++++++++++ db/__init__.py | 1 + db/database.py | 60 +++++ db/models.py | 130 +++++++++ docker-compose.yml | 11 + ...ersist-entries-in-sqlite-via-sqlalchemy.md | 9 + ...mi-automated-inscription-to-respect-tos.md | 9 + docs/adr/0003-web-monitoring-layer-fastapi.md | 10 + inscriber.py | 248 +++++++++++------- main.py | 114 ++++---- migrations/README | 1 + migrations/env.py | 57 ++++ migrations/script.py.mako | 28 ++ .../versions/425a49281f60_initial_schema.py | 106 ++++++++ notifications.py | 89 +++++++ remind.py | 23 ++ requirements.txt | 8 +- tests/conftest.py | 56 ++++ tests/test_analyzer.py | 73 ++++++ tests/test_inscriber.py | 71 +++++ tests/test_models.py | 68 +++++ tests/test_notifications.py | 83 ++++++ tests/test_web.py | 192 ++++++++++++++ web/__init__.py | 1 + web/app.py | 189 +++++++++++++ web/auth.py | 25 ++ web/templates/index.html | 192 ++++++++++++++ 32 files changed, 2062 insertions(+), 192 deletions(-) create mode 100644 .dockerignore create mode 100644 CONTEXT.md create mode 100644 alembic.ini create mode 100644 db/__init__.py create mode 100644 db/database.py create mode 100644 db/models.py create mode 100644 docker-compose.yml create mode 100644 docs/adr/0001-persist-entries-in-sqlite-via-sqlalchemy.md create mode 100644 docs/adr/0002-semi-automated-inscription-to-respect-tos.md create mode 100644 docs/adr/0003-web-monitoring-layer-fastapi.md create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/425a49281f60_initial_schema.py create mode 100644 notifications.py create mode 100644 remind.py create mode 100644 tests/conftest.py create mode 100644 tests/test_analyzer.py create mode 100644 tests/test_inscriber.py create mode 100644 tests/test_models.py create mode 100644 tests/test_notifications.py create mode 100644 tests/test_web.py create mode 100644 web/__init__.py create mode 100644 web/app.py create mode 100644 web/auth.py create mode 100644 web/templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fbb95e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.venv +venv +__pycache__ +*.pyc +*.pyo +*.pyd +.env +*.env +tests +data +*.db +*.db-* +.DS_Store +.idea +.vscode diff --git a/.gitignore b/.gitignore index 4f0c844..0348318 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,10 @@ venv/ .idea/ # OS -.DS_Store \ No newline at end of file +.DS_Store + +# Database +*.db +*.db-* +data/ +tests/_test_entries.db diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..791a9e7 --- /dev/null +++ b/CONTEXT.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 5ba6ae5..6e3c75a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/ai/analyzer.py b/ai/analyzer.py index 69fbda9..b7d65dd 100644 --- a/ai/analyzer.py +++ b/ai/analyzer.py @@ -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 - } \ No newline at end of file + "text": text[:100] + "..." if len(text) > 100 else text, + } diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..08f1d9f --- /dev/null +++ b/alembic.ini @@ -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 /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 diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..99ce930 --- /dev/null +++ b/db/__init__.py @@ -0,0 +1 @@ +"""Persistence package for instagram-sorteo.""" diff --git a/db/database.py b/db/database.py new file mode 100644 index 0000000..8dac536 --- /dev/null +++ b/db/database.py @@ -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() diff --git a/db/models.py b/db/models.py new file mode 100644 index 0000000..bd05666 --- /dev/null +++ b/db/models.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e73e00 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/adr/0001-persist-entries-in-sqlite-via-sqlalchemy.md b/docs/adr/0001-persist-entries-in-sqlite-via-sqlalchemy.md new file mode 100644 index 0000000..f205dee --- /dev/null +++ b/docs/adr/0001-persist-entries-in-sqlite-via-sqlalchemy.md @@ -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. diff --git a/docs/adr/0002-semi-automated-inscription-to-respect-tos.md b/docs/adr/0002-semi-automated-inscription-to-respect-tos.md new file mode 100644 index 0000000..d1a2898 --- /dev/null +++ b/docs/adr/0002-semi-automated-inscription-to-respect-tos.md @@ -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. diff --git a/docs/adr/0003-web-monitoring-layer-fastapi.md b/docs/adr/0003-web-monitoring-layer-fastapi.md new file mode 100644 index 0000000..666d5bd --- /dev/null +++ b/docs/adr/0003-web-monitoring-layer-fastapi.md @@ -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. diff --git a/inscriber.py b/inscriber.py index 689528c..c6d1794 100644 --- a/inscriber.py +++ b/inscriber.py @@ -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") \ No newline at end of file + inscribed = self.auto_enter(posts) + print(f"\n✅ Inscripciones en cola (drafts): {len(inscribed)}") + return inscribed diff --git a/main.py b/main.py index d59f768..6f126cb 100644 --- a/main.py +++ b/main.py @@ -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() \ No newline at end of file + main() diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..eb23577 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/425a49281f60_initial_schema.py b/migrations/versions/425a49281f60_initial_schema.py new file mode 100644 index 0000000..0a29506 --- /dev/null +++ b/migrations/versions/425a49281f60_initial_schema.py @@ -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 ### diff --git a/notifications.py b/notifications.py new file mode 100644 index 0000000..ec33224 --- /dev/null +++ b/notifications.py @@ -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, + } diff --git a/remind.py b/remind.py new file mode 100644 index 0000000..67528a7 --- /dev/null +++ b/remind.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 100ed3d..7f00976 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +urllib3==2.7.0 +uvicorn==0.34.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..87ec349 --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py new file mode 100644 index 0000000..7193952 --- /dev/null +++ b/tests/test_analyzer.py @@ -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 diff --git a/tests/test_inscriber.py b/tests/test_inscriber.py new file mode 100644 index 0000000..ee8c6a1 --- /dev/null +++ b/tests/test_inscriber.py @@ -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() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..46138d2 --- /dev/null +++ b/tests/test_models.py @@ -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" diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..e6493a0 --- /dev/null +++ b/tests/test_notifications.py @@ -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) == [] diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..fd9f587 --- /dev/null +++ b/tests/test_web.py @@ -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 diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..be4ada8 --- /dev/null +++ b/web/__init__.py @@ -0,0 +1 @@ +"""Web layer package for instagram-sorteo.""" diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..fd4719d --- /dev/null +++ b/web/app.py @@ -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"], + } diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..e2a2f83 --- /dev/null +++ b/web/auth.py @@ -0,0 +1,25 @@ +"""Opt-in bearer-token auth for the web dashboard. + +If INSTAGRAM_SORTEO_TOKEN is set, protected endpoints require +`Authorization: Bearer `. 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") diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..006d38c --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,192 @@ + + + + + + instagram-sorteo · Dashboard + + + +
+

instagram-sorteo

+ + + + +
+ +
+ + + +
+ +
+
+
+
+
+ + + +