-
Notifications
You must be signed in to change notification settings - Fork 0
/
bot.mjs
241 lines (212 loc) · 7.37 KB
/
bot.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
// Import necessary modules
import { BskyAgent } from '@atproto/api';
import RSSParser from 'rss-parser';
import fetch from 'node-fetch';
import dotenv from 'dotenv';
import fs from 'fs';
import * as cheerio from 'cheerio';
// Load environment variables from .env file (for Bluesky credentials)
dotenv.config();
// Initialize Bluesky agent with service URL
const agent = new BskyAgent({ service: 'https://bsky.social' });
// Initialize RSS parser
const parser = new RSSParser();
/**
* RSS feed configuration
* Replace the placeholder URLs with the actual RSS feed URLs you want to monitor.
* Optional `title` is used to prefix posts from each feed for easier identification.
*/
const RSS_FEEDS = [
{ url: 'https://example.com/rss-feed-1.xml', title: 'Example Feed 1' },
{ url: 'https://example.com/rss-feed-2.xml', title: 'Example Feed 2' },
];
// File to store links to the last posted entries (to avoid duplicate posts)
const LAST_POSTED_LINKS_FILE = 'lastPostedLinks.json';
// Rate limit configuration based on Bluesky's API documentation
const MAX_API_CALLS_PER_5_MINUTES = 3000;
const MAX_CREATES_PER_HOUR = 1666;
let apiCallCount = 0;
let createActionCount = 0;
let lastApiReset = Date.now();
let lastCreateReset = Date.now();
// Load last posted entries from file if it exists
function loadLastPostedLinks() {
if (fs.existsSync(LAST_POSTED_LINKS_FILE)) {
return JSON.parse(fs.readFileSync(LAST_POSTED_LINKS_FILE));
}
return {};
}
// Save last posted entries to file
function saveLastPostedLinks() {
fs.writeFileSync(LAST_POSTED_LINKS_FILE, JSON.stringify(lastPostedLinks, null, 2));
}
// Global variable to store last posted entries
let lastPostedLinks = loadLastPostedLinks();
/**
* Rate limiting function
* Ensures the bot adheres to Bluesky's API rate limits by delaying requests when necessary.
*/
async function rateLimit() {
if (Date.now() - lastApiReset >= 5 * 60 * 1000) {
apiCallCount = 0;
lastApiReset = Date.now();
}
if (Date.now() - lastCreateReset >= 60 * 60 * 1000) {
createActionCount = 0;
lastCreateReset = Date.now();
}
if (apiCallCount >= MAX_API_CALLS_PER_5_MINUTES) {
const waitTime = 5 * 60 * 1000 - (Date.now() - lastApiReset);
console.log(`API rate limit reached. Waiting ${Math.ceil(waitTime / 1000)} seconds.`);
await new Promise(resolve => setTimeout(resolve, waitTime));
apiCallCount = 0;
lastApiReset = Date.now();
}
if (createActionCount >= MAX_CREATES_PER_HOUR) {
const waitTime = 60 * 60 * 1000 - (Date.now() - lastCreateReset);
console.log(`CREATE limit reached. Waiting ${Math.ceil(waitTime / 1000)} seconds.`);
await new Promise(resolve => setTimeout(resolve, waitTime));
createActionCount = 0;
lastCreateReset = Date.now();
}
apiCallCount++;
createActionCount++;
}
/**
* Utility function: Check if an RSS entry was published within the last hour
* @param {string} pubDate - The publication date of the RSS entry
* @returns {boolean} True if published within the last hour, otherwise false
*/
function isPublishedWithinLastHour(pubDate) {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
return new Date(pubDate).getTime() >= oneHourAgo;
}
/**
* Utility function: Check if a link has already been posted
* @param {string} feedUrl - The RSS feed URL
* @param {string} link - The link of the RSS entry
* @returns {boolean} True if the link has already been posted, otherwise false
*/
function isAlreadyPosted(feedUrl, link) {
if (!lastPostedLinks[feedUrl]) {
lastPostedLinks[feedUrl] = [];
}
return lastPostedLinks[feedUrl].includes(link);
}
/**
* Utility function: Record a link as posted
* @param {string} feedUrl - The RSS feed URL
* @param {string} link - The link of the RSS entry
*/
function recordPostedLink(feedUrl, link) {
if (!lastPostedLinks[feedUrl]) {
lastPostedLinks[feedUrl] = [];
}
lastPostedLinks[feedUrl].push(link);
if (lastPostedLinks[feedUrl].length > 20) {
lastPostedLinks[feedUrl].shift();
}
}
/**
* Fetch metadata for a link (title, description, and image) for embedding in posts
* @param {string} url - The URL to fetch metadata for
* @returns {object|null} Embed card object or null if fetching fails
*/
async function fetchEmbedCard(url) {
try {
await rateLimit();
const response = await fetch(url);
const html = await response.text();
const $ = cheerio.load(html);
const ogTitle = $('meta[property="og:title"]').attr('content') || "Link";
const ogDescription = $('meta[property="og:description"]').attr('content') || "";
const ogImage = $('meta[property="og:image"]').attr('content');
const card = {
"$type": "app.bsky.embed.external",
"external": {
"uri": url,
"title": ogTitle,
"description": ogDescription,
},
};
if (ogImage) {
const imageResponse = await fetch(ogImage);
const imageData = Buffer.from(await imageResponse.arrayBuffer());
await rateLimit();
const uploadResponse = await agent.uploadBlob(imageData, "image/jpeg");
card.external.thumb = {
"$type": "blob",
"ref": uploadResponse.data.blob.ref,
"mimeType": "image/jpeg",
"size": imageData.length,
};
}
return card;
} catch (error) {
console.error("Failed to fetch metadata for URL:", error);
return null;
}
}
/**
* Process a single RSS feed and post new entries from the last hour
* @param {object} feed - The RSS feed configuration
*/
async function processFeed(feed) {
try {
const { url: feedUrl, title: feedTitle } = feed;
console.log(`Fetching RSS feed: ${feedUrl}`);
const feedData = await parser.parseURL(feedUrl);
let newPostsFound = false;
for (const item of feedData.items) {
if (isPublishedWithinLastHour(item.pubDate) && !isAlreadyPosted(feedUrl, item.link)) {
const embedCard = await fetchEmbedCard(item.link);
await rateLimit();
const postText = `${feedTitle ? `${feedTitle}: ` : ''}${item.title}\n\n${item.link}`;
await agent.post({
text: postText,
embed: embedCard || undefined,
langs: ["en"],
});
console.log(`Posted: ${postText}`);
recordPostedLink(feedUrl, item.link);
saveLastPostedLinks();
newPostsFound = true;
}
}
if (!newPostsFound) {
console.log(`No new entries found for ${feedUrl} in the last hour.`);
}
} catch (error) {
console.error("An error occurred while processing the feed:", error);
}
}
/**
* Main function to log in to Bluesky and process RSS feeds
* Repeats every 5 minutes to ensure consistent updates.
*/
async function postLatestRSSItems() {
try {
console.log("Starting bot...");
if (!process.env.BLUESKY_USERNAME || !process.env.BLUESKY_PASSWORD) {
console.error("Error: Bluesky credentials are missing in the .env file.");
return;
}
await agent.login({
identifier: process.env.BLUESKY_USERNAME,
password: process.env.BLUESKY_PASSWORD,
});
console.log("Logged in to Bluesky!");
for (const feed of RSS_FEEDS) {
await processFeed(feed);
}
} catch (error) {
if (error.response && error.response.status === 429) {
console.log('API rate limit exceeded. Waiting before retrying.');
} else {
console.error('An error occurred:', error);
}
}
}
// Start the bot immediately and repeat every 5 minutes
postLatestRSSItems();
setInterval(postLatestRSSItems, 5 * 60 * 1000);