├── .gitignore ├── package.json ├── index.html ├── LICENSE ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | 4 | node_modules 5 | config.js -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gh-feed", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "start": "node index.js" 6 | }, 7 | "engines": { 8 | "node": "7.x" 9 | }, 10 | "dependencies": { 11 | "co-fs": "^1.2.0", 12 | "co-request": "^1.0.0", 13 | "koa": "^1.2.4", 14 | "koa-compress": "^1.0.9", 15 | "koa-router": "^5.4.0", 16 | "marked": "^0.3.6", 17 | "rss": "^1.2.1", 18 | "string": "^3.3.3", 19 | "xmldom": "0.1.22" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | gh-feed 6 | 7 | 8 |

gh-feed

9 |

Generate RSS feed from GitHub Issues

10 | Fork me on GitHub 11 |

Paste the URL of project's issues page below:

12 | 13 |

Tip: You can customize your feed by adding filters on GitHub.

14 |

Your feed address:

15 |
16 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Shiquan Sun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | gh-feed 2 | ======= 3 | 4 | Generate RSS feed from GitHub Issues. 5 | 6 | Check out [gh-feed.imsun.net](http://gh-feed.imsun.net) 7 | 8 | [中文简介](http://imsun.net/posts/gh-feed) 9 | 10 | ## Why 11 | 12 | Some engineers take GitHub Issues as blogs. It's easy to use, supporting Markdown, Git, code highlighting, comments, notifications, and lots of fancy features. But there isn't a feed address for it. So I write this project. 13 | 14 | ## Usage 15 | 16 | 1. Open project's issues page on GitHub. 17 | 1. Paste its URL to the input field on gh-feed's index page, or just replace `https://github.com` with `http://gh-feed.imsun.net` 18 | 1. Get your feed address. 19 | 20 | To customize your feed, you can add filters on issues page. 21 | 22 | ## To Run 23 | 24 | ### 1. Set Your GitHub Token (optional) 25 | 26 | By default gh-feed uses GitHub API, but rate of requests is [limited by GitHub](https://developer.github.com/v3/#rate-limiting). 27 | 28 | > For requests using Basic Authentication or OAuth, you can make up to 5,000 requests per hour. For unauthenticated requests, the rate limit allows you to make up to 60 requests per hour. Unauthenticated requests are associated with your IP address, and not the user making requests. 29 | 30 | For higher rate, you need to create `config.js` as follows: 31 | 32 | ``` 33 | module.exports = { 34 | token: 'Your GitHub token' 35 | } 36 | ``` 37 | 38 | You can use [personal access token](https://github.com/settings/tokens) or [register an application](https://github.com/settings/developers) and [generate a token](https://developer.github.com/v3/oauth_authorizations/#create-a-new-authorization) for it. 39 | 40 | Once you runs out your requests, gh-feed will **load the full issues page** to generate feed, which costs much more than using GitHub API. 41 | 42 | ### 2. Run It 43 | 44 | ``` 45 | npm install && npm start 46 | ``` 47 | 48 | ## License 49 | 50 | MIT -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const app = require('koa')() 2 | const router = require('koa-router')() 3 | const compress = require('koa-compress') 4 | 5 | const request = require('co-request') 6 | const { readFile } = require('co-fs') 7 | const S = require('string') 8 | const marked = require('marked') 9 | const RSS = require('rss') 10 | const { DOMParser, XMLSerializer } = require('xmldom') 11 | const parser = new DOMParser({ 12 | errorHandler: {} 13 | }) 14 | const serlializer = new XMLSerializer() 15 | 16 | const config = { token: '' } 17 | try { 18 | Object.assign(config, require('./config')) 19 | } catch (e) {} 20 | if (process.env.GH_TOKEN) { 21 | Object.assign(config, { 22 | token: process.env.GH_TOKEN 23 | }) 24 | } 25 | 26 | const headers = { 27 | 'User-Agent': 'gh-feed', 28 | 'Accept': 'application/vnd.github.v3+json' 29 | } 30 | if (config.token) { 31 | headers.Authorization = `token ${config.token}` 32 | } 33 | 34 | const hotFeeds = {} 35 | 36 | router 37 | .get('/', function *() { 38 | const page = yield readFile('./index.html', 'utf8') 39 | this.set('Content-Type', 'text/html; charset=utf-8') 40 | this.body = page 41 | }) 42 | .get('/hot', function *() { 43 | this.set('Content-Type', 'text/html; charset=utf-8') 44 | this.body = `

Hotest

    ${ 45 | Object.keys(hotFeeds) 46 | .map(title => ({ 47 | title, 48 | count: hotFeeds[title] 49 | })) 50 | .sort((a, b) => a.count < b.count) 51 | .map(feed => `
  1. ${feed.title} (${feed.count})
  2. `) 52 | .join('') 53 | }
` 54 | }) 55 | .get('/:owner/:repo/*', genFeed) 56 | 57 | app.use(compress()) 58 | app.use(router.routes()) 59 | 60 | function *genFeed() { 61 | console.log(`URL: ${this.url}`) 62 | console.log(`Date: ${(new Date()).toGMTString()}`) 63 | const host = 'https://api.github.com' 64 | const { owner, repo } = this.params 65 | const filter = {} 66 | const [, filterKey, filterValue ] = this.params[0].split('/') 67 | switch (filterKey) { 68 | case 'created_by': 69 | filter.creator = filterValue 70 | break 71 | case 'assigned': 72 | filter.assignee = filterValue 73 | break 74 | } 75 | 76 | const queryString = this.query.q || '' 77 | yield S(queryString).parseCSV(':', '"', '\\', ' ') // dirty but effective 78 | .map(filterInfo => function *() { 79 | const [ filterKey, filterValue ] = filterInfo 80 | switch (filterKey) { 81 | case 'is': 82 | if (filterValue === 'open' || filterValue === 'closed' || filterValue === 'all') { 83 | filter.state = filterValue 84 | } 85 | break 86 | case 'no': 87 | if (filterValue === 'milestone') { 88 | filter.milestone = 'none' 89 | } 90 | break 91 | case 'label': 92 | filter.labels = filter.labels || '' 93 | filter.labels += filterValue + ',' 94 | break 95 | case 'sort': 96 | const [ sortKey, direction ] = filterValue.split('-') 97 | filter.sort = sortKey 98 | filter.direction = direction 99 | break 100 | case 'author': 101 | filter.creator = filterValue 102 | break 103 | case 'milestone': 104 | const milestonesRes = yield request({ 105 | headers, 106 | url: `${host}/repos/${owner}/${repo}/milestones` 107 | }) 108 | const milestones = JSON.parse(milestonesRes.body) 109 | for (let i = 0, len = milestones.length; i < len; i++) { 110 | const milestone = milestones[i] 111 | if (milestone.title === filterValue) { 112 | filter.milestone = milestone.number 113 | break 114 | } 115 | } 116 | break 117 | } 118 | }) 119 | 120 | const src = `${host}/repos/${owner}/${repo}/issues` 121 | const issuesRes = yield request({ 122 | headers, 123 | url: src, 124 | qs: filter 125 | }) 126 | 127 | const feedTitle = `${owner}/${repo}` 128 | hotFeeds[feedTitle] = ++hotFeeds[feedTitle] || 1 129 | 130 | const feed = new RSS({ 131 | title: feedTitle, 132 | generator: 'gh-feed', 133 | feed_url: this.url, 134 | site_url: `https://github.com/${owner}/${repo}/${this.params[0]}${this.search}`, 135 | image_url: `https://github.com/${owner}.png`, 136 | ttl: 60 137 | }) 138 | 139 | const rateRemaining = parseInt(issuesRes.headers['x-ratelimit-remaining']) 140 | console.log(`RateRemaining: ${rateRemaining}`) 141 | if (rateRemaining <= 0) { 142 | yield genFeedFromPage.call(this) 143 | return 144 | } 145 | 146 | JSON.parse(issuesRes.body) 147 | .slice(0, 25) 148 | .forEach(issue => { 149 | feed.item({ 150 | title: issue.title, 151 | url: issue.html_url, 152 | categories: issue.labels.map(label => label.name), 153 | author: issue.user.login, 154 | date: issue.created_at, 155 | description: marked(issue.body) 156 | }) 157 | }) 158 | 159 | this.set('Content-Type', 'application/xml; charset=utf-8') 160 | this.body = feed.xml().replace(/[\u0000-\u0008\u0011-\u001F\u007F\u0080-\u009F]/g, '�') 161 | } 162 | 163 | function *genFeedFromPage() { 164 | const host = 'https://github.com' 165 | const { owner, repo } = this.params 166 | const src = `${host}/${owner}/${repo}/${this.params[0]}${this.search}` 167 | 168 | const feed = new RSS({ 169 | title: `${owner}/${repo}`, 170 | generator: 'gh-feed', 171 | feed_url: this.url, 172 | site_url: src, 173 | image_url: `${host}/${owner}.png`, 174 | ttl: 60 175 | }) 176 | 177 | const res = yield request(src) 178 | const doc = parser.parseFromString(res.body, 'text/html') 179 | const issues = [] 180 | const ul = doc.getElementsByTagName('ul')[1] 181 | if (ul && ul.getAttribute('class') === 'js-navigation-container js-active-navigation-container') { 182 | Array.from(ul.getElementsByTagName('li')) 183 | .forEach(li => { 184 | const issue = {} 185 | Array.from(li.getElementsByTagName('a')).forEach(a => { 186 | const className = a.getAttribute('class') 187 | if (/h4/.test(className)) { 188 | issue.title = a.textContent.trim() 189 | issue.url = host + a.getAttribute('href') 190 | } else if (/label/.test(className)) { 191 | issue.categories = issue.categories || [] 192 | issue.categories.push(a.textContent.trim()) 193 | } else if (className === 'tooltipped tooltipped-s muted-link') { 194 | issue.author = a.textContent.trim() 195 | } 196 | }) 197 | issue.date = li.getElementsByTagName('relative-time')[0].getAttribute('datetime') 198 | issues.push(issue) 199 | }) 200 | } 201 | 202 | yield issues.map(issue => function *() { 203 | const res = yield request(issue.url) 204 | const body = res.body.replace(/<\/option><\/form>/g, '') 205 | const id = body.match(/id="(issue-.*?)"/)[1] 206 | const doc = parser.parseFromString(body, 'text/html') 207 | const contentElement = doc 208 | .getElementById(id) 209 | .getElementsByTagName('td')[0] 210 | .childNodes 211 | issue.description = serlializer.serializeToString(contentElement) 212 | feed.item(issue) 213 | }) 214 | this.set('Content-Type', 'application/xml; charset=utf-8') 215 | this.body = feed.xml() 216 | } 217 | 218 | app.listen(process.env.PORT || 3000) --------------------------------------------------------------------------------