├── tests ├── testFeed.js └── testHeadlines.js ├── README.md ├── utils ├── retry.js ├── octokitBuilder.js ├── issue.js ├── feed.js └── getHeadlines.js ├── package.json ├── .github └── workflows │ └── main.yml ├── index.js └── .gitignore /tests/testFeed.js: -------------------------------------------------------------------------------- 1 | import { updateFeed } from '../utils/feed.js'; 2 | 3 | await updateFeed(); 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RSS feed 2 | 3 | https://raw.githubusercontent.com/meixger/hackernews-daily/main/rss2.xml 4 | 5 | https://rsshub.app/github/issue/meixger/hackernews-daily 6 | -------------------------------------------------------------------------------- /tests/testHeadlines.js: -------------------------------------------------------------------------------- 1 | import { getHeadlines } from "../utils/getHeadlines.js"; 2 | 3 | const headlines = await getHeadlines(new Date(), 25); 4 | console.log(headlines); 5 | -------------------------------------------------------------------------------- /utils/retry.js: -------------------------------------------------------------------------------- 1 | const wait = (ms) => new Promise((res) => setTimeout(res, ms)); 2 | 3 | export const callWithRetry = async (fn, depth = 0) => { 4 | try { 5 | console.log(`try #${depth}`); 6 | return await fn(); 7 | }catch(e) { 8 | if (depth > 1) { 9 | throw e; 10 | } 11 | console.log(`wait #${2 ** depth * 1000}`); 12 | await wait(2 ** depth * 1000); 13 | 14 | return callWithRetry(fn, depth + 1); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/octokitBuilder.js: -------------------------------------------------------------------------------- 1 | import { createActionAuth } from "@octokit/auth-action"; 2 | import { createTokenAuth } from "@octokit/auth-token"; 3 | import { Octokit } from "@octokit/core"; 4 | 5 | export const buildOctokit = async () => { 6 | 7 | let options = {}; 8 | var ghp = process.env.ghp; 9 | if (ghp) { 10 | const auth = createTokenAuth(ghp); 11 | const authentication = await auth(); 12 | options.auth = authentication.token; 13 | } else { 14 | options.authStrategy = createActionAuth; 15 | } 16 | return new Octokit(options); 17 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackernews-daily", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/meixger/hackernews-daily.git" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "bugs": { 14 | "url": "https://github.com/meixger/hackernews-daily/issues" 15 | }, 16 | "homepage": "https://github.com/meixger/hackernews-daily#readme", 17 | "dependencies": { 18 | "@actions/core": "^1.11.1", 19 | "@octokit/auth-action": "^6.0.1", 20 | "@octokit/auth-token": "^6.0.0", 21 | "@octokit/core": "^7.0.3", 22 | "dayjs": "^1.11.12", 23 | "feed": "^4.2.2", 24 | "marked": "^15.0.7", 25 | "timeago.js": "^4.0.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: hackernews-daily 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "40 6 * 11-12,1-3 *" 7 | - cron: "40 5 * 4-10 *" 8 | 9 | env: 10 | BRANCH_NAME: ${{ github.head_ref || github.ref_name }} 11 | 12 | jobs: 13 | fetch-top-posts: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 22 21 | - name: npm install 22 | run: npm install --only=prod 23 | working-directory: . 24 | - name: fetch 25 | run: node index.js 26 | working-directory: . 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | - name: commit 30 | run: | 31 | git config user.name github-actions 32 | git config user.email github-actions@github.com 33 | git add . 34 | git commit -m "feed: update" 35 | git push 36 | 37 | 38 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import { getHeadlines } from './utils/getHeadlines.js'; 3 | import { lockIssue, openIssue } from './utils/issue.js'; 4 | import { updateFeed } from './utils/feed.js'; 5 | 6 | const date = new Date(); 7 | const takeHeadlines = 30; 8 | 9 | const contents = await getHeadlines(date, takeHeadlines); 10 | if (!contents) { 11 | core.error("no content - skip issue creation"); 12 | process.exit(1); 13 | } 14 | core.info(contents); 15 | 16 | if (process.env.BRANCH_NAME === 'main') { 17 | const res = await openIssue({ 18 | owner: 'meixger', 19 | repo: 'hackernews-daily', 20 | title: `Hacker News Daily Top ${takeHeadlines} @${date.toISOString().slice(0, 10)}`, 21 | body: contents 22 | }); 23 | 24 | const issueNumber = res.data.number; 25 | core.info(`created issue ${issueNumber}`); 26 | 27 | await lockIssue({ 28 | owner: 'meixger', 29 | repo: 'hackernews-daily', 30 | issueNumber, 31 | }); 32 | } 33 | 34 | await updateFeed(); 35 | 36 | -------------------------------------------------------------------------------- /utils/issue.js: -------------------------------------------------------------------------------- 1 | import { callWithRetry } from "./retry.js"; 2 | import { buildOctokit } from "./octokitBuilder.js"; 3 | 4 | export const openIssue = async ({ owner, repo, title, body }) => { 5 | const octokit = await buildOctokit(); 6 | const res = await callWithRetry(() => { 7 | console.log('opening issue'); 8 | return octokit.request('POST /repos/{owner}/{repo}/issues', { 9 | owner, 10 | repo, 11 | title, 12 | body, 13 | }); 14 | }); 15 | console.log('opened'); 16 | return res; 17 | } 18 | 19 | export const getIssues = async ({ owner, repo, take }) => { 20 | const octokit = await buildOctokit(); 21 | const res = await callWithRetry(() => { 22 | return octokit.request('GET /repos/{owner}/{repo}/issues', { 23 | owner, 24 | repo, 25 | per_page: take 26 | }); 27 | }); 28 | const issues = res.data; 29 | return issues 30 | } 31 | 32 | export const lockIssue = async ({owner, repo, issueNumber}) => { 33 | const octokit = await buildOctokit(); 34 | await callWithRetry(() => { 35 | return octokit.request('PUT /repos/{owner}/{repo}/issues/{issue_number}/lock', { 36 | owner: owner, 37 | repo: repo, 38 | issue_number: issueNumber, 39 | lock_reason: 'resolved' 40 | }); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /utils/feed.js: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as fs from 'fs'; 3 | import { Feed } from "feed"; 4 | import { getIssues } from "../utils/issue.js"; 5 | import { marked } from "marked"; 6 | 7 | export const updateFeed = async () => { 8 | const issues = await getIssues({ 9 | owner: 'meixger', 10 | repo: 'hackernews-daily', 11 | take: 30 12 | }); 13 | 14 | const feed = new Feed({ 15 | title: `Hacker News Daily Top 30`, 16 | description: "Hacker News Daily Top 30", 17 | // id: "http://example.com/", 18 | link: "https://github.com/meixger/hackernews-daily/issues/", 19 | // language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes 20 | // image: "http://example.com/image.png", 21 | // favicon: "http://example.com/favicon.ico", 22 | // copyright: "All rights reserved 2013, John Doe", 23 | // updated: new Date(2013, 6, 14), // optional, default = today 24 | // generator: "awesome", // optional, default = 'Feed for Node.js' 25 | // feedLinks: { 26 | // json: "https://example.com/json", 27 | // atom: "https://example.com/atom" 28 | // }, 29 | // author: { 30 | // name: "John Doe", 31 | // email: "johndoe@example.com", 32 | // link: "https://example.com/johndoe" 33 | // } 34 | }); 35 | 36 | issues.forEach(i => { 37 | feed.addItem({ 38 | title: i.title, 39 | id: i.number.toString(), 40 | link: i.html_url, 41 | // description: post.description, 42 | content: marked.parse(i.body), 43 | // author: [ 44 | // { 45 | // name: "Jane Doe", 46 | // email: "janedoe@example.com", 47 | // link: "https://example.com/janedoe" 48 | // } 49 | // ], 50 | // contributor: [ 51 | // { 52 | // name: "Shawn Kemp", 53 | // email: "shawnkemp@example.com", 54 | // link: "https://example.com/shawnkemp" 55 | // } 56 | // ], 57 | date: new Date(i.updated_at), 58 | // image: post.image 59 | }); 60 | }); 61 | 62 | fs.writeFile('rss2.xml', feed.rss2(), cb => { if (cb) core.error(cb); }) 63 | } 64 | -------------------------------------------------------------------------------- /utils/getHeadlines.js: -------------------------------------------------------------------------------- 1 | import core from "@actions/core"; 2 | import { EOL } from "os"; 3 | import { exit } from "process"; 4 | 5 | function githubAvoidingBacklinksToLinkedReferences(value) { 6 | // Avoiding backlinks to linked references 7 | // ref: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/autolinked-references-and-urls#avoiding-backlinks-to-linked-references 8 | return value.replace(/https?:\/\/(www\.)?github.com\//g, 'https://redirect.github.com/'); 9 | } 10 | 11 | function escapeHTML(value) { 12 | return (value?.replace( 13 | /[&<>'"]/g, 14 | tag => ({ 15 | '&': '&', 16 | '<': '<', 17 | '>': '>', 18 | "'": ''', 19 | '"': '"' 20 | }[tag] || tag)) ?? "" 21 | ); 22 | } 23 | 24 | export const getHeadlines = async (date, take) => { 25 | try { 26 | // end of the date 27 | const endTime = Math.round(date.getTime() / 1000); 28 | // 1 hour before start of the date (save missed posts) 29 | const startTime = endTime - (25 * 60 * 60); 30 | core.notice(`date range from ${new Date(startTime * 1000)} to ${new Date(endTime * 1000)}`); 31 | const url = `https://hn.algolia.com/api/v1/search?hitsPerPage=${take}&numericFilters=created_at_i>${startTime},created_at_i<${endTime}`; 32 | let data; 33 | try { 34 | data = await fetch(url).then(res => res.json()); 35 | } catch (error) { 36 | core.info(url); 37 | core.error(`request failed: ${error.message}`); 38 | exit(1); 39 | } 40 | 41 | const count = data?.hits?.length; 42 | if (!(count > 0)) { 43 | core.info(url); 44 | core.error('no results from api'); 45 | exit(1); 46 | } 47 | 48 | const headlines = data.hits.slice(0, take); 49 | const contents = headlines 50 | .map((obj, i) => { 51 | let { title, url, points, objectID, num_comments } = obj; 52 | const ycombinatorUrl = `https://news.ycombinator.com/item?id=${objectID}`; 53 | if (!url) url = ycombinatorUrl; 54 | const domain = url ? `${new URL(url).hostname}` : ''; 55 | url = githubAvoidingBacklinksToLinkedReferences(url); 56 | const titleAndDomain = `[**${escapeHTML(title)}** ${domain}](${url})`; 57 | const commentsAndPoints = `[${num_comments} comments ${points} points](${ycombinatorUrl})`; 58 | return `${i + 1}. ${titleAndDomain} - ${commentsAndPoints}`; 59 | }) 60 | .join(EOL); 61 | 62 | return contents; 63 | } catch (error) { 64 | console.log(error); 65 | throw error 66 | } 67 | 68 | } 69 | --------------------------------------------------------------------------------