Explorar el Código

Merge branch 'develop' of Mastodon/bots into main

Pablo Yaksic hace 2 semanas
padre
commit
09e61927be
Se han modificado 4 ficheros con 139 adiciones y 7 borrados
  1. 6 0
      .env.example
  2. 5 0
      src/config.ts
  3. 20 7
      src/portales/portal.ts
  4. 108 0
      src/utils/tag-generator.ts

+ 6 - 0
.env.example

@@ -63,6 +63,12 @@ MASTODON_KEY_COUNTDOWN = ""
 # COUNTDOWN
 COUNTDOWN_EVENTS = ""
 
+# OLLAMA
+OLLAMA_URL = "http://localhost:11434/api/generate"
+OLLAMA_MODEL = "llama3.2"
+OLLAMA_ENABLED = true
+OLLAMA_TIMEOUT = 10000
+
 # Develop
 DEVELOP = true
 DEV_ACTIVE_PORTALS = ""

+ 5 - 0
src/config.ts

@@ -66,6 +66,11 @@ const config = {
   ANONYBOT_RATE_LIMIT_HOURS: process.env.ANONYBOT_RATE_LIMIT_HOURS ?? 1,
   ANONYBOT_MAX_MESSAGES_PER_HOUR: process.env.ANONYBOT_MAX_MESSAGES_PER_HOUR ?? 5,
   ANONYBOT_MODERATION_LEVEL: process.env.ANONYBOT_MODERATION_LEVEL ?? "conservative",
+  // OLLAMA
+  OLLAMA_URL: process.env.OLLAMA_URL ?? "http://localhost:11434/api/generate",
+  OLLAMA_MODEL: process.env.OLLAMA_MODEL ?? "llama3.2",
+  OLLAMA_ENABLED: !(process.env.OLLAMA_ENABLED === "false"),
+  OLLAMA_TIMEOUT: Number(process.env.OLLAMA_TIMEOUT ?? 10000),
   // Develop
   DEVELOP: !(process.env.DEVELOP === "false"),
   DEV_ACTIVE_PORTALS: process.env.DEV_ACTIVE_PORTALS?.split(";") ?? [],

+ 20 - 7
src/portales/portal.ts

@@ -2,6 +2,7 @@ import { type Handler } from "aws-lambda";
 import { createRestAPIClient, mastodon } from "masto";
 
 import ScraperArticles from "../utils/scraper-articles";
+import TagGenerator from "../utils/tag-generator";
 import RedisClient from "../libs/redis-client";
 import LogLevels from "../enums/log-levels";
 import Emojis from "../enums/emojis";
@@ -15,12 +16,14 @@ export default class Portal {
   private readonly _redisClient: RedisClient;
   private readonly _mastodonClient: mastodon.rest.Client
   private readonly _scraperArticles: ScraperArticles;
+  private readonly _tagGenerator: TagGenerator;
 
   constructor (name: string, accessToken = config.MASTODON_TEST_ACCESS_TOKEN, scraperArticlesOptions: IScraperArticlesOptions) {
     this._name = name;
     this._scraperArticlesOptions = scraperArticlesOptions;
     this._scraperArticles = new ScraperArticles(this._name, this._scraperArticlesOptions);
     this._redisClient = new RedisClient();
+    this._tagGenerator = new TagGenerator(this._name);
 
     accessToken = config.DEVELOP ? config.MASTODON_TEST_ACCESS_TOKEN : accessToken;
     this._mastodonClient = createRestAPIClient({ url: config.MASTODON_URL, accessToken });
@@ -64,18 +67,28 @@ export default class Portal {
           continue;
         }
 
-        // If the message is more than 400 characters long, its very likely due to the article.content
-        if (message.length > 400) {
-          message = message.substring(0, 397) + "...";
-        }
+        // Generating hashtags from article content using Ollama
+        const generatedTags = await this._tagGenerator.generate(article.title, article.content);
+        const tags = generatedTags.length > 0
+          ? generatedTags
+          : (this._scraperArticlesOptions.hashtags ?? []);
 
-        // Adding hashtags if there is
-        if (this._scraperArticlesOptions.hashtags) {
-          message += `\n${Emojis.TAGS} ${this._scraperArticlesOptions.hashtags?.map((hastag) => `#${hastag}`).join(" ")}\n\n`;
+        if (tags.length > 0) {
+          message += `\n${Emojis.TAGS} ${tags.map((hashtag) => `#${hashtag}`).join(" ")}`;
         }
 
         message += `\n${Emojis.LINK} ${article.link}`;
 
+        // Mastodon allows a maximum of 500 characters, but historically this bot limits to 400
+        if (message.length > 400) {
+          const linkLine = `\n${Emojis.LINK} ${article.link}`;
+          const tagsLine = tags.length > 0
+            ? `\n${Emojis.TAGS} ${tags.map((hashtag) => `#${hashtag}`).join(" ")}`
+            : "";
+          const maxContentLength = 400 - linkLine.length - tagsLine.length;
+          message = `${Emojis.NEWS} ${article.title}\n\n${article.content.substring(0, Math.max(0, maxContentLength - 10))}...${tagsLine}${linkLine}`;
+        }
+
         if (this._name.toUpperCase() == "Chile Cultura".toUpperCase()) {
           // Icons Chilecultura
           let regex = new RegExp(/\n(\d{1,2} \w{3})\s*\n?\s*-\s*(\d{1,2} \w{3})/);

+ 108 - 0
src/utils/tag-generator.ts

@@ -0,0 +1,108 @@
+import axios from "axios";
+
+import config from "../config";
+import LogLevels from "../enums/log-levels";
+
+export default class TagGenerator {
+  private readonly _name: string;
+  private readonly _ollamaUrl: string;
+  private readonly _ollamaModel: string;
+  private readonly _ollamaTimeout: number;
+  private readonly _enabled: boolean;
+
+  constructor(name: string) {
+    this._name = name;
+    this._ollamaUrl = config.OLLAMA_URL;
+    this._ollamaModel = config.OLLAMA_MODEL;
+    this._ollamaTimeout = config.OLLAMA_TIMEOUT;
+    this._enabled = config.OLLAMA_ENABLED;
+  }
+
+  private buildPrompt(title: string, content: string): string {
+    return `A partir del siguiente título y contenido de una noticia de un medio digital chileno, sugiere exactamente 3 hashtags relevantes y concisos para publicar en Mastodon.
+
+Reglas:
+- Responde ÚNICAMENTE con un array JSON de strings.
+- Los hashtags deben estar en español.
+- Sin espacios ni caracteres especiales dentro de cada hashtag.
+- Sin símbolo #.
+- Solo el array JSON, sin explicaciones.
+
+Título: ${title}
+Contenido: ${content}
+
+Respuesta:`;
+  }
+
+  private cleanTag(tag: string): string {
+    return tag
+      .normalize("NFD")
+      .replace(/[\u0300-\u036f]/g, "")
+      .replace(/[^a-zA-Z0-9]/g, "")
+      .trim()
+      .toLowerCase();
+  }
+
+  private parseResponse(responseText: string): string[] {
+    const cleaned = responseText.trim();
+
+    try {
+      const parsed = JSON.parse(cleaned);
+      if (Array.isArray(parsed)) {
+        return parsed
+          .slice(0, 3)
+          .map((tag: unknown) => this.cleanTag(String(tag)))
+          .filter((tag) => tag !== "");
+      }
+    } catch {
+      // Intento extraer el primer array JSON que aparezca en la respuesta
+      const match = cleaned.match(/\[[\s\S]*?\]/);
+      if (match) {
+        return this.parseResponse(match[0]);
+      }
+    }
+
+    return [];
+  }
+
+  public async generate(title: string, content: string): Promise<string[]> {
+    if (!this._enabled) {
+      return [];
+    }
+
+    const trimmedTitle = title.trim();
+    const trimmedContent = content.trim();
+
+    if (trimmedTitle === "" && trimmedContent === "") {
+      return [];
+    }
+
+    try {
+      const response = await axios.post(
+        this._ollamaUrl,
+        {
+          model: this._ollamaModel,
+          prompt: this.buildPrompt(trimmedTitle, trimmedContent),
+          stream: false
+        },
+        {
+          timeout: this._ollamaTimeout,
+          headers: { "Content-Type": "application/json" }
+        }
+      );
+
+      const responseText = response.data?.response ?? "";
+      const tags = this.parseResponse(responseText);
+
+      if (config.LOG_LEVEL === LogLevels.DEBUG) {
+        console.debug(`${this._name} | Tags generadas por Ollama`, tags);
+      }
+
+      return tags;
+    } catch (err: any) {
+      console.log(`${this._name} | No se pudieron generar tags con Ollama`);
+      console.error(err.message);
+      return [];
+    }
+  }
+}