NewsPanel: Add support for Atom feeds (#45390)

This commit is contained in:
kay delaney 2022-02-15 12:26:59 +00:00 committed by GitHub
parent 146eed400a
commit e1ff4dc9fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 144 additions and 38 deletions

View File

@ -6,7 +6,7 @@ import { CustomScrollbar, stylesFactory } from '@grafana/ui';
import config from 'app/core/config';
import { feedToDataFrame } from './utils';
import { loadRSSFeed } from './rss';
import { loadFeed } from './feed';
// Types
import { PanelProps, DataFrameView, dateTimeFormat, GrafanaTheme2, textUtil } from '@grafana/data';
@ -55,8 +55,9 @@ export class NewsPanel extends PureComponent<Props, State> {
? `${PROXY_PREFIX}${options.feedUrl}`
: options.feedUrl
: DEFAULT_FEED_URL;
const res = await loadRSSFeed(url);
const frame = feedToDataFrame(res);
const feed = await loadFeed(url);
const frame = feedToDataFrame(feed);
this.setState({
news: new DataFrameView<NewsItem>(frame),
isError: false,

View File

@ -0,0 +1,16 @@
import { parseAtomFeed } from './atom';
import fs from 'fs';
describe('Atom feed parser', () => {
it('should successfully parse an atom feed', async () => {
const atomFile = fs.readFileSync(`${__dirname}/fixtures/atom.xml`, 'utf8');
const parsedFeed = parseAtomFeed(atomFile);
expect(parsedFeed.items).toHaveLength(1);
expect(parsedFeed.items[0].title).toBe('Why Testing Is The Best');
expect(parsedFeed.items[0].link).toBe('https://www.example.com/2022/02/12/why-testing-is-the-best/');
expect(parsedFeed.items[0].pubDate).toBe('2022-02-12T08:00:00+00:00');
expect(parsedFeed.items[0].content).toMatch(
/Testing is the best because it lets you know your code isn't broken, probably./
);
});
});

View File

@ -0,0 +1,19 @@
import { getProperty } from './feed';
import { Feed } from './types';
export function parseAtomFeed(txt: string): Feed {
const domParser = new DOMParser();
const doc = domParser.parseFromString(txt, 'text/xml');
const feed: Feed = {
items: Array.from(doc.querySelectorAll('entry')).map((node) => ({
title: getProperty(node, 'title'),
link: node.querySelector('link')?.getAttribute('href') ?? '',
content: getProperty(node, 'content'),
pubDate: getProperty(node, 'published'),
ogImage: node.querySelector("meta[property='og:image']")?.getAttribute('content'),
})),
};
return feed;
}

View File

@ -0,0 +1,25 @@
import { parseAtomFeed } from './atom';
import { parseRSSFeed } from './rss';
export async function fetchFeedText(url: string) {
const rsp = await fetch(url);
const txt = await rsp.text();
return txt;
}
export function isAtomFeed(txt: string) {
const domParser = new DOMParser();
const doc = domParser.parseFromString(txt, 'text/xml');
return doc.querySelector('feed') !== null;
}
export function getProperty(node: Element, property: string): string {
const propNode = node.querySelector(property);
return propNode?.textContent ?? '';
}
export async function loadFeed(url: string) {
const res = await fetchFeedText(url);
const parsedFeed = isAtomFeed(res) ? parseAtomFeed(res) : parseRSSFeed(res);
return parsedFeed;
}

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<generator uri="https://jekyllrb.com/" version="3.9.0">Jekyll</generator>
<link href="https://www.example.com/feed.xml" rel="self" type="application/atom+xml" />
<link href="https://www.example.com/" rel="alternate" type="text/html" />
<updated>2022-02-15T07:00:47+00:00</updated>
<id>https://www.example.com/feed.xml</id>
<title type="html">Test Feed</title>
<subtitle>An example of an atom feed, for testing</subtitle>
<author>
<name>Bobby Test</name>
</author>
<entry>
<title type="html">Why Testing Is The Best</title>
<link href="https://www.example.com/2022/02/12/why-testing-is-the-best/" rel="alternate" type="text/html" title="Why Testing Is The Best" />
<published>2022-02-12T08:00:00+00:00</published>
<updated>2022-02-12T08:00:00+00:00</updated>
<id>https://www.example.com/2022/02/12/why-testing-is-the-best</id>
<content type="html" xml:base="https://www.hugohaggmark.com/2022/02/12/why-testing-is-the-best/">
Testing is the best because it lets you know your code isn't broken, probably.
</content>
<author>
<name>Bobby Test</name>
</author>
<category term="Testing" />
<summary type="html">An example of a summary.</summary>
</entry>
</feed>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
<channel>
<title>RSS Feed Example</title>
<atom:link href="https://www.example.net/feed" rel="self" type="application/rss+xml" />
<link>https://www.example.net</link>
<description>A small description of this feed</description>
<language>en-US</language>
<item>
<title>A fake item</title>
<link>https://www.example.net/2022/02/10/something-fake/</link>
<dc:creator>Bill Test</dc:creator>
<pubDate>Thu, 10 Feb 2022 16:00:17 +0000</pubDate>
<category>Fake</category>
<description>A description of a fake blog post</description>
</item>
</channel>
</rss>

View File

@ -9,7 +9,7 @@ export const plugin = new PanelPlugin<PanelOptions>(NewsPanel).setPanelOptions((
.addTextInput({
path: 'feedUrl',
name: 'URL',
description: 'Only RSS feed formats are supported (not Atom).',
description: 'Supports RSS and Atom feeds',
settings: {
placeholder: DEFAULT_FEED_URL,
},

View File

@ -0,0 +1,14 @@
import { parseRSSFeed } from './rss';
import fs from 'fs';
describe('RSS feed parser', () => {
it('should successfully parse an rss feed', async () => {
const rssFile = fs.readFileSync(`${__dirname}/fixtures/rss.xml`, 'utf8');
const parsedFeed = parseRSSFeed(rssFile);
expect(parsedFeed.items).toHaveLength(1);
expect(parsedFeed.items[0].title).toBe('A fake item');
expect(parsedFeed.items[0].link).toBe('https://www.example.net/2022/02/10/something-fake/');
expect(parsedFeed.items[0].pubDate).toBe('Thu, 10 Feb 2022 16:00:17 +0000');
expect(parsedFeed.items[0].content).toBe('A description of a fake blog post');
});
});

View File

@ -1,37 +1,19 @@
import { RssFeed, RssItem } from './types';
import { getProperty } from './feed';
import { Feed } from './types';
export async function loadRSSFeed(url: string): Promise<RssFeed> {
const rsp = await fetch(url);
const txt = await rsp.text();
export function parseRSSFeed(txt: string): Feed {
const domParser = new DOMParser();
const doc = domParser.parseFromString(txt, 'text/xml');
const feed: RssFeed = {
items: [],
};
const getProperty = (node: Element, property: string) => {
const propNode = node.querySelector(property);
if (propNode) {
return propNode.textContent ?? '';
}
return '';
};
doc.querySelectorAll('item').forEach((node) => {
const item: RssItem = {
const feed: Feed = {
items: Array.from(doc.querySelectorAll('item')).map((node) => ({
title: getProperty(node, 'title'),
link: getProperty(node, 'link'),
content: getProperty(node, 'description'),
pubDate: getProperty(node, 'pubDate'),
};
const imageNode = node.querySelector("meta[property='og:image']");
if (imageNode) {
item.ogImage = imageNode.getAttribute('content');
}
feed.items.push(item);
});
ogImage: node.querySelector("meta[property='og:image']")?.getAttribute('content'),
})),
};
return feed;
}

View File

@ -7,15 +7,15 @@ export interface NewsItem {
}
/**
* Helper class for rss-parser
* Helper interface for feed parser
*/
export interface RssFeed {
export interface Feed {
title?: string;
description?: string;
items: RssItem[];
items: FeedItem[];
}
export interface RssItem {
export interface FeedItem {
title: string;
link: string;
pubDate?: string;

View File

@ -1,5 +1,5 @@
import { feedToDataFrame } from './utils';
import { RssFeed, NewsItem } from './types';
import { Feed, NewsItem } from './types';
import { DataFrameView } from '@grafana/data';
describe('news', () => {
@ -65,4 +65,4 @@ const grafana20191216 = {
link: 'https://grafana.com/blog/',
language: 'en-us',
lastBuildDate: 'Fri, 13 Dec 2019 00:00:00 +0000',
} as RssFeed;
} as Feed;

View File

@ -1,7 +1,7 @@
import { RssFeed } from './types';
import { Feed } from './types';
import { ArrayVector, FieldType, DataFrame, dateTime } from '@grafana/data';
export function feedToDataFrame(feed: RssFeed): DataFrame {
export function feedToDataFrame(feed: Feed): DataFrame {
const date = new ArrayVector<number>([]);
const title = new ArrayVector<string>([]);
const link = new ArrayVector<string>([]);