portal.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { type Handler } from "aws-lambda";
  2. import { createRestAPIClient, mastodon } from "masto";
  3. import ScraperArticles from "../utils/scraper-articles";
  4. import TagGenerator from "../utils/tag-generator";
  5. import RedisClient from "../libs/redis-client";
  6. import LogLevels from "../enums/log-levels";
  7. import Emojis from "../enums/emojis";
  8. import config from "../config";
  9. import { type IScraperArticlesOptions } from "../interfaces/scaper-articles-options";
  10. export default class Portal {
  11. private readonly _name: string;
  12. private readonly _scraperArticlesOptions: IScraperArticlesOptions
  13. private readonly _redisClient: RedisClient;
  14. private readonly _mastodonClient: mastodon.rest.Client
  15. private readonly _scraperArticles: ScraperArticles;
  16. private readonly _tagGenerator: TagGenerator;
  17. constructor (name: string, accessToken = config.MASTODON_TEST_ACCESS_TOKEN, scraperArticlesOptions: IScraperArticlesOptions) {
  18. this._name = name;
  19. this._scraperArticlesOptions = scraperArticlesOptions;
  20. this._scraperArticles = new ScraperArticles(this._name, this._scraperArticlesOptions);
  21. this._redisClient = new RedisClient();
  22. this._tagGenerator = new TagGenerator(this._name);
  23. accessToken = config.DEVELOP ? config.MASTODON_TEST_ACCESS_TOKEN : accessToken;
  24. this._mastodonClient = createRestAPIClient({ url: config.MASTODON_URL, accessToken });
  25. }
  26. public async run (event?: any, context?: any): Promise<void> {
  27. try {
  28. const articles = await this._scraperArticles.getArticles();
  29. if (config.LOG_LEVEL === LogLevels.DEBUG) {
  30. console.log(`${this._name} | Articles`, articles);
  31. }
  32. let totalPublished = 0;
  33. // Deduplicate articles by link within the same batch
  34. const seenLinks = new Set<string>();
  35. const uniqueArticles = articles.filter((article) => {
  36. if (seenLinks.has(article.link)) {
  37. return false;
  38. }
  39. seenLinks.add(article.link);
  40. return true;
  41. });
  42. // Order has to be reversed to appear in the correct order when posting
  43. uniqueArticles.reverse();
  44. const maxArticles = this._scraperArticlesOptions.maxArticles;
  45. const articlesToPublish = maxArticles !== undefined && maxArticles > 0
  46. ? uniqueArticles.slice(0, maxArticles)
  47. : uniqueArticles;
  48. const length = articlesToPublish.length;
  49. for (let i = 0; i < length; i++) {
  50. const article = articlesToPublish[i];
  51. const exists = await this._redisClient.retrieve(article.link);
  52. if (exists !== null) {
  53. continue;
  54. }
  55. // To avoid publiposts (I'm lookint at you La Tercera >:-|)
  56. if (article.link.includes("publirreportajes") || article.link.includes("publireportajes")) {
  57. continue;
  58. }
  59. if (article.title.includes("Exclusivo suscriptor")) {
  60. article.title = article.title.replace("Exclusivo suscriptor", "");
  61. }
  62. article.title = article.title.replace(/\s+/g, " ").trim();
  63. if (article.content !== "") {
  64. article.content = article.content.replace(/\s+/g, " ").trim();
  65. }
  66. let message = `${Emojis.NEWS} ${article.title}`;
  67. if (article.content !== "") {
  68. message += `\n\n${article.content}`;
  69. }
  70. if (message.trim().length <= 10) {
  71. continue;
  72. }
  73. // Generating hashtags from article content using Ollama
  74. const generatedTags = await this._tagGenerator.generate(article.title, article.content);
  75. const tags = generatedTags.length > 0
  76. ? generatedTags
  77. : (this._scraperArticlesOptions.hashtags ?? []);
  78. message += `\n${Emojis.LINK} ${article.link}`;
  79. if (tags.length > 0) {
  80. message += `\n\n${tags.map((hashtag) => `#${hashtag}`).join(" ")}`;
  81. }
  82. // Mastodon allows a maximum of 500 characters, but historically this bot limits to 400
  83. if (message.length > 400) {
  84. const linkLine = `\n${Emojis.LINK} ${article.link}`;
  85. const tagsLine = tags.length > 0
  86. ? `\n\n${tags.map((hashtag) => `#${hashtag}`).join(" ")}`
  87. : "";
  88. const maxContentLength = 400 - linkLine.length - tagsLine.length;
  89. message = `${Emojis.NEWS} ${article.title}\n\n${article.content.substring(0, Math.max(0, maxContentLength - 10))}...${linkLine}${tagsLine}`;
  90. }
  91. if (this._name.toUpperCase() == "Chile Cultura".toUpperCase()) {
  92. // Icons Chilecultura
  93. let regex = new RegExp(/\n(\d{1,2} \w{3})\s*\n?\s*-\s*(\d{1,2} \w{3})/);
  94. message = message.replace("date_range", `${Emojis.CALENDAR} `);
  95. message = message.replace(regex, (match, p1, p2) => {
  96. return `${p1} - ${p2}`;
  97. });
  98. message = message.replace("location_on", Emojis.PIN).replace(`${Emojis.PIN}\n`, `${Emojis.PIN} `);
  99. // Expresión regular para capturar el texto antes del 📆 seguido de una fecha
  100. regex = new RegExp(/(.*?)📆 (\d{1,2} \w{3}( - \d{1,2} \w{3}))?/);
  101. message = message.replace(regex, (match, p1, p2) => {
  102. const tags = p1 ? p1.split(" ").map(tag => `#${tag}`).join(" ") : "";
  103. return `🎭 ${tags}\n📆 ${p2 ? p2 : ""}`;
  104. });
  105. }
  106. const mediaIds: any[] = [];
  107. if (article.image !== null && article.image !== undefined) {
  108. const media = await this._mastodonClient.v2.media.create({ file: article.image, description: article.title });
  109. mediaIds.push(media.id);
  110. }
  111. console.log(`\n${this._name} | Sending\n`, message);
  112. await this._mastodonClient.v1.statuses.create({ status: message, mediaIds });
  113. totalPublished++
  114. if (!config.DEVELOP) {
  115. await this._redisClient.store(
  116. article.link,
  117. new Date(Date.now()).toLocaleDateString(),
  118. { EX: this._scraperArticlesOptions.cacheExpiration ? this._scraperArticlesOptions.cacheExpiration : 60 * 60 * 24 } // EX: 24 hrs expiration
  119. );
  120. }
  121. }
  122. console.log(`${this._name} | Published ${totalPublished} new articles`);
  123. } catch (err: any) {
  124. console.log(`${this._name} | An error has occurred\n`)
  125. console.error(err.message);
  126. if (config.LOG_LEVEL === LogLevels.DEBUG) {
  127. if (event !== undefined) {
  128. console.debug("\nEvent\n");
  129. console.debug(event);
  130. }
  131. if (context !== undefined) {
  132. console.debug("\nContext\n");
  133. console.debug(context);
  134. }
  135. }
  136. }
  137. console.log(`${this._name} | Finished`);
  138. }
  139. public getHandler (): Handler {
  140. return async (event, context) => {
  141. await this.run(event, context);
  142. }
  143. }
  144. }