├── rssify.sh
├── test
├── server.test.js
├── sample.html
├── main.test.js
├── storage.mem.test.js
├── storage.file.test.js
└── rss.test.js
├── index.js
├── .travis.yml
├── .gitignore
├── package.json
├── lib
├── server.js
├── main.js
├── storage.mem.js
├── storage.file.js
└── rss.js
├── config.json
├── README.md
└── LICENSE
/rssify.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | require('.');
--------------------------------------------------------------------------------
/test/server.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var expect = require('chai').expect;
4 |
5 | var server = require('../lib/server');
6 |
7 | describe('server.js', () => {
8 | describe('#initialize()', () => {
9 |
10 | });
11 | });
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | process.on('uncaughtException', console.log);
4 |
5 | var config = process.argv.pop();
6 | config = config.match(/\.json$/) ? require('path').resolve(config) : './config.json';
7 |
8 | require('./lib/main').initialize(require(config));
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | sudo: false
3 |
4 | node_js:
5 | - "5.6"
6 | - "4.0"
7 |
8 | cache:
9 | directories:
10 | - node_modules
11 |
12 | os:
13 | - linux
14 | # - osx
15 |
16 | script: "npm run test-travis"
17 | after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js || true"
--------------------------------------------------------------------------------
/test/sample.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | This is sample page for unit tests for rssify
5 |
6 |
7 |
8 |
9 |
10 |
Item Title
11 |

