├── .devcontainer └── devcontainer.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ ├── manual-publish-version.yml │ ├── python-test-dev.yml │ ├── python-test-pr.yml │ └── python-test-release.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── SECURITY.md ├── codecov.yml ├── howlongtobeatpy ├── LICENSE.md ├── README.md ├── howlongtobeatpy │ ├── HTMLRequests.py │ ├── HowLongToBeat.py │ ├── HowLongToBeatEntry.py │ ├── JSONResultParser.py │ └── __init__.py ├── setup.py └── tests │ ├── __init__.py │ ├── test_async_request.py │ ├── test_async_request_by_id.py │ ├── test_normal_request.py │ └── test_normal_request_by_id.py └── sonar-project.properties /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"howlongtobeatpy-devcontainer", 3 | "image":"mcr.microsoft.com/devcontainers/python:3.9", 4 | "features":{ 5 | 6 | }, 7 | "customizations":{ 8 | "vscode":{ 9 | "settings":{ 10 | 11 | }, 12 | "extensions":[ 13 | "ms-python.python", 14 | "ms-python.debugpy", 15 | "ms-python.vscode-pylance", 16 | "ms-python.pylint" 17 | ] 18 | } 19 | }, 20 | "postCreateCommand":"pip3 install -r pytest" 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem with the API 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: ScrappyCocco 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | *A clear and concise description of what the bug is.* 12 | 13 | **To Reproduce** 14 | *Steps to reproduce the behavior...* 15 | 16 | **HLTB API Version** 17 | *You can show it with `pip show`* 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea, a change or an improvement 4 | title: "[REQUEST]" 5 | labels: enhancement 6 | assignees: ScrappyCocco 7 | 8 | --- 9 | 10 | *Describe what change or improvement would you like* 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/howlongtobeatpy" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | types: [opened, reopened] 19 | # The branches below must be a subset of the branches above 20 | branches: [ master ] 21 | release: 22 | types: [created, edited] 23 | schedule: 24 | - cron: '24 8 * * 3' 25 | discussion: 26 | types: [created, edited, pinned] 27 | issues: 28 | types: [opened, edited, reopened, closed] 29 | workflow_dispatch: 30 | 31 | jobs: 32 | analyze: 33 | name: Analyze 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | language: [ 'python' ] 40 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 41 | # Learn more: 42 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 43 | 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@v4 47 | 48 | # Initializes the CodeQL tools for scanning. 49 | - name: Initialize CodeQL 50 | uses: github/codeql-action/init@v3 51 | with: 52 | languages: ${{ matrix.language }} 53 | # If you wish to specify custom queries, you can do so here or in a config file. 54 | # By default, queries listed here will override any specified in a config file. 55 | # Prefix the list here with "+" to use these queries and those in the config file. 56 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v3 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 https://git.io/JvXDl 65 | 66 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 67 | # and modify them (or add more) to build your code if your project 68 | # uses a compiled language 69 | 70 | #- run: | 71 | # make bootstrap 72 | # make release 73 | 74 | - name: Perform CodeQL Analysis 75 | uses: github/codeql-action/analyze@v3 76 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | 9 | on: 10 | pull_request: 11 | types: [opened, reopened] 12 | branches: [ master ] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | dependency-review: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: 'Checkout Repository' 22 | uses: actions/checkout@v4 23 | - name: 'Dependency Review' 24 | uses: actions/dependency-review-action@v4 25 | -------------------------------------------------------------------------------- /.github/workflows/manual-publish-version.yml: -------------------------------------------------------------------------------- 1 | name: "Manually Publish Python 🐍 distribution 📦 to PyPI" 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | # Disabling shallow clone is recommended for improving relevancy of reporting 15 | fetch-depth: 0 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.9' 20 | - name: Install pypa/build 21 | run: | 22 | python3 -m pip install build --user 23 | - name: Build a binary wheel and a source tarball 24 | run: | 25 | cd howlongtobeatpy 26 | python3 -m build 27 | - name: Store the distribution packages 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: python-package-distributions 31 | path: howlongtobeatpy/dist/ 32 | publish-to-pypi: 33 | name: >- 34 | Publish Python 🐍 distribution 📦 to PyPI 35 | needs: 36 | - build 37 | runs-on: ubuntu-latest 38 | environment: 39 | name: pypi 40 | url: https://pypi.org/p/howlongtobeatpy 41 | permissions: 42 | id-token: write # IMPORTANT: mandatory for trusted publishing 43 | steps: 44 | - name: Download all the dists 45 | uses: actions/download-artifact@v4 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | - name: Publish distribution 📦 to PyPI 50 | uses: pypa/gh-action-pypi-publish@release/v1 51 | 52 | -------------------------------------------------------------------------------- /.github/workflows/python-test-dev.yml: -------------------------------------------------------------------------------- 1 | # This workflows will test the dev version of the API when pushed on github 2 | 3 | name: Python Test Github Dev Version 4 | 5 | on: 6 | [push, release] 7 | 8 | jobs: 9 | test: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | # Disabling shallow clone is recommended for improving relevancy of reporting 17 | fetch-depth: 0 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.9' 22 | - name: Install dependencies 23 | run: | 24 | cd howlongtobeatpy 25 | python -m pip install --upgrade pip 26 | pip install -U pytest pytest-cov 27 | pip install . 28 | - name: Test 29 | run: pytest --cov=howlongtobeatpy --cov-report=xml 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v5 32 | with: 33 | fail_ci_if_error: true 34 | verbose: true 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | - name: SonarQube Scan 37 | uses: SonarSource/sonarqube-scan-action@v4 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/python-test-pr.yml: -------------------------------------------------------------------------------- 1 | # This workflows will test the dev version of the API when pushed on github 2 | 3 | name: Python Test Github Community 4 | 5 | on: 6 | [pull_request, discussion, issues] 7 | 8 | jobs: 9 | test: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | # Disabling shallow clone is recommended for improving relevancy of reporting 17 | fetch-depth: 0 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.9' 22 | - name: Install dependencies 23 | run: | 24 | cd howlongtobeatpy 25 | python -m pip install --upgrade pip 26 | pip install -U pytest pytest-cov 27 | pip install . 28 | - name: Test 29 | run: pytest --cov=howlongtobeatpy --cov-report=xml 30 | -------------------------------------------------------------------------------- /.github/workflows/python-test-release.yml: -------------------------------------------------------------------------------- 1 | # This workflows will test the released version of the API 2 | 3 | name: Python Test Scheduled 4 | 5 | on: 6 | release: 7 | types: [created, edited] 8 | schedule: 9 | - cron: '30 8 * * 1' 10 | discussion: 11 | types: [created, edited, pinned] 12 | issues: 13 | types: [opened, edited, reopened, closed] 14 | workflow_dispatch: 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | # Disabling shallow clone is recommended for improving relevancy of reporting 25 | fetch-depth: 0 26 | - name: Set up Python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: '3.9' 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install -U pytest pytest-cov 34 | pip install howlongtobeatpy 35 | - name: Test 36 | run: pytest --cov=howlongtobeatpy --cov-report=xml 37 | - name: Upload coverage to Codecov 38 | uses: codecov/codecov-action@v5 39 | with: 40 | fail_ci_if_error: true 41 | verbose: true 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | - name: SonarQube Scan 44 | uses: SonarSource/sonarqube-scan-action@v4 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 48 | 49 | 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | \.idea/ 3 | 4 | howlongtobeatpy/dist/ 5 | 6 | howlongtobeatpy/tests/__pycache__/ 7 | 8 | howlongtobeatpy/howlongtobeatpy/__pycache__/ 9 | 10 | howlongtobeatpy/howlongtobeatpy\.egg-info/ 11 | 12 | \.scannerwork/ 13 | 14 | howlongtobeatpy/tests/test_normal_request_debug\.py 15 | 16 | howlongtobeatpy/build/ 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "howlongtobeatpy" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Michele 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HowLongToBeat Python API 2 | 3 | [![Python Test Released Published Version](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/actions/workflows/python-test-release.yml/badge.svg)](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/actions/workflows/python-test-release.yml) 4 | [![CodeQL](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/actions/workflows/codeql-analysis.yml) 5 | 6 | [![codecov](https://codecov.io/gh/ScrappyCocco/HowLongToBeat-PythonAPI/branch/master/graph/badge.svg)](https://codecov.io/gh/ScrappyCocco/HowLongToBeat-PythonAPI) 7 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=ScrappyCocco_HowLongToBeat-PythonAPI&metric=bugs)](https://sonarcloud.io/dashboard?id=ScrappyCocco_HowLongToBeat-PythonAPI) 8 | 9 | A simple Python API to read data from howlongtobeat.com. 10 | 11 | It is inspired by [ckatzorke - howlongtobeat](https://github.com/ckatzorke/howlongtobeat) JS API. 12 | 13 | ## Content 14 | 15 | - [HowLongToBeat Python API](#howlongtobeat-python-api) 16 | - [Content](#content) 17 | - [Usage](#usage) 18 | - [Installation](#installation) 19 | - [Installing the package downloading the last release](#installing-the-package-downloading-the-last-release) 20 | - [Installing the package from the source code](#installing-the-package-from-the-source-code) 21 | - [Usage in code](#usage-in-code) 22 | - [Start including it in your file](#start-including-it-in-your-file) 23 | - [Now call search()](#now-call-search) 24 | - [Alternative search (by ID)](#alternative-search-by-id) 25 | - [DLC search](#dlc-search) 26 | - [Results auto-filters](#results-auto-filters) 27 | - [Reading an entry](#reading-an-entry) 28 | - [Issues, Questions \& Discussions](#issues-questions--discussions) 29 | - [Authors](#authors) 30 | - [License](#license) 31 | 32 | ## Usage 33 | 34 | ## Installation 35 | 36 | ### Installing the package downloading the last release 37 | 38 | ```python 39 | pip install howlongtobeatpy 40 | ``` 41 | 42 | ### Installing the package from the source code 43 | 44 | Download the repo, enter the folder with 'setup.py' and run the command 45 | 46 | ```python 47 | pip install . 48 | ``` 49 | 50 | ## Usage in code 51 | 52 | ### Start including it in your file 53 | 54 | ```python 55 | from howlongtobeatpy import HowLongToBeat 56 | ``` 57 | 58 | ### Now call search() 59 | 60 | The API main functions are: 61 | 62 | ```python 63 | results = HowLongToBeat().search("Awesome Game") 64 | ``` 65 | 66 | or, if you prefer using async: 67 | 68 | ```python 69 | results = await HowLongToBeat().async_search("Awesome Game") 70 | ``` 71 | 72 | The return of that function is a **list** of possible games, or **None** in case you passed an invalid "game name" as parameter or if there was an error in the request. 73 | 74 | If the list **is not None** you should choose the best entry checking the Similarity value with the original name, example: 75 | 76 | ```python 77 | results_list = await HowLongToBeat().async_search("Awesome Game") 78 | if results_list is not None and len(results_list) > 0: 79 | best_element = max(results_list, key=lambda element: element.similarity) 80 | ``` 81 | 82 | Once done, "best_element" will contain the best game found in the research. 83 | Every entry in the list (if not None in case of errors) is an object of type: [HowLongToBeatEntry](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/howlongtobeatpy/howlongtobeatpy/HowLongToBeatEntry.py). 84 | 85 | ### Alternative search (by ID) 86 | 87 | If you prefer, you can get a game by ID, this can be useful if you already have the game's howlongtobeat-id (the ID is the number in the URL, for example in [https://howlongtobeat.com/game/7231]([hello](https://howlongtobeat.com/game/7231)) the ID is 7231). 88 | 89 | To avoid a new parser, the search by ID use a first request to get the game title, and then use the standard search with that title, filtering the results and returning the unique game with that ID. 90 | 91 | Remember that it could be a bit slower, but you avoid searching the game in the array by similarity. 92 | 93 | Here's the example: 94 | 95 | ```python 96 | result = HowLongToBeat().search_from_id(123456) 97 | ``` 98 | 99 | or, if you prefer using async: 100 | 101 | ```python 102 | result = await HowLongToBeat().async_search_from_id(123456) 103 | ``` 104 | 105 | This call will return an unique [HowLongToBeatEntry](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/howlongtobeatpy/howlongtobeatpy/HowLongToBeatEntry.py) or None in case of errors. 106 | 107 | ### DLC search 108 | 109 | An `enum` has been added to have a filter in the search: 110 | 111 | ```python 112 | SearchModifiers.NONE # default 113 | SearchModifiers.ISOLATE_DLC 114 | SearchModifiers.HIDE_DLC 115 | ``` 116 | 117 | This optional parameter allow you to specify in the search if you want the default search (with DLCs), to HIDE DLCs and only show games, or to ISOLATE DLCs (show only DLCs). 118 | 119 | ### Results auto-filters 120 | 121 | To ignore games with a very different name, the standard search automatically filter results with a game name that has a similarity with the given name > than `0.4`, not adding the others to the result list. 122 | If you want all the results, or you want to change this value, you can put a parameter in the constructor: 123 | 124 | ```python 125 | results = HowLongToBeat(0.0).search("Awesome Game") 126 | ``` 127 | 128 | putting `0.0` (or just `0`) will return all the found games, otherwise you can write another (`float`) number between 0...1 to set a new filter, such as `0.7`. 129 | 130 | Also remember that by default the similarity check **is case-sensitive** between the name given and the name found, if you want to ignore the case you can use: 131 | 132 | ```python 133 | results = HowLongToBeat(0.0).search("Awesome Game", similarity_case_sensitive=False) 134 | ``` 135 | 136 | **Remember** that, when searching by ID, the similarity value and the case-sensitive bool are **ignored**. 137 | 138 | An auto-filter for game-types has been added, it is not active by default (False) but can be used as: 139 | 140 | ```python 141 | results = HowLongToBeat(input_auto_filter_times = True).search("The Witcher 3") 142 | ``` 143 | 144 | That auto-filter "nullify" values based on the game-type, if it is a singleplayer game then the coop/multiplayer values are overridden to Null; on the other side if it is a Multiplayer game the singleplayer values such as "main story" could be overridden to Null if that game doesn't have a story. Use with caution, it is probably better if you decide what fits best for you. 145 | 146 | ### Reading an entry 147 | 148 | An entry is made of few values, you can check them [in the Entry class file](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/howlongtobeatpy/howlongtobeatpy/HowLongToBeatEntry.py). It also include the full JSON of values (already converted to Python dict) received from HLTB. 149 | 150 | ## Issues, Questions & Discussions 151 | 152 | If you found a bug report it as soon as you can creating an [issue](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/issues/new), the code may not be perfect. 153 | 154 | If you need any new feature, or want to discuss the current implementation/features, consider opening a [discussion](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/discussions) or even propose a change with a [Pull Request](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/pulls). 155 | 156 | ## Authors 157 | 158 | - **ScrappyCocco** - Thank you for using my API 159 | 160 | ## License 161 | 162 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 163 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | To make sure everything works as expected always use and test the latest version before reporting an issue. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | If you think something is wrong/unsafe in the code, or a dependency is vulnerable, please open an [Issue](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/issues) or a [Discussion](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/discussions). 10 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | ignore: 3 | - "howlongtobeatpy/tests/test_normal_request.py" 4 | - "howlongtobeatpy/tests/test_async_request.py" 5 | - "howlongtobeatpy/tests/test_async_request_by_id.py" 6 | - "howlongtobeatpy/tests/test_normal_request_by_id.py" 7 | - "howlongtobeatpy/setup.py" 8 | -------------------------------------------------------------------------------- /howlongtobeatpy/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Michele 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 | -------------------------------------------------------------------------------- /howlongtobeatpy/README.md: -------------------------------------------------------------------------------- 1 | # HowLongToBeat Python API 2 | 3 | [![Python Test Released Published Version](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/actions/workflows/python-test-release.yml/badge.svg)](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/actions/workflows/python-test-release.yml) 4 | [![CodeQL](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/actions/workflows/codeql-analysis.yml) 5 | 6 | [![codecov](https://codecov.io/gh/ScrappyCocco/HowLongToBeat-PythonAPI/branch/master/graph/badge.svg)](https://codecov.io/gh/ScrappyCocco/HowLongToBeat-PythonAPI) 7 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=ScrappyCocco_HowLongToBeat-PythonAPI&metric=bugs)](https://sonarcloud.io/dashboard?id=ScrappyCocco_HowLongToBeat-PythonAPI) 8 | 9 | A simple Python API to read data from howlongtobeat.com. 10 | 11 | It is inspired by [ckatzorke - howlongtobeat](https://github.com/ckatzorke/howlongtobeat) JS API. 12 | 13 | ## Content 14 | 15 | - [HowLongToBeat Python API](#howlongtobeat-python-api) 16 | - [Content](#content) 17 | - [Usage](#usage) 18 | - [Installation](#installation) 19 | - [Installing the package downloading the last release](#installing-the-package-downloading-the-last-release) 20 | - [Installing the package from the source code](#installing-the-package-from-the-source-code) 21 | - [Usage in code](#usage-in-code) 22 | - [Start including it in your file](#start-including-it-in-your-file) 23 | - [Now call search()](#now-call-search) 24 | - [Alternative search (by ID)](#alternative-search-by-id) 25 | - [DLC search](#dlc-search) 26 | - [Results auto-filters](#results-auto-filters) 27 | - [Reading an entry](#reading-an-entry) 28 | - [Issues, Questions \& Discussions](#issues-questions--discussions) 29 | - [Authors](#authors) 30 | - [License](#license) 31 | 32 | ## Usage 33 | 34 | ## Installation 35 | 36 | ### Installing the package downloading the last release 37 | 38 | ```python 39 | pip install howlongtobeatpy 40 | ``` 41 | 42 | ### Installing the package from the source code 43 | 44 | Download the repo, enter the folder with 'setup.py' and run the command 45 | 46 | ```python 47 | pip install . 48 | ``` 49 | 50 | ## Usage in code 51 | 52 | ### Start including it in your file 53 | 54 | ```python 55 | from howlongtobeatpy import HowLongToBeat 56 | ``` 57 | 58 | ### Now call search() 59 | 60 | The API main functions are: 61 | 62 | ```python 63 | results = HowLongToBeat().search("Awesome Game") 64 | ``` 65 | 66 | or, if you prefer using async: 67 | 68 | ```python 69 | results = await HowLongToBeat().async_search("Awesome Game") 70 | ``` 71 | 72 | The return of that function is a **list** of possible games, or **None** in case you passed an invalid "game name" as parameter or if there was an error in the request. 73 | 74 | If the list **is not None** you should choose the best entry checking the Similarity value with the original name, example: 75 | 76 | ```python 77 | results_list = await HowLongToBeat().async_search("Awesome Game") 78 | if results_list is not None and len(results_list) > 0: 79 | best_element = max(results_list, key=lambda element: element.similarity) 80 | ``` 81 | 82 | Once done, "best_element" will contain the best game found in the research. 83 | Every entry in the list (if not None in case of errors) is an object of type: [HowLongToBeatEntry](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/howlongtobeatpy/howlongtobeatpy/HowLongToBeatEntry.py). 84 | 85 | ### Alternative search (by ID) 86 | 87 | If you prefer, you can get a game by ID, this can be useful if you already have the game's howlongtobeat-id (the ID is the number in the URL, for example in [https://howlongtobeat.com/game/7231]([hello](https://howlongtobeat.com/game/7231)) the ID is 7231). 88 | 89 | To avoid a new parser, the search by ID use a first request to get the game title, and then use the standard search with that title, filtering the results and returning the unique game with that ID. 90 | 91 | Remember that it could be a bit slower, but you avoid searching the game in the array by similarity. 92 | 93 | Here's the example: 94 | 95 | ```python 96 | result = HowLongToBeat().search_from_id(123456) 97 | ``` 98 | 99 | or, if you prefer using async: 100 | 101 | ```python 102 | result = await HowLongToBeat().async_search_from_id(123456) 103 | ``` 104 | 105 | This call will return an unique [HowLongToBeatEntry](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/howlongtobeatpy/howlongtobeatpy/HowLongToBeatEntry.py) or None in case of errors. 106 | 107 | ### DLC search 108 | 109 | An `enum` has been added to have a filter in the search: 110 | 111 | ```python 112 | SearchModifiers.NONE # default 113 | SearchModifiers.ISOLATE_DLC 114 | SearchModifiers.HIDE_DLC 115 | ``` 116 | 117 | This optional parameter allow you to specify in the search if you want the default search (with DLCs), to HIDE DLCs and only show games, or to ISOLATE DLCs (show only DLCs). 118 | 119 | ### Results auto-filters 120 | 121 | To ignore games with a very different name, the standard search automatically filter results with a game name that has a similarity with the given name > than `0.4`, not adding the others to the result list. 122 | If you want all the results, or you want to change this value, you can put a parameter in the constructor: 123 | 124 | ```python 125 | results = HowLongToBeat(0.0).search("Awesome Game") 126 | ``` 127 | 128 | putting `0.0` (or just `0`) will return all the found games, otherwise you can write another (`float`) number between 0...1 to set a new filter, such as `0.7`. 129 | 130 | Also remember that by default the similarity check **is case-sensitive** between the name given and the name found, if you want to ignore the case you can use: 131 | 132 | ```python 133 | results = HowLongToBeat(0.0).search("Awesome Game", similarity_case_sensitive=False) 134 | ``` 135 | 136 | **Remember** that, when searching by ID, the similarity value and the case-sensitive bool are **ignored**. 137 | 138 | An auto-filter for game-types has been added, it is not active by default (False) but can be used as: 139 | 140 | ```python 141 | results = HowLongToBeat(input_auto_filter_times = True).search("The Witcher 3") 142 | ``` 143 | 144 | That auto-filter "nullify" values based on the game-type, if it is a singleplayer game then the coop/multiplayer values are overridden to Null; on the other side if it is a Multiplayer game the singleplayer values such as "main story" could be overridden to Null if that game doesn't have a story. Use with caution, it is probably better if you decide what fits best for you. 145 | 146 | ### Reading an entry 147 | 148 | An entry is made of few values, you can check them [in the Entry class file](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/howlongtobeatpy/howlongtobeatpy/HowLongToBeatEntry.py). It also include the full JSON of values (already converted to Python dict) received from HLTB. 149 | 150 | ## Issues, Questions & Discussions 151 | 152 | If you found a bug report it as soon as you can creating an [issue](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/issues/new), the code may not be perfect. 153 | 154 | If you need any new feature, or want to discuss the current implementation/features, consider opening a [discussion](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/discussions) or even propose a change with a [Pull Request](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/pulls). 155 | 156 | ## Authors 157 | 158 | - **ScrappyCocco** - Thank you for using my API 159 | 160 | ## License 161 | 162 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 163 | -------------------------------------------------------------------------------- /howlongtobeatpy/howlongtobeatpy/HTMLRequests.py: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------------------------- 2 | # IMPORTS 3 | 4 | import re 5 | import json 6 | from enum import Enum 7 | from bs4 import BeautifulSoup 8 | import aiohttp 9 | import requests 10 | from fake_useragent import UserAgent 11 | 12 | # --------------------------------------------------------------------- 13 | 14 | 15 | class SearchModifiers(Enum): 16 | NONE = "" 17 | # ISOLATE_DLC shows only DLC in the search result 18 | ISOLATE_DLC = "only_dlc" 19 | # ISOLATE_MODS shows only MODs 20 | ISOLATE_MODS = "only_mods" 21 | # ISOLATE_HACKS shows only Hacks 22 | ISOLATE_HACKS = "only_hacks" 23 | # HIDE_DLC hide DLCs/MODs in the search result 24 | HIDE_DLC = "hide_dlc" 25 | 26 | 27 | class SearchInformations: 28 | search_url = None 29 | api_key = None 30 | 31 | def __init__(self, script_content: str): 32 | self.api_key = self.__extract_api_from_script(script_content) 33 | self.search_url = self.__extract_search_url_script(script_content) 34 | if HTMLRequests.BASE_URL.endswith("/") and self.search_url is not None: 35 | self.search_url = self.search_url.lstrip("/") 36 | 37 | def __extract_api_from_script(self, script_content: str): 38 | """ 39 | Function that extract the htlb code to use in the request from the given script 40 | @return: the string of the api key found 41 | """ 42 | # Try multiple find one after the other as hltb keep changing format 43 | # Test 1 - The API Key is in the user id in the request json 44 | user_id_api_key_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"' 45 | matches = re.findall(user_id_api_key_pattern, script_content) 46 | if matches: 47 | key = ''.join(matches) 48 | return key 49 | # Test 2 - The API Key is in format fetch("/api/[word here]/".concat("X").concat("Y")... 50 | concat_api_key_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))*' 51 | matches = re.findall(concat_api_key_pattern, script_content) 52 | if matches: 53 | matches = str(matches).split('.concat') 54 | matches = [re.sub(r'["\(\)\[\]\']', '', match) for match in matches[1:]] 55 | key = ''.join(matches) 56 | return key 57 | # Unable to find :( 58 | return None 59 | 60 | def __extract_search_url_script(self, script_content: str): 61 | """ 62 | Function that extract the htlb search url to append from the script as /api/search 63 | @return: the search url to append 64 | """ 65 | pattern = re.compile( 66 | r'fetch\(\s*["\'](\/api\/[^"\']*)["\']' # Matches the endpoint 67 | r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)' # Captures concatenated strings 68 | r'\s*,', # Matches up to the comma 69 | re.DOTALL 70 | ) 71 | matches = pattern.finditer(script_content) 72 | for match in matches: 73 | endpoint = match.group(1) 74 | concat_calls = match.group(2) 75 | # Extract all concatenated strings 76 | concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls) 77 | concatenated_str = ''.join(concat_strings) 78 | # Check if the concatenated string matches the known string 79 | if concatenated_str == self.api_key: 80 | return endpoint 81 | # Unable to find :( 82 | return None 83 | 84 | 85 | class HTMLRequests: 86 | BASE_URL = 'https://howlongtobeat.com/' 87 | REFERER_HEADER = BASE_URL 88 | GAME_URL = BASE_URL + "game" 89 | # Static search url to use in case it can't be extracted from JS code 90 | SEARCH_URL = BASE_URL + "api/s/" 91 | 92 | @staticmethod 93 | def get_search_request_headers(): 94 | """ 95 | Generate the headers for the search request 96 | @return: The headers object for the request 97 | """ 98 | ua = UserAgent() 99 | headers = { 100 | 'content-type': 'application/json', 101 | 'accept': '*/*', 102 | 'User-Agent': ua.random.strip(), 103 | 'referer': HTMLRequests.REFERER_HEADER 104 | } 105 | return headers 106 | 107 | @staticmethod 108 | def get_search_request_data(game_name: str, search_modifiers: SearchModifiers, page: int, search_info: SearchInformations): 109 | """ 110 | Generate the data payload for the search request 111 | @param game_name: The name of the game to search 112 | @param search_modifiers: The search modifiers to use in the search 113 | @param page: The page to search 114 | @return: The request (data) payload object for the request 115 | """ 116 | payload = { 117 | 'searchType': "games", 118 | 'searchTerms': game_name.split(), 119 | 'searchPage': page, 120 | 'size': 20, 121 | 'searchOptions': { 122 | 'games': { 123 | 'userId': 0, 124 | 'platform': "", 125 | 'sortCategory': "popular", 126 | 'rangeCategory': "main", 127 | 'rangeTime': { 128 | 'min': 0, 129 | 'max': 0 130 | }, 131 | 'gameplay': { 132 | 'perspective': "", 133 | 'flow': "", 134 | 'genre': "", 135 | "difficulty": "" 136 | }, 137 | 'rangeYear': 138 | { 139 | 'max': "", 140 | 'min': "" 141 | }, 142 | 'modifier': search_modifiers.value, 143 | }, 144 | 'users': { 145 | 'sortCategory': "postcount" 146 | }, 147 | 'lists': { 148 | 'sortCategory': "follows" 149 | }, 150 | 'filter': "", 151 | 'sort': 0, 152 | 'randomizer': 0 153 | }, 154 | 'useCache': True 155 | } 156 | 157 | # If api_key is passed add it to the dict 158 | if search_info is not None and search_info.api_key is not None: 159 | payload['searchOptions']['users']['id'] = search_info.api_key 160 | 161 | return json.dumps(payload) 162 | 163 | @staticmethod 164 | def send_web_request(game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, 165 | page: int = 1): 166 | """ 167 | Function that search the game using a normal request 168 | @param game_name: The original game name received as input 169 | @param search_modifiers: The "Modifiers" list in "Search Options", allow to show/isolate/hide DLCs 170 | @param page: The page to explore of the research, unknown if this is actually used 171 | @return: The HTML code of the research if the request returned 200(OK), None otherwise 172 | """ 173 | headers = HTMLRequests.get_search_request_headers() 174 | search_info_data = HTMLRequests.send_website_request_getcode(False) 175 | if search_info_data is None or search_info_data.api_key is None: 176 | search_info_data = HTMLRequests.send_website_request_getcode(True) 177 | # Make the request 178 | if search_info_data.search_url is not None: 179 | HTMLRequests.SEARCH_URL = HTMLRequests.BASE_URL + search_info_data.search_url 180 | # The main method currently is the call to the API search URL 181 | search_url_with_key = HTMLRequests.SEARCH_URL + search_info_data.api_key 182 | payload = HTMLRequests.get_search_request_data(game_name, search_modifiers, page, None) 183 | resp = requests.post(search_url_with_key, headers=headers, data=payload, timeout=60) 184 | if resp.status_code == 200: 185 | return resp.text 186 | # Try to call with the standard url adding the api key to the user 187 | search_url = HTMLRequests.SEARCH_URL 188 | payload = HTMLRequests.get_search_request_data(game_name, search_modifiers, page, search_info_data) 189 | resp = requests.post(search_url, headers=headers, data=payload, timeout=60) 190 | if resp.status_code == 200: 191 | return resp.text 192 | return None 193 | 194 | @staticmethod 195 | async def send_async_web_request(game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, 196 | page: int = 1): 197 | """ 198 | Function that search the game using an async request 199 | @param game_name: The original game name received as input 200 | @param search_modifiers: The "Modifiers" list in "Search Options", allow to show/isolate/hide DLCs 201 | @param page: The page to explore of the research, unknown if this is actually used 202 | @return: The HTML code of the research if the request returned 200(OK), None otherwise 203 | """ 204 | headers = HTMLRequests.get_search_request_headers() 205 | search_info_data = HTMLRequests.send_website_request_getcode(False) 206 | if search_info_data is None or search_info_data.api_key is None: 207 | search_info_data = HTMLRequests.send_website_request_getcode(True) 208 | # Make the request 209 | if search_info_data.search_url is not None: 210 | HTMLRequests.SEARCH_URL = HTMLRequests.BASE_URL + search_info_data.search_url 211 | # The main method currently is the call to the API search URL 212 | search_url_with_key = HTMLRequests.SEARCH_URL + search_info_data.api_key 213 | payload = HTMLRequests.get_search_request_data(game_name, search_modifiers, page, None) 214 | async with aiohttp.ClientSession() as session: 215 | async with session.post(search_url_with_key, headers=headers, data=payload) as resp_with_key: 216 | if resp_with_key is not None and resp_with_key.status == 200: 217 | return await resp_with_key.text() 218 | else: 219 | search_url = HTMLRequests.SEARCH_URL 220 | payload = HTMLRequests.get_search_request_data(game_name, search_modifiers, page, search_info_data) 221 | async with session.post(search_url, headers=headers, data=payload) as resp_user_id: 222 | if resp_user_id is not None and resp_user_id.status == 200: 223 | return await resp_user_id.text() 224 | return None 225 | 226 | @staticmethod 227 | def __cut_game_title(page_source: str): 228 | """ 229 | Function that extract the game title from the html title of the howlongtobeat page 230 | @param game_title: The HowLongToBeat page title of the game 231 | (For example "How long is A Way Out? | HowLongToBeat") 232 | @return: The cut game-title, without howlongtobeat names and grammatical symbols 233 | (So, in this example: "A Way Out") 234 | """ 235 | 236 | if page_source is None or len(page_source) == 0: 237 | return None 238 | 239 | soup = BeautifulSoup(page_source, 'html.parser') 240 | title_tag = soup.title 241 | title_text = title_tag.string 242 | 243 | # The position of start and end of this method may change if the website change 244 | cut_title = title_text[12:-17].strip() 245 | return cut_title 246 | 247 | @staticmethod 248 | def get_title_request_parameters(game_id: int): 249 | """ 250 | Generate the parameters for the search request 251 | @param game_id: The game id to search in HLTB 252 | @return: The parameters object for the request 253 | """ 254 | params = { 255 | 'id': str(game_id) 256 | } 257 | return params 258 | 259 | @staticmethod 260 | def get_title_request_headers(): 261 | """ 262 | Generate the headers for the search request 263 | @return: The headers object for the request 264 | """ 265 | ua = UserAgent() 266 | headers = { 267 | 'User-Agent': ua.random, 268 | 'referer': HTMLRequests.REFERER_HEADER 269 | } 270 | return headers 271 | 272 | @staticmethod 273 | def get_game_title(game_id: int): 274 | """ 275 | Function that gets the title of a game from the game (howlongtobeat) id 276 | @param game_id: id of the game to get the title 277 | @return: The game title from the given id 278 | """ 279 | 280 | params = HTMLRequests.get_title_request_parameters(game_id) 281 | headers = HTMLRequests.get_title_request_headers() 282 | 283 | # Request and extract title 284 | contents = requests.get(HTMLRequests.GAME_URL, params=params, headers=headers, timeout=60) 285 | return HTMLRequests.__cut_game_title(contents.text) 286 | 287 | @staticmethod 288 | async def async_get_game_title(game_id: int): 289 | """ 290 | Function that gets the title of a game from the game (howlongtobeat) id 291 | @param game_id: id of the game to get the title 292 | @return: The game title from the given id 293 | """ 294 | 295 | params = HTMLRequests.get_title_request_parameters(game_id) 296 | headers = HTMLRequests.get_title_request_headers() 297 | 298 | # Request and extract title 299 | async with aiohttp.ClientSession() as session: 300 | async with session.post(HTMLRequests.GAME_URL, params=params, headers=headers) as resp: 301 | if resp is not None and resp.status == 200: 302 | text = await resp.text() 303 | return HTMLRequests.__cut_game_title(text) 304 | return None 305 | 306 | @staticmethod 307 | def send_website_request_getcode(parse_all_scripts: bool): 308 | """ 309 | Function that send a request to howlongtobeat to scrape the API key 310 | @return: The string key to use 311 | """ 312 | # Make the post request and return the result if is valid 313 | headers = HTMLRequests.get_title_request_headers() 314 | resp = requests.get(HTMLRequests.BASE_URL, headers=headers, timeout=60) 315 | if resp.status_code == 200 and resp.text is not None: 316 | # Parse the HTML content using BeautifulSoup 317 | soup = BeautifulSoup(resp.text, 'html.parser') 318 | # Find all