Skip to content

Commit cf28435

Browse files
committed
Initial commit
1 parent 7681edc commit cf28435

7 files changed

+335
-2
lines changed

README.md

+85-2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,85 @@
1-
# feed-visualizer
2-
Feed Visualizer is a tool that can cluster RSS/Atom feed items based on semantic similarity and generate interactive visualization. This tool can be used go generate 'semantic summary' of any website by reading it's RSS/Atom feed. The below image how the visualization generated by Feed Visualizer looks like.
1+
## Introduction
2+
3+
Feed Visualizer is a tool that can cluster RSS/Atom feed items based on semantic similarity and generate interactive visualization.
4+
This tool can be used to generate 'semantic summary' of any website by reading it's RSS/Atom feed. Shown below is an image of how the visualization generated by Feed Visualizer looks like. If you like this tool please consider giving a ⭐ on github !
5+
![](sample_visualization.gif)
6+
7+
8+
Interactive Demos:
9+
* Visualization created from [Martin Fowler's Atom Feed](https://martinfowler.com/feed.atom) :
10+
[https://ashishware.com/static/martin_fowler_viz.html](https://ashishware.com/static/martin_fowler_viz.html)
11+
12+
* Visualization created from [BCC's RSS Feed](http://feeds.bbci.co.uk/news/rss.xml) :
13+
[https://ashishware.com/static/martin_fowler_viz.html](https://ashishware.com/static/martin_fowler_viz.html)
14+
15+
## Quick Start
16+
17+
Clone the repo
18+
19+
```bash
20+
git clone https://github.com/code2k13/feed-visualizer.git
21+
```
22+
23+
Navigate to the the newly created directory
24+
```bash
25+
cd feed-visualizer
26+
```
27+
28+
Install the required modules
29+
```bash
30+
pip install -r requirements.txt
31+
```
32+
33+
34+
35+
> Typically a RSS or Atom file only contains recent information from the website. This is where, I would highly recommend using [wayback_machine_downloader](https://github.com/hartator/wayback-machine-downloader) tool. Follow the instructions on this page to install the tool.
36+
37+
The below command downloads public RSS feed from [NASA](https://www.nasa.gov/rss/dyn/breaking_news.rss) for last few months and saves to folder named 'nasa'
38+
```bash
39+
wayback_machine_downloader https://www.nasa.gov/rss/dyn/breaking_news.rss -s -f 202101 -t 202106 -d nasa
40+
```
41+
> Alternatively you can simply create a new folder and paste all RSS or Atom files in it (if you have them) ! Make sure to point your config to this folder (read next step)
42+
43+
44+
Now, we need to create a config file for Feed Visualizer. The config file contains path to input directory, name of output directory and some other settings (discussed later) that control the output of the tool. This is what a sample configuration file looks like :
45+
46+
```json
47+
{
48+
"input_directory": "nasa",
49+
"output_directory": "nasa_output",
50+
"pretrained_model": "all-mpnet-base-v2",
51+
"clust_dist_threshold": 4,
52+
"tsne_iter": 8000,
53+
"text_max_length": 2048
54+
}
55+
```
56+
57+
Now its time to run our tool
58+
59+
```bash
60+
python3 visualize.py -c config.json
61+
```
62+
63+
Once the above command completes, you should see *visualization.html* and *data.csv* files in the output folder (nasa_output). Copy these files to a webserver (or use a dummy server like [http-server](https://www.npmjs.com/package/http-server) ) and view the visualization.html page in a browser. You should see something like this:
64+
65+
![nasa](nasa_visualization.png)
66+
67+
68+
## Config settings
69+
70+
Here is some information on what each config setting does:
71+
72+
```json
73+
{
74+
"input_directory": "path to input directory. Can contain subfolders. But should only contain RSS or Atom files",
75+
"output_directory": "path to output directory where visualization will be stored. Directory is created if not present. Contents are always overwritten.",
76+
"pretrained_model": "name of pretrained model. Here is list of all valid model names https://www.sbert.net/docs/pretrained_models.html#model-overview",
77+
"clust_dist_threshold": "Integer representing maximum radius of cluster. There is no correct value here. Experiment !",
78+
"tsne_iter": "Integer representing number of iterations for TSNE (higher is better)",
79+
"text_max_length": "Integer representing number of characters to read from content/description for semantic encoding."
80+
}
81+
```
82+
83+
## Issues/Feature Requests/Bugs
84+
85+
You can reach out to me on [👨‍💼 LinkedIn](https://www.linkedin.com/in/ashish-patil-66bb568/) and [🗨️Twitter](https://twitter.com/patilsaheb) for reporting any issues/bugs or for feature requests !

nasa_visualization.png

20.5 KB
Loading

requirements.txt

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
feedparser
2+
sentence-transformers
3+
pandas
4+
nltk

sample_config.json

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"input_directory": "nasa",
3+
"output_directory": "nasa_output",
4+
"pretrained_model": "all-mpnet-base-v2",
5+
"clust_dist_threshold": 4,
6+
"tsne_iter": 8000,
7+
"text_max_length": 2048
8+
}

sample_visualization.gif

456 KB
Loading

visualization.html

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<head>
2+
<!-- Load plotly.js into the DOM -->
3+
<script src="https://d3js.org/d3.v7.min.js"></script>
4+
<script src='https://cdn.plot.ly/plotly-2.11.1.min.js'></script>
5+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"
6+
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
7+
<meta name="viewport" content="width=device-width, initial-scale=1">
8+
9+
</head>
10+
11+
<body>
12+
13+
<div class="container">
14+
<div class="jumbotron">
15+
<p class="lead" style="padding-top:20px">
16+
Add information about the visualization here !
17+
</p>
18+
</div>
19+
<div id="clusters"> </div>
20+
<div id='myDiv' style="width:90%;height:800px"> </div>
21+
</div>
22+
<script>
23+
24+
//let color = d3.scaleOrdinal(d3.schemeCategory10);
25+
let color = d3.scaleSequential(d3.interpolateRainbow);
26+
let cluster_count = 1
27+
let csv_data = null;
28+
29+
let trace1 = {
30+
x: [],
31+
y: [],
32+
marker: {
33+
size: [],
34+
color: []
35+
},
36+
text: [],
37+
mode: 'markers',
38+
name: 'Markers and Text',
39+
textposition: 'top',
40+
type: 'scatter',
41+
42+
};
43+
let data = [trace1]
44+
let layout = {
45+
showlegend: false,
46+
xaxis: {
47+
showgrid: false,
48+
zeroline: false
49+
},
50+
yaxis: {
51+
showgrid: false,
52+
showline: false,
53+
zeroline: false
54+
}
55+
}
56+
57+
function makeplot() {
58+
d3.csv("data.csv" + '?' + Math.floor(Math.random() * 1000)).then((d) => {
59+
csv_data = d
60+
let clusterNumbers = d.map(a => parseInt(a.cluster))
61+
cluster_count = Math.max(...clusterNumbers)
62+
d3.select('#clusters')
63+
.selectAll('span')
64+
.data(d3.range(0, cluster_count))
65+
.enter()
66+
.append('span')
67+
.style("background-color", function (d) { return color(d / cluster_count) })
68+
.style("padding", "2px")
69+
.style("border", "1px solid grey")
70+
.style("min-width", "25px")
71+
.style("display", "inline-block")
72+
.style("color", "white")
73+
.style("text-shadow", "1px 1px grey")
74+
.style("margin", "1px")
75+
.style("border-radius", "2px")
76+
.attr("data-clusterId", function (d) { return d })
77+
.on("mouseover", function (e, d) {
78+
//console.log(this.data.cluserId)
79+
//let currentClusterId = this.getAttribute("data-clusterId")
80+
let newTrace = JSON.parse(JSON.stringify(trace1));
81+
let new_colors = newTrace.marker.color.map(function (c,idx) {
82+
return csv_data[idx].cluster == d ? color(d / cluster_count) : "#e0eeeeee"
83+
})
84+
newTrace.marker.color = new_colors
85+
drawPlot('myDiv', [newTrace], layout)
86+
87+
})
88+
.on("mouseout", function () {
89+
drawPlot('myDiv', [trace1], layout)
90+
})
91+
.text(function (d) {
92+
return d;
93+
});
94+
95+
d.forEach(element => {
96+
processData(element)
97+
});
98+
drawPlot('myDiv', data, layout)
99+
100+
101+
})
102+
103+
};
104+
function drawPlot(placeholder, data, layout) {
105+
Plotly.newPlot(placeholder, data, layout).then(function () {
106+
document.getElementById(placeholder).on('plotly_click', function (data) {
107+
window.open(csv_data[data.points[0].pointIndex].url, "__blank")
108+
});
109+
})
110+
111+
}
112+
113+
function processData(row) {
114+
trace1.x.push(row.x)
115+
trace1.y.push(row.y)
116+
trace1.text.push(row.label.replace("Bliki:", ""))
117+
trace1.marker.size.push((parseInt(row.count) + 8) * 1.2)
118+
trace1.marker.color.push(color(parseInt(row.cluster) / cluster_count))
119+
console.log(row.cluster)
120+
}
121+
makeplot()
122+
</script>
123+
</body>

visualize.py

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import csv
5+
import glob
6+
import json
7+
import os
8+
import shutil
9+
10+
import feedparser
11+
import numpy as np
12+
import pandas as pd
13+
from bs4 import BeautifulSoup, SoupStrainer
14+
from sentence_transformers import SentenceTransformer
15+
from sklearn.cluster import AgglomerativeClustering
16+
from sklearn.manifold import TSNE
17+
from tqdm import tqdm
18+
19+
parser = argparse.ArgumentParser(description='Generates cool visualization from Atom/RSS feeds !')
20+
parser.add_argument('-c', '--configuration', required=True,
21+
help='location of configuration file.')
22+
args = parser.parse_args()
23+
24+
with open(args.configuration, 'r') as config_file:
25+
config = json.load(config_file)
26+
27+
semantic_encoder_model = SentenceTransformer(config["pretrained_model"])
28+
29+
def get_all_entries(path):
30+
all_entries = {}
31+
files = glob.glob(path+"/**/**/*.*",recursive=True)
32+
for file in tqdm(files, desc='Reading posts from files'):
33+
34+
feed = feedparser.parse(file)
35+
for entry in feed['entries']:
36+
if 'summary' in entry:
37+
all_entries[entry['link']] = [
38+
entry['title'], entry['title'] + " " + entry['summary']]
39+
elif 'content' in entry:
40+
all_entries[entry['link']] = [
41+
entry['title'], entry['title'] + " " + entry['content'][0]['value']]
42+
return all_entries
43+
44+
45+
def generate_text_for_entry(raw_text,entry_counts):
46+
output = []
47+
raw_text = raw_text.replace("\n", " ")
48+
soup = BeautifulSoup(raw_text, features="html.parser")
49+
output.append(soup.text)
50+
for link in BeautifulSoup(raw_text, parse_only=SoupStrainer('a'), features="html.parser"):
51+
if link.has_attr('href'):
52+
url = link['href']
53+
if url in entry_counts:
54+
entry_counts[url] = entry_counts[url] + 1
55+
else:
56+
entry_counts[url] = 0
57+
58+
return ' ' .join(output)
59+
60+
61+
def generate_embeddings(entries,entry_counts):
62+
sentences = [generate_text_for_entry(
63+
entries[a][1][0:config["text_max_length"]],entry_counts) for a in entries]
64+
print('Generating embeddings ...')
65+
embeddings = semantic_encoder_model.encode(sentences)
66+
print('Generating embeddings ... Done !')
67+
index = 0
68+
for uri in entries:
69+
entries[uri].append(embeddings[index])
70+
index = index+1
71+
return entries
72+
73+
74+
def get_coordinates(entries):
75+
X = [entries[e][-1] for e in entries]
76+
X = np.array(X)
77+
tsne = TSNE(n_iter=config["tsne_iter"],init='pca',learning_rate='auto')
78+
clustering_model = AgglomerativeClustering(distance_threshold=config["clust_dist_threshold"], n_clusters=None)
79+
clusters = clustering_model.fit_predict(tsne.fit_transform(X))
80+
return [x[0] for x in tsne.fit_transform(X)], [x[1] for x in tsne.fit_transform(X)], clusters
81+
82+
83+
def main():
84+
all_entries = get_all_entries(config["input_directory"])
85+
entry_counts = {}
86+
entry_texts = []
87+
disinct_entries = {}
88+
for k in all_entries.keys():
89+
if all_entries[k][0] not in entry_texts:
90+
disinct_entries[k] = all_entries[k]
91+
entry_texts.append(all_entries[k][0])
92+
93+
all_entries = disinct_entries
94+
entries = generate_embeddings(all_entries,entry_counts)
95+
print('Creating clusters ...')
96+
x, y, cluster_info = get_coordinates(entries)
97+
print('Creating clusters ... Done !')
98+
labels = [entries[k][0] for k in entries]
99+
100+
101+
counts = [entry_counts[k] if k in entry_counts else 0 for k in entries]
102+
103+
df = pd.DataFrame({'x': x, 'y': y, 'label': labels,
104+
'count': counts, 'url': entries.keys(), 'cluster': cluster_info})
105+
106+
107+
if not os.path.exists(config["output_directory"]):
108+
os.makedirs(config["output_directory"])
109+
df.to_csv(config["output_directory"]+"/data.csv")
110+
111+
shutil.copy('visualization.html', config["output_directory"])
112+
print('Vizualization generation is complete !!')
113+
114+
if __name__ == "__main__":
115+
main()

0 commit comments

Comments
 (0)