rss-reader.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. /**
  2. * @node rss-reader
  3. * @name RSS Reader
  4. * @category rss
  5. * @version 1.0.0
  6. * @description Read and parse RSS/Atom feeds with optional filtering by keyword, date, or count
  7. * @icon rss
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. url: {
  13. type: 'string',
  14. title: 'Feed URL',
  15. description: 'URL of the RSS or Atom feed'
  16. },
  17. filterKeyword: {
  18. type: 'string',
  19. title: 'Keyword Filter',
  20. description: 'Filter items by keyword (matches title and description)'
  21. },
  22. filterNewerThan: {
  23. type: 'string',
  24. title: 'Newer Than',
  25. description: 'Only include items newer than this date (ISO 8601 format, e.g., 2024-01-15T00:00:00Z)'
  26. },
  27. maxItems: {
  28. type: 'number',
  29. title: 'Max Items',
  30. description: 'Maximum number of items to return (0 = unlimited)',
  31. default: 0
  32. },
  33. timeout: {
  34. type: 'number',
  35. title: 'Timeout (ms)',
  36. default: 30000
  37. }
  38. },
  39. required: ['url']
  40. };
  41. const inputSchema = {
  42. type: 'object',
  43. properties: {
  44. url: {
  45. type: 'string',
  46. description: 'Override feed URL from input'
  47. },
  48. filterKeyword: {
  49. type: 'string',
  50. description: 'Override keyword filter from input'
  51. },
  52. filterNewerThan: {
  53. type: 'string',
  54. description: 'Override date filter from input'
  55. },
  56. maxItems: {
  57. type: 'number',
  58. description: 'Override max items from input'
  59. }
  60. }
  61. };
  62. const outputSchema = {
  63. type: 'object',
  64. properties: {
  65. feedTitle: { type: 'string', description: 'Title of the feed' },
  66. feedLink: { type: 'string', description: 'Link to the feed homepage' },
  67. feedDescription: { type: 'string', description: 'Description of the feed' },
  68. itemCount: { type: 'number', description: 'Number of items returned' },
  69. items: {
  70. type: 'array',
  71. description: 'Array of feed items',
  72. items: {
  73. type: 'object',
  74. properties: {
  75. title: { type: 'string' },
  76. link: { type: 'string' },
  77. description: { type: 'string' },
  78. pubDate: { type: 'string' },
  79. author: { type: 'string' },
  80. categories: { type: 'array', items: { type: 'string' } },
  81. guid: { type: 'string' }
  82. }
  83. }
  84. }
  85. }
  86. };
  87. /**
  88. * Simple XML parser for RSS/Atom feeds
  89. * Handles basic XML structure without external dependencies
  90. */
  91. function parseXml() {
  92. function getTagContent(xml, tagName) {
  93. const patterns = [
  94. new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'),
  95. new RegExp(`<${tagName}[^>]*\\/>`, 'i')
  96. ];
  97. const match = xml.match(patterns[0]);
  98. if (match) {
  99. return match[1].trim();
  100. }
  101. return null;
  102. }
  103. function getAllTagContents(xml, tagName) {
  104. const results = [];
  105. const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'gi');
  106. let match;
  107. while ((match = regex.exec(xml)) !== null) {
  108. results.push(match[1].trim());
  109. }
  110. return results;
  111. }
  112. function getTagAttribute(xml, tagName, attrName) {
  113. const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}=["']([^"']*)["'][^>]*>`, 'i');
  114. const match = xml.match(regex);
  115. return match ? match[1] : null;
  116. }
  117. function getAllTags(xml, tagName) {
  118. const results = [];
  119. const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>|<${tagName}([^>]*)\\/>`, 'gi');
  120. let match;
  121. while ((match = regex.exec(xml)) !== null) {
  122. results.push({
  123. content: match[1] ? match[1].trim() : '',
  124. fullMatch: match[0]
  125. });
  126. }
  127. return results;
  128. }
  129. function decodeHtmlEntities(text) {
  130. if (!text) return '';
  131. return text
  132. .replace(/&lt;/g, '<')
  133. .replace(/&gt;/g, '>')
  134. .replace(/&amp;/g, '&')
  135. .replace(/&quot;/g, '"')
  136. .replace(/&apos;/g, "'")
  137. .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec))
  138. .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
  139. }
  140. function stripCdata(text) {
  141. if (!text) return '';
  142. return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
  143. }
  144. function cleanText(text) {
  145. if (!text) return '';
  146. return decodeHtmlEntities(stripCdata(text)).trim();
  147. }
  148. return {
  149. getTagContent,
  150. getAllTagContents,
  151. getTagAttribute,
  152. getAllTags,
  153. cleanText
  154. };
  155. }
  156. /**
  157. * Parse RSS 2.0 feed
  158. */
  159. function parseRss(xml, parser) {
  160. const channel = parser.getTagContent(xml, 'channel');
  161. if (!channel) {
  162. throw new Error('Invalid RSS feed: no channel element found');
  163. }
  164. const feed = {
  165. title: parser.cleanText(parser.getTagContent(channel, 'title')) || '',
  166. link: parser.cleanText(parser.getTagContent(channel, 'link')) || '',
  167. description: parser.cleanText(parser.getTagContent(channel, 'description')) || '',
  168. items: []
  169. };
  170. const itemRegex = /<item[^>]*>([\s\S]*?)<\/item>/gi;
  171. let match;
  172. while ((match = itemRegex.exec(channel)) !== null) {
  173. const itemXml = match[1];
  174. const categories = parser.getAllTagContents(itemXml, 'category')
  175. .map(c => parser.cleanText(c));
  176. const item = {
  177. title: parser.cleanText(parser.getTagContent(itemXml, 'title')) || '',
  178. link: parser.cleanText(parser.getTagContent(itemXml, 'link')) || '',
  179. description: parser.cleanText(parser.getTagContent(itemXml, 'description')) || '',
  180. pubDate: parser.cleanText(parser.getTagContent(itemXml, 'pubDate')) || '',
  181. author: parser.cleanText(parser.getTagContent(itemXml, 'author') ||
  182. parser.getTagContent(itemXml, 'dc:creator')) || '',
  183. categories: categories,
  184. guid: parser.cleanText(parser.getTagContent(itemXml, 'guid')) || ''
  185. };
  186. feed.items.push(item);
  187. }
  188. return feed;
  189. }
  190. /**
  191. * Parse Atom feed
  192. */
  193. function parseAtom(xml, parser) {
  194. const feedContent = parser.getTagContent(xml, 'feed');
  195. if (!feedContent) {
  196. throw new Error('Invalid Atom feed: no feed element found');
  197. }
  198. function getAtomLink(content, rel) {
  199. const linkRegex = /<link([^>]*)>/gi;
  200. let match;
  201. while ((match = linkRegex.exec(content)) !== null) {
  202. const attrs = match[1];
  203. const relMatch = attrs.match(/rel=["']([^"']*)["']/);
  204. const hrefMatch = attrs.match(/href=["']([^"']*)["']/);
  205. if (hrefMatch) {
  206. const linkRel = relMatch ? relMatch[1] : 'alternate';
  207. if (linkRel === rel || (!rel && linkRel === 'alternate')) {
  208. return hrefMatch[1];
  209. }
  210. }
  211. }
  212. return '';
  213. }
  214. const feed = {
  215. title: parser.cleanText(parser.getTagContent(feedContent, 'title')) || '',
  216. link: getAtomLink(feedContent, 'alternate') || getAtomLink(feedContent, null) || '',
  217. description: parser.cleanText(parser.getTagContent(feedContent, 'subtitle')) || '',
  218. items: []
  219. };
  220. const entryRegex = /<entry[^>]*>([\s\S]*?)<\/entry>/gi;
  221. let match;
  222. while ((match = entryRegex.exec(feedContent)) !== null) {
  223. const entryXml = match[1];
  224. const categories = [];
  225. const catRegex = /<category([^>]*)>/gi;
  226. let catMatch;
  227. while ((catMatch = catRegex.exec(entryXml)) !== null) {
  228. const termMatch = catMatch[1].match(/term=["']([^"']*)["']/);
  229. if (termMatch) {
  230. categories.push(parser.cleanText(termMatch[1]));
  231. }
  232. }
  233. let author = '';
  234. const authorContent = parser.getTagContent(entryXml, 'author');
  235. if (authorContent) {
  236. author = parser.cleanText(parser.getTagContent(authorContent, 'name')) || '';
  237. }
  238. const pubDate = parser.cleanText(
  239. parser.getTagContent(entryXml, 'published') ||
  240. parser.getTagContent(entryXml, 'updated')
  241. ) || '';
  242. const description = parser.cleanText(
  243. parser.getTagContent(entryXml, 'summary') ||
  244. parser.getTagContent(entryXml, 'content')
  245. ) || '';
  246. const item = {
  247. title: parser.cleanText(parser.getTagContent(entryXml, 'title')) || '',
  248. link: getAtomLink(entryXml, 'alternate') || getAtomLink(entryXml, null) || '',
  249. description: description,
  250. pubDate: pubDate,
  251. author: author,
  252. categories: categories,
  253. guid: parser.cleanText(parser.getTagContent(entryXml, 'id')) || ''
  254. };
  255. feed.items.push(item);
  256. }
  257. return feed;
  258. }
  259. /**
  260. * Parse date string to timestamp
  261. */
  262. function parseDate(dateStr) {
  263. if (!dateStr) return 0;
  264. const timestamp = Date.parse(dateStr);
  265. if (!isNaN(timestamp)) {
  266. return timestamp;
  267. }
  268. const rfc822Regex = /(\d{1,2})\s+(\w{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/;
  269. const match = dateStr.match(rfc822Regex);
  270. if (match) {
  271. const months = {
  272. Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
  273. Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
  274. };
  275. const day = parseInt(match[1], 10);
  276. const month = months[match[2]];
  277. const year = parseInt(match[3], 10);
  278. const hour = parseInt(match[4], 10);
  279. const minute = parseInt(match[5], 10);
  280. const second = parseInt(match[6], 10);
  281. if (month !== undefined) {
  282. return new Date(year, month, day, hour, minute, second).getTime();
  283. }
  284. }
  285. return 0;
  286. }
  287. /**
  288. * Filter items by keyword
  289. */
  290. function filterByKeyword(items, keyword) {
  291. if (!keyword) return items;
  292. const lowerKeyword = keyword.toLowerCase();
  293. return items.filter(item => {
  294. const title = (item.title || '').toLowerCase();
  295. const description = (item.description || '').toLowerCase();
  296. return title.includes(lowerKeyword) || description.includes(lowerKeyword);
  297. });
  298. }
  299. /**
  300. * Filter items by date
  301. */
  302. function filterByDate(items, newerThanStr) {
  303. if (!newerThanStr) return items;
  304. const threshold = parseDate(newerThanStr);
  305. if (threshold === 0) {
  306. smartbotic.log.warn(`Could not parse date filter: ${newerThanStr}`);
  307. return items;
  308. }
  309. return items.filter(item => {
  310. const itemDate = parseDate(item.pubDate);
  311. return itemDate > threshold;
  312. });
  313. }
  314. /**
  315. * Limit number of items
  316. */
  317. function limitItems(items, maxItems) {
  318. if (!maxItems || maxItems <= 0) return items;
  319. return items.slice(0, maxItems);
  320. }
  321. async function execute(config, input, context) {
  322. const url = input.url || config.url;
  323. const filterKeyword = input.filterKeyword || config.filterKeyword;
  324. const filterNewerThan = input.filterNewerThan || config.filterNewerThan;
  325. const maxItems = input.maxItems !== undefined ? input.maxItems : config.maxItems;
  326. const timeout = config.timeout || 30000;
  327. if (!url) {
  328. throw new Error('Feed URL is required');
  329. }
  330. smartbotic.log.info(`Fetching RSS/Atom feed from ${url}`);
  331. const response = smartbotic.http.request({
  332. method: 'GET',
  333. url: url,
  334. timeout: timeout,
  335. headers: {
  336. 'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*'
  337. }
  338. });
  339. if (response.status < 200 || response.status >= 300) {
  340. throw new Error(`Failed to fetch feed: HTTP ${response.status}`);
  341. }
  342. const xml = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
  343. if (!xml || xml.trim().length === 0) {
  344. throw new Error('Empty response from feed URL');
  345. }
  346. const parser = parseXml(xml);
  347. let feed;
  348. if (xml.includes('<feed') && xml.includes('xmlns') && xml.includes('atom')) {
  349. smartbotic.log.debug('Detected Atom feed format');
  350. feed = parseAtom(xml, parser);
  351. } else if (xml.includes('<rss') || xml.includes('<channel')) {
  352. smartbotic.log.debug('Detected RSS feed format');
  353. feed = parseRss(xml, parser);
  354. } else {
  355. throw new Error('Unknown feed format: neither RSS nor Atom detected');
  356. }
  357. let items = feed.items;
  358. if (filterKeyword) {
  359. smartbotic.log.debug(`Filtering by keyword: ${filterKeyword}`);
  360. items = filterByKeyword(items, filterKeyword);
  361. }
  362. if (filterNewerThan) {
  363. smartbotic.log.debug(`Filtering items newer than: ${filterNewerThan}`);
  364. items = filterByDate(items, filterNewerThan);
  365. }
  366. if (maxItems && maxItems > 0) {
  367. smartbotic.log.debug(`Limiting to ${maxItems} items`);
  368. items = limitItems(items, maxItems);
  369. }
  370. smartbotic.log.info(`Parsed ${items.length} items from feed`);
  371. return {
  372. feedTitle: feed.title,
  373. feedLink: feed.link,
  374. feedDescription: feed.description,
  375. itemCount: items.length,
  376. items: items
  377. };
  378. }
  379. module.exports = { configSchema, inputSchema, outputSchema, execute };