12 |
13 | I don't always test, but when I do I do it in production!
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
18 | .grunt
19 |
20 | # node-waf configuration
21 | .lock-wscript
22 |
23 | # Compiled binary addons (http://nodejs.org/api/addons.html)
24 | build/Release
25 |
26 | # Dependency directory
27 | node_modules
28 |
29 | # Optional npm cache directory
30 | .npm
31 |
32 | # Optional REPL history
33 | .node_repl_history
34 |
35 | feeds/
--------------------------------------------------------------------------------
/test/main.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var expect = require('chai').expect;
4 |
5 | var main = require('../lib/main');
6 |
7 | describe('main.js', () => {
8 | describe('#applyGlobals()', () => {
9 | it('should apply all the global options to the individual options', () => {
10 | var config = {
11 | global: {
12 | prop1: 'value1',
13 | prop2: 2,
14 | prop3: true,
15 | prop4: 'success'
16 | },
17 | testFeed: {
18 | prop1: 'value2',
19 | prop4: ['Yay!']
20 | }
21 | };
22 | main.applyGlobals(config);
23 | expect(config.testFeed.prop1).to.equal('value2');
24 | expect(config.testFeed.prop2).to.equal(2);
25 | expect(config.testFeed.prop3).to.equal(true);
26 | expect(config.testFeed.prop4).to.deep.equal(['Yay!', 'success']);
27 | });
28 | });
29 |
30 | describe('#initialize()', () => {
31 | it('should call all initializers and set the proper configuration values', () => {
32 |
33 | })
34 | });
35 | });
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rssify",
3 | "version": "1.0.0",
4 | "description": "Turns web pages into an rss feeds",
5 | "main": "index.js",
6 | "homepage": "https://github.com/mallocator/rssify",
7 | "repository": {
8 | "type": "git",
9 | "url": "git+https://github.com/mallocator/rssify.git"
10 | },
11 | "keywords": [
12 | "rss",
13 | "crawler",
14 | "scraper",
15 | "feed",
16 | "builder"
17 | ],
18 | "author": "Ravi Gairola ",
19 | "license": "Apache-2.0",
20 | "bugs": {
21 | "url": "https://github.com/mallocator/rssify/issues",
22 | "email": "mallox@pyxzl.net"
23 | },
24 | "dependencies": {
25 | "cheerio": "^0.20.0",
26 | "gently": "^0.10.0",
27 | "initd-forever": "^0.1.8",
28 | "rss": "^1.2.1"
29 | },
30 | "devDependencies": {
31 | "chai": "^3.4.1",
32 | "coveralls": "^2.11.4",
33 | "gently": "^0.10.0",
34 | "istanbul": "^0.4.1",
35 | "mocha": "^2.3.4",
36 | "mocha-lcov-reporter": "^1.0.0"
37 | },
38 | "engine": {
39 | "node": ">=5.0"
40 | },
41 | "os": [
42 | "darwin",
43 | "linux"
44 | ],
45 | "scripts": {
46 | "test": "istanbul cover _mocha -- --recursive",
47 | "test-travis": "istanbul cover _mocha --report lcovonly -- -R spec --recursive"
48 | },
49 | "bin": {
50 | "rssify": "rssify.sh"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/lib/server.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | var http = require('http');
4 |
5 | var RSS = require('rss');
6 |
7 |
8 | /**
9 | * Enable or disable debug messages (set through global config object).
10 | * @type {boolean}
11 | */
12 | exports.debug = false;
13 | /**
14 | * The storage implementation used to store feed data and configuration.
15 | * @type {Object}
16 | */
17 | exports.storage = null;
18 |
19 | /**
20 | * Start the server that will respond to http requests.
21 | * @param port
22 | */
23 | exports.initialize = (host, port) => {
24 | http.createServer((req, res) => {
25 | var feedName = req.url.replace(/^\/|\/$/g, '');
26 | var feed = exports.storage.get(feedName);
27 | var config = exports.storage.getConfig(feedName);
28 | var output = new RSS({
29 | title: feedName,
30 | generator: 'rssify',
31 | feed_url: host + '/' + feedName,
32 | site_url: config.url,
33 | ttl: config.interval
34 | });
35 | for (let entry of feed.reverse()) {
36 | output.item(entry);
37 | }
38 | res.writeHead(200, {
39 | 'Content-Type': 'application/xml; charset=utf-8',
40 | 'Cache-Control': 'public; max-age=' + config.interval * 60,
41 | 'Date': new Date().toUTCString()
42 | });
43 | res.end(output.xml());
44 | }).listen(port, () => {
45 | exports.debug && console.log('Server is listening on port ' + port);
46 | });
47 |
48 | };
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "global": {
3 | "interval": 60,
4 | "size": 20,
5 | "debug": true,
6 | "storage": "file",
7 | "host": "localhost:10001",
8 | "port": "10001",
9 | "fields": [
10 | {
11 | "field": "categories",
12 | "content": "comics"
13 | }
14 | ]
15 | },
16 | "example1": {
17 | "url": "http://www.example1.com/",
18 | "fields": [
19 | {
20 | "field": "description",
21 | "selector": "#content img",
22 | "attr": "src",
23 | "format": "
"
24 | },
25 | {
26 | "field": "title",
27 | "selector": "#content img",
28 | "attr": "title"
29 | }
30 | ]
31 | },
32 | "example2": {
33 | "url": "http://example2.com/",
34 | "cooldown": 1440,
35 | "fields": [
36 | {
37 | "field": "description",
38 | "selector": "#comic > a > img",
39 | "attr": "src",
40 | "format": "
"
41 | },
42 | {
43 | "field": "title",
44 | "selector": "#comic > a > img",
45 | "attr": "alt"
46 | }
47 | ]
48 | },
49 | "example3": {
50 | "url": "http://my.example3.com",
51 | "validate": "description",
52 | "fields": [
53 | {
54 | "field": "description",
55 | "selector": ".feature_item .strip",
56 | "attr": "src",
57 | "format": "
"
58 | },
59 | {
60 | "field": "title",
61 | "evaluate": "return new Date().toDateString();"
62 | }
63 | ]
64 | }
65 | }
--------------------------------------------------------------------------------
/lib/main.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var path = require('path');
4 |
5 | var server = require('./server');
6 | var rss = require('./rss');
7 |
8 |
9 | /**
10 | * A list of global option properties that should not be applied to each entry.
11 | * @type {string[]}
12 | */
13 | exports.nonEntryOptions = ['debug', 'storage', 'port', 'host', 'path'];
14 |
15 | /**
16 | * Applies global options to each individual config.
17 | * @param config
18 | * @returns {*}
19 | */
20 | exports.applyGlobals = config => {
21 | if (config.global) {
22 | for (let prop of exports.nonEntryOptions) {
23 | if (config.global[prop] !== undefined) {
24 | exports[prop] = config.global[prop];
25 | delete config.global[prop];
26 | }
27 | }
28 | for (let feed in config) {
29 | if (feed != 'global') {
30 | for (let prop in config.global) {
31 | if (!config[feed][prop]) {
32 | config[feed][prop] = config.global[prop];
33 | } else if (Array.isArray(config[feed][prop])) {
34 | config[feed][prop] = config[feed][prop].concat(config.global[prop]);
35 | }
36 | }
37 | }
38 | }
39 | delete config.global;
40 | }
41 | };
42 |
43 |
44 | exports.initialize = config => {
45 | exports.applyGlobals(config);
46 | server.debug = rss.debug = exports.debug;
47 | exports.storage = server.storage = rss.storage = require('./storage.' + exports.storage);
48 | exports.storage.path = exports.path || path.join(__dirname, '../feeds');
49 | exports.storage.updateConfig(config);
50 | rss.cron();
51 | server.initialize(exports.host || 'http://localhost:10001', exports.port || 10001)
52 | };
--------------------------------------------------------------------------------
/lib/storage.mem.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | var storage = exports.storage = {};
4 |
5 |
6 | /**
7 | * Returns a list of feed names that have a configuration.
8 | */
9 | exports.list = () => {
10 | return Object.keys(storage);
11 | };
12 |
13 | /**
14 | * Returns the entire list of entries for a feed.
15 | * @param feedName
16 | */
17 | exports.get = feedName => {
18 | var feed = storage[feedName] = storage[feedName] || {entries: [], config: {}};
19 | return feed.entries;
20 | };
21 |
22 | /**
23 | * Returns the last known entry for a specific feed.
24 | * @param feedName
25 | */
26 | exports.last = feedName => {
27 | var size = storage[feedName] ? storage[feedName].entries.length : 0;
28 | return size ? storage[feedName].entries[size - 1] : null;
29 | };
30 |
31 | /**
32 | * Appends an entry to a given feed. If the feed entries are more then what's set in the config, the oldest element
33 | * is removed.
34 | * @param feedName
35 | * @param entry
36 | * @param config
37 | */
38 | exports.append = (feedName, entry, config) => {
39 | var feed = storage[feedName] = storage[feedName] || {entries:[], config: {}};
40 | feed.config = config;
41 | feed.entries.push(entry);
42 | var size = feed.config.size || 20;
43 | if (feed.entries.length > size) {
44 | feed.entries.shift();
45 | }
46 | };
47 |
48 | /**
49 | * Sets any configuration values (such as headers) if they don't exist already.
50 | * @param config
51 | */
52 | exports.updateConfig = config => {
53 | for (let feedName in config) {
54 | storage[feedName] = storage[feedName] || {entries: [], config: {}}
55 | for (let prop in config[feedName]) {
56 | storage[feedName].config[prop] = config[feedName][prop];
57 | }
58 | }
59 | };
60 |
61 | /**
62 | * Returns the configuration of a given feed.
63 | * @param feedName
64 | */
65 | exports.getConfig = feedName => {
66 | var feed = storage[feedName] = storage[feedName] || {entries: [], config: {}};
67 | return feed.config;
68 | };
69 |
70 | /**
71 | * clear all stored data.
72 | */
73 | exports.reset = () => {
74 | storage = {};
75 | };
--------------------------------------------------------------------------------
/test/storage.mem.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var expect = require('chai').expect;
4 |
5 | var storage = require('../lib/storage.mem');
6 |
7 | describe('storage.mem.js', () => {
8 | afterEach(() => {
9 | storage.reset();
10 | });
11 |
12 | describe("#append()", () => {
13 | it('should append an item to the storage', () => {
14 | storage.append('testFeed', {title: 'test'}, { prop: 'value' });
15 | expect(storage.last('testFeed')).to.deep.equal({title:'test'});
16 | });
17 |
18 | it('should append another item and still deliver only the last one', () => {
19 | storage.append('testFeed', {title: 'test'}, {prop: 'value'});
20 | storage.append('testFeed', {title: 'test2'}, {prop: 'value'});
21 | expect(storage.last('testFeed')).to.deep.equal({title: 'test2'});
22 | });
23 | });
24 |
25 | describe("#updateConfig()", () => {
26 | it('should update all existing configurations', () => {
27 | storage.updateConfig({
28 | testFeed: {
29 | prop1: 'test2'
30 | },
31 | testFeed2: {
32 | prop2: 'test3'
33 | }
34 | });
35 | expect(storage.getConfig('testFeed')).to.deep.equal({prop1: 'test2'});
36 | expect(storage.getConfig('testFeed2')).to.deep.equal({prop2: 'test3'});
37 | });
38 | });
39 |
40 | describe("#list()", () => {
41 | it('should return a list of all known feeds', () => {
42 | storage.append('testFeed1', {title: 'test1'}, {prop: 'value1'});
43 | storage.append('testFeed2', {title: 'test2'}, {prop: 'value1'});
44 | storage.append('testFeed3', {title: 'test3'}, {prop: 'value1'});
45 | var feeds = storage.list();
46 | expect(feeds).to.include('testFeed1');
47 | expect(feeds).to.include('testFeed2');
48 | expect(feeds).to.include('testFeed3');
49 | });
50 | });
51 |
52 | describe("#get()", () => {
53 | it('should return a list of items in the same order', () => {
54 | storage.append('testFeed', {title: 'test1'}, {prop: 'value1'});
55 | storage.append('testFeed', {title: 'test2'}, {prop: 'value1'});
56 | storage.append('testFeed', {title: 'test3'}, {prop: 'value1'});
57 | var feeds = storage.get('testFeed');
58 | expect(feeds[0]).to.deep.equal({title: 'test1'});
59 | expect(feeds[1]).to.deep.equal({title: 'test2'});
60 | expect(feeds[2]).to.deep.equal({title: 'test3'});
61 | });
62 | });
63 | });
--------------------------------------------------------------------------------
/lib/storage.file.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | var fs = require('fs');
4 | var path = require('path');
5 |
6 | /**
7 | * In memory buffer so we don't have to go to disk every time we want data
8 | * @type {{}}
9 | */
10 | var storage = {};
11 |
12 | function getFeed(feedName) {
13 | if (!storage[feedName]) {
14 | try {
15 | storage[feedName] = JSON.parse(fs.readFileSync(path.join(exports.path, feedName), 'utf8'));
16 | } catch (e) {
17 | storeFeed(feedName, {entries: [], config: {}});
18 | }
19 | }
20 | return storage[feedName];
21 | }
22 |
23 | function storeFeed(feedName, feed) {
24 | storage[feedName] = feed;
25 | try {
26 | fs.mkdirSync(exports.path);
27 | } catch (e) {}
28 | fs.writeFileSync(path.join(exports.path, feedName), JSON.stringify(feed), 'utf8');
29 | }
30 |
31 | /**
32 | * Returns a list of feed names that have a configuration.
33 | */
34 | exports.list = () => {
35 | return Object.keys(storage);
36 | };
37 |
38 | /**
39 | * Returns the entire list of entries for a feed.
40 | * @param feedName
41 | */
42 | exports.get = feedName => {
43 | return getFeed(feedName).entries;
44 | };
45 |
46 | /**
47 | * Returns the last known entry for a specific feed.
48 | * @param feedName
49 | */
50 | exports.last = feedName => {
51 | var feed = getFeed(feedName);
52 | var size = feed.entries.length;
53 | return size ? feed.entries[size - 1] : null;
54 | };
55 |
56 | /**
57 | * Appends an entry to a given feed. If the feed entries are more then what's set in the config, the oldest element
58 | * is removed.
59 | * @param feedName
60 | * @param entry
61 | * @param config
62 | */
63 | exports.append = (feedName, entry, config) => {
64 | var feed = getFeed(feedName);
65 | feed.config = config;
66 | feed.entries.push(entry);
67 | var size = feed.config.size || 20;
68 | if (feed.entries.length > size) {
69 | feed.entries.shift();
70 | }
71 | storeFeed(feedName, feed);
72 | };
73 |
74 | /**
75 | * Sets any configuration values (such as headers) if they don't exist already.
76 | * @param config
77 | */
78 | exports.updateConfig = config => {
79 | for (let feedName in config) {
80 | var feed = getFeed(feedName)
81 | for (let prop in config[feedName]) {
82 | feed.config[prop] = config[feedName][prop];
83 | }
84 | storeFeed(feedName, feed);
85 | }
86 | };
87 |
88 | /**
89 | * Returns the configuration of a given feed.
90 | * @param feedName
91 | */
92 | exports.getConfig = feedName => {
93 | return getFeed(feedName).config;
94 | };
95 |
96 | /**
97 | * clear all stored data.
98 | */
99 | exports.reset = () => {
100 | storage = {};
101 | try {
102 | var files = fs.readdirSync(exports.path);
103 | files.forEach(file => {
104 | var curPath = path.join(exports.path, file);
105 | if (!fs.lstatSync(curPath).isDirectory()) {
106 | fs.unlinkSync(curPath);
107 | }
108 | });
109 | fs.rmdirSync(exports.path);
110 | } catch(e) {}
111 | };
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # rssify
2 | [](http://badge.fury.io/js/rssify)
3 | [](https://travis-ci.org/mallocator/rssify)
4 | [](https://coveralls.io/github/mallocator/rssify?branch=master)
5 | [](https://david-dm.org/mallocator/rssify)
6 |
7 | Turns web pages into rss feeds
8 |
9 | ## About
10 |
11 | After the shutdown of Yahoo pipes and the Kimono Labs I got tired of finding yet another rss scraper online. Instead
12 | I figured I can write a server of my own in a weekend and host it on my own instance.
13 |
14 | ## Features
15 |
16 | This sever is really pretty simple. Grab some elements of a website, compare to what you got before and then server
17 | that content as rss feed:
18 |
19 | * Crawl multiple websites in parallel
20 | * Configure using json file
21 | * Use CSS selectors from cheerio/jquery
22 | * Run additional formatting/javascript to modify the results
23 | * Persist feed data either on the file system or keep it in memory as long as the server runs
24 |
25 | ## Installation
26 |
27 | ```npm install rssify```
28 |
29 | ## Daemonizing
30 |
31 | If you want to have the server run as a daemon I recommend using a 3rd party tool such as
32 | [initd-forever](https://github.com/92bondstreet/initd-forever)
33 |
34 | ## Running
35 |
36 | With node installed just go to the project folder and run ```node .```. If you installed the library globally you should
37 | have a new executable available called ```rssify```.
38 |
39 | There's only one (optional) argument the script accepts and that is the location of config file. Where ever you decide
40 | to put your config, make sure it ends with .json, otherwise the program will not know how to parse it.
41 |
42 | ## Configuration
43 |
44 | The configuration is stored in a file called config.json in the project directory and consists of 2 basic elements.
45 | The global element holds certain properties that are used by the environment, which are:
46 |
47 | ```debug``` Wether you want the server to print a few message or want it to shut up
48 | ```storage``` Right now supports "file" or "meme"
49 | ```path``` Used with file storage, configures the location on disk (default = /feeds)
50 | ```port``` The port the server is going to listen on for incoming requests
51 | ```host``` The hostname used when generating the rss feed, that a reader can link back to (defaults to http://localhost:10001)
52 |
53 | Other elements of the global config will be applied to each of the feed configs.
54 |
55 | Feed configs are defined by their feed name as property and the configuration object:
56 |
57 | ```url``` The address where the server should check for updates
58 | ```interval``` The interval in minutes between crawling a web page again
59 | ```cooldown``` How long to wait (in minutes) after a new entry has been found until we look again
60 | ```size``` The maximum number of items that are reporting on this feed
61 | ```validate``` An array of field names that will be checked to determine if content has changed/updated
62 | ```fields``` An array of field configurations. See below for more info.
63 |
64 | Fields are mapped directly to the rss item properties. The fields are used to define where to grab content from and
65 | potentially transform it:
66 |
67 | ```field``` Name of the field as it will appear in the rss feed
68 | ```selector``` a cheerio/jquery selector. If multiple elements are selected, they will be concatenated.
69 | ```attr``` The attribute of the selected element to use ("text" and "html" are special values that will return the
70 | content). This field is only evaluated if a selector has been set.
71 | ```format``` A standard util.format string that gets the content from selected + attribute passed in as string. Will
72 | only be evaluated if a selector has been set and is applied to each individual element if a selector returns more than
73 | one
74 | ```evaluate``` A javascript string that's passed in to eval(). The concatenated content (if any) is available for
75 | manipulation, but really any javascript can be used to return content.
76 | ```content``` Used to set a static string as content. If any of the other methods produces a string, this value will be
77 | overwritten.
78 |
79 | To see an example just take a look at the [config](./config.json).
80 |
81 |
82 | ## Getting Feedly to work
83 |
84 | Feedly needs some extra love to understand this feed. To help it along the way you can use
85 | [feedburner](https://feedburner.google.com/) to host a compatible version.
--------------------------------------------------------------------------------
/test/storage.file.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var fs = require('fs');
4 | var path = require('path');
5 |
6 | var expect = require('chai').expect;
7 |
8 | var storage = require('../lib/storage.file');
9 |
10 |
11 | storage.path = path.join(__dirname, '../feeds');
12 |
13 | function getFile(feedname) {
14 | try {
15 | return JSON.parse(fs.readFileSync(path.join(storage.path, feedname), 'utf8'));
16 | } catch (e) {
17 | return null;
18 | }
19 | }
20 |
21 | describe('storage.file.js', () => {
22 | afterEach(() => {
23 | storage.reset();
24 | });
25 |
26 | describe("#append()", () => {
27 | it('should append an item to the storage', () => {
28 | storage.append('testFeed', {title: 'test'}, {prop: 'value'});
29 | expect(storage.last('testFeed')).to.deep.equal({title: 'test'});
30 | var file = getFile('testFeed');
31 | expect(file.config).to.deep.equal({prop: 'value'});
32 | expect(file.entries).to.deep.equal([{title: 'test'}]);
33 | });
34 |
35 | it('should append another item and still deliver only the last one', () => {
36 | storage.append('testFeed', {title: 'test'}, {prop: 'value'});
37 | storage.append('testFeed', {title: 'test2'}, {prop: 'value'});
38 | expect(storage.last('testFeed')).to.deep.equal({title: 'test2'});
39 | var file = getFile('testFeed');
40 | expect(file.config).to.deep.equal({prop: 'value'});
41 | expect(file.entries).to.deep.equal([{title: 'test'}, {title: 'test2'}]);
42 | });
43 | });
44 |
45 | describe("#updateConfig()", () => {
46 | it('should update all existing configurations', () => {
47 | storage.updateConfig({
48 | testFeed: {
49 | prop1: 'test2'
50 | },
51 | testFeed2: {
52 | prop2: 'test3'
53 | }
54 | });
55 | expect(storage.getConfig('testFeed')).to.deep.equal({prop1: 'test2'});
56 | expect(storage.getConfig('testFeed2')).to.deep.equal({prop2: 'test3'});
57 | expect(getFile('testFeed').config).to.deep.equal({prop1: 'test2'});
58 | expect(getFile('testFeed2').config).to.deep.equal({prop2: 'test3'});
59 | });
60 | });
61 |
62 | describe("#list()", () => {
63 | it('should return a list of all known feeds', () => {
64 | storage.append('testFeed1', {title: 'test1'}, {prop: 'value1'});
65 | storage.append('testFeed2', {title: 'test2'}, {prop: 'value1'});
66 | storage.append('testFeed3', {title: 'test3'}, {prop: 'value1'});
67 | var feeds = storage.list();
68 | expect(feeds).to.include('testFeed1');
69 | expect(feeds).to.include('testFeed2');
70 | expect(feeds).to.include('testFeed3');
71 | expect(getFile('testFeed1')).to.deep.equal({entries: [{title: 'test1'}], config: {prop: 'value1'}});
72 | expect(getFile('testFeed2')).to.deep.equal({entries: [{title: 'test2'}], config: {prop: 'value1'}});
73 | expect(getFile('testFeed3')).to.deep.equal({entries: [{title: 'test3'}], config: {prop: 'value1'}});
74 | });
75 | });
76 |
77 | describe("#get()", () => {
78 | it('should return a list of items in the same order', () => {
79 | storage.append('testFeed', {title: 'test1'}, {prop: 'value1'});
80 | storage.append('testFeed', {title: 'test2'}, {prop: 'value1'});
81 | storage.append('testFeed', {title: 'test3'}, {prop: 'value1'});
82 | var feeds = storage.get('testFeed');
83 | expect(feeds[0]).to.deep.equal({title: 'test1'});
84 | expect(feeds[1]).to.deep.equal({title: 'test2'});
85 | expect(feeds[2]).to.deep.equal({title: 'test3'});
86 | var file = getFile('testFeed');
87 | expect(file.config).to.deep.equal({prop: 'value1'});
88 | expect(file.entries).to.deep.equal([{title: 'test1'}, {title: 'test2'}, {title: 'test3'}]);
89 | });
90 |
91 | it('should return a list of items of a previously saved file', () => {
92 | var testFeed = {
93 | entries: [{title: 'test1'}, {title: 'test2'}, {title: 'test3'}],
94 | config: {prop: 'value1'}
95 | };
96 | fs.mkdirSync(storage.path);
97 | fs.writeFileSync(path.join(storage.path, 'testFeed'), JSON.stringify(testFeed), 'utf8');
98 | var feeds = storage.get('testFeed');
99 | expect(feeds[0]).to.deep.equal({title: 'test1'});
100 | expect(feeds[1]).to.deep.equal({title: 'test2'});
101 | expect(feeds[2]).to.deep.equal({title: 'test3'});
102 | });
103 | });
104 | });
--------------------------------------------------------------------------------
/lib/rss.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var http = require('http');
4 | var url = require('url');
5 | var util = require('util');
6 |
7 | var cheerio = require('cheerio');
8 |
9 |
10 | /**
11 | * A list of headers that should be checked for modification before doing the actual get request of a page.
12 | * @type {string[]}
13 | */
14 | exports.headers = ['last-modified', 'etag'];
15 | /**
16 | * Enable or disable debug messages (set through global config object).
17 | * @type {boolean}
18 | */
19 | exports.debug = false;
20 | /**
21 | * The storage implementation used to store feed data and configuration.
22 | * @type {Object}
23 | */
24 | exports.storage = null;
25 |
26 | /**
27 | * Check if the content is still the same as last time. If a header matches a previous on this function stops
28 | * immediately and returns. If no matching header is found, the current values are set on the config object.
29 | * @param feedConfig
30 | * @param cb
31 | */
32 | exports.checkHeader = (feedConfig, cb) => {
33 | if (feedConfig.headers && !Object.keys(feedConfig.headers).length) {
34 | exports.debug && console.log('Skipping header check since there were none received before');
35 | return cb(false);
36 | }
37 | var options = url.parse(feedConfig.url);
38 | options.method = 'HEAD';
39 | feedConfig.headers = feedConfig.headers || {};
40 | http.request(options, function (res) {
41 | for (let prop of exports.headers) {
42 | if (res.headers[prop]) {
43 | if (feedConfig.headers[prop] == res.headers[prop]) {
44 | return cb(true);
45 | } else {
46 | feedConfig.headers[prop] = res.headers[prop];
47 | }
48 | }
49 | }
50 | cb(false);
51 | }).end();
52 | };
53 |
54 | /**
55 | * Checks if content is still the same based on the content. This setting is configurable per feed.
56 | * @param feedConfig
57 | * @param previous
58 | * @param current
59 | * @returns {boolean}
60 | */
61 | exports.checkContent = (feedConfig, previous, current) => {
62 | if (!previous) {
63 | return false;
64 | }
65 | if (!feedConfig.validate) {
66 | return current.title == previous.title && current.description == previous.description;
67 | }
68 | if (!util.isArray(feedConfig.validate)) {
69 | feedConfig.validate = [ feedConfig.validate ];
70 | }
71 | var hasProp = false;
72 | for (let property of feedConfig.validate) {
73 | hasProp = hasProp || previous[property] ? true : false;
74 | if (previous[property] != current[property]) {
75 | return false;
76 | }
77 | }
78 | return hasProp;
79 | };
80 |
81 | /**
82 | * Checks a source for updates.
83 | * @param feedConfig
84 | * @param cb
85 | */
86 | exports.update = (feedConfig, cb) => {
87 | exports.checkHeader(feedConfig, same => {
88 | if (same) {
89 | return cb('Content header unchanged, no update needed.')
90 | }
91 | http.get(feedConfig.url, res => {
92 | var data = '';
93 | res.on('data', chunk => {
94 | data += chunk;
95 | });
96 | res.on('end', () => {
97 | var now = new Date();
98 | var result = {
99 | url: feedConfig.url,
100 | author: 'rssify',
101 | title: now.toDateString(),
102 | pubDate: now,
103 | guid: now.getTime()
104 | };
105 | var $ = cheerio.load(data);
106 | for (let fieldConfig of feedConfig.fields) {
107 | var content = result[fieldConfig.field] || fieldConfig.content || '';
108 | if (fieldConfig.selector) {
109 | var elements = $(fieldConfig.selector);
110 | elements.each(function () {
111 | var elementContent;
112 | if (fieldConfig.attr) {
113 | switch (fieldConfig.attr) {
114 | case 'html':
115 | elementContent = $(this).html();
116 | break;
117 | case 'text':
118 | elementContent = $(this).text();
119 | break;
120 | default:
121 | elementContent = $(this).attr(fieldConfig.attr);
122 | }
123 | }
124 | if (fieldConfig.format) {
125 | elementContent = util.format(fieldConfig.format, elementContent);
126 | }
127 | content += elementContent;
128 | });
129 | }
130 | if (fieldConfig.evaluate) {
131 | eval('content = (function() {' + fieldConfig.evaluate + '})()');
132 | }
133 | result[fieldConfig.field] = content
134 | }
135 | // Fix rss lib using categories string array instead of category property
136 | if (result.categories && !Array.isArray(result.categories)) {
137 | result.categories = result.categories.split(/,:;/);
138 | }
139 | if (result.category) {
140 | result.categories = result.categories || [];
141 | result.categories.push(result.category);
142 | delete result.category;
143 | }
144 | // Fix rss lib option called url, not link
145 | if (result.link && !result.url) {
146 | result.url = result.link;
147 | delete result.link;
148 | }
149 | // Fix rss lib using date instead of pubDate
150 | if (result.pubDate && !result.date) {
151 | result.date = result.pubDate;
152 | delete result.pubDate;
153 | }
154 | cb(null, result);
155 | });
156 | });
157 | });
158 | };
159 |
160 | /**
161 | * Go through the configuration and run each one.
162 | * @param feedName
163 | */
164 | exports.process = feedName => {
165 | exports.debug && console.log('Processing feed "' + feedName + '"');
166 | var feedConfig = exports.storage.getConfig(feedName);
167 | if (feedConfig.cooldown
168 | && feedConfig.lastUpdate
169 | && feedConfig.lastUpdate + feedConfig.cooldown * 60000 > Date.now()) {
170 | return;
171 | }
172 | exports.update(feedConfig, (err, entry) => {
173 | if (err) {
174 | return exports.debug && console.log(err);
175 | }
176 | feedConfig.lastUpdate = Date.now();
177 | var last = exports.storage.last(feedName);
178 | if(exports.checkContent(feedConfig, last, entry)) {
179 | return exports.debug && console.log('Feed content is still the same, no need to update');
180 | }
181 | exports.debug && console.log('Received updated entry from ' + feedName);
182 | exports.storage.append(feedName, entry, feedConfig);
183 | });
184 | };
185 |
186 | /**
187 | * Sets up to run the process call on each feed with whatever interval was set.
188 | */
189 | exports.cron = () => {
190 | var feeds = exports.storage.list();
191 | for (let feed of feeds) {
192 | var config = exports.storage.getConfig(feed);
193 | setInterval(exports.process, config.interval * 60000, feed);
194 | exports.process(feed);
195 | }
196 | };
--------------------------------------------------------------------------------
/test/rss.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var events = require('events');
4 | var fs = require('fs');
5 | var http = require('http');
6 | var path = require('path');
7 |
8 | var expect = require('chai').expect;
9 | var gently = new (require('gently'));
10 |
11 | var rss = require('../lib/rss');
12 |
13 | describe('rss.js', () => {
14 | rss.storage = {};
15 |
16 | describe('#checkHeader()', () => {
17 | before(() => {
18 | gently.expect(http, 'request', 4, (options, cb) => {
19 | cb({
20 | headers: {
21 | etag: '456def',
22 | 'last-modified': "Today"
23 | }
24 | });
25 | return {
26 | end: () => {}
27 | };
28 | });
29 | });
30 | after(() => gently.verify());
31 |
32 | it('should return true for a matching "etag" header', (done) => {
33 | var config = {
34 | url: 'localhost',
35 | headers: {
36 | etag: '456def'
37 | }
38 | };
39 | rss.checkHeader(config, result => {
40 | expect(result).to.be.true;
41 | done();
42 | });
43 | });
44 |
45 | it('should return true for a matching "last-modified" header', (done) => {
46 | var config = {
47 | url: 'localhost',
48 | headers: {
49 | 'last-modified': "Today"
50 | }
51 | };
52 | rss.checkHeader(config, result => {
53 | expect(result).to.be.true;
54 | done();
55 | });
56 | });
57 |
58 | it('should return false for a mismatching "etag" header', (done) => {
59 | var config = {
60 | url: 'localhost',
61 | headers: {
62 | etag: '123abc'
63 | }
64 | };
65 | rss.checkHeader(config, result => {
66 | expect(result).to.be.false;
67 | expect(config.headers.etag).to.equal('456def');
68 | done();
69 | });
70 | });
71 |
72 | it('should return set the header if there is non', (done) => {
73 | var config = {
74 | url: 'localhost'
75 | };
76 | rss.checkHeader(config, result => {
77 | expect(result).to.be.false;
78 | expect(config.headers.etag).to.equal('456def');
79 | done();
80 | });
81 | });
82 |
83 | it('should return false for an empty header', (done) => {
84 | var config = {
85 | url: 'localhost',
86 | headers: {}
87 | };
88 | rss.checkHeader(config, result => {
89 | expect(result).to.be.false;
90 | done();
91 | });
92 | });
93 | });
94 |
95 | describe('#checkContent()', () => {
96 | it('should return false if there is no previous feed', () => {
97 | var result = rss.checkContent(null, null, null);
98 | expect(result).to.be.false;
99 | });
100 |
101 | it('should return false if none of the validation fields exist', () => {
102 | var result = rss.checkContent({
103 | validate: 'prop'
104 | }, {
105 | other: 'value'
106 | }, {
107 | other: 'value'
108 | });
109 | expect(result).to.be.false;
110 | });
111 |
112 | it('should return false if the properties don\'t match', () => {
113 | var result = rss.checkContent({
114 | validate: 'prop'
115 | }, {
116 | prop: 'value'
117 | }, {
118 | other: 'value'
119 | });
120 | expect(result).to.be.false;
121 |
122 | var result = rss.checkContent({
123 | validate: 'prop'
124 | }, {
125 | prop: 'value'
126 | }, {
127 | prop: 'other'
128 | });
129 | expect(result).to.be.false;
130 | });
131 |
132 | it('should return true if one property matches', () => {
133 | var result = rss.checkContent({
134 | validate: 'prop'
135 | }, {
136 | prop: 'value'
137 | }, {
138 | prop: 'value'
139 | });
140 | expect(result).to.be.true;
141 | });
142 |
143 | it('should return true if all multiple properties match', () => {
144 | var result = rss.checkContent({
145 | validate: [ 'prop1', 'prop2' ]
146 | }, {
147 | prop1: 'value1',
148 | prop2: 'value2'
149 | }, {
150 | prop1: 'value1',
151 | prop2: 'value2'
152 | });
153 | expect(result).to.be.true;
154 | });
155 |
156 | it('should return false if one of multiple properties don\'t match', () => {
157 | var result = rss.checkContent({
158 | validate: ['prop1', 'prop2']
159 | }, {
160 | prop1: 'value1',
161 | prop2: 'value2'
162 | }, {
163 | prop1: 'value1',
164 | prop2: 'other'
165 | });
166 | expect(result).to.be.false;
167 | });
168 | });
169 |
170 | describe('#update()', () => {
171 | beforeEach(() => {
172 | gently.expect(rss, 'checkHeader', (config, cb) => {
173 | cb(false);
174 | });
175 |
176 | gently.expect(http, 'get', (url, cb) => {
177 | var res = new events.EventEmitter();
178 | cb(res);
179 | res.emit('data', fs.readFileSync(path.join(__dirname, 'sample.html')));
180 | res.emit('end');
181 | });
182 | });
183 | afterEach(() => gently.verify());
184 |
185 | it('should process feed with constant content', done => {
186 | var config = {
187 | url: 'test.url',
188 | fields: [{
189 | field: 'testField',
190 | content: 'testContent'
191 | }]
192 | };
193 |
194 | rss.update(config, (err, result) => {
195 | expect(result.url).to.equal('test.url');
196 | expect(result.testField).to.equal('testContent');
197 | expect(result.date).to.be.instanceof(Date);
198 | done();
199 | })
200 | });
201 |
202 | it('should process feed while extracting data from the html into a specific format', done => {
203 | var config = {
204 | url: 'test.url',
205 | fields: [{
206 | field: 'testField',
207 | selector: 'div > img',
208 | attr: 'src',
209 | format: '
'
210 | }]
211 | };
212 |
213 | rss.update(config, (err, result) => {
214 | expect(result.url).to.equal('test.url');
215 | expect(result.testField).to.equal('
');
216 | expect(result.date).to.be.instanceof(Date);
217 | done();
218 | })
219 | });
220 |
221 | it('should process feed while evaluating a script as content', done => {
222 | var config = {
223 | url: 'test.url',
224 | fields: [{
225 | field: 'testField',
226 | evaluate: 'return "testData"'
227 | }]
228 | };
229 |
230 | rss.update(config, (err, result) => {
231 | expect(result.url).to.equal('test.url');
232 | expect(result.testField).to.equal('testData');
233 | expect(result.date).to.be.instanceof(Date);
234 | done();
235 | })
236 | });
237 | });
238 |
239 | describe('#process()', () => {
240 | it('should not process if cooldown is still active', () => {
241 | gently.expect(rss.storage, 'getConfig', feedName => {
242 | return {
243 | lastUpdate: Date.now(),
244 | cooldown: 60
245 | }
246 | });
247 |
248 | var result = rss.process('testFeed');
249 | expect(result).to.be.undefined;
250 | gently.verify()
251 | });
252 |
253 | it('should process an update and store it', () => {
254 | gently.expect(rss.storage, 'getConfig', feedName => { return {
255 | name: feedName
256 | }});
257 |
258 | gently.expect(rss, 'update', (feedConfig, cb) => cb(null, {
259 | title: 'testEntry'
260 | }));
261 |
262 | gently.expect(rss.storage, 'last', () => { return {
263 | title: 'previousEntry'
264 | }});
265 |
266 | gently.expect(rss, 'checkContent', () => false);
267 |
268 | gently.expect(rss.storage, 'append', (feedName, entry, feedConfig) => {
269 | expect(feedName).to.equal('testFeed');
270 | expect(entry.title).to.equal('testEntry');
271 | expect(feedConfig.name).to.equal('testFeed');
272 | });
273 |
274 | var result = rss.process('testFeed');
275 | expect(result).to.be.undefined;
276 | gently.verify()
277 | });
278 | });
279 |
280 | describe('#cron()', () => {
281 |
282 | });
283 | });
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright {yyyy} {name of copyright owner}
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------