mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 18:32:16 +08:00
NewsPanel: Add support for Atom feeds (#45390)
This commit is contained in:
@ -6,7 +6,7 @@ import { CustomScrollbar, stylesFactory } from '@grafana/ui';
|
|||||||
|
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import { feedToDataFrame } from './utils';
|
import { feedToDataFrame } from './utils';
|
||||||
import { loadRSSFeed } from './rss';
|
import { loadFeed } from './feed';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import { PanelProps, DataFrameView, dateTimeFormat, GrafanaTheme2, textUtil } from '@grafana/data';
|
import { PanelProps, DataFrameView, dateTimeFormat, GrafanaTheme2, textUtil } from '@grafana/data';
|
||||||
@ -55,8 +55,9 @@ export class NewsPanel extends PureComponent<Props, State> {
|
|||||||
? `${PROXY_PREFIX}${options.feedUrl}`
|
? `${PROXY_PREFIX}${options.feedUrl}`
|
||||||
: options.feedUrl
|
: options.feedUrl
|
||||||
: DEFAULT_FEED_URL;
|
: DEFAULT_FEED_URL;
|
||||||
const res = await loadRSSFeed(url);
|
|
||||||
const frame = feedToDataFrame(res);
|
const feed = await loadFeed(url);
|
||||||
|
const frame = feedToDataFrame(feed);
|
||||||
this.setState({
|
this.setState({
|
||||||
news: new DataFrameView<NewsItem>(frame),
|
news: new DataFrameView<NewsItem>(frame),
|
||||||
isError: false,
|
isError: false,
|
||||||
|
16
public/app/plugins/panel/news/atom.test.ts
Normal file
16
public/app/plugins/panel/news/atom.test.ts
Normal 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./
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
19
public/app/plugins/panel/news/atom.ts
Normal file
19
public/app/plugins/panel/news/atom.ts
Normal 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;
|
||||||
|
}
|
25
public/app/plugins/panel/news/feed.ts
Normal file
25
public/app/plugins/panel/news/feed.ts
Normal 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;
|
||||||
|
}
|
28
public/app/plugins/panel/news/fixtures/atom.xml
Normal file
28
public/app/plugins/panel/news/fixtures/atom.xml
Normal 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>
|
21
public/app/plugins/panel/news/fixtures/rss.xml
Normal file
21
public/app/plugins/panel/news/fixtures/rss.xml
Normal 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>
|
@ -9,7 +9,7 @@ export const plugin = new PanelPlugin<PanelOptions>(NewsPanel).setPanelOptions((
|
|||||||
.addTextInput({
|
.addTextInput({
|
||||||
path: 'feedUrl',
|
path: 'feedUrl',
|
||||||
name: 'URL',
|
name: 'URL',
|
||||||
description: 'Only RSS feed formats are supported (not Atom).',
|
description: 'Supports RSS and Atom feeds',
|
||||||
settings: {
|
settings: {
|
||||||
placeholder: DEFAULT_FEED_URL,
|
placeholder: DEFAULT_FEED_URL,
|
||||||
},
|
},
|
||||||
|
14
public/app/plugins/panel/news/rss.test.ts
Normal file
14
public/app/plugins/panel/news/rss.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
@ -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> {
|
export function parseRSSFeed(txt: string): Feed {
|
||||||
const rsp = await fetch(url);
|
|
||||||
const txt = await rsp.text();
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
const doc = domParser.parseFromString(txt, 'text/xml');
|
const doc = domParser.parseFromString(txt, 'text/xml');
|
||||||
const feed: RssFeed = {
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProperty = (node: Element, property: string) => {
|
const feed: Feed = {
|
||||||
const propNode = node.querySelector(property);
|
items: Array.from(doc.querySelectorAll('item')).map((node) => ({
|
||||||
if (propNode) {
|
|
||||||
return propNode.textContent ?? '';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
doc.querySelectorAll('item').forEach((node) => {
|
|
||||||
const item: RssItem = {
|
|
||||||
title: getProperty(node, 'title'),
|
title: getProperty(node, 'title'),
|
||||||
link: getProperty(node, 'link'),
|
link: getProperty(node, 'link'),
|
||||||
content: getProperty(node, 'description'),
|
content: getProperty(node, 'description'),
|
||||||
pubDate: getProperty(node, 'pubDate'),
|
pubDate: getProperty(node, 'pubDate'),
|
||||||
};
|
ogImage: node.querySelector("meta[property='og:image']")?.getAttribute('content'),
|
||||||
|
})),
|
||||||
const imageNode = node.querySelector("meta[property='og:image']");
|
};
|
||||||
if (imageNode) {
|
|
||||||
item.ogImage = imageNode.getAttribute('content');
|
|
||||||
}
|
|
||||||
|
|
||||||
feed.items.push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return feed;
|
return feed;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
items: RssItem[];
|
items: FeedItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RssItem {
|
export interface FeedItem {
|
||||||
title: string;
|
title: string;
|
||||||
link: string;
|
link: string;
|
||||||
pubDate?: string;
|
pubDate?: string;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { feedToDataFrame } from './utils';
|
import { feedToDataFrame } from './utils';
|
||||||
import { RssFeed, NewsItem } from './types';
|
import { Feed, NewsItem } from './types';
|
||||||
import { DataFrameView } from '@grafana/data';
|
import { DataFrameView } from '@grafana/data';
|
||||||
|
|
||||||
describe('news', () => {
|
describe('news', () => {
|
||||||
@ -65,4 +65,4 @@ const grafana20191216 = {
|
|||||||
link: 'https://grafana.com/blog/',
|
link: 'https://grafana.com/blog/',
|
||||||
language: 'en-us',
|
language: 'en-us',
|
||||||
lastBuildDate: 'Fri, 13 Dec 2019 00:00:00 +0000',
|
lastBuildDate: 'Fri, 13 Dec 2019 00:00:00 +0000',
|
||||||
} as RssFeed;
|
} as Feed;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { RssFeed } from './types';
|
import { Feed } from './types';
|
||||||
import { ArrayVector, FieldType, DataFrame, dateTime } from '@grafana/data';
|
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 date = new ArrayVector<number>([]);
|
||||||
const title = new ArrayVector<string>([]);
|
const title = new ArrayVector<string>([]);
|
||||||
const link = new ArrayVector<string>([]);
|
const link = new ArrayVector<string>([]);
|
||||||
|
Reference in New Issue
Block a user