├── .circleci └── config.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── jest.config.js ├── package.json ├── prettier.config.js ├── src ├── __tests__ │ ├── index.spec.ts │ └── setup.js └── index.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | refs: 4 | container: &container 5 | docker: 6 | - image: node:10.10 7 | working_directory: ~/repo 8 | steps: 9 | - &Versions 10 | run: 11 | name: Versions 12 | command: node -v && npm -v && yarn -v 13 | - &Install 14 | run: 15 | name: Install Dependencies 16 | command: yarn install --pure-lockfile 17 | - &Build 18 | run: 19 | name: Build 20 | command: yarn build 21 | - &Test 22 | run: 23 | name: Test 24 | command: yarn test 25 | - &Post_to_dev_null 26 | run: 27 | name: 'Post to Slack #dev-null' 28 | command: npx ci-scripts slack --channel="dev-null" 29 | 30 | jobs: 31 | all: 32 | <<: *container 33 | steps: 34 | - checkout 35 | - *Versions 36 | - *Install 37 | - *Build 38 | - *Test 39 | - *Post_to_dev_null 40 | 41 | master: 42 | <<: *container 43 | steps: 44 | - checkout 45 | - *Versions 46 | - *Install 47 | - *Build 48 | - *Test 49 | - *Post_to_dev_null 50 | - run: 51 | name: Release 52 | command: yarn release 53 | - *Post_to_dev_null 54 | 55 | nightly: 56 | <<: *container 57 | steps: 58 | - checkout 59 | - *Versions 60 | - *Install 61 | - *Build 62 | - *Test 63 | - *Post_to_dev_null 64 | - run: 65 | name: Post to Slack on FAILURE 66 | command: npx ci slack --channel="dev" --text="** nightly build failed :scream:" --icon_emoji=tired_face 67 | when: on_fail 68 | 69 | workflows: 70 | version: 2 71 | all: 72 | jobs: 73 | - all: 74 | context: common-env-vars 75 | filters: 76 | branches: 77 | ignore: 78 | - master 79 | - gh-pages 80 | master: 81 | jobs: 82 | - master: 83 | context: common-env-vars 84 | filters: 85 | branches: 86 | only: master 87 | nightly: 88 | triggers: 89 | - schedule: 90 | cron: '0 1 * * *' 91 | filters: 92 | branches: 93 | only: master 94 | jobs: 95 | - nightly: 96 | context: common-env-vars 97 | -------------------------------------------------------------------------------- /.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 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | .env.test 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # nuxt.js build output 72 | .nuxt 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless/ 79 | 80 | # FuseBox cache 81 | .fusebox/ 82 | 83 | # DynamoDB Local files 84 | .dynamodb/ 85 | 86 | # Build folder 87 | lib/ 88 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.1.0](https://github.com/streamich/content-ranking/compare/v1.0.0...v1.1.0) (2019-04-15) 2 | 3 | 4 | ### Features 5 | 6 | * 🎸 add controversialReddit() function ([8c84f9d](https://github.com/streamich/content-ranking/commit/8c84f9d)) 7 | 8 | # 1.0.0 (2019-04-15) 9 | 10 | 11 | ### Features 12 | 13 | * 🎸 add first implementation ([8f3351f](https://github.com/streamich/content-ranking/commit/8f3351f)) 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. 4 | 5 | In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | For more information, please refer to 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # content-ranking 2 | 3 | Content ranking formulas. 4 | 5 | - `hotReddit` — Reddit's *"hot"* content ranking formula. 6 | - `hotYCombinator` — YCombinator's *"hot"* content ranking formula. 7 | - `bestReddit` — Reddit's *"best"* content ranking formula. 8 | - `controversialReddit` — Reddit's *"controversial"* content ranking formula. 9 | 10 | 11 | ## Usage 12 | 13 | ```shell 14 | npm i content-ranking 15 | ``` 16 | 17 | then 18 | 19 | ```js 20 | import {hotReddit, hotYCombinator, bestReddit, controversialReddit} from 'content-ranking'; 21 | 22 | hotReddit(ups, dows, createdTimeInMilliseconds); 23 | hotYCombinator(ups, createdTimeInMilliseconds); 24 | bestReddit(ups, dows); 25 | controversialReddit(ups, dows); 26 | ``` 27 | 28 | 29 | ## License 30 | 31 | [Unlicense](LICENSE) — public domain. 32 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'subject-case': [1, 'always', 'lower-case'], 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | testURL: 'http://localhost/', 4 | setupFiles: ['/src/__tests__/setup.js'], 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 6 | transform: { 7 | '^.+\.tsx?$': 'ts-jest', 8 | }, 9 | transformIgnorePatterns: [], 10 | testRegex: '.*/__tests__/.*\.(test|spec)\.(jsx?|tsx?)$', 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "content-ranking", 3 | "version": "1.1.0", 4 | "description": "", 5 | "author": { 6 | "name": "streamich", 7 | "url": "https://github.com/streamich" 8 | }, 9 | "homepage": "https://github.com/streamich/content-ranking", 10 | "repository": "streamich/content-ranking", 11 | "license": "Unlicense", 12 | "engines": { 13 | "node": ">=6.9" 14 | }, 15 | "main": "lib/index.js", 16 | "files": [ 17 | "lib/" 18 | ], 19 | "scripts": { 20 | "prettier": "prettier --ignore-path .gitignore --write 'src/**/*.{ts,tsx,js,jsx}'", 21 | "prettier:diff": "prettier -l 'src/**/*.{ts,tsx,js,jsx}'", 22 | "prepush": "yarn prettier:diff", 23 | "precommit": "pretty-quick --staged && yarn tslint", 24 | "tslint": "tslint 'src/**/*.{js,jsx,ts,tsx}' -t verbose", 25 | "commitmsg": "commitlint -E GIT_PARAMS", 26 | "clean": "rimraf lib", 27 | "build": "tsc", 28 | "test": "jest --no-cache --config='jest.config.js'", 29 | "release": "semantic-release" 30 | }, 31 | "keywords": [], 32 | "dependencies": {}, 33 | "devDependencies": { 34 | "@commitlint/cli": "^7.5.2", 35 | "@commitlint/config-conventional": "^7.5.0", 36 | "@semantic-release/changelog": "^3.0.2", 37 | "@semantic-release/git": "^7.0.8", 38 | "@semantic-release/npm": "^5.1.4", 39 | "@types/jest": "^24.0.11", 40 | "husky": "^1.3.1", 41 | "jest": "^24.7.1", 42 | "prettier": "^1.17.0", 43 | "pretty-quick": "^1.10.0", 44 | "rimraf": "^2.6.3", 45 | "semantic-release": "^15.13.3", 46 | "ts-jest": "^24.0.2", 47 | "ts-node": "^8.1.0", 48 | "tslint": "^5.15.0", 49 | "tslint-config-common": "^1.6.0", 50 | "typescript": "^3.4.3" 51 | }, 52 | "types": "lib/index.d.ts", 53 | "typings": "lib/index.d.ts", 54 | "release": { 55 | "verifyConditions": [ 56 | "@semantic-release/changelog", 57 | "@semantic-release/npm", 58 | "@semantic-release/git" 59 | ], 60 | "prepare": [ 61 | "@semantic-release/changelog", 62 | "@semantic-release/npm", 63 | "@semantic-release/git" 64 | ] 65 | }, 66 | "config": { 67 | "commitizen": { 68 | "path": "git-cz" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | printWidth: 120, 4 | tabWidth: 2, 5 | useTabs: false, 6 | semi: true, 7 | singleQuote: true, 8 | trailingComma: 'all', 9 | bracketSpacing: false, 10 | jsxBracketSameLine: false, 11 | }; 12 | -------------------------------------------------------------------------------- /src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import {hotReddit, bestReddit, hotYCombinator, controversialReddit} from '..'; 2 | 3 | describe('hotReddit', () => { 4 | it('gives higher rating to never content', async () => { 5 | const ts = Date.now(); 6 | const score1 = hotReddit(10, 2, ts - 10000); 7 | const score2 = hotReddit(10, 2, ts); 8 | 9 | expect(score2).toBeGreaterThan(score1); 10 | }); 11 | 12 | it('gives higher rating to content with more up votes', async () => { 13 | const ts = Date.now(); 14 | const score1 = hotReddit(12, 2, ts); 15 | const score2 = hotReddit(10, 2, ts); 16 | 17 | expect(score1).toBeGreaterThan(score2); 18 | }); 19 | 20 | it('gives higher rating to content with less down votes', async () => { 21 | const ts = Date.now(); 22 | const score1 = hotReddit(12, 2, ts); 23 | const score2 = hotReddit(12, 1, ts); 24 | 25 | expect(score2).toBeGreaterThan(score1); 26 | }); 27 | 28 | it('gives higher rating to content with more up votes - 2', async () => { 29 | const ts = Date.now(); 30 | const score1 = hotReddit(1, 0, ts); 31 | const score2 = hotReddit(2, 0, ts); 32 | 33 | expect(score2).toBeGreaterThan(score1); 34 | }); 35 | }); 36 | 37 | describe('bestReddit', () => { 38 | it('gives higher score to content with more up votes', async () => { 39 | const score1 = bestReddit(11, 2); 40 | const score2 = bestReddit(10, 2); 41 | 42 | expect(score1).toBeGreaterThan(score2); 43 | }); 44 | 45 | it('gives higher score to content with less down votes', async () => { 46 | const score1 = bestReddit(1000, 10); 47 | const score2 = bestReddit(1000, 11); 48 | 49 | expect(score1).toBeGreaterThan(score2); 50 | }); 51 | }); 52 | 53 | describe('hotYCombinator', () => { 54 | it('gives higher score to more recent content', async () => { 55 | const ts = Date.now(); 56 | const score1 = hotYCombinator(10, ts); 57 | const score2 = hotYCombinator(10, ts - 10000); 58 | 59 | expect(score1).toBeGreaterThan(score2); 60 | }); 61 | 62 | it('gives higher score to content with more up votes', async () => { 63 | const ts = Date.now(); 64 | const score1 = hotYCombinator(110, ts); 65 | const score2 = hotYCombinator(111, ts); 66 | 67 | expect(score2).toBeGreaterThan(score1); 68 | }); 69 | 70 | it('gives higher score to content with more up votes - 2', async () => { 71 | const ts = Date.now(); 72 | const score1 = hotYCombinator(0, ts); 73 | const score2 = hotYCombinator(111, ts); 74 | 75 | expect(score2).toBeGreaterThan(score1); 76 | }); 77 | 78 | it('gives higher score to content with more up votes - 3', async () => { 79 | const ts = Date.now(); 80 | const score1 = hotYCombinator(1, ts); 81 | const score2 = hotYCombinator(111, ts); 82 | 83 | expect(score2).toBeGreaterThan(score1); 84 | }); 85 | }); 86 | 87 | describe('controversialReddit', () => { 88 | it('gives higher score to content with more down votes', async () => { 89 | const score1 = controversialReddit(100, 10); 90 | const score2 = controversialReddit(100, 50); 91 | 92 | expect(score2).toBeGreaterThan(score1); 93 | }); 94 | 95 | it('gives higher score to content with more down votes - 2', async () => { 96 | const score1 = controversialReddit(100, 50); 97 | const score2 = controversialReddit(100, 120); 98 | 99 | expect(score2).toBeGreaterThan(score1); 100 | }); 101 | 102 | it('ranks higher content with more activity', async () => { 103 | const score1 = controversialReddit(2, 2); 104 | const score2 = controversialReddit(100, 100); 105 | 106 | expect(score2).toBeGreaterThan(score1); 107 | }); 108 | 109 | it('ranks higher content with more activity - 2', async () => { 110 | const score1 = controversialReddit(10, 20); 111 | const score2 = controversialReddit(100, 110); 112 | 113 | expect(score2).toBeGreaterThan(score1); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/__tests__/setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup. 2 | process.env.JEST = true; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reddit's hot content scoring function. Used for raking higher most recent and 3 | * highest voted content. More recent content items get higher score automatically. 4 | * 5 | * - See: https://github.com/reddit-archive/reddit/blob/d990533d0b57a499cefcec70f4c51d8c5593c497/r2/r2/lib/db/_sorts.pyx#L47-L58 6 | * 7 | * @param ups Number of up votes. 8 | * @param downs Number of down votes. 9 | * @param ts Timestamp in milliseconds when content was created. 10 | */ 11 | export function hotReddit (ups: number, downs: number, ts = Date.now()) { 12 | const s = ups - downs; 13 | const order = Math.log(Math.max(Math.abs(s), 1)) / Math.LN10; 14 | const sign = s > 0 ? 1 : (s < 0 ? -1 : 0); 15 | const seconds = (ts / 1e3) - 1134028003; 16 | return sign * order + seconds / 45000; 17 | } 18 | 19 | /** 20 | * YCombinator's hot content scoring formula. Does not require down votes. 21 | * 22 | * - See: https://moz.com/blog/reddit-stumbleupon-delicious-and-hacker-news-algorithms-exposed 23 | * 24 | * @param ups Number of up votes. 25 | * @param ts Timestamp when content was created in milliseconds. 26 | */ 27 | export function hotYCombinator (ups: number, ts: number = Date.now()) { 28 | const hoursSinceCreated = (Date.now() - ts) / (1000 * 60 * 60); 29 | return ups / Math.pow(hoursSinceCreated + 2, 1.5); 30 | } 31 | 32 | /** 33 | * Reddit's content scoring function for finding best replies regardless of time 34 | * when content was created. This function does not penalize old content. 35 | * 36 | * - See: https://github.com/reddit-archive/reddit/blob/d990533d0b57a499cefcec70f4c51d8c5593c497/r2/r2/lib/db/_sorts.pyx#L70-L85 37 | * 38 | * @param ups Number of up votes. 39 | * @param downs Number of down votes. 40 | */ 41 | export function bestReddit (ups: number, downs: number) { 42 | const n = ups + downs; 43 | if (!n) return 0; 44 | const z = 1.281551565545; 45 | const p = ups / n; 46 | const left = p + 1 / (2 * n) * z * z; 47 | const right = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)); 48 | const under = 1 + 1 / n * z * z; 49 | return (left - right) / under; 50 | } 51 | 52 | /** 53 | * Reddit's algorithm for scoring high controversial content. 54 | * 55 | * - See: https://github.com/reddit-archive/reddit/blob/d990533d0b57a499cefcec70f4c51d8c5593c497/r2/r2/lib/db/_sorts.pyx#L60-L68 56 | * 57 | * @param ups Number of up votes. 58 | * @param downs Number of down votes. 59 | */ 60 | export function controversialReddit (ups: number, downs: number) { 61 | if (!ups || !downs) return 0; 62 | const magnitude = ups + downs; 63 | const balance = ups > downs ? (downs / ups) : (ups / downs); 64 | return magnitude ** balance; 65 | } 66 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "moduleResolution": "Node", 6 | "removeComments": true, 7 | "noImplicitAny": false, 8 | "allowJs": false, 9 | "allowSyntheticDefaultImports": true, 10 | "skipDefaultLibCheck": true, 11 | "skipLibCheck": true, 12 | "experimentalDecorators": true, 13 | "importHelpers": true, 14 | "pretty": true, 15 | "sourceMap": false, 16 | "strict": true, 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "noEmitHelpers": true, 21 | "noEmitOnError": true, 22 | "noErrorTruncation": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noImplicitReturns": true, 25 | "declaration": true, 26 | "lib": ["es2018", "es2017", "esnext", "dom", "esnext.asynciterable"], 27 | "outDir": "./lib" 28 | }, 29 | "include": ["src"], 30 | "exclude": [ 31 | "node_modules", 32 | "lib", 33 | "src/__tests__", 34 | "src/**/__tests__/**/*.*", 35 | "src/**/__mocks__/**/*.*", 36 | "*.test.ts", 37 | "*.spec.ts" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-common" 4 | ] 5 | } 6 | --------------------------------------------------------------------------------