├── public ├── .nojekyll ├── favicon.ico ├── loc-cta.jpg ├── jumbotron-bg.jpg ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-150x150.png ├── apple-touch-icon.png ├── jumbotron-bg-dark.jpg ├── social-share-img.jpg ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── prompt-images │ ├── novellists.jpg │ ├── critical-holdouts.jpg │ ├── keyboard-artists.jpg │ ├── localisation-issues.jpg │ ├── new-technical-issues.jpg │ ├── conversation-starters.jpg │ └── happy-content-creators.jpg ├── steam-review-explorer-logo.png ├── browserconfig.xml └── site.webmanifest ├── next.config.js ├── README.md ├── next-env.d.ts ├── components ├── GameCardDeck.tsx ├── BetaNotice.tsx ├── Donate.tsx ├── StorageCardDeck.tsx ├── SortControl.tsx ├── visualisations │ ├── ReviewScoreOverTimeChart.tsx │ ├── ReviewVolumeDistributionBarChart.tsx │ ├── SwearWords.tsx │ ├── WordFrequency.tsx │ └── LanguageDistribution.tsx ├── Footer.tsx ├── DarkModeToggle.tsx ├── Header.tsx ├── ReviewScoreBadge.tsx ├── StorageCard.tsx ├── Paginator.tsx ├── GameSummary.tsx ├── HighlightedReviewList.tsx ├── GameCard.tsx ├── ReviewText.tsx ├── Layout.tsx ├── HighlightedReview.tsx ├── Loader.tsx ├── PaginatedReviewTable.tsx ├── ReviewItem.tsx ├── ReviewTable.tsx ├── PromptsList.tsx ├── Export.tsx ├── GameSearch.tsx └── ReviewOverview.tsx ├── pages ├── index.tsx ├── app.scss ├── feedback.tsx ├── _app.tsx ├── 404.tsx ├── _document.tsx ├── storage.tsx └── game │ └── [appId].tsx ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ └── nodejs.yml ├── package.json └── lib └── utils ├── DBUtils.ts ├── SteamLocales.ts ├── CommonWords.ts ├── curseWords.ts ├── SteamWebApiClient.ts └── ReviewListUtils.ts /public/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | basePath: '/steam-review-explorer' 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proper README coming soon 2 | 3 | This was a weekend project so it's a bit messy -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/loc-cta.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/loc-cta.jpg -------------------------------------------------------------------------------- /public/jumbotron-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/jumbotron-bg.jpg -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/jumbotron-bg-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/jumbotron-bg-dark.jpg -------------------------------------------------------------------------------- /public/social-share-img.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/social-share-img.jpg -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/prompt-images/novellists.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/prompt-images/novellists.jpg -------------------------------------------------------------------------------- /public/steam-review-explorer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/steam-review-explorer-logo.png -------------------------------------------------------------------------------- /public/prompt-images/critical-holdouts.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/prompt-images/critical-holdouts.jpg -------------------------------------------------------------------------------- /public/prompt-images/keyboard-artists.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/prompt-images/keyboard-artists.jpg -------------------------------------------------------------------------------- /public/prompt-images/localisation-issues.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/prompt-images/localisation-issues.jpg -------------------------------------------------------------------------------- /public/prompt-images/new-technical-issues.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/prompt-images/new-technical-issues.jpg -------------------------------------------------------------------------------- /public/prompt-images/conversation-starters.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/prompt-images/conversation-starters.jpg -------------------------------------------------------------------------------- /public/prompt-images/happy-content-creators.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhills/steam-review-explorer/HEAD/public/prompt-images/happy-content-creators.jpg -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #00aba9 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /components/GameCardDeck.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import GameCard from "./GameCard" 3 | 4 | const GameCardDeck = ({ games, onExplore }) => { 5 | return ( 6 |
7 | {games.map(game =>
)} 8 |
9 | ) 10 | } 11 | 12 | export default GameCardDeck -------------------------------------------------------------------------------- /components/BetaNotice.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import React from "react" 3 | import { Alert } from "react-bootstrap" 4 | 5 | const BetaNotice = () => { 6 | return ( 7 | 8 | This website is in beta, please consider leaving feedback to help improve it 9 | 10 | ) 11 | } 12 | 13 | export default BetaNotice -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import GameSearch from "../components/GameSearch" 3 | import { Row, Col } from "react-bootstrap" 4 | import Header from "components/Header" 5 | import BetaNotice from "components/BetaNotice" 6 | 7 | const IndexPage = () => { 8 | 9 | return (<> 10 | {/* */} 11 |
12 | 13 | 14 | 15 | 16 | 17 | ) 18 | } 19 | 20 | export default IndexPage -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Steam Review Explorer", 3 | "short_name": "Steam Review Explorer", 4 | "icons": [ 5 | { 6 | "src": "/steam-review-explorer/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/steam-review-explorer/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /components/Donate.tsx: -------------------------------------------------------------------------------- 1 | const Donate = () => { 2 | return (<> 3 | 4 | 13 | ) 14 | } 15 | 16 | export default Donate -------------------------------------------------------------------------------- /pages/app.scss: -------------------------------------------------------------------------------- 1 | .game-card, .prompt-card { 2 | width: 100%; 3 | height: calc(100% - 1rem); 4 | 5 | @media (min-width: 700px) { 6 | width: 340px; 7 | } 8 | } 9 | 10 | .footer-col { 11 | padding-left: 0; 12 | padding-right: 0; 13 | 14 | @media (min-width: 500px) { 15 | padding: auto; 16 | } 17 | } 18 | 19 | html.dark footer .text-muted { 20 | color: #ddd !important; 21 | } 22 | 23 | html.dark .multi-select * { 24 | background-color: transparent; 25 | color: #ddd; 26 | } 27 | 28 | html.dark .multi-select .dropdown-content *:not([type='input']) { 29 | background-color: #222; 30 | color: #ddd !important; 31 | } -------------------------------------------------------------------------------- /pages/feedback.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import { Breadcrumb } from "react-bootstrap"; 4 | 5 | export default function Feedback() { 6 | return (<> 7 |
8 | 9 |
  • Home
  • 10 | Feedback 11 |
    12 |
    13 | 14 | ) 15 | } -------------------------------------------------------------------------------- /components/StorageCardDeck.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import StorageCard from "./StorageCard" 3 | 4 | const StorageCardDeck = ({ games, searches, quotaPercent, reviewCounts, handleDelete }) => { 5 | 6 | // Compute total reviews 7 | const totalReviews = Object.values(reviewCounts).reduce((p: number, c: number) => p + c) 8 | 9 | return ( 10 |
    11 | {Object.keys(games).sort((a, b) => reviewCounts[b] - reviewCounts[a]).map(game =>
    12 |
    )} 13 |
    14 | ) 15 | } 16 | 17 | export default StorageCardDeck -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "noEmit": true, 19 | "noImplicitThis": true, 20 | "noImplicitAny": false, 21 | "preserveConstEnums": true, 22 | "removeComments": false, 23 | "resolveJsonModule": true, 24 | "skipLibCheck": true, 25 | "sourceMap": true, 26 | "strict": false, 27 | "suppressImplicitAnyIndexErrors": true, 28 | "downlevelIteration": true, 29 | "incremental": true 30 | }, 31 | "exclude": [ 32 | "node_modules" 33 | ], 34 | "include": [ 35 | "next-env.d.ts", 36 | "**/*.ts", 37 | "**/*.tsx", 38 | "next.config.js" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /components/SortControl.tsx: -------------------------------------------------------------------------------- 1 | import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa" 2 | 3 | const SortControl = ({ sortId, sorting, callBack }) => { 4 | 5 | let activeControl 6 | if (sortId === sorting.id) { 7 | if (sorting.direction === 'ascending') { 8 | activeControl = 9 | } else if (sorting.direction === 'descending') { 10 | activeControl = 11 | } 12 | } else { 13 | activeControl = 14 | } 15 | 16 | return (<> 17 | 31 | callBack(sortId)}> 32 | {activeControl} 33 | 34 | ) 35 | } 36 | 37 | export default SortControl -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Josh Hills 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 | -------------------------------------------------------------------------------- /components/visualisations/ReviewScoreOverTimeChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Brush, Label, Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from "recharts" 3 | 4 | const ReviewScoreOverTimeChart = ({ reviewStatistics }) => { 5 | 6 | return (<> 7 |
    Review Score Over Time
    8 | 9 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } 26 | 27 | export default ReviewScoreOverTimeChart -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Row, Col, Container } from "react-bootstrap" 3 | import { FaTwitter, FaGithub } from "react-icons/fa"; 4 | import Donate from "./Donate"; 5 | 6 | export default function Footer() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 17 |
    18 |
    19 |
    20 | 21 |
    22 | 23 |
    24 |
    25 | ) 26 | } -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import './bsn.css' 2 | import './app.scss' 3 | import Layout from '../components/Layout' 4 | import { useRouter } from 'next/router' 5 | import Head from 'next/head' 6 | 7 | export default function MyApp({ Component, pageProps }) { 8 | 9 | console.log('%cHey, are you trying to figure out how something works?\nView the source at https://github.com/joshhills/steam-review-explorer', 10 | 'color: #007bff; font-size: .75rem; padding: 2px; border-radius:2px') 11 | 12 | const router = useRouter() 13 | 14 | if (Object.keys(router.query).length >= 1) { 15 | const firstQuery = Object.keys(router.query)[0] 16 | if (firstQuery[0] === '/') { 17 | const decoded = firstQuery.slice(1).split('&').map((s) => s.replace(/~and~/g,'&')).join('?') 18 | 19 | router.push(router.pathname + decoded) 20 | } 21 | } 22 | 23 | return (<> 24 | 25 | Steam Review Explorer 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | } -------------------------------------------------------------------------------- /components/visualisations/ReviewVolumeDistributionBarChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Bar, BarChart, Brush, Label, ReferenceLine, ResponsiveContainer, Tooltip, XAxis } from "recharts" 3 | 4 | const ReviewVolumeDistributionBarChart = ({ reviewStatistics }) => { 5 | 6 | return (<> 7 |
    Total Reviews Over Time
    8 | 9 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | export default ReviewVolumeDistributionBarChart -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: github pages 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Setup Node 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: "12.x" 20 | 21 | - name: Get yarn cache 22 | id: yarn-cache 23 | run: echo "::set-output name=dir::$(yarn cache dir)" 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v1 27 | with: 28 | path: ${{ steps.yarn-cache.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | - run: yarn install 33 | - run: yarn build 34 | - run: yarn export 35 | 36 | - name: Deploy 37 | uses: peaceiris/actions-gh-pages@v3 38 | with: 39 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} 40 | publish_dir: ./out -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import React from "react"; 4 | import { Container } from "react-bootstrap"; 5 | 6 | export default function Custom404() { 7 | return (<> 8 | 9 | 24 | 25 | 26 |

    404

    27 |

    Page Not Found

    28 |

    Did you expect to see a page here? Leave some feedback

    29 |
    30 | ) 31 | } -------------------------------------------------------------------------------- /components/DarkModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { Form } from "react-bootstrap" 3 | import { FaSun, FaMoon } from "react-icons/fa" 4 | import { useCookies } from "react-cookie" 5 | 6 | const DarkModeToggle = () => { 7 | 8 | const [cookies, setCookie] = useCookies(['darkMode']) 9 | const [darkMode, setDarkMode] = useState(false) 10 | 11 | const updateDarkMode = (nowDark) => { 12 | setDarkMode(nowDark) 13 | 14 | if (nowDark) { 15 | document.querySelector("html").classList.add("dark") 16 | } else { 17 | document.querySelector("html").classList.remove("dark") 18 | } 19 | } 20 | 21 | useEffect(() => { 22 | updateDarkMode(cookies.darkMode === 'true' ? true : false) 23 | }) 24 | 25 | const handleChange = (value) => { 26 | const nowDark = value.target.checked 27 | 28 | updateDarkMode(nowDark) 29 | setCookie('darkMode', nowDark, { path: '/' }) 30 | } 31 | 32 | return ( 33 | : } 36 | id="dark-mode-switch" 37 | checked={darkMode} onChange={handleChange}/> 38 | ) 39 | } 40 | 41 | export default DarkModeToggle -------------------------------------------------------------------------------- /components/visualisations/SwearWords.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactSpoiler from "react-spoiler" 3 | 4 | const SwearWords = ({ game, reviewStatistics }) => { 5 | 6 | let totalReviewsWithSwears, percentReviewsWithSwears 7 | 8 | if (reviewStatistics.totalSwearWords.length > 0) { 9 | totalReviewsWithSwears = reviewStatistics.totalSwearWords.reduce((a:any, b:any) => a + b[1], 0) 10 | percentReviewsWithSwears = Math.round(totalReviewsWithSwears / reviewStatistics.totalLanguages['english'] * 100) 11 | } 12 | 13 | return <> 14 | 15 | return reviewStatistics.totalReviews > 2 && (reviewStatistics.totalSwearWords.length > 1 && reviewStatistics.totalSwearWords[0][1] !== reviewStatistics.totalSwearWords[1][1]) ? 16 |
    17 |
    Total Profanity
    18 | 19 | {totalReviewsWithSwears} / {reviewStatistics.totalLanguages['english']} ({Math.round(percentReviewsWithSwears)}%) English reviews contain curse words, 20 | the most common being '{reviewStatistics.totalSwearWords[0][0]}' which appears in {reviewStatistics.totalSwearWords[0][1].toLocaleString()} review{reviewStatistics.totalSwearWords[0][1] === 1 ? '' : 's'} 21 | 22 |
    : <> 23 | 24 | } 25 | 26 | export default SwearWords -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "steam-review-facts", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "export": "next export" 10 | }, 11 | "dependencies": { 12 | "bootstrap": "^5.2.2", 13 | "bootstrap-daterangepicker": "^3.1.0", 14 | "censor-sensor": "^1.0.6", 15 | "column-resizer": "^1.3.6", 16 | "dateformat": "^4.6.3", 17 | "dexie": "^4.0.1", 18 | "dexie-react-hooks": "^1.1.7", 19 | "jquery": "^3.6.1", 20 | "lodash": "^4.17.21", 21 | "moment": "^2.29.4", 22 | "next": "12.3.1", 23 | "p-retry": "^5.1.1", 24 | "rc-slider": "^9.7.5", 25 | "react": "^17.0.2", 26 | "react-bootstrap": "^2.5.0", 27 | "react-bootstrap-daterangepicker": "^8.0.0", 28 | "react-cookie": "^4.1.1", 29 | "react-csv-downloader": "^3.1.0", 30 | "react-dom": "^17.0.2", 31 | "react-highlight-words": "^0.17.0", 32 | "react-icons": "^5.0.1", 33 | "react-multi-select-component": "^4.3.3", 34 | "react-spoiler": "^0.4.3", 35 | "recharts": "2.1.9", 36 | "sanitize-filename": "^1.6.3", 37 | "sass": "^1.55.0" 38 | }, 39 | "devDependencies": { 40 | "@types/lodash": "^4.14.186", 41 | "@types/node": "^14.18.31", 42 | "@types/react": "^17.0.50", 43 | "typescript": "^4.8.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Link from "next/link" 3 | import { Row, Col, Image } from "react-bootstrap" 4 | 5 | const Header = () => { 6 | 7 | return (<> 8 | {/* 9 | 10 | 11 | Localisation call to arms 12 | 13 | 14 | */} 15 | 16 | 28 | 29 |
    30 |
    31 |
    32 |

    Understand player feedback

    33 |

    Search, visualise and download Steam reviews using this free data analysis tool

    34 | Find out how it works
    35 | View Changelog 36 |
    37 |
    38 |
    39 | 40 |
    41 | 42 | ) 43 | } 44 | 45 | export default Header -------------------------------------------------------------------------------- /components/ReviewScoreBadge.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Badge, OverlayTrigger, Tooltip } from "react-bootstrap" 3 | 4 | const ReviewScoreBadge = ({ game, showTooltip }) => { 5 | 6 | let variant 7 | 8 | switch (game.review_score_desc.toLowerCase()) { 9 | case 'overwhelmingly positive': 10 | case 'very positive': 11 | case 'positive': 12 | case 'mostly positive': 13 | variant = 'success' 14 | break 15 | case 'mixed': 16 | variant = 'warning' 17 | break 18 | case 'mostly negative': 19 | case 'negative': 20 | case 'very negative': 21 | case 'overwhelmingly negative': 22 | variant = 'danger' 23 | break 24 | default: 25 | variant = 'secondary' 26 | } 27 | 28 | const badge = 29 | {game.review_score_desc} 30 | 31 | 32 | const percentPositive = Math.round(game.total_positive / game.total_reviews * 100) 33 | 34 | return ( 35 | showTooltip ? 36 | game.total_reviews > 0 ? 37 | {game.total_reviews === 1 ? 38 | <>The one review for this game is {percentPositive === 100 ? 'positive' : 'negative'} : 39 | <>{percentPositive}% of the {game.total_reviews.toLocaleString()} reviews for this {game.type === 'dlc' ? 'DLC' : game.type} are positive} 40 | :

    }> 41 | {badge} 42 |
    : 43 | badge 44 | ) 45 | } 46 | 47 | export default ReviewScoreBadge -------------------------------------------------------------------------------- /components/StorageCard.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router" 2 | import React from "react" 3 | import dateFormat from "dateformat" 4 | import { Button, Card } from "react-bootstrap" 5 | 6 | const StorageCard = ({ game, search, quotaPercent, reviewCount, totalReviews, handleDelete }) => { 7 | 8 | const dateFormatStringDetailed = 'hh:MMtt, dd/mm/yyyy' 9 | const proportion = reviewCount > 0 ? Math.round((reviewCount / totalReviews) * 100) : 0 10 | const overallProportion = Math.round((proportion / 100) * quotaPercent) 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | {game.name} 18 | 19 | 20 | Using {overallProportion}% of overall space 21 | 22 | 23 | Last updated {dateFormat(new Date(search.when), dateFormatStringDetailed)} 24 | 25 | {reviewCount.toLocaleString()} review{reviewCount === 1 ? '' : 's'} stored 26 | 27 | 28 |
    29 | 33 |
    34 |
    35 |
    36 | ) 37 | } 38 | 39 | export default StorageCard -------------------------------------------------------------------------------- /components/Paginator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Pagination } from "react-bootstrap" 3 | 4 | const Paginator = ({ pageBuffer, currentIndex, lastIndex, callback }) => { 5 | 6 | // Handle changes to to chosen pagination 7 | const handleSetIndex = (i: number) => { 8 | if (i >= 0 && i <= lastIndex) { 9 | callback(i) 10 | } 11 | } 12 | 13 | // Compute the bounds 14 | let startPaginationIndex = currentIndex - pageBuffer 15 | let endPaginationIndex = currentIndex + pageBuffer 16 | 17 | if (endPaginationIndex > lastIndex) { 18 | endPaginationIndex = lastIndex 19 | startPaginationIndex = lastIndex - pageBuffer * 2 20 | } 21 | if (startPaginationIndex < 0) { 22 | startPaginationIndex = 0 23 | endPaginationIndex = pageBuffer * 2 24 | } 25 | if (endPaginationIndex > lastIndex) { 26 | endPaginationIndex = lastIndex 27 | } 28 | 29 | // Populate the paginator 30 | let paginationItems = [] 31 | for (let i = startPaginationIndex; i <= endPaginationIndex; i++) { 32 | paginationItems.push( 33 | handleSetIndex(i)}> 34 | {i + 1} 35 | 36 | ) 37 | } 38 | 39 | return ( 40 | 41 | handleSetIndex(0)} /> 42 | handleSetIndex(currentIndex - 1)} /> 43 | {paginationItems} 44 | handleSetIndex(currentIndex + 1)} /> 45 | handleSetIndex(lastIndex)} /> 46 | 47 | ) 48 | } 49 | 50 | export default Paginator -------------------------------------------------------------------------------- /components/GameSummary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Row, Col, Badge } from "react-bootstrap" 3 | import ReviewScoreBadge from "./ReviewScoreBadge" 4 | 5 | const GameSummary = ({ game }) => { 6 | 7 | const type = game.type === 'dlc' ? 'DLC' : game.type.charAt(0).toUpperCase() + game.type.slice(1) 8 | const developers = game.developers.join(', ') 9 | 10 | const steamUrl = `https://store.steampowered.com/app/${game.steam_appid}` 11 | const steamDBUrl = `https://steamdb.info/app/${game.steam_appid}` 12 | const steamSpyUrl = `https://steamspy.com/app/${game.steam_appid}` 13 | 14 | let supportedLanguages = 'Unknown' 15 | if (Object.keys(game.parsed_supported_languages).length > 0) { 16 | supportedLanguages = Object.entries(game.parsed_supported_languages).map((e:any) => e[1].englishName).join(', ') 17 | } 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 |

    26 | {game.name} 27 | {(game.content_descriptors.ids !== null && game.content_descriptors.ids.indexOf(3) !== -1) && Adult} 28 |

    29 |

    {type} by {developers} {game.release_date.coming_soon ? 'coming soon' : `released ${game.release_date.date}`}

    30 |

    Steam Store | SteamDB | SteamSpy

    31 |

    Supported languages: { supportedLanguages }

    32 |

    {game.short_description}

    33 | 34 |
    35 | ) 36 | } 37 | 38 | export default GameSummary -------------------------------------------------------------------------------- /components/HighlightedReviewList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import HighlightedReview from "./HighlightedReview" 3 | 4 | const HighlightedReviewList = ({ game, reviewStatistics }) => { 5 | 6 | const reviews = {} 7 | 8 | const addReviewWithTitle = (title, review) => { 9 | if (reviews[review.recommendationid]) { 10 | reviews[review.recommendationid].titles.push(title) 11 | } else { 12 | reviews[review.recommendationid] = { 13 | titles: [title], 14 | review: review 15 | } 16 | } 17 | } 18 | 19 | if (reviewStatistics.totalReviews > 1) { 20 | addReviewWithTitle('highest playtime at review time', reviewStatistics.reviewMaxTotalMinutesPlayedAtReviewTime) 21 | addReviewWithTitle('highest playtime forever', reviewStatistics.reviewMaxTotalMinutesPlayedForever) 22 | addReviewWithTitle('longest', reviewStatistics.reviewMaxTextLength) 23 | 24 | if (reviewStatistics.reviewMaxVotesUp.votes_up > 0) { 25 | addReviewWithTitle('most helpful', reviewStatistics.reviewMaxVotesUp) 26 | } 27 | if (reviewStatistics.reviewMaxVotesFunny.votes_funny > 0) { 28 | addReviewWithTitle('most funny', reviewStatistics.reviewMaxVotesFunny) 29 | } 30 | if (reviewStatistics.reviewMaxCommentCount.comment_count > 0) { 31 | addReviewWithTitle('most comments', reviewStatistics.reviewMaxCommentCount) 32 | } 33 | 34 | if (reviewStatistics.reviewMaxAuthorNumReviews.author_num_reviews > 1) { 35 | addReviewWithTitle('most reviews written', reviewStatistics.reviewMaxAuthorNumReviews) 36 | } 37 | } 38 | 39 | 40 | return (<> 41 | {Object.values(reviews).map((r: any) => )} 42 | {Object.values(reviews).length === 0 &&

    This product has too few reviews to determine highlighted ones

    } 43 | ) 44 | } 45 | 46 | export default HighlightedReviewList -------------------------------------------------------------------------------- /lib/utils/DBUtils.ts: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie" 2 | 3 | const REVIEW_STORE_VERSION = 1 4 | const OPERATION_STORE_VERSION = 1 5 | const RESERVED_DATABASE_NAMES = [ 6 | 'operation' 7 | ] 8 | 9 | function getReviewStoreForGame(appid: string) { 10 | const db = new Dexie(appid) 11 | db.version(REVIEW_STORE_VERSION).stores({ 12 | reviews: 'recommendationid, timestamp_created, timestamp_updated, language, author_playtime_at_review, author_playtime_forever, author_playtime_last_two_weeks, author_playtime_after_review_time, author_num_reviews, author_num_games_owned, votes_up, votes_funny, comment_count, length' 13 | }) 14 | return db['reviews'] 15 | } 16 | 17 | function getOperationStore() { 18 | const db = new Dexie('operation') 19 | db.version(OPERATION_STORE_VERSION).stores({ 20 | operations: 'appid' 21 | }) 22 | return db['operations'] 23 | } 24 | 25 | async function listReviewsInDatabase() { 26 | const games = (await Dexie.getDatabaseNames()).filter(n => RESERVED_DATABASE_NAMES.indexOf(n) === -1) 27 | 28 | let result = {} 29 | for (let game of games) { 30 | result[game] = await getReviewStoreForGame(game).count() 31 | } 32 | 33 | return result 34 | } 35 | 36 | function getStorageQuota() { 37 | if (navigator.storage && navigator.storage.estimate) { 38 | return navigator.storage.estimate() 39 | } else { 40 | throw new Error('StorageManager not found') 41 | } 42 | } 43 | 44 | async function deleteGame(appid: string) { 45 | await Dexie.delete(appid) 46 | await getOperationStore().delete(appid) 47 | } 48 | 49 | function logSearch(appid: string, start: Date, end: Date) { 50 | 51 | return getOperationStore().put({ 52 | appid: appid, 53 | start: start.getTime(), 54 | end: end.getTime(), 55 | when: Date.now() 56 | }) 57 | } 58 | 59 | function getSearch(appid: string) { 60 | return getOperationStore().get(appid.toString()) 61 | } 62 | 63 | export default { 64 | logSearch: logSearch, 65 | getSearch: getSearch, 66 | getStorageQuota: getStorageQuota, 67 | getReviewStoreForGame: getReviewStoreForGame, 68 | listReviewsInDatabase: listReviewsInDatabase, 69 | deleteGame: deleteGame, 70 | } -------------------------------------------------------------------------------- /lib/utils/SteamLocales.ts: -------------------------------------------------------------------------------- 1 | // API language codes mapped to other names 2 | // https://partner.steamgames.com/doc/store/localization 3 | const supportedLocales = { 4 | arabic: { 5 | englishName: 'Arabic' 6 | }, 7 | bulgarian: { 8 | englishName: 'Bulgarian' 9 | }, 10 | schinese: { 11 | englishName: 'Simplified Chinese' 12 | }, 13 | tchinese: { 14 | englishName: 'Traditional Chinese' 15 | }, 16 | czech: { 17 | englishName: 'Czech' 18 | }, 19 | danish: { 20 | englishName: 'Danish' 21 | }, 22 | dutch: { 23 | englishName: 'Dutch' 24 | }, 25 | english: { 26 | englishName: 'English' 27 | }, 28 | finnish: { 29 | englishName: 'Finnish' 30 | }, 31 | french: { 32 | englishName: 'French' 33 | }, 34 | german: { 35 | englishName: 'German' 36 | }, 37 | greek: { 38 | englishName: 'Greek' 39 | }, 40 | hungarian: { 41 | englishName: 'Hungarian' 42 | }, 43 | indonesian: { 44 | englishName: 'Indonesian' 45 | }, 46 | italian: { 47 | englishName: 'Italian' 48 | }, 49 | japanese: { 50 | englishName: 'Japanese' 51 | }, 52 | koreana: { 53 | englishName: 'Korean' 54 | }, 55 | norwegian: { 56 | englishName: 'Norwegian' 57 | }, 58 | polish: { 59 | englishName: 'Polish' 60 | }, 61 | portuguese: { 62 | englishName: 'Portuguese' 63 | }, 64 | brazilian: { 65 | englishName: 'Portuguese - Brazil' 66 | }, 67 | romanian: { 68 | englishName: 'Romanian' 69 | }, 70 | russian: { 71 | englishName: 'Russian' 72 | }, 73 | spanish: { 74 | englishName: 'Spanish - Spain' 75 | }, 76 | latam: { 77 | englishName: 'Spanish - Latin America' 78 | }, 79 | swedish: { 80 | englishName: 'Swedish' 81 | }, 82 | thai: { 83 | englishName: 'Thai' 84 | }, 85 | turkish: { 86 | englishName: 'Turkish' 87 | }, 88 | ukrainian: { 89 | englishName: 'Ukrainian' 90 | }, 91 | vietnamese: { 92 | englishName: 'Vietnamese' 93 | } 94 | } 95 | 96 | export default supportedLocales -------------------------------------------------------------------------------- /components/GameCard.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router" 2 | import React from "react" 3 | import { Badge, Button, Card } from "react-bootstrap" 4 | import ReviewScoreBadge from "./ReviewScoreBadge" 5 | 6 | const GameCard = ({ game, onExplore }) => { 7 | 8 | // Use router to navigate to game if selected 9 | const router = useRouter() 10 | 11 | const steamUrl = `https://store.steampowered.com/app/${game.steam_appid}` 12 | const steamDBUrl = `https://steamdb.info/app/${game.steam_appid}` 13 | const steamSpyUrl = `https://steamspy.com/app/${game.steam_appid}` 14 | const type = game.type === 'dlc' ? 'DLC' : game.type.charAt(0).toUpperCase() + game.type.slice(1) 15 | const developers = game.developers ? game.developers.join(', ') : 'Unknown' 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | {game.total_reviews === 0 ?

    {game.name}

    : {game.name}} 23 |   24 | 25 | {(game.content_descriptors.ids !== null && game.content_descriptors.ids.indexOf(3) !== -1) && Adult} 26 |
    27 | 28 | {type} by {developers} {game.release_date.coming_soon ? 'coming soon' : `released ${game.release_date.date}`}
    29 |
    30 | 31 | Steam Store | SteamDB | SteamSpy 32 | 33 | {game.short_description} 34 |
    35 | 36 |
    37 | 41 |
    42 |
    43 |
    44 | ) 45 | } 46 | 47 | export default GameCard -------------------------------------------------------------------------------- /components/ReviewText.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { Button } from "react-bootstrap" 3 | import Highlighter from "react-highlight-words" 4 | import _ from "lodash" 5 | import { FaArrowDown, FaArrowUp, FaCopy, FaRegCopy } from "react-icons/fa" 6 | 7 | const MIN_REVIEW_TEXT_TRUNCATE_LENGTH_THRESHOLD = 256 8 | 9 | const ReviewText = ({ review, viewOptions, filters, textLength }) => { 10 | 11 | let textLengthActual = textLength < MIN_REVIEW_TEXT_TRUNCATE_LENGTH_THRESHOLD ? MIN_REVIEW_TEXT_TRUNCATE_LENGTH_THRESHOLD : textLength 12 | 13 | const [initialExpanded, setInitialExpanded] = useState(!viewOptions.truncateLongReviews) 14 | const [expanded, setExpanded] = useState(!viewOptions.truncateLongReviews) 15 | const [copied, setCopied] = useState(false) 16 | const [copiedTimerHandle, setCopiedTimerHandle] = useState(null) 17 | 18 | if (initialExpanded !== !viewOptions.truncateLongReviews) { 19 | setInitialExpanded(!viewOptions.truncateLongReviews) 20 | setExpanded(!viewOptions.truncateLongReviews) 21 | } 22 | 23 | const needsTruncating = review.review.length > textLengthActual 24 | 25 | const truncatedReviewText = viewOptions.censorBadWords && review.censored !== undefined ? _.truncate(review.censored, {length: textLengthActual }) 26 | : _.truncate(review.review, {length: textLengthActual }) 27 | 28 | const textToHighlight = viewOptions.censorBadWords && review.censored !== undefined ? review.censored : review.review 29 | 30 | const copyTextToClipboard = () => { 31 | 32 | clearTimeout(copiedTimerHandle) 33 | 34 | navigator.clipboard.writeText(textToHighlight) 35 | 36 | setCopied(true) 37 | 38 | const timerHandle = setTimeout(() => { 39 | setCopied(false) 40 | }, 3000) 41 | 42 | setCopiedTimerHandle(timerHandle) 43 | } 44 | 45 | return (<> 46 | 51 |
    52 | {needsTruncating && !expanded && <>  } 53 | {needsTruncating && expanded && <>  } 54 | 55 | ) 56 | } 57 | 58 | export default ReviewText -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, {Head, Html, Main, NextScript} from 'next/document' 2 | import React from "react" 3 | 4 | class MyDocument extends Document { 5 | static async getInitialProps(ctx) { 6 | const initialProps = await Document.getInitialProps(ctx) 7 | return { ...initialProps } 8 | } 9 | 10 | render() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
    40 | 41 | 42 | 43 | ) 44 | } 45 | } 46 | 47 | export default MyDocument -------------------------------------------------------------------------------- /lib/utils/CommonWords.ts: -------------------------------------------------------------------------------- 1 | const commonWords = [ 2 | "yes", 3 | "no", 4 | "maybe", 5 | "good", 6 | "bad", 7 | "great", 8 | "mostly", 9 | "awful", 10 | "terrible", 11 | "fun", 12 | "time", 13 | "better", 14 | "dont", 15 | "wont", 16 | "the", 17 | "and", 18 | "that", 19 | "for", 20 | "you", 21 | "are", 22 | "this", 23 | "games", 24 | "game", 25 | "have", 26 | "all", 27 | "but", 28 | "not", 29 | "was", 30 | "with", 31 | "without", 32 | "out", 33 | "will", 34 | "itself", 35 | "got", 36 | "shall", 37 | "shan", 38 | "such", 39 | "both", 40 | "many", 41 | "can", 42 | "could", 43 | "would", 44 | "should", 45 | "did", 46 | "some", 47 | "one", 48 | "two", 49 | "three", 50 | "four", 51 | "five", 52 | "six", 53 | "seven", 54 | "eight", 55 | "nine", 56 | "ten", 57 | "play", 58 | "they", 59 | "them", 60 | "there", 61 | "their", 62 | "who", 63 | "why", 64 | "has", 65 | "over", 66 | "your", 67 | "most", 68 | "more", 69 | "then", 70 | "again", 71 | "ever", 72 | "while", 73 | "buy", 74 | "honestly", 75 | "very", 76 | "actually", 77 | "too", 78 | "had", 79 | "like", 80 | "just", 81 | "from", 82 | "really", 83 | "even", 84 | "what", 85 | "don", 86 | "doesn", 87 | "people", 88 | "cannot", 89 | "than", 90 | "does", 91 | "now", 92 | "back", 93 | "otherwise", 94 | "because", 95 | "anything", 96 | "seem", 97 | "seems", 98 | "recommend", 99 | "times", 100 | "want", 101 | "played", 102 | "get", 103 | "gets", 104 | "use", 105 | "useful", 106 | "review", 107 | "reviews", 108 | "isn", 109 | "only", 110 | "its", 111 | "these", 112 | "how", 113 | "still", 114 | "when", 115 | "know", 116 | "which", 117 | "any", 118 | "way", 119 | "other", 120 | "first", 121 | "second", 122 | "third", 123 | "low", 124 | "high", 125 | "having", 126 | "well", 127 | "also", 128 | "lot", 129 | "were", 130 | "into", 131 | "top", 132 | "say", 133 | "few", 134 | "made", 135 | "didn", 136 | "bit", 137 | "think", 138 | "around", 139 | "though", 140 | "thing", 141 | "something", 142 | "after", 143 | "rather", 144 | "until", 145 | "off", 146 | "being", 147 | "much", 148 | "about", 149 | "nothing", 150 | "never", 151 | "those", 152 | "see", 153 | "need", 154 | "much", 155 | "see", 156 | "where", 157 | "little", 158 | "every", 159 | "especially", 160 | "give", 161 | "must", 162 | "before", 163 | "things", 164 | "here", 165 | "wait", 166 | "hope", 167 | "own", 168 | "make", 169 | "etc", 170 | "early", 171 | "access", 172 | "part", 173 | "being", 174 | "been", 175 | "going", 176 | "yet", 177 | "once", 178 | "twice", 179 | "www", 180 | "http", 181 | "https", 182 | "youtube", 183 | "com", 184 | "watch", 185 | "through", 186 | "near", 187 | "far", 188 | "wasn", 189 | "last", 190 | "away", 191 | "feels", 192 | "find" 193 | ] 194 | 195 | export default commonWords -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import { Container, Navbar, Nav, Form, InputGroup, Button } from "react-bootstrap" 3 | import Footer from "./Footer" 4 | import { withRouter } from 'next/router' 5 | import { WithRouterProps } from "next/dist/client/with-router" 6 | import DarkModeToggle from "./DarkModeToggle" 7 | import Link from "next/link" 8 | import { FaSearch } from "react-icons/fa" 9 | 10 | class Layout extends Component { 11 | 12 | constructor(props) { 13 | super(props) 14 | this.state = { 15 | searchTerm: '' 16 | } 17 | } 18 | 19 | handleSearchInput = (e: any) => { 20 | if (e.code === 'Enter') { 21 | this.props.router.push({ 22 | pathname: '/', 23 | query: { search: encodeURI(e.target.value) } 24 | }, null, { shallow: false }) 25 | 26 | this.setState({ searchTerm: '' }) 27 | } else { 28 | this.setState({ searchTerm: e.target.value }) 29 | } 30 | } 31 | 32 | render () { 33 | const { children } = this.props 34 | 35 | return ( 36 |
    37 | 38 | 39 | 40 | 41 | Steam Review Explorer logo 47 | Steam Review Explorer 48 | 49 | 50 | 51 | 52 | 58 |
    59 | 60 |
    61 |
    62 | 63 | 64 | 67 | 68 |
    69 |
    70 |
    71 |
    72 | 73 | {children} 74 | 75 |
    76 |
    77 | ) 78 | } 79 | } 80 | 81 | export default withRouter(Layout) -------------------------------------------------------------------------------- /components/HighlightedReview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Badge, Card } from "react-bootstrap" 3 | import dateFormat from "dateformat" 4 | import supportedLocales from "lib/utils/SteamLocales" 5 | 6 | const dateFormatString = 'dd/mm/yyyy h:MM:sstt' 7 | 8 | const HighlightedReview = ({ game, titles, review }) => { 9 | 10 | let title = titles.join(', ') 11 | title = title.charAt(0).toUpperCase() + title.slice(1) 12 | 13 | const hoursPlaytimeForever = Math.round(review.author_playtime_forever / 60) 14 | const hoursPlaytimeAtReview = Math.round(review.author_playtime_at_review / 60) 15 | 16 | const steamUrl = `https://steamcommunity.com/profiles/${review.author_steamid}/recommended/${game.steam_appid}/` 17 | 18 | return ( 19 | 20 | 21 | {title} 22 | 23 | {review.voted_up ? '👍 Recommended' : '👎 Not recommended'} with {review.author_playtime_forever < 60 ? `${review.author_playtime_forever} minute${review.author_playtime_forever !== 1 ? 's' : ''}` : `${hoursPlaytimeForever.toLocaleString()} hour${hoursPlaytimeForever !== 1 ? 's' : ''}`} recorded {review.author_playtime_at_review !== review.author_playtime_forever ? 24 | review.author_playtime_at_review < 60 ? `(${review.author_playtime_at_review} minute${review.author_playtime_at_review !== 1 ? 's at review time)' : ' at review time)'}` : `(${hoursPlaytimeAtReview.toLocaleString()} hour${hoursPlaytimeAtReview !== 1 ? 's at review time)' : ' at review time)'}` : ''} 25 | 26 | 27 | Posted in {supportedLocales[review.language].englishName} @ {dateFormat(new Date(review.timestamp_created * 1000), dateFormatString)} {review.timestamp_created !== review.timestamp_updated && `(Updated ${dateFormat(new Date(review.timestamp_updated * 1000), dateFormatString)})`} 28 | 29 | 30 | {review.written_during_early_access && Written during early access} 31 | {review.received_for_free && Marked as received for free} 32 | 33 | 34 | {review.review} 35 | 36 | {review.author_num_reviews > 1 && 37 | {review.author_num_reviews.toLocaleString()} reviews written 38 | } 39 | {review.votes_up > 0 && 40 | {review.votes_up.toLocaleString()} {review.votes_up === 1 ? 'person' : 'people'} found this review helpful 41 | } 42 | {review.votes_funny > 0 && 43 | {review.votes_funny.toLocaleString()} {review.votes_funny === 1 ? 'person' : 'people'} found this review funny 44 | } 45 | {review.comment_count > 0 && 46 | There {review.comment_count === 1 ? 'is' : 'are'} {review.comment_count.toLocaleString()} {review.comment_count === 1 ? 'comment' : 'comments'} on this review 47 | } 48 | 49 | 50 | ) 51 | } 52 | 53 | export default HighlightedReview -------------------------------------------------------------------------------- /components/visualisations/WordFrequency.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Badge, Button, Table } from "react-bootstrap" 3 | 4 | const WordFrequency = ({ game, reviewStatistics, handleFilterPreset }) => { 5 | 6 | const navToPositiveWord = (word) => { 7 | handleFilterPreset({ 8 | searchTerm: word, 9 | languages : [{label: 'English', value: 'english'}], 10 | votedUpPositive: true, 11 | votedUpNegative: false 12 | }) 13 | } 14 | 15 | const navToNegativeWord = (word) => { 16 | handleFilterPreset({ 17 | searchTerm: word, 18 | languages : [{label: 'English', value: 'english'}], 19 | votedUpPositive: false, 20 | votedUpNegative: true 21 | }) 22 | } 23 | 24 | return reviewStatistics.positiveWordFrequencyList.length === 0 || reviewStatistics.negativeWordFrequencyList.length === 0 || reviewStatistics.positiveWordFrequencyList.length !== reviewStatistics.negativeWordFrequencyList.length ? 25 |

    Insufficient number of reviews for this visualisation

    : 26 | <> 27 |
    Top 20 Frequently Occuring Words*
    28 | 29 | 30 | 31 | 34 | 37 | 40 | 41 | 42 | 43 | 46 | 49 | 52 | 55 | 56 | 57 | 58 | {reviewStatistics.positiveWordFrequencyList.map((e, i) => { 59 | return ( 60 | 61 | 64 | 67 | 72 | 75 | 80 | 81 | ) 82 | })} 83 | 84 |
    32 | {/* Rank */} 33 | 35 | Positive reviews 36 | 38 | Negative reviews 39 |
    44 | Word 45 | 47 | Total occurences 48 | 50 | Word 51 | 53 | Total occurences 54 |
    62 | {i + 1} 63 | 65 | {e[0]} 66 | 68 | 71 | 73 | {reviewStatistics.negativeWordFrequencyList[i][0]} 74 | 76 | 79 |
    85 |

    86 | 87 | *Excluding a list of commonly occuring words, and only counting each word once per review. 88 | Word clouds may provide misleading insights! 89 | 90 |

    91 | 92 | } 93 | 94 | export default WordFrequency -------------------------------------------------------------------------------- /components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Button, Col, Container, ProgressBar, Row } from "react-bootstrap" 3 | 4 | /** 5 | * Format milliseconds into a more human-readable 6 | * format 7 | * 8 | * @param ms Milliseconds as a number 9 | * @returns The value represented as HH:MM:SS 10 | */ 11 | function formatMs(ms: number) { 12 | let d = new Date(1000 * Math.round(ms / 1000)) 13 | function pad(i: number) { return ('0' + i).slice(-2) } 14 | return d.getUTCHours() + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()) 15 | } 16 | 17 | /** 18 | * A component to display scraping progress 19 | * of game reviews 20 | */ 21 | const Loader = ({ game, update, error, proceedCallback, timeStartedScraping, foreverTime = true }) => { 22 | 23 | if (update.count > game.total_reviews) { 24 | update.count = game.total_reviews 25 | } 26 | 27 | let percentCompleted = Math.round(update.count / game.total_reviews * 100) 28 | const countFormatted = update.count.toLocaleString() 29 | const checkedFormatted = update.checked.toLocaleString() 30 | const totalFormatted = game.total_reviews.toLocaleString() 31 | const kilobytesFormatted = (Math.round(update.bytes / 1000)).toLocaleString() 32 | const estimatedTimeRemaining = formatMs(((game.total_reviews - update.checked) / 100) * update.averageRequestTime) 33 | const timeElapsedMs = Date.now() - timeStartedScraping 34 | const timeElapsed = formatMs(Date.now() - timeStartedScraping) 35 | 36 | const message = update.finished ?

    Finished loading {countFormatted} review{update.count !== 1 ? 's' : ''} for {game.name}, computing statistics...

    :

    Searching backwards in time! Checked {checkedFormatted} of an estimated total {totalFormatted} review{game.total_reviews !== 1 ? 's' : ''} ( 37 | ~{kilobytesFormatted}kb) for {game.name}, {timeElapsed} elapsed{foreverTime ? <>, estimated time remaining {estimatedTimeRemaining} : <>{/*A time estimate cannot be provided for custom searches.*/}}

    38 | 39 | const loaderContents = update.finished && update.count === 0 ? 40 | <> 41 |

    No reviews were found matching your criteria

    42 | 43 | 44 |
    45 | 48 |
    49 | 50 |
    51 | 52 | : 53 | <> 54 | 58 | {message} 59 | {error && error.attemptNumber &&

    60 | Stopped receiving reviews from Steam, making sure we're at the end (attempt {error.attemptNumber} of {error.attemptNumber + error.triesLeft}) 61 |

    } 62 |

    63 | {timeElapsedMs > 20000 ? Steam's API limits us to requesting 100 reviews at a time, every few seconds...{!foreverTime && <> We also can't start looking from a specific point in time...} :  } 64 |

    65 | 66 | 67 |
    68 | 71 |
    72 | 73 | 74 |
    75 | 78 |
    79 | 80 |
    81 | 82 | 83 | return ( 84 | 85 | {loaderContents} 86 | 87 | ) 88 | } 89 | 90 | export default Loader -------------------------------------------------------------------------------- /components/PaginatedReviewTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { RefObject, useEffect } from "react" 2 | import { useState } from "react" 3 | import { Button, Col, Form, FormSelect, Row } from "react-bootstrap" 4 | import { FaArrowUp } from "react-icons/fa" 5 | import Paginator from "./Paginator" 6 | import ReviewTable from "./ReviewTable" 7 | 8 | const useKeyPress = function (targetKey: string) { 9 | 10 | const [keyPressed, setKeyPressed] = useState(false) 11 | 12 | function downHandler({ key }: { key: string }) { 13 | if (key === targetKey) { 14 | setKeyPressed(true) 15 | } 16 | } 17 | 18 | const upHandler = ({ key }: { key: string }) => { 19 | if (key === targetKey) { 20 | setKeyPressed(false) 21 | } 22 | }; 23 | 24 | React.useEffect(() => { 25 | window.addEventListener("keydown", downHandler) 26 | window.addEventListener("keyup", upHandler) 27 | 28 | return () => { 29 | window.removeEventListener("keydown", downHandler) 30 | window.removeEventListener("keyup", upHandler) 31 | } 32 | }) 33 | 34 | return keyPressed 35 | } 36 | 37 | const PaginatedReviewTable = ({ index, filteredReviewCount, filters, viewOptions, game, reviews, sorting, handleSort, handleChangeIndex, exportComponent, keyNavigationEnabled, reviewTextTruncateLength, pageSize, setPageSize }) => { 38 | 39 | let lastIndex = Math.ceil(filteredReviewCount / pageSize) - 1 40 | 41 | if (lastIndex < 0) { 42 | lastIndex = 0 43 | } 44 | 45 | const ref = React.createRef() 46 | 47 | const leftPress = useKeyPress("ArrowLeft") 48 | const rightPress = useKeyPress("ArrowRight") 49 | 50 | useEffect(() => { 51 | if (!keyNavigationEnabled) { 52 | return 53 | } 54 | 55 | if (leftPress && index - 1 >= 0) { 56 | setIndexAndScrollTop(index - 1) 57 | } 58 | if (rightPress && index + 1 <= lastIndex) { 59 | setIndexAndScrollTop(index + 1) 60 | } 61 | }, [leftPress, rightPress]) 62 | 63 | const setIndexAndScrollTop = (i: number) => { 64 | 65 | handleChangeIndex(i) 66 | if (ref.current) { 67 | ref.current.scrollIntoView({ 68 | behavior: 'smooth' 69 | }) 70 | } 71 | } 72 | 73 | return (<>
    74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | { setPageSize(+e.target.value); handleChangeIndex(0)}}> 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | { exportComponent } 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | { setPageSize(+e.target.value); setIndexAndScrollTop(0)}}> 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |
    111 | 114 |
    115 | 116 |
    117 | 118 | 119 | { exportComponent } 120 | 121 |
    122 | ) 123 | } 124 | 125 | export default PaginatedReviewTable -------------------------------------------------------------------------------- /components/ReviewItem.tsx: -------------------------------------------------------------------------------- 1 | import dateFormat from "dateformat" 2 | import supportedLocales from "lib/utils/SteamLocales" 3 | import React from "react" 4 | import { FaCheck, FaRegThumbsDown, FaRegThumbsUp } from "react-icons/fa" 5 | import ReviewText from "./ReviewText" 6 | 7 | const dateFormatString = 'dd/mm/yyyy h:MM:sstt' 8 | 9 | const ReviewItem = ({ viewOptions, filters, game, review, reviewTextTruncateLength }) => { 10 | 11 | const timeCreated = dateFormat(new Date(review.timestamp_created * 1000), dateFormatString) 12 | const timeUpdated = review.timestamp_updated > review.timestamp_created ? dateFormat(new Date(review.timestamp_updated * 1000), dateFormatString) : '' 13 | const timeLastPlayed = review.author_last_played > 0 ? dateFormat(new Date(review.author_last_played * 1000), dateFormatString): '' 14 | let language = supportedLocales[review.language].englishName 15 | 16 | const playtimeAtReviewTimeHours = Math.round(review.author_playtime_at_review / 60) 17 | const playtimeForeverHours = Math.round(review.author_playtime_forever / 60) 18 | const playtime2WeeksHours = Math.round(review.author_playtime_last_two_weeks / 60) 19 | 20 | const playtimeAtReview = review.author_playtime_at_review < 60 ? 21 | `${review.author_playtime_at_review} minute${review.author_playtime_at_review !== 1 ? 's' : ''}` 22 | : `${playtimeAtReviewTimeHours.toLocaleString()} hour${playtimeAtReviewTimeHours !== 1 ? 's' : ''}` 23 | 24 | const playtimeForever = review.author_playtime_forever < 60 ? 25 | `${review.author_playtime_forever} minute${review.author_playtime_forever !== 1 ? 's' : ''}` 26 | : `${playtimeForeverHours.toLocaleString()} hour${playtimeForeverHours !== 1 ? 's' : ''}` 27 | 28 | const playtime2Weeks = review.author_playtime_last_two_weeks < 60 ? 29 | `${review.author_playtime_last_two_weeks} minute${review.author_playtime_last_two_weeks !== 1 ? 's' : ''}` 30 | : `${playtime2WeeksHours.toLocaleString()} hour${playtime2WeeksHours !== 1 ? 's' : ''}` 31 | 32 | const hiddenColumnsFormatted = viewOptions.hiddenColumns.map((v: { value: string }) => v.value) 33 | 34 | return ( 35 | 36 | 42 | 43 | {hiddenColumnsFormatted.indexOf('timeCreated') === -1 &&
    {timeCreated}
    } 44 | {hiddenColumnsFormatted.indexOf('timeUpdated') === -1 &&
    {timeUpdated}
    } 45 | {hiddenColumnsFormatted.indexOf('votedUp') === -1 &&
    {review.voted_up ? : }
    } 46 | {hiddenColumnsFormatted.indexOf('language') === -1 &&
    {language}
    } 47 |
    48 | 49 |
    50 | {hiddenColumnsFormatted.indexOf('playtimeAtReview') === -1 &&
    {playtimeAtReview}
    } 51 | {hiddenColumnsFormatted.indexOf('playtimeForever') === -1 &&
    {playtimeForever}
    } 52 | {hiddenColumnsFormatted.indexOf('playtime2Weeks') === -1 &&
    {playtime2Weeks}
    } 53 | {hiddenColumnsFormatted.indexOf('earlyAccess') === -1 &&
    {review.written_during_early_access && }
    } 54 | {hiddenColumnsFormatted.indexOf('receivedForFree') === -1 &&
    {review.received_for_free && }
    } 55 | {hiddenColumnsFormatted.indexOf('steamPurchase') === -1 &&
    {review.steam_purchase && }
    } 56 | {hiddenColumnsFormatted.indexOf('authorNumReviews') === -1 &&
    {review.author_num_reviews.toLocaleString()}
    } 57 | {hiddenColumnsFormatted.indexOf('authorNumGames') === -1 &&
    {review.author_num_games_owned.toLocaleString()}
    } 58 | {hiddenColumnsFormatted.indexOf('authorContinuedPlaying') === -1 &&
    {review.author_continued_playing && }
    } 59 | {hiddenColumnsFormatted.indexOf('authorLastPlayed') === -1 &&
    {timeLastPlayed}
    } 60 | {hiddenColumnsFormatted.indexOf('votesUp') === -1 &&
    {review.votes_up.toLocaleString()}
    } 61 | {hiddenColumnsFormatted.indexOf('votesFunny') === -1 &&
    {review.votes_funny.toLocaleString()}
    } 62 | {hiddenColumnsFormatted.indexOf('commentCount') === -1 &&
    {review.comment_count.toLocaleString()}
    } 63 | 64 | ) 65 | } 66 | 67 | export default ReviewItem -------------------------------------------------------------------------------- /pages/storage.tsx: -------------------------------------------------------------------------------- 1 | import StorageCardDeck from "components/StorageCardDeck"; 2 | import DBUtils from "lib/utils/DBUtils"; 3 | import SteamWebApiClient from "lib/utils/SteamWebApiClient"; 4 | import Link from "next/link"; 5 | import React, { useEffect, useState } from "react"; 6 | import { Breadcrumb, Button, Container, ProgressBar, Row, Spinner } from "react-bootstrap"; 7 | 8 | // 10GB 9 | const CONSERVATIVE_QUOTA = 10737418240 10 | 11 | export default function Storage() { 12 | 13 | const [quota, setQuota] = useState(null) 14 | const [reviewCounts, setReviewCounts] = useState({}) 15 | const [games, setGames] = useState({}) 16 | const [loading, setLoading] = useState(true) 17 | const [searches, setSearches] = useState({}) 18 | 19 | const updateData = () => { 20 | setLoading(true) 21 | 22 | let promises = [] 23 | 24 | promises.push(DBUtils.getStorageQuota().then(quota => { 25 | if (CONSERVATIVE_QUOTA < quota.quota) { 26 | quota.quota = CONSERVATIVE_QUOTA 27 | } else { 28 | quota.quota /= 2 29 | } 30 | 31 | setQuota(quota) 32 | })) 33 | 34 | promises.push(DBUtils.listReviewsInDatabase().then(reviewCounts => { 35 | setReviewCounts(reviewCounts) 36 | 37 | let gameRequests = [] 38 | let searchRequests = [] 39 | for (let appid of Object.keys(reviewCounts)) { 40 | gameRequests.push(SteamWebApiClient.getGame(appid)) 41 | searchRequests.push(DBUtils.getSearch(appid)) 42 | } 43 | let gamePromise = Promise.all(gameRequests).then(games => { 44 | let _games = {} 45 | for (let game of games) { 46 | _games[game.steam_appid] = game 47 | } 48 | setGames(_games) 49 | }) 50 | let searchPromise = Promise.all(searchRequests).then((searches) => { 51 | let _searches = {} 52 | for (let search of searches) { 53 | _searches[search.appid] = search 54 | } 55 | setSearches(_searches) 56 | }) 57 | 58 | return Promise.all([gamePromise, searchPromise]) 59 | })) 60 | 61 | Promise.all(promises).then(() => { 62 | setLoading(false) 63 | }) 64 | } 65 | 66 | useEffect(() => { 67 | updateData() 68 | }, []) 69 | 70 | let quotaPercent = null 71 | if (quota) { 72 | quotaPercent = Math.round((quota.usage / quota.quota) * 100) 73 | } 74 | 75 | const handleDelete = async (appid) => { 76 | await DBUtils.deleteGame(appid) 77 | 78 | delete games[appid] 79 | delete reviewCounts[appid] 80 | 81 | updateData() 82 | } 83 | 84 | const handleDeleteAll = async () => { 85 | 86 | let deleteRequests = [] 87 | for (let game of Object.keys(games)) { 88 | deleteRequests.push(DBUtils.deleteGame(game)) 89 | } 90 | 91 | return Promise.all(deleteRequests).then(() => { 92 | setGames({}) 93 | setReviewCounts({}) 94 | setSearches({}) 95 | updateData() 96 | }) 97 | } 98 | 99 | let progressBarVariant = 'info' 100 | if (quotaPercent >= 60) { 101 | progressBarVariant = 'warning' 102 | } 103 | if (quotaPercent >= 80) { 104 | progressBarVariant = 'danger' 105 | } 106 | 107 | return (<> 108 |
    109 | 110 |
  • Home
  • 111 | Storage 112 |
    113 |
    114 | {!loading && quotaPercent !== null && <> 115 | 119 |

    {quotaPercent}% estimated available site storage space used{quotaPercent >= 80 && ', consider deleting some games'}{quotaPercent >= 95 && ', site may fail to function properly otherwise'}

    120 | {Object.keys(games).length > 0 &&
    } 121 | } 122 | {loading && 123 | 124 | Loading... 125 | 126 | } 127 | {!loading && Object.keys(games).length === 0 &&

    Scraped games will appear here

    } 128 | {!loading && Object.keys(games).length > 0 && } 129 | ) 130 | } -------------------------------------------------------------------------------- /components/visualisations/LanguageDistribution.tsx: -------------------------------------------------------------------------------- 1 | import supportedLocales from "lib/utils/SteamLocales"; 2 | import React from "react"; 3 | import { Button, Table } from "react-bootstrap"; 4 | import { FaCheck, } from "react-icons/fa"; 5 | import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; 6 | 7 | const LanguageDistribution = ({ game, reviewStatistics, handleFilterPreset }) => { 8 | 9 | const numSupportedLanguagesAsNum = Object.keys(game.parsed_supported_languages).length 10 | const numSupportedLanguages = numSupportedLanguagesAsNum.toLocaleString() 11 | const numAvailableLanguages = Object.keys(supportedLocales).length.toLocaleString() 12 | const numReviewLanguagesAsNum = Object.keys(reviewStatistics.totalLanguages).length 13 | const numReviewLanguages = numReviewLanguagesAsNum.toLocaleString() 14 | let reviewLanguagesUnsupported = [] 15 | let numReviewsInUnsupportedLangauges = 0 16 | for (let lang of Object.keys(reviewStatistics.totalLanguages)) { 17 | if (Object.keys(game.unsupported_languages).indexOf(lang) !== -1) { 18 | reviewLanguagesUnsupported.push(lang) 19 | numReviewsInUnsupportedLangauges += reviewStatistics.totalLanguages[lang].total 20 | } 21 | } 22 | 23 | const mungeReviewData = (totalLanguages) => { 24 | return Object.entries(totalLanguages).sort((a:any, b:any) => b[1].total - a[1].total).map((a: any) => { 25 | return { 26 | name: supportedLocales[a[0]].englishName, 27 | langCode: a[0], 28 | numReviews: a[1].total 29 | } 30 | }) 31 | } 32 | 33 | const data = mungeReviewData(reviewStatistics.totalLanguages) 34 | const COLORS = ['#024b7a', '#03578f', '#056aad', '#0b76bd', '#1586d1', '#2c8bc9', '#4093c9', '#57a5d9', '#5998c2', '#75a8c9', '#7ba9c7', '#85b0cc', '#94bad4', '#9ec1d9', '#a5c3d6', '#bad6e8', '#c5dded', '#cadce8', '#e4eff7'] 35 | 36 | const navToLanguage = (languageName: string, languageCode: string) => { 37 | handleFilterPreset({ 38 | languages: [{label: languageName, value: languageCode}] 39 | }) 40 | } 41 | 42 | return ( 43 | <> 44 | {numSupportedLanguagesAsNum > 0 &&
    45 |

    This product supports {numSupportedLanguages} of the {numAvailableLanguages} available Steam languages

    46 |

    Reviews have been retrieved in {numReviewLanguages} language{numReviewLanguagesAsNum === 1 ? '' : 's'}, {reviewLanguagesUnsupported.length} of which {reviewLanguagesUnsupported.length === 1 ? 'is' : 'are'} unsupported

    47 |

    {numReviewsInUnsupportedLangauges} review{numReviewsInUnsupportedLangauges === 1 ? '' : 's'} ({Math.round(numReviewsInUnsupportedLangauges / reviewStatistics.totalReviews * 100)}%) {numReviewsInUnsupportedLangauges === 1 ? 'has' : 'have'} been made in {numReviewsInUnsupportedLangauges === 1 ? 'an ' : ''}unsupported language{numReviewsInUnsupportedLangauges === 1 ? '' : 's'}

    48 |
    Unsupported product languages
    49 |

    {Object.keys(game.unsupported_languages).map(e => supportedLocales[e].englishName).sort().join(', ')}

    50 |
    Unsupported review languages
    51 |

    {reviewLanguagesUnsupported.map(e => supportedLocales[e].englishName).sort().join(', ')}

    52 |
    } 53 | 54 | Note that Steam allows users to tag their review's language, defaulting to their account's primary language; 55 | therefore, the actual content of a review may be in a different language mix to that stated 56 | 57 |
    Language Distribution
    58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {data.map((e, i) => { 73 | const ref = reviewStatistics.totalLanguages[e.langCode] 74 | const langSupported = Object.keys(game.parsed_supported_languages).indexOf(e.langCode) !== -1 75 | 76 | return 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | })} 87 | 88 |
    TotalProportionLanguageSupportedTotal Positive% PositiveTotal Negative% Negative
    {ref.total.toLocaleString()}{Math.round(ref.total / reviewStatistics.totalReviews * 100)}%{langSupported && }{ref.positive.toLocaleString()}{Math.round(ref.positive / ref.total * 100)}%{ref.negative.toLocaleString()}{Math.round(ref.negative / ref.total * 100)}%
    89 | 90 | 91 | 92 | 93 | 94 | {data.map((entry, index) => ( 95 | 96 | ))} 97 | 98 | 99 | 100 | 101 | ) 102 | } 103 | 104 | export default LanguageDistribution -------------------------------------------------------------------------------- /lib/utils/curseWords.ts: -------------------------------------------------------------------------------- 1 | const curseWords = { 2 | // Fuck 3 | "fuck": "fuck", 4 | "fucks": "fuck", 5 | "fucky": "fuck", 6 | "fuckin": "fuck", 7 | "fucking": "fuck", 8 | "fucko": "fuck", 9 | "fucker": "fuck", 10 | "fucked": "fuck", 11 | "fucc": "fuck", 12 | "fucken": "fuck", 13 | "fukken": "fuck", 14 | "motherfucker": "fuck", 15 | "mofo": "fuck", 16 | "muhfuck": "fuck", 17 | "muhfucker": "fuck", 18 | "motherfuck": "fuck", 19 | "mofuck": "fuck", 20 | "bumblefuck": "fuck", 21 | "bumfuck": "fuck", 22 | "clusterfuck": "fuck", 23 | "facefuck": "fuck", 24 | "fingerfuck": "fuck", 25 | "fuckabout": "fuck", 26 | "fuckaround": "fuck", 27 | "fuckboy": "fuck", 28 | "fook": "fuck", 29 | "feck": "fuck", 30 | "fookin": "fuck", 31 | "fooking": "fuck", 32 | "fecking": "fuck", 33 | "feckin": "fuck", 34 | "fuckery": "fuck", 35 | "fuckface": "fuck", 36 | "fack": "fuck", 37 | "fak": "fuck", 38 | "fuckity": "fuck", 39 | "mindfuck": "fuck", 40 | "frack": "fuck", 41 | "facking": "fuck", 42 | "freakin": "fuck", 43 | "freaking": "fuck", 44 | "freaker": "fuck", 45 | "fuuck": "fuck", 46 | "fuuuck": "fuck", 47 | "sex": "fuck", 48 | "shag": "fuck", 49 | "shagging": "fuck", 50 | "sext": "fuck", 51 | "sexting": "fuck", 52 | "humping": "fuck", 53 | // Shit 54 | "shit": "shit", 55 | "shits": "shit", 56 | "shite": "shit", 57 | "shittiest": "shit", 58 | "shiter": "shit", 59 | "shiters": "shit", 60 | "shitey": "shit", 61 | "shitty": "shit", 62 | "shitter": "shit", 63 | "shitters": "shit", 64 | "shat": "shit", 65 | "shats": "shit", 66 | "sheet": "shit", 67 | "sheets": "shit", 68 | "sheeit": "shit", 69 | "shittest": "shit", 70 | "shitbag": "shit", 71 | "shitbags": "shit", 72 | "bullshit": "shit", 73 | "bullshitting": "shit", 74 | // Bitch 75 | "bitch": "bitch", 76 | "bitching": "bitch", 77 | "bitchin": "bitch", 78 | "bizzle": "bitch", 79 | "bitches": "bitch", 80 | "biatch": "bitch", 81 | "biatches": "bitch", 82 | "biznatch": "bitch", 83 | "bish": "bitch", 84 | "beech": "bitch", 85 | // Asshole 86 | "asshole": "asshole", 87 | "assholes": "asshole", 88 | "arsehole": "asshole", 89 | "arseholes": "asshole", 90 | "butthole": "asshole", 91 | "buttholes": "asshole", 92 | "arse": "asshole", 93 | "arses": "asshole", 94 | "arseface": "asshole", 95 | "bunghole": "asshole", 96 | "bungholes": "asshole", 97 | "ahole": "asshole", 98 | "bhole": "asshole", 99 | "rectum": "asshole", 100 | // Dick 101 | "dick": "dick", 102 | "dicks": "dick", 103 | "dicked": "dick", 104 | "dickwad": "dick", 105 | "dicky": "dick", 106 | "dicking": "dick", 107 | "cock": "dick", 108 | "cocks": "dick", 109 | "peepee": "dick", 110 | "peepees": "dick", 111 | "dildo": "dick", 112 | "dildos": "dick", 113 | "penis": "dick", 114 | "schlong": "dick", 115 | "prick": "dick", 116 | "bellend": "dick", 117 | "knob": "dick", 118 | "nob": "dick", 119 | // Cunt 120 | "cunt": "cunt", 121 | "vag": "cunt", 122 | "fanny": "cunt", 123 | "twat": "cunt", 124 | "twot": "cunt", 125 | "twatty": "cunt", 126 | "vulva": "cunt", 127 | "pussy": "cunt", 128 | "clunge": "cunt", 129 | "gash": "cunt", 130 | "punani": "cunt", 131 | // Bastard 132 | "bastard": "bastard", 133 | "bastards": "bastard", 134 | "basterd": "bastard", 135 | "basterds": "bastard", 136 | "bastad": "bastard", 137 | "bastads": "bastard", 138 | // Cum 139 | "semen": "cum", 140 | "spunk": "cum", 141 | "spunked": "cum", 142 | "spooge": "cum", 143 | "spooging": "cum", 144 | "spooged": "cum", 145 | "splooge": "cum", 146 | "splooging": "cum", 147 | "splooged": "cum", 148 | "cumming": "cum", 149 | "cummin": "cum", 150 | "cums": "cum", 151 | "coom": "cum", 152 | "cooms": "cum", 153 | "coomer": "cum", 154 | "cummies": "cum", 155 | "cummy": "cum", 156 | "ejaculation": "cum", 157 | // Jerkoff 158 | "jerkoff": "jerkoff", 159 | "jackoff": "jerkoff", 160 | "wank": "jerkoff", 161 | "masturbate": "jerkoff", 162 | "handjob": "jerkoff", 163 | "jobbie": "jerkoff", 164 | // Piss 165 | "piss": "piss", 166 | "pisses": "piss", 167 | "pisss": "piss", 168 | "pissing": "piss", 169 | "pissed": "piss", 170 | // Blowjob 171 | "blowjob": "blowjob", 172 | "blowjobs": "blowjob", 173 | "blowie": "blowjob", 174 | "blowies": "blowjob", 175 | "jobby": "blowjob", 176 | "jobbies": "blowjob", 177 | // Faggot 178 | "faggot": "the F word", 179 | "fag": "the F word", 180 | "faggy": "the F word", 181 | "homo": "the F word", 182 | "gayass": "the F word", 183 | // Racial slurs 184 | "nigger": "the N word", 185 | "niggers": "the N word", 186 | "nagger": "the N word", 187 | "nigga": "the N word", 188 | "niggas": "the N word", 189 | "negro": "the N word", 190 | "negros": "the N word", 191 | "darkie": "the N word", 192 | "darkies": "the N word", 193 | "chink": "the N word", 194 | "chinky": "the N word", 195 | "chinkies": "the N word", 196 | // Misc. 197 | "bollocks": "balls", 198 | "bollock": "balls", 199 | "bollocking": "balls", 200 | "douche": "douche", 201 | "douches": "douche", 202 | "douching": "douche", 203 | "tosser": "tosser", 204 | "tossa": "tosser", 205 | "pedophile": "pedophile", 206 | "pedo": "pedophile", 207 | "paedophile": "pedophile", 208 | "paedo": "pedophile", 209 | "kiddyfiddler": "pedophile", 210 | "pedobear": "pedophile", 211 | } 212 | 213 | export default curseWords -------------------------------------------------------------------------------- /components/ReviewTable.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Table } from "react-bootstrap" 3 | import ReviewItem from "./ReviewItem" 4 | import ColumnResizer from "column-resizer" 5 | import SortControl from "./SortControl" 6 | 7 | interface ReviewTableProps { 8 | game: any, 9 | reviews: any, 10 | sorting: any, 11 | handleSort: any, 12 | filters: any, 13 | viewOptions: any, 14 | reviewTextTruncateLength: number 15 | } 16 | 17 | class ReviewTable extends React.Component { 18 | 19 | resizer: any 20 | table: any 21 | 22 | constructor(props) { 23 | super(props) 24 | } 25 | 26 | componentDidMount() { 27 | this.enableResize() 28 | } 29 | 30 | componentWillUnmount() { 31 | this.disableResize() 32 | } 33 | 34 | componentDidUpdate() { 35 | this.enableResize() 36 | } 37 | 38 | componentWillUpdate() { 39 | this.disableResize(); 40 | } 41 | 42 | enableResize() { 43 | 44 | let options = { 45 | resizeMode: 'overflow', 46 | minWidth: 50, 47 | draggingClass: 'customDrag' 48 | } 49 | 50 | if (!this.resizer) { 51 | if (this.table) { 52 | this.resizer = new ColumnResizer( 53 | this.table, 54 | options 55 | ) 56 | } 57 | } else { 58 | this.resizer.reset(options) 59 | } 60 | } 61 | 62 | disableResize() { 63 | if (this.resizer) { 64 | this.resizer.reset({ disable: true }) 65 | } 66 | } 67 | 68 | 69 | render() { 70 | 71 | const hiddenColumnsFormatted = this.props.viewOptions.hiddenColumns.map((v: { value: string }) => v.value) 72 | 73 | return ( 74 | { this.table = table }}> 75 | 81 | 82 | 83 | 84 | {hiddenColumnsFormatted.indexOf('timeCreated') === -1 && } 85 | {hiddenColumnsFormatted.indexOf('timeUpdated') === -1 && } 86 | {hiddenColumnsFormatted.indexOf('votedUp') === -1 && } 87 | {hiddenColumnsFormatted.indexOf('language') === -1 && } 88 | 89 | {hiddenColumnsFormatted.indexOf('playtimeAtReview') === -1 && } 90 | {hiddenColumnsFormatted.indexOf('playtimeForever') === -1 && } 91 | {hiddenColumnsFormatted.indexOf('playtime2Weeks') === -1 && } 92 | {hiddenColumnsFormatted.indexOf('earlyAccess') === -1 && } 93 | {hiddenColumnsFormatted.indexOf('receivedForFree') === -1 && } 94 | {hiddenColumnsFormatted.indexOf('steamPurchase') === -1 && } 95 | {hiddenColumnsFormatted.indexOf('authorNumReviews') === -1 && } 96 | {hiddenColumnsFormatted.indexOf('authorNumGames') === -1 && } 97 | {hiddenColumnsFormatted.indexOf('authorContinuedPlaying') === -1 && } 98 | {hiddenColumnsFormatted.indexOf('authorLastPlayed') === -1 && } 99 | {hiddenColumnsFormatted.indexOf('votesUp') === -1 && } 100 | {hiddenColumnsFormatted.indexOf('votesFunny') === -1 && } 101 | {hiddenColumnsFormatted.indexOf('commentCount') === -1 && } 102 | 103 | 104 | 105 | {this.props.reviews.length > 0 ? 106 | this.props.reviews.map(r => ) 107 | : } 108 | 109 |
    IDTime created Time updated VotedLanguageText Playtime at review time Playtime forever Playtime last two weeks Written during early accessMarked as received for freePurchased via SteamAuthor total reviews Author total games owned Author continued playingAuthor last playedVotes helpful Votes funny Comment count
    No reviews found
    110 | ) 111 | } 112 | } 113 | 114 | export default ReviewTable -------------------------------------------------------------------------------- /pages/game/[appId].tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { useRouter } from 'next/router' 3 | import SteamWebApiClient from "lib/utils/SteamWebApiClient" 4 | import { Row, Col, Breadcrumb, Alert, Spinner, Card, Button } from "react-bootstrap" 5 | import BetaNotice from "components/BetaNotice" 6 | import Breakdown from "components/Breakdown" 7 | import Loader from "components/Loader" 8 | import GameSummary from "components/GameSummary" 9 | import Link from "next/link" 10 | import ReviewListUtils from "lib/utils/ReviewListUtils" 11 | import dateFormat from "dateformat" 12 | import supportedLocales from "lib/utils/SteamLocales" 13 | import _ from "lodash" 14 | import DBUtils from "lib/utils/DBUtils" 15 | import { FaArrowsRotate } from "react-icons/fa6" 16 | 17 | /** 18 | * A page to scrape game info and propagate it 19 | * to breakdowns 20 | */ 21 | const Game = () => { 22 | 23 | // Format some stats for display 24 | const dateFormatString = 'mmm d, yyyy' 25 | const dateFormatStringDetailed = 'hh:MMtt, dd/mm/yyyy' 26 | 27 | // State objects 28 | const [originalGame, setOriginalGame] = useState(null) 29 | const [game, setActiveGame] = useState(null) 30 | const [showAlert, setShowAlert] = useState(true) 31 | const [wasReviewCountMismatch, setWasReviewCountMismatch] = useState(null) 32 | const [didProceed, setDidProceed] = useState(false) 33 | const [scrapeError, setScrapeError] = useState(null) 34 | const [reviewStatistics, setReviewStatistics] = useState(null) 35 | const [timeStartedScraping, setTimeStartedScraping] = useState(Date.now()) 36 | const [update, setUpdate] = useState({checked: 0, count: 0, averageRequestTime: 0, bytes: 0, finished: false}) 37 | const [dbCount, setDbCount] = useState(0) 38 | const [didSkipScrapingReviews, setDidSkipScrapingReviews] = useState(false) 39 | const [lastSearch, setLastSearch] = useState(null) 40 | 41 | // Retrieve the app ID from the query params 42 | const router = useRouter() 43 | let appId = router.query.appId as string 44 | 45 | let start = +(router.query.start as string) 46 | let end = +(router.query.end as string) 47 | const startDate = isNaN(start) ? new Date(0) : new Date(start) 48 | const endDate = isNaN(end) ? new Date() : new Date(end) 49 | 50 | let selectedLanguages = [] 51 | let languages = router.query.languages as string 52 | if (languages) { 53 | selectedLanguages = languages.split(',') 54 | } 55 | 56 | let missingParams = isNaN(start) || isNaN(end) 57 | 58 | const processReviews = (withGame, reviewCount) => { 59 | ReviewListUtils.processReviewsForGame(withGame).then((reviewStatisticsComputed) => { 60 | 61 | setReviewStatistics(reviewStatisticsComputed) 62 | 63 | if (reviewCount < withGame.total_reviews) { 64 | setWasReviewCountMismatch({ originalTotal: withGame.total_reviews }) 65 | setActiveGame({ ...withGame, total_reviews: reviewCount, total_positive: reviewStatisticsComputed.totalReviewsPositive, total_negative: reviewStatisticsComputed.totalReviewsNegative }) 66 | } 67 | }) 68 | } 69 | 70 | const continueToScrapeReviews = (withGame) => { 71 | 72 | setDidSkipScrapingReviews(false) 73 | setReviewStatistics(null) 74 | 75 | const abortController = new AbortController() 76 | 77 | SteamWebApiClient.getReviews(withGame, appId, setUpdate, setScrapeError, abortController, startDate, endDate, selectedLanguages).then((reviewCount) => { 78 | 79 | if (reviewCount === 0) { 80 | return 81 | } 82 | 83 | processReviews(withGame, reviewCount) 84 | }) 85 | } 86 | 87 | const skipScrapingReviews = (withGame) => { 88 | setDidSkipScrapingReviews(true) 89 | processReviews(withGame, dbCount) 90 | } 91 | 92 | // Initial state fetch 93 | if (game === null && appId !== undefined) { 94 | 95 | SteamWebApiClient.getGame(appId, selectedLanguages) 96 | .then((withGame) => { setActiveGame(withGame); setOriginalGame(_.clone(withGame)); return withGame }) 97 | .then((withGame) => { 98 | 99 | if (withGame.total_reviews === 0) { 100 | let newUpdate = _.clone(update) 101 | newUpdate.finished = true 102 | setUpdate(newUpdate) 103 | return 104 | } 105 | 106 | // Before getting reviews, check if we already have some 107 | const store = DBUtils.getReviewStoreForGame(withGame.steam_appid) 108 | store.count().then(dbCount => { 109 | if (dbCount > 0) { 110 | setDbCount(dbCount) 111 | 112 | // So there are pre-existing ones, check if this matches the previous search 113 | DBUtils.getSearch(withGame.steam_appid).then(search => { 114 | if ((search.start === 0 && startDate.getTime() === 0) || 115 | (search.start === startDate.getTime() 116 | && search.end === endDate.getTime())) { 117 | setLastSearch(search) 118 | // Was the same search, move to showing reviews 119 | skipScrapingReviews(withGame) 120 | } else { 121 | continueToScrapeReviews(withGame) 122 | } 123 | }) 124 | } else { 125 | continueToScrapeReviews(withGame) 126 | } 127 | }) 128 | }) 129 | } 130 | 131 | const onProceed = () => { 132 | if (scrapeError.abortController) { 133 | scrapeError.abortController.abort('User clicked cancel') 134 | setDidProceed(true) 135 | } 136 | } 137 | 138 | return (<> 139 | {/* */} 140 | 141 | 142 | {game && <> 143 |
    144 | 145 |
  • Home
  • 146 | {game.name} 147 |
    148 |
    149 | {didSkipScrapingReviews && lastSearch && 150 | 3600000 ? 'warning' : 'info'}> 151 | 152 | 153 |

    You last explored at {dateFormat(new Date(lastSearch.when), dateFormatStringDetailed)}. {(Date.now() - lastSearch.when) > 3600000 ? 'It is recommended to search again as data may have changed.' : ''}

    154 | 155 | 156 |
    157 | 158 |
    159 | 160 |
    161 |
    } 162 | 163 | } 164 | 165 | {game && originalGame && (reviewStatistics ? 166 | <> 167 | {missingParams && wasReviewCountMismatch && setShowAlert(false)} variant="warning" dismissible> 168 | {didProceed && 'You chose to proceed without scraping all reviews, '}{reviewStatistics.totalReviews.toLocaleString()} out of a reported {wasReviewCountMismatch.originalTotal.toLocaleString()} review{wasReviewCountMismatch.originalTotal !== 1 ? 's were' : ' was'} retrieved. 169 | {' '}{!didProceed && Why can this happen?} 170 | {reviewStatistics.totalReviews > 30000 && reviewStatistics.totalReviews <= 50000 && <>
    Due to the large number of reviews for this product the site may perform slowly} 171 |
    } 172 | {!missingParams && setShowAlert(false)} variant="info" dismissible> 173 | Retrieved {reviewStatistics.totalReviews.toLocaleString()} public review{reviewStatistics.totalReviews === 1 ? '' : 's'} in {selectedLanguages.length === 0 || selectedLanguages.length === Object.keys(supportedLocales).length ? 'all languages' : `${selectedLanguages.length} language${selectedLanguages.length !== 1 ? 's' : ''}`}, in date range {dateFormat(new Date(reviewStatistics.reviewMinTimestampCreated.timestamp_updated * 1000), dateFormatString)} - {dateFormat(new Date(reviewStatistics.reviewMaxTimestampUpdated.timestamp_updated * 1000), dateFormatString)} 174 | } 175 | { return {label: supportedLocales[l].englishName, value: l} })}/> 176 | 177 | : )} 178 | 179 | {!game && 180 | Loading... 181 | } 182 | 183 |
    184 | ) 185 | } 186 | 187 | export default Game -------------------------------------------------------------------------------- /components/PromptsList.tsx: -------------------------------------------------------------------------------- 1 | import supportedLocales from "lib/utils/SteamLocales" 2 | import React from "react" 3 | import { Badge, Button, Card, Container } from "react-bootstrap" 4 | 5 | const PromptsList = ({ handleFilterPreset, initialFilterRanges, reviewStatistics, game }) => { 6 | 7 | const availableLanguages = Object.keys(reviewStatistics.totalLanguages).sort((a:any, b:any) => supportedLocales[a].englishName { 8 | return { 9 | label: supportedLocales[l].englishName, 10 | value: l 11 | } 12 | }) 13 | 14 | let reviewLanguagesUnsupported = [] 15 | for (let lang of Object.keys(reviewStatistics.totalLanguages)) { 16 | if (Object.keys(game.unsupported_languages).indexOf(lang) !== -1) { 17 | reviewLanguagesUnsupported.push({label: supportedLocales[lang].englishName, value: lang}) 18 | } 19 | } 20 | 21 | const navToCreators = () => { 22 | handleFilterPreset({ 23 | languages: availableLanguages, 24 | votedUpPositive: true, 25 | votedUpNegative: false, 26 | containsUrlYes: true, 27 | containsUrlNo: false 28 | }) 29 | } 30 | 31 | const navToASCIIArt = () => { 32 | handleFilterPreset({ 33 | languages: availableLanguages, 34 | containsASCIIArtYes: true, 35 | containsASCIIArtNo: false 36 | }) 37 | } 38 | 39 | const navToHoldouts = () => { 40 | const maxHoursPlayedForever = Math.ceil(reviewStatistics.reviewMaxTotalMinutesPlayedForever.author_playtime_forever / 60) 41 | const averagePlaytimeAtReviewTimeHours = Math.round(reviewStatistics.averageMinutesPlaytimeAtReviewTime / 60) 42 | 43 | handleFilterPreset({ 44 | votedUpNegative: true, 45 | votedUpPositive: false, 46 | timePlayedForever: [averagePlaytimeAtReviewTimeHours + 1, maxHoursPlayedForever] 47 | }) 48 | } 49 | 50 | const navToTechnicalIssues = () => { 51 | const dNow = new Date() 52 | let d24HoursAgo = new Date() 53 | d24HoursAgo.setHours(d24HoursAgo.getHours() - 24) 54 | 55 | handleFilterPreset({ 56 | searchTerm: 'crash bug issue performance lag', 57 | exactSearchTerm: 'partialIgnoreCase', 58 | timeCreated: [d24HoursAgo, dNow] 59 | }) 60 | } 61 | 62 | const navToConversationStarters = () => { 63 | handleFilterPreset({ 64 | commentCount: [2, reviewStatistics.reviewMaxCommentCount.comment_count] 65 | }) 66 | } 67 | 68 | const navToNovellists = () => { 69 | handleFilterPreset({ 70 | textLength: [reviewStatistics.averageTextLength + 1, reviewStatistics.reviewMaxTextLength.review.length] 71 | }) 72 | } 73 | 74 | const navToUnsupportedReviewLanguages = () => { 75 | handleFilterPreset({ 76 | languages: reviewLanguagesUnsupported, 77 | votedUpNegative: true, 78 | votedUpPositive: false 79 | }) 80 | } 81 | 82 | return ( 83 |
    84 | {reviewLanguagesUnsupported.length > 0 &&
    85 | 86 | 87 | 88 | 89 | Find localisation issues New 90 | 91 | Show negative reviews made in unsupported languages 92 | 93 | 94 |
    95 | 99 |
    100 |
    101 |
    102 |
    } 103 | 104 |
    105 | 106 | 107 | 108 | 109 | Find happy content creators 110 | 111 | Show positive reviews containing URLs that may point to videos, blogs 112 | 113 | 114 |
    115 | 119 |
    120 |
    121 |
    122 |
    123 | 124 |
    125 | 126 | 127 | 128 | 129 | Find new technical issues 130 | 131 | Show reviews from the last 24 hours containing key words related to bugs 132 | 133 | 134 |
    135 | 139 |
    140 |
    141 |
    142 |
    143 | 144 |
    145 | 146 | 147 | 148 | 149 | Find conversation starters 150 | 151 | Show reviews with more than one comment 152 | 153 | 154 |
    155 | 159 |
    160 |
    161 |
    162 |
    163 | 164 |
    165 | 166 | 167 | 168 | 169 | Find critical holdouts 170 | 171 | Show negative reviews that have more than average playtime 172 | 173 | 174 |
    175 | 179 |
    180 |
    181 |
    182 |
    183 | 184 |
    185 | 186 | 187 | 188 | 189 | Find the novellists 190 | 191 | Show reviews that are longer than average length 192 | 193 | 194 |
    195 | 199 |
    200 |
    201 |
    202 |
    203 | 204 |
    205 | 206 | 207 | 208 | 209 | Find the keyboard artists 210 | 211 | Show reviews that are primarily ASCII art 212 | 213 | 214 |
    215 | 219 |
    220 |
    221 |
    222 |
    223 |
    224 |
    ) 225 | } 226 | 227 | export default PromptsList -------------------------------------------------------------------------------- /components/Export.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { Button, Col, Form, Modal } from "react-bootstrap" 3 | import sanitize from "sanitize-filename" 4 | import { MultiSelect } from "react-multi-select-component" 5 | import CsvDownload from "react-csv-downloader" 6 | import { FaDownload } from "react-icons/fa" 7 | 8 | const Export = ({ game, reviewCount, filteredReviewCount, viewOptions, viewOptionsCallback, handleGetData }) => { 9 | 10 | const hiddenColumnsFormatted = viewOptions.hiddenColumns.map((v: { value: string }) => v.value) 11 | 12 | const computeHeaders = () => { 13 | const headers = [ 14 | { displayName: 'recommendation_id', id: 'recommendationid'}, 15 | { displayName: 'recommendation_url', id: 'recommendationurl'}, 16 | { displayName: 'author_steam_id', id: 'author_steamid'}, 17 | { displayName: 'author_number_games_owned', id: 'author_num_games_owned'}, 18 | { displayName: 'author_number_reviews', id: 'author_num_reviews'}, 19 | { displayName: 'author_minutes_playtime_last_two_weeks', id: 'author_playtime_last_two_weeks'}, 20 | { displayName: 'author_last_played_timestamp', id: 'author_last_played'}, 21 | { displayName: 'review', id: 'review'}, 22 | { displayName: 'weighted_review_score', id: 'weighted_vote_score'} 23 | ] 24 | 25 | if (hiddenColumnsFormatted.indexOf('timeCreated') === -1 ) { 26 | headers.push({ displayName: 'created_timestamp', id: 'timestamp_created'}) 27 | } 28 | if (hiddenColumnsFormatted.indexOf('timeUpdated') === -1 ) { 29 | headers.push({ displayName: 'updated_timestamp', id: 'timestamp_updated'}) 30 | } 31 | if (hiddenColumnsFormatted.indexOf('votedUp') === -1 ) { 32 | headers.push({ displayName: 'voted_up', id: 'voted_up'}) 33 | } 34 | if (hiddenColumnsFormatted.indexOf('language') === -1 ) { 35 | headers.push({ displayName: 'language', id: 'language'}) 36 | } 37 | if (hiddenColumnsFormatted.indexOf('playtimeAtReview') === -1 ) { 38 | headers.push({ displayName: 'author_minutes_playtime_at_review_time', id: 'author_playtime_at_review'}) 39 | } 40 | if (hiddenColumnsFormatted.indexOf('playtimeForever') === -1 ) { 41 | headers.push({ displayName: 'author_minutes_playtime_forever', id: 'author_playtime_forever'}) 42 | } 43 | if (hiddenColumnsFormatted.indexOf('playtime2Weeks') === -1 ) { 44 | headers.push({ displayName: 'author_minutes_playtime_last_two_weeks', id: 'author_playtime_last_two_weeks'}) 45 | } 46 | if (hiddenColumnsFormatted.indexOf('earlyAccess') === -1 ) { 47 | headers.push({ displayName: 'written_during_early_access', id: 'written_during_early_access'}) 48 | } 49 | if (hiddenColumnsFormatted.indexOf('receivedForFree') === -1 ) { 50 | headers.push({ displayName: 'marked_as_received_for_free', id: 'received_for_free'}) 51 | } 52 | if (hiddenColumnsFormatted.indexOf('authorNumReviews') === -1 ) { 53 | headers.push({ displayName: 'author_num_reviews', id: 'author_num_reviews'}) 54 | } 55 | if (hiddenColumnsFormatted.indexOf('authorNumGames') === -1 ) { 56 | headers.push({ displayName: 'author_num_games', id: 'author_num_games_owned'}) 57 | } 58 | if (hiddenColumnsFormatted.indexOf('steamPurchase') === -1 ) { 59 | headers.push({ displayName: 'steam_purchase', id: 'steam_purchase'}) 60 | } 61 | if (hiddenColumnsFormatted.indexOf('votesUp') === -1 ) { 62 | headers.push({ displayName: 'votes_up', id: 'votes_up'}) 63 | } 64 | if (hiddenColumnsFormatted.indexOf('votesFunny') === -1 ) { 65 | headers.push({ displayName: 'votes_funny', id: 'votes_funny'}) 66 | } 67 | if (hiddenColumnsFormatted.indexOf('commentCount') === -1 ) { 68 | headers.push({ displayName: 'comment_count', id: 'comment_count'}) 69 | } 70 | 71 | return headers 72 | } 73 | 74 | const defaultFileName = sanitize(`${game.steam_appid} ${game.name} Reviews`).replace(/[^a-z0-9]/gi, '_') 75 | 76 | const [showModal, setShowModal] = useState(false) 77 | const [fileName, setFileName] = useState(defaultFileName) 78 | const [selectionAll, setSelectionAll] = useState(true) 79 | const [selectionFiltered, setSelectionFiltered] = useState(false) 80 | const [selectedData, setSelectedData] = useState('reviews') 81 | const [isLoading, setIsLoading] = useState(false) 82 | 83 | const columns = computeHeaders() 84 | 85 | const getData = () => { 86 | if (isLoading) { 87 | return 88 | } 89 | 90 | setIsLoading(true) 91 | 92 | return handleGetData(selectedData).then(data => { 93 | setIsLoading(false) 94 | return data 95 | }) 96 | } 97 | 98 | const updateViewOption = ({ label, value }) => { 99 | 100 | let newViewOptions = { ...viewOptions, [label]: value} 101 | 102 | viewOptionsCallback(newViewOptions) 103 | } 104 | 105 | let countToUse = selectedData === 'reviews' ? reviewCount : filteredReviewCount 106 | 107 | return (<> 108 |
    109 | 110 |
    111 | 112 | setShowModal(false)}> 113 | 114 | 115 | Export Reviews 116 | 117 | 118 | 119 | Filename 120 | { setFileName(e.target.value) }}/>
    121 | Selection
    122 | { setSelectionAll(e.target.checked); setSelectionFiltered(!e.target.checked); setSelectedData('reviews') }}/> 123 | { setSelectionFiltered(e.target.checked); setSelectionAll(!e.target.checked); setSelectedData('filteredReviews') }}/> 124 |

    {countToUse.toLocaleString()} review{countToUse !== 1 ? 's' : ''} will be exported

    125 | Exclude Columns ({hiddenColumnsFormatted.length.toLocaleString()} excluded) 126 | { updateViewOption({ label: 'hiddenColumns', value: e })}} 200 | /> 201 |
    202 | 203 | 204 |
    205 | 208 |
    209 | 210 | 211 |
    212 | 213 | 216 | 217 |
    218 | 219 |
    220 |
    221 | ) 222 | } 223 | 224 | export default Export -------------------------------------------------------------------------------- /components/GameSearch.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from "react" 2 | import _ from "lodash" 3 | import SteamWebApiClient from "lib/utils/SteamWebApiClient" 4 | import { Container, Row, Col, Form, Spinner, Modal, Button } from "react-bootstrap" 5 | import GameCardDeck from "./GameCardDeck" 6 | import { useRouter } from "next/router" 7 | import { MultiSelect } from "react-multi-select-component" 8 | import { useCookies } from "react-cookie" 9 | import DateRangePicker from "react-bootstrap-daterangepicker" 10 | import "bootstrap-daterangepicker/daterangepicker.css" 11 | import supportedLocales from "lib/utils/SteamLocales" 12 | 13 | const PRODUCT_TYPES = [ 14 | { 15 | label: 'Game', 16 | value: 'game' 17 | }, 18 | { 19 | label: 'Adult Game', 20 | value: 'adult_game' 21 | }, 22 | { 23 | label: 'Application', 24 | value: 'application' 25 | }, 26 | { 27 | label: 'Tool', 28 | value: 'tool' 29 | }, 30 | { 31 | label: 'Demo', 32 | value: 'demo' 33 | }, 34 | { 35 | label: 'DLC', 36 | value: 'dlc' 37 | }, 38 | { 39 | label: 'Music', 40 | value: 'music' 41 | } 42 | ] 43 | 44 | const LANGUAGES = Object.keys(supportedLocales).map((k) => { return { label: supportedLocales[k].englishName, value: k } }) 45 | 46 | const GameSearch = () => { 47 | 48 | const router = useRouter() 49 | 50 | const [searchTerm, setSearchTerm] = useState('') 51 | const [searchResult, setSearchResult] = useState(null) 52 | const [featuredGames, setFeaturedGames] = useState(null) 53 | const [loadingSomething, setLoadingSomething] = useState(true) 54 | const [productTypes, setProductTypes] = useState(PRODUCT_TYPES) 55 | const [showModal, setShowModal] = useState(false) 56 | const [selectedGame, setSelectedGame] = useState(null) 57 | const [timeSpanOption, setTimespanOption] = useState('2weeks') 58 | const [customTimeSpan, setCustomTimeSpan] = useState([]) 59 | const [selectedLanguages, setSelectedLanguages] = useState(LANGUAGES) 60 | 61 | const [cookies, setCookie] = useCookies(['productTypes']) 62 | 63 | const decodeProductTypes = (productTypesStr) => { 64 | let productTypeValues = productTypesStr.split(',') 65 | 66 | let productTypesArr = [] 67 | 68 | for (let productTypeValue of productTypeValues) { 69 | for (let pt of PRODUCT_TYPES) { 70 | if (productTypeValue === pt.value) { 71 | productTypesArr.push(pt) 72 | break 73 | } 74 | } 75 | } 76 | return productTypesArr 77 | } 78 | 79 | useEffect(() => { 80 | if (cookies.productTypes) { 81 | setProductTypes(cookies.productTypes) 82 | } 83 | }, []) 84 | 85 | useEffect(() => { 86 | 87 | let loadedProductTypes = [] 88 | if (router.query.productTypes !== undefined) { 89 | loadedProductTypes = decodeProductTypes(router.query.productTypes as string) 90 | if (loadedProductTypes.length !== 0 && !_.isEqual(productTypes, loadedProductTypes)) { 91 | setProductTypes(loadedProductTypes) 92 | } 93 | } 94 | 95 | if (loadedProductTypes.length === 0) { 96 | loadedProductTypes = productTypes 97 | } 98 | 99 | if (router.query.search !== undefined && searchResult !== router.query.search as string) { 100 | const searchStr = decodeURI(router.query.search as string) 101 | setSearchTerm(searchStr) 102 | getGames(searchStr, loadedProductTypes) 103 | } 104 | 105 | if (featuredGames === null) { 106 | getFeaturedGames() 107 | } 108 | }, [router.query.search, router.query.productTypes]) 109 | 110 | const getFeaturedGames = async () => { 111 | 112 | const featuredGames = await SteamWebApiClient.getFeaturedGames() 113 | 114 | setFeaturedGames(featuredGames) 115 | setLoadingSomething(false) 116 | } 117 | 118 | const getGames = async (searchStr, productTypesToUse) => { 119 | if (productTypesToUse.length === 0 || !searchStr || /^\s*$/.test(searchStr)) { 120 | setSearchResult(null) 121 | setLoadingSomething(false) 122 | return 123 | } 124 | 125 | let now = new Date() 126 | 127 | setLoadingSomething(true) 128 | 129 | let response = null 130 | 131 | if (searchStr.indexOf('http') !== -1 || searchStr.indexOf('www.') !== -1) { 132 | let match = searchStr.match(/\/app\/(\d+)/) 133 | if (match && match[1]) { 134 | const individualGame = await SteamWebApiClient.getGame(match[1]) 135 | if (individualGame) { 136 | response = [individualGame] 137 | } 138 | } 139 | } 140 | 141 | if (response === null) { 142 | response = await SteamWebApiClient.findGamesBySearchTerm(searchStr, productTypesToUse.map(e => e.value)) 143 | } 144 | 145 | setSearchResult(previousSearchResult => previousSearchResult === null || now > previousSearchResult.time ? { time: now, data: response, term: searchStr } : previousSearchResult) 146 | 147 | setLoadingSomething(false) 148 | } 149 | 150 | const encodeProductTypes = (productTypesArr) => { 151 | if (productTypesArr.length === PRODUCT_TYPES.length) { 152 | return '' 153 | } 154 | 155 | let selectedProductValues = [] 156 | 157 | for (let entry of productTypesArr) { 158 | selectedProductValues.push(entry.value) 159 | } 160 | 161 | return selectedProductValues.join(',') 162 | } 163 | 164 | const updateQuery = useCallback(_.debounce((searchStr, newProductTypes) => { 165 | 166 | setCookie('productTypes', newProductTypes, { path: '/' }) 167 | 168 | let productTypesEncoded = encodeProductTypes(newProductTypes) 169 | 170 | let queryObj: any = {} 171 | if (searchStr.length !== 0) { 172 | queryObj.search = encodeURI(searchStr) 173 | if (productTypesEncoded.length !== 0) { 174 | queryObj.productTypes = encodeURI(productTypesEncoded) 175 | } 176 | } 177 | 178 | router.push({ 179 | pathname: '/', 180 | query: queryObj 181 | }, null, { shallow: true }) 182 | 183 | getGames(searchStr, newProductTypes) 184 | }, 800), []) 185 | 186 | const handleExplore = (game) => { 187 | setSelectedGame(game) 188 | setShowModal(true) 189 | } 190 | 191 | const handleCancelExplore = () => { 192 | setShowModal(false) 193 | setSelectedGame(null) 194 | } 195 | 196 | const getDateXDaysAgo = (numOfDays: number) => { 197 | const daysAgo = new Date() 198 | daysAgo.setDate(daysAgo.getDate() - numOfDays) 199 | return daysAgo 200 | } 201 | 202 | const exploreSelectedGame = () => { 203 | 204 | let dateRange = [new Date(0), new Date()] 205 | if (timeSpanOption === '2weeks') { 206 | dateRange[0] = getDateXDaysAgo(14) 207 | } else if (timeSpanOption === '1month') { 208 | dateRange[0] = getDateXDaysAgo(30) 209 | } else if (timeSpanOption === 'custom') { 210 | dateRange = customTimeSpan 211 | } 212 | 213 | let languageStr = null 214 | if (selectedLanguages.length !== LANGUAGES.length) { 215 | languageStr = encodeURI(selectedLanguages.map((l) => l.value).join(',')) 216 | } 217 | 218 | if (timeSpanOption === 'forever') { 219 | // Default, backwards-compatible behaviour 220 | router.push(`/game/${selectedGame.steam_appid}${languageStr ? `?languages=${languageStr}` : ''}`) 221 | } else{ 222 | router.push(`/game/${selectedGame.steam_appid}?start=${dateRange[0].getTime()}&end=${dateRange[1].getTime()}${languageStr ? `&languages=${languageStr}` : ''}`) 223 | } 224 | } 225 | 226 | return ( 227 | 228 | 229 | 230 | { setSearchTerm(e.target.value); updateQuery.cancel(); updateQuery(e.target.value, productTypes); }} /> 231 | 232 | 233 | { setProductTypes(e); updateQuery.cancel(); updateQuery(searchTerm, e); }} 251 | /> 252 | 253 | 254 | 255 | {loadingSomething && 256 | 257 | Loading... 258 | 259 | } 260 | 261 | {searchResult && !loadingSomething && 262 | 263 |

    {searchResult.data && `${searchResult.data.length} result${searchResult.data.length !== 1 ? 's' : ''} found for `}{searchResult.term}. 264 | {searchResult.data.length > 0 && searchResult.term.length < 11 && Not what you're looking for? Try being more specific{productTypes.length !== PRODUCT_TYPES.length ? ', or selecting more product types' : ''}} 265 | {searchResult.data.length === 0 && Looking for something specific? Try searching the name as it appears on Steam}

    266 | 267 |
    } 268 | 269 | {!loadingSomething && searchResult?.data.length > 0 && 270 | } 271 | 272 | {featuredGames?.length > 0 && <> 273 | 274 | 275 |

    Featured Products

    276 | 277 |
    278 | } 279 | 280 | {selectedGame && 281 | 282 | 283 | Find {selectedGame.name} reviews 284 | 285 | 286 | 287 |

    Since...

    288 | { setTimespanOption(e.target.id)}} 295 | checked={timeSpanOption === '2weeks'} 296 | /> 297 | { setTimespanOption(e.target.id)}} 304 | checked={timeSpanOption === '1month'} 305 | /> 306 | { setTimespanOption(e.target.id)}} 313 | checked={timeSpanOption === 'forever'} 314 | /> 315 | { setTimespanOption(e.target.id)}} 322 | checked={timeSpanOption === 'custom'} 323 | className={timeSpanOption === 'custom' ? 'mb-3' : ''}/> 324 | {timeSpanOption === 'custom' && <> 325 | {}} onCallback={(start: any, end: any) => { setCustomTimeSpan([start.toDate(), end.toDate()])}} initialSettings={{ minDate: null, maxDate: new Date(), startDate: getDateXDaysAgo(14), endDate: new Date(), timePicker: true, locale: { cancelLabel: 'Clear', applyLabel: 'Apply' }}}> 326 | 327 | 328 | } 329 |

    In language{selectedLanguages.length > 1 && 's'}... {selectedLanguages.length === 0 && (Select at least one)}

    330 | { setSelectedLanguages(e) }} 335 | /> 336 |
    337 | 338 | 341 | 342 |
    } 343 | 344 |
    345 | ) 346 | } 347 | 348 | export default GameSearch -------------------------------------------------------------------------------- /lib/utils/SteamWebApiClient.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import pRetry from 'p-retry' 3 | import { CensorSensor } from 'censor-sensor' 4 | import supportedLocales from './SteamLocales' 5 | import Dexie from 'dexie' 6 | import DBUtils from './DBUtils' 7 | 8 | const censor = new CensorSensor() 9 | 10 | // A sensible max total for funny and helpful counts 11 | const MAX_VALUE = 9999999 12 | 13 | const CORS_URL = 'https://joshhills.dev/cors/' 14 | // const CORS_URL = 'https://fair-jade-sparrow-tam.cyclic.app/' 15 | // const CORS_URL = 'https://cors-proxy-teal.vercel.app/' 16 | 17 | var re_weburl = new RegExp( 18 | "^" + 19 | // protocol identifier (optional) 20 | // short syntax // still required 21 | "(?:(?:(?:https?|ftp):)?\\/\\/)" + 22 | // user:pass BasicAuth (optional) 23 | "(?:\\S+(?::\\S*)?@)?" + 24 | "(?:" + 25 | // IP address exclusion 26 | // private & local networks 27 | "(?!(?:10|127)(?:\\.\\d{1,3}){3})" + 28 | "(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" + 29 | "(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" + 30 | // IP address dotted notation octets 31 | // excludes loopback network 0.0.0.0 32 | // excludes reserved space >= 224.0.0.0 33 | // excludes network & broadcast addresses 34 | // (first & last IP address of each class) 35 | "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" + 36 | "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" + 37 | "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" + 38 | "|" + 39 | // host & domain names, may end with dot 40 | // can be replaced by a shortest alternative 41 | // (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+ 42 | "(?:" + 43 | "(?:" + 44 | "[a-z0-9\\u00a1-\\uffff]" + 45 | "[a-z0-9\\u00a1-\\uffff_-]{0,62}" + 46 | ")?" + 47 | "[a-z0-9\\u00a1-\\uffff]\\." + 48 | ")+" + 49 | // TLD identifier name, may end with dot 50 | "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" + 51 | ")" + 52 | // port number (optional) 53 | "(?::\\d{2,5})?" + 54 | // resource path (optional) 55 | "(?:[/?#]\\S*)?" + 56 | "$", "i" 57 | ); 58 | function hasUrl(text: string) { 59 | for (let token of text.split(/\s/)) { 60 | if (re_weburl.test(token)) { 61 | return true 62 | } 63 | } 64 | return false 65 | } 66 | 67 | async function getReviewScore(appId: string, selectedLanguages: Array = []) { 68 | 69 | let langString = "all" 70 | if (selectedLanguages.length === 1) { 71 | langString = selectedLanguages[0] 72 | } 73 | 74 | return await fetch(`${CORS_URL}store.steampowered.com/appreviews/${appId}?json=1&day_range=9223372036854775807&language=${langString}&review_type=all&purchase_type=all&filter_offtopic_activity=0&num_per_page=0&cacheBust=${Math.random()}`) 75 | .then(res => res.json()) 76 | .then(res => { 77 | return { 78 | review_score: res.query_summary.review_score, 79 | review_score_desc: res.query_summary.review_score_desc === '1 user reviews' ? '1 user review' : res.query_summary.review_score_desc, 80 | total_positive: res.query_summary.total_positive, 81 | total_negative: res.query_summary.total_negative, 82 | total_reviews: res.query_summary.total_reviews, 83 | } 84 | }) 85 | } 86 | 87 | async function getFeaturedGames() { 88 | let featuredGames = await fetch(`${CORS_URL}store.steampowered.com/api/featured?cacheBust=${Math.random()}`) 89 | .then(res => res.json()) 90 | .then(res => res.featured_win) 91 | 92 | let games = [] 93 | for (let game of _.uniqBy(featuredGames, (g: any) => g.id) as any) { 94 | let fullGame = await getGame(game.id) 95 | 96 | if (fullGame === null) { 97 | continue 98 | } 99 | 100 | const isNSFW = fullGame.content_descriptors.ids.indexOf(3) !== -1 101 | 102 | if (!isNSFW) { 103 | games.push({ ...fullGame, time_scraped: Math.floor(new Date().getTime() / 1000) }) 104 | } 105 | } 106 | 107 | return games 108 | } 109 | 110 | async function findGamesBySearchTerm(searchTerm: string, productTypes: [string]) { 111 | 112 | let searchedGames = await fetch(`${CORS_URL}store.steampowered.com/api/storesearch/?term=${searchTerm}&l=english&cc=US`) 113 | .then(res => res.json()) 114 | .then(res => res.items) 115 | 116 | let games = [] 117 | for (let game of searchedGames) { 118 | let fullGame = await getGame(game.id) 119 | 120 | const isNSFW = fullGame.content_descriptors.ids.indexOf(3) !== -1 121 | 122 | let askingForAdultGames = productTypes.indexOf('adult_game') !== -1 123 | 124 | try { 125 | if ((askingForAdultGames && fullGame.type === 'game' && isNSFW) || productTypes.indexOf(fullGame.type) !== -1 && !fullGame.release_date.coming_soon) { 126 | 127 | games.push({ ...fullGame, time_scraped: Math.floor(new Date().getTime() / 1000) }) 128 | } 129 | } catch(e) {} 130 | } 131 | 132 | return games 133 | } 134 | 135 | function parseSupportedLanguages(supportedLanguages: string) { 136 | 137 | const regexHTMLRemove = /^[^<]*/ 138 | 139 | const languagesTrimmed = supportedLanguages.split(',') // Separate lines 140 | .map(e => e.trim()) // Remove spacing 141 | .map(e => e.match(regexHTMLRemove)[0]) // Remove HTML 142 | 143 | let supportedLanguagesFormatted = {} 144 | 145 | for (let parsedLang of languagesTrimmed) { 146 | for (let supportedLocale in supportedLocales) { 147 | if (parsedLang === supportedLocales[supportedLocale].englishName) { 148 | supportedLanguagesFormatted[supportedLocale] = { 149 | englishName: parsedLang 150 | } 151 | } 152 | } 153 | } 154 | 155 | return supportedLanguagesFormatted 156 | } 157 | 158 | function getUnsupportedLanguages(supportedLanguages: Object) { 159 | 160 | let unsupportedLanguages = {} 161 | 162 | for (let lang of Object.keys(supportedLocales)) { 163 | if (Object.keys(supportedLanguages).indexOf(lang) === -1) { 164 | unsupportedLanguages[lang] = supportedLocales[lang] 165 | } 166 | } 167 | 168 | return unsupportedLanguages 169 | } 170 | 171 | async function getGame(appId: string, selectedLanguages: Array = []) { 172 | const appDetails = await fetch(`${CORS_URL}store.steampowered.com/api/appdetails?appids=${appId}`) 173 | .then(res => res.json()) 174 | .then(res => res[appId].success ? res[appId].data : null) 175 | 176 | if (appDetails === null) { 177 | return null 178 | } 179 | 180 | let parsedSupportedLanguages = {} 181 | if (appDetails['supported_languages']) { 182 | parsedSupportedLanguages = parseSupportedLanguages(appDetails['supported_languages']) 183 | } 184 | const unsupportedLanguages = getUnsupportedLanguages(parsedSupportedLanguages) 185 | 186 | const reviewScore = await getReviewScore(appId, selectedLanguages) 187 | 188 | return { 189 | ...appDetails, 190 | parsed_supported_languages: parsedSupportedLanguages, 191 | unsupported_languages: unsupportedLanguages, 192 | ...reviewScore, 193 | time_scraped: Math.floor(new Date().getTime() / 1000) 194 | } 195 | } 196 | 197 | async function getReviews(game, appId: string, updateCallback, errorCallback, abortController, startDate: Date, endDate: Date, languages: Array) { 198 | 199 | const store = DBUtils.getReviewStoreForGame(appId) 200 | 201 | await store.clear() 202 | await DBUtils.logSearch(appId, startDate, endDate) 203 | 204 | const RETRY_THRESHOLD = 50 205 | 206 | let cursor = null, checked = 0 207 | 208 | const getReviewsPage = async (appId: string, languages: Array, cursor: string) => { 209 | if (cursor) { 210 | cursor = encodeURIComponent(cursor) 211 | } 212 | 213 | let cacheBust = null 214 | if (!cursor) { 215 | cacheBust = Math.random() 216 | } 217 | 218 | let langString = "all" 219 | if (languages.length === 1) { 220 | langString = languages[0] 221 | } 222 | 223 | // const url = `${CORS_URL}https://store.steampowered.com/appreviews/${appId}?json=1&day_range=9223372036854775807&language=all&review_type=all&purchase_type=all&filter_offtopic_activity=0&num_per_page=100${cursor ? `&cursor=${cursor}` : ''}` 224 | let url = `${CORS_URL}store.steampowered.com/appreviews/${appId}?json=1&filter=recent&language=${langString}&review_type=all&purchase_type=all&num_per_page=100&filter_offtopic_activity=0${cursor ? `&cursor=${cursor}` : ''}${cacheBust ? `&cacheBust=${cacheBust}` : ''}` 225 | 226 | try { 227 | 228 | return await pRetry(() => fetch(url) 229 | .then(async res => { 230 | 231 | let resJson = await res.json() 232 | 233 | if (resJson !== null && resJson.success && resJson.query_summary.num_reviews > 0) { 234 | errorCallback({ abortController: abortController }) 235 | return { reviews: resJson.reviews, cursor: resJson.cursor, bytes: +res.headers.get('Content-Length') } 236 | } 237 | errorCallback({ abortController: abortController }) 238 | if (resJson.query_summary.num_reviews === 0 && game.total_reviews - checked > RETRY_THRESHOLD) { 239 | throw new Error("Expected more reviews but response was empty") 240 | } 241 | }), { retries: 4, signal: abortController.signal, onFailedAttempt: (e) => { 242 | cacheBust = Math.random() 243 | url = `${CORS_URL}store.steampowered.com/appreviews/${appId}?json=1&filter=recent&language=${langString}&review_type=all&purchase_type=all&num_per_page=100&filter_offtopic_activity=0${cursor ? `&cursor=${cursor}` : ''}${cacheBust ? `&cacheBust=${cacheBust}` : ''}` 244 | errorCallback({ triesLeft: e.retriesLeft, attemptNumber: e.attemptNumber, goal: game.total_reviews, abortController: abortController})} 245 | }) 246 | } catch (e) { 247 | return 248 | } 249 | } 250 | 251 | let accumulativeElapsedMs = [] 252 | let accumulativeBytesReceived = 0 253 | let stop = false 254 | do { 255 | let before = new Date().getTime() 256 | 257 | let res = await getReviewsPage(appId, languages, cursor) 258 | 259 | let elapsedMs = new Date().getTime() - before 260 | 261 | if (accumulativeElapsedMs.length === 3) { 262 | accumulativeElapsedMs.shift() 263 | } 264 | accumulativeElapsedMs.push(elapsedMs) 265 | 266 | if (res) { 267 | accumulativeBytesReceived += res.bytes 268 | 269 | for (let review of res.reviews) { 270 | 271 | checked++ 272 | 273 | // Check language 274 | if (languages.length > 0 && languages.indexOf(review.language) === -1) { 275 | continue 276 | } 277 | 278 | // Check timespan 279 | let timestamp = review.timestamp_created * 1000 280 | 281 | if (timestamp > endDate.getTime()) { 282 | // Skip as it's more recent than we care about 283 | continue 284 | } 285 | if (timestamp < startDate.getTime()) { 286 | // Stop at this point 287 | stop = true 288 | break 289 | } 290 | 291 | // Normalise review 292 | review.author_steamid = review.author.steamid 293 | review.author_num_games_owned = review.author.num_games_owned 294 | review.author_num_reviews = review.author.num_reviews 295 | review.author_playtime_forever = review.author.playtime_forever 296 | review.author_playtime_last_two_weeks = review.author.playtime_last_two_weeks 297 | review.author_playtime_at_review = review.author.playtime_at_review 298 | review.author_last_played = review.author.last_played 299 | delete review.author 300 | 301 | if (isNaN(review.author_playtime_at_review)) { 302 | review.author_playtime_at_review = review.author_playtime_forever 303 | } 304 | 305 | review.review = review.review.replace(/"/g, "'") 306 | if (censor.isProfaneIsh(review.review)) { 307 | review.censored = censor.cleanProfanityIsh(review.review) 308 | } 309 | 310 | review.recommendationurl = `https://steamcommunity.com/profiles/${review.author_steamid}/recommended/${game.steam_appid}/`; 311 | 312 | // Sanitize Steam bugs... 313 | if (review.votes_up > MAX_VALUE || review.votes_up < 0) { 314 | review.votes_up = 0 315 | } 316 | if (review.votes_funny > MAX_VALUE || review.votes_funny < 0) { 317 | review.votes_funny = 0 318 | } 319 | 320 | // Check if it contains URLs 321 | review.contains_url = hasUrl(review.review) 322 | 323 | // Compute extra fields 324 | if (review.author_playtime_forever > review.author_playtime_at_review) { 325 | review.author_continued_playing = true 326 | review.author_playtime_after_review_time = review.author_playtime_forever - review.author_playtime_at_review 327 | } else { 328 | review.author_continued_playing = false 329 | review.author_playtime_after_review_time = 0 330 | } 331 | 332 | review.length = review.review.length 333 | 334 | store.add(review) 335 | } 336 | 337 | let totalElapsedMs = 0 338 | for (let ms of accumulativeElapsedMs) { 339 | totalElapsedMs += ms 340 | } 341 | 342 | let reviewCount = await store.count() 343 | updateCallback({ checked: checked, count: reviewCount, averageRequestTime: totalElapsedMs / accumulativeElapsedMs.length, bytes: accumulativeBytesReceived, finished: false }) 344 | 345 | cursor = res.cursor 346 | } else { 347 | cursor = null 348 | } 349 | 350 | if (stop) { 351 | break 352 | } 353 | } while (cursor) 354 | 355 | let totalElapsedMs = 0 356 | for (let ms of accumulativeElapsedMs) { 357 | totalElapsedMs += ms 358 | } 359 | let reviewCount = await store.count() 360 | 361 | updateCallback({ checked: checked, count: reviewCount, averageRequestTime: totalElapsedMs / accumulativeElapsedMs.length, bytes: accumulativeBytesReceived, finished: true }) 362 | 363 | // Delete pointless store if there's no reviews 364 | if (reviewCount === 0) { 365 | await Dexie.delete(appId) 366 | } 367 | 368 | return reviewCount 369 | } 370 | 371 | const SteamWebApiClient = { 372 | getFeaturedGames: getFeaturedGames, 373 | getReviewScore: getReviewScore, 374 | findGamesBySearchTerm: findGamesBySearchTerm, 375 | getGame: getGame, 376 | getReviews: getReviews 377 | } 378 | 379 | export default SteamWebApiClient 380 | -------------------------------------------------------------------------------- /components/ReviewOverview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Button, Table } from "react-bootstrap" 3 | import dateFormat from "dateformat" 4 | import supportedLocales from "lib/utils/SteamLocales" 5 | 6 | const ReviewOverview = ({ game, reviewStatistics, handleFilterPreset, initialFilterRanges }) => { 7 | 8 | // Format some stats for display 9 | const dateFormatString = 'mmm d, yyyy' 10 | 11 | const averagePlaytimeAtReviewTimeHours = Math.round(reviewStatistics.averageMinutesPlaytimeAtReviewTime / 60) 12 | const averageMinutesPlaytimeAfterReviewTimeHours = Math.round(reviewStatistics.averageMinutesPlaytimeAfterReviewTime / 60) 13 | const averageMinutesPlaytimeAfterPositiveReviewTimeHours = Math.round(reviewStatistics.averageMinutesPlayedAfterReviewTimePositive / 60) 14 | const averageMinutesPlaytimeAfterNegativeReviewTimeHours = Math.round(reviewStatistics.averageMinutesPlayedAfterReviewTimeNegative / 60) 15 | const medianMinutesContinuedPlayingAfterPositiveReviewHours = Math.round(reviewStatistics.medianMinutesContinuedPlayingAfterPositiveReview / 60) 16 | const medianMinutesContinuedPlayingAfterNegativeReviewHours = Math.round(reviewStatistics.medianMinutesContinuedPlayingAfterNegativeReview / 60) 17 | const averagePlaytimeForeverHours = Math.round(reviewStatistics.averageMinutesPlaytimeForever / 60) 18 | const averagePlaytimeLastTwoWeeksHours = Math.round(reviewStatistics.averageMinutesPlaytimeLastTwoWeeks / 60) 19 | const medianPlaytimeAtReviewTimeHours = Math.round(reviewStatistics.medianMinutesPlayedAtReviewTime / 60) 20 | const medianPlaytimeForeverHours = Math.round(reviewStatistics.medianMinutesPlayedForever / 60) 21 | const medianPlaytimeLastTwoWeeksHours = Math.round(reviewStatistics.medianMinutesPlayedLastTwoWeeks / 60) 22 | const totalPlaytimeForeverHours = Math.round(reviewStatistics.totalMinutesPlayedForever / 60) 23 | const totalPlaytimeLastTwoWeeksHours = Math.round(reviewStatistics.totalMinutesPlayedLastTwoWeeks / 60) 24 | 25 | const navToPositive = () => { 26 | handleFilterPreset({ 27 | votedUpPositive: true, 28 | votedUpNegative: false 29 | }) 30 | } 31 | 32 | const navToNegative = () => { 33 | handleFilterPreset({ 34 | votedUpPositive: false, 35 | votedUpNegative: true 36 | }) 37 | } 38 | 39 | const navToEarlyAccess = () => { 40 | handleFilterPreset({ 41 | earlyAccessYes: true, 42 | earlyAccessNo: false 43 | }) 44 | } 45 | 46 | const navToSteamPurchase = () => { 47 | handleFilterPreset({ 48 | steamPurchaseYes: true, 49 | steamPurchaseNo: false 50 | }) 51 | } 52 | 53 | const navToFree = () => { 54 | handleFilterPreset({ 55 | receivedForFreeYes: true, 56 | receivedForFreeNo: false 57 | }) 58 | } 59 | 60 | const navToComments = () => { 61 | handleFilterPreset({ 62 | commentCount: [1, initialFilterRanges['commentCount'][1]] 63 | }) 64 | } 65 | 66 | return ( 67 | <> 68 |
    Totals & Ranges
    69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 |
    Total public reviews retrieved{reviewStatistics.totalReviews.toLocaleString()}
    In date range{dateFormat(new Date(reviewStatistics.reviewMinTimestampCreated.timestamp_updated * 1000), dateFormatString)} - {dateFormat(new Date(reviewStatistics.reviewMaxTimestampUpdated.timestamp_updated * 1000), dateFormatString)}
    Total positive
    Total negative
    Average text length{reviewStatistics.averageTextLength.toLocaleString()} character{reviewStatistics.averageTextLength !== 1 ? 's' : ''}
    Median text length{reviewStatistics.medianTextLength.toLocaleString()} character{reviewStatistics.medianTextLength !== 1 ? 's' : ''}
    Min playtime at review time{reviewStatistics.reviewMinTotalMinutesPlayedAtReviewTime.author_playtime_at_review < 60 ? `${reviewStatistics.reviewMinTotalMinutesPlayedAtReviewTime.author_playtime_at_review} minute${reviewStatistics.reviewMinTotalMinutesPlayedAtReviewTime.author_playtime_at_review !== 1 ? 's' : ''}` : `${(Math.round(reviewStatistics.reviewMinTotalMinutesPlayedAtReviewTime.author_playtime_at_review / 60)).toLocaleString()} hour${(Math.round(reviewStatistics.reviewMinTotalMinutesPlayedAtReviewTime.author_playtime_at_review / 60)) !== 1 ? 's' : ''}`}
    Max playtime at review time{reviewStatistics.reviewMaxTotalMinutesPlayedAtReviewTime.author_playtime_at_review < 60 ? `${reviewStatistics.reviewMaxTotalMinutesPlayedAtReviewTime.author_playtime_at_review} minute${reviewStatistics.reviewMaxTotalMinutesPlayedAtReviewTime.author_playtime_at_review !== 1 ? 's' : ''}` : `${(Math.round(reviewStatistics.reviewMaxTotalMinutesPlayedAtReviewTime.author_playtime_at_review / 60)).toLocaleString()} hour${(Math.round(reviewStatistics.reviewMaxTotalMinutesPlayedAtReviewTime.author_playtime_at_review / 60)) !== 1 ? 's' : ''}`}
    Average playtime at review time{reviewStatistics.averageMinutesPlaytimeAtReviewTime < 60 ? `${reviewStatistics.averageMinutesPlaytimeAtReviewTime} minute${reviewStatistics.averageMinutesPlaytimeAtReviewTime !== 1 ? 's' : ''}` : `${averagePlaytimeAtReviewTimeHours.toLocaleString()} hour${averagePlaytimeAtReviewTimeHours !== 1 ? 's' : ''}`}
    Median playtime at review time{reviewStatistics.medianMinutesPlaytimeAtReviewTime < 60 ? `${reviewStatistics.medianMinutesPlaytimeAtReviewTime} minute${reviewStatistics.medianMinutesPlaytimeAtReviewTime !== 1 ? 's' : ''}` : `${medianPlaytimeAtReviewTimeHours.toLocaleString()} hour${medianPlaytimeAtReviewTimeHours !== 1 ? 's' : ''}`}
    Min playtime forever{reviewStatistics.reviewMinTotalMinutesPlayedForever.author_playtime_forever < 60 ? `${reviewStatistics.reviewMinTotalMinutesPlayedForever.author_playtime_forever} minute${reviewStatistics.reviewMinTotalMinutesPlayedForever.author_playtime_forever !== 1 ? 's' : ''}` : `${(Math.round(reviewStatistics.reviewMinTotalMinutesPlayedForever.author_playtime_forever / 60)).toLocaleString()} hour${(Math.round(reviewStatistics.reviewMinTotalMinutesPlayedForever.author_playtime_forever / 60)) !== 1 ? 's' : ''}`}
    Max playtime forever{reviewStatistics.reviewMaxTotalMinutesPlayedForever.author_playtime_forever < 60 ? `${reviewStatistics.reviewMaxTotalMinutesPlayedForever.author_playtime_forever} minute${reviewStatistics.reviewMaxTotalMinutesPlayedForever.author_playtime_forever !== 1 ? 's' : ''}` : `${(Math.round(reviewStatistics.reviewMaxTotalMinutesPlayedForever.author_playtime_forever / 60)).toLocaleString()} hour${(Math.round(reviewStatistics.reviewMaxTotalMinutesPlayedForever.author_playtime_forever / 60)) !== 1 ? 's' : ''}`}
    Average playtime forever{reviewStatistics.averageMinutesPlaytimeForever < 60 ? `${reviewStatistics.averageMinutesPlaytimeForever} minute${reviewStatistics.averageMinutesPlaytimeForever !== 1 ? 's' : ''}` : `${averagePlaytimeForeverHours.toLocaleString()} hour${averagePlaytimeForeverHours !== 1 ? 's' : ''}`}
    Median playtime forever{reviewStatistics.medianMinutesPlaytimeForever < 60 ? `${reviewStatistics.medianMinutesPlaytimeForever} minute${reviewStatistics.medianMinutesPlaytimeForever !== 1 ? 's' : ''}` : `${medianPlaytimeForeverHours.toLocaleString()} hour${medianPlaytimeForeverHours !== 1 ? 's' : ''}`}
    Total playtime forever{reviewStatistics.totalMinutesPlayedForever < 60 ? `${reviewStatistics.totalMinutesPlayedForever} minute${reviewStatistics.totalMinutesPlayedForever !== 1 ? 's' : ''}` : `${totalPlaytimeForeverHours.toLocaleString()} hour${totalPlaytimeForeverHours !== 1 ? 's' : ''}`}
    Average playtime last two weeks{reviewStatistics.averageMinutesPlaytimeLastTwoWeeks < 60 ? `${reviewStatistics.averageMinutesPlaytimeLastTwoWeeks} minute${reviewStatistics.averageMinutesPlaytimeLastTwoWeeks !== 1 ? 's' : ''}` : `${averagePlaytimeLastTwoWeeksHours.toLocaleString()} hour${averagePlaytimeLastTwoWeeksHours !== 1 ? 's' : ''}`}
    Median playtime last two weeks{reviewStatistics.medianMinutesPlaytimeLastTwoWeeks < 60 ? `${reviewStatistics.medianMinutesPlaytimeLastTwoWeeks} minute${reviewStatistics.medianMinutesPlaytimeLastTwoWeeks !== 1 ? 's' : ''}` : `${medianPlaytimeLastTwoWeeksHours.toLocaleString()} hour${medianPlaytimeLastTwoWeeksHours !== 1 ? 's' : ''}`}
    Total playtime last two weeks{reviewStatistics.totalMinutesPlayedLastTwoWeeks < 60 ? `${reviewStatistics.totalMinutesPlayedLastTwoWeeks} minute${reviewStatistics.totalMinutesPlayedLastTwoWeeks !== 1 ? 's' : ''}` : `${totalPlaytimeLastTwoWeeksHours.toLocaleString()} hour${totalPlaytimeLastTwoWeeksHours !== 1 ? 's' : ''}`}
    Total continued playing after review{reviewStatistics.totalContinuedPlayingAfterReviewTime.toLocaleString()} ({Math.round(reviewStatistics.totalContinuedPlayingAfterReviewTime / reviewStatistics.totalReviews * 100)}%)
    Total continued playing after positive review{reviewStatistics.totalContinuedPlayingAfterReviewTimePositive.toLocaleString()} ({Math.round(reviewStatistics.totalContinuedPlayingAfterReviewTimePositive / reviewStatistics.totalReviews * 100)}%)
    Total continued playing after negative review{reviewStatistics.totalContinuedPlayingAfterReviewTimeNegative.toLocaleString()} ({Math.round(reviewStatistics.totalContinuedPlayingAfterReviewTimeNegative / reviewStatistics.totalReviews * 100)}%)
    Average playtime after review time{reviewStatistics.averageMinutesPlaytimeAfterReviewTime < 60 ? `${reviewStatistics.averageMinutesPlaytimeAfterReviewTime} minute${reviewStatistics.averageMinutesPlaytimeAfterReviewTime !== 1 ? 's' : ''}` : `${averageMinutesPlaytimeAfterReviewTimeHours.toLocaleString()} hour${averageMinutesPlaytimeAfterReviewTimeHours !== 1 ? 's' : ''}`}
    Average playtime after positive review time{reviewStatistics.averageMinutesPlaytimeAfterReviewTimePositive < 60 ? `${reviewStatistics.averageMinutesPlaytimeAfterReviewTimePositive} minute${reviewStatistics.averageMinutesPlaytimeAfterReviewTimePositive !== 1 ? 's' : ''}` : `${averageMinutesPlaytimeAfterPositiveReviewTimeHours.toLocaleString()} hour${averageMinutesPlaytimeAfterPositiveReviewTimeHours !== 1 ? 's' : ''}`}
    Median playtime after positive review time{reviewStatistics.medianMinutesContinuedPlayingAfterPositiveReview < 60 ? `${reviewStatistics.medianMinutesContinuedPlayingAfterPositiveReview} minute${reviewStatistics.medianMinutesContinuedPlayingAfterPositiveReview !== 1 ? 's' : ''}` : `${medianMinutesContinuedPlayingAfterPositiveReviewHours.toLocaleString()} hour${medianMinutesContinuedPlayingAfterPositiveReviewHours !== 1 ? 's' : ''}`}
    Average playtime after negative review time{reviewStatistics.averageMinutesPlaytimeAfterReviewTimeNegative < 60 ? `${reviewStatistics.averageMinutesPlaytimeAfterReviewTimeNegative} minute${reviewStatistics.averageMinutesPlaytimeAfterReviewTimeNegative !== 1 ? 's' : ''}` : `${averageMinutesPlaytimeAfterNegativeReviewTimeHours.toLocaleString()} hour${averageMinutesPlaytimeAfterNegativeReviewTimeHours !== 1 ? 's' : ''}`}
    Median playtime after negative review time{reviewStatistics.medianMinutesContinuedPlayingAfterNegativeReview < 60 ? `${reviewStatistics.medianMinutesContinuedPlayingAfterNegativeReview} minute${reviewStatistics.medianMinutesContinuedPlayingAfterNegativeReview !== 1 ? 's' : ''}` : `${medianMinutesContinuedPlayingAfterNegativeReviewHours.toLocaleString()} hour${medianMinutesContinuedPlayingAfterNegativeReviewHours !== 1 ? 's' : ''}`}
    Total updated{reviewStatistics.totalReviewsUpdated.toLocaleString()} ({Math.round(reviewStatistics.totalReviewsUpdated / reviewStatistics.totalReviews * 100)}%)
    Total with comments
    Total purchased via Steam
    Total marked as received for Free
    Total written during early access
    Total languages{Object.keys(reviewStatistics.totalLanguages).length.toLocaleString()} / {Object.keys(supportedLocales).length.toLocaleString()}
    201 | 202 | ) 203 | } 204 | 205 | export default ReviewOverview -------------------------------------------------------------------------------- /lib/utils/ReviewListUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | { 3 | "recommendationid": "162049123", 4 | "author": { 5 | "steamid": "76561198098863723", 6 | "num_games_owned": 0, 7 | "num_reviews": 1, 8 | "playtime_forever": 688, 9 | "playtime_last_two_weeks": 688, 10 | "playtime_at_review": 109, 11 | "last_played": 1712231335 12 | }, 13 | "language": "english", 14 | "review": "A cute city builder with a surprising amount of depth.", 15 | "timestamp_created": 1712081624, 16 | "timestamp_updated": 1712081624, 17 | "voted_up": true, 18 | "votes_up": 9, 19 | "votes_funny": 0, 20 | "weighted_vote_score": "0.570457041263580322", 21 | "comment_count": 0, 22 | "steam_purchase": false, 23 | "received_for_free": true, 24 | "written_during_early_access": true, 25 | "hidden_in_steam_china": true, 26 | "steam_china_location": "", 27 | "recommendationurl": "https://steamcommunity.com/profiles/76561198098863723/recommended/1733110/", 28 | "contains_url": false, 29 | "continued_playing": true 30 | } 31 | */ 32 | 33 | import dateFormat from "dateformat" 34 | import commonWords from "./CommonWords" 35 | import curseWords from "./curseWords" 36 | import Dexie from "dexie" 37 | import DBUtils from "./DBUtils" 38 | const dateFormatString = 'dd/mm/yyyy' 39 | 40 | const roundDate = (timeStamp: number) => { 41 | timeStamp -= timeStamp % (24 * 60 * 60 * 1000) 42 | return new Date(timeStamp) 43 | } 44 | 45 | /** 46 | * Check if a word meets the criteria to be included 47 | * in the word frequency graph 48 | * 49 | * @param word The word to check 50 | */ 51 | function wordMeetsCriteria(word: string, badwords: string[]) { 52 | return word.length > 2 && !badwords.includes(word.toLowerCase()) 53 | } 54 | 55 | /** 56 | * Process descriptive statistics for an array of reviews to be 57 | * used to further derive insights - more efficient than allowing 58 | * components to invidually compute their own statistics and searches 59 | */ 60 | async function processReviewsForGame(game: any) { 61 | 62 | const store = DBUtils.getReviewStoreForGame(game.steam_appid) 63 | const reviewCount = await store.count() 64 | 65 | const median = function(array, valueFunction) { 66 | if (array.length === 0) { 67 | return 0 68 | } 69 | 70 | array.sort((a: any, b: any) => valueFunction(a) - valueFunction(b)) 71 | 72 | if (array.length % 2 === 0) { 73 | return ((valueFunction(array[array.length/2]) + valueFunction(array[(array.length / 2) - 1]))) / 2 74 | } 75 | else { 76 | return valueFunction(array[(Math.ceil(array.length / 2)) - 1]) 77 | } 78 | } 79 | 80 | const medianDB = async function(store: Dexie.Table, index: string, count: number, valuefunction: Function) { 81 | let limit = 1 82 | let startIndex = Math.ceil(count / 2) - 1 83 | if (count % 2 === 0) { 84 | limit = 2 85 | } 86 | 87 | const test = await store.orderBy(index).toArray() 88 | 89 | const results = await store.orderBy(index).offset(startIndex).limit(limit).toArray() 90 | 91 | let accumulator = 0 92 | for (let r of results) { 93 | accumulator += valuefunction(r) 94 | } 95 | 96 | return accumulator / limit 97 | } 98 | 99 | const medianDBContinuedPlaying = async function(store: Dexie.Table, valuefunction: Function, findPositive: boolean) { 100 | 101 | const collection = store.orderBy('author_playtime_after_review_time').filter(r => r.author_continued_playing && r.voted_up === findPositive) 102 | const collectionCount = await collection.count() 103 | 104 | let limit = 1 105 | let startIndex = Math.ceil(collectionCount / 2) - 1 106 | if (collectionCount % 2 === 0) { 107 | limit = 2 108 | } 109 | 110 | const results = await collection.offset(startIndex).limit(limit).toArray() 111 | 112 | let accumulator = 0 113 | for (let r of results) { 114 | accumulator += valuefunction(r) 115 | } 116 | 117 | return accumulator / limit 118 | } 119 | 120 | // Totals 121 | const totalReviews = reviewCount 122 | let totalReviewsPositive = 0 123 | let totalReviewsNegative = 0 124 | let totalContinuedPlayingAfterReviewTime = 0 125 | let totalContinuedPlayingAfterReviewTimePositive = 0 126 | let totalContinuedPlayingAfterReviewTimeNegative = 0 127 | let totalMinutesPlayedForever = 0 128 | let totalMinutesPlayedLastTwoWeeks = 0 129 | let totalMinutesPlayedAtReviewTime = 0 130 | let totalMinutesPlayedAfterReviewTime = 0 131 | let totalMinutesPlayedAfterReviewTimePositive = 0 132 | let totalMinutesPlayedAfterReviewTimeNegative = 0 133 | let totalReviewsUpdated = 0 134 | let totalTextLength = 0 135 | 136 | let totalWithComments = 0 137 | let totalPurchasedViaSteam = 0 138 | let totalMarkedAsReceivedForFree = 0 139 | let totalWrittenDuringEarlyAccess = 0 140 | 141 | const totalLanguages = {} 142 | 143 | // Individual reviews 144 | let reviewMinTimestampCreated = null 145 | let reviewMaxTimestampCreated = null 146 | let reviewMinTimestampUpdated = null 147 | let reviewMaxTimestampUpdated = null 148 | 149 | let reviewMinTotalMinutesPlayedForever = null 150 | let reviewMaxTotalMinutesPlayedForever = null 151 | let reviewMinTotalMinutesPlayedLastTwoWeeks = null 152 | let reviewMaxTotalMinutesPlayedLastTwoWeeks = null 153 | let reviewMinTotalMinutesPlayedAtReviewTime = null 154 | let reviewMaxTotalMinutesPlayedAtReviewTime = null 155 | 156 | let reviewMinAuthorNumReviews = null 157 | let reviewMaxAuthorNumReviews = null 158 | 159 | let reviewMinAuthorNumGames = null 160 | let reviewMaxAuthorNumGames = null 161 | 162 | let reviewMinCommentCount = null 163 | let reviewMaxCommentCount = null 164 | 165 | let reviewMinVotesUp = null 166 | let reviewMaxVotesUp = null 167 | 168 | let reviewMinVotesFunny = null 169 | let reviewMaxVotesFunny = null 170 | 171 | let reviewMinTextLength = null 172 | let reviewMaxTextLength = null 173 | 174 | // For visualizations 175 | let reviewVolumeOverTime = {} as any 176 | 177 | // For word cloud 178 | let positiveWordFrequencyMap = new Map() 179 | let negativeWordFrequencyMap = new Map() 180 | const nameWords = game.name.match(/\b(\w+)\b/g) 181 | let badWords 182 | if (nameWords !== null) { 183 | badWords = commonWords.concat(...nameWords).map(w => w.toLowerCase()) 184 | } else { 185 | badWords = commonWords.map(w => w.toLowerCase()) 186 | } 187 | 188 | // const swearWords = {} 189 | 190 | // Perform iteration over the reviews 191 | await store.each(review => { 192 | 193 | if (review.language === 'english') { 194 | // Process review text for word frequency 195 | let words: string[] = review.review.match(/\b(\w+)\b/g) 196 | if (words !== null) { 197 | words = [...new Set(words)].map(w => w.toLowerCase()) 198 | } 199 | 200 | if (words !== null) { 201 | for (let word of words) { 202 | 203 | // Frequency 204 | if (wordMeetsCriteria(word, badWords)) { 205 | if (review.voted_up) { 206 | if (positiveWordFrequencyMap.has(word)) { 207 | positiveWordFrequencyMap.set(word, positiveWordFrequencyMap.get(word) + 1) 208 | } else { 209 | positiveWordFrequencyMap.set(word, 1) 210 | } 211 | } else { 212 | if (negativeWordFrequencyMap.has(word)) { 213 | negativeWordFrequencyMap.set(word, negativeWordFrequencyMap.get(word) + 1) 214 | } else { 215 | negativeWordFrequencyMap.set(word, 1) 216 | } 217 | } 218 | } 219 | 220 | // Swears 221 | // if (curseWords[word]) { 222 | // const likeSwear = curseWords[word] 223 | // if (swearWords[likeSwear]) { 224 | // swearWords[likeSwear] += 1 225 | // } else { 226 | // swearWords[likeSwear] = 1 227 | // } 228 | // } 229 | } 230 | } 231 | } 232 | 233 | totalTextLength += review.length 234 | 235 | if (totalLanguages[review.language] === undefined) { 236 | totalLanguages[review.language] = { 237 | total: 1, 238 | positive: review.voted_up ? 1 : 0, 239 | negative: review.voted_up ? 0 : 1 240 | } 241 | } else { 242 | totalLanguages[review.language].total++ 243 | if (review.voted_up) { 244 | totalLanguages[review.language].positive++ 245 | } else { 246 | totalLanguages[review.language].negative++ 247 | } 248 | } 249 | 250 | if (review.voted_up) { 251 | totalReviewsPositive++ 252 | } else { 253 | totalReviewsNegative++ 254 | } 255 | 256 | if (review.steam_purchase) { 257 | totalPurchasedViaSteam++ 258 | } 259 | 260 | if (review.received_for_free) { 261 | totalMarkedAsReceivedForFree++ 262 | } 263 | 264 | if (review.written_during_early_access) { 265 | totalWrittenDuringEarlyAccess++ 266 | } 267 | 268 | if (review.comment_count > 0) { 269 | totalWithComments++ 270 | } 271 | 272 | if (reviewMinCommentCount === null || review.comment_count < reviewMinCommentCount.comment_count) { 273 | reviewMinCommentCount = review 274 | } 275 | if (reviewMaxCommentCount === null || review.comment_count > reviewMaxCommentCount.comment_count) { 276 | reviewMaxCommentCount = review 277 | } 278 | 279 | if (reviewMinAuthorNumReviews === null || review.author_num_reviews < reviewMinAuthorNumReviews.author_num_reviews) { 280 | reviewMinAuthorNumReviews = review 281 | } 282 | if (reviewMaxAuthorNumReviews === null || review.author_num_reviews > reviewMaxAuthorNumReviews.author_num_reviews) { 283 | reviewMaxAuthorNumReviews = review 284 | } 285 | 286 | if (reviewMinAuthorNumGames === null || review.author_num_games_owned < reviewMinAuthorNumGames.author_num_games_owned) { 287 | reviewMinAuthorNumGames = review 288 | } 289 | if (reviewMaxAuthorNumGames === null || review.author_num_games_owned > reviewMaxAuthorNumGames.author_num_games_owned) { 290 | reviewMaxAuthorNumGames = review 291 | } 292 | 293 | if (reviewMinVotesUp === null || review.votes_up < reviewMinVotesUp.votes_up) { 294 | reviewMinVotesUp = review 295 | } 296 | if (reviewMaxVotesUp === null || review.votes_up > reviewMaxVotesUp.votes_up) { 297 | reviewMaxVotesUp = review 298 | } 299 | 300 | if (reviewMinVotesFunny === null || review.votes_funny < reviewMinVotesFunny.votes_funny) { 301 | reviewMinVotesFunny = review 302 | } 303 | if (reviewMaxVotesFunny === null || review.votes_funny > reviewMaxVotesFunny.votes_funny) { 304 | reviewMaxVotesFunny = review 305 | } 306 | 307 | if (reviewMinTextLength === null || review.review.length < reviewMinTextLength.review.length) { 308 | reviewMinTextLength = review 309 | } 310 | if (reviewMaxTextLength === null || review.review.length > reviewMaxTextLength.review.length) { 311 | reviewMaxTextLength = review 312 | } 313 | 314 | if (review.timestamp_updated !== review.timestamp_created) { 315 | totalReviewsUpdated++ 316 | // TODO: Factor this into views 317 | review.was_updated = true 318 | if (reviewMinTimestampUpdated === null || review.timestamp_updated < reviewMinTimestampUpdated.timestamp_updated) { 319 | reviewMinTimestampUpdated = review 320 | } 321 | if (reviewMaxTimestampUpdated === null || review.timestamp_updated > reviewMaxTimestampUpdated.timestamp_updated) { 322 | reviewMaxTimestampUpdated = review 323 | } 324 | } 325 | 326 | if (review.author_continued_playing) { 327 | 328 | totalContinuedPlayingAfterReviewTime++ 329 | 330 | let mPlayedAfterReview = review.author_playtime_after_review_time 331 | totalMinutesPlayedAfterReviewTime += mPlayedAfterReview 332 | 333 | if (review.voted_up) { 334 | totalContinuedPlayingAfterReviewTimePositive++ 335 | totalMinutesPlayedAfterReviewTimePositive += mPlayedAfterReview 336 | } else { 337 | totalContinuedPlayingAfterReviewTimeNegative++ 338 | totalMinutesPlayedAfterReviewTimeNegative += mPlayedAfterReview 339 | } 340 | } 341 | 342 | totalMinutesPlayedForever += review.author_playtime_forever 343 | totalMinutesPlayedLastTwoWeeks += review.author_playtime_last_two_weeks 344 | totalMinutesPlayedAtReviewTime += review.author_playtime_at_review 345 | 346 | if (reviewMinTotalMinutesPlayedForever === null || review.author_playtime_forever < reviewMinTotalMinutesPlayedForever.author_playtime_forever) { 347 | reviewMinTotalMinutesPlayedForever = review 348 | } 349 | if (reviewMaxTotalMinutesPlayedForever === null || review.author_playtime_forever > reviewMaxTotalMinutesPlayedForever.author_playtime_forever) { 350 | reviewMaxTotalMinutesPlayedForever = review 351 | } 352 | 353 | if (reviewMinTotalMinutesPlayedLastTwoWeeks === null || review.author_playtime_last_two_weeks < reviewMinTotalMinutesPlayedLastTwoWeeks.author_playtime_last_two_weeks) { 354 | reviewMinTotalMinutesPlayedLastTwoWeeks = review 355 | } 356 | if (reviewMaxTotalMinutesPlayedLastTwoWeeks === null || review.author_playtime_last_two_weeks > reviewMaxTotalMinutesPlayedLastTwoWeeks.author_playtime_last_two_weeks) { 357 | reviewMaxTotalMinutesPlayedLastTwoWeeks = review 358 | } 359 | 360 | if (reviewMinTotalMinutesPlayedAtReviewTime === null || review.author_playtime_at_review < reviewMinTotalMinutesPlayedAtReviewTime.author_playtime_at_review) { 361 | reviewMinTotalMinutesPlayedAtReviewTime = review 362 | } 363 | if (reviewMaxTotalMinutesPlayedAtReviewTime === null || review.author_playtime_at_review > reviewMaxTotalMinutesPlayedAtReviewTime.author_playtime_at_review) { 364 | reviewMaxTotalMinutesPlayedAtReviewTime = review 365 | } 366 | 367 | if (reviewMinTimestampCreated === null || review.timestamp_created < reviewMinTimestampCreated.timestamp_created) { 368 | reviewMinTimestampCreated = review 369 | } 370 | if (reviewMaxTimestampCreated === null || review.timestamp_created > reviewMaxTimestampCreated.timestamp_created) { 371 | reviewMaxTimestampCreated = review 372 | } 373 | 374 | var roundedCreatedTimestamp = roundDate(review.timestamp_created * 1000).getTime() / 1000 375 | 376 | if (reviewVolumeOverTime[roundedCreatedTimestamp] !== undefined) { 377 | if (review.voted_up) { 378 | reviewVolumeOverTime[roundedCreatedTimestamp]["Total Positive"]++ 379 | } else { 380 | reviewVolumeOverTime[roundedCreatedTimestamp]["Total Negative"]-- 381 | } 382 | } else { 383 | if (review.voted_up) { 384 | reviewVolumeOverTime[roundedCreatedTimestamp] = { name: dateFormat(new Date(roundedCreatedTimestamp * 1000), dateFormatString), "Total Positive": 1, "Total Negative": 0, asEpoch: roundedCreatedTimestamp } 385 | } else { 386 | reviewVolumeOverTime[roundedCreatedTimestamp] = { name: dateFormat(new Date(roundedCreatedTimestamp * 1000), dateFormatString), "Total Positive": 0, "Total Negative": -1, asEpoch: roundedCreatedTimestamp } 387 | } 388 | } 389 | }) 390 | 391 | // Word frequency 392 | const positiveWordFrequencyList = [...positiveWordFrequencyMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20) 393 | const negativeWordFrequencyList = [...negativeWordFrequencyMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20) 394 | 395 | // Swear words 396 | // const swearWordsSorted = Object.entries(swearWords).sort((a: any, b: any) => b[1] - a[1]) 397 | 398 | // Compute remaining stats 399 | const averageMinutesPlaytimeAfterReviewTime = Math.floor(totalMinutesPlayedAfterReviewTime / totalReviews) 400 | const averageMinutesPlaytimeAfterReviewTimePositive = totalContinuedPlayingAfterReviewTimePositive === 0 ? 0 : Math.floor(totalMinutesPlayedAfterReviewTimePositive / totalContinuedPlayingAfterReviewTimePositive) 401 | const averageMinutesPlaytimeAfterReviewTimeNegative = totalContinuedPlayingAfterReviewTimeNegative === 0 ? 0 : Math.floor(totalMinutesPlayedAfterReviewTimeNegative / totalContinuedPlayingAfterReviewTimeNegative) 402 | const averageMinutesPlaytimeAtReviewTime = Math.floor(totalMinutesPlayedAtReviewTime / totalReviews) 403 | const averageMinutesPlaytimeForever = Math.floor(totalMinutesPlayedForever / totalReviews) 404 | const averageMinutesPlaytimeLastTwoWeeks = Math.floor(totalMinutesPlayedLastTwoWeeks / totalReviews) 405 | 406 | const medianMinutesPlayedForever = Math.floor(await medianDB(store, 'author_playtime_forever', reviewCount, (r: any) => r.author_playtime_forever)) 407 | const medianMinutesPlayedAtReviewTime = Math.floor(await medianDB(store, 'author_playtime_at_review', reviewCount, (r: any) => r.author_playtime_at_review)) 408 | const medianMinutesPlayedLastTwoWeeks = Math.floor(await medianDB(store, 'author_playtime_last_two_weeks', reviewCount, (r: any) => r.author_playtime_last_two_weeks)) 409 | const medianTextLength = Math.floor(await medianDB(store, 'length', reviewCount, (r: any) => r.length)) 410 | const averageTextLength = Math.floor(totalTextLength / totalReviews) 411 | 412 | const medianMinutesContinuedPlayingAfterPositiveReview = Math.floor(await medianDBContinuedPlaying(store, t => t.author_playtime_after_review_time, true)) 413 | const medianMinutesContinuedPlayingAfterNegativeReview = Math.floor(await medianDBContinuedPlaying(store, t => t.author_playtime_after_review_time, false)) 414 | 415 | if (reviewMaxTimestampUpdated === null) { 416 | reviewMaxTimestampUpdated = reviewMaxTimestampCreated 417 | } 418 | 419 | const firstCreatedTimestamp = roundDate(reviewMinTimestampCreated.timestamp_created * 1000).getTime() / 1000 420 | 421 | for (let i = firstCreatedTimestamp; i < reviewMaxTimestampCreated.timestamp_created; i += 86400) { 422 | if (reviewVolumeOverTime[i] === undefined) { 423 | reviewVolumeOverTime[i] = { name: dateFormat(new Date(i * 1000), dateFormatString), "Total Positive": 0, "Total Negative": 0, asEpoch: i} 424 | } 425 | } 426 | 427 | reviewVolumeOverTime = Object.values(reviewVolumeOverTime).sort((a: any, b: any) => a.asEpoch - b.asEpoch) 428 | 429 | let totalPositiveSoFar = 0 430 | let totalNegativeSoFar = 0 431 | 432 | for (let item of reviewVolumeOverTime) { 433 | totalPositiveSoFar += item["Total Positive"] 434 | totalNegativeSoFar += Math.abs(item["Total Negative"]) 435 | 436 | item["Review Score"] = Math.round((totalPositiveSoFar / (totalPositiveSoFar + totalNegativeSoFar)) * 100) 437 | } 438 | 439 | // TODO: PROBABLY IMPORTANT FOR DEFAULT SORTING... 440 | // reviews.sort((a, b) => b.timestamp_updated - a.timestamp_updated) 441 | 442 | const result = { 443 | totalReviews: totalReviews, 444 | totalReviewsPositive: totalReviewsPositive, 445 | totalReviewsNegative: totalReviewsNegative, 446 | totalContinuedPlayingAfterReviewTime: totalContinuedPlayingAfterReviewTime, 447 | totalContinuedPlayingAfterReviewTimePositive: totalContinuedPlayingAfterReviewTimePositive, 448 | totalContinuedPlayingAfterReviewTimeNegative: totalContinuedPlayingAfterReviewTimeNegative, 449 | averageMinutesPlaytimeAfterReviewTime: averageMinutesPlaytimeAfterReviewTime, 450 | averageMinutesPlayedAfterReviewTimePositive: averageMinutesPlaytimeAfterReviewTimePositive, 451 | averageMinutesPlayedAfterReviewTimeNegative: averageMinutesPlaytimeAfterReviewTimeNegative, 452 | medianMinutesContinuedPlayingAfterPositiveReview: medianMinutesContinuedPlayingAfterPositiveReview, 453 | medianMinutesContinuedPlayingAfterNegativeReview: medianMinutesContinuedPlayingAfterNegativeReview, 454 | totalMinutesPlayedForever: totalMinutesPlayedForever, 455 | totalMinutesPlayedLastTwoWeeks: totalMinutesPlayedLastTwoWeeks, 456 | totalMinutesPlayedAtReviewTime: totalMinutesPlayedAtReviewTime, 457 | totalReviewsUpdated: totalReviewsUpdated, 458 | totalWithComments: totalWithComments, 459 | totalPurchasedViaSteam: totalPurchasedViaSteam, 460 | totalMarkedAsReceivedForFree: totalMarkedAsReceivedForFree, 461 | totalWrittenDuringEarlyAccess: totalWrittenDuringEarlyAccess, 462 | totalLanguages: totalLanguages, 463 | averageMinutesPlaytimeAtReviewTime: averageMinutesPlaytimeAtReviewTime, 464 | averageMinutesPlaytimeForever: averageMinutesPlaytimeForever, 465 | averageMinutesPlaytimeLastTwoWeeks: averageMinutesPlaytimeLastTwoWeeks, 466 | medianMinutesPlayedForever: medianMinutesPlayedForever, 467 | medianMinutesPlayedLastTwoWeeks: medianMinutesPlayedLastTwoWeeks, 468 | medianMinutesPlayedAtReviewTime: medianMinutesPlayedAtReviewTime, 469 | reviewMinTimestampCreated: reviewMinTimestampCreated, 470 | reviewMaxTimestampCreated: reviewMaxTimestampCreated, 471 | reviewMinTimestampUpdated: reviewMinTimestampUpdated, 472 | reviewMaxTimestampUpdated: reviewMaxTimestampUpdated, 473 | reviewMinTotalMinutesPlayedForever: reviewMinTotalMinutesPlayedForever, 474 | reviewMaxTotalMinutesPlayedForever: reviewMaxTotalMinutesPlayedForever, 475 | reviewMinTotalMinutesPlayedLastTwoWeeks: reviewMinTotalMinutesPlayedLastTwoWeeks, 476 | reviewMaxTotalMinutesPlayedLastTwoWeeks: reviewMaxTotalMinutesPlayedLastTwoWeeks, 477 | reviewMinTotalMinutesPlayedAtReviewTime: reviewMinTotalMinutesPlayedAtReviewTime, 478 | reviewMaxTotalMinutesPlayedAtReviewTime: reviewMaxTotalMinutesPlayedAtReviewTime, 479 | reviewMinAuthorNumReviews: reviewMinAuthorNumReviews, 480 | reviewMaxAuthorNumReviews: reviewMaxAuthorNumReviews, 481 | reviewMinAuthorNumGames: reviewMinAuthorNumGames, 482 | reviewMaxAuthorNumGames: reviewMaxAuthorNumGames, 483 | reviewMinCommentCount: reviewMinCommentCount, 484 | reviewMaxCommentCount: reviewMaxCommentCount, 485 | reviewMinVotesUp: reviewMinVotesUp, 486 | reviewMaxVotesUp: reviewMaxVotesUp, 487 | reviewMinVotesFunny: reviewMinVotesFunny, 488 | reviewMaxVotesFunny: reviewMaxVotesFunny, 489 | reviewMinTextLength: reviewMinTextLength, 490 | reviewMaxTextLength: reviewMaxTextLength, 491 | averageTextLength: averageTextLength, 492 | medianTextLength: medianTextLength, 493 | reviewVolumeOverTime: reviewVolumeOverTime, 494 | positiveWordFrequencyList: positiveWordFrequencyList, 495 | negativeWordFrequencyList: negativeWordFrequencyList, 496 | // totalSwearWords: swearWordsSorted 497 | } 498 | 499 | return result 500 | } 501 | 502 | export default { 503 | processReviewsForGame: processReviewsForGame, 504 | sortReviews: null, 505 | filterReviews: null 506 | } --------------------------------------------------------------------------------