[
  {
    "path": ".dockerignore",
    "content": ".git/\nvenv/\ntest/\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\ngithub: benbusby\nko_fi: benbusby\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a bug report to help fix an issue with Whoogle\ntitle: \"[BUG] <brief bug description>\"\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Deployment Method**\n- [ ] Heroku (one-click deploy)\n- [ ] Docker\n- [ ] `run` executable\n- [ ] pip/pipx\n- [ ] Other: [describe setup]\n\n**Version of Whoogle Search**\n- [ ] Latest build from [source] (i.e. GitHub, Docker Hub, pip, etc)\n- [ ] Version [version number]\n- [ ] Not sure\n\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest a feature that would improve Whoogle\ntitle: \"[FEATURE] <description of feature>\"\nlabels: enhancement\nassignees: ''\n\n---\n\n<!--\nDO NOT REQUEST UI/THEME/GUI/APPEARANCE IMPROVEMENTS HERE\nTHESE SHOULD GO IN ISSUE #60\nREQUESTING A NEW FEATURE SHOULD BE STRICTLY RELATED TO NEW FUNCTIONALITY\n-->\n\n**Describe the feature you'd like to see added**\nA short description of the feature, and what it would accomplish.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/new-theme.md",
    "content": "---\nname: New theme\nabout: Create a new theme for Whoogle\ntitle: \"[THEME] <your theme name>\"\nlabels: theme\nassignees: benbusby\n\n---\n\nUse the following template to design your theme, replacing the blank spaces with the colors of your choice.\n\n```css\n:root {\n    /* LIGHT THEME COLORS */\n    --whoogle-logo: #______;\n    --whoogle-page-bg: #______;\n    --whoogle-element-bg: #______;\n    --whoogle-text: #______;\n    --whoogle-contrast-text: #______;\n    --whoogle-secondary-text: #______;\n    --whoogle-result-bg: #______;\n    --whoogle-result-title: #______;\n    --whoogle-result-url: #______;\n    --whoogle-result-visited: #______;\n\n    /* DARK THEME COLORS */\n    --whoogle-dark-logo: #______;\n    --whoogle-dark-page-bg: #______;\n    --whoogle-dark-element-bg: #______;\n    --whoogle-dark-text: #______;\n    --whoogle-dark-contrast-text: #______;\n    --whoogle-dark-secondary-text: #______;\n    --whoogle-dark-result-bg: #______;\n    --whoogle-dark-result-title: #______;\n    --whoogle-dark-result-url: #______;\n    --whoogle-dark-result-visited: #______;\n}\n```\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/question.md",
    "content": "---\nname: Question\nabout: Ask a (simple) question about Whoogle\ntitle: \"[QUESTION] <question here>\"\nlabels: question\nassignees: ''\n\n---\n\nType out your question here. Please make sure that this is a topic that isn't already covered in the README.\n"
  },
  {
    "path": ".github/workflows/buildx.yml",
    "content": "name: buildx\n\non:\n  workflow_run:\n    workflows: [\"docker_main\"]\n    branches: [main, updates]\n    types:\n      - completed\n  push:\n    tags:\n      - '*'\n  release:\n    types:\n      - published\n\njobs:\n  on-success:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Wait for tests to succeed\n        if: ${{ github.event.workflow_run.conclusion != 'success' && startsWith(github.ref, 'refs/tags') != true }}\n        run: exit 1\n      - name: Debug workflow context\n        run: |\n          echo \"Event name: ${{ github.event_name }}\"\n          echo \"Ref: ${{ github.ref }}\"\n          echo \"Actor: ${{ github.actor }}\"\n          echo \"Branch: ${{ github.event.workflow_run.head_branch }}\"\n          echo \"Conclusion: ${{ github.event.workflow_run.conclusion }}\"\n      - name: checkout code\n        uses: actions/checkout@v4\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n      - name: Login to ghcr.io\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      # Disabled: only build on release events now\n      # - name: build and push the image\n      #   if: startsWith(github.ref, 'refs/heads/main') && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')\n      #   run: |\n      #     docker run --rm --privileged multiarch/qemu-user-static --reset -p yes\n      #     docker buildx ls\n      #     docker buildx build --push \\\n      #       --tag benbusby/whoogle-search:latest \\\n      #       --platform linux/amd64,linux/arm64 .\n      #     docker buildx build --push \\\n      #       --tag ghcr.io/benbusby/whoogle-search:latest \\\n      #       --platform linux/amd64,linux/arm64 .\n      - name: build and push updates branch (update-testing tag)\n        if: github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'updates' && github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.actor.login == 'benbusby' || github.event.workflow_run.actor.login == 'Don-Swanson')\n        run: |\n          docker buildx build --push \\\n            --tag benbusby/whoogle-search:update-testing \\\n            --tag ghcr.io/benbusby/whoogle-search:update-testing \\\n            --platform linux/amd64,linux/arm64 .\n      - name: build and push release (version + latest)\n        if: github.event_name == 'release' && github.event.release.prerelease == false && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')\n        run: |\n          TAG=\"${{ github.event.release.tag_name }}\"\n          VERSION=\"${TAG#v}\"\n          docker buildx build --push \\\n            --tag benbusby/whoogle-search:${VERSION} \\\n            --tag benbusby/whoogle-search:latest \\\n            --tag ghcr.io/benbusby/whoogle-search:${VERSION} \\\n            --tag ghcr.io/benbusby/whoogle-search:latest \\\n            --platform linux/amd64,linux/arm64 .\n      - name: build and push pre-release (version only)\n        if: github.event_name == 'release' && github.event.release.prerelease == true && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')\n        run: |\n          TAG=\"${{ github.event.release.tag_name }}\"\n          VERSION=\"${TAG#v}\"\n          docker buildx build --push \\\n            --tag benbusby/whoogle-search:${VERSION} \\\n            --tag ghcr.io/benbusby/whoogle-search:${VERSION} \\\n            --platform linux/amd64,linux/arm64 .\n      - name: build and push tag\n        if: startsWith(github.ref, 'refs/tags')\n        run: |\n          docker buildx build --push \\\n            --tag benbusby/whoogle-search:${GITHUB_REF#refs/*/v} \\\n            --tag ghcr.io/benbusby/whoogle-search:${GITHUB_REF#refs/*/v} \\\n            --platform linux/amd64,linux/arm64 .\n"
  },
  {
    "path": ".github/workflows/docker_main.yml",
    "content": "name: docker_main\n\non:\n  workflow_run:\n    workflows: [\"tests\"]\n    branches: [main, updates]\n    types:\n      - completed\n\n# TODO: Needs refactoring to use reusable workflows and share w/ docker_tests\njobs:\n  on-success:\n    runs-on: ubuntu-latest\n    if: ${{ github.event.workflow_run.conclusion == 'success' }}\n    steps:\n    - name: checkout code\n      uses: actions/checkout@v4\n    - name: build and test (docker)\n      run: |\n        docker build --tag whoogle-search:test .\n        docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test\n        sleep 15\n        docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1\n    - name: build and test (docker-compose)\n      run: |\n        docker rm -f whoogle-search-nocompose\n        WHOOGLE_IMAGE=\"whoogle-search:test\" docker compose up --detach\n        sleep 15\n        docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1\n"
  },
  {
    "path": ".github/workflows/docker_tests.yml",
    "content": "name: docker_tests\n\non:\n  push:\n    branches: main\n  pull_request:\n    branches: main\n\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    steps:\n    - name: checkout code\n      uses: actions/checkout@v2\n    - name: build and test (docker)\n      run: |\n        docker build --tag whoogle-search:test .\n        docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test\n        sleep 15\n        docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1\n    - name: build and test (docker compose)\n      run: |\n        docker rm -f whoogle-search-nocompose\n        WHOOGLE_IMAGE=\"whoogle-search:test\" docker compose up --detach\n        sleep 15\n        docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1\n"
  },
  {
    "path": ".github/workflows/pypi.yml",
    "content": "name: pypi\n\non:\n  push:\n    branches: main\n    tags: v*\n\njobs:\n  publish-test:\n    name: Build and publish to TestPyPI\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up Python 3.9\n        uses: actions/setup-python@v5\n        with:\n          python-version: 3.9\n      - name: Install pypa/build\n        run: >-\n          python -m\n          pip install\n          build\n          setuptools\n          --user\n      - name: Set dev timestamp\n        run: echo \"DEV_BUILD=$(date +%s)\" >> $GITHUB_ENV\n      - name: Build binary wheel and source tarball\n        run: >-\n          python -m\n          build\n          --sdist\n          --wheel\n          --outdir dist/\n          .\n      - name: Publish distribution to TestPyPI\n        uses: pypa/gh-action-pypi-publish@master\n        with:\n          password: ${{ secrets.TEST_PYPI_API_TOKEN }}\n          repository_url: https://test.pypi.org/legacy/\n  publish:\n    # Gate real PyPI publishing to stable SemVer tags only\n    if: startsWith(github.ref, 'refs/tags/')\n    name: Build and publish to PyPI\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Check if stable release\n        id: check_tag\n        run: |\n          TAG=\"${{ github.ref_name }}\"\n          if echo \"$TAG\" | grep -qE '^v?[0-9]+\\.[0-9]+\\.[0-9]+$'; then\n            echo \"is_stable=true\" >> $GITHUB_OUTPUT\n            echo \"Tag '$TAG' is a stable release. Will publish to PyPI.\"\n          else\n            echo \"is_stable=false\" >> $GITHUB_OUTPUT\n            echo \"Tag '$TAG' is not a stable release (contains pre-release suffix). Skipping PyPI publish.\"\n          fi\n      - name: Set up Python 3.9\n        if: steps.check_tag.outputs.is_stable == 'true'\n        uses: actions/setup-python@v5\n        with:\n          python-version: 3.9\n      - name: Install pypa/build\n        if: steps.check_tag.outputs.is_stable == 'true'\n        run: >-\n          python -m\n          pip install\n          build\n          --user\n      - name: Build binary wheel and source tarball\n        if: steps.check_tag.outputs.is_stable == 'true'\n        run: >-\n          python -m\n          build\n          --sdist\n          --wheel\n          --outdir dist/\n          .\n      - name: Publish distribution to PyPI\n        if: steps.check_tag.outputs.is_stable == 'true'\n        uses: pypa/gh-action-pypi-publish@master\n        with:\n          password: ${{ secrets.PYPI_API_TOKEN }}"
  },
  {
    "path": ".github/workflows/scan.yml",
    "content": "name: scan\n\non:\n  schedule:\n    - cron: '0 0 * * *'\n\njobs:\n  scan:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v2\n    - name: Build the container image\n      run: |\n        docker build --tag whoogle-search:test .\n    - name: Initiate grype scan\n      run: |\n        curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b .\n        chmod +x ./grype\n        ./grype whoogle-search:test --only-fixed\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.\n#\n# You can adjust the behavior by modifying this file.\n# For more information, see:\n# https://github.com/actions/stale\nname: Mark stale issues and pull requests\n\non:\n  schedule:\n  - cron: '35 10 * * *'\n\njobs:\n  stale:\n\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n      pull-requests: write\n\n    steps:\n    - uses: actions/stale@v10\n      with:\n        days-before-stale: 90\n        days-before-close: 7\n        stale-issue-message: 'This issue has been automatically marked as stale due to inactivity. If it is still valid please comment within 7 days or it will be auto-closed.'\n        close-issue-message: 'Closing this issue due to prolonged inactivity.'\n        # Disabled PR Closing for now, but pre-staged the settings\n        days-before-pr-stale: -1\n        days-before-pr-close: -1\n        operations-per-run: 100\n        stale-pr-message: \"This PR appears to be stale.  If it is still valid please comment within 14 days or it will be auto-closed.\"\n        close-pr-message: \"This PR was closed as stale.\"\n        exempt-issue-labels: 'keep-open,enhancement,critical,dependencies,documentation'\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: tests\n\non: [push, pull_request]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.x'\n      - name: Install dependencies\n        run: pip install --upgrade pip && pip install -r requirements.txt\n      - name: Run tests\n        run: ./run test\n"
  },
  {
    "path": ".gitignore",
    "content": "venv/\n.venv/\n.idea/\n__pycache__/\n*.pyc\n*.pem\n*.conf\n*.key\nconfig.json\ntest/static\nflask_session/\napp/static/config\napp/static/custom_config\napp/static/bangs/*\n!app/static/bangs/00-whoogle.json\n\n# pip stuff\n/build/\ndist/\n*.egg-info/\n\n# env\nwhoogle.env\n\n# vim\n*~\n*.swp\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.6.9\n    hooks:\n      - id: ruff\n        args: [--fix]\n      - id: ruff-format\n  - repo: https://github.com/psf/black\n    rev: 24.8.0\n    hooks:\n      - id: black\n        args: [--quiet]\n\n"
  },
  {
    "path": ".replit",
    "content": "entrypoint = \"misc/replit.py\"\n"
  },
  {
    "path": "Dockerfile",
    "content": "# NOTE: ARMv7 support has been dropped due to lack of pre-built cryptography wheels for Alpine/musl.\n# To restore ARMv7 support for local builds:\n# 1. Change requirements.txt:\n#    cryptography==3.3.2; platform_machine == 'armv7l'\n#    cryptography==46.0.1; platform_machine != 'armv7l'\n#    pyOpenSSL==19.1.0; platform_machine == 'armv7l'\n#    pyOpenSSL==25.3.0; platform_machine != 'armv7l'\n# 2. Add linux/arm/v7 to --platform flag when building:\n#    docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64 .\n\nFROM python:3.12-alpine3.22 AS builder\n\nRUN apk --no-cache add \\\n    build-base \\\n    libxml2-dev \\\n    libxslt-dev \\\n    openssl-dev \\\n    libffi-dev\n\nCOPY requirements.txt .\n\nRUN pip install --upgrade pip\nRUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt\n\nFROM python:3.12-alpine3.22\n\n# Remove bridge package to avoid CVEs (not needed for Docker containers)\nRUN apk add --no-cache --no-scripts tor curl openrc libstdc++ && \\\n    apk del --no-cache bridge || true\n# git go //for obfs4proxy\n# libcurl4-openssl-dev\nRUN pip install --upgrade pip\nRUN apk --no-cache upgrade && \\\n    apk del --no-cache --rdepends bridge || true\n\n# uncomment to build obfs4proxy\n# RUN git clone https://gitlab.com/yawning/obfs4.git\n# WORKDIR /obfs4\n# RUN go build -o obfs4proxy/obfs4proxy ./obfs4proxy\n# RUN cp ./obfs4proxy/obfs4proxy /usr/bin/obfs4proxy\n\nARG DOCKER_USER=whoogle\nARG DOCKER_USERID=927\nARG config_dir=/config\nRUN mkdir -p $config_dir\nRUN chmod a+w $config_dir\nVOLUME $config_dir\n\nARG url_prefix=''\nARG username=''\nARG password=''\nARG proxyuser=''\nARG proxypass=''\nARG proxytype=''\nARG proxyloc=''\nARG whoogle_dotenv=''\nARG use_https=''\nARG whoogle_port=5000\nARG twitter_alt='farside.link/nitter'\nARG youtube_alt='farside.link/invidious'\nARG reddit_alt='farside.link/libreddit'\nARG medium_alt='farside.link/scribe'\nARG translate_alt='farside.link/lingva'\nARG imgur_alt='farside.link/rimgo'\nARG wikipedia_alt='farside.link/wikiless'\nARG imdb_alt='farside.link/libremdb'\nARG quora_alt='farside.link/quetre'\nARG so_alt='farside.link/anonymousoverflow'\n\nENV CONFIG_VOLUME=$config_dir \\\n    WHOOGLE_URL_PREFIX=$url_prefix \\\n    WHOOGLE_USER=$username \\\n    WHOOGLE_PASS=$password \\\n    WHOOGLE_PROXY_USER=$proxyuser \\\n    WHOOGLE_PROXY_PASS=$proxypass \\\n    WHOOGLE_PROXY_TYPE=$proxytype \\\n    WHOOGLE_PROXY_LOC=$proxyloc \\\n    WHOOGLE_DOTENV=$whoogle_dotenv \\\n    HTTPS_ONLY=$use_https \\\n    EXPOSE_PORT=$whoogle_port \\\n    WHOOGLE_ALT_TW=$twitter_alt \\\n    WHOOGLE_ALT_YT=$youtube_alt \\\n    WHOOGLE_ALT_RD=$reddit_alt \\\n    WHOOGLE_ALT_MD=$medium_alt \\\n    WHOOGLE_ALT_TL=$translate_alt \\\n    WHOOGLE_ALT_IMG=$imgur_alt \\\n    WHOOGLE_ALT_WIKI=$wikipedia_alt \\\n    WHOOGLE_ALT_IMDB=$imdb_alt \\\n    WHOOGLE_ALT_QUORA=$quora_alt \\\n    WHOOGLE_ALT_SO=$so_alt\n\nWORKDIR /whoogle\n\nCOPY --from=builder /install /usr/local\nCOPY misc/tor/torrc /etc/tor/torrc\nCOPY misc/tor/start-tor.sh misc/tor/start-tor.sh\nCOPY app/ app/\nCOPY run whoogle.env* ./\n\n# Create user/group to run as\nRUN adduser -D -g $DOCKER_USERID -u $DOCKER_USERID $DOCKER_USER\n\n# Fix ownership / permissions\nRUN chown -R ${DOCKER_USER}:${DOCKER_USER} /whoogle /var/lib/tor\n\n# Allow writing symlinks to build dir\nRUN chown $DOCKER_USERID:$DOCKER_USERID app/static/build\n\nUSER $DOCKER_USER:$DOCKER_USER\n\nEXPOSE $EXPOSE_PORT\n\nHEALTHCHECK --interval=30s --timeout=5s \\\n  CMD curl -f http://localhost:${EXPOSE_PORT}/healthz || exit 1\n\nCMD [\"/bin/sh\", \"-c\", \"misc/tor/start-tor.sh & ./run\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020 Ben Busby\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "graft app/static\ngraft app/templates\ngraft app/misc\ninclude requirements.txt\nrecursive-include test\nglobal-exclude *.pyc\n"
  },
  {
    "path": "README.md",
    "content": ">[!WARNING]\n>\n>Since 16 January, 2025, Google has been attacking the ability to perform search queries without JavaScript enabled. This is a fundamental part of how Whoogle\n>works -- Whoogle requests the JavaScript-free search results, then filters out garbage from the results page and proxies all external content for the user.\n>\n>This is possibly a breaking change that may mean the end for Whoogle. We'll continue fighting back and releasing workarounds until all workarounds are \n>exhausted or a better method is found. If you know of a better way, please review and comment in our Way Forward Discussion\n\n___\n\n![Whoogle Search](docs/banner.png)\n\n[![Latest Release](https://img.shields.io/github/v/release/benbusby/whoogle-search)](https://github.com/benbusby/shoogle/releases)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![tests](https://github.com/benbusby/whoogle-search/actions/workflows/tests.yml/badge.svg)](https://github.com/benbusby/whoogle-search/actions/workflows/tests.yml)\n[![buildx](https://github.com/benbusby/whoogle-search/actions/workflows/buildx.yml/badge.svg)](https://github.com/benbusby/whoogle-search/actions/workflows/buildx.yml)\n[![Docker Pulls](https://img.shields.io/docker/pulls/benbusby/whoogle-search)](https://hub.docker.com/r/benbusby/whoogle-search)\n\n<table>\n  <tr>\n    <td><a href=\"https://sr.ht/~benbusby/whoogle-search\">SourceHut</a></td>\n    <td><a href=\"https://github.com/benbusby/whoogle-search\">GitHub</a></td>\n  </tr>\n</table>\n\nGet Google search results, but without any ads, JavaScript, AMP links, cookies, or IP address tracking. Easily deployable in one click as a Docker app, and customizable with a single config file. Quick and simple to implement as a primary search engine replacement on both desktop and mobile.\n\nContents\n1. [Features](#features)\n3. [Install/Deploy Options](#install)\n    1. [Heroku Quick Deploy](#heroku-quick-deploy)\n    1. [Render.com](#render)\n    1. [Repl.it](#replit)\n    1. [Fly.io](#flyio)\n    1. [Koyeb](#koyeb)\n    1. [pipx](#pipx)\n    1. [pip](#pip)\n    1. [Manual](#manual)\n    1. [Docker](#manual-docker)\n    1. [Arch/AUR](#arch-linux--arch-based-distributions)\n    1. [Helm/Kubernetes](#helm-chart-for-kubernetes)\n4. [Environment Variables and Configuration](#environment-variables)\n5. [Google Custom Search (BYOK)](#google-custom-search-byok)\n6. [Usage](#usage)\n7. [Extra Steps](#extra-steps)\n    1. [Set Primary Search Engine](#set-whoogle-as-your-primary-search-engine)\n\t2. [Custom Redirecting](#custom-redirecting)\n\t2. [Custom Bangs](#custom-bangs)\n    3. [Prevent Downtime (Heroku Only)](#prevent-downtime-heroku-only)\n    4. [Manual HTTPS Enforcement](#https-enforcement)\n    5. [Using with Firefox Containers](#using-with-firefox-containers)\n    6. [Reverse Proxying](#reverse-proxying)\n        1. [Nginx](#nginx)\n8. [Contributing](#contributing)\n9. [FAQ](#faq)\n10. [Public Instances](#public-instances)\n11. [Screenshots](#screenshots)\n\n## Features\n- No ads or sponsored content\n- No JavaScript\\*\n- No cookies\\*\\*\n- No tracking/linking of your personal IP address\\*\\*\\*\n- No AMP links\n- No URL tracking tags (i.e. utm=%s)\n- No referrer header\n- Tor and HTTP/SOCKS proxy support\n- Autocomplete/search suggestions\n- POST request search and suggestion queries (when possible)\n- View images at full res without site redirect (currently mobile only)\n- Light/Dark/System theme modes (with support for [custom CSS theming](https://github.com/benbusby/whoogle-search/wiki/User-Contributed-CSS-Themes))\n- Auto-generated Opera User Agents with random rotation\n  - 10 unique Opera-based UAs generated on startup from 115 language variants\n  - Randomly rotated for each search request to avoid detection patterns\n  - Cached across restarts with configurable refresh options\n  - Fallback to safe default UA if generation fails\n  - Optional display of current UA in search results footer\n- Easy to install/deploy\n- DDG-style bang (i.e. `!<tag> <query>`) searches\n- User-defined [custom bangs](#custom-bangs)\n- Optional location-based searching (i.e. results near \\<city\\>)\n- Optional NoJS mode to view search results in a separate window with JavaScript blocked\n- JSON output for results via content negotiation (see \"JSON results (API)\")\n\n<sup>*No third party JavaScript. Whoogle can be used with JavaScript disabled, but if enabled, uses JavaScript for things like presenting search suggestions.</sup>\n\n<sup>**No third party cookies. Whoogle uses server side cookies (sessions) to store non-sensitive configuration settings such as theme, language, etc. Just like with JavaScript, cookies can be disabled and not affect Whoogle's search functionality.</sup>\n\n<sup>***If deployed to a remote server, or configured to send requests through a VPN, Tor, proxy, etc.</sup>\n\n## Install\n\n### Supported Platforms\nOfficial Docker images are built for:\n- **linux/amd64** (x86_64)\n- **linux/arm64** (ARM 64-bit, Raspberry Pi 3/4/5, Apple Silicon)\n\n**Note**: ARMv7 support (32-bit ARM, Raspberry Pi 2) was dropped in v1.2.0 due to incompatibility with modern security libraries on Alpine Linux. Users with ARMv7 devices can either:\n- Use an older version (v1.1.x or earlier)\n- Build locally with pinned dependencies (see notes in Dockerfile)\n- Upgrade to a 64-bit OS if hardware supports it (Raspberry Pi 3+)\n\nThere are a few different ways to begin using the app, depending on your preferences:\n\n___\n\n### [Heroku Quick Deploy](https://heroku.com/about)\n[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/benbusby/whoogle-search/tree/main)\n\nProvides:\n- Easy Deployment of App\n- A HTTPS url (https://\\<your app name\\>.herokuapp.com)\n\nNotes:\n- Requires a **PAID** Heroku Account.\n- Sometimes has issues with auto-redirecting to `https`. Make sure to navigate to the `https` version of your app before adding as a default search engine.\n\n___\n\n### [Render](https://render.com)\n\nCreate an account on [render.com](https://render.com) and import the Whoogle repo with the following settings:\n\n- Runtime: `Python 3`\n- Build Command: `pip install -r requirements.txt`\n- Run Command: `./run`\n\n___\n\n### [Repl.it](https://repl.it)\n[![Run on Repl.it](https://repl.it/badge/github/benbusby/whoogle-search)](https://repl.it/github/benbusby/whoogle-search)\n\n*Note: Requires a (free) Replit account*\n\nProvides:\n- Free deployment of app\n- Free HTTPS url (https://\\<app name\\>.\\<username\\>\\.repl\\.co)\n    - Supports custom domains\n- Downtime after periods of inactivity ([solution](https://repl.it/talk/learn/How-to-use-and-setup-UptimeRobot/9003)\\)\n\n___\n\n### [Fly.io](https://fly.io)\n\nYou will need a [Fly.io](https://fly.io) account to deploy Whoogle.\n\n#### Install the CLI: https://fly.io/docs/hands-on/installing/\n\n#### Deploy the app\n\n```bash\nflyctl auth login\nflyctl launch --image benbusby/whoogle-search:latest\n```\n\nThe first deploy won't succeed because the default `internal_port` is wrong.\nTo fix this, open the generated `fly.toml` file, set `services.internal_port` to `5000` and run `flyctl launch` again.\n\nYour app is now available at `https://<app-name>.fly.dev`.\n\nNotes:\n- Requires a [**PAID**](https://fly.io/docs/about/pricing/#free-allowances) Fly.io Account.\n\n___\n\n### [Koyeb](https://www.koyeb.com)\n\nUse one of the following guides to install Whoogle on Koyeb:\n\n1. Using GitHub: https://www.koyeb.com/docs/quickstart/deploy-with-git\n2. Using Docker: https://www.koyeb.com/docs/quickstart/deploy-a-docker-application\n\n___\n\n### [RepoCloud](https://repocloud.io)\n[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=309)\n\n1. Sign up for a free [RepoCloud account](https://repocloud.io) and receive free credits to get started.\n2. Click \"Deploy\" to launch the app and access it instantly via your RepoCloud URL.\n___\n\n### [pipx](https://github.com/pipxproject/pipx#install-pipx)\nPersistent install:\n\n`pipx install https://github.com/benbusby/whoogle-search/archive/refs/heads/main.zip`\n\nSandboxed temporary instance:\n\n`pipx run --spec git+https://github.com/benbusby/whoogle-search.git whoogle-search`\n\n___\n\n### pip\n`pip install whoogle-search`\n\n```bash\n$ whoogle-search --help\nusage: whoogle-search [-h] [--port <port number>] [--host <ip address>] [--debug] [--https-only] [--userpass <username:password>]\n                      [--proxyauth <username:password>] [--proxytype <socks4|socks5|http>] [--proxyloc <location:port>]\n\nWhoogle Search console runner\n\noptional arguments:\n  -h, --help            Show this help message and exit\n  --port <port number>  Specifies a port to run on (default 5000)\n  --host <ip address>   Specifies the host address to use (default 127.0.0.1)\n  --debug               Activates debug mode for the server (default False)\n  --https-only          Enforces HTTPS redirects for all requests\n  --userpass <username:password>\n                        Sets a username/password basic auth combo (default None)\n  --proxyauth <username:password>\n                        Sets a username/password for a HTTP/SOCKS proxy (default None)\n  --proxytype <socks4|socks5|http>\n                        Sets a proxy type for all connections (default None)\n  --proxyloc <location:port>\n                        Sets a proxy location for all connections (default None)\n```\nSee the [available environment variables](#environment-variables) for additional configuration.\n\n___\n\n### Manual\n\n*Note: `Content-Security-Policy` headers can be sent by Whoogle if you set `WHOOGLE_CSP`.*\n\n#### Dependencies\n- [Python3](https://www.python.org/downloads/)\n- `libcurl4-openssl-dev` and `libssl-dev`\n  - macOS: `brew install openssl curl-openssl`\n  - Ubuntu: `sudo apt-get install -y libcurl4-openssl-dev libssl-dev`\n  - Arch: `pacman -S curl openssl`\n\n#### Install\n\nClone the repo and run the following commands to start the app in a local-only environment:\n\n```bash\ngit clone https://github.com/benbusby/whoogle-search.git\ncd whoogle-search\npython3 -m venv venv\nsource venv/bin/activate\npip install -r requirements.txt\n./run\n```\nSee the [available environment variables](#environment-variables) for additional configuration.\n\n#### systemd Configuration\nAfter building the virtual environment, you can add something like the following to `/lib/systemd/system/whoogle.service` to set up a Whoogle Search systemd service:\n\n```ini\n[Unit]\nDescription=Whoogle\n\n[Service]\n# Basic auth configuration, uncomment to enable\n#Environment=WHOOGLE_USER=<username>\n#Environment=WHOOGLE_PASS=<password>\n# Proxy configuration, uncomment to enable\n#Environment=WHOOGLE_PROXY_USER=<proxy username>\n#Environment=WHOOGLE_PROXY_PASS=<proxy password>\n#Environment=WHOOGLE_PROXY_TYPE=<proxy type (http|https|proxy4|proxy5)\n#Environment=WHOOGLE_PROXY_LOC=<proxy host/ip>\n# Site alternative configurations, uncomment to enable\n# Note: If not set, the feature will still be available\n# with default values.\n#Environment=WHOOGLE_ALT_TW=farside.link/nitter\n#Environment=WHOOGLE_ALT_YT=farside.link/invidious\n#Environment=WHOOGLE_ALT_RD=farside.link/libreddit\n#Environment=WHOOGLE_ALT_MD=farside.link/scribe\n#Environment=WHOOGLE_ALT_TL=farside.link/lingva\n#Environment=WHOOGLE_ALT_IMG=farside.link/rimgo\n#Environment=WHOOGLE_ALT_WIKI=farside.link/wikiless\n#Environment=WHOOGLE_ALT_IMDB=farside.link/libremdb\n#Environment=WHOOGLE_ALT_QUORA=farside.link/quetre\n#Environment=WHOOGLE_ALT_SO=farside.link/anonymousoverflow\n# Load values from dotenv only\n#Environment=WHOOGLE_DOTENV=1\n# specify dotenv location if not in default location\n#Environment=WHOOGLE_DOTENV_PATH=<path/to>/whoogle.env\nType=simple\nUser=<username>\n# If installed as a package, add:\nExecStart=<python_install_dir>/python3 <whoogle_install_dir>/whoogle-search --host 127.0.0.1 --port 5000\n# For example:\n# ExecStart=/usr/bin/python3 /home/my_username/.local/bin/whoogle-search --host 127.0.0.1 --port 5000\n# Otherwise if running the app from source, add:\nExecStart=<whoogle_repo_dir>/run\n# For example:\n# ExecStart=/var/www/whoogle-search/run\nWorkingDirectory=<whoogle_repo_dir>\nExecReload=/bin/kill -HUP $MAINPID\nRestart=always\nRestartSec=3\nSyslogIdentifier=whoogle\n\n[Install]\nWantedBy=multi-user.target\n```\nThen,\n```\nsudo systemctl daemon-reload\nsudo systemctl enable whoogle\nsudo systemctl start whoogle\n```\n\n#### Tor Configuration *optional*\nIf routing your request through Tor you will need to make the following adjustments.\nDue to the nature of interacting with Google through Tor we will need to be able to send signals to Tor and therefore authenticate with it.\n\nThere are two authentication methods, password and cookie. You will need to make changes to your torrc:\n  * Cookie\n    1. Uncomment or add the following lines in your torrc:\n       - `ControlPort 9051`\n       - `CookieAuthentication 1`\n       - `DataDirectoryGroupReadable 1`\n       - `CookieAuthFileGroupReadable 1`\n\n    2. Make the tor auth cookie readable:\n       - This is assuming that you are using a dedicated user to run whoogle. If you are using a different user replace `whoogle` with that user.\n\n       1. `chmod tor:whoogle /var/lib/tor`\n       2. `chmod tor:whoogle /var/lib/tor/control_auth_cookie`\n\n    3. Restart the tor service:\n       - `systemctl restart tor`\n\n    4. Set the Tor environment variable to 1, `WHOOGLE_CONFIG_TOR`. Refer to the [Environment Variables](#environment-variables) section for more details.\n       - This may be added in the systemd unit file or env file `WHOOGLE_CONFIG_TOR=1`\n\n  * Password\n    1. Run this command:\n       - `tor --hash-password {Your Password Here}`; put your password in place of `{Your Password Here}`.\n       - Keep the output of this command, you will be placing it in your torrc.\n       - Keep the password input of this command, you will be using it later.\n\n    2. Uncomment or add the following lines in your torrc:\n       - `ControlPort 9051`\n       - `HashedControlPassword {Place output here}`; put the output of the previous command in place of `{Place output here}`.\n\n    3. Now take the password from the first step and place it in the control.conf file within the whoogle working directory, ie. [misc/tor/control.conf](misc/tor/control.conf)\n       - If you want to place your password file in a different location set this location with the `WHOOGLE_TOR_CONF` environment variable. Refer to the [Environment Variables](#environment-variables) section for more details.\n\n    4. Heavily restrict access to control.conf to only be readable by the user running whoogle:\n       - `chmod 400 control.conf`\n\n    5. Finally set the Tor environment variable and use password variable to 1, `WHOOGLE_CONFIG_TOR` and `WHOOGLE_TOR_USE_PASS`. Refer to the [Environment Variables](#environment-variables) section for more details.\n       - These may be added to the systemd unit file or env file:\n          - `WHOOGLE_CONFIG_TOR=1`\n          - `WHOOGLE_TOR_USE_PASS=1`\n\n___\n\n### Manual (Docker)\n1. Ensure the Docker daemon is running, and is accessible by your user account\n  - To add user permissions, you can execute `sudo usermod -aG docker yourusername`\n  - Running `docker ps` should return something besides an error. If you encounter an error saying the daemon isn't running, try `sudo systemctl start docker` (Linux) or ensure the docker tool is running (Windows/macOS).\n2. Clone and deploy the docker app using a method below:\n\n#### Docker CLI\n\nThrough Docker Hub:\n```bash\ndocker pull benbusby/whoogle-search\ndocker run --publish 5000:5000 --detach --name whoogle-search benbusby/whoogle-search:latest\n```\n\nor with docker-compose:\n\n```bash\ngit clone https://github.com/benbusby/whoogle-search.git\ncd whoogle-search\ndocker-compose up\n```\n\nor by building yourself:\n\n```bash\ngit clone https://github.com/benbusby/whoogle-search.git\ncd whoogle-search\ndocker build --tag whoogle-search:1.0 .\ndocker run --publish 5000:5000 --detach --name whoogle-search whoogle-search:1.0\n```\n\nOptionally, you can also enable some of the following environment variables to further customize your instance:\n\n```bash\ndocker run --publish 5000:5000 --detach --name whoogle-search \\\n  -e WHOOGLE_USER=username \\\n  -e WHOOGLE_PASS=password \\\n  -e WHOOGLE_PROXY_USER=username \\\n  -e WHOOGLE_PROXY_PASS=password \\\n  -e WHOOGLE_PROXY_TYPE=socks5 \\\n  -e WHOOGLE_PROXY_LOC=ip \\\n  whoogle-search:1.0\n```\n\nAnd kill with: `docker rm --force whoogle-search`\n\n#### Using [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli)\n```bash\nheroku login\nheroku container:login\ngit clone https://github.com/benbusby/whoogle-search.git\ncd whoogle-search\nheroku create\nheroku container:push web\nheroku container:release web\nheroku open\n```\n\nThis series of commands can take a while, but once you run it once, you shouldn't have to run it again. The final command, `heroku open` will launch a tab in your web browser, where you can test out Whoogle and even [set it as your primary search engine](https://github.com/benbusby/whoogle-search#set-whoogle-as-your-primary-search-engine).\nYou may also edit environment variables from your app’s Settings tab in the Heroku Dashboard.\n\n___\n\n### Arch Linux & Arch-based Distributions\nThere is an [AUR package available](https://aur.archlinux.org/packages/whoogle-git/), as well as a pre-built and daily updated package available at [Chaotic-AUR](https://chaotic.cx).\n\n___\n\n### Helm chart for Kubernetes\nTo use the Kubernetes Helm Chart:\n1. Ensure you have [Helm](https://helm.sh/docs/intro/install/) `>=3.0.0` installed\n2. Clone this repository\n3. Update [charts/whoogle/values.yaml](./charts/whoogle/values.yaml) as desired\n4. Run `helm upgrade --install whoogle ./charts/whoogle`\n\n___\n\n#### Using your own server, or alternative container deployment\nThere are other methods for deploying docker containers that are well outlined in [this article](https://rollout.io/blog/the-shortlist-of-docker-hosting/), but there are too many to describe set up for each here. Generally it should be about the same amount of effort as the Heroku deployment.\n\nDepending on your preferences, you can also deploy the app yourself on your own infrastructure. This route would require a few extra steps:\n  - A server (I personally recommend [Digital Ocean](https://www.digitalocean.com/pricing/) or [Linode](https://www.linode.com/pricing/), their cheapest tiers will work fine)\n  - Your own URL (I suppose this is optional, but recommended)\n  - SSL certificates (free through [Let's Encrypt](https://letsencrypt.org/getting-started/))\n  - A bit more experience or willingness to work through issues\n\n## Environment Variables\nThere are a few optional environment variables available for customizing a Whoogle instance. These can be set manually, or copied into `whoogle.env` and enabled for your preferred deployment method:\n\n- Local runs: Set `WHOOGLE_DOTENV=1` before running\n- With `docker-compose`: Uncomment the `env_file` option\n- With `docker build/run`: Add `--env-file ./whoogle.env` to your command\n\n| Variable             | Description                                                                               |\n| -------------------- | ----------------------------------------------------------------------------------------- |\n| WHOOGLE_URL_PREFIX   | The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")                          |\n| WHOOGLE_DOTENV       | Load environment variables in `whoogle.env`                                               |\n| WHOOGLE_DOTENV_PATH  | The path to `whoogle.env` if not in default location                                      |\n| WHOOGLE_USER         | The username for basic auth. WHOOGLE_PASS must also be set if used.                       |\n| WHOOGLE_PASS         | The password for basic auth. WHOOGLE_USER must also be set if used.                       |\n| WHOOGLE_PROXY_USER   | The username of the proxy server.                                                         |\n| WHOOGLE_PROXY_PASS   | The password of the proxy server.                                                         |\n| WHOOGLE_PROXY_TYPE   | The type of the proxy server. Can be \"socks5\", \"socks4\", or \"http\".                       |\n| WHOOGLE_PROXY_LOC    | The location of the proxy server (host or ip).                                            |\n| WHOOGLE_USER_AGENT   | The desktop user agent to use when using 'env_conf' option. Leave empty to use auto-generated Opera UAs. |\n| WHOOGLE_USER_AGENT_MOBILE | The mobile user agent to use when using 'env_conf' option. Leave empty to use auto-generated Opera UAs. |\n| WHOOGLE_USE_CLIENT_USER_AGENT | Enable to use your own user agent for all requests. Defaults to false.           |\n| WHOOGLE_UA_CACHE_PERSISTENT | Whether to persist auto-generated UAs across restarts. Set to '0' to regenerate on each startup. Default '1'. |\n| WHOOGLE_UA_CACHE_REFRESH_DAYS | Auto-refresh UA cache after N days. Set to '0' to never refresh (cache persists indefinitely). Default '0'. |\n| WHOOGLE_UA_LIST_FILE | Path to text file containing custom UA strings (one per line). When set, uses these instead of auto-generated UAs. |\n| WHOOGLE_REDIRECTS    | Specify sites that should be redirected elsewhere. See [custom redirecting](#custom-redirecting). |\n| EXPOSE_PORT          | The port where Whoogle will be exposed.                                                   |\n| HTTPS_ONLY           | Enforce HTTPS. (See [here](https://github.com/benbusby/whoogle-search#https-enforcement)) |\n| WHOOGLE_ALT_TW       | The twitter.com alternative to use when site alternatives are enabled in the config. Set to \"\" to disable. |\n| WHOOGLE_ALT_YT       | The youtube.com alternative to use when site alternatives are enabled in the config. Set to \"\" to disable. |\n| WHOOGLE_ALT_RD       | The reddit.com alternative to use when site alternatives are enabled in the config. Set to \"\" to disable. |\n| WHOOGLE_ALT_TL       | The Google Translate alternative to use. This is used for all \"translate ____\" searches.  Set to \"\" to disable. |\n| WHOOGLE_ALT_MD       | The medium.com alternative to use when site alternatives are enabled in the config. Set to \"\" to disable. |\n| WHOOGLE_ALT_IMG      | The imgur.com alternative to use when site alternatives are enabled in the config. Set to \"\" to disable. |\n| WHOOGLE_ALT_WIKI     | The wikipedia.org alternative to use when site alternatives are enabled in the config. Set to \"\" to disable. |\n| WHOOGLE_ALT_IMDB     | The imdb.com alternative to use when site alternatives are enabled in the config. Set to \"\" to disable.  |\n| WHOOGLE_ALT_QUORA    | The quora.com alternative to use when site alternatives are enabled in the config. Set to \"\" to disable. |\n| WHOOGLE_ALT_SO       | The stackoverflow.com alternative to use when site alternatives are enabled in the config. Set to \"\" to disable. |\n| WHOOGLE_AUTOCOMPLETE | Controls visibility of autocomplete/search suggestions. Default on -- use '0' to disable. |\n| WHOOGLE_MINIMAL      | Remove everything except basic result cards from all search queries.                      |\n| WHOOGLE_CSP          | Sets a default set of 'Content-Security-Policy' headers                                   |\n| WHOOGLE_TOR_SERVICE  | Enable/disable the Tor service on startup. Default on -- use '0' to disable.              |\n| WHOOGLE_TOR_USE_PASS | Use password authentication for tor control port. |\n| WHOOGLE_TOR_CONF | The absolute path to the config file containing the password for the tor control port. Default: ./misc/tor/control.conf WHOOGLE_TOR_PASS must be 1 for this to work.|\n| WHOOGLE_SHOW_FAVICONS | Show/hide favicons next to search result URLs. Default on.                               |\n| WHOOGLE_UPDATE_CHECK  | Enable/disable the automatic daily check for new versions of Whoogle. Default on.        |\n| WHOOGLE_FALLBACK_ENGINE_URL | Set a fallback Search Engine URL when there is internal server error or instance is rate-limited. Search query is appended to the end of the URL (eg. https://duckduckgo.com/?k1=-1&q=). |\n| WHOOGLE_BUNDLE_STATIC | When set to 1, serve a single bundled CSS and JS file generated at startup to reduce requests. Default off. |\n| WHOOGLE_HTTP2         | Enable HTTP/2 for upstream requests (via httpx). Default on — set to 0 to force HTTP/1.1. |\n\n### Config Environment Variables\nThese environment variables allow setting default config values, but can be overwritten manually by using the home page config menu. These allow a shortcut for destroying/rebuilding an instance to the same config state every time.\n\n| Variable                             | Description                                                     |\n| ------------------------------------ | --------------------------------------------------------------- |\n| WHOOGLE_CONFIG_DISABLE               | Hide config from UI and disallow changes to config by client    |\n| WHOOGLE_CONFIG_COUNTRY               | Filter results by hosting country                               |\n| WHOOGLE_CONFIG_LANGUAGE              | Set interface language                                          |\n| WHOOGLE_CONFIG_SEARCH_LANGUAGE       | Set search result language                                      |\n| WHOOGLE_CONFIG_BLOCK                 | Block websites from search results (use comma-separated list)   |\n| WHOOGLE_CONFIG_BLOCK_TITLE           | Block search result with a REGEX filter on title                |\n| WHOOGLE_CONFIG_BLOCK_URL             | Block search result with a REGEX filter on URL                  |\n| WHOOGLE_CONFIG_THEME                 | Set theme mode (light, dark, or system)                         |\n| WHOOGLE_CONFIG_SAFE                  | Enable safe searches                                            |\n| WHOOGLE_CONFIG_ALTS                  | Use social media site alternatives (nitter, invidious, etc)     |\n| WHOOGLE_CONFIG_NEAR                  | Restrict results to only those near a particular city           |\n| WHOOGLE_CONFIG_TOR                   | Use Tor routing (if available)                                  |\n| WHOOGLE_CONFIG_NEW_TAB               | Always open results in new tab                                  |\n| WHOOGLE_CONFIG_VIEW_IMAGE            | Enable View Image option                                        |\n| WHOOGLE_CONFIG_GET_ONLY              | Search using GET requests only                                  |\n| WHOOGLE_CONFIG_URL                   | The root url of the instance (`https://<your url>/`)            |\n| WHOOGLE_CONFIG_STYLE                 | The custom CSS to use for styling (should be single line)       |\n| WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED | Encrypt preferences token, requires preferences key             |\n| WHOOGLE_CONFIG_PREFERENCES_KEY       | Key to encrypt preferences in URL (REQUIRED to show url)        |\n| WHOOGLE_CONFIG_ANON_VIEW             | Include the \"anonymous view\" option for each search result      |\n| WHOOGLE_CONFIG_SHOW_USER_AGENT       | Display the User Agent string used for search in results footer |\n\n### Google Custom Search (BYOK) Environment Variables\n\nThese environment variables configure the \"Bring Your Own Key\" feature for Google Custom Search API:\n\n| Variable             | Description                                                                               |\n| -------------------- | ----------------------------------------------------------------------------------------- |\n| WHOOGLE_CSE_API_KEY  | Your Google API key with Custom Search API enabled                                        |\n| WHOOGLE_CSE_ID       | Your Custom Search Engine ID (cx parameter)                                               |\n| WHOOGLE_USE_CSE      | Enable Custom Search API by default (set to '1' to enable)                                |\n\n## Google Custom Search (BYOK)\n\nIf Google blocks traditional search scraping (captchas, IP bans), you can use your own Google Custom Search Engine credentials as a fallback. This uses Google's official API with your own quota.\n\n### Why Use This?\n\n- **Reliability**: Official API never gets blocked or rate-limited (within quota)\n- **Speed**: Direct JSON responses are faster than HTML scraping\n- **Fallback**: Works when all scraping workarounds fail\n- **Privacy**: Your searches still don't go through third parties—they go directly to Google with your own API key\n\n### Limitations vs Standard Whoogle\n\n| Feature          | Standard Scraping        | CSE API             |\n|------------------|--------------------------|---------------------|\n| Daily limit      | None (until blocked)     | 100 free, then paid |\n| Image search     | ✅ Full support          | ✅ Supported         |\n| News/Videos tabs | ✅                       | ❌ Web results only  |\n| Speed            | Slower (HTML parsing)    | Faster (JSON)       |\n| Reliability      | Can be blocked           | Always works        |\n\n### Setup Steps\n\n#### 1. Create a Custom Search Engine\n1. Go to [Programmable Search Engine](https://programmablesearchengine.google.com/controlpanel/all)\n2. Click **\"Add\"** to create a new search engine\n3. Under \"What to search?\", select **\"Search the entire web\"**\n4. Give it a name (e.g., \"My Whoogle CSE\")\n5. Click **\"Create\"**\n6. Copy your **Search Engine ID** \n\n#### 2. Get an API Key\n1. Go to [Google Cloud Console](https://console.cloud.google.com/)\n2. Create a new project or select an existing one\n3. Go to **APIs & Services** → **Library**\n4. Search for **\"Custom Search API\"** and click **Enable**\n5. Go to **APIs & Services** → **Credentials**\n6. Click **\"Create Credentials\"** → **\"API Key\"**\n7. Copy your API key (looks like `AIza...`)\n\n#### 3. (Recommended) Restrict Your API Key\nTo prevent misuse if your key is exposed:\n1. Click on your API key in Credentials\n2. Under **\"API restrictions\"**, select **\"Restrict key\"**\n3. Choose only **\"Custom Search API\"**\n4. Under **\"Application restrictions\"**, consider adding IP restrictions if using on a server\n5. Click **Save**\n\n#### 4. Configure Whoogle\n\n**Option A: Via Settings UI**\n1. Open your Whoogle instance\n2. Click the **Config** button\n3. Scroll to \"Google Custom Search (BYOK)\" section\n4. Enter your API Key and CSE ID\n5. Check \"Use Custom Search API\"\n6. Click **Apply**\n\n**Option B: Via Environment Variables**\n```bash\nWHOOGLE_CSE_API_KEY=AIza...\nWHOOGLE_CSE_ID=23f...\nWHOOGLE_USE_CSE=1\n```\n\n### Pricing & Avoiding Charges\n\n| Tier | Queries          | Cost                  |\n|------|------------------|-----------------------|\n| Free | 100/day          | $0                    |\n| Paid | Up to 10,000/day | $5 per 1,000 queries  |\n\n**⚠️ To avoid unexpected charges:**\n\n1. **Don't add a payment method** to Google Cloud (safest option—API stops at 100/day)\n2. **Set a billing budget alert**: [Billing → Budgets & Alerts](https://console.cloud.google.com/billing/budgets)\n3. **Cap API usage**: APIs & Services → Custom Search API → Quotas → Set \"Queries per day\" to 100\n4. **Monitor usage**: APIs & Services → Custom Search API → Metrics\n\n### Troubleshooting\n\n| Error               | Cause                     | Solution                                                        |\n|---------------------|---------------------------|-----------------------------------------------------------------|\n| \"API key not valid\" | Invalid or restricted key | Check key in Cloud Console, ensure Custom Search API is enabled |\n| \"Quota exceeded\"    | Hit 100/day limit         | Wait until midnight PT, or enable billing                       |\n| \"Invalid CSE ID\"    | Wrong cx parameter        | Copy ID from Programmable Search Engine control panel           |\n\n## Usage\nSame as most search engines, with the exception of filtering by time range.\n\nTo filter by a range of time, append \":past <time>\" to the end of your search, where <time> can be `hour`, `day`, `month`, or `year`. Example: `coronavirus updates :past hour`\n\n### JSON results (API)\nWhoogle can return filtered results as JSON using the same sanitization rules as the HTML view.\n\n- Send `Accept: application/json` or append `format=json` to the search URL.\n- Example: `/search?q=whoogle` with `Accept: application/json`, or `/search?q=whoogle&format=json`.\n- Response shape:\n\n```\n{\n  \"query\": \"whoogle\",\n  \"search_type\": \"\",\n  \"results\": [\n    {\"href\": \"https://example.com/page\", \"text\": \"Example Page\"},\n    ...\n  ]\n}\n```\n\nSpecial cases:\n- Feeling Lucky returns HTTP 303 with body `{ \"redirect\": \"<url>\" }`.\n- Temporary blocks (captcha) return HTTP 503 with `{ \"blocked\": true, \"error_message\": \"...\", \"query\": \"...\" }`.\n\n## Extra Steps\n\n### Set Whoogle as your primary search engine\n*Note: If you're using a reverse proxy to run Whoogle Search, make sure the \"Root URL\" config option on the home page is set to your URL before going through these steps.*\n\nBrowser settings:\n  - Firefox (Desktop)\n    - Version 89+\n      - Navigate to your app's url, right click the address bar, and select \"Add Search Engine\".\n    - Previous versions\n      - Navigate to your app's url, and click the 3 dot menu in the address bar. At the bottom, there should be an option to \"Add Search Engine\".\n    - Once you've added the new search engine, open your Firefox Preferences menu, click \"Search\" in the left menu, and use the available dropdown to select \"Whoogle\" from the list.\n    - **Note**: If your Whoogle instance uses Firefox Containers, you'll need to [go through the steps here](#using-with-firefox-containers) to get it working properly.\n  - Firefox (iOS)\n    - In the mobile app Settings page, tap \"Search\" within the \"General\" section. There should be an option titled \"Add Search Engine\" to select. It should prompt you to enter a title and search query url - use the following elements to fill out the form:\n      - Title: \"Whoogle\"\n      - URL: `http[s]://\\<your whoogle url\\>/search?q=%s`\n  - Firefox (Android)\n    - Version <79.0.0\n      - Navigate to your app's url\n      - Long-press on the search text field\n      - Click the \"Add Search Engine\" menu item\n        - Select a name and click ok\n      - Click the 3 dot menu in the top right\n      - Navigate to the settings menu and select the \"Search\" sub-menu\n      - Select Whoogle and press \"Set as default\"\n    - Version >=79.0.0\n      - Click the 3 dot menu in the top right\n      - Navigate to the settings menu and select the \"Search\" sub-menu\n      - Click \"Add search engine\"\n      - Select the 'Other' radio button\n        - Name: \"Whoogle\"\n        - Search string to use: `https://\\<your whoogle url\\>/search?q=%s`\n  - [Alfred](https://www.alfredapp.com/) (Mac OS X)\n\t  1. Go to `Alfred Preferences` > `Features` > `Web Search` and click `Add Custom Search`. Then configure these settings\n\t\t   - Search URL: `https://\\<your whoogle url\\>/search?q={query}`\n\t\t   - Title: `Whoogle for '{query}'` (or whatever you want)\n\t\t   - Keyword: `whoogle`\n\n\t  2. Go to `Default Results` and click the `Setup fallback results` button. Click `+` and add Whoogle, then  drag it to the top.\n  - Chrome/Chromium-based Browsers\n    - Automatic\n      - Visit the home page of your Whoogle Search instance -- this will automatically add the search engine if the [requirements](https://www.chromium.org/tab-to-search/) are met (GET request, no OnSubmit script, no path). If not, you can add it manually.\n    - Manual\n      - Under search engines > manage search engines > add, manually enter your Whoogle instance details with a `<whoogle url>/search?q=%s` formatted search URL.\n\n### Custom Redirecting\nYou can set custom site redirects using the `WHOOGLE_REDIRECTS` environment\nvariable. A lot of sites, such as Twitter, Reddit, etc, have built-in redirects\nto [Farside links](https://sr.ht/~benbusby/farside), but you may want to define\nyour own.\n\nTo do this, you can use the following syntax:\n\n```\nWHOOGLE_REDIRECTS=\"<parent_domain>:<new_domain>\"\n```\n\nFor example, if you want to redirect from \"badsite.com\" to \"goodsite.com\":\n\n```\nWHOOGLE_REDIRECTS=\"badsite.com:goodsite.com\"\n```\n\nThis can be used for multiple sites as well, with comma separation:\n\n```\nWHOOGLE_REDIRECTS=\"badA.com:goodA.com,badB.com:goodB.com\"\n```\n\nNOTE: Do not include \"http(s)://\" when defining your redirect.\n\n### Custom Bangs\nYou can create your own custom bangs. By default, bangs are stored in \n`app/static/bangs`. See [`00-whoogle.json`](https://github.com/benbusby/whoogle-search/blob/main/app/static/bangs/00-whoogle.json)\nfor an example. These are parsed in alphabetical order with later files\noverriding bangs set in earlier files, with the exception that DDG bangs\n(downloaded to `app/static/bangs/bangs.json`) are always parsed first. Thus,\nany custom bangs will always override the DDG ones.\n\n### Prevent Downtime (Heroku only)\nPart of the deal with Heroku's free tier is that you're allocated 550 hours/month (meaning it can't stay active 24/7), and the app is temporarily shut down after 30 minutes of inactivity. Once it becomes inactive, any Whoogle searches will still work, but it'll take an extra 10-15 seconds for the app to come back online before displaying the result, which can be frustrating if you're in a hurry.\n\nA good solution for this is to set up a simple cronjob on any device at your home that is consistently powered on and connected to the internet (in my case, a PiHole worked perfectly). All the device needs to do is fetch app content on a consistent basis to keep the app alive in whatever ~17 hour window you want it on (17 hrs * 31 days = 527, meaning you'd still have 23 leftover hours each month if you searched outside of your target window).\n\nFor instance, adding `*/20 7-23 * * * curl https://<your heroku app name>.herokuapp.com > /home/<username>/whoogle-refresh` will fetch the home page of the app every 20 minutes between 7am and midnight, allowing for downtime from midnight to 7am. And again, this wouldn't be a hard limit - you'd still have plenty of remaining hours of uptime each month in case you were searching after this window has closed.\n\nSince the instance is destroyed and rebuilt after inactivity, config settings will be reset once the app enters downtime. If you have configuration settings active that you'd like to keep between periods of downtime (like dark mode for example), you could instead add `*/20 7-23 * * * curl -d \"dark=1\" -X POST https://<your heroku app name>.herokuapp.com/config > /home/<username>/whoogle-refresh` to keep these settings more or less permanent, and still keep the app from entering downtime when you're using it.\n\n### HTTPS Enforcement\nOnly needed if your setup requires Flask to redirect to HTTPS on its own -- generally this is something that doesn't need to be handled by Whoogle Search.\n\nNote: You should have your own domain name and [an https certificate](https://letsencrypt.org/getting-started/) in order for this to work properly.\n\n- Heroku: Ensure that the `Root URL` configuration on the home page begins with `https://` and not `http://`\n- Docker build: Add `--build-arg use_https=1` to your run command\n- Docker image: Set the environment variable HTTPS_ONLY=1\n- Pip/Pipx: Add the `--https-only` flag to the end of the `whoogle-search` command\n- Default `run` script: Modify the script locally to include the `--https-only` flag at the end of the python run command\n\n### Using with Firefox Containers\nUnfortunately, Firefox Containers do not currently pass through `POST` requests (the default) to the engine, and Firefox caches the opensearch template on initial page load. To get around this, you can take the following steps to get it working as expected:\n\n1. Remove any existing Whoogle search engines from Firefox settings\n2. Enable `GET Requests Only` in Whoogle config\n3. Clear Firefox cache\n4. Restart Firefox\n5. Navigate to Whoogle instance and [re-add the engine](#set-whoogle-as-your-primary-search-engine)\n\n### Reverse Proxying\n\n#### Nginx\n\nHere is a sample Nginx config for Whoogle:\n\n```\nserver {\n\tserver_name your_domain_name.com;\n\taccess_log /dev/null;\n\terror_log /dev/null;\n\n\tlocation / {\n\t    proxy_set_header X-Real-IP $remote_addr;\n\t    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t    proxy_set_header X-Forwarded-Proto $scheme;\n\t    proxy_set_header Host $host;\n\t    proxy_set_header X-NginX-Proxy true;\n\t    proxy_set_header X-Forwarded-Host $http_host;\n\t    proxy_pass http://localhost:5000;\n\t}\n}\n```\n\nYou can then add SSL support using LetsEncrypt by following a guide such as [this one](https://www.nginx.com/blog/using-free-ssltls-certificates-from-lets-encrypt-with-nginx/).\n\n### Static asset bundling (optional)\nWhoogle can optionally serve a single bundled CSS and JS to reduce the number of HTTP requests.\n\n- Enable by setting `WHOOGLE_BUNDLE_STATIC=1` and restarting the app.\n- On startup, Whoogle concatenates local CSS/JS into hashed files under `app/static/build/` and templates will prefer those bundles.\n- When disabled (default), templates load individual CSS/JS files for easier development.\n- Note: Theme CSS (`*-theme.css`) are still loaded separately to honor user theme selection.\n\n## User Agent Generator Tool\n\nA standalone command-line tool is available for generating Opera User Agent strings on demand:\n\n```bash\n# Generate 10 User Agent strings (default)\npython misc/generate_uas.py\n\n# Generate custom number of UAs\npython misc/generate_uas.py 20\n```\n\nThis tool is useful for:\n- Testing different UA strings\n- Generating UAs for other projects\n- Verifying UA generation patterns\n- Debugging UA-related issues\n\n## Using Custom User Agent Lists\n\nInstead of using auto-generated Opera UA strings, you can provide your own list of User Agent strings for Whoogle to use.\n\n### Setup\n\n1. Create a text file with your preferred UA strings (one per line):\n\n```\nOpera/9.80 (J2ME/MIDP; Opera Mini/4.2.13337/22.478; U; en) Presto/2.4.15 Version/10.00\nOpera/9.80 (Android; Linux; Opera Mobi/498; U; en) Presto/2.12.423 Version/10.1\n```\n\n2. Set the `WHOOGLE_UA_LIST_FILE` environment variable to point to your file:\n\n```bash\n# Docker\ndocker run -e WHOOGLE_UA_LIST_FILE=/config/my_user_agents.txt ...\n\n# Docker Compose\nenvironment:\n  - WHOOGLE_UA_LIST_FILE=/config/my_user_agents.txt\n\n# Manual/systemd\nexport WHOOGLE_UA_LIST_FILE=/path/to/my_user_agents.txt\n```\n\n### Priority Order\n\nWhoogle uses the following priority when loading User Agent strings:\n\n1. **Custom UA list file** (if `WHOOGLE_UA_LIST_FILE` is set and valid)\n2. **Cached auto-generated UAs** (if cache exists and is valid)\n3. **Newly generated UAs** (if no cache or cache expired)\n\n### Tips\n\n- You can use the output from `misc/check_google_user_agents.py` as your custom UA list\n- Generate a list with `python misc/generate_uas.py 50 2>/dev/null > my_uas.txt`\n- Mix different UA types (Opera, Firefox, Chrome) for more variety\n- Keep the file readable by Whoogle (proper permissions)\n- One UA string per line, blank lines are ignored\n\n### Example Workflow\n\n```bash\n# Generate and test UAs, save working ones\npython misc/generate_uas.py 100 2>/dev/null > candidate_uas.txt\npython misc/check_google_user_agents.py candidate_uas.txt --output working_uas.txt\n\n# Use the working UAs with Whoogle\nexport WHOOGLE_UA_LIST_FILE=./working_uas.txt\n./run\n```\n\n## User Agent Testing Tool\n\nWhoogle now includes a comprehensive testing tool (`misc/check_google_user_agents.py`) to verify which User Agent strings successfully return Google search results without triggering blocks, JavaScript-only pages, or browser upgrade prompts.\n\n### Usage\n\n```bash\n# Test all UAs from a file\npython misc/check_google_user_agents.py UAs.txt\n\n# Save working UAs to a file (appends incrementally)\npython misc/check_google_user_agents.py UAs.txt --output working_uas.txt\n\n# Use a specific search query\npython misc/check_google_user_agents.py UAs.txt --query \"python programming\"\n\n# Verbose mode to see detailed results\npython misc/check_google_user_agents.py UAs.txt --output working.txt --verbose\n\n# Adjust delay between requests (default: 0.5 seconds)\npython misc/check_google_user_agents.py UAs.txt --delay 1.0\n\n# Set request timeout (default: 10 seconds)\npython misc/check_google_user_agents.py UAs.txt --timeout 15.0\n```\n\n### Features\n\n- **Incremental Results**: Working UAs are saved immediately to the output file (append mode), so progress is preserved even if interrupted\n- **Duplicate Detection**: Automatically skips UAs already in the output file when resuming\n- **Random Query Cycling**: By default, cycles through diverse search queries to simulate realistic usage patterns\n- **Rate Limit Detection**: Detects and reports Google rate limiting with recovery instructions\n- **Comprehensive Validation**: Checks for:\n  - HTTP status codes (blocks, server errors, rate limits)\n  - Block markers (unusual traffic, upgrade browser messages)\n  - Success markers (actual search result HTML elements)\n  - JavaScript-only pages and redirects\n  - Response size validation\n\n### Testing Methodology\n\nThe tool evaluates UAs against multiple criteria:\n\n1. **HTTP Status**: Rejects 4xx/5xx errors, detects 429 rate limits\n2. **Block Detection**: Searches for Google's block messages (CAPTCHA, unusual traffic, etc.)\n3. **JavaScript Detection**: Identifies JS-only pages and noscript redirects\n4. **Result Validation**: Confirms presence of actual search result HTML elements\n5. **Content Analysis**: Validates response size and structure\n\nThis tool was used to discover and validate the working Opera UA patterns that power Whoogle's auto-generation feature.\n\n## Known Issues\n\n### User Agent Strings and Image Search\n\n**Issue**: Most, if not all, of the auto-generated Opera User Agent strings may fail when performing **image searches** on Google. This appears to be a limitation with how Google's image search validates User Agent strings.\n\n**Impact**:\n- Regular web searches work correctly with generated UAs\n- Image search may return errors or no results\n\n## Contributing\n\nUnder the hood, Whoogle is a basic Flask app with the following structure:\n\n- `app/`\n  - `routes.py`: Primary app entrypoint, contains all API routes\n  - `request.py`: Handles all outbound requests, including proxied/Tor connectivity\n  - `filter.py`: Functions and utilities used for filtering out content from upstream Google search results\n  - `utils/`\n    - `bangs.py`: All logic related to handling DDG-style \"bang\" queries\n    - `results.py`: Utility functions for interpreting/modifying individual search results\n    - `search.py`: Creates and handles new search queries\n    - `session.py`: Miscellaneous methods related to user sessions\n    - `ua_generator.py`: Auto-generates Opera User Agent strings with pattern-based randomization\n  - `templates/`\n    - `index.html`: The home page template\n    - `display.html`: The search results template\n    - `header.html`: A general \"top of the page\" query header for desktop and mobile\n    - `search.html`: An iframe-able search page\n    - `logo.html`: A template consisting mostly of the Whoogle logo as an SVG (separated to help keep `index.html` a bit cleaner)\n    - `opensearch.xml`: A template used for supporting [OpenSearch](https://developer.mozilla.org/en-US/docs/Web/OpenSearch).\n    - `imageresults.html`: An \"experimental\" template used for supporting the \"Full Size\" image feature on desktop.\n  - `static/<css|js>`\n    - CSS/JavaScript files, should be self-explanatory\n  - `static/settings`\n    - Key-value JSON files for establishing valid configuration values\n\n\nIf you're new to the project, the easiest way to get started would be to try fixing [an open bug report](https://github.com/benbusby/whoogle-search/issues?q=is%3Aissue+is%3Aopen+label%3Abug). If there aren't any open, or if the open ones are too stale, try taking on a [feature request](https://github.com/benbusby/whoogle-search/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement). Generally speaking, if you can write something that has any potential of breaking down in the future, you should write a test for it.\n\nThe project follows the [PEP 8 Style Guide](https://www.python.org/dev/peps/pep-0008/), but is liable to change. Static typing should always be used when possible. Function documentation is greatly appreciated, and typically follows the below format:\n\n```python\ndef contains(x: list, y: int) -> bool:\n    \"\"\"Check a list (x) for the presence of an element (y)\n\n    Args:\n        x: The list to inspect\n        y: The int to look for\n\n    Returns:\n        bool: True if the list contains the item, otherwise False\n    \"\"\"\n\n    return y in x\n```\n\n#### Translating\n\nWhoogle currently supports translations using [`translations.json`](https://github.com/benbusby/whoogle-search/blob/main/app/static/settings/translations.json). Language values in this file need to match the \"value\" of the according language in [`languages.json`](https://github.com/benbusby/whoogle-search/blob/main/app/static/settings/languages.json) (i.e. \"lang_en\" for English, \"lang_es\" for Spanish, etc). After you add a new set of translations to `translations.json`, open a PR with your changes and they will be merged in as soon as possible.\n\n## FAQ\n**What's the difference between this and [Searx](https://github.com/asciimoo/searx)?**\n\nWhoogle is intended to only ever be deployed to private instances by individuals of any background, with as little effort as possible. Prior knowledge of/experience with the command line or deploying applications is not necessary to deploy Whoogle, which isn't the case with Searx. As a result, Whoogle is missing some features of Searx in order to be as easy to deploy as possible.\n\nWhoogle also only uses Google search results, not Bing/Quant/etc, and uses the existing Google search UI to make the transition away from Google search as unnoticeable as possible.\n\nI'm a huge fan of Searx though and encourage anyone to use that instead if they want access to other search engines/a different UI/more configuration.\n\n**Why does the image results page look different?**\n\nA lot of the app currently piggybacks on Google's existing support for fetching results pages with JavaScript disabled. To their credit, they've done an excellent job with styling pages, but it seems that the image results page - particularly on mobile - is a little rough. Moving forward, with enough interest, I'd like to transition to fetching the results and parsing them into a unique Whoogle-fied interface that I can style myself.\n\n## Public Instances\n\n*Note: Use public instances at your own discretion. The maintainers of Whoogle do not personally validate the integrity of any other instances. Popular public instances are more likely to be rate-limited or blocked.*\n\n| Website | Country | Language | Cloudflare |\n|-|-|-|-|\n| [https://search.garudalinux.org](https://search.garudalinux.org) | 🇫🇮 FI | Multi-choice | ✅ |\n| [https://whoogle.privacydev.net](https://whoogle.privacydev.net) | 🇫🇷 FR | English | |\n| [https://whoogle.lunar.icu](https://whoogle.lunar.icu) | 🇩🇪 DE | Multi-choice | ✅ |\n\n\n* A checkmark in the \"Cloudflare\" category here refers to the use of the reverse proxy, [Cloudflare](https://cloudflare.com). The checkmark will not be listed for a site which uses Cloudflare DNS but rather the proxying service which grants Cloudflare the ability to monitor traffic to the website.\n\n#### Onion Instances\n\n| Website | Country | Language |\n|-|-|-|\nNONE of the existing Onion accessible sites appear to be live anymore\n\n## Screenshots\n#### Desktop\n![Whoogle Desktop](docs/screenshot_desktop.png)\n\n#### Mobile\n![Whoogle Mobile](docs/screenshot_mobile.png)\n"
  },
  {
    "path": "app/__init__.py",
    "content": "from app.filter import clean_query\nfrom app.request import send_tor_signal\nfrom app.utils.session import generate_key\nfrom app.utils.bangs import gen_bangs_json, load_all_bangs\nfrom app.utils.misc import gen_file_hash, read_config_bool\nfrom app.utils.ua_generator import load_ua_pool\nfrom base64 import b64encode\nfrom bs4 import MarkupResemblesLocatorWarning\nfrom datetime import datetime, timedelta\nfrom dotenv import load_dotenv\nfrom flask import Flask\nimport json\nimport logging.config\nimport os\nimport sys\nfrom stem import Signal\nimport threading\nimport warnings\n\nfrom werkzeug.middleware.proxy_fix import ProxyFix\n\nfrom app.services.http_client import HttpxClient\nfrom app.services.provider import close_all_clients\nfrom app.version import __version__\n\napp = Flask(__name__, static_folder=os.path.join(\n    os.path.dirname(os.path.abspath(__file__)), 'static'))\n\napp.wsgi_app = ProxyFix(app.wsgi_app)\n\n# look for WHOOGLE_ENV, else look in parent directory\ndot_env_path = os.getenv(\n    \"WHOOGLE_DOTENV_PATH\",\n    os.path.join(os.path.dirname(os.path.abspath(__file__)), \"../whoogle.env\"))\n\n# Load .env file if enabled\nif os.path.exists(dot_env_path):\n    load_dotenv(dot_env_path)\n\napp.enc_key = generate_key()\n\nif read_config_bool('HTTPS_ONLY'):\n    app.config['SESSION_COOKIE_NAME'] = '__Secure-session'\n    app.config['SESSION_COOKIE_SECURE'] = True\n\napp.config['VERSION_NUMBER'] = __version__\napp.config['APP_ROOT'] = os.getenv(\n    'APP_ROOT',\n    os.path.dirname(os.path.abspath(__file__)))\napp.config['STATIC_FOLDER'] = os.getenv(\n    'STATIC_FOLDER',\n    os.path.join(app.config['APP_ROOT'], 'static'))\napp.config['BUILD_FOLDER'] = os.path.join(\n    app.config['STATIC_FOLDER'], 'build')\napp.config['CACHE_BUSTING_MAP'] = {}\napp.config['BUNDLE_STATIC'] = read_config_bool('WHOOGLE_BUNDLE_STATIC')\nwith open(os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json'), 'r', encoding='utf-8') as f:\n    app.config['LANGUAGES'] = json.load(f)\nwith open(os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json'), 'r', encoding='utf-8') as f:\n    app.config['COUNTRIES'] = json.load(f)\nwith open(os.path.join(app.config['STATIC_FOLDER'], 'settings/time_periods.json'), 'r', encoding='utf-8') as f:\n    app.config['TIME_PERIODS'] = json.load(f)\nwith open(os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json'), 'r', encoding='utf-8') as f:\n    app.config['TRANSLATIONS'] = json.load(f)\nwith open(os.path.join(app.config['STATIC_FOLDER'], 'settings/themes.json'), 'r', encoding='utf-8') as f:\n    app.config['THEMES'] = json.load(f)\nwith open(os.path.join(app.config['STATIC_FOLDER'], 'settings/header_tabs.json'), 'r', encoding='utf-8') as f:\n    app.config['HEADER_TABS'] = json.load(f)\napp.config['CONFIG_PATH'] = os.getenv(\n    'CONFIG_VOLUME',\n    os.path.join(app.config['STATIC_FOLDER'], 'config'))\napp.config['DEFAULT_CONFIG'] = os.path.join(\n    app.config['CONFIG_PATH'],\n    'config.json')\napp.config['CONFIG_DISABLE'] = read_config_bool('WHOOGLE_CONFIG_DISABLE')\napp.config['SESSION_FILE_DIR'] = os.path.join(\n    app.config['CONFIG_PATH'],\n    'session')\n# Maximum session file size in bytes (4KB limit to prevent abuse and disk exhaustion)\n# Session files larger than this are ignored during cleanup to avoid processing\n# potentially malicious or corrupted files\napp.config['MAX_SESSION_SIZE'] = 4000\napp.config['BANG_PATH'] = os.getenv(\n    'CONFIG_VOLUME',\n    os.path.join(app.config['STATIC_FOLDER'], 'bangs'))\napp.config['BANG_FILE'] = os.path.join(\n    app.config['BANG_PATH'],\n    'bangs.json')\n\n# Global services registry (simple DI)\napp.services = {}\n\n\n@app.teardown_appcontext\ndef _teardown_clients(exception):\n    try:\n        close_all_clients()\n    except Exception:\n        pass\n\n# Ensure all necessary directories exist\nif not os.path.exists(app.config['CONFIG_PATH']):\n    os.makedirs(app.config['CONFIG_PATH'])\n\nif not os.path.exists(app.config['SESSION_FILE_DIR']):\n    os.makedirs(app.config['SESSION_FILE_DIR'])\n\nif not os.path.exists(app.config['BANG_PATH']):\n    os.makedirs(app.config['BANG_PATH'])\n\nif not os.path.exists(app.config['BUILD_FOLDER']):\n    os.makedirs(app.config['BUILD_FOLDER'])\n\n# Initialize User Agent pool\napp.config['UA_CACHE_PATH'] = os.path.join(app.config['CONFIG_PATH'], 'ua_cache.json')\ntry:\n    app.config['UA_POOL'] = load_ua_pool(app.config['UA_CACHE_PATH'], count=10)\nexcept Exception as e:\n    # If UA pool loading fails, log warning and set empty pool\n    # The gen_user_agent function will handle the fallback\n    print(f\"Warning: Could not initialize UA pool: {e}\")\n    app.config['UA_POOL'] = []\n\n# Session values - Secret key management\n# Priority: environment variable → file → generate new\ndef get_secret_key():\n    \"\"\"Load or generate secret key with validation.\n    \n    Priority order:\n    1. WHOOGLE_SECRET_KEY environment variable\n    2. Existing key file\n    3. Generate new key and save to file\n    \n    Returns:\n        str: Valid secret key for Flask sessions\n    \"\"\"\n    # Check environment variable first\n    env_key = os.getenv('WHOOGLE_SECRET_KEY', '').strip()\n    if env_key:\n        # Validate env key has minimum length\n        if len(env_key) >= 32:\n            return env_key\n        else:\n            print(f\"Warning: WHOOGLE_SECRET_KEY too short ({len(env_key)} chars, need 32+). Using file/generated key instead.\", file=sys.stderr)\n    \n    # Check file-based key\n    app_key_path = os.path.join(app.config['CONFIG_PATH'], 'whoogle.key')\n    if os.path.exists(app_key_path):\n        try:\n            with open(app_key_path, 'r', encoding='utf-8') as f:\n                key = f.read().strip()\n                # Validate file key\n                if len(key) >= 32:\n                    return key\n                else:\n                    print(f\"Warning: Key file too short, regenerating\", file=sys.stderr)\n        except (PermissionError, IOError) as e:\n            print(f\"Warning: Could not read key file: {e}\", file=sys.stderr)\n    \n    # Generate new key\n    new_key = str(b64encode(os.urandom(32)))\n    try:\n        with open(app_key_path, 'w', encoding='utf-8') as key_file:\n            key_file.write(new_key)\n    except (PermissionError, IOError) as e:\n        print(f\"Warning: Could not save key file: {e}. Key will not persist across restarts.\", file=sys.stderr)\n    \n    return new_key\n\napp.config['SECRET_KEY'] = get_secret_key()\napp.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365)\n\n# NOTE: SESSION_COOKIE_SAMESITE must be set to 'lax' to allow the user's\n# previous session to persist when accessing the instance from an external\n# link. Setting this value to 'strict' causes Whoogle to revalidate a new\n# session, and fail, resulting in cookies being disabled.\napp.config['SESSION_COOKIE_SAMESITE'] = 'Lax'\n\n# Config fields that are used to check for updates\napp.config['RELEASES_URL'] = 'https://github.com/' \\\n                             'benbusby/whoogle-search/releases'\napp.config['LAST_UPDATE_CHECK'] = datetime.now() - timedelta(hours=24)\napp.config['HAS_UPDATE'] = ''\n\n# The alternative to Google Translate is treated a bit differently than other\n# social media site alternatives, in that it is used for any translation\n# related searches.\ntranslate_url = os.getenv('WHOOGLE_ALT_TL', 'https://farside.link/lingva')\nif not translate_url.startswith('http'):\n    translate_url = 'https://' + translate_url\napp.config['TRANSLATE_URL'] = translate_url\n\napp.config['CSP'] = 'default-src \\'none\\';' \\\n                    'frame-src ' + translate_url + ';' \\\n                    'manifest-src \\'self\\';' \\\n                    'img-src \\'self\\' data:;' \\\n                    'style-src \\'self\\' \\'unsafe-inline\\';' \\\n                    'script-src \\'self\\';' \\\n                    'media-src \\'self\\';' \\\n                    'connect-src \\'self\\';'\n\n# Generate DDG bang filter\ngenerating_bangs = False\nif not os.path.exists(app.config['BANG_FILE']):\n    generating_bangs = True\n    with open(app.config['BANG_FILE'], 'w', encoding='utf-8') as f:\n        json.dump({}, f)\n    bangs_thread = threading.Thread(\n        target=gen_bangs_json,\n        args=(app.config['BANG_FILE'],))\n    bangs_thread.start()\n\n# Build new mapping of static files for cache busting\ncache_busting_dirs = ['css', 'js']\nfor cb_dir in cache_busting_dirs:\n    full_cb_dir = os.path.join(app.config['STATIC_FOLDER'], cb_dir)\n    for cb_file in os.listdir(full_cb_dir):\n        # Create hash from current file state\n        full_cb_path = os.path.join(full_cb_dir, cb_file)\n        cb_file_link = gen_file_hash(full_cb_dir, cb_file)\n        build_path = os.path.join(app.config['BUILD_FOLDER'], cb_file_link)\n\n        try:\n            os.symlink(full_cb_path, build_path)\n        except FileExistsError:\n            # Symlink hasn't changed, ignore\n            pass\n\n        # Create mapping for relative path urls\n        map_path = build_path.replace(app.config['APP_ROOT'], '')\n        if map_path.startswith('/'):\n            map_path = map_path[1:]\n        app.config['CACHE_BUSTING_MAP'][cb_file] = map_path\n\n# Optionally create simple bundled assets (opt-in via WHOOGLE_BUNDLE_STATIC=1)\nif app.config['BUNDLE_STATIC']:\n    # CSS bundle: include all css except theme files (end with -theme.css)\n    css_dir = os.path.join(app.config['STATIC_FOLDER'], 'css')\n    css_parts = []\n    for name in sorted(os.listdir(css_dir)):\n        if not name.endswith('.css'):\n            continue\n        if name.endswith('-theme.css'):\n            continue\n        try:\n            with open(os.path.join(css_dir, name), 'r', encoding='utf-8') as f:\n                css_parts.append(f.read())\n        except Exception:\n            pass\n    css_bundle = '\\n'.join(css_parts)\n    if css_bundle:\n        css_tmp = os.path.join(app.config['BUILD_FOLDER'], 'app.css')\n        with open(css_tmp, 'w', encoding='utf-8') as f:\n            f.write(css_bundle)\n        css_hashed = gen_file_hash(app.config['BUILD_FOLDER'], 'app.css')\n        os.replace(css_tmp, os.path.join(app.config['BUILD_FOLDER'], css_hashed))\n        map_path = os.path.join('app/static/build', css_hashed)\n        app.config['CACHE_BUSTING_MAP']['bundle.css'] = map_path\n\n    # JS bundle: include all js files\n    js_dir = os.path.join(app.config['STATIC_FOLDER'], 'js')\n    js_parts = []\n    for name in sorted(os.listdir(js_dir)):\n        if not name.endswith('.js'):\n            continue\n        try:\n            with open(os.path.join(js_dir, name), 'r', encoding='utf-8') as f:\n                js_parts.append(f.read())\n        except Exception:\n            pass\n    js_bundle = '\\n;'.join(js_parts)\n    if js_bundle:\n        js_tmp = os.path.join(app.config['BUILD_FOLDER'], 'app.js')\n        with open(js_tmp, 'w', encoding='utf-8') as f:\n            f.write(js_bundle)\n        js_hashed = gen_file_hash(app.config['BUILD_FOLDER'], 'app.js')\n        os.replace(js_tmp, os.path.join(app.config['BUILD_FOLDER'], js_hashed))\n        map_path = os.path.join('app/static/build', js_hashed)\n        app.config['CACHE_BUSTING_MAP']['bundle.js'] = map_path\n\n# Templating functions\napp.jinja_env.globals.update(clean_query=clean_query)\napp.jinja_env.globals.update(\n    cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f.lower()])\napp.jinja_env.globals.update(\n    bundle_static=lambda: app.config.get('BUNDLE_STATIC', False))\n\n# Attempt to acquire tor identity, to determine if Tor config is available\nsend_tor_signal(Signal.HEARTBEAT)\n\n# Suppress spurious warnings from BeautifulSoup\nwarnings.simplefilter('ignore', MarkupResemblesLocatorWarning)\n\nfrom app import routes  # noqa\n\n# The gen_bangs_json function takes care of loading bangs, so skip it here if\n# it's already being loaded\nif not generating_bangs:\n    load_all_bangs(app.config['BANG_FILE'])\n\n# Disable logging from imported modules\nlogging.config.dictConfig({\n    'version': 1,\n    'disable_existing_loggers': True,\n})\n"
  },
  {
    "path": "app/__main__.py",
    "content": "from .routes import run_app\n\nrun_app()\n"
  },
  {
    "path": "app/filter.py",
    "content": "import cssutils\nfrom bs4 import BeautifulSoup\nfrom bs4.element import ResultSet, Tag\nfrom cryptography.fernet import Fernet\nfrom flask import render_template\nimport html\nimport urllib.parse as urlparse\nimport os\nfrom urllib.parse import parse_qs, urlencode, urlunparse\nimport re\n\nfrom app.models.g_classes import GClasses\nfrom app.request import VALID_PARAMS, MAPS_URL\nfrom app.utils.misc import get_abs_url, read_config_bool\nfrom app.utils.results import (\n    BLANK_B64, GOOG_IMG, GOOG_STATIC, G_M_LOGO_URL, LOGO_URL, SITE_ALTS,\n    has_ad_content, filter_link_args, append_anon_view, get_site_alt,\n)\nfrom app.models.endpoint import Endpoint\nfrom app.models.config import Config\n\n\nMAPS_ARGS = ['q', 'daddr']\n\nminimal_mode_sections = ['Top stories', 'Images', 'People also ask']\nunsupported_g_pages = [\n    'support.google.com',\n    'accounts.google.com',\n    'policies.google.com',\n    'google.com/preferences',\n    'google.com/intl',\n    'advanced_search',\n    'tbm=shop',\n    'ageverification.google.co.kr'\n]\n\nunsupported_g_divs = ['google.com/preferences?hl=', 'ageverification.google.co.kr']\n\n\ndef extract_q(q_str: str, href: str) -> str:\n    \"\"\"Extracts the 'q' element from a result link. This is typically\n    either the link to a result's website, or a string.\n\n    Args:\n        q_str: The result link to parse\n        href: The full url to check for standalone 'q' elements first,\n              rather than parsing the whole query string and then checking.\n\n    Returns:\n        str: The 'q' element of the link, or an empty string\n    \"\"\"\n    return parse_qs(q_str, keep_blank_values=True)['q'][0] if ('&q=' in href or '?q=' in href) else ''\n\n\ndef build_map_url(href: str) -> str:\n    \"\"\"Tries to extract known args that explain the location in the url. If a\n    location is found, returns the default url with it. Otherwise, returns the\n    url unchanged.\n\n    Args:\n        href: The full url to check.\n\n    Returns:\n        str: The parsed url, or the url unchanged.\n    \"\"\"\n    # parse the url\n    parsed_url = parse_qs(href)\n    # iterate through the known parameters and try build the url\n    for param in MAPS_ARGS:\n        if param in parsed_url:\n            return MAPS_URL + \"?q=\" + parsed_url[param][0]\n\n    # query could not be extracted returning unchanged url\n    return href\n\n\ndef clean_query(query: str) -> str:\n    \"\"\"Strips the blocked site list from the query, if one is being\n    used.\n\n    Args:\n        query: The query string\n\n    Returns:\n        str: The query string without any \"-site:...\" filters\n    \"\"\"\n    return query[:query.find('-site:')] if '-site:' in query else query\n\n\ndef clean_css(css: str, page_url: str) -> str:\n    \"\"\"Removes all remote URLs from a CSS string.\n\n    Args:\n        css: The CSS string\n\n    Returns:\n        str: The filtered CSS, with URLs proxied through Whoogle\n    \"\"\"\n    sheet = cssutils.parseString(css)\n    urls = cssutils.getUrls(sheet)\n\n    for url in urls:\n        abs_url = get_abs_url(url, page_url)\n        if abs_url.startswith('data:'):\n            continue\n        css = css.replace(\n            url,\n            f'{Endpoint.element}?type=image/png&url={abs_url}'\n        )\n\n    return css\n\n\nclass Filter:\n    # Minimum number of child div elements that indicates a collapsible section\n    # Regular search results typically have fewer child divs (< 7)\n    # Special sections like \"People also ask\", \"Related searches\" have more (>= 7)\n    # This threshold helps identify and collapse these extended result sections\n    RESULT_CHILD_LIMIT = 7\n\n    def __init__(\n            self,\n            user_key: str,\n            config: Config,\n            root_url='',\n            page_url='',\n            query='',\n            mobile=False) -> None:\n        self.soup = None\n        self.config = config\n        self.mobile = mobile\n        self.user_key = user_key\n        self.page_url = page_url\n        self.query = query\n        self.main_divs = ResultSet('')\n        self._elements = 0\n        self._av = set()\n\n        self.root_url = root_url[:-1] if root_url.endswith('/') else root_url\n\n    def __getitem__(self, name):\n        return getattr(self, name)\n\n    @property\n    def elements(self):\n        return self._elements\n\n    def encrypt_path(self, path, is_element=False) -> str:\n        # Encrypts path to avoid plaintext results in logs\n        if is_element:\n            # Element paths are encrypted separately from text, to allow key\n            # regeneration once all items have been served to the user\n            enc_path = Fernet(self.user_key).encrypt(path.encode()).decode()\n            self._elements += 1\n            return enc_path\n\n        return Fernet(self.user_key).encrypt(path.encode()).decode()\n\n    def clean(self, soup) -> BeautifulSoup:\n        self.soup = soup\n        self.main_divs = self.soup.find('div', {'id': 'main'})\n        self.remove_ads()\n        self.remove_ai_overview()\n        self.remove_block_titles()\n        self.remove_block_url()\n        self.collapse_sections()\n        self.update_css()\n        self.update_styling()\n        self.remove_block_tabs()\n\n        # self.main_divs is only populated for the main page of search results\n        # (i.e. not images/news/etc).\n        if self.main_divs:\n            for div in self.main_divs:\n                self.sanitize_div(div)\n\n        for img in [_ for _ in self.soup.find_all('img') if 'src' in _.attrs]:\n            self.update_element_src(img, 'image/png')\n\n        for audio in [_ for _ in self.soup.find_all('audio') if 'src' in _.attrs]:\n            self.update_element_src(audio, 'audio/mpeg')\n            audio['controls'] = ''\n\n        for link in self.soup.find_all('a', href=True):\n            self.update_link(link)\n            self.add_favicon(link)\n\n        if self.config.alts:\n            self.site_alt_swap()\n\n        input_form = self.soup.find('form')\n        if input_form is not None:\n            input_form['method'] = 'GET' if self.config.get_only else 'POST'\n            # Use a relative URI for submissions\n            input_form['action'] = 'search'\n\n        # Ensure no extra scripts passed through\n        for script in self.soup('script'):\n            script.decompose()\n\n        # Update default footer and header\n        footer = self.soup.find('footer')\n        if footer:\n            # Remove divs that have multiple links beyond just page navigation\n            [_.decompose() for _ in footer.find_all('div', recursive=False)\n             if len(_.find_all('a', href=True)) > 3]\n            for link in footer.find_all('a', href=True):\n                link['href'] = f'{link[\"href\"]}&preferences={self.config.preferences}'\n\n        header = self.soup.find('header')\n        if header:\n            header.decompose()\n        # Remove broken \"Dark theme\" toggle snippets that occasionally slip\n        # into the footer.\n        self.remove_dark_theme_toggle(self.soup)\n        self.remove_site_blocks(self.soup)\n        return self.soup\n\n    def sanitize_div(self, div) -> None:\n        \"\"\"Removes escaped script and iframe tags from results\n\n        Returns:\n            None (The soup object is modified directly)\n        \"\"\"\n        if not div or not isinstance(div, Tag):\n            return\n\n        for d in div.find_all('div', recursive=True):\n            d_text = d.find(string=True, recursive=False)\n\n            # Ensure we're working with tags that contain text content\n            if not d_text or not d.string:\n                continue\n\n            d.string = html.unescape(d_text)\n            div_soup = BeautifulSoup(d.string, 'html.parser')\n\n            # Remove all valid script or iframe tags in the div\n            for script in div_soup.find_all('script'):\n                script.decompose()\n\n            for iframe in div_soup.find_all('iframe'):\n                iframe.decompose()\n\n            d.string = str(div_soup)\n\n    def add_favicon(self, link) -> None:\n        \"\"\"Adds icons for each returned result, using the result site's favicon\n\n        Returns:\n            None (The soup object is modified directly)\n        \"\"\"\n        # Skip empty, parentless, or internal links\n        show_favicons = read_config_bool('WHOOGLE_SHOW_FAVICONS', True)\n        is_valid_link = link and link.parent and link['href'].startswith('http')\n        if not show_favicons or not is_valid_link:\n            return\n\n        parent = link.parent\n        is_result_div = False\n\n        # Check each parent to make sure that the div doesn't already have a\n        # favicon attached, and that the div is a result div\n        while parent:\n            p_cls = parent.attrs.get('class') or []\n            if 'has-favicon' in p_cls or GClasses.scroller_class in p_cls:\n                return\n            elif GClasses.result_class_a not in p_cls:\n                parent = parent.parent\n            else:\n                is_result_div = True\n                break\n\n        if not is_result_div:\n            return\n\n        # Construct the html for inserting the icon into the parent div\n        parsed = urlparse.urlparse(link['href'])\n        favicon = self.encrypt_path(\n            f'{parsed.scheme}://{parsed.netloc}/favicon.ico',\n            is_element=True)\n        src = f'{self.root_url}/{Endpoint.element}?url={favicon}' + \\\n            '&type=image/x-icon'\n        html = f'<img class=\"site-favicon\" src=\"{src}\">'\n\n        favicon = BeautifulSoup(html, 'html.parser')\n        link.parent.insert(0, favicon)\n\n        # Update all parents to indicate that a favicon has been attached\n        parent = link.parent\n        while parent:\n            p_cls = parent.get('class') or []\n            p_cls.append('has-favicon')\n            parent['class'] = p_cls\n            parent = parent.parent\n\n            if GClasses.result_class_a in p_cls:\n                break\n\n    def remove_dark_theme_toggle(self, soup: BeautifulSoup) -> None:\n        \"\"\"Removes stray Dark theme toggle/link fragments that can appear\n        in the footer.\"\"\"\n        for node in soup.find_all(string=re.compile(r'Dark theme', re.I)):\n            try:\n                parent = node.find_parent(\n                    lambda tag: tag.name in ['div', 'span', 'p', 'a', 'li',\n                                             'section'])\n                target = parent or node.parent\n                if target:\n                    target.decompose()\n                else:\n                    node.extract()\n            except Exception:\n                continue\n\n    def remove_site_blocks(self, soup) -> None:\n        if not self.config.block or not soup.body:\n            return\n        search_string = ' '.join(['-site:' +\n                                 _ for _ in self.config.block.split(',')])\n        selected = soup.body.find_all(string=re.compile(search_string))\n\n        for result in selected:\n            result.string.replace_with(result.string.replace(\n                                       search_string, ''))\n\n    def remove_ai_overview(self) -> None:\n        \"\"\"Removes Google's AI Overview/SGE results from search results\n\n        Returns:\n            None (The soup object is modified directly)\n        \"\"\"\n        if not self.main_divs:\n            return\n\n        # Patterns that identify AI Overview sections\n        ai_patterns = [\n            'AI Overview',\n            'AI responses may include mistakes',\n        ]\n\n        # Result div classes - check both original Google classes and mapped ones\n        # since this runs before CSS class replacement\n        result_classes = [GClasses.result_class_a]  # 'ZINbbc'\n        result_classes.extend(GClasses.result_classes.get(\n            GClasses.result_class_a, []))  # ['Gx5Zad']\n\n        # Collect divs to remove first to avoid modifying while iterating\n        divs_to_remove = []\n\n        for div in self.main_divs.find_all('div', recursive=True):\n            # Check if this div or its children contain AI Overview markers\n            div_text = div.get_text()\n            if any(pattern in div_text for pattern in ai_patterns):\n                # Walk up to find the top-level result div\n                parent = div\n                while parent:\n                    p_cls = parent.attrs.get('class') or []\n                    if any(rc in p_cls for rc in result_classes):\n                        if parent not in divs_to_remove:\n                            divs_to_remove.append(parent)\n                        break\n                    parent = parent.parent\n\n        # Remove collected divs\n        for div in divs_to_remove:\n            div.decompose()\n\n    def remove_ads(self) -> None:\n        \"\"\"Removes ads found in the list of search result divs\n\n        Returns:\n            None (The soup object is modified directly)\n        \"\"\"\n        if not self.main_divs:\n            return\n\n        for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]:\n            div_ads = [_ for _ in div.find_all('span', recursive=True)\n                       if has_ad_content(_.text)]\n            _ = div.decompose() if len(div_ads) else None\n\n    def remove_block_titles(self) -> None:\n        if not self.main_divs or not self.config.block_title:\n            return\n        block_title = re.compile(self.config.block_title)\n        for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]:\n            block_divs = [_ for _ in div.find_all('h3', recursive=True)\n                          if block_title.search(_.text) is not None]\n            _ = div.decompose() if len(block_divs) else None\n\n    def remove_block_url(self) -> None:\n        if not self.main_divs or not self.config.block_url:\n            return\n        block_url = re.compile(self.config.block_url)\n        for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]:\n            block_divs = [_ for _ in div.find_all('a', recursive=True)\n                          if block_url.search(_.attrs['href']) is not None]\n            _ = div.decompose() if len(block_divs) else None\n\n    def remove_block_tabs(self) -> None:\n        if self.main_divs:\n            for div in self.main_divs.find_all(\n                'div',\n                attrs={'class': f'{GClasses.main_tbm_tab}'}\n            ):\n                _ = div.decompose()\n        else:\n            # when in images tab\n            for div in self.soup.find_all(\n                'div',\n                attrs={'class': f'{GClasses.images_tbm_tab}'}\n            ):\n                _ = div.decompose()\n\n    def collapse_sections(self) -> None:\n        \"\"\"Collapses long result sections (\"people also asked\", \"related\n         searches\", etc) into \"details\" elements\n\n        These sections are typically the only sections in the results page that\n        have more than ~5 child divs within a primary result div.\n\n        Returns:\n            None (The soup object is modified directly)\n        \"\"\"\n        minimal_mode = read_config_bool('WHOOGLE_MINIMAL')\n\n        def pull_child_divs(result_div: BeautifulSoup):\n            try:\n                top_level_divs = result_div.find_all('div', recursive=False)\n                if not top_level_divs:\n                    return []\n                return top_level_divs[0].find_all('div', recursive=False)\n            except Exception:\n                return []\n\n        if not self.main_divs:\n            return\n\n        # Skip collapsing for CSE (Custom Search Engine) results\n        # CSE results have a data-cse attribute on the main container\n        if self.soup.find(attrs={'data-cse': 'true'}):\n            return\n\n        # Loop through results and check for the number of child divs in each\n        for result in self.main_divs.find_all():\n            result_children = pull_child_divs(result)\n            if minimal_mode:\n                if any(f\">{x}</span\" in str(s) for s in result_children\n                   for x in minimal_mode_sections):\n                    result.decompose()\n                    continue\n                for s in result_children:\n                    if ('Twitter ›' in str(s)):\n                        result.decompose()\n                        continue\n                if len(result_children) < self.RESULT_CHILD_LIMIT:\n                    continue\n            else:\n                if len(result_children) < self.RESULT_CHILD_LIMIT:\n                    continue\n\n            # Find and decompose the first element with an inner HTML text val.\n            # This typically extracts the title of the section (i.e. \"Related\n            # Searches\", \"People also ask\", etc)\n            # If there are more than one child tags with text\n            # parenthesize the rest except the first\n            label = 'Collapsed Results'\n            subtitle = None\n            for elem in result_children:\n                if elem.text:\n                    content = list(elem.strings)\n                    label = content[0]\n                    if len(content) > 1:\n                        subtitle = '<span> (' + \\\n                            ''.join(content[1:]) + ')</span>'\n                    elem.decompose()\n                    break\n\n            # Create the new details element to wrap around the result's\n            # first parent\n            parent = None\n            idx = 0\n            while not parent and idx < len(result_children):\n                parent = result_children[idx].parent\n                idx += 1\n\n            details = BeautifulSoup(features='html.parser').new_tag('details')\n            summary = BeautifulSoup(features='html.parser').new_tag('summary')\n            summary.string = label\n\n            if subtitle:\n                soup = BeautifulSoup(subtitle, 'html.parser')\n                summary.append(soup)\n\n            details.append(summary)\n\n            if parent and not minimal_mode:\n                parent.wrap(details)\n            elif parent and minimal_mode:\n                # Remove parent element from document if \"minimal mode\" is\n                # enabled\n                parent.decompose()\n\n    def update_element_src(self, element: Tag, mime: str, attr='src') -> None:\n        \"\"\"Encrypts the original src of an element and rewrites the element src\n        to use the \"/element?src=\" pass-through.\n\n        Returns:\n            None (The soup element is modified directly)\n\n        \"\"\"\n        src = element[attr].split(' ')[0]\n\n        if src.startswith('//'):\n            src = 'https:' + src\n        elif src.startswith('data:'):\n            return\n\n        if src.startswith(LOGO_URL):\n            # Re-brand with Whoogle logo\n            element.replace_with(BeautifulSoup(\n                render_template('logo.html'),\n                features='html.parser'))\n            return\n        elif src.startswith(G_M_LOGO_URL):\n            # Re-brand with single-letter Whoogle logo\n            element['src'] = 'static/img/favicon/apple-icon.png'\n            element.parent['href'] = 'home'\n            return\n        elif src.startswith(GOOG_IMG) or GOOG_STATIC in src:\n            element['src'] = BLANK_B64\n            return\n\n        element[attr] = f'{self.root_url}/{Endpoint.element}?url=' + (\n            self.encrypt_path(\n                src,\n                is_element=True\n            ) + '&type=' + urlparse.quote(mime)\n        )\n\n    def update_css(self) -> None:\n        \"\"\"Updates URLs used in inline styles to be proxied by Whoogle\n        using the /element endpoint.\n\n        Returns:\n            None (The soup element is modified directly)\n\n        \"\"\"\n        # Filter all <style> tags\n        for style in self.soup.find_all('style'):\n            style.string = clean_css(style.string, self.page_url)\n\n        # TODO: Convert remote stylesheets to style tags and proxy all\n        # remote requests\n        # for link in soup.find_all('link', attrs={'rel': 'stylesheet'}):\n            # print(link)\n\n    def update_styling(self) -> None:\n        # Update CSS classes for result divs\n        soup = GClasses.replace_css_classes(self.soup)\n\n        # Remove unnecessary button(s)\n        for button in self.soup.find_all('button'):\n            button.decompose()\n\n        # Remove svg logos\n        for svg in self.soup.find_all('svg'):\n            svg.decompose()\n\n        # Update logo\n        logo = self.soup.find('a', {'class': 'l'})\n        if logo and self.mobile:\n            logo['style'] = ('display:flex; justify-content:center; '\n                             'align-items:center; color:#685e79; '\n                             'font-size:18px; ')\n\n        # Fix search bar length on mobile\n        try:\n            search_bar = self.soup.find('header').find('form').find('div')\n            search_bar['style'] = 'width: 100%;'\n        except AttributeError:\n            pass\n\n        # Fix body max width on images tab\n        style = self.soup.find('style')\n        div = self.soup.find('div', attrs={\n            'class': f'{GClasses.images_tbm_tab}'})\n        if style and div and not self.mobile:\n            css = style.string\n            css_html_tag = (\n                'html{'\n                'font-family: Roboto, Helvetica Neue, Arial, sans-serif;'\n                'font-size: 14px;'\n                'line-height: 20px;'\n                'text-size-adjust: 100%;'\n                'word-wrap: break-word;'\n                '}'\n            )\n            css = f\"{css_html_tag}{css}\"\n            css = re.sub('body{(.*?)}',\n                         'body{padding:0 12px;margin:0 auto;max-width:1200px;}',\n                         css)\n            style.string = css\n\n        # Normalize the max width between result types so the page doesn't\n        # jump in size when switching tabs.\n        if not self.mobile:\n            max_width_css = (\n                'body, #cnt, #center_col, .main, .e9EfHf, #searchform, '\n                '.GyAeWb, .s6JM6d {'\n                'max-width:1200px;'\n                'margin:0 auto;'\n                'padding-left:12px;'\n                'padding-right:12px;'\n                '}'\n            )\n            # Build the style tag using a fresh soup to avoid cases where the\n            # current soup lacks the helper methods (e.g., non-root elements).\n            factory_soup = BeautifulSoup('', 'html.parser')\n            extra_style = factory_soup.new_tag('style')\n            extra_style.string = max_width_css\n            if self.soup.head:\n                self.soup.head.append(extra_style)\n            else:\n                self.soup.insert(0, extra_style)\n\n    def update_link(self, link: Tag) -> None:\n        \"\"\"Update internal link paths with encrypted path, otherwise remove\n        unnecessary redirects and/or marketing params from the url\n\n        Args:\n            link: A bs4 Tag element to inspect and update\n\n        Returns:\n            None (the tag is updated directly)\n\n        \"\"\"\n        parsed_link = urlparse.urlparse(link['href'])\n        if '/url?q=' in link['href']:\n            link_netloc = extract_q(parsed_link.query, link['href'])\n        else:\n            link_netloc = parsed_link.netloc\n\n        # Remove any elements that direct to unsupported Google pages\n        if any(url in link_netloc for url in unsupported_g_pages):\n            # Replaces the /url google unsupported link to the direct url\n            link['href'] = link_netloc\n            parent = link.parent\n\n            if any(divlink in link_netloc for divlink in unsupported_g_divs):\n                # Handle case where a search is performed in a different\n                # language than what is configured. This usually returns a\n                # div with the same classes as normal search results, but with\n                # a link to configure language preferences through Google.\n                # Since we want all language config done through Whoogle, we\n                # can safely decompose this element.\n                while parent:\n                    p_cls = parent.attrs.get('class') or []\n                    if f'{GClasses.result_class_a}' in p_cls:\n                        parent.decompose()\n                        break\n                    parent = parent.parent\n            else:\n                # Remove cases where google links appear in the footer\n                while parent:\n                    p_cls = parent.attrs.get('class') or []\n                    if parent.name == 'footer' or f'{GClasses.footer}' in p_cls:\n                        link.decompose()\n                    parent = parent.parent\n\n            if link.decomposed:\n                return\n\n        # Replace href with only the intended destination (no \"utm\" type tags)\n        href = link['href'].replace('https://www.google.com', '')\n        result_link = urlparse.urlparse(href)\n        q = extract_q(result_link.query, href)\n\n        if q.startswith('/') and q not in self.query and 'spell=1' not in href:\n            # Internal google links (i.e. mail, maps, etc) should still\n            # be forwarded to Google\n            link['href'] = 'https://google.com' + q\n        elif q.startswith('https://accounts.google.com'):\n            # Remove Sign-in link\n            link.decompose()\n            return\n        elif '/search?q=' in href:\n            # \"li:1\" implies the query should be interpreted verbatim,\n            # which is accomplished by wrapping the query in double quotes\n            if 'li:1' in href:\n                q = '\"' + q + '\"'\n            new_search = 'search?q=' + self.encrypt_path(q)\n\n            query_params = parse_qs(urlparse.urlparse(href).query)\n            for param in VALID_PARAMS:\n                if param not in query_params:\n                    continue\n                param_val = query_params[param][0]\n                new_search += '&' + param + '=' + param_val\n            link['href'] = new_search\n        elif 'url?q=' in href:\n            # Strip unneeded arguments\n            link['href'] = filter_link_args(q)\n\n            # Add alternate viewing options for results,\n            # if the result doesn't already have an AV link\n            netloc = urlparse.urlparse(link['href']).netloc\n            if self.config.anon_view and netloc not in self._av:\n                self._av.add(netloc)\n                append_anon_view(link, self.config)\n\n        else:\n            if href.startswith(MAPS_URL):\n                # Maps links don't work if a site filter is applied\n                link['href'] = build_map_url(link['href'])\n            elif (href.startswith('/?') or href.startswith('/search?') or\n                  href.startswith('/imgres?')):\n                # make sure that tags can be clicked as relative URLs\n                link['href'] = href[1:]\n            elif href.startswith('/intl/'):\n                # do nothing, keep original URL for ToS\n                pass\n            elif href.startswith('/preferences'):\n                # there is no config specific URL, remove this\n                link.decompose()\n                return\n            else:\n                link['href'] = href\n\n        if self.config.new_tab and (\n            link[\"href\"].startswith(\"http\")\n            or link[\"href\"].startswith(\"imgres?\")\n        ):\n            link[\"target\"] = \"_blank\"\n\n    def site_alt_swap(self) -> None:\n        \"\"\"Replaces link locations and page elements if \"alts\" config\n        is enabled\n        \"\"\"\n        # Precompute regex for sites (escape dots) and common prefixes\n        site_keys = list(SITE_ALTS.keys())\n        if not site_keys:\n            return\n        sites_pattern = re.compile('|'.join([re.escape(k) for k in site_keys]))\n        prefix_pattern = re.compile(r'^(?:https?:\\/\\/)?(?:(?:www|mobile|m)\\.)?')\n\n        # 1) Replace bare domain divs (single token) once, avoiding duplicates\n        for div in self.soup.find_all('div', string=sites_pattern):\n            if not div or not div.string:\n                continue\n            if len(div.string.split(' ')) != 1:\n                continue\n            match = sites_pattern.search(div.string)\n            if not match:\n                continue\n            site = match.group(0)\n            alt = SITE_ALTS.get(site, '')\n            if not alt:\n                continue\n            # Skip if already contains the alt to avoid old.old.* repetition\n            if alt in div.string:\n                continue\n            div.string = div.string.replace(site, alt)\n\n        # 2) Update link hrefs and descriptions in a single pass\n        for link in self.soup.find_all('a', href=True):\n            link['href'] = get_site_alt(link['href'])\n\n            # Find a description text node matching a known site\n            desc_nodes = link.find_all(string=sites_pattern)\n            if not desc_nodes:\n                continue\n            desc_node = desc_nodes[0]\n            link_str = str(desc_node)\n\n            # Determine which site key is present in the description\n            site_match = sites_pattern.search(link_str)\n            if not site_match:\n                continue\n            site = site_match.group(0)\n            alt = SITE_ALTS.get(site, '')\n            if not alt:\n                continue\n\n            # Avoid duplication if alt already present\n            if alt in link_str:\n                continue\n\n            # Medium-specific handling remains to avoid matching substrings\n            if 'medium.com' in link_str:\n                if link_str.startswith('medium.com') or '.medium.com' in link_str:\n                    replaced = SITE_ALTS['medium.com'] + link_str[\n                        link_str.find('medium.com') + len('medium.com'):\n                    ]\n                else:\n                    replaced = link_str\n            else:\n                # If the description looks like a URL with scheme, replace only the host\n                if '://' in link_str:\n                    scheme, rest = link_str.split('://', 1)\n                    host, sep, path = rest.partition('/')\n                    # Drop common prefixes from host when swapping to a fully-qualified alt\n                    alt_parsed = urlparse.urlparse(alt)\n                    alt_host = alt_parsed.netloc if alt_parsed.netloc else alt.replace('https://', '').replace('http://', '')\n                    # If alt includes a scheme, prefer its host; otherwise use alt as host\n                    if alt_parsed.scheme:\n                        new_host = alt_host\n                    else:\n                        # When alt has no scheme, still replace entire host\n                        new_host = alt\n                    # Prevent replacing if host already equals target\n                    if host == new_host:\n                        replaced = link_str\n                    else:\n                        replaced = f\"{scheme}://{new_host}{sep}{path}\"\n                else:\n                    # No scheme in the text; include optional prefixes in replacement\n                    # Replace any leading www./m./mobile. + site with alt host (no scheme)\n                    alt_parsed = urlparse.urlparse(alt)\n                    alt_host = alt_parsed.netloc if alt_parsed.netloc else alt.replace('https://', '').replace('http://', '')\n                    # Build a pattern that includes optional prefixes for the specific site\n                    site_with_prefix = re.compile(rf'(?:(?:www|mobile|m)\\.)?{re.escape(site)}')\n                    replaced = site_with_prefix.sub(alt_host, link_str, count=1)\n\n            new_desc = BeautifulSoup(features='html.parser').new_tag('div')\n            new_desc.string = replaced\n            desc_node.replace_with(new_desc)\n\n    def view_image(self, soup) -> BeautifulSoup:\n        \"\"\"Parses image results from Google Images and rewrites them into the\n        lightweight Whoogle image results template.\n\n        Google now serves image results via the modern udm=2 endpoint, where\n        the raw HTML contains only placeholder thumbnails. The actual image\n        URLs live inside serialized data blobs in script tags. We extract that\n        data and pair it with the visible result cards.\n        \"\"\"\n\n        def _decode_url(url: str) -> str:\n            if not url:\n                return ''\n            # Decode common escaped characters found in the script blobs\n            return html.unescape(\n                url.replace('\\\\u003d', '=').replace('\\\\u0026', '&')\n            )\n\n        def _extract_image_data(modern_soup: BeautifulSoup) -> dict:\n            \"\"\"Extracts docid -> {img_url, img_tbn} from serialized scripts.\"\"\"\n            scripts_text = ' '.join(\n                script.string for script in modern_soup.find_all('script')\n                if script.string\n            )\n            pattern = re.compile(\n                r'\\[0,\"(?P<docid>[^\"]+)\",\\[\"(?P<thumb>https://encrypted-tbn[^\"]+)\"'\n                r'(?:,\\d+,\\d+)?\\],\\[\"(?P<full>https?://[^\"]+?)\"'\n                r'(?:,\\d+,\\d+)?\\]',\n                re.DOTALL\n            )\n            results_map = {}\n            for match in pattern.finditer(scripts_text):\n                docid = match.group('docid')\n                thumb = _decode_url(match.group('thumb'))\n                full = _decode_url(match.group('full'))\n                results_map[docid] = {\n                    'img_tbn': thumb,\n                    'img_url': full\n                }\n            return results_map\n\n        def _parse_modern_results(modern_soup: BeautifulSoup) -> list:\n            cards = modern_soup.find_all(\n                'div',\n                attrs={\n                    'data-attrid': 'images universal',\n                    'data-docid': True\n                }\n            )\n            if not cards:\n                return []\n\n            meta_map = _extract_image_data(modern_soup)\n            parsed = []\n            seen = set()\n\n            for card in cards:\n                docid = card.get('data-docid')\n                meta = meta_map.get(docid, {})\n                img_url = meta.get('img_url')\n                img_tbn = meta.get('img_tbn')\n\n                # Fall back to the inline src if we failed to map the docid\n                if not img_tbn:\n                    img_tag = card.find('img')\n                    if img_tag:\n                        candidate_src = img_tag.get('src')\n                        if candidate_src and candidate_src.startswith('http'):\n                            img_tbn = candidate_src\n\n                web_page = card.get('data-lpage') or ''\n                if not web_page:\n                    link = card.find('a', href=True)\n                    if link:\n                        web_page = link['href']\n\n                key = (img_url, img_tbn, web_page)\n                if not any(key) or key in seen:\n                    continue\n                seen.add(key)\n\n                parsed.append({\n                    'domain': urlparse.urlparse(web_page).netloc\n                    if web_page else '',\n                    'img_url': img_url or img_tbn or '',\n                    'web_page': web_page,\n                    'img_tbn': img_tbn or img_url or ''\n                })\n            return parsed\n\n        # Try parsing the modern (udm=2) layout first\n        modern_results = _parse_modern_results(soup)\n        if modern_results:\n            # TODO: Implement proper image pagination. Google images uses\n            # infinite scroll with `ijn` offsets; we need a clean,\n            # de-duplicated pagination strategy before exposing a Next link.\n            next_link = None\n            return BeautifulSoup(\n                render_template(\n                    'imageresults.html',\n                    length=len(modern_results),\n                    results=modern_results,\n                    view_label=\"View Image\",\n                    next_link=next_link\n                ),\n                features='html.parser'\n            )\n\n        # get some tags that are unchanged between mobile and pc versions\n        cor_suggested = soup.find_all('table', attrs={'class': \"By0U9\"})\n        next_pages = soup.find('table', attrs={'class': \"uZgmoc\"})\n\n        results = []\n        # find results div\n        results_div = soup.find('div', attrs={'class': \"nQvrDb\"})\n        # find all the results (if any)\n        results_all = []\n        if results_div:\n            results_all = results_div.find_all('div', attrs={'class': \"lIMUZd\"})\n\n        for item in results_all:\n            link = item.find('a', href=True)\n            if not link:\n                continue\n\n            urls = link['href'].split('&imgrefurl=')\n\n            # Skip urls that are not two-element lists\n            if len(urls) != 2:\n                continue\n\n            img_url = urlparse.unquote(urls[0].replace(\n                f'/{Endpoint.imgres}?imgurl=', ''))\n\n            try:\n                # Try to strip out only the necessary part of the web page link\n                web_page = urlparse.unquote(urls[1].split('&')[0])\n            except IndexError:\n                web_page = urlparse.unquote(urls[1])\n\n            img_tag = link.find('img')\n            if not img_tag:\n                continue\n\n            img_tbn = urlparse.unquote(\n                img_tag.get('src') or img_tag.get('data-src', '')\n            )\n\n            if not img_tbn:\n                continue\n\n            results.append({\n                'domain': urlparse.urlparse(web_page).netloc,\n                'img_url': img_url,\n                'web_page': web_page,\n                'img_tbn': img_tbn\n            })\n\n        soup = BeautifulSoup(render_template('imageresults.html',\n                                             length=len(results),\n                                             results=results,\n                                             view_label=\"View Image\"),\n                             features='html.parser')\n\n        # replace correction suggested by google object if exists\n        if len(cor_suggested):\n            suggested_tables = soup.find_all(\n                'table',\n                attrs={'class': \"By0U9\"}\n            )\n            if suggested_tables:\n                suggested_tables[0].replaceWith(cor_suggested[0])\n\n        # replace next page object at the bottom of the page, when present\n        next_page_tables = soup.find_all('table', attrs={'class': \"uZgmoc\"})\n        if next_pages and next_page_tables:\n            next_page_tables[0].replaceWith(next_pages)\n\n        # TODO: Reintroduce pagination for legacy image layout if needed.\n\n        return soup\n"
  },
  {
    "path": "app/models/__init__.py",
    "content": ""
  },
  {
    "path": "app/models/config.py",
    "content": "from inspect import Attribute\nfrom typing import Optional\nfrom app.utils.misc import read_config_bool\nfrom flask import current_app\nimport os\nfrom base64 import urlsafe_b64encode, urlsafe_b64decode\nfrom cryptography.fernet import Fernet\nimport hashlib\nimport brotli\nimport logging\nimport json\n\nimport cssutils\nfrom cssutils.css.cssstylesheet import CSSStyleSheet\nfrom cssutils.css.cssstylerule import CSSStyleRule\n\n# removes warnings from cssutils\ncssutils.log.setLevel(logging.CRITICAL)\n\n\ndef get_rule_for_selector(stylesheet: CSSStyleSheet,\n                          selector: str) -> Optional[CSSStyleRule]:\n    \"\"\"Search for a rule that matches a given selector in a stylesheet.\n\n    Args:\n        stylesheet (CSSStyleSheet) -- the stylesheet to search\n        selector (str) -- the selector to search for\n\n    Returns:\n        Optional[CSSStyleRule] -- the rule that matches the selector or None\n    \"\"\"\n    for rule in stylesheet.cssRules:\n        if hasattr(rule, \"selectorText\") and selector == rule.selectorText:\n            return rule\n    return None\n\n\nclass Config:\n    def __init__(self, **kwargs):\n        # User agent configuration - default to env_conf if environment variables exist, otherwise default\n        env_user_agent = os.getenv('WHOOGLE_USER_AGENT', '')\n        env_mobile_agent = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')\n        default_ua_option = 'env_conf' if (env_user_agent or env_mobile_agent) else 'default'\n        \n        self.user_agent = kwargs.get('user_agent', default_ua_option)\n        self.custom_user_agent = kwargs.get('custom_user_agent', '')\n        self.use_custom_user_agent = kwargs.get('use_custom_user_agent', False)\n        self.show_user_agent = read_config_bool('WHOOGLE_CONFIG_SHOW_USER_AGENT')\n\n        # Add user agent related keys to safe_keys\n        # Note: CSE credentials (cse_api_key, cse_id) are intentionally NOT included\n        # in safe_keys for security - they should not be shareable via URL\n        self.safe_keys = [\n            'lang_search',\n            'lang_interface',\n            'country',\n            'theme',\n            'alts',\n            'new_tab',\n            'view_image',\n            'block',\n            'safe',\n            'nojs',\n            'anon_view',\n            'preferences_encrypted',\n            'tbs',\n            'user_agent',\n            'custom_user_agent',\n            'use_custom_user_agent',\n            'show_user_agent'\n        ]\n\n        app_config = current_app.config\n        self.url = os.getenv('WHOOGLE_CONFIG_URL', '')\n        self.lang_search = os.getenv('WHOOGLE_CONFIG_SEARCH_LANGUAGE', '')\n        self.lang_interface = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '')\n        self.style_modified = os.getenv(\n            'WHOOGLE_CONFIG_STYLE', '')\n        self.block = os.getenv('WHOOGLE_CONFIG_BLOCK', '')\n        self.block_title = os.getenv('WHOOGLE_CONFIG_BLOCK_TITLE', '')\n        self.block_url = os.getenv('WHOOGLE_CONFIG_BLOCK_URL', '')\n        self.country = os.getenv('WHOOGLE_CONFIG_COUNTRY', '')\n        self.tbs = os.getenv('WHOOGLE_CONFIG_TIME_PERIOD', '')\n        self.theme = os.getenv('WHOOGLE_CONFIG_THEME', 'system')\n        self.safe = read_config_bool('WHOOGLE_CONFIG_SAFE')\n        self.alts = read_config_bool('WHOOGLE_CONFIG_ALTS')\n        self.nojs = read_config_bool('WHOOGLE_CONFIG_NOJS')\n        self.tor = read_config_bool('WHOOGLE_CONFIG_TOR')\n        self.near = os.getenv('WHOOGLE_CONFIG_NEAR', '')\n        self.new_tab = read_config_bool('WHOOGLE_CONFIG_NEW_TAB')\n        self.view_image = read_config_bool('WHOOGLE_CONFIG_VIEW_IMAGE')\n        self.get_only = read_config_bool('WHOOGLE_CONFIG_GET_ONLY')\n        self.anon_view = read_config_bool('WHOOGLE_CONFIG_ANON_VIEW')\n        self.preferences_encrypted = read_config_bool('WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED')\n        self.preferences_key = os.getenv('WHOOGLE_CONFIG_PREFERENCES_KEY', '')\n\n        # Google Custom Search Engine (CSE) BYOK settings\n        self.cse_api_key = os.getenv('WHOOGLE_CSE_API_KEY', '')\n        self.cse_id = os.getenv('WHOOGLE_CSE_ID', '')\n        self.use_cse = read_config_bool('WHOOGLE_USE_CSE')\n\n        self.accept_language = False\n\n        # Skip setting custom config if there isn't one\n        if kwargs:\n            mutable_attrs = self.get_mutable_attrs()\n            for attr in mutable_attrs:\n                if attr == 'show_user_agent':\n                    # Handle show_user_agent as boolean\n                    self.show_user_agent = bool(kwargs.get(attr))\n                elif attr in kwargs.keys():\n                    setattr(self, attr, kwargs[attr])\n                elif attr not in kwargs.keys() and mutable_attrs[attr] == bool:\n                    setattr(self, attr, False)\n\n    def __getitem__(self, name):\n        return getattr(self, name)\n\n    def __setitem__(self, name, value):\n        return setattr(self, name, value)\n\n    def __delitem__(self, name):\n        return delattr(self, name)\n\n    def __contains__(self, name):\n        return hasattr(self, name)\n\n    def get_mutable_attrs(self):\n        return {name: type(attr) for name, attr in self.__dict__.items()\n                if not name.startswith(\"__\")\n                and (type(attr) is bool or type(attr) is str)}\n\n    def get_attrs(self):\n        return {name: attr for name, attr in self.__dict__.items()\n                if not name.startswith(\"__\")\n                and (type(attr) is bool or type(attr) is str)}\n\n    @property\n    def style(self) -> str:\n        \"\"\"Returns the default style updated with specified modifications.\n\n        Returns:\n            str -- the new style\n        \"\"\"\n        vars_path = os.path.join(current_app.config['STATIC_FOLDER'], 'css/variables.css')\n        with open(vars_path, 'r', encoding='utf-8') as f:\n            style_sheet = cssutils.parseString(f.read())\n\n        modified_sheet = cssutils.parseString(self.style_modified)\n        for rule in modified_sheet:\n            rule_default = get_rule_for_selector(style_sheet,\n                                                 rule.selectorText)\n            # if modified rule is in default stylesheet, update it\n            if rule_default is not None:\n                # TODO: update this in a smarter way to handle :root better\n                # for now if we change a varialbe in :root all other default\n                # variables need to be also present\n                rule_default.style = rule.style\n            # else add the new rule to the default stylesheet\n            else:\n                style_sheet.add(rule)\n        return str(style_sheet.cssText, 'utf-8')\n\n    @property\n    def preferences(self) -> str:\n        # if encryption key is not set will uncheck preferences encryption\n        if self.preferences_encrypted:\n            self.preferences_encrypted = bool(self.preferences_key)\n\n        # add a tag for visibility if preferences token startswith 'e' it means\n        # the token is encrypted, 'u' means the token is unencrypted and can be\n        # used by other whoogle instances\n        encrypted_flag = \"e\" if self.preferences_encrypted else 'u'\n        preferences_digest = self._encode_preferences()\n        return f\"{encrypted_flag}{preferences_digest}\"\n\n    def is_safe_key(self, key) -> bool:\n        \"\"\"Establishes a group of config options that are safe to set\n        in the url.\n\n        Args:\n            key (str) -- the key to check against\n\n        Returns:\n            bool -- True/False depending on if the key is in the \"safe\"\n            array\n        \"\"\"\n\n        return key in self.safe_keys\n\n    def get_localization_lang(self):\n        \"\"\"Returns the correct language to use for localization, but falls\n        back to english if not set.\n\n        Returns:\n            str -- the localization language string\n        \"\"\"\n        if (self.lang_interface and\n                self.lang_interface in current_app.config['TRANSLATIONS']):\n            return self.lang_interface\n\n        return 'lang_en'\n\n    def from_params(self, params) -> 'Config':\n        \"\"\"Modify user config with search parameters. This is primarily\n        used for specifying configuration on a search-by-search basis on\n        public instances.\n\n        Args:\n            params -- the url arguments (can be any deemed safe by is_safe())\n\n        Returns:\n            Config -- a modified config object\n        \"\"\"\n        if 'preferences' in params:\n            params_new = self._decode_preferences(params['preferences'])\n            # if preferences leads to an empty dictionary it means preferences\n            # parameter was not decrypted successfully\n            if len(params_new):\n                params = params_new\n\n        for param_key in params.keys():\n            if not self.is_safe_key(param_key):\n                continue\n            param_val = params.get(param_key)\n\n            if param_val == 'off':\n                param_val = False\n            elif isinstance(param_val, str):\n                if param_val.isdigit():\n                    param_val = int(param_val)\n\n            self[param_key] = param_val\n        return self\n\n    def to_params(self, keys: list = []) -> str:\n        \"\"\"Generates a set of safe params for using in Whoogle URLs\n\n        Args:\n            keys (list) -- optional list of keys of URL parameters\n\n        Returns:\n            str -- a set of URL parameters\n        \"\"\"\n        if not len(keys):\n            keys = self.safe_keys\n\n        param_str = ''\n        for safe_key in keys:\n            if not self[safe_key]:\n                continue\n            param_str = param_str + f'&{safe_key}={self[safe_key]}'\n\n        return param_str\n\n    def _get_fernet_key(self, password: str) -> bytes:\n        \"\"\"Derive a Fernet-compatible key from a password using PBKDF2.\n        \n        Note: This uses a static salt for simplicity. This is a breaking change\n        from the previous MD5-based implementation. Existing encrypted preferences\n        will need to be re-encrypted.\n        \n        Args:\n            password: The password to derive the key from\n            \n        Returns:\n            bytes: A URL-safe base64 encoded 32-byte key suitable for Fernet\n        \"\"\"\n        # Use a static salt derived from app context\n        # In a production system, you'd want to store per-user salts\n        salt = b'whoogle-preferences-salt-v2'\n        \n        # Derive a 32-byte key using PBKDF2 with SHA256\n        # 100,000 iterations is a reasonable balance of security and performance\n        kdf_key = hashlib.pbkdf2_hmac(\n            'sha256',\n            password.encode('utf-8'),\n            salt,\n            100000,\n            dklen=32\n        )\n        \n        # Fernet requires a URL-safe base64 encoded key\n        return urlsafe_b64encode(kdf_key)\n\n    def _encode_preferences(self) -> str:\n        preferences_json = json.dumps(self.get_attrs()).encode()\n        compressed_preferences = brotli.compress(preferences_json)\n\n        if self.preferences_encrypted and self.preferences_key:\n            key = self._get_fernet_key(self.preferences_key)\n            encrypted_preferences = Fernet(key).encrypt(compressed_preferences)\n            compressed_preferences = brotli.compress(encrypted_preferences)\n\n        return urlsafe_b64encode(compressed_preferences).decode()\n\n    def _decode_preferences(self, preferences: str) -> dict:\n        mode = preferences[0]\n        preferences = preferences[1:]\n\n        try:\n            decoded_data = brotli.decompress(urlsafe_b64decode(preferences.encode() + b'=='))\n\n            if mode == 'e' and self.preferences_key:\n                # preferences are encrypted\n                key = self._get_fernet_key(self.preferences_key)\n                decrypted_data = Fernet(key).decrypt(decoded_data)\n                decoded_data = brotli.decompress(decrypted_data)\n\n            config = json.loads(decoded_data)\n        except Exception:\n            config = {}\n\n        return config\n\n"
  },
  {
    "path": "app/models/endpoint.py",
    "content": "from enum import Enum\n\n\nclass Endpoint(Enum):\n    autocomplete = 'autocomplete'\n    home = 'home'\n    healthz = 'healthz'\n    config = 'config'\n    opensearch = 'opensearch.xml'\n    search = 'search'\n    search_html = 'search.html'\n    url = 'url'\n    imgres = 'imgres'\n    element = 'element'\n    window = 'window'\n\n    def __str__(self):\n        return self.value\n\n    def in_path(self, path: str) -> bool:\n        return path.startswith(self.value) or \\\n               path.startswith(f'/{self.value}')\n"
  },
  {
    "path": "app/models/g_classes.py",
    "content": "from bs4 import BeautifulSoup\n\n\nclass GClasses:\n    \"\"\"A class for tracking obfuscated class names used in Google results that\n    are directly referenced in Whoogle's filtering code.\n\n    Note: Using these should be a last resort. It is always preferred to filter\n    results using structural cues instead of referencing class names, as these\n    are liable to change at any moment.\n    \"\"\"\n    main_tbm_tab = 'KP7LCb'\n    images_tbm_tab = 'n692Zd'\n    footer = 'TuS8Ad'\n    result_class_a = 'ZINbbc'\n    result_class_b = 'luh4td'\n    scroller_class = 'idg8be'\n    line_tag = 'BsXmcf'\n\n    result_classes = {\n        result_class_a: ['Gx5Zad'],\n        result_class_b: ['fP1Qef']\n    }\n\n    @classmethod\n    def replace_css_classes(cls, soup: BeautifulSoup) -> BeautifulSoup:\n        \"\"\"Replace updated Google classes with the original class names that\n        Whoogle relies on for styling.\n\n        Args:\n            soup: The result page as a BeautifulSoup object\n\n        Returns:\n            BeautifulSoup: The new BeautifulSoup\n        \"\"\"\n        result_divs = soup.find_all('div', {\n            'class': [_ for c in cls.result_classes.values() for _ in c]\n        })\n\n        for div in result_divs:\n            new_class = ' '.join(div['class'])\n            for key, val in cls.result_classes.items():\n                new_class = ' '.join(new_class.replace(_, key) for _ in val)\n            div['class'] = new_class.split(' ')\n        return soup\n\n    def __str__(self):\n        return self.value\n"
  },
  {
    "path": "app/request.py",
    "content": "from app.models.config import Config\nfrom app.utils.misc import read_config_bool\nfrom app.services.provider import get_http_client\nfrom app.utils.ua_generator import load_ua_pool, get_random_ua, DEFAULT_FALLBACK_UA\nfrom defusedxml import ElementTree as ET\nimport httpx\nimport urllib.parse as urlparse\nimport os\nfrom stem import Signal, SocketError\nfrom stem.connection import AuthenticationFailure\nfrom stem.control import Controller\nfrom stem.connection import authenticate_cookie, authenticate_password\n\nMAPS_URL = 'https://maps.google.com/maps'\nAUTOCOMPLETE_URL = ('https://suggestqueries.google.com/'\n                    'complete/search?client=toolbar&')\n\n# Valid query params\nVALID_PARAMS = ['tbs', 'tbm', 'start', 'near', 'source', 'nfpr']\n\n\nclass TorError(Exception):\n    \"\"\"Exception raised for errors in Tor requests.\n\n    Attributes:\n        message: a message describing the error that occurred\n        disable: optionally disables Tor in the user config (note:\n            this should only happen if the connection has been dropped\n            altogether).\n    \"\"\"\n\n    def __init__(self, message, disable=False) -> None:\n        self.message = message\n        self.disable = disable\n        super().__init__(message)\n\n\ndef send_tor_signal(signal: Signal) -> bool:\n    use_pass = read_config_bool('WHOOGLE_TOR_USE_PASS')\n\n    confloc = './misc/tor/control.conf'\n    # Check that the custom location of conf is real.\n    temp = os.getenv('WHOOGLE_TOR_CONF', '')\n    if os.path.isfile(temp):\n        confloc = temp\n\n    # Attempt to authenticate and send signal.\n    try:\n        with Controller.from_port(port=9051) as c:\n            if use_pass:\n                with open(confloc, \"r\") as conf:\n                    # Scan for the last line of the file.\n                    for line in conf:\n                        pass\n                    secret = line.strip('\\n')\n                authenticate_password(c, password=secret)\n            else:\n                cookie_path = '/var/lib/tor/control_auth_cookie'\n                authenticate_cookie(c, cookie_path=cookie_path)\n            c.signal(signal)\n            os.environ['TOR_AVAILABLE'] = '1'\n            return True\n    except (SocketError, AuthenticationFailure,\n            ConnectionRefusedError, ConnectionError):\n        # TODO: Handle Tor authentication (password and cookie)\n        os.environ['TOR_AVAILABLE'] = '0'\n\n    return False\n\n\ndef gen_user_agent(config, is_mobile) -> str:\n    # If using custom user agent, return the custom string\n    if config.user_agent == 'custom' and config.custom_user_agent:\n        return config.custom_user_agent\n\n    # If using environment configuration\n    if config.user_agent == 'env_conf':\n        if is_mobile:\n            env_ua = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')\n            if env_ua:\n                return env_ua\n        else:\n            env_ua = os.getenv('WHOOGLE_USER_AGENT', '')\n            if env_ua:\n                return env_ua\n        # If env vars are not set, fall back to Opera UA\n        return DEFAULT_FALLBACK_UA\n\n    # If using default user agent - use auto-generated Opera UA pool\n    if config.user_agent == 'default':\n        try:\n            # Try to load UA pool from cache (lazy loading if not in app.config)\n            # First check if we have access to Flask app context\n            try:\n                from flask import current_app\n                if hasattr(current_app, 'config') and 'UA_POOL' in current_app.config:\n                    ua_pool = current_app.config['UA_POOL']\n                else:\n                    # Fall back to loading from disk\n                    raise ImportError(\"UA_POOL not in app config\")\n            except (ImportError, RuntimeError):\n                # No Flask context available or UA_POOL not in config, load from disk\n                config_path = os.environ.get('CONFIG_VOLUME', \n                                            os.path.join(os.path.dirname(os.path.abspath(__file__)), \n                                                        'static', 'config'))\n                cache_path = os.path.join(config_path, 'ua_cache.json')\n                ua_pool = load_ua_pool(cache_path, count=10)\n            \n            return get_random_ua(ua_pool)\n        except Exception as e:\n            # If anything goes wrong, fall back to default Opera UA\n            print(f\"Warning: Could not load UA pool, using fallback Opera UA: {e}\")\n            return DEFAULT_FALLBACK_UA\n\n    # Fallback for backwards compatibility (old configs or invalid user_agent values)\n    return DEFAULT_FALLBACK_UA\n\n\ndef gen_query(query, args, config) -> str:\n    param_dict = {key: '' for key in VALID_PARAMS}\n\n    # Use :past(hour/day/week/month/year) if available\n    # example search \"new restaurants :past month\"\n    lang = ''\n    if ':past' in query and 'tbs' not in args:\n        time_range = str.strip(query.split(':past', 1)[-1])\n        param_dict['tbs'] = '&tbs=' + ('qdr:' + str.lower(time_range[0]))\n    elif 'tbs' in args or 'tbs' in config:\n        result_tbs = args.get('tbs') if 'tbs' in args else config['tbs']\n        param_dict['tbs'] = '&tbs=' + result_tbs\n\n        # Occasionally the 'tbs' param provided by google also contains a\n        # field for 'lr', but formatted strangely. This is a rough solution\n        # for this.\n        #\n        # Example:\n        # &tbs=qdr:h,lr:lang_1pl\n        # -- the lr param needs to be extracted and remove the leading '1'\n        result_params = [_ for _ in result_tbs.split(',') if 'lr:' in _]\n        if len(result_params) > 0:\n            result_param = result_params[0]\n            lang = result_param[result_param.find('lr:') + 3:len(result_param)]\n\n    # Ensure search query is parsable\n    query = urlparse.quote(query)\n\n    # Pass along type of results (news, images, books, etc)\n    if 'tbm' in args:\n        param_dict['tbm'] = '&tbm=' + args.get('tbm')\n        # Google Images now expects the modern udm=2 layout; force it when\n        # requesting images to avoid redirects to the new AI/text layout.\n        if args.get('tbm') == 'isch' and 'udm' not in args:\n            param_dict['udm'] = '&udm=2'\n\n    # Get results page start value (10 per page, ie page 2 start val = 20)\n    if 'start' in args:\n        param_dict['start'] = '&start=' + args.get('start')\n\n    # Search for results near a particular city, if available\n    if config.near:\n        param_dict['near'] = '&near=' + urlparse.quote(config.near)\n\n    # Set language for results (lr) if source isn't set, otherwise use the\n    # result language param provided in the results\n    if 'source' in args:\n        param_dict['source'] = '&source=' + args.get('source')\n        param_dict['lr'] = ('&lr=' + ''.join(\n            [_ for _ in lang if not _.isdigit()]\n        )) if lang else ''\n    else:\n        param_dict['lr'] = (\n            '&lr=' + config.lang_search\n        ) if config.lang_search else ''\n\n    # 'nfpr' defines the exclusion of results from an auto-corrected query\n    if 'nfpr' in args:\n        param_dict['nfpr'] = '&nfpr=' + args.get('nfpr')\n\n    # 'chips' is used in image tabs to pass the optional 'filter' to add to the\n    # given search term\n    if 'chips' in args:\n        param_dict['chips'] = '&chips=' + args.get('chips')\n\n    param_dict['gl'] = (\n        '&gl=' + config.country\n    ) if config.country else ''\n    param_dict['hl'] = (\n        '&hl=' + config.lang_interface.replace('lang_', '')\n    ) if config.lang_interface else ''\n    param_dict['safe'] = '&safe=' + ('active' if config.safe else 'off')\n\n    # Block all sites specified in the user config\n    unquoted_query = urlparse.unquote(query)\n    for blocked_site in config.block.replace(' ', '').split(','):\n        if not blocked_site:\n            continue\n        block = (' -site:' + blocked_site)\n        query += block if block not in unquoted_query else ''\n\n    for val in param_dict.values():\n        if not val:\n            continue\n        query += val\n\n    return query\n\n\nclass Request:\n    \"\"\"Class used for handling all outbound requests, including search queries,\n    search suggestions, and loading of external content (images, audio, etc).\n\n    Attributes:\n        normal_ua: the user's current user agent\n        root_path: the root path of the whoogle instance\n        config: the user's current whoogle configuration\n    \"\"\"\n\n    def __init__(self, normal_ua, root_path, config: Config, http_client=None):\n        self.search_url = 'https://www.google.com/search?gbv=1&q='\n        # Google Images rejects the lightweight gbv=1 interface. Use the\n        # modern udm=2 entrypoint specifically for image searches to avoid the\n        # \"update your browser\" interstitial.\n        self.image_search_url = 'https://www.google.com/search?udm=2&q='\n        # Optionally send heartbeat to Tor to determine availability\n        # Only when Tor is enabled in config to avoid unnecessary socket usage\n        if config.tor:\n            send_tor_signal(Signal.HEARTBEAT)\n\n        self.language = config.lang_search if config.lang_search else ''\n        self.country = config.country if config.country else ''\n\n        # For setting Accept-language Header\n        self.lang_interface = ''\n        if config.accept_language:\n            self.lang_interface = config.lang_interface\n\n        self.mobile = bool(normal_ua) and ('Android' in normal_ua\n                                           or 'iPhone' in normal_ua)\n\n        # Generate user agent based on config\n        self.modified_user_agent = gen_user_agent(config, self.mobile)\n        if not self.mobile:\n            self.modified_user_agent_mobile = gen_user_agent(config, True)\n\n        # Dedicated modern UA to use when Google rejects legacy ones (e.g. Images)\n        self.image_user_agent = (\n            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '\n            'AppleWebKit/537.36 (KHTML, like Gecko) '\n            'Chrome/127.0.0.0 Safari/537.36'\n        )\n\n        # Set up proxy configuration\n        proxy_path = os.environ.get('WHOOGLE_PROXY_LOC', '')\n        if proxy_path:\n            proxy_type = os.environ.get('WHOOGLE_PROXY_TYPE', '')\n            proxy_user = os.environ.get('WHOOGLE_PROXY_USER', '')\n            proxy_pass = os.environ.get('WHOOGLE_PROXY_PASS', '')\n            auth_str = ''\n            if proxy_user:\n                auth_str = f'{proxy_user}:{proxy_pass}@'\n\n            proxy_str = f'{proxy_type}://{auth_str}{proxy_path}'\n            self.proxies = {\n                'https': proxy_str,\n                'http': proxy_str\n            }\n        else:\n            self.proxies = {\n                'http': 'socks5://127.0.0.1:9050',\n                'https': 'socks5://127.0.0.1:9050'\n            } if config.tor else {}\n\n        self.tor = config.tor\n        self.tor_valid = False\n        self.root_path = root_path\n        # Initialize HTTP client (shared per proxies)\n        self.http_client = http_client or get_http_client(self.proxies)\n\n    def __getitem__(self, name):\n        return getattr(self, name)\n\n    def autocomplete(self, query) -> list:\n        \"\"\"Sends a query to Google's search suggestion service\n\n        Args:\n            query: The in-progress query to send\n\n        Returns:\n            list: The list of matches for possible search suggestions\n\n        \"\"\"\n        # Check if autocomplete is disabled via environment variable\n        if os.environ.get('WHOOGLE_AUTOCOMPLETE', '1') == '0':\n            return []\n            \n        try:\n            ac_query = dict(q=query)\n            if self.language:\n                ac_query['lr'] = self.language\n            if self.country:\n                ac_query['gl'] = self.country\n            if self.lang_interface:\n                ac_query['hl'] = self.lang_interface\n\n            response = self.send(base_url=AUTOCOMPLETE_URL,\n                                 query=urlparse.urlencode(ac_query)).text\n\n            if not response:\n                return []\n\n            try:\n                root = ET.fromstring(response)\n                return [_.attrib['data'] for _ in\n                        root.findall('.//suggestion/[@data]')]\n            except ET.ParseError:\n                # Malformed XML response\n                return []\n        except Exception as e:\n            # Log the error but don't crash - autocomplete is non-essential\n            print(f\"Autocomplete error: {str(e)}\")\n            return []\n\n    def send(self, base_url='', query='', attempt=0,\n             force_mobile=False, user_agent=''):\n        \"\"\"Sends an outbound request to a URL. Optionally sends the request\n        using Tor, if enabled by the user.\n\n        Args:\n            base_url: The URL to use in the request\n            query: The optional query string for the request\n            attempt: The number of attempts made for the request\n                (used for cycling through Tor identities, if enabled)\n            force_mobile: Optional flag to enable a mobile user agent\n                (used for fetching full size images in search results)\n\n        Returns:\n            Response: The Response object returned by the requests call\n\n        \"\"\"\n        use_client_user_agent = int(os.environ.get('WHOOGLE_USE_CLIENT_USER_AGENT', '0'))\n        if user_agent and use_client_user_agent == 1:\n            modified_user_agent = user_agent\n        else:\n            if force_mobile and not self.mobile:\n                modified_user_agent = self.modified_user_agent_mobile\n            else:\n                modified_user_agent = self.modified_user_agent\n\n        # Some Google endpoints (notably Images) now refuse legacy user agents.\n        # If an image search is detected and the generated UA isn't Chromium-\n        # like, retry with a modern Chrome string to avoid the \"update your\n        # browser\" interstitial.\n        if (('tbm=isch' in query) or ('udm=2' in query)) and 'Chrome' not in modified_user_agent:\n            modified_user_agent = self.image_user_agent\n\n        headers = {\n            'User-Agent': modified_user_agent,\n            'Accept': ('text/html,application/xhtml+xml,application/xml;'\n                       'q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8'),\n            'Accept-Language': 'en-US,en;q=0.9',\n            'Accept-Encoding': 'gzip, deflate, br',\n            'Connection': 'keep-alive',\n            'Cache-Control': 'max-age=0',\n            'Pragma': 'no-cache',\n            'Upgrade-Insecure-Requests': '1',\n            'Sec-Fetch-Site': 'none',\n            'Sec-Fetch-Mode': 'navigate',\n            'Sec-Fetch-User': '?1',\n            'Sec-Fetch-Dest': 'document'\n        }\n        # Only attach client hints when using a Chromium-like user agent to\n        # avoid sending conflicting information that can trigger unsupported\n        # browser pages.\n        if 'Chrome' in headers['User-Agent']:\n            headers.update({\n                'Sec-CH-UA': (\n                    '\"Not/A)Brand\";v=\"8\", '\n                    '\"Chromium\";v=\"127\", '\n                    '\"Google Chrome\";v=\"127\"'\n                ),\n                'Sec-CH-UA-Mobile': '?0',\n                'Sec-CH-UA-Platform': '\"Windows\"'\n            })\n\n \n        # Add Accept-Language header tied to the current config if requested\n        if self.lang_interface:\n            headers['Accept-Language'] = (\n                self.lang_interface.replace('lang_', '') + ';q=1.0'\n            )\n\n        # Consent cookies keep Google from showing the interstitial consent wall\n        consent_cookies = {\n            'CONSENT': 'PENDING+987',\n            'SOCS': 'CAESHAgBEhIaAB'\n        }\n\n        # Validate Tor conn and request new identity if the last one failed\n        if self.tor and not send_tor_signal(\n                Signal.NEWNYM if attempt > 0 else Signal.HEARTBEAT):\n            raise TorError(\n                \"Tor was previously enabled, but the connection has been \"\n                \"dropped. Please check your Tor configuration and try again.\",\n                disable=True)\n\n        # Make sure that the tor connection is valid, if enabled\n        if self.tor:\n            try:\n                tor_check = self.http_client.get('https://check.torproject.org/',\n                                                 headers=headers,\n                                                 retries=1)\n                self.tor_valid = 'Congratulations' in tor_check.text\n\n                if not self.tor_valid:\n                    raise TorError(\n                        \"Tor connection succeeded, but the connection could \"\n                        \"not be validated by torproject.org\",\n                        disable=True)\n            except httpx.RequestError:\n                raise TorError(\n                    \"Error raised during Tor connection validation\",\n                    disable=True)\n\n        search_base = base_url or self.search_url\n        if not base_url and ('tbm=isch' in query or 'udm=2' in query):\n            search_base = self.image_search_url\n\n        try:\n            response = self.http_client.get(\n                search_base + query,\n                headers=headers,\n                cookies=consent_cookies)\n        except httpx.HTTPError as e:\n            raise\n\n        # Retry query with new identity if using Tor (max 10 attempts)\n        if 'form id=\"captcha-form\"' in response.text and self.tor:\n            attempt += 1\n            if attempt > 10:\n                raise TorError(\"Tor query failed -- max attempts exceeded 10\")\n            return self.send(search_base, query, attempt)\n\n        return response\n"
  },
  {
    "path": "app/routes.py",
    "content": "import argparse\nimport base64\nimport io\nimport json\nimport os\nimport re\nimport urllib.parse as urlparse\nimport uuid\nimport validators\nimport sys\nimport traceback\nfrom datetime import datetime, timedelta\nfrom functools import wraps\n\nimport waitress\nfrom app import app\nfrom app.models.config import Config\nfrom app.models.endpoint import Endpoint\nfrom app.request import Request, TorError\nfrom app.services.cse_client import CSEException\nfrom app.utils.bangs import suggest_bang, resolve_bang\nfrom app.utils.misc import empty_gif, placeholder_img, get_proxy_host_url, \\\n    fetch_favicon\nfrom app.filter import Filter\nfrom app.utils.misc import read_config_bool, get_client_ip, get_request_url, \\\n    check_for_update, encrypt_string\nfrom app.utils.widgets import *\nfrom app.utils.results import bold_search_terms,\\\n    add_currency_card, check_currency, get_tabs_content\nfrom app.utils.search import Search, needs_https, has_captcha\nfrom app.utils.session import valid_user_session\nfrom bs4 import BeautifulSoup as bsoup\nfrom flask import jsonify, make_response, request, redirect, render_template, \\\n    send_file, session, url_for, g\nimport httpx\nfrom cryptography.fernet import Fernet, InvalidToken\nfrom cryptography.exceptions import InvalidSignature\nfrom werkzeug.datastructures import MultiDict\n\nac_var = 'WHOOGLE_AUTOCOMPLETE'\nautocomplete_enabled = os.getenv(ac_var, '1')\n\n\ndef get_search_name(tbm):\n    for tab in app.config['HEADER_TABS'].values():\n        if tab['tbm'] == tbm:\n            return tab['name']\n\n\ndef auth_required(f):\n    @wraps(f)\n    def decorated(*args, **kwargs):\n        # do not ask password if cookies already present\n        if (\n            valid_user_session(session)\n            and 'cookies_disabled' not in request.args\n            and session['auth']\n        ):\n            return f(*args, **kwargs)\n\n        auth = request.authorization\n\n        # Skip if username/password not set\n        whoogle_user = os.getenv('WHOOGLE_USER', '')\n        whoogle_pass = os.getenv('WHOOGLE_PASS', '')\n        if (not whoogle_user or not whoogle_pass) or (\n                auth\n                and whoogle_user == auth.username\n                and whoogle_pass == auth.password):\n            session['auth'] = True\n            return f(*args, **kwargs)\n        else:\n            return make_response('Not logged in', 401, {\n                'WWW-Authenticate': 'Basic realm=\"Login Required\"'})\n\n    return decorated\n\n\ndef session_required(f):\n    @wraps(f)\n    def decorated(*args, **kwargs):\n        if not valid_user_session(session):\n            session.pop('_permanent', None)\n\n        # Note: This sets all requests to use the encryption key determined per\n        # instance on app init. This can be updated in the future to use a key\n        # that is unique for their session (session['key']) but this should use\n        # a config setting to enable the session based key. Otherwise there can\n        # be problems with searches performed by users with cookies blocked if\n        # a session based key is always used.\n        g.session_key = app.enc_key\n\n        # Clear out old sessions\n        invalid_sessions = []\n        for user_session in os.listdir(app.config['SESSION_FILE_DIR']):\n            file_path = os.path.join(\n                app.config['SESSION_FILE_DIR'],\n                user_session)\n\n            try:\n                # Ignore files that are larger than the max session file size\n                if os.path.getsize(file_path) > app.config['MAX_SESSION_SIZE']:\n                    continue\n\n                with open(file_path, 'r', encoding='utf-8') as session_file:\n                    data = json.load(session_file)\n                    if isinstance(data, dict) and 'valid' in data:\n                        continue\n                    invalid_sessions.append(file_path)\n            except Exception:\n                # Broad exception handling here due to how instances installed\n                # with pip seem to have issues storing unrelated files in the\n                # same directory as sessions\n                pass\n\n        for invalid_session in invalid_sessions:\n            try:\n                os.remove(invalid_session)\n            except FileNotFoundError:\n                # Don't throw error if the invalid session has been removed\n                pass\n\n        return f(*args, **kwargs)\n\n    return decorated\n\n\n@app.before_request\ndef before_request_func():\n    session.permanent = True\n\n    # Check for latest version if needed\n    now = datetime.now()\n    needs_update_check = now - timedelta(hours=24) > app.config['LAST_UPDATE_CHECK']\n    if read_config_bool('WHOOGLE_UPDATE_CHECK', True) and needs_update_check:\n        app.config['LAST_UPDATE_CHECK'] = now\n        app.config['HAS_UPDATE'] = check_for_update(\n            app.config['RELEASES_URL'],\n            app.config['VERSION_NUMBER'])\n\n    g.request_params = (\n        request.args if request.method == 'GET' else request.form\n    )\n\n    default_config = json.load(open(app.config['DEFAULT_CONFIG'])) \\\n        if os.path.exists(app.config['DEFAULT_CONFIG']) else {}\n\n    # Generate session values for user if unavailable\n    if not valid_user_session(session):\n        session['config'] = default_config\n        session['uuid'] = str(uuid.uuid4())\n        session['key'] = app.enc_key\n        session['auth'] = False\n\n    # Establish config values per user session\n    g.user_config = Config(**session['config'])\n\n    # Update user config if specified in search args\n    g.user_config = g.user_config.from_params(g.request_params)\n\n    if not g.user_config.url:\n        g.user_config.url = get_request_url(request.url_root)\n\n    g.user_request = Request(\n        request.headers.get('User-Agent'),\n        get_request_url(request.url_root),\n        config=g.user_config\n    )\n\n    g.app_location = g.user_config.url\n\n\n@app.after_request\ndef after_request_func(resp):\n    resp.headers['X-Content-Type-Options'] = 'nosniff'\n    resp.headers['X-Frame-Options'] = 'DENY'\n    resp.headers['Cache-Control'] = 'max-age=86400'\n    \n    # Security headers\n    resp.headers['Referrer-Policy'] = 'no-referrer'\n    resp.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'\n    \n    # Add HSTS header if HTTPS is enabled\n    if os.environ.get('HTTPS_ONLY', False):\n        resp.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'\n\n    # Enable CSP by default (can be disabled via env var)\n    if os.getenv('WHOOGLE_CSP', '1') != '0':\n        resp.headers['Content-Security-Policy'] = app.config['CSP']\n        if os.environ.get('HTTPS_ONLY', False):\n            resp.headers['Content-Security-Policy'] += \\\n                ' upgrade-insecure-requests'\n\n    return resp\n\n\n@app.errorhandler(404)\ndef unknown_page(e):\n    app.logger.warning(e)\n    return redirect(g.app_location)\n\n\n@app.route(f'/{Endpoint.healthz}', methods=['GET'])\ndef healthz():\n    return ''\n\n\n@app.route('/', methods=['GET'])\n@app.route(f'/{Endpoint.home}', methods=['GET'])\n@auth_required\ndef index():\n    # Redirect if an error was raised\n    if 'error_message' in session and session['error_message']:\n        error_message = session['error_message']\n        session['error_message'] = ''\n        return render_template('error.html', error_message=error_message)\n\n    return render_template('index.html',\n                           has_update=app.config['HAS_UPDATE'],\n                           languages=app.config['LANGUAGES'],\n                           countries=app.config['COUNTRIES'],\n                           time_periods=app.config['TIME_PERIODS'],\n                           themes=app.config['THEMES'],\n                           autocomplete_enabled=autocomplete_enabled,\n                           translation=app.config['TRANSLATIONS'][\n                               g.user_config.get_localization_lang()\n                           ],\n                           logo=render_template('logo.html'),\n                           config_disabled=(\n                                   app.config['CONFIG_DISABLE'] or\n                                   not valid_user_session(session)),\n                           config=g.user_config,\n                           tor_available=int(os.environ.get('TOR_AVAILABLE')),\n                           version_number=app.config['VERSION_NUMBER'])\n\n\n@app.route(f'/{Endpoint.opensearch}', methods=['GET'])\ndef opensearch():\n    opensearch_url = g.app_location\n    if opensearch_url.endswith('/'):\n        opensearch_url = opensearch_url[:-1]\n\n    # Enforce https for opensearch template\n    if needs_https(opensearch_url):\n        opensearch_url = opensearch_url.replace('http://', 'https://', 1)\n\n    get_only = g.user_config.get_only or 'Chrome' in request.headers.get(\n        'User-Agent')\n\n    return render_template(\n        'opensearch.xml',\n        main_url=opensearch_url,\n        request_type='' if get_only else 'method=\"post\"',\n        search_type=request.args.get('tbm'),\n        search_name=get_search_name(request.args.get('tbm'))\n    ), 200, {'Content-Type': 'application/xml'}\n\n\n@app.route(f'/{Endpoint.search_html}', methods=['GET'])\ndef search_html():\n    search_url = g.app_location\n    if search_url.endswith('/'):\n        search_url = search_url[:-1]\n    return render_template('search.html', url=search_url)\n\n\n@app.route(f'/{Endpoint.autocomplete}', methods=['GET', 'POST'])\ndef autocomplete():\n    if os.getenv(ac_var) and not read_config_bool(ac_var):\n        return jsonify({})\n\n    q = g.request_params.get('q')\n    if not q:\n        # FF will occasionally (incorrectly) send the q field without a\n        # mimetype in the format \"b'q=<query>'\" through the request.data field\n        q = str(request.data).replace('q=', '')\n\n    # Search bangs if the query begins with \"!\", but not \"! \" (feeling lucky)\n    if q.startswith('!') and len(q) > 1 and not q.startswith('! '):\n        return jsonify([q, suggest_bang(q)])\n\n    if not q and not request.data:\n        return jsonify({'?': []})\n    elif request.data:\n        q = urlparse.unquote_plus(\n            request.data.decode('utf-8').replace('q=', ''))\n\n    # Return a list of suggestions for the query\n    #\n    # Note: If Tor is enabled, this returns nothing, as the request is\n    # almost always rejected\n    # Also check if autocomplete is disabled globally\n    autocomplete_enabled = os.environ.get('WHOOGLE_AUTOCOMPLETE', '1') != '0'\n    return jsonify([\n        q,\n        g.user_request.autocomplete(q) if (not g.user_config.tor and autocomplete_enabled) else []\n    ])\n\ndef clean_text_spacing(text: str) -> str:\n    \"\"\"Clean up text spacing issues from HTML extraction.\n    \n    Args:\n        text: Text extracted from HTML that may have spacing issues\n        \n    Returns:\n        Cleaned text with proper spacing\n    \"\"\"\n    if not text:\n        return text\n    \n    # Normalize multiple spaces to single space\n    text = re.sub(r'\\s+', ' ', text)\n    \n    # Fix domain names: remove space before period followed by domain extension\n    # Examples: \"weather .com\" -> \"weather.com\", \"example .org\" -> \"example.org\"\n    text = re.sub(r'\\s+\\.([a-zA-Z]{2,})\\b', r'.\\1', text)\n    \n    # Fix www/http/https patterns\n    # Examples: \"www .example\" -> \"www.example\"\n    text = re.sub(r'\\b(www|http|https)\\s+\\.', r'\\1.', text)\n    \n    # Fix spaces before common punctuation\n    text = re.sub(r'\\s+([,;:])', r'\\1', text)\n    \n    # Strip leading/trailing whitespace\n    return text.strip()\n\n\n@app.route(f'/{Endpoint.search}', methods=['GET', 'POST'])\n@session_required\n@auth_required\ndef search():\n    if request.method == 'POST':\n        # Redirect as a GET request with an encrypted query\n        post_data = MultiDict(request.form)\n        post_data['q'] = encrypt_string(g.session_key, post_data['q'])\n        get_req_str = urlparse.urlencode(post_data)\n        return redirect(url_for('.search') + '?' + get_req_str)\n\n    search_util = Search(request, g.user_config, g.session_key, user_request=g.user_request)\n    query = search_util.new_search_query()\n\n    bang = resolve_bang(query)\n    if bang:\n        return redirect(bang)\n\n    # Redirect to home if invalid/blank search\n    if not query:\n        return redirect(url_for('.index'))\n\n    # Generate response and number of external elements from the page\n    try:\n        response = search_util.generate_response()\n    except TorError as e:\n        session['error_message'] = e.message + (\n            \"\\\\n\\\\nTor config is now disabled!\" if e.disable else \"\")\n        session['config']['tor'] = False if e.disable else session['config'][\n            'tor']\n        return redirect(url_for('.index'))\n    except CSEException as e:\n        localization_lang = g.user_config.get_localization_lang()\n        translation = app.config['TRANSLATIONS'][localization_lang]\n        wants_json = (\n            request.args.get('format') == 'json' or\n            'application/json' in request.headers.get('Accept', '') or\n            'application/*+json' in request.headers.get('Accept', '')\n        )\n        error_msg = f\"Custom Search API Error: {e.message}\"\n        if e.is_quota_error:\n            error_msg = (\"Google Custom Search API quota exceeded. \"\n                        \"Free tier allows 100 queries/day. \"\n                        \"Wait until midnight PT or disable CSE in settings.\")\n        if wants_json:\n            return jsonify({\n                'error': True,\n                'error_message': error_msg,\n                'query': urlparse.unquote(query)\n            }), e.code\n        return render_template(\n            'error.html',\n            error_message=error_msg,\n            translation=translation,\n            config=g.user_config), e.code\n\n    wants_json = (\n        request.args.get('format') == 'json' or\n        'application/json' in request.headers.get('Accept', '') or\n        'application/*+json' in request.headers.get('Accept', '')\n    )\n\n    if search_util.feeling_lucky:\n        if wants_json:\n            return jsonify({'redirect': response}), 303\n        return redirect(response, code=303)\n\n    # If the user is attempting to translate a string, determine the correct\n    # string for formatting the lingva.ml url\n    localization_lang = g.user_config.get_localization_lang()\n    translation = app.config['TRANSLATIONS'][localization_lang]\n    translate_to = localization_lang.replace('lang_', '')\n\n    # removing st-card to only use whoogle time selector\n    soup = bsoup(response, \"html.parser\");\n    for x in soup.find_all(attrs={\"id\": \"st-card\"}):\n        x.replace_with(\"\")\n\n    response = str(soup)\n\n    # Return 503 if temporarily blocked by captcha\n    if has_captcha(str(response)):\n        app.logger.error('503 (CAPTCHA)')\n        fallback_engine = os.environ.get('WHOOGLE_FALLBACK_ENGINE_URL', '')\n        if (fallback_engine):\n            if wants_json:\n                return jsonify({'redirect': fallback_engine + query}), 302\n            return redirect(fallback_engine + query)\n        \n        if wants_json:\n            return jsonify({\n                'blocked': True,\n                'error_message': translation['ratelimit'],\n                'query': urlparse.unquote(query)\n            }), 503\n        else:\n            return render_template(\n                'error.html',\n                blocked=True,\n                error_message=translation['ratelimit'],\n                translation=translation,\n                farside='https://farside.link',\n                config=g.user_config,\n                query=urlparse.unquote(query),\n                params=g.user_config.to_params(keys=['preferences'])), 503\n\n    response = bold_search_terms(response, query)\n\n    # check for widgets and add if requested\n    if search_util.widget != '':\n        html_soup = bsoup(str(response), 'html.parser')\n        if search_util.widget == 'ip':\n            response = add_ip_card(html_soup, get_client_ip(request))\n        elif search_util.widget == 'calculator' and not 'nojs' in request.args:\n            response = add_calculator_card(html_soup)\n\n    # Update tabs content (fallback to the raw query if full_query isn't set)\n    full_query_val = getattr(search_util, 'full_query', query)\n    tabs = get_tabs_content(app.config['HEADER_TABS'],\n                            full_query_val,\n                            search_util.search_type,\n                            g.user_config.preferences,\n                            translation)\n    \n    # Filter out unsupported tabs when CSE is enabled\n    # CSE only supports web (all) and image search, not videos/news\n    use_cse = (\n        g.user_config.use_cse and \n        g.user_config.cse_api_key and \n        g.user_config.cse_id\n    )\n    if use_cse:\n        tabs = {k: v for k, v in tabs.items() if k in ['all', 'images', 'maps']}\n\n    # Feature to display currency_card\n    # Since this is determined by more than just the\n    # query is it not defined as a standard widget\n    conversion = check_currency(str(response))\n    if conversion:\n        html_soup = bsoup(str(response), 'html.parser')\n        response = add_currency_card(html_soup, conversion)\n\n    preferences = g.user_config.preferences\n    home_url = f\"home?preferences={preferences}\" if preferences else \"home\"\n    cleanresponse = str(response).replace(\"andlt;\",\"&lt;\").replace(\"andgt;\",\"&gt;\")\n\n    if wants_json:\n        # Build a parsable JSON from the filtered soup\n        json_soup = bsoup(str(response), 'html.parser')\n        results = []\n        seen = set()\n        \n        # Find all result containers (using known result classes)\n        result_divs = json_soup.find_all('div', class_=['ZINbbc', 'ezO2md'])\n        \n        if result_divs:\n            # Process structured Google results with container divs\n            for div in result_divs:\n                # Find the first valid link in this result container\n                link = None\n                for a in div.find_all('a', href=True):\n                    if a['href'].startswith('http'):\n                        link = a\n                        break\n                \n                if not link:\n                    continue\n                    \n                href = link['href']\n                if href in seen:\n                    continue\n                \n                # Get all text from the result container, not just the link\n                text = clean_text_spacing(div.get_text(separator=' ', strip=True))\n                if not text:\n                    continue\n                \n                # Extract title and content separately\n                # Title is typically in an h3 tag, CVA68e span, or the main link text\n                title = ''\n                # First try h3 tag\n                h3_tag = div.find('h3')\n                if h3_tag:\n                    title = clean_text_spacing(h3_tag.get_text(separator=' ', strip=True))\n                else:\n                    # Try CVA68e class (common title class in Google results)\n                    title_span = div.find('span', class_='CVA68e')\n                    if title_span:\n                        title = clean_text_spacing(title_span.get_text(separator=' ', strip=True))\n                    elif link:\n                        # Fallback to link text, but exclude URL breadcrumb\n                        title = clean_text_spacing(link.get_text(separator=' ', strip=True))\n                \n                # Content is the description/snippet text\n                # Look for description/snippet elements\n                content = ''\n                # Common classes for snippets/descriptions in Google results\n                snippet_selectors = [\n                    {'class_': 'VwiC3b'},   # Standard snippet\n                    {'class_': 'FrIlee'},   # Alternative snippet class (common in current Google)\n                    {'class_': 's'},        # Another snippet class\n                    {'class_': 'st'},       # Legacy snippet class\n                ]\n                \n                for selector in snippet_selectors:\n                    snippet_elem = div.find('span', selector) or div.find('div', selector)\n                    if snippet_elem:\n                        # Get text but exclude any nested links (like \"Related searches\")\n                        content = clean_text_spacing(snippet_elem.get_text(separator=' ', strip=True))\n                        # Only use if it's substantial content (not just the URL breadcrumb)\n                        if content and not content.startswith('www.') and '›' not in content:\n                            break\n                        else:\n                            content = ''\n                \n                # If no specific content found, use text minus title as fallback\n                if not content and title:\n                    # Try to extract content by removing title from full text\n                    if text.startswith(title):\n                        content = text[len(title):].strip()\n                    else:\n                        content = text\n                elif not content:\n                    content = text\n                    \n                seen.add(href)\n                results.append({\n                    'href': href,\n                    'text': text,\n                    'title': title,\n                    'content': content\n                })\n        else:\n            # Fallback: extract links directly if no result containers found\n            for a in json_soup.find_all('a', href=True):\n                href = a['href']\n                if not href.startswith('http'):\n                    continue\n                if href in seen:\n                    continue\n                text = clean_text_spacing(a.get_text(separator=' ', strip=True))\n                if not text:\n                    continue\n                seen.add(href)\n                # In fallback mode, the link text serves as both title and text\n                results.append({\n                    'href': href,\n                    'text': text,\n                    'title': text,\n                    'content': ''\n                })\n\n        return jsonify({\n            'query': urlparse.unquote(query),\n            'search_type': search_util.search_type,\n            'results': results\n        })\n\n    # Get the user agent that was used for the search\n    used_user_agent = ''\n    if search_util.user_request:\n        used_user_agent = search_util.user_request.modified_user_agent\n    elif hasattr(g, 'user_request') and g.user_request:\n        used_user_agent = g.user_request.modified_user_agent\n    \n    return render_template(\n        'display.html',\n        has_update=app.config['HAS_UPDATE'],\n        query=urlparse.unquote(query),\n        search_type=search_util.search_type,\n        search_name=get_search_name(search_util.search_type),\n        config=g.user_config,\n        autocomplete_enabled=autocomplete_enabled,\n        lingva_url=app.config['TRANSLATE_URL'],\n        translation=translation,\n        translate_to=translate_to,\n        translate_str=query.replace(\n            'translate', ''\n        ).replace(\n            translation['translate'], ''\n        ),\n        is_translation=any(\n            _ in query.lower() for _ in [translation['translate'], 'translate']\n        ) and not search_util.search_type,  # Standard search queries only\n        response=cleanresponse,\n        version_number=app.config['VERSION_NUMBER'],\n        used_user_agent=used_user_agent,\n        search_header=render_template(\n            'header.html',\n            home_url=home_url,\n            config=g.user_config,\n            translation=translation,\n            languages=app.config['LANGUAGES'],\n            countries=app.config['COUNTRIES'],\n            time_periods=app.config['TIME_PERIODS'],\n            logo=render_template('logo.html'),\n            query=urlparse.unquote(query),\n            search_type=search_util.search_type,\n            mobile=g.user_request.mobile,\n            tabs=tabs)).replace(\"  \", \"\")\n\n\n@app.route(f'/{Endpoint.config}', methods=['GET', 'POST', 'PUT'])\n@session_required\n@auth_required\ndef config():\n    config_disabled = (\n            app.config['CONFIG_DISABLE'] or\n            not valid_user_session(session))\n\n    name = ''\n    if 'name' in request.args:\n        name = os.path.normpath(request.args.get('name'))\n        if not re.match(r'^[A-Za-z0-9_.+-]+$', name):\n            return make_response('Invalid config name', 400)\n\n    if request.method == 'GET':\n        return json.dumps(g.user_config.__dict__)\n    elif request.method == 'PUT' and not config_disabled:\n        if name:\n            config_file = os.path.join(app.config['CONFIG_PATH'], name)\n            if os.path.exists(config_file):\n                with open(config_file, 'r', encoding='utf-8') as f:\n                    session['config'] = json.load(f)\n            # else keep existing session['config']\n            return json.dumps(session['config'])\n        else:\n            return json.dumps({})\n    elif not config_disabled:\n        config_data = request.form.to_dict()\n        if 'url' not in config_data or not config_data['url']:\n            config_data['url'] = g.user_config.url\n\n        # Handle user agent configuration\n        if 'user_agent' in config_data:\n            if config_data['user_agent'] == 'custom':\n                config_data['use_custom_user_agent'] = True\n                # Keep both the selection and the custom string\n                if 'custom_user_agent' in config_data:\n                    config_data['custom_user_agent'] = config_data['custom_user_agent']\n                    app.logger.debug(f\"Setting custom user agent to: {config_data['custom_user_agent']}\")\n            else:\n                config_data['use_custom_user_agent'] = False\n                # Only clear custom_user_agent if not using custom option\n                if config_data['user_agent'] != 'custom':\n                    config_data['custom_user_agent'] = ''\n\n        # Save config by name to allow a user to easily load later\n        if name:\n            config_file = os.path.join(app.config['CONFIG_PATH'], name)\n            with open(config_file, 'w', encoding='utf-8') as f:\n                json.dump(config_data, f, indent=2)\n\n        session['config'] = config_data\n        return redirect(config_data['url'])\n    else:\n        return redirect(url_for('.index'), code=403)\n\n\n@app.route(f'/{Endpoint.imgres}')\n@session_required\n@auth_required\ndef imgres():\n    return redirect(request.args.get('imgurl'))\n\n\n@app.route(f'/{Endpoint.element}')\n@session_required\n@auth_required\ndef element():\n    element_url = src_url = request.args.get('url')\n    if element_url.startswith('gAAAAA'):\n        try:\n            cipher_suite = Fernet(g.session_key)\n            src_url = cipher_suite.decrypt(element_url.encode()).decode()\n        except (InvalidSignature, InvalidToken) as e:\n            return render_template(\n                'error.html',\n                error_message=str(e)), 401\n\n    src_type = request.args.get('type')\n\n    # Ensure requested element is from a valid domain\n    domain = urlparse.urlparse(src_url).netloc\n    if not validators.domain(domain):\n        return send_file(io.BytesIO(empty_gif), mimetype='image/gif')\n\n    try:\n        response = g.user_request.send(base_url=src_url)\n\n        # Display an empty gif if the requested element couldn't be retrieved\n        if response.status_code != 200 or len(response.content) == 0:\n            if 'favicon' in src_url:\n                favicon = fetch_favicon(src_url)\n                return send_file(io.BytesIO(favicon), mimetype='image/png')\n            else:\n                return send_file(io.BytesIO(empty_gif), mimetype='image/gif')\n\n        file_data = response.content\n        tmp_mem = io.BytesIO()\n        tmp_mem.write(file_data)\n        tmp_mem.seek(0)\n\n        return send_file(tmp_mem, mimetype=src_type)\n    except httpx.HTTPError:\n        pass\n\n    return send_file(io.BytesIO(empty_gif), mimetype='image/gif')\n\n\n@app.route(f'/{Endpoint.window}')\n@session_required\n@auth_required\ndef window():\n    target_url = request.args.get('location')\n    if target_url.startswith('gAAAAA'):\n        cipher_suite = Fernet(g.session_key)\n        target_url = cipher_suite.decrypt(target_url.encode()).decode()\n\n    content_filter = Filter(\n        g.session_key,\n        root_url=request.url_root,\n        config=g.user_config)\n    target = urlparse.urlparse(target_url)\n\n    # Ensure requested URL has a valid domain\n    if not validators.domain(target.netloc):\n        return render_template(\n            'error.html',\n            error_message='Invalid location'), 400\n\n    host_url = f'{target.scheme}://{target.netloc}'\n\n    get_body = g.user_request.send(base_url=target_url).text\n\n    results = bsoup(get_body, 'html.parser')\n    src_attrs = ['src', 'href', 'srcset', 'data-srcset', 'data-src']\n\n    # Parse HTML response and replace relative links w/ absolute\n    for element in results.find_all():\n        for attr in src_attrs:\n            if not element.has_attr(attr) or not element[attr].startswith('/'):\n                continue\n\n            element[attr] = host_url + element[attr]\n\n    # Replace or remove javascript sources\n    for script in results.find_all('script', {'src': True}):\n        if 'nojs' in request.args:\n            script.decompose()\n        else:\n            content_filter.update_element_src(script, 'application/javascript')\n\n    # Replace all possible image attributes\n    img_sources = ['src', 'data-src', 'data-srcset', 'srcset']\n    for img in results.find_all('img'):\n        _ = [\n            content_filter.update_element_src(img, 'image/png', attr=_)\n            for _ in img_sources if img.has_attr(_)\n        ]\n\n    # Replace all stylesheet sources\n    for link in results.find_all('link', {'href': True}):\n        content_filter.update_element_src(link, 'text/css', attr='href')\n\n    # Use anonymous view for all links on page\n    for a in results.find_all('a', {'href': True}):\n        a['href'] = f'{Endpoint.window}?location=' + a['href'] + (\n            '&nojs=1' if 'nojs' in request.args else '')\n\n    # Remove all iframes -- these are commonly used inside of <noscript> tags\n    # to enforce loading Google Analytics\n    for iframe in results.find_all('iframe'):\n        iframe.decompose()\n\n    return render_template(\n        'display.html',\n        response=results,\n        translation=app.config['TRANSLATIONS'][\n            g.user_config.get_localization_lang()\n        ]\n    )\n\n\n@app.route('/robots.txt')\ndef robots():\n    response = make_response(\n'''User-Agent: *\nDisallow: /''', 200)\n    response.mimetype = 'text/plain'\n    return response\n\n\n@app.route('/favicon.ico')\ndef favicon():\n    return app.send_static_file('img/favicon.ico')\n\n\n@app.errorhandler(404)\ndef page_not_found(e):\n    return render_template('error.html', error_message=str(e)), 404\n\n\n@app.errorhandler(Exception)\ndef internal_error(e):\n    query = ''\n    if request.method == 'POST':\n        query = request.form.get('q')\n    else:\n        query = request.args.get('q')\n\n    # Attempt to parse the query\n    try:\n        if hasattr(g, 'user_config') and hasattr(g, 'session_key'):\n            search_util = Search(request, g.user_config, g.session_key)\n            query = search_util.new_search_query()\n    except Exception:\n        pass\n\n    print(traceback.format_exc(), file=sys.stderr)\n\n    fallback_engine = os.environ.get('WHOOGLE_FALLBACK_ENGINE_URL', '')\n    if (fallback_engine):\n        return redirect(fallback_engine + (query or ''))\n\n    # Safely get localization language with fallback\n    if hasattr(g, 'user_config'):\n        localization_lang = g.user_config.get_localization_lang()\n    else:\n        localization_lang = 'lang_en'\n    translation = app.config['TRANSLATIONS'][localization_lang]\n    # Build template context with safe defaults\n    template_context = {\n        'error_message': 'Internal server error (500)',\n        'translation': translation,\n        'farside': 'https://farside.link',\n        'query': urlparse.unquote(query or '')\n    }\n    \n    # Add user config if available\n    if hasattr(g, 'user_config'):\n        template_context['config'] = g.user_config\n        template_context['params'] = g.user_config.to_params(keys=['preferences'])\n    \n    return render_template('error.html', **template_context), 500\n\n\ndef run_app() -> None:\n    parser = argparse.ArgumentParser(\n        description='Whoogle Search console runner')\n    parser.add_argument(\n        '--port',\n        default=5000,\n        metavar='<port number>',\n        help='Specifies a port to run on (default 5000)')\n    parser.add_argument(\n        '--host',\n        default='127.0.0.1',\n        metavar='<ip address>',\n        help='Specifies the host address to use (default 127.0.0.1)')\n    parser.add_argument(\n        '--unix-socket',\n        default='',\n        metavar='</path/to/unix.sock>',\n        help='Listen for app on unix socket instead of host:port')\n    parser.add_argument(\n        '--unix-socket-perms',\n        default='600',\n        metavar='<octal permissions>',\n        help='Octal permissions to use for the Unix domain socket (default 600)')\n    parser.add_argument(\n        '--debug',\n        default=False,\n        action='store_true',\n        help='Activates debug mode for the server (default False)')\n    parser.add_argument(\n        '--https-only',\n        default=False,\n        action='store_true',\n        help='Enforces HTTPS redirects for all requests')\n    parser.add_argument(\n        '--userpass',\n        default='',\n        metavar='<username:password>',\n        help='Sets a username/password basic auth combo (default None)')\n    parser.add_argument(\n        '--proxyauth',\n        default='',\n        metavar='<username:password>',\n        help='Sets a username/password for a HTTP/SOCKS proxy (default None)')\n    parser.add_argument(\n        '--proxytype',\n        default='',\n        metavar='<socks4|socks5|http>',\n        help='Sets a proxy type for all connections (default None)')\n    parser.add_argument(\n        '--proxyloc',\n        default='',\n        metavar='<location:port>',\n        help='Sets a proxy location for all connections (default None)')\n    args = parser.parse_args()\n\n    if args.userpass:\n        user_pass = args.userpass.split(':')\n        os.environ['WHOOGLE_USER'] = user_pass[0]\n        os.environ['WHOOGLE_PASS'] = user_pass[1]\n\n    if args.proxytype and args.proxyloc:\n        if args.proxyauth:\n            proxy_user_pass = args.proxyauth.split(':')\n            os.environ['WHOOGLE_PROXY_USER'] = proxy_user_pass[0]\n            os.environ['WHOOGLE_PROXY_PASS'] = proxy_user_pass[1]\n        os.environ['WHOOGLE_PROXY_TYPE'] = args.proxytype\n        os.environ['WHOOGLE_PROXY_LOC'] = args.proxyloc\n\n    if args.https_only:\n        os.environ['HTTPS_ONLY'] = '1'\n\n    if args.debug:\n        app.run(host=args.host, port=args.port, debug=args.debug)\n    elif args.unix_socket:\n        waitress.serve(app, unix_socket=args.unix_socket, unix_socket_perms=args.unix_socket_perms)\n    else:\n        waitress.serve(\n            app,\n            listen=\"{}:{}\".format(args.host, args.port),\n            url_prefix=os.environ.get('WHOOGLE_URL_PREFIX', ''))\n"
  },
  {
    "path": "app/services/__init__.py",
    "content": "\n\n"
  },
  {
    "path": "app/services/cse_client.py",
    "content": "\"\"\"Google Custom Search Engine (CSE) API Client\n\nThis module provides a client for Google's Custom Search JSON API,\nallowing users to bring their own API key (BYOK) for search functionality.\n\"\"\"\n\nimport httpx\nfrom typing import Optional\nfrom dataclasses import dataclass\nfrom urllib.parse import urlparse\n\nfrom flask import render_template\n\n\n# Google Custom Search API endpoint\nCSE_API_URL = 'https://www.googleapis.com/customsearch/v1'\n\n\nclass CSEException(Exception):\n    \"\"\"Exception raised for CSE API errors\"\"\"\n    def __init__(self, message: str, code: int = 500, is_quota_error: bool = False):\n        self.message = message\n        self.code = code\n        self.is_quota_error = is_quota_error\n        super().__init__(self.message)\n\n\n@dataclass\nclass CSEError:\n    \"\"\"Represents an error from the CSE API\"\"\"\n    code: int\n    message: str\n    \n    @property\n    def is_quota_exceeded(self) -> bool:\n        return self.code == 429 or 'quota' in self.message.lower()\n    \n    @property\n    def is_invalid_key(self) -> bool:\n        return self.code == 400 or 'invalid' in self.message.lower()\n\n\n@dataclass\nclass CSEResult:\n    \"\"\"Represents a single search result from CSE API\"\"\"\n    title: str\n    link: str\n    snippet: str\n    display_link: str\n    html_title: Optional[str] = None\n    html_snippet: Optional[str] = None\n    # Image-specific fields (populated for image search)\n    image_url: Optional[str] = None\n    thumbnail_url: Optional[str] = None\n    image_width: Optional[int] = None\n    image_height: Optional[int] = None\n    context_link: Optional[str] = None  # Page where image was found\n\n\n@dataclass\nclass CSEResponse:\n    \"\"\"Represents a complete CSE API response\"\"\"\n    results: list[CSEResult]\n    total_results: str\n    search_time: float\n    query: str\n    start_index: int\n    is_image_search: bool = False\n    error: Optional[CSEError] = None\n    \n    @property\n    def has_error(self) -> bool:\n        return self.error is not None\n    \n    @property\n    def has_results(self) -> bool:\n        return len(self.results) > 0\n\n\nclass CSEClient:\n    \"\"\"Client for Google Custom Search Engine API\n    \n    Usage:\n        client = CSEClient(api_key='your-key', cse_id='your-cse-id')\n        response = client.search('python programming')\n        \n        if response.has_error:\n            print(f\"Error: {response.error.message}\")\n        else:\n            for result in response.results:\n                print(f\"{result.title}: {result.link}\")\n    \"\"\"\n    \n    def __init__(self, api_key: str, cse_id: str, timeout: float = 10.0):\n        \"\"\"Initialize CSE client\n        \n        Args:\n            api_key: Google API key with Custom Search API enabled\n            cse_id: Custom Search Engine ID (cx parameter)\n            timeout: Request timeout in seconds\n        \"\"\"\n        self.api_key = api_key\n        self.cse_id = cse_id\n        self.timeout = timeout\n        self._client = httpx.Client(timeout=timeout)\n    \n    def search(\n        self,\n        query: str,\n        start: int = 1,\n        num: int = 10,\n        safe: str = 'off',\n        language: str = '',\n        country: str = '',\n        search_type: str = ''\n    ) -> CSEResponse:\n        \"\"\"Execute a search query against the CSE API\n        \n        Args:\n            query: Search query string\n            start: Starting result index (1-based, for pagination)\n            num: Number of results to return (max 10)\n            safe: Safe search setting ('off', 'medium', 'high')\n            language: Language restriction (e.g., 'lang_en')\n            country: Country restriction (e.g., 'countryUS')\n            search_type: Type of search ('image' for image search, '' for web)\n            \n        Returns:\n            CSEResponse with results or error information\n        \"\"\"\n        params = {\n            'key': self.api_key,\n            'cx': self.cse_id,\n            'q': query,\n            'start': start,\n            'num': min(num, 10),  # API max is 10\n            'safe': safe,\n        }\n        \n        # Add search type for image search\n        if search_type == 'image':\n            params['searchType'] = 'image'\n        \n        # Add optional parameters\n        if language:\n            # CSE uses 'lr' for language restrict\n            params['lr'] = language\n        if country:\n            # CSE uses 'cr' for country restrict\n            params['cr'] = country\n        \n        try:\n            response = self._client.get(CSE_API_URL, params=params)\n            data = response.json()\n            \n            # Check for API errors\n            if 'error' in data:\n                error_info = data['error']\n                return CSEResponse(\n                    results=[],\n                    total_results='0',\n                    search_time=0.0,\n                    query=query,\n                    start_index=start,\n                    error=CSEError(\n                        code=error_info.get('code', 500),\n                        message=error_info.get('message', 'Unknown error')\n                    )\n                )\n            \n            # Parse successful response\n            search_info = data.get('searchInformation', {})\n            items = data.get('items', [])\n            is_image = search_type == 'image'\n            \n            results = []\n            for item in items:\n                # Extract image-specific data if present\n                image_data = item.get('image', {})\n                \n                results.append(CSEResult(\n                    title=item.get('title', ''),\n                    link=item.get('link', ''),\n                    snippet=item.get('snippet', ''),\n                    display_link=item.get('displayLink', ''),\n                    html_title=item.get('htmlTitle'),\n                    html_snippet=item.get('htmlSnippet'),\n                    # Image fields\n                    image_url=item.get('link') if is_image else None,\n                    thumbnail_url=image_data.get('thumbnailLink'),\n                    image_width=image_data.get('width'),\n                    image_height=image_data.get('height'),\n                    context_link=image_data.get('contextLink')\n                ))\n            \n            return CSEResponse(\n                results=results,\n                total_results=search_info.get('totalResults', '0'),\n                search_time=float(search_info.get('searchTime', 0)),\n                query=query,\n                start_index=start,\n                is_image_search=is_image\n            )\n            \n        except httpx.TimeoutException:\n            return CSEResponse(\n                results=[],\n                total_results='0',\n                search_time=0.0,\n                query=query,\n                start_index=start,\n                error=CSEError(code=408, message='Request timed out')\n            )\n        except httpx.RequestError as e:\n            return CSEResponse(\n                results=[],\n                total_results='0',\n                search_time=0.0,\n                query=query,\n                start_index=start,\n                error=CSEError(code=500, message=f'Request failed: {str(e)}')\n            )\n        except Exception as e:\n            return CSEResponse(\n                results=[],\n                total_results='0',\n                search_time=0.0,\n                query=query,\n                start_index=start,\n                error=CSEError(code=500, message=f'Unexpected error: {str(e)}')\n            )\n    \n    def close(self):\n        \"\"\"Close the HTTP client\"\"\"\n        self._client.close()\n    \n    def __enter__(self):\n        return self\n    \n    def __exit__(self, *args):\n        self.close()\n\n\ndef cse_results_to_html(response: CSEResponse, query: str) -> str:\n    \"\"\"Convert CSE API response to HTML matching Whoogle's result format\n    \n    This generates HTML that mimics the structure expected by Whoogle's\n    existing filter and result processing pipeline.\n    \n    Args:\n        response: CSEResponse from the API\n        query: Original search query\n        \n    Returns:\n        HTML string formatted like Google search results\n    \"\"\"\n    if response.has_error:\n        error = response.error\n        if error.is_quota_exceeded:\n            return _error_html(\n                'API Quota Exceeded',\n                'Your Google Custom Search API quota has been exceeded. '\n                'Free tier allows 100 queries/day. Wait until midnight PT '\n                'or enable billing in Google Cloud Console.'\n            )\n        elif error.is_invalid_key:\n            return _error_html(\n                'Invalid API Key',\n                'Your Google Custom Search API key is invalid. '\n                'Please check your API key and CSE ID in settings.'\n            )\n        else:\n            return _error_html('Search Error', error.message)\n    \n    if not response.has_results:\n        return _no_results_html(query)\n    \n    # Use different HTML structure for image vs web results\n    if response.is_image_search:\n        return _image_results_html(response, query)\n    \n    # Build HTML results matching Whoogle's expected structure\n    results_html = []\n    \n    for result in response.results:\n        # Escape HTML in content\n        title = _escape_html(result.title)\n        snippet = _escape_html(result.snippet)\n        link = result.link\n        display_link = _escape_html(result.display_link)\n        \n        # Use HTML versions if available (they have bold tags for query terms)\n        if result.html_title:\n            title = result.html_title\n        if result.html_snippet:\n            snippet = result.html_snippet\n        \n        # Match the structure used by Google/mock results\n        result_html = f'''\n        <div class=\"ZINbbc xpd O9g5cc uUPGi\">\n            <div class=\"kCrYT\">\n                <a href=\"{link}\">\n                    <h3 class=\"BNeawe vvjwJb AP7Wnd\">{title}</h3>\n                    <div class=\"BNeawe UPmit AP7Wnd luh4tb\" style=\"color: var(--whoogle-result-url);\">{display_link}</div>\n                </a>\n            </div>\n            <div class=\"kCrYT\">\n                <div class=\"BNeawe s3v9rd AP7Wnd\">\n                    <span class=\"VwiC3b\">{snippet}</span>\n                </div>\n            </div>\n        </div>\n        '''\n        results_html.append(result_html)\n    \n    # Build pagination if needed\n    pagination_html = ''\n    if int(response.total_results) > 10:\n        pagination_html = _pagination_html(response.start_index, response.query)\n    \n    # Wrap in expected structure\n    # Add data-cse attribute to prevent collapse_sections from collapsing these results\n    return f'''\n    <html>\n    <body>\n        <div id=\"main\" data-cse=\"true\">\n            <div id=\"cnt\">\n                <div id=\"rcnt\">\n                    <div id=\"center_col\">\n                        <div id=\"res\">\n                            <div id=\"search\">\n                                <div id=\"rso\">\n                                    {''.join(results_html)}\n                                </div>\n                            </div>\n                        </div>\n                        {pagination_html}\n                    </div>\n                </div>\n            </div>\n        </div>\n    </body>\n    </html>\n    '''\n\n\ndef _escape_html(text: str) -> str:\n    \"\"\"Escape HTML special characters\"\"\"\n    if not text:\n        return ''\n    return (text\n            .replace('&', '&amp;')\n            .replace('<', '&lt;')\n            .replace('>', '&gt;')\n            .replace('\"', '&quot;')\n            .replace(\"'\", '&#39;'))\n\n\ndef _error_html(title: str, message: str) -> str:\n    \"\"\"Generate error HTML\"\"\"\n    return f'''\n    <html>\n    <body>\n        <div id=\"main\">\n            <div style=\"padding: 20px; text-align: center;\">\n                <h2 style=\"color: #d93025;\">{_escape_html(title)}</h2>\n                <p>{_escape_html(message)}</p>\n            </div>\n        </div>\n    </body>\n    </html>\n    '''\n\n\ndef _no_results_html(query: str) -> str:\n    \"\"\"Generate no results HTML\"\"\"\n    return f'''\n    <html>\n    <body>\n        <div id=\"main\">\n            <div style=\"padding: 20px;\">\n                <p>No results found for <b>{_escape_html(query)}</b></p>\n            </div>\n        </div>\n    </body>\n    </html>\n    '''\n\n\ndef _image_results_html(response: CSEResponse, query: str) -> str:\n    \"\"\"Generate HTML for image search results using the imageresults template\n    \n    Args:\n        response: CSEResponse with image results\n        query: Original search query\n        \n    Returns:\n        HTML string formatted for image results display\n    \"\"\"\n    # Convert CSE results to the format expected by imageresults.html template\n    results = []\n    for result in response.results:\n        image_url = result.image_url or result.link\n        thumbnail_url = result.thumbnail_url or image_url\n        web_page = result.context_link or result.link\n        domain = urlparse(web_page).netloc if web_page else result.display_link\n        \n        results.append({\n            'domain': domain,\n            'img_url': image_url,\n            'web_page': web_page,\n            'img_tbn': thumbnail_url\n        })\n    \n    # Build pagination link if needed\n    next_link = None\n    if int(response.total_results) > response.start_index + len(response.results) - 1:\n        next_start = response.start_index + 10\n        next_link = f'search?q={query}&tbm=isch&start={next_start}'\n    \n    # Use the same template as regular image results\n    return render_template(\n        'imageresults.html',\n        length=len(results),\n        results=results,\n        view_label=\"View Image\",\n        next_link=next_link\n    )\n\n\ndef _pagination_html(current_start: int, query: str) -> str:\n    \"\"\"Generate pagination links\"\"\"\n    # CSE API uses 1-based indexing, 10 results per page\n    current_page = (current_start - 1) // 10 + 1\n    \n    prev_link = ''\n    next_link = ''\n    \n    if current_page > 1:\n        prev_start = (current_page - 2) * 10 + 1\n        prev_link = f'<a href=\"search?q={query}&start={prev_start}\">Previous</a>'\n    \n    next_start = current_page * 10 + 1\n    next_link = f'<a href=\"search?q={query}&start={next_start}\">Next</a>'\n    \n    return f'''\n    <div id=\"foot\" style=\"text-align: center; padding: 20px;\">\n        {prev_link}\n        <span style=\"margin: 0 20px;\">Page {current_page}</span>\n        {next_link}\n    </div>\n    '''\n"
  },
  {
    "path": "app/services/http_client.py",
    "content": "import threading\nimport time\nfrom typing import Any, Dict, Optional, Tuple\n\nimport httpx\nfrom cachetools import TTLCache\nimport ssl\nimport os\n\n# Import h2 exceptions for better error handling\ntry:\n    from h2.exceptions import ProtocolError as H2ProtocolError\nexcept ImportError:\n    H2ProtocolError = None\n\n\nclass HttpxClient:\n    \"\"\"Thin wrapper around httpx.Client providing simple retries and optional TTL caching.\n\n    The client is intended to be safe for reuse across requests. Per-request\n    overrides for headers/cookies are supported.\n    \"\"\"\n\n    def __init__(\n            self,\n            proxies: Optional[Dict[str, str]] = None,\n            timeout_seconds: float = 15.0,\n            cache_ttl_seconds: int = 30,\n            cache_maxsize: int = 256,\n            http2: bool = True) -> None:\n        # Allow disabling HTTP/2 via environment variable\n        # HTTP/2 can sometimes cause protocol errors with certain servers\n        if os.environ.get('WHOOGLE_DISABLE_HTTP2', '').lower() in ('1', 'true', 't', 'yes', 'y'):\n            http2 = False\n            \n        client_kwargs = dict(http2=http2,\n                             timeout=timeout_seconds,\n                             follow_redirects=True)\n        # Prefer future-proof mounts when proxies are provided; fall back to proxies=\n        self._proxies = proxies or {}\n        self._http2 = http2\n\n        # Determine verify behavior and initialize client with fallbacks\n        self._verify = self._determine_verify_setting()\n        try:\n            self._client = self._build_client(client_kwargs, self._verify)\n        except ssl.SSLError:\n            # Fallback to system trust store\n            try:\n                system_ctx = ssl.create_default_context()\n                self._client = self._build_client(client_kwargs, system_ctx)\n                self._verify = system_ctx\n            except ssl.SSLError:\n                insecure_fallback = os.environ.get('WHOOGLE_INSECURE_FALLBACK', '0').lower() in ('1', 'true', 't', 'yes', 'y')\n                if insecure_fallback:\n                    self._client = self._build_client(client_kwargs, False)\n                    self._verify = False\n                else:\n                    raise\n        self._timeout_seconds = timeout_seconds\n        self._cache = TTLCache(maxsize=cache_maxsize, ttl=cache_ttl_seconds)\n        self._cache_lock = threading.Lock()\n\n    def _determine_verify_setting(self):\n        \"\"\"Determine SSL verification setting from environment.\n\n        Honors:\n        - WHOOGLE_CA_BUNDLE: path to CA bundle file\n        - WHOOGLE_SSL_VERIFY: '0' to disable verification\n        - WHOOGLE_SSL_BACKEND: 'system' to prefer system trust store\n        \"\"\"\n        ca_bundle = os.environ.get('WHOOGLE_CA_BUNDLE', '').strip()\n        if ca_bundle:\n            return ca_bundle\n\n        verify_env = os.environ.get('WHOOGLE_SSL_VERIFY', '1').lower()\n        if verify_env in ('0', 'false', 'no', 'n'):\n            return False\n\n        backend = os.environ.get('WHOOGLE_SSL_BACKEND', '').lower()\n        if backend == 'system':\n            return ssl.create_default_context()\n\n        return True\n\n    def _build_client(self, client_kwargs: Dict[str, Any], verify: Any) -> httpx.Client:\n        \"\"\"Construct httpx.Client with proxies and provided verify setting.\"\"\"\n        kwargs = dict(client_kwargs)\n        kwargs['verify'] = verify\n        if self._proxies:\n            proxy_values = list(self._proxies.values())\n            single_proxy = proxy_values[0] if proxy_values and all(v == proxy_values[0] for v in proxy_values) else None\n            if single_proxy:\n                try:\n                    return httpx.Client(proxy=single_proxy, **kwargs)\n                except TypeError:\n                    try:\n                        return httpx.Client(proxies=self._proxies, **kwargs)\n                    except TypeError:\n                        mounts: Dict[str, httpx.Proxy] = {}\n                        for scheme_key, url in self._proxies.items():\n                            prefix = f\"{scheme_key}://\"\n                            mounts[prefix] = httpx.Proxy(url)\n                        return httpx.Client(mounts=mounts, **kwargs)\n            else:\n                try:\n                    return httpx.Client(proxies=self._proxies, **kwargs)\n                except TypeError:\n                    mounts: Dict[str, httpx.Proxy] = {}\n                    for scheme_key, url in self._proxies.items():\n                        prefix = f\"{scheme_key}://\"\n                        mounts[prefix] = httpx.Proxy(url)\n                    return httpx.Client(mounts=mounts, **kwargs)\n        else:\n            return httpx.Client(**kwargs)\n\n    @property\n    def proxies(self) -> Dict[str, str]:\n        return self._proxies\n\n    def _cache_key(self, method: str, url: str, headers: Optional[Dict[str, str]]) -> Tuple[str, str, Tuple[Tuple[str, str], ...]]:\n        normalized_headers = tuple(sorted((headers or {}).items()))\n        return (method.upper(), url, normalized_headers)\n\n    def get(self,\n            url: str,\n            headers: Optional[Dict[str, str]] = None,\n            cookies: Optional[Dict[str, str]] = None,\n            retries: int = 2,\n            backoff_seconds: float = 0.5,\n            use_cache: bool = False) -> httpx.Response:\n        if use_cache:\n            key = self._cache_key('GET', url, headers)\n            with self._cache_lock:\n                cached = self._cache.get(key)\n            if cached is not None:\n                return cached\n\n        last_exc: Optional[Exception] = None\n        attempt = 0\n        while attempt <= retries:\n            try:\n                # Check if client is closed and recreate if needed\n                if self._client.is_closed:\n                    self._recreate_client()\n                    \n                response = self._client.get(url, headers=headers, cookies=cookies)\n                if use_cache and response.status_code == 200:\n                    with self._cache_lock:\n                        self._cache[key] = response\n                return response\n            except Exception as exc:\n                last_exc = exc\n                # Check for specific errors that require client recreation\n                should_recreate = False\n                \n                if isinstance(exc, (httpx.HTTPError, RuntimeError)):\n                    if \"client has been closed\" in str(exc).lower():\n                        should_recreate = True\n                \n                # Handle H2 protocol errors (connection state issues)\n                if H2ProtocolError and isinstance(exc, H2ProtocolError):\n                    should_recreate = True\n                \n                # Also check if the error message contains h2 protocol error info\n                if \"ProtocolError\" in str(exc) or \"ConnectionState.CLOSED\" in str(exc):\n                    should_recreate = True\n                \n                if should_recreate:\n                    self._recreate_client()\n                    if attempt < retries:\n                        time.sleep(backoff_seconds * (2 ** attempt))\n                        attempt += 1\n                        continue\n                \n                # For non-recoverable errors or last attempt, raise\n                if attempt == retries:\n                    raise\n                    \n                # For other errors, still retry with backoff\n                time.sleep(backoff_seconds * (2 ** attempt))\n                attempt += 1\n\n        # Should not reach here\n        if last_exc:\n            raise last_exc\n        raise httpx.HTTPError('Unknown HTTP error')\n\n    def _recreate_client(self) -> None:\n        \"\"\"Recreate the HTTP client when it has been closed.\"\"\"\n        try:\n            self._client.close()\n        except Exception:\n            pass  # Client might already be closed\n        \n        # Recreate with same configuration\n        client_kwargs = dict(timeout=self._timeout_seconds,\n                             follow_redirects=True,\n                             http2=self._http2)\n\n        try:\n            self._client = self._build_client(client_kwargs, self._verify)\n        except ssl.SSLError:\n            try:\n                system_ctx = ssl.create_default_context()\n                self._client = self._build_client(client_kwargs, system_ctx)\n                self._verify = system_ctx\n            except ssl.SSLError:\n                insecure_fallback = os.environ.get('WHOOGLE_INSECURE_FALLBACK', '0').lower() in ('1', 'true', 't', 'yes', 'y')\n                if insecure_fallback:\n                    self._client = self._build_client(client_kwargs, False)\n                    self._verify = False\n                else:\n                    raise\n\n    def close(self) -> None:\n        self._client.close()\n\n\n"
  },
  {
    "path": "app/services/provider.py",
    "content": "import os\nfrom typing import Dict, Tuple\n\nfrom app.services.http_client import HttpxClient\n\n\n_clients: Dict[tuple, HttpxClient] = {}\n\n\ndef _proxies_key(proxies: Dict[str, str]) -> Tuple[Tuple[str, str], Tuple[str, str]]:\n    if not proxies:\n        return tuple(), tuple()\n    # Separate http/https for stable key\n    items = sorted((proxies or {}).items())\n    return tuple(items), tuple(items)\n\n\ndef get_http_client(proxies: Dict[str, str]) -> HttpxClient:\n    # Determine HTTP/2 enablement from env (default on)\n    http2_env = os.environ.get('WHOOGLE_HTTP2', '1').lower()\n    http2_enabled = http2_env in ('1', 'true', 't', 'yes', 'y')\n\n    key = (_proxies_key(proxies or {}), http2_enabled)\n    client = _clients.get(key)\n    if client is not None:\n        return client\n    client = HttpxClient(proxies=proxies or None, http2=http2_enabled)\n    _clients[key] = client\n    return client\n\n\ndef close_all_clients() -> None:\n    for client in list(_clients.values()):\n        try:\n            client.close()\n        except Exception:\n            pass\n    _clients.clear()\n\n\n"
  },
  {
    "path": "app/static/bangs/00-whoogle.json",
    "content": "{\n  \"!i\": {\n    \"url\": \"search?q={}&tbm=isch\",\n    \"suggestion\": \"!i (Whoogle Images)\"\n  },\n  \"!v\": {\n    \"url\": \"search?q={}&tbm=vid\",\n    \"suggestion\": \"!v (Whoogle Videos)\"\n  },\n  \"!n\": {\n    \"url\": \"search?q={}&tbm=nws\",\n    \"suggestion\": \"!n (Whoogle News)\"\n  }\n}\n"
  },
  {
    "path": "app/static/build/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "app/static/css/dark-theme.css",
    "content": "html {\n    background: var(--whoogle-dark-page-bg) !important;\n}\n\nbody {\n    background: var(--whoogle-dark-page-bg) !important;\n}\n\ndiv {\n    color: var(--whoogle-dark-text) !important;\n}\n\nlabel {\n    color: var(--whoogle-dark-contrast-text) !important;\n}\n\nli a {\n    color: var(--whoogle-dark-result-url) !important;\n}\n\nli {\n    color: var(--whoogle-dark-text) !important;\n}\n\n.anon-view {\n    color: var(--whoogle-dark-text) !important;\n    text-decoration: underline;\n}\n\ntextarea {\n    background: var(--whoogle-dark-page-bg) !important;\n    color: var(--whoogle-dark-text) !important;\n}\n\na:visited h3 div, a:visited .qXLe6d {\n    color: var(--whoogle-dark-result-visited) !important;\n}\n\na:link h3 div, a:link .qXLe6d {\n    color: var(--whoogle-dark-result-title) !important;\n}\n\na:link div, a:link .fYyStc {\n    color: var(--whoogle-dark-result-url) !important;\n}\n\ndiv span {\n    color: var(--whoogle-dark-secondary-text) !important;\n}\n\ninput {\n    background-color: var(--whoogle-dark-page-bg) !important;\n    color: var(--whoogle-dark-text) !important;\n}\n\nselect {\n    background: var(--whoogle-dark-page-bg) !important;\n    color: var(--whoogle-dark-text) !important;\n}\n\n.search-container {\n    background-color: var(--whoogle-dark-page-bg) !important;\n}\n\n.ZINbbc, .ezO2md {\n    overflow: hidden;\n    box-shadow: 0 0 0 0 !important;\n    background-color: var(--whoogle-dark-result-bg) !important;\n    margin-bottom: 10px !important;\n    border-radius: 8px !important;\n}\n\n.BsXmcf {\n    background-color: unset !important;\n}\n\n.KP7LCb {\n    box-shadow: 0 0 0 0 !important;\n}\n\n.BVG0Nb {\n    box-shadow: 0 0 0 0 !important;\n    background-color: var(--whoogle-dark-page-bg) !important;\n}\n\n.ZINbbc.luh4tb {\n    background: var(--whoogle-dark-result-bg) !important;\n    margin-bottom: 24px !important;\n}\n\n.bRsWnc {\n    background-color: var(--whoogle-dark-result-bg) !important;\n}\n\n.x54gtf {\n    background-color: var(--whoogle-dark-divider) !important;\n}\n\n.Q0HXG {\n    background-color: var(--whoogle-dark-divider) !important;\n}\n\n.LKSyXe {\n    background-color: var(--whoogle-dark-divider) !important;\n}\n\n.home-search {\n    border-color: var(--whoogle-dark-element-bg) !important;\n}\n\n.sa1toc {\n\tbackground: var(--whoogle-dark-page-bg) !important;\n}\n\n#search-bar {\n    border-color: var(--whoogle-dark-element-bg) !important;\n    color: var(--whoogle-dark-text) !important;\n    background-color: var(--whoogle-dark-result-bg) !important;\n    border-bottom: 2px solid var(--whoogle-dark-element-bg);\n}\n\n#search-bar:focus {\n    color: var(--whoogle-dark-text) !important;\n}\n\n#search-submit {\n    border: 1px solid var(--whoogle-dark-element-bg) !important;\n    background: var(--whoogle-dark-element-bg) !important;\n    color: var(--whoogle-dark-contrast-text) !important;\n}\n\n.info-text {\n    color: var(--whoogle-dark-contrast-text) !important;\n    opacity: 75%;\n}\n\n.collapsible {\n    color: var(--whoogle-dark-text) !important;\n}\n\n.collapsible:after {\n    color: var(--whoogle-dark-text) !important;\n}\n\n.active {\n    background-color: var(--whoogle-dark-element-bg) !important;\n    color: var(--whoogle-dark-contrast-text) !important;\n}\n\n.content, .result-config {\n    background-color: var(--whoogle-dark-element-bg) !important;\n    color: var(--whoogle-contrast-text) !important;\n}\n\n.active:after {\n    color: var(--whoogle-dark-contrast-text) !important;\n}\n\n.link {\n    color: var(--whoogle-dark-contrast-text);\n}\n\n.link-color {\n    color: var(--whoogle-dark-result-url) !important;\n}\n\n.autocomplete-items {\n    border: 1px solid var(--whoogle-dark-element-bg);\n}\n\n.autocomplete-items div {\n    color: var(--whoogle-dark-text);\n    background-color: var(--whoogle-dark-page-bg);\n    border-bottom: 1px solid var(--whoogle-dark-element-bg);\n}\n\n.autocomplete-items div:hover {\n    background-color: var(--whoogle-dark-element-bg);\n    color: var(--whoogle-dark-contrast-text) !important;\n}\n\n.autocomplete-active {\n    background-color: var(--whoogle-dark-element-bg) !important;\n    color: var(--whoogle-dark-contrast-text) !important;\n}\n\n.footer {\n    color: var(--whoogle-dark-text);\n}\n\npath {\n    fill: var(--whoogle-dark-logo);\n}\n\n.header-div {\n    background-color: var(--whoogle-dark-result-bg) !important;\n}\n\n#search-reset {\n    color: var(--whoogle-dark-text) !important;\n}\n\n.mobile-search-bar {\n    background-color: var(--whoogle-dark-result-bg) !important;\n    color: var(--whoogle-dark-text) !important;\n}\n\n.search-bar-desktop {\n    color: var(--whoogle-dark-text) !important;\n}\n\n.ip-text-div, .update_available, .cb_label, .cb {\n  color: var(--whoogle-dark-secondary-text) !important;\n}\n\n.cb:focus {\n  color: var(--whoogle-dark-contrast-text) !important;\n}\n\n.desktop-header, .mobile-header {\n    background-color: var(--whoogle-dark-result-bg) !important;\n}\n"
  },
  {
    "path": "app/static/css/error.css",
    "content": "html {\n    font-size: 1.3rem;\n}\n\n@media (max-width: 1000px) {\n    html {\n        font-size: 3rem;\n    }\n}\n"
  },
  {
    "path": "app/static/css/header.css",
    "content": "header {\n    font-family: Roboto,HelveticaNeue,Arial,sans-serif;\n    font-size: 14px;\n    line-height: 20px;\n    color: #3C4043;\n    word-wrap: break-word;\n}\n\n.logo-link, .logo-letter {\n    text-decoration: none !important;\n    letter-spacing: -1px;\n    text-align: center;\n    border-radius: 2px 0 0 0;\n}\n\n.result-config {\n    margin-bottom: 10px;\n    padding: 10px;\n    border-radius: 8px;\n}\n\n.mobile-logo {\n    font: 22px/36px Futura, Arial, sans-serif;\n    padding-left: 5px;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n}\n\n.logo-div {\n    letter-spacing: -1px;\n    text-align: center;\n    font: 22pt Futura, Arial, sans-serif;\n    padding: 10px 0 5px 0;\n    height: 37px;\n    font-smoothing: antialiased;\n}\n\n.search-bar-desktop {\n    border-radius: 8px 8px 0 0;\n    height: 40px !important;\n}\n\n.search-div {\n    border-radius: 8px 8px 0 0;\n    box-shadow: 0 1px 6px rgba(32, 33, 36, 0.18);\n    margin-top: 10px;\n}\n\n.search-form {\n    height: 39px;\n    display: flex;\n    width: 100%;\n    margin: 0px;\n}\n\n.search-input {\n    background: none;\n    margin: 2px 4px 2px 8px;\n    display: block;\n    font-size: 16px;\n    padding: 0 0 0 8px;\n    flex: 1;\n    height: 35px;\n    outline: none;\n    border: none;\n    width: 100%;\n    -webkit-tap-highlight-color: rgba(0,0,0,0);\n    overflow: hidden;\n}\n\n.tracking-link {\n    font-size: large;\n    text-align: center;\n    margin: 15px;\n    display: block;\n}\n\n#main>div:focus-within {\n    border-radius: 8px;\n    box-shadow: 0 0 6px 1px #2375e8;\n}\n\n#mobile-header-logo {\n    height: 1.75em;\n}\n\n.mobile-input-div {\n    width: 100%;\n}\n\n.mobile-search-bar {\n    display: block;\n    font-size: 16px;\n    padding: 0 0 0 8px;\n    padding-right: 0px;\n    -webkit-box-flex: 1;\n    height: 35px;\n    outline: none;\n    border: none;\n    width: 100%;\n    -webkit-tap-highlight-color: rgba(0,0,0,.00);\n    overflow: hidden;\n    border: 0px !important;\n}\n\n.autocomplete-mobile{\n    display: -webkit-box;\n    width: 100%;\n}\n\n.desktop-header-logo {\n    height: 1.65em;\n}\n\n.header-autocomplete {\n    width: 100%;\n    flex: 1\n}\n\na {\n    color: #1967D2;\n    text-decoration: none;\n    tap-highlight-color: rgba(0, 0, 0, .10);\n}\n\n.header-tab-div {\n    border-radius: 0 0 8px 8px;\n    box-shadow: 0 2px 3px rgba(32, 33, 36, 0.18);\n    overflow: hidden;\n    margin-bottom: 10px;\n}\n\n.header-tab-div-2 {\n    border-top: 1px solid #dadce0;\n    height: 39px;\n    overflow: hidden;\n}\n\n.header-tab-div-3 {\n    height: 51px;\n    overflow-x: auto;\n    overflow-y: hidden;\n}\n\n.desktop-header {\n    height: 39px;\n    display: box;\n    display: flex;\n    width: 100%;\n}\n\n.header-tab {\n    box-pack: justify;\n    font-size: 14px;\n    line-height: 37px;\n    justify-content: space-between;\n}\n\n.desktop-header a, .desktop-header span {\n    color: #70757a;\n    display: block;\n    flex: none;\n    padding: 0 16px;\n    text-align: center;\n    text-transform: uppercase;\n}\n\nspan.header-tab-span {\n    border-bottom: 2px solid #4285f4;\n    color: #4285f4;\n    font-weight: bold;\n}\n\n.mobile-header {\n    height: 39px;\n    display: box;\n    display: flex;\n    overflow-x: scroll;\n    width: 100%;\n    padding-left: 12px;\n}\n\n.mobile-header a, .mobile-header span {\n    color: #70757a;\n    text-decoration: none;\n    display: inline-block;\n    /* padding: 8px 12px 8px 12px; */\n}\n\nspan.mobile-tab-span {\n    border-bottom: 2px solid #202124;\n    color: #202124;\n    height: 26px;\n    /* margin: 0 12px; */\n    /* padding: 0; */\n}\n\n.desktop-header input {\n    margin: 2px 4px 2px 8px;\n}\n\na.header-tab-a:visited {\n    color: #70757a;\n}\n\n.header-tab-div-end {\n    border-left: 1px solid rgba(0, 0, 0, 0.12);\n}\n\n.adv-search {\n    font-size: 30px;\n}\n\n.adv-search:hover {\n    cursor: pointer;\n}\n\n#adv-search-toggle {\n    display: none;\n}\n\n.result-collapsible {\n    max-height: 0px;\n    overflow: hidden;\n    transition: max-height .25s linear;\n}\n\n.search-bar-input {\n    display: block;\n    font-size: 16px;\n    padding: 0 0 0 8px;\n    flex: 1;\n    height: 35px;\n    outline: none;\n    border: none;\n    width: 100%;\n    -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n    overflow: hidden;\n}\n\n#result-country {\n    max-width: 200px;\n}\n\n@media (max-width: 801px) {\n    .header-tab-div {\n        margin-bottom: 10px !important\n    }\n}\n"
  },
  {
    "path": "app/static/css/input.css",
    "content": "#search-bar {\n    background: transparent !important;\n    padding-right: 50px;\n}\n\n#search-reset {\n    all: unset;\n    margin-left: -50px;\n    text-align: center;\n    background-color: transparent !important;\n    cursor: pointer;\n    height: 40px;\n    width: 50px;\n}\n.ZINbbc.xpd.O9g5cc.uUPGi input::-webkit-outer-spin-button,\ninput::-webkit-inner-spin-button {\n  -webkit-appearance: none;\n  margin: 0;\n}\n\n.cb {\n    width: 40%;\n    overflow: hidden;\n    text-align: left;\n    line-height: 28px;\n    background: transparent;\n    border-radius: 6px;\n    border: 1px solid #5f6368;\n    font-size: 14px !important;\n    height: 36px;\n    padding: 0 0 0 12px;\n    margin: 10px 10px 10px 0;\n}\n\n.conversion_box {\n    margin-top: 15px;\n}\n\n.ZINbbc.xpd.O9g5cc.uUPGi input:focus-visible {\n    outline: 0;\n}\n"
  },
  {
    "path": "app/static/css/light-theme.css",
    "content": "html {\n    background: var(--whoogle-page-bg) !important;\n}\n\nbody {\n    background: var(--whoogle-page-bg) !important;\n}\n\ndiv {\n    color: var(--whoogle-text) !important;\n}\n\nlabel {\n    color: var(--whoogle-contrast-text) !important;\n}\n\nli a {\n    color: var(--whoogle-result-url) !important;\n}\n\nli {\n    color: var(--whoogle-text) !important;\n}\n\n.anon-view {\n    color: var(--whoogle-text) !important;\n    text-decoration: underline;\n}\n\ntextarea {\n    background: var(--whoogle-page-bg) !important;\n    color: var(--whoogle-text) !important;\n}\n\nselect {\n    background: var(--whoogle-page-bg) !important;\n    color: var(--whoogle-text) !important;\n}\n\n.ZINbbc {\n    overflow: hidden;\n    background-color: var(--whoogle-result-bg) !important;\n    margin-bottom: 10px !important;\n    border-radius: 8px !important;\n    box-shadow: 0 1px 6px rgba(32,33,36,0.28) !important;\n}\n\n.BsXmcf {\n    background-color: unset !important;\n}\n\n.BVG0Nb {\n    background-color: var(--whoogle-result-bg) !important;\n}\n\n.ZINbbc.luh4tb {\n    background: var(--whoogle-result-bg) !important;\n    margin-bottom: 24px !important;\n}\n\n.bRsWnc {\n    background-color: var(--whoogle-result-bg) !important;\n}\n\n.x54gtf {\n    background-color: var(--whoogle-divider) !important;\n}\n\n.Q0HXG {\n    background-color: var(--whoogle-divider) !important;\n}\n\n.LKSyXe {\n    background-color: var(--whoogle-divider) !important;\n}\n\n\na:visited div, a:visited .qXLe6d {\n    color: var(--whoogle-result-visited) !important;\n}\n\na:link div, a:link .qXLe6d {\n    color: var(--whoogle-result-title) !important;\n}\n\na:link div, a:link .fYyStc {\n    color: var(--whoogle-result-url) !important;\n}\n\ndiv span {\n    color: var(--whoogle-secondary-text) !important;\n}\n\ninput {\n    background-color: var(--whoogle-page-bg) !important;\n    color: var(--whoogle-text) !important;\n}\n\n#search-bar {\n    color: var(--whoogle-text) !important;\n    background-color: var(--whoogle-page-bg);\n}\n\n.home-search {\n    border-color: var(--whoogle-element-bg) !important;\n}\n\n.search-container {\n    background-color: var(--whoogle-page-bg) !important;\n}\n\n#search-submit {\n    border: 1px solid var(--whoogle-element-bg) !important;\n    background: var(--whoogle-element-bg) !important;\n    color: var(--whoogle-contrast-text) !important;\n}\n\n.info-text {\n    color: var(--whoogle-contrast-text) !important;\n    opacity: 75%;\n}\n\n.collapsible {\n    color: var(--whoogle-text) !important;\n}\n\n.collapsible:after {\n    color: var(--whoogle-text);\n}\n\n.active {\n    background-color: var(--whoogle-element-bg) !important;\n    color: var(--whoogle-contrast-text) !important;\n}\n\n.content, .result-config {\n    background-color: var(--whoogle-element-bg) !important;\n    color: var(--whoogle-contrast-text) !important;\n}\n\n.active:after {\n    color: var(--whoogle-contrast-text);\n}\n\n.link {\n    color: var(--whoogle-element-bg);\n}\n\n.link-color {\n    color: var(--whoogle-result-url) !important;\n}\n\n.autocomplete-items {\n    border: 1px solid var(--whoogle-element-bg);\n}\n\n.autocomplete-items div {\n    background-color: var(--whoogle-page-bg);\n    border-bottom: 1px solid var(--whoogle-element-bg);\n}\n\n.autocomplete-items div:hover {\n    background-color: var(--whoogle-element-bg);\n    color: var(--whoogle-contrast-text) !important;\n}\n\n.autocomplete-active {\n    background-color: var(--whoogle-element-bg) !important;\n    color: var(--whoogle-contrast-text) !important;\n}\n\n.footer {\n    color: var(--whoogle-text);\n}\n\npath {\n    fill: var(--whoogle-logo);\n}\n\n.header-div {\n    background-color: var(--whoogle-result-bg) !important;\n}\n\n#search-reset {\n    color: var(--whoogle-text) !important;\n}\n\n.mobile-search-bar {\n    background-color: var(--whoogle-result-bg) !important;\n    color: var(--whoogle-text) !important;\n}\n\n.search-bar-desktop {\n    background-color: var(--whoogle-result-bg) !important;\n    color: var(--whoogle-text);\n    border-bottom: 0px;\n}\n\n.ip-text-div, .update_available, .cb_label, .cb {\n  color: var(--whoogle-secondary-text) !important;\n}\n\n.cb:focus {\n  color: var(--whoogle-text) !important;\n}\n\n.desktop-header, .mobile-header {\n    background-color: var(--whoogle-result-bg) !important;\n}\n"
  },
  {
    "path": "app/static/css/logo.css",
    "content": ".cls-1 {\n    fill: transparent;\n}\n\nsvg {\n    height: inherit;\n}\n\na {\n    height: inherit;\n}\n\n@media (max-width: 1000px) {\n    svg {\n        margin-top: .3em;\n        height: 70%;\n    }\n}\n"
  },
  {
    "path": "app/static/css/main.css",
    "content": "body {\n    font-family: Avenir, Helvetica, Arial, sans-serif;\n}\n\n.logo {\n    width: 80%;\n    display: block;\n    margin: auto;\n    padding-bottom: 10px;\n}\n\n.logo-container {\n    max-height: 500px;\n}\n\n.home-search {\n    background: transparent !important;\n    border: 3px solid;\n}\n\n.search-container {\n    background: transparent !important;\n    width: 80%;\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n    max-width: 600px;\n    z-index: 15;\n}\n\n.search-items {\n    width: 100%;\n    position: relative;\n    display: flex;\n}\n\n#search-bar {\n    background: transparent !important;\n    width: 100%;\n    padding: 5px;\n    height: 40px;\n    outline: none;\n    font-size: 24px;\n    border-radius: 10px 10px 0 0;\n    max-width: 600px;\n    background: rgba(0, 0, 0, 0);\n}\n\n#search-submit {\n    width: 100%;\n    height: 40px;\n    text-align: center;\n    cursor: pointer;\n    font-size: 20px;\n    align-content: center;\n    align-items: center;\n    margin: auto;\n    border-radius: 0 0 10px 10px;\n    max-width: 600px;\n    -webkit-appearance: none;\n}\n\n.config-options {\n    max-height: 370px;\n    overflow-y: scroll;\n}\n\n.config-buttons {\n    max-height: 30px;\n}\n\n.config-div {\n    padding: 5px;\n}\n\nbutton::-moz-focus-inner {\n    border: 0;\n}\n\n.collapsible {\n    outline: 0;\n    background-color: rgba(0, 0, 0, 0);\n    cursor: pointer;\n    padding: 18px;\n    width: 100%;\n    border: none;\n    text-align: left;\n    outline: none;\n    font-size: 15px;\n    border-radius: 10px 10px 0 0;\n}\n\n.collapsible:after {\n    content: '\\002B';\n    font-weight: bold;\n    float: right;\n    margin-left: 5px;\n}\n\n.active:after {\n    content: \"\\2212\";\n}\n\n.content {\n    padding: 0 18px;\n    max-height: 0;\n    overflow: hidden;\n    transition: max-height 0.2s ease-out;\n    border-radius: 0 0 10px 10px;\n}\n\n.open {\n    padding-bottom: 20px;\n}\n\n.hidden {\n    display: none;\n}\n\nfooter {\n    position: fixed;\n    bottom: 0%;\n    text-align: center;\n    width: 100%;\n    z-index: 10;\n}\n\n.info-text {\n    font-style: italic;\n    font-size: 12px;\n}\n\n#config-style {\n    resize: none;\n    overflow-y: scroll;\n    width: 100%;\n    height: 100px;\n}\n\n.whoogle-logo {\n    display: none;\n}\n\n.whoogle-svg {\n    width: 80%;\n    height: initial;\n    display: block;\n    margin: auto;\n    padding-bottom: 10px;\n}\n\n.autocomplete {\n    position: relative;\n    display: inline-block;\n    width: 100%;\n}\n\n.autocomplete-items {\n    position: absolute;\n    border-bottom: none;\n    border-top: none;\n    z-index: 99;\n\n    /*position the autocomplete items to be the same width as the container:*/\n    top: 100%;\n    left: 0;\n    right: 0;\n}\n\n.autocomplete-items div {\n    padding: 10px;\n    cursor: pointer;\n}\n\ndetails summary {\n    padding: 10px;\n    font-weight: bold;\n}\n\n/* Mobile styles */\n@media (max-width: 1000px) {\n    select {\n        width: 100%;\n    }\n\n    #search-bar {\n        font-size: 20px;\n    }\n}\n"
  },
  {
    "path": "app/static/css/search.css",
    "content": "body {\n    display: block !important;\n    margin: auto !important;\n}\n\n.vvjwJb {\n    font-size: 16px !important;\n}\n\n.ezO2md {\n    border-radius: 10px;\n    border: 0 !important;\n    box-shadow: 0 3px 5px rgb(0 0 0 / 0.2);\n}\n\n.autocomplete {\n    position: relative;\n    display: inline-block;\n    width: 100%;\n}\n\n.autocomplete-items {\n    position: absolute;\n    border-bottom: none;\n    border-top: none;\n    z-index: 99;\n\n    /*position the autocomplete items to be the same width as the container:*/\n    top: 100%;\n    left: 0;\n    right: 0;\n}\n\n.autocomplete-items div {\n    padding: 10px;\n    cursor: pointer;\n}\n\ndetails summary {\n    margin-bottom: 20px;\n    font-weight: bold;\n    padding-left: 10px;\n}\n\ndetails summary span {\n    font-weight: normal;\n}\n\n#lingva-iframe {\n    width: 100%;\n    height: 650px;\n    border: 0;\n}\n\n.ip-address-div {\n    padding-bottom: 0 !important;\n}\n\n.ip-text-div {\n    padding-top: 0 !important;\n}\n\n.footer {\n    text-align: center;\n}\n\n.site-favicon {\n\tfloat: left;\n\twidth: 25px;\n\tpadding-right: 5px;\n}\n\n.has-favicon .sCuL3 {\n\tpadding-left: 30px;\n}\n\n#flex_text_audio_icon_chunk {\n\tdisplay: none;\n}\n\naudio {\n\tdisplay: block;\n\tmargin-right: auto;\n\tpadding-bottom: 5px;\n}\n\n@media (min-width: 801px) {\n    body {\n        min-width: 736px !important;\n    }\n}\n\n@media (max-width: 801px) {\n    details summary {\n        margin-bottom: 10px !important\n    }\n}\n"
  },
  {
    "path": "app/static/css/variables.css",
    "content": "/* Colors */\n:root {\n    /* LIGHT THEME COLORS */\n    --whoogle-logo: #685e79;\n    --whoogle-page-bg: #ffffff;\n    --whoogle-element-bg: #4285f4;\n    --whoogle-text: #000000;\n    --whoogle-contrast-text: #ffffff;\n    --whoogle-secondary-text: #70757a;\n    --whoogle-result-bg: #ffffff;\n    --whoogle-result-title: #1967d2;\n    --whoogle-result-url: #0d652d;\n    --whoogle-result-visited: #4b11a8;\n\n    /* DARK THEME COLORS */\n    --whoogle-dark-logo: #685e79;\n    --whoogle-dark-page-bg: #101020;\n    --whoogle-dark-element-bg: #4285f4;\n    --whoogle-dark-text: #ffffff;\n    --whoogle-dark-contrast-text: #ffffff;\n    --whoogle-dark-secondary-text: #bbbbbb;\n    --whoogle-dark-result-bg: #212131;\n    --whoogle-dark-result-title: #64a7f6;\n    --whoogle-dark-result-url: #34a853;\n    --whoogle-dark-result-visited: #bbbbff;\n}\n\n#whoogle-w {\n    fill: #4285f4;\n}\n\n#whoogle-h {\n    fill: #ea4335;\n}\n\n#whoogle-o-1 {\n    fill: #fbbc05;\n}\n\n#whoogle-o-2 {\n    fill: #4285f4;\n}\n\n#whoogle-g {\n    fill: #34a853;\n}\n\n#whoogle-l {\n    fill: #ea4335;\n}\n\n#whoogle-e {\n    fill: #fbbc05;\n}\n"
  },
  {
    "path": "app/static/img/favicon/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig><msapplication><tile><square70x70logo src=\"/ms-icon-70x70.png\"/><square150x150logo src=\"/ms-icon-150x150.png\"/><square310x310logo src=\"/ms-icon-310x310.png\"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>"
  },
  {
    "path": "app/static/img/favicon/manifest.json",
    "content": "{\n \"name\": \"Whoogle Search\",\n \"short_name\": \"Whoogle\",\n \"display\": \"fullscreen\",\n \"scope\": \"/\",\n \"icons\": [\n  {\n   \"src\": \"android-icon-36x36.png\",\n   \"sizes\": \"36x36\",\n   \"type\": \"image\\/png\",\n   \"density\": \"0.75\"\n  },\n  {\n   \"src\": \"android-icon-48x48.png\",\n   \"sizes\": \"48x48\",\n   \"type\": \"image\\/png\",\n   \"density\": \"1.0\"\n  },\n  {\n   \"src\": \"android-icon-72x72.png\",\n   \"sizes\": \"72x72\",\n   \"type\": \"image\\/png\",\n   \"density\": \"1.5\"\n  },\n  {\n   \"src\": \"android-icon-96x96.png\",\n   \"sizes\": \"96x96\",\n   \"type\": \"image\\/png\",\n   \"density\": \"2.0\"\n  },\n  {\n   \"src\": \"android-icon-144x144.png\",\n   \"sizes\": \"144x144\",\n   \"type\": \"image\\/png\",\n   \"density\": \"3.0\"\n  },\n  {\n   \"src\": \"android-icon-192x192.png\",\n   \"sizes\": \"192x192\",\n   \"type\": \"image\\/png\",\n   \"density\": \"4.0\"\n  }\n ]\n}\n"
  },
  {
    "path": "app/static/js/autocomplete.js",
    "content": "let searchInput;\nlet currentFocus;\nlet originalSearch;\nlet autocompleteResults;\n\nconst handleUserInput = () => {\n    let xhrRequest = new XMLHttpRequest();\n    xhrRequest.open(\"POST\", \"autocomplete\");\n    xhrRequest.setRequestHeader(\"Content-type\", \"application/x-www-form-urlencoded\");\n    xhrRequest.onload = function () {\n        if (xhrRequest.readyState === 4 && xhrRequest.status !== 200) {\n            // Do nothing if failed to fetch autocomplete results\n            return;\n        }\n\n        // Fill autocomplete with fetched results\n        autocompleteResults = JSON.parse(xhrRequest.responseText)[1];\n        updateAutocompleteList();\n    };\n\n    xhrRequest.send('q=' + searchInput.value);\n};\n\nconst removeActive = suggestion => {\n    // Remove \"autocomplete-active\" class from previously active suggestion\n    for (let i = 0; i < suggestion.length; i++) {\n        suggestion[i].classList.remove(\"autocomplete-active\");\n    }\n};\n\nconst addActive = (suggestion) => {\n    // Handle navigation outside of suggestion list\n    if (!suggestion || !suggestion[currentFocus]) {\n        if (currentFocus >= suggestion.length) {\n            // Move selection back to the beginning\n            currentFocus = 0;\n        } else if (currentFocus < 0) {\n            // Retrieve original search and remove active suggestion selection\n            currentFocus = -1;\n            searchInput.value = originalSearch;\n            removeActive(suggestion);\n            return;\n        } else {\n            return;\n        }\n    }\n\n    removeActive(suggestion);\n    suggestion[currentFocus].classList.add(\"autocomplete-active\");\n\n    // Autofill search bar with suggestion content (minus the \"bang name\" if using a bang operator)\n    let searchContent = suggestion[currentFocus].textContent;\n    if (searchContent.indexOf('(') > 0) {\n        searchInput.value = searchContent.substring(0, searchContent.indexOf('('));\n    } else {\n        searchInput.value = searchContent;\n    }\n\n    searchInput.focus();\n};\n\nconst autocompleteInput = (e) => {\n    // Handle navigation between autocomplete suggestions\n    let suggestion = document.getElementById(\"autocomplete-list\");\n    if (suggestion) suggestion = suggestion.getElementsByTagName(\"div\");\n    if (e.keyCode === 40) { // down\n        e.preventDefault();\n        currentFocus++;\n        addActive(suggestion);\n    } else if (e.keyCode === 38) { //up\n        e.preventDefault();\n        currentFocus--;\n        addActive(suggestion);\n    } else if (e.keyCode === 13) { // enter\n        e.preventDefault();\n        if (currentFocus > -1) {\n            if (suggestion) suggestion[currentFocus].click();\n        }\n    } else {\n        originalSearch = searchInput.value;\n    }\n};\n\nconst updateAutocompleteList = () => {\n    let autocompleteItem, i;\n    let val = originalSearch;\n\n    let autocompleteList = document.getElementById(\"autocomplete-list\");\n    autocompleteList.innerHTML = \"\";\n\n    if (!val || !autocompleteResults) {\n        return false;\n    }\n\n    currentFocus = -1;\n\n    for (i = 0; i < autocompleteResults.length; i++) {\n        if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {\n            autocompleteItem = document.createElement(\"div\");\n            autocompleteItem.setAttribute(\"class\", \"autocomplete-item\");\n            autocompleteItem.innerHTML = \"<strong>\" + autocompleteResults[i].substr(0, val.length) + \"</strong>\";\n            autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);\n            autocompleteItem.innerHTML += \"<input type=\\\"hidden\\\" value=\\\"\" + autocompleteResults[i] + \"\\\">\";\n            autocompleteItem.addEventListener(\"click\", function () {\n                searchInput.value = this.getElementsByTagName(\"input\")[0].value;\n                autocompleteList.innerHTML = \"\";\n                document.getElementById(\"search-form\").submit();\n            });\n            autocompleteList.appendChild(autocompleteItem);\n        }\n    }\n};\n\ndocument.addEventListener(\"DOMContentLoaded\", function() {\n    let autocompleteList = document.createElement(\"div\");\n    autocompleteList.setAttribute(\"id\", \"autocomplete-list\");\n    autocompleteList.setAttribute(\"class\", \"autocomplete-items\");\n\n    searchInput = document.getElementById(\"search-bar\");\n    searchInput.parentNode.appendChild(autocompleteList);\n\n    searchInput.addEventListener(\"keydown\", (event) => autocompleteInput(event));\n\n    document.addEventListener(\"click\", function (e) {\n        autocompleteList.innerHTML = \"\";\n    });\n});\n"
  },
  {
    "path": "app/static/js/controller.js",
    "content": "const setupSearchLayout = () => {\n    // Setup search field\n    const searchBar = document.getElementById(\"search-bar\");\n    const searchBtn = document.getElementById(\"search-submit\");\n    const arrowKeys = [37, 38, 39, 40];\n    let searchValue = searchBar.value;\n\n    // Automatically focus on search field\n    searchBar.focus();\n    searchBar.select();\n\n    searchBar.addEventListener(\"keyup\", function(event) {\n        if (event.keyCode === 13) {\n            event.preventDefault();\n            searchBtn.click();\n        } else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {\n            searchValue = searchBar.value;\n            handleUserInput();\n        }\n    });\n};\n\nconst setupConfigLayout = () => {\n    // Setup whoogle config\n    const collapsible = document.getElementById(\"config-collapsible\");\n    collapsible.addEventListener(\"click\", function() {\n        this.classList.toggle(\"active\");\n        let content = this.nextElementSibling;\n        if (content.style.maxHeight) {\n            content.style.maxHeight = null;\n        } else {\n            content.style.maxHeight = \"400px\";\n        }\n\n        content.classList.toggle(\"open\");\n    });\n\n    // Setup user agent dropdown handler\n    const userAgentSelect = document.getElementById(\"config-user-agent\");\n    const customUserAgentDiv = document.querySelector(\".config-div-custom-user-agent\");\n    \n    if (userAgentSelect && customUserAgentDiv) {\n        userAgentSelect.addEventListener(\"change\", function() {\n            if (this.value === \"custom\") {\n                customUserAgentDiv.style.display = \"block\";\n            } else {\n                customUserAgentDiv.style.display = \"none\";\n            }\n        });\n    }\n};\n\nconst loadConfig = event => {\n    event.preventDefault();\n    let config = prompt(\"Enter name of config:\");\n    if (!config) {\n        alert(\"Must specify a name for the config to load\");\n        return;\n    }\n\n    let xhrPUT = new XMLHttpRequest();\n    xhrPUT.open(\"PUT\", \"config?name=\" + config + \".conf\");\n    xhrPUT.onload = function() {\n        if (xhrPUT.readyState === 4 && xhrPUT.status !== 200) {\n            alert(\"Error loading Whoogle config\");\n            return;\n        }\n\n        location.reload(true);\n    };\n\n    xhrPUT.send();\n};\n\nconst saveConfig = event => {\n    event.preventDefault();\n    let config = prompt(\"Enter name for this config:\");\n    if (!config) {\n        alert(\"Must specify a name for the config to save\");\n        return;\n    }\n\n    let configForm = document.getElementById(\"config-form\");\n    configForm.action = 'config?name=' + config + \".conf\";\n    configForm.submit();\n};\n\ndocument.addEventListener(\"DOMContentLoaded\", function() {\n    setTimeout(function() {\n        document.getElementById(\"main\").style.display = \"block\";\n    }, 100);\n\n    setupSearchLayout();\n    setupConfigLayout();\n\n    document.getElementById(\"config-load\").addEventListener(\"click\", loadConfig);\n    document.getElementById(\"config-save\").addEventListener(\"click\", saveConfig);\n\n    // Focusing on the search input field requires a delay for elements to finish\n    // loading (seemingly only on FF)\n    setTimeout(function() { document.getElementById(\"search-bar\").focus(); }, 250);\n});\n"
  },
  {
    "path": "app/static/js/currency.js",
    "content": "const convert = (n1, n2, conversionFactor) => {\n    // id's for currency input boxes\n    let id1 = \"cb\" + n1; \n    let id2 = \"cb\" + n2;\n    // getting the value of the input box that just got filled\n    let inputBox = document.getElementById(id1).value;\n    // updating the other input box after conversion\n    document.getElementById(id2).value = ((inputBox * conversionFactor).toFixed(2));\n}\n"
  },
  {
    "path": "app/static/js/header.js",
    "content": "document.addEventListener(\"DOMContentLoaded\", () => {\n    const advSearchToggle = document.getElementById(\"adv-search-toggle\");\n    const advSearchDiv = document.getElementById(\"adv-search-div\");\n    const searchBar = document.getElementById(\"search-bar\");\n    const countrySelect = document.getElementById(\"result-country\");\n    const timePeriodSelect = document.getElementById(\"result-time-period\");\n    const arrowKeys = [37, 38, 39, 40];\n    let searchValue = searchBar.value;\n\n    countrySelect.onchange = () => {\n        let str = window.location.href;\n        n = str.lastIndexOf(\"/search\");\n        if (n > 0) {\n            str = str.substring(0, n) + `/search?q=${searchBar.value}`;\n            str = tackOnParams(str);\n            window.location.href = str;\n        }\n    }\n\n    timePeriodSelect.onchange = () => {\n        let str = window.location.href;\n        n = str.lastIndexOf(\"/search\");\n        if (n > 0) {\n            str = str.substring(0, n) + `/search?q=${searchBar.value}`;\n            str = tackOnParams(str);\n            window.location.href = str;\n        }\n    }\n\n    function tackOnParams(str) {\n        if (timePeriodSelect.value != \"\") {\n            str = str + `&tbs=${timePeriodSelect.value}`;\n        }\n        if (countrySelect.value != \"\") {\n            str = str + `&country=${countrySelect.value}`;\n        }\n        return str;\n    }\n\n    const toggleAdvancedSearch = on => {\n        if (on) {\n            advSearchDiv.style.maxHeight = \"70px\";\n        } else {\n            advSearchDiv.style.maxHeight = \"0px\";\n        }\n        localStorage.advSearchToggled = on;\n    }\n\n    try {\n        toggleAdvancedSearch(JSON.parse(localStorage.advSearchToggled));\n    } catch (error) {\n        console.warn(\"Did not recover advanced search toggle state\");\n    }\n\n    advSearchToggle.onclick = () => {\n        toggleAdvancedSearch(advSearchToggle.checked);\n    }\n\n    searchBar.addEventListener(\"keyup\", function(event) {\n        if (event.keyCode === 13) {\n            document.getElementById(\"search-form\").submit();\n        } else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {\n            searchValue = searchBar.value;\n            handleUserInput();\n        }\n    });\n});\n"
  },
  {
    "path": "app/static/js/keyboard.js",
    "content": "(function () {\n    let searchBar, results;\n    let shift = false;\n    const keymap = {\n        ArrowUp: goUp,\n        ArrowDown: goDown,\n        ShiftTab: goUp,\n        Tab: goDown,\n        k: goUp,\n        j: goDown,\n        '/': focusSearch,\n    };\n    let activeIdx = -1;\n\n    document.addEventListener('DOMContentLoaded', () => {\n        searchBar = document.querySelector('#search-bar');\n        results = document.querySelectorAll('#main>div>div>div>a');\n    });\n\n    document.addEventListener('keydown', (e) => {\n        if (e.key === 'Shift') {\n            shift = true;\n        }\n\n        if (e.target.tagName === 'INPUT') return true;\n        if (typeof keymap[e.key] === 'function') {\n            e.preventDefault();\n\n            keymap[`${shift && e.key == 'Tab' ? 'Shift' : ''}${e.key}`]();\n        }\n    });\n\n    document.addEventListener('keyup', (e) => {\n        if (e.key === 'Shift') {\n            shift = false;\n        }\n    });\n\n    function goUp () {\n        if (activeIdx > 0) focusResult(activeIdx - 1);\n        else focusSearch();\n    }\n\n    function goDown () {\n        if (activeIdx < results.length - 1) focusResult(activeIdx + 1);\n    }\n\n    function focusResult (idx) {\n        activeIdx = idx;\n        results[activeIdx].scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });\n        results[activeIdx].focus();\n    }\n\n    function focusSearch () {\n        if (window.usingCalculator) {\n            // if this function exists, it means the calculator widget has been displayed\n            if (usingCalculator()) return;\n        }\n        activeIdx = -1;\n        searchBar.focus();\n    }\n}());\n"
  },
  {
    "path": "app/static/js/utils.js",
    "content": "const checkForTracking = () => {\n    const mainDiv = document.getElementById(\"main\");\n    const searchBar = document.getElementById(\"search-bar\");\n    // some pages (e.g. images) do not have these\n    if (!mainDiv || !searchBar)\n        return;\n    const query = searchBar.value.replace(/\\s+/g, '');\n\n    // Note: regex functions for checking for tracking queries were derived\n    // from here -- https://stackoverflow.com/questions/619977\n    const matchTracking = {\n        \"ups\": {\n            \"link\": `https://www.ups.com/track?tracknum=${query}`,\n            \"expr\": [\n                /\\b(1Z ?[0-9A-Z]{3} ?[0-9A-Z]{3} ?[0-9A-Z]{2} ?[0-9A-Z]{4} ?[0-9A-Z]{3} ?[0-9A-Z]|[\\dT]\\d\\d\\d ?\\d\\d\\d\\d ?\\d\\d\\d)\\b/\n            ]\n        },\n        \"usps\": {\n            \"link\": `https://tools.usps.com/go/TrackConfirmAction_input?origTrackNum=${query}`,\n            \"expr\": [\n                /(\\b\\d{30}\\b)|(\\b91\\d+\\b)|(\\b\\d{20}\\b)/,\n                /^E\\D{1}\\d{9}\\D{2}$|^9\\d{15,21}$/,\n                /^91[0-9]+$/,\n                /^[A-Za-z]{2}[0-9]+US$/\n            ]\n        },\n        \"fedex\": {\n            \"link\": `https://www.fedex.com/apps/fedextrack/?tracknumbers=${query}`,\n            \"expr\": [\n                /(\\b96\\d{20}\\b)|(\\b\\d{15}\\b)|(\\b\\d{12}\\b)/,\n                /\\b((98\\d\\d\\d\\d\\d?\\d\\d\\d\\d|98\\d\\d) ?\\d\\d\\d\\d ?\\d\\d\\d\\d( ?\\d\\d\\d)?)\\b/,\n                /^[0-9]{15}$/\n            ]\n        }\n    };\n    \n    // Creates a link to a UPS/USPS/FedEx tracking page\n    const createTrackingLink = href => {\n        let link = document.createElement(\"a\");\n        link.className = \"tracking-link\";\n        link.innerHTML = \"View Tracking Info\";\n        link.href = href;\n        mainDiv.prepend(link);\n    };\n\n    // Compares the query against a set of regex patterns\n    // for tracking numbers\n    const compareQuery = provider => {\n        provider.expr.some(regex => {\n            if (query.match(regex)) {\n                createTrackingLink(provider.link);\n                return true;\n            }\n        });\n    };\n\n    for (const key of Object.keys(matchTracking)) {\n        compareQuery(matchTracking[key]);\n    }\n};\n\ndocument.addEventListener(\"DOMContentLoaded\", function() {\n    checkForTracking();\n\n    // Clear input if reset button tapped\n    const searchBar = document.getElementById(\"search-bar\");\n    const resetBtn = document.getElementById(\"search-reset\");\n    // some pages (e.g. images) do not have these\n    if (!searchBar || !resetBtn)\n        return;\n    resetBtn.addEventListener(\"click\", event => {\n        event.preventDefault();\n        searchBar.value = \"\";\n        searchBar.focus();\n    });\n});\n"
  },
  {
    "path": "app/static/settings/countries.json",
    "content": "[\n  {\"name\": \"-------\", \"value\": \"\"},\n  {\"name\": \"Afghanistan\", \"value\": \"AF\"},\n  {\"name\": \"Albania\", \"value\": \"AL\"},\n  {\"name\": \"Algeria\", \"value\": \"DZ\"},\n  {\"name\": \"American Samoa\", \"value\": \"AS\"},\n  {\"name\": \"Andorra\", \"value\": \"AD\"},\n  {\"name\": \"Angola\", \"value\": \"AO\"},\n  {\"name\": \"Anguilla\", \"value\": \"AI\"},\n  {\"name\": \"Antarctica\", \"value\": \"AQ\"},\n  {\"name\": \"Antigua and Barbuda\", \"value\": \"AG\"},\n  {\"name\": \"Argentina\", \"value\": \"AR\"},\n  {\"name\": \"Armenia\", \"value\": \"AM\"},\n  {\"name\": \"Aruba\", \"value\": \"AW\"},\n  {\"name\": \"Australia\", \"value\": \"AU\"},\n  {\"name\": \"Austria\", \"value\": \"AT\"},\n  {\"name\": \"Azerbaijan\", \"value\": \"AZ\"},\n  {\"name\": \"Bahamas\", \"value\": \"BS\"},\n  {\"name\": \"Bahrain\", \"value\": \"BH\"},\n  {\"name\": \"Bangladesh\", \"value\": \"BD\"},\n  {\"name\": \"Barbados\", \"value\": \"BB\"},\n  {\"name\": \"Belarus\", \"value\": \"BY\"},\n  {\"name\": \"Belgium\", \"value\": \"BE\"},\n  {\"name\": \"Belize\", \"value\": \"BZ\"},\n  {\"name\": \"Benin\", \"value\": \"BJ\"},\n  {\"name\": \"Bermuda\", \"value\": \"BM\"},\n  {\"name\": \"Bhutan\", \"value\": \"BT\"},\n  {\"name\": \"Bolivia\", \"value\": \"BO\"},\n  {\"name\": \"Bosnia and Herzegovina\", \"value\": \"BA\"},\n  {\"name\": \"Botswana\", \"value\": \"BW\"},\n  {\"name\": \"Bouvet Island\", \"value\": \"BV\"},\n  {\"name\": \"Brazil\", \"value\": \"BR\"},\n  {\"name\": \"British Indian Ocean Territory\", \"value\": \"IO\"},\n  {\"name\": \"Brunei Darussalam\", \"value\": \"BN\"},\n  {\"name\": \"Bulgaria\", \"value\": \"BG\"},\n  {\"name\": \"Burkina Faso\", \"value\": \"BF\"},\n  {\"name\": \"Burundi\", \"value\": \"BI\"},\n  {\"name\": \"Cambodia\", \"value\": \"KH\"},\n  {\"name\": \"Cameroon\", \"value\": \"CM\"},\n  {\"name\": \"Canada\", \"value\": \"CA\"},\n  {\"name\": \"Cape Verde\", \"value\": \"CV\"},\n  {\"name\": \"Cayman Islands\", \"value\": \"KY\"},\n  {\"name\": \"Central African Republic\", \"value\": \"CF\"},\n  {\"name\": \"Chad\", \"value\": \"TD\"},\n  {\"name\": \"Chile\", \"value\": \"CL\"},\n  {\"name\": \"China\", \"value\": \"CN\"},\n  {\"name\": \"Christmas Island\", \"value\": \"CX\"},\n  {\"name\": \"Cocos (Keeling) Islands\", \"value\": \"CC\"},\n  {\"name\": \"Colombia\", \"value\": \"CO\"},\n  {\"name\": \"Comoros\", \"value\": \"KM\"},\n  {\"name\": \"Congo\", \"value\": \"CG\"},\n  {\"name\": \"Congo, Democratic Republic of the\", \"value\": \"CD\"},\n  {\"name\": \"Cook Islands\", \"value\": \"CK\"},\n  {\"name\": \"Costa Rica\", \"value\": \"CR\"},\n  {\"name\": \"Cote D'ivoire\", \"value\": \"CI\"},\n  {\"name\": \"Croatia (Hrvatska)\", \"value\": \"HR\"},\n  {\"name\": \"Cuba\", \"value\": \"CU\"},\n  {\"name\": \"Cyprus\", \"value\": \"CY\"},\n  {\"name\": \"Czech Republic\", \"value\": \"CZ\"},\n  {\"name\": \"Denmark\", \"value\": \"DK\"},\n  {\"name\": \"Djibouti\", \"value\": \"DJ\"},\n  {\"name\": \"Dominica\", \"value\": \"DM\"},\n  {\"name\": \"Dominican Republic\", \"value\": \"DO\"},\n  {\"name\": \"East Timor\", \"value\": \"TP\"},\n  {\"name\": \"Ecuador\", \"value\": \"EC\"},\n  {\"name\": \"Egypt\", \"value\": \"EG\"},\n  {\"name\": \"El Salvador\", \"value\": \"SV\"},\n  {\"name\": \"Equatorial Guinea\", \"value\": \"GQ\"},\n  {\"name\": \"Eritrea\", \"value\": \"ER\"},\n  {\"name\": \"Estonia\", \"value\": \"EE\"},\n  {\"name\": \"Ethiopia\", \"value\": \"ET\"},\n  {\"name\": \"European Union\", \"value\": \"EU\"},\n  {\"name\": \"Falkland Islands (Malvinas)\", \"value\": \"FK\"},\n  {\"name\": \"Faroe Islands\", \"value\": \"FO\"},\n  {\"name\": \"Fiji\", \"value\": \"FJ\"},\n  {\"name\": \"Finland\", \"value\": \"FI\"},\n  {\"name\": \"France\", \"value\": \"FR\"},\n  {\"name\": \"France, Metropolitan\", \"value\": \"FX\"},\n  {\"name\": \"French Guiana\", \"value\": \"GF\"},\n  {\"name\": \"French Polynesia\", \"value\": \"PF\"},\n  {\"name\": \"French Southern Territories\", \"value\": \"TF\"},\n  {\"name\": \"Gabon\", \"value\": \"GA\"},\n  {\"name\": \"Gambia\", \"value\": \"GM\"},\n  {\"name\": \"Georgia\", \"value\": \"GE\"},\n  {\"name\": \"Germany\", \"value\": \"DE\"},\n  {\"name\": \"Ghana\", \"value\": \"GH\"},\n  {\"name\": \"Gibraltar\", \"value\": \"GI\"},\n  {\"name\": \"Greece\", \"value\": \"GR\"},\n  {\"name\": \"Greenland\", \"value\": \"GL\"},\n  {\"name\": \"Grenada\", \"value\": \"GD\"},\n  {\"name\": \"Guadeloupe\", \"value\": \"GP\"},\n  {\"name\": \"Guam\", \"value\": \"GU\"},\n  {\"name\": \"Guatemala\", \"value\": \"GT\"},\n  {\"name\": \"Guinea\", \"value\": \"GN\"},\n  {\"name\": \"Guinea-Bissau\", \"value\": \"GW\"},\n  {\"name\": \"Guyana\", \"value\": \"GY\"},\n  {\"name\": \"Haiti\", \"value\": \"HT\"},\n  {\"name\": \"Heard Island and Mcdonald Islands\", \"value\": \"HM\"},\n  {\"name\": \"Holy See (Vatican City State)\", \"value\": \"VA\"},\n  {\"name\": \"Honduras\", \"value\": \"HN\"},\n  {\"name\": \"Hong Kong\", \"value\": \"HK\"},\n  {\"name\": \"Hungary\", \"value\": \"HU\"},\n  {\"name\": \"Iceland\", \"value\": \"IS\"},\n  {\"name\": \"India\", \"value\": \"IN\"},\n  {\"name\": \"Indonesia\", \"value\": \"ID\"},\n  {\"name\": \"Iran, Islamic Republic of\", \"value\": \"IR\"},\n  {\"name\": \"Iraq\", \"value\": \"IQ\"},\n  {\"name\": \"Ireland\", \"value\": \"IE\"},\n  {\"name\": \"Israel\", \"value\": \"IL\"},\n  {\"name\": \"Italy\", \"value\": \"IT\"},\n  {\"name\": \"Jamaica\", \"value\": \"JM\"},\n  {\"name\": \"Japan\", \"value\": \"JP\"},\n  {\"name\": \"Jordan\", \"value\": \"JO\"},\n  {\"name\": \"Kazakhstan\", \"value\": \"KZ\"},\n  {\"name\": \"Kenya\", \"value\": \"KE\"},\n  {\"name\": \"Kiribati\", \"value\": \"KI\"},\n  {\"name\": \"Korea, Democratic People's Republic of\", \"value\": \"KP\"},\n  {\"name\": \"Korea, Republic of\", \"value\": \"KR\"},\n  {\"name\": \"Kuwait\", \"value\": \"KW\"},\n  {\"name\": \"Kyrgyzstan\", \"value\": \"KG\"},\n  {\"name\": \"Lao People's Democratic Republic\", \"value\": \"LA\"},\n  {\"name\": \"Latvia\", \"value\": \"LV\"},\n  {\"name\": \"Lebanon\", \"value\": \"LB\"},\n  {\"name\": \"Lesotho\", \"value\": \"LS\"},\n  {\"name\": \"Liberia\", \"value\": \"LR\"},\n  {\"name\": \"Libyan Arab Jamahiriya\", \"value\": \"LY\"},\n  {\"name\": \"Liechtenstein\", \"value\": \"LI\"},\n  {\"name\": \"Lithuania\", \"value\": \"LT\"},\n  {\"name\": \"Luxembourg\", \"value\": \"LU\"},\n  {\"name\": \"Macao\", \"value\": \"MO\"},\n  {\"name\": \"Madagascar\", \"value\": \"MG\"},\n  {\"name\": \"Malawi\", \"value\": \"MW\"},\n  {\"name\": \"Malaysia\", \"value\": \"MY\"},\n  {\"name\": \"Maldives\", \"value\": \"MV\"},\n  {\"name\": \"Mali\", \"value\": \"ML\"},\n  {\"name\": \"Malta\", \"value\": \"MT\"},\n  {\"name\": \"Marshall Islands\", \"value\": \"MH\"},\n  {\"name\": \"Martinique\", \"value\": \"MQ\"},\n  {\"name\": \"Mauritania\", \"value\": \"MR\"},\n  {\"name\": \"Mauritius\", \"value\": \"MU\"},\n  {\"name\": \"Mayotte\", \"value\": \"YT\"},\n  {\"name\": \"Mexico\", \"value\": \"MX\"},\n  {\"name\": \"Micronesia, Federated States of\", \"value\": \"FM\"},\n  {\"name\": \"Moldova, Republic of\", \"value\": \"MD\"},\n  {\"name\": \"Monaco\", \"value\": \"MC\"},\n  {\"name\": \"Mongolia\", \"value\": \"MN\"},\n  {\"name\": \"Montserrat\", \"value\": \"MS\"},\n  {\"name\": \"Morocco\", \"value\": \"MA\"},\n  {\"name\": \"Mozambique\", \"value\": \"MZ\"},\n  {\"name\": \"Myanmar\", \"value\": \"MM\"},\n  {\"name\": \"Namibia\", \"value\": \"NA\"},\n  {\"name\": \"Nauru\", \"value\": \"NR\"},\n  {\"name\": \"Nepal\", \"value\": \"NP\"},\n  {\"name\": \"Netherlands\", \"value\": \"NL\"},\n  {\"name\": \"Netherlands Antilles\", \"value\": \"AN\"},\n  {\"name\": \"New Caledonia\", \"value\": \"NC\"},\n  {\"name\": \"New Zealand\", \"value\": \"NZ\"},\n  {\"name\": \"Nicaragua\", \"value\": \"NI\"},\n  {\"name\": \"Niger\", \"value\": \"NE\"},\n  {\"name\": \"Nigeria\", \"value\": \"NG\"},\n  {\"name\": \"Niue\", \"value\": \"NU\"},\n  {\"name\": \"Norfolk Island\", \"value\": \"NF\"},\n  {\"name\": \"North Macedonia\", \"value\": \"MK\"},\n  {\"name\": \"Northern Mariana Islands\", \"value\": \"MP\"},\n  {\"name\": \"Norway\", \"value\": \"NO\"},\n  {\"name\": \"Oman\", \"value\": \"OM\"},\n  {\"name\": \"Pakistan\", \"value\": \"PK\"},\n  {\"name\": \"Palau\", \"value\": \"PW\"},\n  {\"name\": \"Palestinian Territory\", \"value\": \"PS\"},\n  {\"name\": \"Panama\", \"value\": \"PA\"},\n  {\"name\": \"Papua New Guinea\", \"value\": \"PG\"},\n  {\"name\": \"Paraguay\", \"value\": \"PY\"},\n  {\"name\": \"Peru\", \"value\": \"PE\"},\n  {\"name\": \"Philippines\", \"value\": \"PH\"},\n  {\"name\": \"Pitcairn\", \"value\": \"PN\"},\n  {\"name\": \"Poland\", \"value\": \"PL\"},\n  {\"name\": \"Portugal\", \"value\": \"PT\"},\n  {\"name\": \"Puerto Rico\", \"value\": \"PR\"},\n  {\"name\": \"Qatar\", \"value\": \"QA\"},\n  {\"name\": \"Reunion\", \"value\": \"RE\"},\n  {\"name\": \"Romania\", \"value\": \"RO\"},\n  {\"name\": \"Russian Federation\", \"value\": \"RU\"},\n  {\"name\": \"Rwanda\", \"value\": \"RW\"},\n  {\"name\": \"Saint Helena\", \"value\": \"SH\"},\n  {\"name\": \"Saint Kitts and Nevis\", \"value\": \"KN\"},\n  {\"name\": \"Saint Lucia\", \"value\": \"LC\"},\n  {\"name\": \"Saint Pierre and Miquelon\", \"value\": \"PM\"},\n  {\"name\": \"Saint Vincent and the Grenadines\", \"value\": \"VC\"},\n  {\"name\": \"Samoa\", \"value\": \"WS\"},\n  {\"name\": \"San Marino\", \"value\": \"SM\"},\n  {\"name\": \"Sao Tome and Principe\", \"value\": \"ST\"},\n  {\"name\": \"Saudi Arabia\", \"value\": \"SA\"},\n  {\"name\": \"Senegal\", \"value\": \"SN\"},\n  {\"name\": \"Serbia and Montenegro\", \"value\": \"CS\"},\n  {\"name\": \"Seychelles\", \"value\": \"SC\"},\n  {\"name\": \"Sierra Leone\", \"value\": \"SL\"},\n  {\"name\": \"Singapore\", \"value\": \"SG\"},\n  {\"name\": \"Slovakia\", \"value\": \"SK\"},\n  {\"name\": \"Slovenia\", \"value\": \"SI\"},\n  {\"name\": \"Solomon Islands\", \"value\": \"SB\"},\n  {\"name\": \"Somalia\", \"value\": \"SO\"},\n  {\"name\": \"South Africa\", \"value\": \"ZA\"},\n  {\"name\": \"South Georgia and the South Sandwich Islands\", \"value\": \"GS\"},\n  {\"name\": \"Spain\", \"value\": \"ES\"},\n  {\"name\": \"Sri Lanka\", \"value\": \"LK\"},\n  {\"name\": \"Sudan\", \"value\": \"SD\"},\n  {\"name\": \"Suriname\", \"value\": \"SR\"},\n  {\"name\": \"Svalbard and Jan Mayen\", \"value\": \"SJ\"},\n  {\"name\": \"Swaziland\", \"value\": \"SZ\"},\n  {\"name\": \"Sweden\", \"value\": \"SE\"},\n  {\"name\": \"Switzerland\", \"value\": \"CH\"},\n  {\"name\": \"Syrian Arab Republic\", \"value\": \"SY\"},\n  {\"name\": \"Taiwan\", \"value\": \"TW\"},\n  {\"name\": \"Tajikistan\", \"value\": \"TJ\"},\n  {\"name\": \"Tanzania, United Republic of\", \"value\": \"TZ\"},\n  {\"name\": \"Thailand\", \"value\": \"TH\"},\n  {\"name\": \"Togo\", \"value\": \"TG\"},\n  {\"name\": \"Tokelau\", \"value\": \"TK\"},\n  {\"name\": \"Tonga\", \"value\": \"TO\"},\n  {\"name\": \"Trinidad and Tobago\", \"value\": \"TT\"},\n  {\"name\": \"Tunisia\", \"value\": \"TN\"},\n  {\"name\": \"Turkmenistan\", \"value\": \"TM\"},\n  {\"name\": \"Turks and Caicos Islands\", \"value\": \"TC\"},\n  {\"name\": \"Tuvalu\", \"value\": \"TV\"},\n  {\"name\": \"Türkiye\", \"value\": \"TR\"},\n  {\"name\": \"Uganda\", \"value\": \"UG\"},\n  {\"name\": \"Ukraine\", \"value\": \"UA\"},\n  {\"name\": \"United Arab Emirates\", \"value\": \"AE\"},\n  {\"name\": \"United Kingdom\", \"value\": \"UK\"},\n  {\"name\": \"United States\", \"value\": \"US\"},\n  {\"name\": \"United States Minor Outlying Islands\", \"value\": \"UM\"},\n  {\"name\": \"Uruguay\", \"value\": \"UY\"},\n  {\"name\": \"Uzbekistan\", \"value\": \"UZ\"},\n  {\"name\": \"Vanuatu\", \"value\": \"VU\"},\n  {\"name\": \"Venezuela\", \"value\": \"VE\"},\n  {\"name\": \"Vietnam\", \"value\": \"VN\"},\n  {\"name\": \"Virgin Islands, British\", \"value\": \"VG\"},\n  {\"name\": \"Virgin Islands, U.S.\", \"value\": \"VI\"},\n  {\"name\": \"Wallis and Futuna\", \"value\": \"WF\"},\n  {\"name\": \"Western Sahara\", \"value\": \"EH\"},\n  {\"name\": \"Yemen\", \"value\": \"YE\"},\n  {\"name\": \"Yugoslavia\", \"value\": \"YU\"},\n  {\"name\": \"Zambia\", \"value\": \"ZM\"},\n  {\"name\": \"Zimbabwe\", \"value\": \"ZW\"}\n]\n"
  },
  {
    "path": "app/static/settings/header_tabs.json",
    "content": "{\n  \"all\": {\n      \"tbm\": null,\n      \"href\": \"search?q={query}\",\n      \"name\": \"All\",\n      \"selected\": true\n  },\n  \"images\": {\n      \"tbm\": \"isch\",\n      \"href\": \"search?q={query}\",\n      \"name\": \"Images\",\n      \"selected\": false\n  },\n  \"maps\": {\n      \"tbm\": null,\n      \"href\": \"https://maps.google.com/maps?q={map_query}\",\n      \"name\": \"Maps\",\n      \"selected\": false\n  },\n  \"videos\": {\n      \"tbm\": \"vid\",\n      \"href\": \"search?q={query}\",\n      \"name\": \"Videos\",\n      \"selected\": false\n  },\n  \"news\": {\n      \"tbm\": \"nws\",\n      \"href\": \"search?q={query}\",\n      \"name\": \"News\",\n      \"selected\": false\n  }\n}\n"
  },
  {
    "path": "app/static/settings/languages.json",
    "content": "[\n  {\"name\": \"-------\", \"value\": \"\"},\n  {\"name\": \"English\", \"value\": \"lang_en\"},\n  {\"name\": \"Afrikaans (Afrikaans)\", \"value\": \"lang_af\"},\n  {\"name\": \"Arabic (عربى)\", \"value\": \"lang_ar\"},\n  {\"name\": \"Armenian (հայերեն)\", \"value\": \"lang_hy\"},\n  {\"name\": \"Azerbaijani (Azərbaycanca)\", \"value\": \"lang_az\"},\n  {\"name\": \"Belarusian (Беларуская)\", \"value\": \"lang_be\"},\n  {\"name\": \"Bulgarian (български)\", \"value\": \"lang_bg\"},\n  {\"name\": \"Catalan (Català)\", \"value\": \"lang_ca\"},\n  {\"name\": \"Chinese, Simplified (简体中文)\", \"value\": \"lang_zh-CN\"},\n  {\"name\": \"Chinese, Traditional (正體中文)\", \"value\": \"lang_zh-TW\"},\n  {\"name\": \"Croatian (Hrvatski)\", \"value\": \"lang_hr\"},\n  {\"name\": \"Czech (čeština)\", \"value\": \"lang_cs\"},\n  {\"name\": \"Danish (Dansk)\", \"value\": \"lang_da\"},\n  {\"name\": \"Dutch (Nederlands)\", \"value\": \"lang_nl\"},\n  {\"name\": \"Esperanto (Esperanto)\", \"value\": \"lang_eo\"},\n  {\"name\": \"Estonian (Eestlane)\", \"value\": \"lang_et\"},\n  {\"name\": \"Filipino (Pilipino)\", \"value\": \"lang_tl\"},\n  {\"name\": \"Finnish (Suomalainen)\", \"value\": \"lang_fi\"},\n  {\"name\": \"French (Français)\", \"value\": \"lang_fr\"},\n  {\"name\": \"German (Deutsch)\", \"value\": \"lang_de\"},\n  {\"name\": \"Greek (Ελληνικά)\", \"value\": \"lang_el\"},\n  {\"name\": \"Hebrew (עִברִית)\", \"value\": \"lang_iw\"},\n  {\"name\": \"Hindi (हिंदी)\", \"value\": \"lang_hi\"},\n  {\"name\": \"Hungarian (Magyar)\", \"value\": \"lang_hu\"},\n  {\"name\": \"Icelandic (Íslenska)\", \"value\": \"lang_is\"},\n  {\"name\": \"Indonesian (Indonesian)\", \"value\": \"lang_id\"},\n  {\"name\": \"Italian (Italiano)\", \"value\": \"lang_it\"},\n  {\"name\": \"Japanese (日本語)\", \"value\": \"lang_ja\"},\n  {\"name\": \"Korean (한국어)\", \"value\": \"lang_ko\"},\n  {\"name\": \"Kurdish (Kurdî)\", \"value\": \"lang_ku\"},\n  {\"name\": \"Latvian (Latvietis)\", \"value\": \"lang_lv\"},\n  {\"name\": \"Lithuanian (Lietuvis)\", \"value\": \"lang_lt\"},\n  {\"name\": \"Norwegian (Norwegian)\", \"value\": \"lang_no\"},\n  {\"name\": \"Persian (فارسی)\", \"value\": \"lang_fa\"},\n  {\"name\": \"Polish (Polskie)\", \"value\": \"lang_pl\"},\n  {\"name\": \"Portuguese (Português)\", \"value\": \"lang_pt\"},\n  {\"name\": \"Romanian (Română)\", \"value\": \"lang_ro\"},\n  {\"name\": \"Russian (русский)\", \"value\": \"lang_ru\"},\n  {\"name\": \"Serbian (Српски)\", \"value\": \"lang_sr\"},\n  {\"name\": \"Sinhala (සිංහල)\", \"value\": \"lang_si\"},\n  {\"name\": \"Slovak (Slovák)\", \"value\": \"lang_sk\"},\n  {\"name\": \"Slovenian (Slovenščina)\", \"value\": \"lang_sl\"},\n  {\"name\": \"Spanish (Español)\", \"value\": \"lang_es\"},\n  {\"name\": \"Swahili (Kiswahili)\", \"value\": \"lang_sw\"},\n  {\"name\": \"Swedish (Svenska)\", \"value\": \"lang_sv\"},\n  {\"name\": \"Thai (ไทย)\", \"value\": \"lang_th\"},\n  {\"name\": \"Turkish (Türkçe)\", \"value\": \"lang_tr\"},\n  {\"name\": \"Ukrainian (Українська)\", \"value\": \"lang_uk\"},\n  {\"name\": \"Vietnamese (Tiếng Việt)\", \"value\": \"lang_vi\"},\n  {\"name\": \"Welsh (Cymraeg)\", \"value\": \"lang_cy\"},\n  {\"name\": \"Xhosa (isiXhosa)\", \"value\": \"lang_xh\"},\n  {\"name\": \"Zulu (isiZulu)\", \"value\": \"lang_zu\"}\n]\n"
  },
  {
    "path": "app/static/settings/themes.json",
    "content": "[\n    \"light\",\n    \"dark\",\n    \"system\"\n]\n"
  },
  {
    "path": "app/static/settings/time_periods.json",
    "content": "[\n  {\"name\": \"Any time\", \"value\": \"\"},\n  {\"name\": \"Past hour\", \"value\": \"qdr:h\"},\n  {\"name\": \"Past 24 hours\", \"value\": \"qdr:d\"},\n  {\"name\": \"Past week\", \"value\": \"qdr:w\"},\n  {\"name\": \"Past month\", \"value\": \"qdr:m\"},\n  {\"name\": \"Past year\", \"value\": \"qdr:y\"}\n]\n"
  },
  {
    "path": "app/static/settings/translations.json",
    "content": "{\n    \"lang_en\": {\n        \"\": \"--\",\n        \"search\": \"Search\",\n        \"config\": \"Configuration\",\n        \"config-country\": \"Country\",\n        \"config-lang\": \"Interface Language\",\n        \"config-lang-search\": \"Search Language\",\n        \"config-near\": \"Near\",\n        \"config-near-help\": \"City Name\",\n        \"config-block\": \"Block\",\n        \"config-block-help\": \"Comma-separated site list\",\n        \"config-block-title\": \"Block by Title\",\n        \"config-block-title-help\": \"Use regex\",\n        \"config-block-url\": \"Block by URL\",\n        \"config-block-url-help\": \"Use regex\",\n        \"config-theme\": \"Theme\",\n        \"config-nojs\": \"Remove Javascript in Anonymous View\",\n        \"config-anon-view\": \"Show Anonymous View Links\",\n        \"config-dark\": \"Dark Mode\",\n        \"config-safe\": \"Safe Search\",\n        \"config-alts\": \"Replace Social Media Links\",\n        \"config-alts-help\": \"Replaces Twitter/YouTube/etc links with privacy respecting alternatives.\",\n        \"config-new-tab\": \"Open Links in New Tab\",\n        \"config-images\": \"Full Size Image Search\",\n        \"config-images-help\": \"(Experimental) Adds the 'View Image' option to desktop image searches. This will cause image result thumbnails to be lower resolution.\",\n        \"config-tor\": \"Use Tor\",\n        \"config-get-only\": \"GET Requests Only\",\n        \"config-url\": \"Root URL\",\n        \"config-pref-url\": \"Preferences URL\",\n        \"config-pref-encryption\": \"Encrypt Preferences\",\n        \"config-pref-help\": \"Requires WHOOGLE_CONFIG_PREFERENCES_KEY, otherwise this will be ignored.\",\n        \"config-css\": \"Custom CSS\",\n        \"config-time-period\": \"Time Period\",\n        \"load\": \"Load\",\n        \"apply\": \"Apply\",\n        \"save-as\": \"Save As...\",\n        \"github-link\": \"View on GitHub\",\n        \"translate\": \"translate\",\n        \"light\": \"light\",\n        \"dark\": \"dark\",\n        \"system\": \"system\",\n        \"ratelimit\": \"Instance has been ratelimited\",\n        \"continue-search\": \"Continue your search with Farside\",\n        \"all\": \"All\",\n        \"images\": \"Images\",\n        \"maps\": \"Maps\",\n        \"videos\": \"Videos\",\n        \"news\": \"News\",\n        \"books\": \"Books\",\n        \"anon-view\": \"Anonymous View\",\n        \"qdr:h\": \"Past hour\",\n        \"qdr:d\": \"Past 24 hours\",\n        \"qdr:w\": \"Past week\",\n        \"qdr:m\": \"Past month\",\n        \"qdr:y\": \"Past year\"\n    },\n    \"lang_nl\": {\n        \"search\": \"Zoeken\",\n        \"config\": \"Instellingen\",\n        \"config-country\": \"Land instellen\",\n        \"config-lang\": \"Taal instellingen\",\n        \"config-lang-search\": \"Zoek taal\",\n        \"config-near\": \"Dichtbij\",\n        \"config-near-help\": \"Stad\",\n        \"config-block\": \"Blok\",\n        \"config-block-help\": \"Lijst met sites met kommas onderscheiden\",\n        \"config-block-title\": \"Blokkeren op titel\",\n        \"config-block-title-help\": \"Gebruik regex\",\n        \"config-block-url\": \"Blokkeren op URL\",\n        \"config-block-url-help\": \"Gebruik regex\",\n        \"config-theme\": \"Thema\",\n        \"config-nojs\": \"Javascript verwijderen in anonieme weergave\",\n        \"config-anon-view\": \"Toon anonieme links bekijken\",\n        \"config-dark\": \"Donkere Modus\",\n        \"config-safe\": \"Veilig zoeken\",\n        \"config-alts\": \"Social Media Links Vervangen\",\n        \"config-alts-help\": \"Vervang Twitter/YouTube/etc links met privacy gerespecteerde alternatieve.\",\n        \"config-new-tab\": \"Open Links in New Tab\",\n        \"config-images\": \"Volledige Grote Afbeelding Zoeken\",\n        \"config-images-help\": \"(Expirimenteel) Voegt de optie 'View Image' toe aan desktop afbeeldingen zoeken. Dit zorgt ervoor dat de voorbeeld foto's kleiner zijn.\",\n        \"config-tor\": \"Gebruik Tor\",\n        \"config-get-only\": \"Alleen GET Requests\",\n        \"config-url\": \"Root URL\",\n        \"config-pref-url\": \"Voorkeurs URL\",\n        \"config-pref-encryption\": \"Versleutel voorkeuren\",\n        \"config-pref-help\": \"Vereist WHOOGLE_CONFIG_PREFERENCES_KEY, anders wordt dit genegeerd.\",\n        \"config-css\": \"Eigen CSS\",\n        \"load\": \"Laden\",\n        \"apply\": \"Opslaan\",\n        \"save-as\": \"Opslaan Als...\",\n        \"github-link\": \"Bekijk op GitHub\",\n        \"translate\": \"vertalen\",\n        \"light\": \"helder\",\n        \"dark\": \"donker\",\n        \"system\": \"systeeminstellingen\",\n        \"ratelimit\": \"Instantie is beperkt in snelheid\",\n        \"continue-search\": \"Ga verder met zoeken met Farside\",\n        \"all\": \"Alle\",\n        \"images\": \"Afbeeldingen\",\n        \"maps\": \"Maps\",\n        \"videos\": \"Videos\",\n        \"news\": \"Nieuws\",\n        \"books\": \"Boeken\",\n        \"anon-view\": \"Anonieme Weergave\",\n        \"\": \"--\",\n        \"qdr:h\": \"Afgelopen uur\",\n        \"qdr:d\": \"Afgelopen 24 uur\",\n        \"qdr:w\": \"Vorige week\",\n        \"qdr:m\": \"Afgelopen maand\",\n        \"qdr:y\": \"Afgelopen jaar\",\n        \"config-time-period\": \"Tijdsperiode\"\n    },\n    \"lang_de\": {\n        \"search\": \"Suchen\",\n        \"config\": \"Einstellungen\",\n        \"config-country\": \"Land einstellen\",\n        \"config-lang\": \"Oberflächen-Sprache\",\n        \"config-lang-search\": \"Such-Sprache\",\n        \"config-near\": \"In der Nähe von\",\n        \"config-near-help\": \"Stadt-Name\",\n        \"config-block\": \"Block\",\n        \"config-block-help\": \"Komma-getrennte Liste von Seiten\",\n        \"config-block-title\": \"Nach Titel blockieren\",\n        \"config-block-title-help\": \"Regex verwenden\",\n        \"config-block-url\": \"Nach URL blockieren\",\n        \"config-block-url-help\": \"Regex verwenden\",\n        \"config-theme\": \"Thema\",\n        \"config-nojs\": \"Entfernen Sie Javascript in der anonymen Ansicht\",\n        \"config-anon-view\": \"Anonyme Ansichtslinks anzeigen\",\n        \"config-dark\": \"Dark Mode\",\n        \"config-safe\": \"Sicheres Suchen\",\n        \"config-alts\": \"Social-Media-Links ersetzen\",\n        \"config-alts-help\": \"Ersetzt Twitter/YouTube/etc Links mit Alternativen, welche die Privatsphäre respektieren.\",\n        \"config-new-tab\": \"Links in neuen Tabs öffnen\",\n        \"config-images\": \"Bilder-Suche in Vollbild\",\n        \"config-images-help\": \"(Experimentell) Fügt 'View Image'-Einstellung zu Dekstop Bilder-Suchen hinzu. Dadurch werden Thumbnails in niedrigerer Auflösung angezeigt.\",\n        \"config-tor\": \"Tor benutzen\",\n        \"config-get-only\": \"Auschließlich GET-Anfragen\",\n        \"config-url\": \"Root URL\",\n        \"config-pref-url\": \"Einstellungs URL\",\n        \"config-pref-encryption\": \"Einstellungen verschlüsseln\",\n        \"config-pref-help\": \"Erfordert WHOOGLE_CONFIG_PREFERENCES_KEY, sonst wird dies ignoriert.\",\n        \"config-css\": \"Custom CSS\",\n        \"load\": \"Laden\",\n        \"apply\": \"Übernehmen\",\n        \"save-as\": \"Speichern unter...\",\n        \"github-link\": \"Auf GitHub öffnen\",\n        \"translate\": \"Übersetzen\",\n        \"light\": \"hell\",\n        \"dark\": \"dunkel\",\n        \"system\": \"Systemeinstellung\",\n        \"ratelimit\": \"Instanz wurde ratenbegrenzt\",\n        \"continue-search\": \"Setzen Sie Ihre Suche fort mit Farside\",\n        \"all\": \"Alle\",\n        \"images\": \"Bilder\",\n        \"maps\": \"Maps\",\n        \"videos\": \"Videos\",\n        \"news\": \"Nachrichten\",\n        \"books\": \"Bücher\",\n        \"anon-view\": \"Anonyme Ansicht\",\n        \"\": \"--\",\n        \"qdr:h\": \"Letzte Stunde\",\n        \"qdr:d\": \"Vergangene 24 Stunden\",\n        \"qdr:w\": \"Letzte Woche\",\n        \"qdr:m\": \"Letzten Monat\",\n        \"qdr:y\": \"Vergangenes Jahr\",\n        \"config-time-period\": \"Zeitraum\"\n    },\n    \"lang_es\": {\n        \"search\": \"Buscar\",\n        \"config\": \"Configuración\",\n        \"config-country\": \"Establecer País\",\n        \"config-lang\": \"Idioma de Interfaz\",\n        \"config-lang-search\": \"Idioma de Búsqueda\",\n        \"config-near\": \"Cerca\",\n        \"config-near-help\": \"Nombre de la Ciudad\",\n        \"config-block\": \"Bloquear\",\n        \"config-block-help\": \"Lista de sitios separados por comas\",\n        \"config-block-title\": \"Bloquear por título\",\n        \"config-block-title-help\": \"Usar expresiones regulares\",\n        \"config-block-url\": \"Bloquear por URL\",\n        \"config-block-url-help\": \"Usar expresiones regulares\",\n        \"config-theme\": \"Tema\",\n        \"config-nojs\": \"Eliminar Javascript en vista anónima\",\n        \"config-anon-view\": \"Mostrar enlaces de vista anónima\",\n        \"config-dark\": \"Modo Oscuro\",\n        \"config-safe\": \"Búsqueda Segura\",\n        \"config-alts\": \"Reemplazar Enlaces de Redes Sociales\",\n        \"config-alts-help\": \"Reemplaza los enlaces de Twitter/YouTube/etc con alternativas que respetan la privacidad.\",\n        \"config-new-tab\": \"Abrir enlaces en una pestaña nueva\",\n        \"config-images\": \"Búsqueda de imágenes a tamaño completo\",\n        \"config-images-help\": \"(Experimental) Agrega la opción 'Ver imagen' a las búsquedas de imágenes de escritorio. Esto hará que las miniaturas de los resultados de la imagen aparezcan con una resolución más baja.\",\n        \"config-tor\": \"Usa Tor\",\n        \"config-get-only\": \"GET solo solicitudes\",\n        \"config-url\": \"URL raíz\",\n        \"config-pref-url\": \"URL de preferencias\",\n        \"config-pref-encryption\": \"Cifrar preferencias\",\n        \"config-pref-help\": \"Requiere WHOOGLE_CONFIG_PREFERENCES_KEY; de lo contrario, se ignorará.\",\n        \"config-css\": \"CSS personalizado\",\n        \"load\": \"Cargar\",\n        \"apply\": \"Aplicar\",\n        \"save-as\": \"Guardar como...\",\n        \"github-link\": \"Ver en GitHub\",\n        \"translate\": \"traducir\",\n        \"light\": \"brillante\",\n        \"dark\": \"oscuro\",\n        \"system\": \"configuración del sistema\",\n        \"ratelimit\": \"La instancia ha sido ratelimited\",\n        \"continue-search\": \"Continúe su búsqueda con Farside\",\n        \"all\": \"Todo\",\n        \"images\": \"Imágenes\",\n        \"maps\": \"Maps\",\n        \"videos\": \"Vídeos\",\n        \"news\": \"Noticias\",\n        \"books\": \"Libros\",\n        \"anon-view\": \"Vista Anónima\",\n        \"\": \"--\",\n        \"qdr:h\": \"Hora pasada\",\n        \"qdr:d\": \"últimas 24 horas\",\n        \"qdr:w\": \"Semana pasada\",\n        \"qdr:m\": \"El mes pasado\",\n        \"qdr:y\": \"Año pasado\",\n        \"config-time-period\": \"Periodo de tiempo\"\n    },\n    \"lang_id\": {\n        \"\": \"--\",\n        \"search\": \"Telusuri\",\n        \"config\": \"Konfigurasi\",\n        \"config-country\": \"Negara\",\n        \"config-lang\": \"Bahasa Antarmuka\",\n        \"config-lang-search\": \"Bahasa Penelusuran\",\n        \"config-near\": \"Dekat\",\n        \"config-near-help\": \"Nama Kota\",\n        \"config-block\": \"Blokir\",\n        \"config-block-help\": \"Daftar situs yang dipisahkan dengan koma\",\n        \"config-block-title\": \"Blokir berdasarkan Judul\",\n        \"config-block-title-help\": \"Gunakan regex\",\n        \"config-block-url\": \"Blokir berdasarkan URL\",\n        \"config-block-url-help\": \"Gunakan regex\",\n        \"config-theme\": \"Tema\",\n        \"config-nojs\": \"Hapus Javascript dalam Tampilan Anonim\",\n        \"config-anon-view\": \"Tampilkan Tautan Tampilan Anonim\",\n        \"config-dark\": \"Mode Gelap\",\n        \"config-safe\": \"Pencarian Aman\",\n        \"config-alts\": \"Ganti Tautan Media Sosial\",\n        \"config-alts-help\": \"Mengganti tautan Twitter/YouTube/dll dengan alternatif yang lebih menjaga privasi.\",\n        \"config-new-tab\": \"Buka Tautan dalam Tab Baru\",\n        \"config-images\": \"Pencarian Gambar Ukuran Penuh\",\n        \"config-images-help\": \"(Eksperimental) Menambahkan opsi 'Lihat Gambar' ke pencarian gambar desktop. Ini akan menyebabkan resolusi thumbnail hasil gambar menjadi lebih rendah.\",\n        \"config-tor\": \"Gunakan Tor\",\n        \"config-get-only\": \"Hanya Gunakan GET\",\n        \"config-url\": \"URL Dasar\",\n        \"config-pref-url\": \"URL Preferensi\",\n        \"config-pref-encryption\": \"Enkripsi Preferensi\",\n        \"config-pref-help\": \"Memerlukan WHOOGLE_CONFIG_PREFERENCES_KEY, jika tidak akan diabaikan.\",\n        \"config-css\": \"CSS Kustom\",\n        \"config-time-period\": \"Periode Waktu\",\n        \"load\": \"Muat\",\n        \"apply\": \"Terapkan\",\n        \"save-as\": \"Simpan Sebagai...\",\n        \"github-link\": \"Lihat di GitHub\",\n        \"translate\": \"terjemahkan\",\n        \"light\": \"terang\",\n        \"dark\": \"gelap\",\n        \"system\": \"sistem\",\n        \"ratelimit\": \"Instansi telah ratelimited\",\n        \"continue-search\": \"Lanjutkan penelusuran Anda dengan Farside\",\n        \"all\": \"Semua\",\n        \"images\": \"Gambar\",\n        \"maps\": \"Peta\",\n        \"videos\": \"Video\",\n        \"news\": \"Berita\",\n        \"books\": \"Buku\",\n        \"anon-view\": \"Tampilan Anonim\",\n        \"qdr:h\": \"1 jam yang lalu\",\n        \"qdr:d\": \"24 jam yang lalu\",\n        \"qdr:w\": \"1 minggu yang lalu\",\n        \"qdr:m\": \"1 bulan yang lalu\",\n        \"qdr:y\": \"1 tahun yang lalu\"\n    },\n    \"lang_it\": {\n        \"search\": \"Cerca\",\n        \"config\": \"Impostazioni\",\n        \"config-country\": \"Imposta Paese\",\n        \"config-lang\": \"Lingua dell'interfaccia\",\n        \"config-lang-search\": \"Lingua della ricerca\",\n        \"config-near\": \"Vicino\",\n        \"config-near-help\": \"Nome della città\",\n        \"config-block\": \"Blocca\",\n        \"config-block-help\": \"Lista di siti separati da virgole\",\n        \"config-block-title\": \"Blocca per titolo\",\n        \"config-block-title-help\": \"Usa regex\",\n        \"config-block-url\": \"Blocca per url\",\n        \"config-block-url-help\": \"Usa regex\",\n        \"config-theme\": \"Tema\",\n        \"config-nojs\": \"Rimuovere Javascript in visualizzazione anonima\",\n        \"config-anon-view\": \"Mostra collegamenti di visualizzazione anonimi\",\n        \"config-dark\": \"Modalità Notte\",\n        \"config-safe\": \"Ricerca Sicura\",\n        \"config-alts\": \"Sostituisci link dei social\",\n        \"config-alts-help\": \"Sostituisci link di Twitter/YouTube/etc con alternative che rispettano la privacy.\",\n        \"config-new-tab\": \"Apri i link in una nuova scheda\",\n        \"config-images\": \"Ricerca Immagini\",\n        \"config-images-help\": \"(Sperimentale) Aggiunge la modalità 'Ricerca Immagini'. Questo ridurrà drasticamente la qualità delle miniature durante la ricerca.\",\n        \"config-tor\": \"Usa Tor\",\n        \"config-get-only\": \"Utilizza solo richieste GET\",\n        \"config-url\": \"Root URL\",\n        \"config-pref-url\": \"URL delle preferenze\",\n        \"config-pref-encryption\": \"Crittografa le preferenze\",\n        \"config-pref-help\": \"Richiede WHOOGLE_CONFIG_PREFERENCES_KEY, altrimenti verrà ignorato.\",\n        \"config-css\": \"CSS Personalizzato\",\n        \"load\": \"Carica\",\n        \"apply\": \"Applica\",\n        \"save-as\": \"Salva Come...\",\n        \"github-link\": \"Guarda su GitHub\",\n        \"translate\": \"tradurre\",\n        \"light\": \"luminoso\",\n        \"dark\": \"notte\",\n        \"system\": \"impostazioni di sistema\",\n        \"ratelimit\": \"L'istanza è stata limitata alla velocità\",\n        \"continue-search\": \"Continua la tua ricerca con Farside\",\n        \"all\": \"Tutti\",\n        \"images\": \"Immagini\",\n        \"maps\": \"Maps\",\n        \"videos\": \"Video\",\n        \"news\": \"Notizie\",\n        \"books\": \"Libri\",\n        \"anon-view\": \"Vista Anonima\",\n        \"\": \"--\",\n        \"qdr:h\": \"Ultima ora\",\n        \"qdr:d\": \"Ultime 24 ore\",\n        \"qdr:w\": \"Settimana scorsa\",\n        \"qdr:m\": \"Mese scorso\",\n        \"qdr:y\": \"L'anno scorso\",\n        \"config-time-period\": \"Periodo di tempo\"\n    },\n    \"lang_pt\": {\n        \"search\": \"Pesquisar\",\n        \"config\": \"Configuração\",\n        \"config-country\": \"Definir País\",\n        \"config-lang\": \"Idioma da Interface\",\n        \"config-lang-search\": \"Idioma da Pesquisa\",\n        \"config-near\": \"Perto\",\n        \"config-near-help\": \"Nome da Cidade\",\n        \"config-block\": \"Bloquear\",\n        \"config-block-help\": \"Lista de sites separados por vírgulas\",\n        \"config-block-title\": \"Bloco por título\",\n        \"config-block-title-help\": \"Use regex\",\n        \"config-block-url\": \"Bloquear por url\",\n        \"config-block-url-help\": \"Use regex\",\n        \"config-theme\": \"Tema\",\n        \"config-nojs\": \"Remover Javascript na visualização anônima\",\n        \"config-anon-view\": \"Mostrar links de visualização anônimos\",\n        \"config-dark\": \"Modo Escuro\",\n        \"config-safe\": \"Pesquisa Segura\",\n        \"config-alts\": \"Substituir Links de Redes Sociais\",\n        \"config-alts-help\": \"Substitui os links do Twitter/YouTube/etc. por alternativas que respeitam sua privacidade.\",\n        \"config-new-tab\": \"Abrir Links em Nova Aba\",\n        \"config-images\": \"Pesquisa de Imagem em Tamanho Real\",\n        \"config-images-help\": \"(Experimental) Adiciona a opção 'Mostrar Imagem' às pesquisas de imagens no modo 'para computador'. Isso fará com que as miniaturas do resultado da imagem sejam de menor resolução.\",\n        \"config-tor\": \"Usar Tor\",\n        \"config-get-only\": \"Apenas Pedidos GET\",\n        \"config-url\": \"URL Fonte\",\n        \"config-pref-url\": \"URL de preferências\",\n        \"config-pref-encryption\": \"Criptografar preferências\",\n        \"config-pref-help\": \"Requer WHOOGLE_CONFIG_PREFERENCES_KEY, caso contrário, será ignorado.\",\n        \"config-css\": \"CSS Personalizado\",\n        \"load\": \"Carregar\",\n        \"apply\": \"Aplicar\",\n        \"save-as\": \"Guardar Como...\",\n        \"github-link\": \"Ver no GitHub\",\n        \"translate\": \"traduzir\",\n        \"light\": \"brilhante\",\n        \"dark\": \"escuro\",\n        \"system\": \"configuração de sistema\",\n        \"ratelimit\": \"A instância foi limitada pela taxa\",\n        \"continue-search\": \"Continue sua pesquisa com Farside\",\n        \"all\": \"Todas\",\n        \"images\": \"Imagens\",\n        \"maps\": \"Maps\",\n        \"videos\": \"Vídeos\",\n        \"news\": \"Notícias\",\n        \"books\": \"Livros\",\n        \"anon-view\": \"Visualização Anônima\",\n        \"\": \"--\",\n        \"qdr:h\": \"Hora passada\",\n        \"qdr:d\": \"últimas 24 horas\",\n        \"qdr:w\": \"Semana passada\",\n        \"qdr:m\": \"Mês passado\",\n        \"qdr:y\": \"Ano passado\",\n        \"config-time-period\": \"Período de tempo\"\n    },\n    \"lang_ru\": {\n        \"search\": \"Поиск\",\n        \"config\": \"Настройка\",\n        \"config-country\": \"Указать страну\",\n        \"config-lang\": \"Язык интерфейса\",\n        \"config-lang-search\": \"Язык поиска\",\n        \"config-near\": \"Около\",\n        \"config-near-help\": \"Название города\",\n        \"config-block\": \"Блокировать\",\n        \"config-block-help\": \"Список сайтов через запятую\",\n        \"config-block-title\": \"Блокировать по названию\",\n        \"config-block-title-help\": \"Используйте regex\",\n        \"config-block-url\": \"Блокировать по URL-адресу\",\n        \"config-block-url-help\": \"Используйте regex\",\n        \"config-theme\": \"Тема\",\n        \"config-nojs\": \"Удалить Javascript в анонимном просмотре\",\n        \"config-anon-view\": \"Показать ссылки для анонимного просмотра\",\n        \"config-dark\": \"Тёмный режим\",\n        \"config-safe\": \"Безопасный поиск\",\n        \"config-alts\": \"Заменить ссылки на социальные сети\",\n        \"config-alts-help\": \"Замена ссылкок Twitter, YouTube, и т.д. на альтернативы, уважающие конфиденциальность.\",\n        \"config-new-tab\": \"Открывать ссылки в новой вкладке\",\n        \"config-images\": \"Поиск полноразмерных изображений\",\n        \"config-images-help\": \"(Эксперимент) Добавляет опцию 'Просмотр изображения' к поиску изображений в ПК-режиме. Это приведет к тому, что миниатюры изображений будут иметь более низкое разрешение.\",\n        \"config-tor\": \"Использовать Tor\",\n        \"config-get-only\": \"Только GET-запросы\",\n        \"config-url\": \"Корневой URL-адрес\",\n        \"config-pref-url\": \"URL-адрес настроек\",\n        \"config-pref-encryption\": \"Зашифровать настройки\",\n        \"config-pref-help\": \"Требуется WHOOGLE_CONFIG_PREFERENCES_KEY, иначе это будет проигнорировано.\",\n        \"config-css\": \"Пользовательский CSS\",\n        \"load\": \"Загрузить\",\n        \"apply\": \"Применить\",\n        \"save-as\": \"Сохранить как...\",\n        \"github-link\": \"Посмотреть на GitHub\",\n        \"translate\": \"перевести\",\n        \"light\": \"светлая\",\n        \"dark\": \"тёмная\",\n        \"system\": \"системная\",\n        \"ratelimit\": \"Инстанс был ограничен по операциям\",\n        \"continue-search\": \"Продолжить поиск с Farside\",\n        \"all\": \"Все\",\n        \"images\": \"Картинки\",\n        \"maps\": \"Карты\",\n        \"videos\": \"Видео\",\n        \"news\": \"Новости\",\n        \"books\": \"Книги\",\n        \"anon-view\": \"Анонимный просмотр\",\n        \"\": \"--\",\n        \"qdr:h\": \"Прошедший час\",\n        \"qdr:d\": \"Последние 24 часа\",\n        \"qdr:w\": \"На прошлой неделе\",\n        \"qdr:m\": \"Прошлый месяц\",\n        \"qdr:y\": \"Прошлый год\",\n        \"config-time-period\": \"Временной период\"\n    },\n    \"lang_zh-CN\": {\n        \"search\": \"搜索\",\n        \"config\": \"配置\",\n        \"config-country\": \"设置国家\",\n        \"config-lang\": \"界面语言\",\n        \"config-lang-search\": \"搜索语言\",\n        \"config-near\": \"接近\",\n        \"config-near-help\": \"城市名\",\n        \"config-block\": \"屏蔽\",\n        \"config-block-help\": \"逗号分隔的网站列表\",\n        \"config-block-title\": \"按网站标题屏蔽\",\n        \"config-block-title-help\": \"使用正则表达式\",\n        \"config-block-url\": \"按网站链接屏蔽\",\n        \"config-block-url-help\": \"使用正则表达式\",\n        \"config-theme\": \"主题\",\n        \"config-nojs\": \"在匿名视图中删除 Javascript\",\n        \"config-anon-view\": \"显示匿名查看链接\",\n        \"config-dark\": \"深色模式\",\n        \"config-safe\": \"安全搜索\",\n        \"config-alts\": \"替换社交媒体链接\",\n        \"config-alts-help\": \"使用尊重隐私的第三方网站替换 Twitter/YouTube 等链接。\",\n        \"config-new-tab\": \"在新标签页打开链接\",\n        \"config-images\": \"完整尺寸图片搜索\",\n        \"config-images-help\": \"（实验性）为桌面版图片搜索添加“查看图片”选项。这会降低图片结果缩略图的分辨率。\",\n        \"config-tor\": \"使用 Tor\",\n        \"config-get-only\": \"仅限 GET 请求\",\n        \"config-url\": \"站点根 URL\",\n        \"config-pref-url\": \"首选项网址\",\n        \"config-pref-encryption\": \"加密首选项\",\n        \"config-pref-help\": \"需要 WHOOGLE_CONFIG_PREFERENCES_KEY，否则将被忽略。\",\n        \"config-css\": \"自定义 CSS\",\n        \"load\": \"载入\",\n        \"apply\": \"应用\",\n        \"save-as\": \"另存为...\",\n        \"github-link\": \"在 GitHub 上查看\",\n        \"translate\": \"翻译\",\n        \"light\": \"明亮的\",\n        \"dark\": \"黑暗的\",\n        \"system\": \"系统设置\",\n        \"ratelimit\": \"实例已被限速\",\n        \"continue-search\": \"继续搜索 Farside\",\n        \"all\": \"全部\",\n        \"images\": \"图片\",\n        \"maps\": \"地图\",\n        \"videos\": \"视频\",\n        \"news\": \"新闻\",\n        \"books\": \"书籍\",\n        \"anon-view\": \"匿名视图\",\n        \"\": \"--\",\n        \"qdr:h\": \"过去一小时\",\n        \"qdr:d\": \"过去 24 小时\",\n        \"qdr:w\": \"上周\",\n        \"qdr:m\": \"过去一个月\",\n        \"qdr:y\": \"过去一年\",\n        \"config-time-period\": \"时间段\"\n    },\n    \"lang_si\": {\n        \"search\": \"සොයන්න\",\n        \"config\": \"වින්‍යාසය\",\n        \"config-country\": \"රට සකසන්න\",\n        \"config-lang\": \"අතුරු මුහුණතෙහි භාෂාව\",\n        \"config-lang-search\": \"සෙවුම් භාෂාව\",\n        \"config-near\": \"ආසන්න\",\n        \"config-near-help\": \"නගරයේ නම\",\n        \"config-block\": \"අවහිර\",\n        \"config-block-help\": \"අල්ප විරාම වලින් වෙන් වූ අඩවි ලැයිස්තුව\",\n        \"config-block-title\": \"මාතෘකාව අනුව අවහිර කරන්න\",\n        \"config-block-title-help\": \"වාක්‍යවිධි භාවිතා කරන්න\",\n        \"config-block-url\": \"ඒ.ස.නි. මඟින් අවහිර කරන්න\",\n        \"config-block-url-help\": \"රෙජෙක්ස් භාවිතා කරන්න\",\n        \"config-theme\": \"තේමාව\",\n        \"config-nojs\": \"Anonymous View හි Javascript ඉවත් කරන්න\",\n        \"config-anon-view\": \"නිර්නාමික බලන්න සබැඳි පෙන්වන්න\",\n        \"config-dark\": \"අඳුරු ආකාරය\",\n        \"config-safe\": \"ආරක්‍ෂිත සෙවුම\",\n        \"config-alts\": \"සමාජ මාධ්‍ය සබැඳි ප්‍රතිස්ථාපනය කරන්න\",\n        \"config-alts-help\": \"ට්විටර්/යූ ටියුබ්/ඉන්ස්ටග්‍රෑම් ආදී සබැඳි පෞද්ගලිකත්වයට ගරු කරන විකල්ප සමඟ ප්‍රතිස්ථාපනය කරයි.\",\n        \"config-new-tab\": \"නව තීරුවකින් සබැඳි විවෘත කරන්න\",\n        \"config-images\": \"පූර්ණ ප්‍රමාණයේ රූප සෙවීම\",\n        \"config-images-help\": \"(පර්යේෂණාත්මක) මේස පරිගණකවල රූප සෙවීමට 'රූපය බලන්න' විකල්පය එකතු කරයි. මෙය රූප ප්‍රතිඵල සංක්ෂිප්තවල අඩු විභේදනයක් ඇති කිරීමට හේතු වේ.\",\n        \"config-tor\": \"ටෝර් භාවිතා කරන්න\",\n        \"config-get-only\": \"ඉල්ලීම් පමණක් ලබා ගන්න\",\n        \"config-url\": \"ඒ.ස.නි.(URL) මූලය\",\n        \"config-pref-url\": \"මනාප URL\",\n        \"config-pref-encryption\": \"මනාප සංකේතනය කරන්න\",\n        \"config-pref-help\": \"WHOOGLE_CONFIG_PREFERENCES_KEY අවශ්‍ය වේ, එසේ නොමැතිනම් මෙය නොසලකා හරිනු ඇත.\",\n        \"config-css\": \"අභිරුචි සීඑස්එස්\",\n        \"load\": \"පූරනය කරන්න\",\n        \"apply\": \"යොදන්න\",\n        \"save-as\": \"...ලෙස සුරකින්න\",\n        \"github-link\": \"ගිට්හබ් හි බලන්න\",\n        \"translate\": \"පරිවර්තනය කරන්න\",\n        \"light\": \"දීප්තිමත්\",\n        \"dark\": \"අඳුරු\",\n        \"system\": \"පද්ධතිය\",\n        \"ratelimit\": \"සේවාදායකය අනුපාතනය කර ඇත\",\n        \"continue-search\": \"Farside සමඟ ඔබගේ සෙවුම කරගෙන යන්න\",\n        \"all\": \"සියල්ල\",\n        \"images\": \"රූප\",\n        \"maps\": \"සිතියම්\",\n        \"videos\": \"වීඩියෝ\",\n        \"news\": \"අනුරූප\",\n        \"books\": \"පොත්\",\n        \"anon-view\": \"නිර්නාමික දසුන\",\n        \"\": \"--\",\n        \"qdr:h\": \"පසුගිය පැය\",\n        \"qdr:d\": \"පසුගිය පැය 24\",\n        \"qdr:w\": \"පසුගිය සතිය\",\n        \"qdr:m\": \"පසුගිය මාසය\",\n        \"qdr:y\": \"පසුගිය වසර\",\n        \"config-time-period\": \"කාල සීමාව\"\n    },\n    \"lang_fr\": {\n        \"search\": \"Chercher\",\n        \"config\": \"Configuration\",\n        \"config-country\": \"Définir le pays\",\n        \"config-lang\": \"Langage de l'Interface\",\n        \"config-lang-search\": \"Langage de Recherche\",\n        \"config-near\": \"Proche\",\n        \"config-near-help\": \"Nom de ville\",\n        \"config-block\": \"Bloquer\",\n        \"config-block-help\": \"Liste de sites séparés pas des virgules\",\n        \"config-block-title\": \"Bloquer par titre\",\n        \"config-block-title-help\": \"Utiliser l'expression régulière\",\n        \"config-block-url\": \"Bloquer par URL\",\n        \"config-block-url-help\": \"Utiliser l'expression régulière\",\n        \"config-theme\": \"Theme\",\n        \"config-nojs\": \"Supprimer Javascript dans la vue anonyme\",\n        \"config-anon-view\": \"Afficher les liens de vue anonymes\",\n        \"config-dark\": \"Mode Sombre\",\n        \"config-safe\": \"Recherche sécurisée\",\n        \"config-alts\": \"Remplacer les liens des réseaux sociaux\",\n        \"config-alts-help\": \"Remplacer les liens Twitter/YouTube/etc avec leurs alternatives respectueuses de la vie privée.\",\n        \"config-new-tab\": \"Ouvrir les Liens dans un Nouveau Onglet\",\n        \"config-images\": \"Recherche d'image en plein écran\",\n        \"config-images-help\": \"(Expérimental) Ajouter l'option 'Voir Image' aux recherches d'images sur ordinateur. Les vignettes des résultats d'image seront de plus faible résolution.\",\n        \"config-tor\": \"Utiliser Tor\",\n        \"config-get-only\": \"Requêtes GET seulement\",\n        \"config-url\": \"URL de la racine\",\n        \"config-pref-url\": \"URL des préférences\",\n        \"config-pref-encryption\": \"Chiffrer les préférences\",\n        \"config-pref-help\": \"Nécessite WHOOGLE_CONFIG_PREFERENCES_KEY, sinon cela sera ignoré.\",\n        \"config-css\": \"CSS Personalisé\",\n        \"load\": \"Charger\",\n        \"apply\": \"Appliquer\",\n        \"save-as\": \"Sauvegarder comme...\",\n        \"github-link\": \"Voir sur GitHub\",\n        \"translate\": \"Traduire\",\n        \"light\": \"clair\",\n        \"dark\": \"sombre\",\n        \"system\": \"système\",\n        \"ratelimit\": \"Le débit de l'instance a été limité\",\n        \"continue-search\": \"Continuez votre recherche avec Farside\",\n        \"all\": \"Tous\",\n        \"images\": \"Images\",\n        \"maps\": \"Maps\",\n        \"videos\": \"Vidéos\",\n        \"news\": \"Actualités\",\n        \"books\": \"Livres\",\n        \"anon-view\": \"Vue anonyme\",\n        \"\": \"--\",\n        \"qdr:h\": \"Heure passée\",\n        \"qdr:d\": \"Dernières 24 heures\",\n        \"qdr:w\": \"La semaine dernière\",\n        \"qdr:m\": \"Mois passé\",\n        \"qdr:y\": \"L'année passée\",\n        \"config-time-period\": \"Période de temps\"\n    },\n    \"lang_fa\": {\n        \"search\": \"جستجو\",\n        \"config\": \"پیکربندی\",\n        \"config-country\": \"کشور را تنظیم کنید\",\n        \"config-lang\": \"زبان رابط کاربری\",\n        \"config-lang-search\": \"زبان جستجو\",\n        \"config-near\": \"نزدیک\",\n        \"config-near-help\": \"نام شهر\",\n        \"config-block\": \"مسدود کردن\",\n        \"config-block-help\": \"لیست سایت‌ها با ویرگول جدا می‌شود.\",\n        \"config-block-title\": \"مسدود کردن بر اساس عنوان\",\n        \"config-block-title-help\": \"از عبارت منظم استفاده کنید\",\n        \"config-block-url\": \"بلوک بر اساس URL\",\n        \"config-block-url-help\": \"از عبارت منظم استفاده کنید\",\n        \"config-theme\": \"پوسته\",\n        \"config-nojs\": \"جاوا اسکریپت را در نمای ناشناس حذف کنید\",\n        \"config-anon-view\": \"نمایش پیوندهای مشاهده ناشناس\",\n        \"config-dark\": \"حالت تاریک\",\n        \"config-safe\": \"جستجوی امن\",\n        \"config-alts\": \"جایگزینی پیوند‌های شبکه‌های اجتماعی\",\n        \"config-alts-help\": \"لینک‌های توییتر، یوتیوب، اینستاگرام و... را با جایگزین‌هایی که به حریم خصوصی احترام می‌گذارند جایگزین می‌کند.\",\n        \"config-new-tab\": \"باز کردن پیوند‌ها در تب جدید\",\n        \"config-images\": \"جستجوی تصویر در اندازه‌ی کامل\",\n        \"config-images-help\": \"(تجربی) گزینه‌ی \\\"مشاهده‌ی تصویر\\\" را به جستجو‌های تصویر میزکار اضافه می‌کند. این باعث می‌شود تصاویر کوچک وضوح و حجم کمتری داشته باشند.\",\n        \"config-tor\": \"استفاده از تور\",\n        \"config-get-only\": \"فقط درخواست‌های GET\",\n        \"config-url\": \"آدرس ریشه‌ی سایت\",\n        \"config-pref-url\": \"URL تنظیمات برگزیده\",\n        \"config-pref-encryption\": \"رمزگذاری تنظیمات برگزیده\",\n        \"config-pref-help\": \"به WHOOGLE_CONFIG_PREFERENCES_KEY نیاز دارد، در غیر این صورت نادیده گرفته خواهد شد.\",\n        \"config-css\": \"CSS دلخواه\",\n        \"load\": \"بارگذاری\",\n        \"apply\": \"تایید\",\n        \"save-as\": \"ذخیره به عنوان...\",\n        \"github-link\": \"نمایش در گیت‌هاب\",\n        \"translate\": \"ترجمه\",\n        \"light\": \"روشن\",\n        \"dark\": \"تیره\",\n        \"system\": \"سیستم\",\n        \"ratelimit\": \"نمونه با نرخ محدود شده است\",\n        \"continue-search\": \"Farside جستجوی خود را با \",\n        \"all\": \"همه\",\n        \"images\": \"تصاویر\",\n        \"maps\": \"نقشه‌ها\",\n        \"videos\": \"ویدئوها\",\n        \"news\": \"اخبار\",\n        \"books\": \"کتاب‌ها\",\n        \"anon-view\": \"نمای ناشناس\",\n        \"\": \"--\",\n        \"qdr:h\": \"ساعت گذشته\",\n        \"qdr:d\": \"24 ساعت گذشته\",\n        \"qdr:w\": \"هفته گذشته\",\n        \"qdr:m\": \"ماه گذشته\",\n        \"qdr:y\": \"سال گذشته\",\n        \"config-time-period\": \"بازه زمانی\"\n    },\n    \"lang_cs\": {\n        \"search\": \"Hledat\",\n        \"config\": \"Konfigurace\",\n        \"config-country\": \"Nastavte zemi\",\n        \"config-lang\": \"Jazyk rozhraní\",\n        \"config-lang-search\": \"Jazyk vyhledávání\",\n        \"config-near\": \"Poblíž\",\n        \"config-near-help\": \"Název města\",\n        \"config-block\": \"Blokovat\",\n        \"config-block-help\": \"Čárkami oddělený seznam stránek\",\n        \"config-block-title\": \"Blokovat podle názvu\",\n        \"config-block-title-help\": \"Použijte regulární výraz\",\n        \"config-block-url\": \"Blokovat podle adresy URL\",\n        \"config-block-url-help\": \"Použijte regulární výraz\",\n        \"config-theme\": \"Motiv\",\n        \"config-nojs\": \"Odeberte Javascript v anonymním zobrazení\",\n        \"config-anon-view\": \"Zobrazit odkazy anonymního zobrazení\",\n        \"config-dark\": \"Tmavý motiv\",\n        \"config-safe\": \"Bezpečné vyhledávání\",\n        \"config-alts\": \"Nahradit odkazy na sociální média\",\n        \"config-alts-help\": \"Nahradí odkazy na Twitter, YouTube, atd. alternativami respektujícími soukromí.\",\n        \"config-new-tab\": \"Otevírat odkazy na novém listu\",\n        \"config-images\": \"Vyhledávání obrázků v plné velikosti\",\n        \"config-images-help\": \"(Experimentální) Přidá volbu ‚Zobrazit obrázek‘ do vyhledávání obrázků na ploše. Způsobí to, že náhledy výsledků vyhledávání obrázků budou mít nižší rozlišení.\",\n        \"config-tor\": \"Používat Tor\",\n        \"config-get-only\": \"Pouze požadavky GET\",\n        \"config-url\": \"Kořenová adresa URL\",\n        \"config-pref-url\": \"Adresa URL předvoleb\",\n        \"config-pref-encryption\": \"Předvolby šifrování\",\n        \"config-pref-help\": \"Vyžaduje WHOOGLE_CONFIG_PREFERENCES_KEY, jinak bude ignorována.\",\n        \"config-css\": \"Vlastní CSS\",\n        \"load\": \"Načíst\",\n        \"apply\": \"Použít\",\n        \"save-as\": \"Uložit jako...\",\n        \"github-link\": \"Zobrazit na GitHub\",\n        \"translate\": \"Přeložit\",\n        \"light\": \"Světlý\",\n        \"dark\": \"Tmavý\",\n        \"system\": \"Systémový\",\n        \"ratelimit\": \"Instance byla omezena sazbou\",\n        \"continue-search\": \"Pokračujte ve vyhledávání pomocí Farside\",\n        \"all\": \"Vše\",\n        \"images\": \"Obrázky\",\n        \"maps\": \"Mapy\",\n        \"videos\": \"Videa\",\n        \"news\": \"Zprávy\",\n        \"books\": \"Knihy\",\n        \"anon-view\": \"Anonymní pohled\",\n        \"\": \"--\",\n        \"qdr:h\": \"Poslední hodina\",\n        \"qdr:d\": \"Posledních 24 hodin\",\n        \"qdr:w\": \"Minulý týden\",\n        \"qdr:m\": \"Minulý měsíc\",\n        \"qdr:y\": \"Minulý rok\",\n        \"config-time-period\": \"Časový úsek\"\n    },\n    \"lang_zh-TW\": {\n        \"\": \"--\",\n        \"search\": \"搜尋\",\n        \"config\": \"設定\",\n        \"config-country\": \"設定國家\",\n        \"config-lang\": \"介面語言\",\n        \"config-lang-search\": \"搜尋語言\",\n        \"config-near\": \"接近\",\n        \"config-near-help\": \"城市名稱\",\n        \"config-block\": \"封鎖\",\n        \"config-block-help\": \"以逗號分隔之網址列表\",\n        \"config-block-title\": \"按標題封鎖\",\n        \"config-block-title-help\": \"使用正規表達式\",\n        \"config-block-url\": \"按網址封鎖\",\n        \"config-block-url-help\": \"使用正規表達式\",\n        \"config-theme\": \"主題\",\n        \"config-nojs\": \"於匿名檢視中刪除 JavaScript\",\n        \"config-anon-view\": \"顯示匿名檢視鏈接\",\n        \"config-dark\": \"深色模式\",\n        \"config-safe\": \"安全搜尋\",\n        \"config-alts\": \"將社群網站連結替換\",\n        \"config-alts-help\": \"將 Twitter/YouTube 等網站之連結替換為尊重隱私的第三方網站。\",\n        \"config-new-tab\": \"以新分頁開啟連結\",\n        \"config-images\": \"完整尺寸圖片搜尋\",\n        \"config-images-help\": \"（實驗性）在桌面版圖片搜尋中增加「檢視圖片」選項。這會使搜尋結果圖片解析度降低。\",\n        \"config-tor\": \"使用 Tor\",\n        \"config-get-only\": \"僅限於 GET 要求\",\n        \"config-url\": \"首頁網址\",\n        \"config-pref-url\": \"設定網址\",\n        \"config-pref-encryption\": \"加密設定\",\n        \"config-pref-help\": \"需要一併設定 WHOOGLE_CONFIG_PREFERENCES_KEY，否則將會被忽略。\",\n        \"config-css\": \"自定 CSS\",\n        \"config-time-period\": \"時間範圍\",\n        \"load\": \"載入\",\n        \"apply\": \"套用\",\n        \"save-as\": \"另存為...\",\n        \"github-link\": \"在 GitHub 上檢視\",\n        \"translate\": \"翻譯\",\n        \"light\": \"明亮的\",\n        \"dark\": \"黑暗的\",\n        \"system\": \"依照系統設定\",\n        \"ratelimit\": \"該實例已被限速\",\n        \"continue-search\": \"繼續搜尋 Farside\",\n        \"all\": \"全部\",\n        \"images\": \"圖片\",\n        \"maps\": \"地圖\",\n        \"videos\": \"影片\",\n        \"news\": \"新聞\",\n        \"books\": \"書籍\",\n        \"anon-view\": \"匿名檢視\",\n        \"qdr:h\": \"過去 1 小時\",\n        \"qdr:d\": \"過去 24 小時\",\n        \"qdr:w\": \"過去 1 週\",\n        \"qdr:m\": \"過去 1 個月\",\n        \"qdr:y\": \"過去 1 年\"\n    },\n    \"lang_bg\": {\n        \"search\": \"Търсене\",\n        \"config\": \"Конфигурация\",\n        \"config-country\": \"Задайте държава\",\n        \"config-lang\": \"Език на интерфейса\",\n        \"config-lang-search\": \"Език за търсене\",\n        \"config-near\": \"Близо до\",\n        \"config-near-help\": \"Име на град\",\n        \"config-block\": \"Блокирани сайтове\",\n        \"config-block-help\": \"Списък сайтове, разделени със запетая\",\n        \"config-block-title\": \"Блокиране по заглавие\",\n        \"config-block-title-help\": \"Използвайте регулярно изражение\",\n        \"config-block-url\": \"Блокиране по url\",\n        \"config-block-url-help\": \"Използвайте регулярно изражение\",\n        \"config-theme\": \"Стил\",\n        \"config-nojs\": \"Премахнете Javascript в анонимен изглед\",\n        \"config-anon-view\": \"Показване на анонимни връзки за преглед\",\n        \"config-dark\": \"Тъмен режим\",\n        \"config-safe\": \"Безопасно търсене\",\n        \"config-alts\": \"Заменете връзките към социалните медии\",\n        \"config-alts-help\": \"Заменя връзките на Twitter/YouTube и т.н. с защитени алтернативни поверителни връзки.\",\n        \"config-new-tab\": \"Отваряне на връзките в нов раздел\",\n        \"config-images\": \"Търсене на изображения в пълен размер\",\n        \"config-images-help\": \"(Експериментално) Добавя опцията „Преглед на изображение“ към резултатите от търсене на изображения през работния плот на компютъра. Това ще доведе до по-ниска разделителна способност на миниатюрите, в резултатите от търсене на изображения.\",\n        \"config-tor\": \"Използвайте Tor\",\n        \"config-get-only\": \"Само GET заявки\",\n        \"config-url\": \"Основен URL адрес\",\n        \"config-pref-url\": \"URL адрес на предпочитанията\",\n        \"config-pref-encryption\": \"Шифроване на предпочитанията\",\n        \"config-pref-help\": \"Изисква WHOOGLE_CONFIG_PREFERENCES_KEY, в противен случай това ще бъде игнорирано.\",\n        \"config-css\": \"Персонализиран CSS\",\n        \"load\": \"Зареди\",\n        \"apply\": \"Приложи\",\n        \"save-as\": \"Запис като...\",\n        \"github-link\": \"Вижте в GitHub\",\n        \"translate\": \"превод\",\n        \"light\": \"светла\",\n        \"dark\": \"тъмна\",\n        \"system\": \"системна\",\n        \"ratelimit\": \"Екземплярът е с ограничена скорост\",\n        \"continue-search\": \"Продължете търсенето си с Farside\",\n        \"all\": \"Всичкo\",\n        \"images\": \"Изображения\",\n        \"maps\": \"Видеоклипове\",\n        \"videos\": \"Новини\",\n        \"news\": \"Карти\",\n        \"books\": \"Книги\",\n        \"anon-view\": \"Анонимен изглед\",\n        \"\": \"--\",\n        \"qdr:h\": \"Последния час\",\n        \"qdr:d\": \"Последните 24 часа\",\n        \"qdr:w\": \"Миналата седмица\",\n        \"qdr:m\": \"Миналия месец\",\n        \"qdr:y\": \"Изминалата година\",\n        \"config-time-period\": \"Времеви период\"\n    },\n    \"lang_hi\": {\n        \"search\": \"खोज\",\n        \"config\": \"कॉन्फ़िगरेशन\",\n        \"config-country\": \"देश सेट करें\",\n        \"config-lang\": \"इंटरफ़ेस भाषा\",\n        \"config-lang-search\": \"खोज की भाषा\",\n        \"config-near\": \"पास\",\n        \"config-near-help\": \"शहर का नाम\",\n        \"config-block\": \"खंड\",\n        \"config-block-help\": \"अल्पविराम से अलग की गई साइट सूची\",\n        \"config-block-title\": \"शीर्षक के अनुसार ब्लॉक करें\",\n        \"config-block-title-help\": \"रेगेक्स का प्रयोग करें\",\n        \"config-block-url\": \"url द्वारा अवरोधित करें\",\n        \"config-block-url-help\": \"रेगेक्स का प्रयोग करें\",\n        \"config-theme\": \"विषय\",\n        \"config-nojs\": \"अनाम दृश्य में जावास्क्रिप्ट निकालें\",\n        \"config-anon-view\": \"बेनामी देखें लिंक दिखाएं\",\n        \"config-dark\": \"डार्क मोड\",\n        \"config-safe\": \"सुरक्षित खोज\",\n        \"config-alts\": \"सोशल मीडिया लिंक बदलें\",\n        \"config-alts-help\": \"गोपनीयता का सम्मान करने वाले विकल्पों के साथ ट्विटर/यूट्यूब/इंस्टाग्राम/आदि लिंक को बदल देता है।\",\n        \"config-new-tab\": \"नए टैब में लिंक खोलें\",\n        \"config-images\": \"पूर्ण आकार छवि खोज\",\n        \"config-images-help\": \"(Experimental) डेस्कटॉप छवि खोजों में 'छवि देखें' विकल्प जोड़ता है। इससे छवि परिणाम थंबनेल कम रिज़ॉल्यूशन वाले होंगे।\",\n        \"config-tor\": \"TOR का प्रयोग करें\",\n        \"config-get-only\": \"केवल GET अनुरोध\",\n        \"config-url\": \"रूट यूआरएल\",\n        \"config-pref-url\": \"वरीयताएँ URL\",\n        \"config-pref-encryption\": \"एन्क्रिप्ट प्राथमिकताएं\",\n        \"config-pref-help\": \"WHOOGLE_CONFIG_PREFERENCES_KEY की आवश्यकता है, अन्यथा इसे अनदेखा कर दिया जाएगा।\",\n        \"config-css\": \"कस्टम सीएसएस\",\n        \"load\": \"भार\",\n        \"apply\": \"लागू करना\",\n        \"save-as\": \"के रूप रक्षित करें...\",\n        \"github-link\": \"गिटहब पर देखें\",\n        \"translate\": \"अनुवाद करना\",\n        \"light\": \"रोशनी\",\n        \"dark\": \"अंधेरा\",\n        \"system\": \"प्रणाली\",\n        \"ratelimit\": \"इंस्टेंस को सीमित कर दिया गया है\",\n        \"continue-search\": \"के साथ अपनी खोज जारी रखें Farside\",\n        \"all\": \"सभी\",\n        \"images\": \"इमेज\",\n        \"maps\": \"वीडियो\",\n        \"videos\": \"मैप\",\n        \"news\": \"समाचार\",\n        \"books\": \"किताबें\",\n        \"anon-view\": \"अनाम दृश्य\",\n        \"\": \"--\",\n        \"qdr:h\": \"पिछले घंटे\",\n        \"qdr:d\": \"पिछले 24 घंटे\",\n        \"qdr:w\": \"पिछले सप्ताह\",\n        \"qdr:m\": \"पिछले महीने\",\n        \"qdr:y\": \"पिछला वर्ष\",\n        \"config-time-period\": \"समय सीमा\"\n    },\n    \"lang_ja\": {\n        \"search\": \"検索\",\n        \"config\": \"設定\",\n        \"config-country\": \"国を設定する\",\n        \"config-lang\": \"インタフェースの言語\",\n        \"config-lang-search\": \"検索する言語\",\n        \"config-near\": \"場所\",\n        \"config-near-help\": \"街の名前\",\n        \"config-block\": \"ブロック\",\n        \"config-block-help\": \"サイトのリストをコンマ区切りで入力\",\n        \"config-block-title\": \"タイトルでブロック\",\n        \"config-block-title-help\": \"正規表現を使用します\",\n        \"config-block-url\": \"でブロック\",\n        \"config-block-url-help\": \"正規表現を使用\",\n        \"config-theme\": \"テーマ\",\n        \"config-nojs\": \"匿名ビューでJavascriptを削除する\",\n        \"config-anon-view\": \"匿名のビューリンクを表示する\",\n        \"config-dark\": \"ダークモード\",\n        \"config-safe\": \"セーフサーチ\",\n        \"config-alts\": \"ソーシャルメディアのリンクを置き換え\",\n        \"config-alts-help\": \"Twitter/YouTubeなどのリンクを、プライバシーを尊重した代替サイトに置き換えます。\",\n        \"config-new-tab\": \"新しいタブでリンクを開く\",\n        \"config-images\": \"フルサイズの画像を検索\",\n        \"config-images-help\": \"(実験的) デスクトップの画像検索に「画像を表示」オプションを追加します。これにより、画像検索結果のサムネイルの解像度が低くなります。\",\n        \"config-tor\": \"Torを使用\",\n        \"config-get-only\": \"GETリクエストのみ\",\n        \"config-url\": \"ルートURL\",\n        \"config-pref-url\": \"設定 URL\",\n        \"config-pref-encryption\": \"設定を暗号化する\",\n        \"config-pref-help\": \"WHOOGLE_CONFIG_PREFERENCES_KEY が必要です。それ以外の場合、これは無視されます。\",\n        \"config-css\": \"カスタムCSS\",\n        \"load\": \"読み込み\",\n        \"apply\": \"反映\",\n        \"save-as\": \"名前を付けて保存\",\n        \"github-link\": \"Githubで確認\",\n        \"translate\": \"翻訳\",\n        \"light\": \"ライト\",\n        \"dark\": \"ダーク\",\n        \"system\": \"自動\",\n        \"ratelimit\": \"インスタンスはレート制限されています\",\n        \"continue-search\": \"で検索を続ける Farside\",\n        \"all\": \"すべて\",\n        \"images\": \"画像\",\n        \"maps\": \"地図\",\n        \"videos\": \"動画\",\n        \"news\": \"ニュース\",\n        \"books\": \"書籍\",\n        \"anon-view\": \"匿名ビュー\",\n        \"\": \"--\",\n        \"qdr:h\": \"過去 1 時間\",\n        \"qdr:d\": \"過去 24 時間\",\n        \"qdr:w\": \"この1週間\",\n        \"qdr:m\": \"先月\",\n        \"qdr:y\": \"過年度\",\n        \"config-time-period\": \"期間\"\n    },\n    \"lang_ko\": {\n        \"search\": \"검색\",\n        \"config\": \"구성\",\n        \"config-country\": \"국가 설정\",\n        \"config-lang\": \"인터페이스 언어\",\n        \"config-lang-search\": \"검색 언어\",\n        \"config-near\": \"주변\",\n        \"config-near-help\": \"도시 이름\",\n        \"config-block\": \"차단\",\n        \"config-block-help\": \"쉼표로 구분된 사이트 목록\",\n        \"config-block-title\": \"제목으로 차단\",\n        \"config-block-title-help\": \"정규 표현식 사용\",\n        \"config-block-url\": \"URL로 차단\",\n        \"config-block-url-help\": \"정규 표현식 사용\",\n        \"config-theme\": \"테마\",\n        \"config-nojs\": \"익명 보기에서 Javascript 제거\",\n        \"config-anon-view\": \"익명 보기 링크 표시\",\n        \"config-dark\": \"다크 모드\",\n        \"config-safe\": \"세이프서치\",\n        \"config-alts\": \"소설 미디어 주소 수정\",\n        \"config-alts-help\": \"Twitter/YouTube 등의 링크를 프라이버시를 존중하는 링크로 대체합니다\",\n        \"config-new-tab\": \"새 탭에서 열기\",\n        \"config-images\": \"최대 크기 이미지 검색\",\n        \"config-images-help\": \"(실험적) 데스크톱 이미지 검색에 '이미지 보기' 옵션을 추가합니다. 이미지 결과 미리보기 썸네일이 낮은 해상도로 표시됩니다.\",\n        \"config-tor\": \"Tor 사용\",\n        \"config-get-only\": \"GET 요청만\",\n        \"config-url\": \"루트 URL\",\n        \"config-pref-url\": \"환경설정 URL\",\n        \"config-pref-encryption\": \"암호화 환경 설정\",\n        \"config-pref-help\": \"WHOOGLE_CONFIG_PREFERENCES_KEY이 필요합니다. 그렇지 않으면 무시됩니다.\",\n        \"config-css\": \"커스텀 CSS\",\n        \"load\": \"불러오기\",\n        \"apply\": \"적용\",\n        \"save-as\": \"다른 이름으로 저장...\",\n        \"github-link\": \"깃허브에서 보기\",\n        \"translate\": \"번역\",\n        \"light\": \"라이트\",\n        \"dark\": \"다크\",\n        \"system\": \"시스템\",\n        \"ratelimit\": \"인스턴스가 속도 제한되었습니다.\",\n        \"continue-search\": \"Farside로 검색 계속하기\",\n        \"all\": \"전체\",\n        \"images\": \"이미지\",\n        \"maps\": \"지도\",\n        \"videos\": \"동영상\",\n        \"news\": \"뉴스\",\n        \"books\": \"도서\",\n        \"anon-view\": \"익명 보기\",\n        \"\": \"--\",\n        \"qdr:h\": \"지난 시간\",\n        \"qdr:d\": \"지난 24시간\",\n        \"qdr:w\": \"지난 주\",\n        \"qdr:m\": \"지난달\",\n        \"qdr:y\": \"지난 해\",\n        \"config-time-period\": \"기간\"\n    },\n    \"lang_ku\": {\n        \"search\": \"Bigere\",\n        \"config\": \"Sazkarî\",\n        \"config-country\": \"Welat\",\n        \"config-lang\": \"Zimanê Navrûyê\",\n        \"config-lang-search\": \"Zimanê Lêgerînê\",\n        \"config-near\": \"Nêzîk\",\n        \"config-near-help\": \"Navê Bajêr\",\n        \"config-block\": \"Astengkirin\",\n        \"config-block-help\": \"Rêzoka malperê ya ji hev veqetandî bi riya bêhnok\",\n        \"config-block-title\": \"Bi ya Sernavê Asteng bike\",\n        \"config-block-title-help\": \"regex bi kar bîne\",\n        \"config-block-url\": \"Bi ya Girêdanê asteng bike\",\n        \"config-block-url-help\": \"regex bi kar bîne\",\n        \"config-theme\": \"Rûkar\",\n        \"config-nojs\": \"Javascript Rake di Nîşandanên Nenenas de\",\n        \"config-anon-view\": \"Girêdanên Nenas Nîşan bide\",\n        \"config-dark\": \"Awaya Tarî\",\n        \"config-safe\": \"Lêgerîna Parastî\",\n        \"config-alts\": \"Girêdanên Tora Civakî Biguherîne\",\n        \"config-alts-help\": \"Girêdanên Twitter/YouTube/hwd biguherîne bi alternatîvên ku ji taybetiyê re rêzê digrin.\",\n        \"config-new-tab\": \"Girêdanan di Rûgereke Nû de Veke\",\n        \"config-images\": \"Lêgerîna Wêne bi Mezinahiya Tevahî\",\n        \"config-images-help\": \"(Ezmûnî) Vebijêrka 'Wêneyê Nîşan bide' tevlî lêgerînên wêneyê yê sermaseyê bike. Ev ê bibe sedem ku çareseriya encamê wêneyên nîşanê kêmtir bibe.\",\n        \"config-tor\": \"Tor bi kar bîne\",\n        \"config-get-only\": \"Daxwazan bi Dest Bixe\",\n        \"config-url\": \"Rêgeha girêdanê\",\n        \"config-pref-url\": \"Vebijêrkên girêdanê\",\n        \"config-pref-encryption\": \"Vebijêrkan şîfre bike\",\n        \"config-pref-help\": \"Pêdivî bi WHOOGLE_CONFIG_PREFERENCES_KEY dike, wekî din ev ê were paşguhkirin.\",\n        \"config-css\": \"CSS kesane bike\",\n        \"load\": \"Bar bike\",\n        \"apply\": \"Bisepîne\",\n        \"save-as\": \"Biparêze wekî...\",\n        \"github-link\": \"Li ser GitHub Nîşan bide\",\n        \"translate\": \"werger\",\n        \"light\": \"ronî\",\n        \"dark\": \"tarî\",\n        \"system\": \"pergal\",\n        \"ratelimit\": \"Mînak bi rêjeya sînorkirî ye\",\n        \"continue-search\": \"Lêgerîna xwe bi Farside re bidomîne\",\n        \"all\": \"Hemû\",\n        \"images\": \"Wêne\",\n        \"maps\": \"Nexşe\",\n        \"videos\": \"Vîdyo\",\n        \"news\": \"Nûçe\",\n        \"books\": \"Pirtûk\",\n        \"anon-view\": \"Dîtina Nenas\",\n        \"\": \"--\",\n        \"qdr:h\": \"Demjimêra borî\",\n        \"qdr:d\": \"24 Demjimêrên borî\",\n        \"qdr:w\": \"Hefteya borî\",\n        \"qdr:m\": \"Meha borî\",\n        \"qdr:y\": \"Sala borî\",\n        \"config-time-period\": \"Pêşsazkariyên demê\"\n    },\n    \"lang_th\": {\n        \"search\": \"ค้นหา\",\n        \"config\": \"กำหนดค่า\",\n        \"config-country\": \"ประเทศ\",\n        \"config-lang\": \"ภาษาหน้าอินเตอร์เฟซ\",\n        \"config-lang-search\": \"ค้นหาในภาษา\",\n        \"config-near\": \"รอบๆ\",\n        \"config-near-help\": \"ชื่อเมือง\",\n        \"config-block\": \"บล็อค\",\n        \"config-block-help\": \"รายการเว็บไซต์คั่นด้วยเครื่องหมายจุลภาค(,)\",\n        \"config-block-title\": \"บล็อกตามหัวชื่อเว็บไซต์\",\n        \"config-block-title-help\": \"ใช้ regex\",\n        \"config-block-url\": \"บล็อกตาม URL\",\n        \"config-block-url-help\": \"ใช้ regex\",\n        \"config-theme\": \"ธีม\",\n        \"config-nojs\": \"ลบ Javascript ในมุมมองที่ไม่ระบุตัวตน\",\n        \"config-anon-view\": \"แสดงลิงค์ในมุมมองไม่ระบุตัวตน\",\n        \"config-dark\": \"โหมดมืด\",\n        \"config-safe\": \"ค้นหาแบบปลอดภัย\",\n        \"config-alts\": \"แทนที่ลิงก์โซเชียลมีเดีย\",\n        \"config-alts-help\": \"แทนที่ลิงก์ Twitter/YouTube/อื่นๆ ตามความเป็นส่วนตัวด้วยทางเลือกอื่น\",\n        \"config-new-tab\": \"เปิดลิงก์ในแท็บใหม่\",\n        \"config-images\": \"ค้นหารูปภาพขนาดเต็ม\",\n        \"config-images-help\": \"(ตัวอย่าง) เพิ่มตัวเลือก 'ดูภาพ' ในการค้นหารูปภาพบนเดสก์ท็อป ซึ่งจะทำให้ภาพขนาดย่อมีความละเอียดต่ำ\",\n        \"config-tor\": \"ใช้ Tor\",\n        \"config-get-only\": \"รับคำขอเท่านั้น\",\n        \"config-url\": \"URL หลัก\",\n        \"config-pref-url\": \"URL การตั้งค่า\",\n        \"config-pref-encryption\": \"เข้ารหัสการตั้งค่า\",\n        \"config-pref-help\": \"จำเป็นต้องมี WHOOGLE_CONFIG_PREFERENCES_KEY ไม่เช่นนั้นจะถูกละเว้นไป\",\n        \"config-css\": \"กำหนด CSS เอง\",\n        \"load\": \"โหลด\",\n        \"apply\": \"ยอมรับ\",\n        \"save-as\": \"บันทึกเป็น...\",\n        \"github-link\": \"ดูบน GitHub\",\n        \"translate\": \"แปลภาษา\",\n        \"light\": \"สว่าง\",\n        \"dark\": \"มืด\",\n        \"system\": \"ระบบ\",\n        \"ratelimit\": \"คำขอร้องจะถูกจำกัดจำนวน\",\n        \"continue-search\": \"ค้นหาต่อไปด้วย Farside\",\n        \"all\": \"ทั้งหมด\",\n        \"images\": \"รูปภาพ\",\n        \"maps\": \"แผนที่\",\n        \"videos\": \"วิดีโอ\",\n        \"news\": \"ข่าว\",\n        \"books\": \"หนังสือ\",\n        \"anon-view\": \"มุมมองที่ไม่ระบุตัวตน\",\n        \"\": \"--\",\n        \"qdr:h\": \"ชั่วโมงที่ผ่านมา\",\n        \"qdr:d\": \"24 ชั่วโมงที่ผ่านมา\",\n        \"qdr:w\": \"สัปดาห์ที่ผ่านมา\",\n        \"qdr:m\": \"เดือนที่ผ่านมา\",\n        \"qdr:y\": \"ปีที่ผ่านมา\",\n        \"config-time-period\": \"ระยะเวลา\"\n    },\n    \"lang_cy\": {\n        \"search\": \"Chwiliwch\",\n        \"config\": \"Cyfluniad\",\n        \"config-country\": \"Gwlad\",\n        \"config-lang\": \"Iaith Rhyngwyneb\",\n        \"config-lang-search\": \"Iaith Chwiliad\",\n        \"config-near\": \"Ger\",\n        \"config-near-help\": \"Enw'r Dinas\",\n        \"config-block\": \"Blociwch\",\n        \"config-block-help\": \"Rhestr Gwahanu Comma o Wefannau\",\n        \"config-block-title\": \"Blocio yn ôl teitl\",\n        \"config-block-title-help\": \"Defnyddio regex\",\n        \"config-block-url\": \"Blocio yn ôl URL\",\n        \"config-block-url-help\": \"Defnyddio regex\",\n        \"config-theme\": \"Thema\",\n        \"config-nojs\": \"Dileu Javascript mewn Golwg Anhysbys\",\n        \"config-anon-view\": \"Dangos Cysylltau Golwg Anhysbys\",\n        \"config-dark\": \"Modd Tywyll\",\n        \"config-safe\": \"Chwilio'n Ddiogel\",\n        \"config-alts\": \"Disodli Cysylltau Cyfryngau Cymdeithasol\",\n        \"config-alts-help\": \"Yn Amnewid Cysylltau Twitter/YouTube/etc gyda Gwefanau Preifatrwydd.\",\n        \"config-new-tab\": \"Agor Cysylltau mewn Tab Newydd\",\n        \"config-images\": \"Chwiliad Delwedd Maint Llawn\",\n        \"config-images-help\": \"(Arbrofol) Yn dangos y 'Gweld Delwedd' opsiwn i chwiliadau delweddau bwrdd gwaith. Mae hyn yn achosi delwedd o ansawdd is.\",\n        \"config-tor\": \"Defnyddiwch Tor\",\n        \"config-get-only\": \"Ceisiadau GET yn unig\",\n        \"config-url\": \"URL am Gwraidd\",\n        \"config-pref-url\": \"URL am Dewisiadau\",\n        \"config-pref-encryption\": \"Cyfluniad Amgryptio\",\n        \"config-pref-help\": \"Yn angen WHOOGLE_CONFIG_PREFERENCES_KEY, neu bydd hyn yn cael ei anwybyddu.\",\n        \"config-css\": \"CSS Arferol\",\n        \"load\": \"Llwythwch\",\n        \"apply\": \"Cymhwyswch\",\n        \"save-as\": \"Cadw Fel...\",\n        \"github-link\": \"Gweld ar GitHub\",\n        \"translate\": \"cyfieithu\",\n        \"light\": \"golau\",\n        \"dark\": \"tywyll\",\n        \"system\": \"system\",\n        \"ratelimit\": \"Whoogle wedi bod yn gyfyngedig\",\n        \"continue-search\": \"Parhau eich chwiliad gyda Farside\",\n        \"all\": \"Holl\",\n        \"images\": \"Delweddau\",\n        \"maps\": \"Mapiau\",\n        \"videos\": \"Fideos\",\n        \"news\": \"Newyddion\",\n        \"books\": \"Llyfrau\",\n        \"anon-view\": \"Golwg Anhysbys\",\n        \"\": \"--\",\n        \"qdr:h\": \"Yr awr ddiwethaf\",\n        \"qdr:d\": \"24 awr diwethaf\",\n        \"qdr:w\": \"Yr wythnos ddiwethaf\",\n        \"qdr:m\": \"Mis diwethaf\",\n        \"qdr:y\": \"Y flwyddyn ddiwethaf\",\n        \"config-time-period\": \"Cyfnod Amser\"\n    },\n    \"lang_az\": {\n        \"\": \"--\",\n        \"search\": \"Axtar\",\n        \"config\": \"Konfiqurasiya\",\n        \"config-country\": \"Ölkə\",\n        \"config-lang\": \"İnterfeys dili\",\n        \"config-lang-search\": \"Axtarış dili\",\n        \"config-near\": \"Yaxın\",\n        \"config-near-help\": \"Şəhər Adı\",\n        \"config-block\": \"Blok\",\n        \"config-block-help\": \"Vergüllə ayrılmış sayt siyahısı\",\n        \"config-block-title\": \"Başlığa görə bloklayın\",\n        \"config-block-title-help\": \"Regex istifadə edin\",\n        \"config-block-url\": \"URL ilə bloklayın\",\n        \"config-block-url-help\": \"Regex istifadə edin\",\n        \"config-theme\": \"Mövzu\",\n        \"config-nojs\": \"Anonim Görünüşdə Javascript-i silin\",\n        \"config-anon-view\": \"Anonim Baxış Linklərini göstərin\",\n        \"config-dark\": \"Qaranlıq rejim\",\n        \"config-safe\": \"Təhlükəsiz axtarış\",\n        \"config-alts\": \"Sosial Media Linklərini dəyişdirin\",\n        \"config-alts-help\": \"Twitter/YouTube/s. linkləri alternativlərə uyğun məxfiliklə əvəz edir.\",\n        \"config-new-tab\": \"Linkləri Yeni Tabda açın\",\n        \"config-images\": \"Tam ölçülü Şəkil Axtarışı\",\n        \"config-images-help\": \"(Eksperimental) Masaüstü şəkil axtarışlarına 'Şəkilə Bax' seçimini əlavə edir. Bu, şəkil nəticəsi miniatürlərinin daha aşağı ayırdetmə keyfiyyətinə səbəb olacaq.\",\n        \"config-tor\": \"Tor-dan istifadə edin\",\n        \"config-get-only\": \"Yalnız GET Sorğuları\",\n        \"config-url\": \"Kök URL\",\n        \"config-pref-url\": \"URL Tərcihləri\",\n        \"config-pref-encryption\": \"Encrypt Tərcihləri\",\n        \"config-pref-help\": \"WHOOGLE_CONFIG_PREFERENCES_KEY tələb edir, əks halda bu nəzərə alınmayacaq.\",\n        \"config-css\": \"Fərdi CSS\",\n        \"config-time-period\": \"Müddət\",\n        \"load\": \"Yüklə\",\n        \"apply\": \"Tətbiq edin\",\n        \"save-as\": \"Fərqli Saxla...\",\n        \"github-link\": \"GitHub-da baxın\",\n        \"translate\": \"tərcümə\",\n        \"light\": \"işıqlı\",\n        \"dark\": \"qaranlıq\",\n        \"system\": \"sistem\",\n        \"ratelimit\": \"Nümunə dərəcəsi məhdudlaşdırılıb\",\n        \"continue-search\": \"Axtarışınızı Farside ilə davam etdirin\",\n        \"all\": \"Hamısı\",\n        \"images\": \"Şəkillər\",\n        \"maps\": \"Xəritələr\",\n        \"videos\": \"Videolar\",\n        \"news\": \"Xəbərlər\",\n        \"books\": \"Kitablar\",\n        \"anon-view\": \"Anonim Baxış\",\n        \"qdr:h\": \"Keçən saat\",\n        \"qdr:d\": \"Keçən 24 saat\",\n        \"qdr:w\": \"Keçən həftə\",\n        \"qdr:m\": \"Keçən ay\",\n        \"qdr:y\": \"Keçən il\"\n    },\n    \"lang_el\": {\n        \"\": \"--\",\n        \"search\": \"Αναζήτηση\",\n        \"config\": \"Ρυθμήσεις\",\n        \"config-country\": \"Χώρα\",\n        \"config-lang\": \"Γλώσσα Περιβάλλοντος\",\n        \"config-lang-search\": \"Γλώσσα Αναζήτησης\",\n        \"config-near\": \"Κοντά\",\n        \"config-near-help\": \"Όνομα Πόλης\",\n        \"config-block\": \"Block\",\n        \"config-block-help\": \"Comma-separated site list\",\n        \"config-block-title\": \"Block by Title\",\n        \"config-block-title-help\": \"Use regex\",\n        \"config-block-url\": \"Block by URL\",\n        \"config-block-url-help\": \"Use regex\",\n        \"config-theme\": \"Θέμα\",\n        \"config-nojs\": \"Αφαίρεση Javascript σε ανώνυμη προβολή\",\n        \"config-anon-view\": \"Show Anonymous View Links\",\n        \"config-dark\": \"Dark Mode\",\n        \"config-safe\": \"Ασφαλής Αναζήτηση\",\n        \"config-alts\": \"Replace Social Media Links\",\n        \"config-alts-help\": \"Replaces Twitter/YouTube/etc links with privacy respecting alternatives.\",\n        \"config-new-tab\": \"Άνοιγμα συνδέσμου σε νέα καρτέλα\",\n        \"config-images\": \"Full Size Image Search\",\n        \"config-images-help\": \"(Experimental) Adds the 'View Image' option to desktop image searches. This will cause image result thumbnails to be lower resolution.\",\n        \"config-tor\": \"Χρήση Tor\",\n        \"config-get-only\": \"GET Requests Only\",\n        \"config-url\": \"Root URL\",\n        \"config-pref-url\": \"Preferences URL\",\n        \"config-pref-encryption\": \"Encrypt Preferences\",\n        \"config-pref-help\": \"Requires WHOOGLE_CONFIG_PREFERENCES_KEY, otherwise this will be ignored.\",\n        \"config-css\": \"Custom CSS\",\n        \"config-time-period\": \"Time Period\",\n        \"load\": \"Load\",\n        \"apply\": \"Apply\",\n        \"save-as\": \"Save As...\",\n        \"github-link\": \"View on GitHub\",\n        \"translate\": \"translate\",\n        \"light\": \"light\",\n        \"dark\": \"dark\",\n        \"system\": \"system\",\n        \"ratelimit\": \"Instance has been ratelimited\",\n        \"continue-search\": \"Continue your search with Farside\",\n        \"all\": \"All\",\n        \"images\": \"Images\",\n        \"maps\": \"Maps\",\n        \"videos\": \"Videos\",\n        \"news\": \"News\",\n        \"books\": \"Books\",\n        \"anon-view\": \"Ανώνυμη Προβολή\",\n        \"qdr:h\": \"Τελευταία ώρα\",\n        \"qdr:d\": \"Τελευταίες 24 ώρες\",\n        \"qdr:w\": \"Τελευταία Βδομάδα\",\n        \"qdr:m\": \"Τελευταίος Μήνας\",\n        \"qdr:y\": \"Τελευταίος Χρόνος\"\n    },\n\t \"lang_tr\": {\n\t\t\"\": \"--\",\n\t\t\"search\": \"Ara\",\n\t\t\"config\": \"Seçenekler\",\n\t\t\"config-country\": \"Ülke\",\n\t\t\"config-lang\": \"Arayüz Dili\",\n\t\t\"config-lang-search\": \"Arama Dili\",\n\t\t\"config-near\": \"Yakınında\",\n\t\t\"config-near-help\": \"Şehir Adı\",\n\t\t\"config-block\": \"Engelle\",\n\t\t\"config-block-help\": \"Virgülle ayrılmış site listesi\",\n\t\t\"config-block-title\": \"Başlığa Göre Engelle\",\n\t\t\"config-block-title-help\": \"Regex kullan\",\n\t\t\"config-block-url\": \"URL'ye Göre Engelle\",\n\t\t\"config-block-url-help\": \"Regex kullan\",\n\t\t\"config-theme\": \"Tema\",\n\t\t\"config-nojs\": \"Anonim Görünümde Javascript'i Kaldır\",\n\t\t\"config-anon-view\": \"Anonim Görünüm Bağlantılarını Göster\",\n\t\t\"config-dark\": \"Karanlık Mod\",\n\t\t\"config-safe\": \"Güvenli Arama\",\n\t\t\"config-alts\": \"Sosyal Medya Bağlantılarını Değiştir\",\n\t\t\"config-alts-help\": \"Twitter/YouTube/vb. bağlantıları gizliliğe saygılı alternatiflerle değiştirir.\",\n\t\t\"config-new-tab\": \"Bağlantıları Yeni Sekmede Aç\",\n\t\t\"config-images\": \"Tam Boyutlu Görsel Arama\",\n\t\t\"config-images-help\": \"(Deneysel) Masaüstü görsel aramalarına 'Görseli Görüntüle' seçeneği ekler. Bu, görsel sonuç küçük resimlerinin daha düşük çözünürlükte olmasına neden olur.\",\n\t\t\"config-tor\": \"Tor Kullan\",\n\t\t\"config-get-only\": \"Yalnızca GET İstekleri\",\n\t\t\"config-url\": \"Kök URL\",\n\t\t\"config-pref-url\": \"Tercihler URL'si\",\n\t\t\"config-pref-encryption\": \"Tercihleri Şifrele\",\n\t\t\"config-pref-help\": \"WHOOGLE_CONFIG_PREFERENCES_KEY gerektirir, aksi takdirde bu göz ardı edilir.\",\n\t\t\"config-css\": \"Özel CSS\",\n\t\t\"config-time-period\": \"Zaman Aralığı\",\n\t\t\"load\": \"Yükle\",\n\t\t\"apply\": \"Uygula\",\n\t\t\"save-as\": \"Farklı Kaydet...\",\n\t\t\"github-link\": \"GitHub'da Görüntüle\",\n\t\t\"translate\": \"çevir\",\n\t\t\"light\": \"açık\",\n\t\t\"dark\": \"koyu\",\n\t\t\"system\": \"sistem\",\n\t\t\"ratelimit\": \"Sunucu hız sınırına ulaştı\",\n\t\t\"continue-search\": \"Aramanızı Farside ile sürdürün\",\n\t\t\"all\": \"Tümü\",\n\t\t\"images\": \"Görseller\",\n\t\t\"maps\": \"Haritalar\",\n\t\t\"videos\": \"Videolar\",\n\t\t\"news\": \"Haberler\",\n\t\t\"books\": \"Kitaplar\",\n\t\t\"anon-view\": \"Anonim Görünüm\",\n\t\t\"qdr:h\": \"Son saat\",\n\t\t\"qdr:d\": \"Son 24 saat\",\n\t\t\"qdr:w\": \"Geçen hafta\",\n\t\t\"qdr:m\": \"Geçen ay\",\n\t\t\"qdr:y\": \"Geçen yıl\"\n\t}\n}\n"
  },
  {
    "path": "app/static/widgets/calculator.html",
    "content": "<!--\n    Calculator widget.\n    This file should contain all required \n    CSS, HTML, and JS for it.\n-->\n\n<style>\n    #calc-text {\n        background: var(--whoogle-dark-page-bg);\n        padding: 8px;\n        border-radius: 8px;\n        text-align: right;\n        font-family: monospace;\n        font-size: 16px;\n        color: var(--whoogle-dark-text);\n    }\n    #prev-equation {\n        text-align: right;\n    }\n    .error-border {\n        border: 1px solid red;\n    }\n\n    #calc-btns {\n        display: grid;\n        grid-template-columns: repeat(6, 1fr);\n        grid-template-rows: repeat(5, 1fr);\n        gap: 5px;\n    }\n    #calc-btns button {\n        background: #313141;\n        color: var(--whoogle-dark-text);\n        border: none;\n        border-radius: 8px;\n        padding: 8px;\n        cursor: pointer;\n    }\n    #calc-btns button:hover {\n        background: #414151;\n    }\n    #calc-btns .common {\n        background: #51516a;\n    }\n    #calc-btns .common:hover {\n        background: #61617a;\n    }\n    #calc-btn-0 { grid-row: 5; grid-column: 3; }\n    #calc-btn-1 { grid-row: 4; grid-column: 3; }\n    #calc-btn-2 { grid-row: 4; grid-column: 4; }\n    #calc-btn-3 { grid-row: 4; grid-column: 5; }\n    #calc-btn-4 { grid-row: 3; grid-column: 3; }\n    #calc-btn-5 { grid-row: 3; grid-column: 4; }\n    #calc-btn-6 { grid-row: 3; grid-column: 5; }\n    #calc-btn-7 { grid-row: 2; grid-column: 3; }\n    #calc-btn-8 { grid-row: 2; grid-column: 4; }\n    #calc-btn-9 { grid-row: 2; grid-column: 5; }\n    #calc-btn-EQ { grid-row: 5; grid-column: 5; }\n    #calc-btn-PT { grid-row: 5; grid-column: 4; }\n    #calc-btn-BCK { grid-row: 5; grid-column: 6; }\n    #calc-btn-ADD { grid-row: 4; grid-column: 6; }\n    #calc-btn-SUB { grid-row: 3; grid-column: 6; }\n    #calc-btn-MLT { grid-row: 2; grid-column: 6; }\n    #calc-btn-DIV { grid-row: 1; grid-column: 6; }\n    #calc-btn-CLR { grid-row: 1; grid-column: 5; }\n    #calc-btn-PRC{ grid-row: 1; grid-column: 4; }\n    #calc-btn-RP { grid-row: 1; grid-column: 3; }\n    #calc-btn-LP { grid-row: 1; grid-column: 2; }\n    #calc-btn-ABS { grid-row: 1; grid-column: 1; }\n    #calc-btn-SIN { grid-row: 2; grid-column: 2; }\n    #calc-btn-COS { grid-row: 3; grid-column: 2; }\n    #calc-btn-TAN { grid-row: 4; grid-column: 2; }\n    #calc-btn-SQR { grid-row: 5; grid-column: 2; }\n    #calc-btn-EXP { grid-row: 2; grid-column: 1; }\n    #calc-btn-E { grid-row: 3; grid-column: 1; }\n    #calc-btn-PI { grid-row: 4; grid-column: 1; }\n    #calc-btn-LOG { grid-row: 5; grid-column: 1; }\n</style>\n<p id=\"prev-equation\"></p>\n<div id=\"calculator-widget\">\n    <p id=\"calc-text\">0</p>\n    <div id=\"calc-btns\">\n        <button id=\"calc-btn-0\" class=\"common\">0</button>\n        <button id=\"calc-btn-1\" class=\"common\">1</button>\n        <button id=\"calc-btn-2\" class=\"common\">2</button>\n        <button id=\"calc-btn-3\" class=\"common\">3</button>\n        <button id=\"calc-btn-4\" class=\"common\">4</button>\n        <button id=\"calc-btn-5\" class=\"common\">5</button>\n        <button id=\"calc-btn-6\" class=\"common\">6</button>\n        <button id=\"calc-btn-7\" class=\"common\">7</button>\n        <button id=\"calc-btn-8\" class=\"common\">8</button>\n        <button id=\"calc-btn-9\" class=\"common\">9</button>\n        <button id=\"calc-btn-EQ\" class=\"common\">=</button>\n        <button id=\"calc-btn-PT\" class=\"common\">.</button>\n        <button id=\"calc-btn-BCK\">⬅</button>\n        <button id=\"calc-btn-ADD\">+</button>\n        <button id=\"calc-btn-SUB\">-</button>\n        <button id=\"calc-btn-MLT\">x</button>\n        <button id=\"calc-btn-DIV\">/</button>\n        <button id=\"calc-btn-CLR\">C</button>\n        <button id=\"calc-btn-PRC\">%</button>\n        <button id=\"calc-btn-RP\">)</button>\n        <button id=\"calc-btn-LP\">(</button>\n        <button id=\"calc-btn-ABS\">|x|</button>\n        <button id=\"calc-btn-SIN\">sin</button>\n        <button id=\"calc-btn-COS\">cos</button>\n        <button id=\"calc-btn-TAN\">tan</button>\n        <button id=\"calc-btn-SQR\">√</button>\n        <button id=\"calc-btn-EXP\">^</button>\n        <button id=\"calc-btn-E\">ℇ</button>\n        <button id=\"calc-btn-PI\">π</button>\n        <button id=\"calc-btn-LOG\">log</button>\n    </div>\n</div>\n<script>\n// JS does not have this by default.\n// from https://www.freecodecamp.org/news/how-to-factorialize-a-number-in-javascript-9263c89a4b38/\nfunction factorial(num) {\n  if (num < 0) \n        return -1;\n  else if (num === 0) \n      return 1;\n  else {\n      return (num * factorial(num - 1));\n  }\n}\n// returns true if the user is currently focused on the calculator widget\nfunction usingCalculator() {\n    let activeElement = document.activeElement;\n    while (true) {\n        if (!activeElement) return false;\n        if (activeElement.id === \"calculator-wrapper\") return true;   \n        activeElement = activeElement.parentElement;\n    }\n}\nconst $ = q => document.querySelectorAll(q);\n// key bindings for commonly used buttons\nconst keybindings = {\n    \"0\": \"0\",\n    \"1\": \"1\",\n    \"2\": \"2\",\n    \"3\": \"3\",\n    \"4\": \"4\",\n    \"5\": \"5\",\n    \"6\": \"6\",\n    \"7\": \"7\",\n    \"8\": \"8\",\n    \"9\": \"9\",\n    \"Enter\": \"EQ\",\n    \".\": \"PT\",\n    \"+\": \"ADD\",\n    \"-\": \"SUB\",\n    \"*\": \"MLT\",\n    \"/\": \"DIV\",\n    \"%\": \"PRC\",\n    \"c\": \"CLR\",\n    \"(\": \"LP\",\n    \")\": \"RP\",\n    \"Backspace\": \"BCK\",\n}\nwindow.addEventListener(\"keydown\", event => {\n    if (!usingCalculator()) return;\n    if (event.key === \"Enter\" && document.activeElement.id !== \"search-bar\")\n        event.preventDefault();\n    if (keybindings[event.key])\n        document.getElementById(\"calc-btn-\" + keybindings[event.key]).click();\n})\n// calculates the string \nconst calc = () => {\n    var mathtext = document.getElementById(\"calc-text\");\n    var statement = mathtext.innerHTML\n        // remove empty ()\n        .replace(\"()\", \"\")\n        // special constants\n        .replace(\"π\", \"(Math.PI)\")\n        .replace(\"ℇ\", \"(Math.E)\")\n        // turns 3(1+2) into 3*(1+2) (for example)\n        .replace(/(?<=[0-9\\)])(?<=[^+\\-x*\\/%^])\\(/, \"x(\")\n        // same except reversed\n        .replace(/\\)(?=[0-9\\(])(?=[^+\\-x*\\/%^])/, \")x\")\n        // replace human friendly x with JS *\n        .replace(\"x\", \"*\")\n        // trig & misc functions\n        .replace(\"sin\", \"Math.sin\")\n        .replace(\"cos\", \"Math.cos\")\n        .replace(\"tan\", \"Math.tan\")\n        .replace(\"√\", \"Math.sqrt\")\n        .replace(\"^\", \"**\")\n        .replace(\"abs\", \"Math.abs\")\n        .replace(\"log\", \"Math.log\")\n        ;\n    // add any missing )s to the end\n    while(true) if (\n        (statement.match(/\\(/g) || []).length > \n        (statement.match(/\\)/g) || []).length\n    ) statement += \")\"; else break;\n    // evaluate the expression using a safe evaluator (no eval())\n    console.log(\"calculating [\" + statement + \"]\");\n    try {\n        // Safe evaluation: create a sandboxed function with only Math object available\n        // This prevents arbitrary code execution while allowing mathematical operations\n        const safeEval = new Function('Math', `'use strict'; return (${statement})`);\n        var result = safeEval(Math);\n        document.getElementById(\"prev-equation\").innerHTML = mathtext.innerHTML + \" = \";\n        mathtext.innerHTML = result;\n        mathtext.classList.remove(\"error-border\");\n    } catch (e) {\n        mathtext.classList.add(\"error-border\");\n        console.error(e);\n    }\n}\nconst updateCalc = (e) => {\n    // character(s) recieved from button\n    var c = event.target.innerHTML;\n    var mathtext = document.getElementById(\"calc-text\");\n    if (mathtext.innerHTML === \"0\") mathtext.innerHTML = \"\";\n    // special cases\n    switch (c) {\n        case \"C\":\n            // Clear\n            mathtext.innerHTML = \"0\";\n            break;\n        case \"⬅\":\n            // Delete\n            mathtext.innerHTML = mathtext.innerHTML.slice(0, -1);\n            if (mathtext.innerHTML.length === 0) {\n                mathtext.innerHTML = \"0\";\n            }\n            break;\n        case \"=\":\n            calc()\n            break;\n        case \"sin\":\n        case \"cos\":\n        case \"tan\":\n        case \"log\":\n        case \"√\":\n            mathtext.innerHTML += `${c}(`;\n            break;\n        case \"|x|\":\n            mathtext.innerHTML += \"abs(\"\n            break;\n        case \"+\":\n        case \"-\":\n        case \"x\":\n        case \"/\":\n        case \"%\":\n        case \"^\":\n            if (mathtext.innerHTML.length === 0) mathtext.innerHTML = \"0\"; \n            // prevent typing 2 operators in a row\n            if (mathtext.innerHTML.match(/[+\\-x\\/%^] $/))\n                mathtext.innerHTML = mathtext.innerHTML.slice(0, -3);\n            mathtext.innerHTML += ` ${c} `;\n            break;\n        default:\n            mathtext.innerHTML += c;\n    }\n}\nfor (let i of $(\"#calc-btns button\")) {\n    i.addEventListener('click', event => {\n        updateCalc(event);\n    })\n}\n</script>\n"
  },
  {
    "path": "app/templates/display.html",
    "content": "<html>\n<head>\n    <link rel=\"shortcut icon\" href=\"static/img/favicon.ico\" type=\"image/x-icon\">\n    <link rel=\"icon\" href=\"static/img/favicon.ico\" type=\"image/x-icon\">\n    {% if not search_type %}\n        <link rel=\"search\" href=\"opensearch.xml\" type=\"application/opensearchdescription+xml\" title=\"Whoogle Search\">\n    {% else %}\n        <link rel=\"search\" href=\"opensearch.xml?tbm={{ search_type }}\" type=\"application/opensearchdescription+xml\" title=\"Whoogle Search ({{ search_name }})\">\n    {% endif %}\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"referrer\" content=\"no-referrer\">\n    {% if bundle_static() %}\n        <link rel=\"stylesheet\" href=\"/{{ cb_url('bundle.css') }}\">\n    {% else %}\n        <link rel=\"stylesheet\" href=\"{{ cb_url('logo.css') }}\">\n        <link rel=\"stylesheet\" href=\"{{ cb_url('input.css') }}\">\n        <link rel=\"stylesheet\" href=\"{{ cb_url('search.css') }}\">\n        <link rel=\"stylesheet\" href=\"{{ cb_url('header.css') }}\">\n    {% endif %}\n    {% if config.theme %}\n        {% if config.theme == 'system' %}\n            <style>\n                @import \"{{ cb_url('light-theme.css') }}\" screen;\n                @import \"{{ cb_url('dark-theme.css') }}\" screen and (prefers-color-scheme: dark);\n            </style>\n        {% else %}\n            <link rel=\"stylesheet\" href=\"{{ cb_url(config.theme + '-theme.css') }}\"/>\n        {% endif %}\n    {% endif %}\n    {% if config.style %}\n        <style>\n            {{ config.style }}\n        </style>\n    {% endif %}\n    <title>{{ clean_query(query) }} - Whoogle Search</title>\n</head>\n<body>\n{{ search_header|safe }}\n{% if is_translation %}\n    <iframe\n            id=\"lingva-iframe\"\n            src=\"{{ lingva_url }}/auto/{{ translate_to }}/{{ translate_str }}\">\n    </iframe>\n{% endif %}\n{{ response|safe }}\n</body>\n{% include 'footer.html' %}\n{% if bundle_static() %}\n    <script src=\"/{{ cb_url('bundle.js') }}\" defer></script>\n{% else %}\n    {% if autocomplete_enabled == '1' %}\n        <script src=\"{{ cb_url('autocomplete.js') }}\"></script>\n    {% endif %}\n    <script src=\"{{ cb_url('utils.js') }}\"></script>\n    <script src=\"{{ cb_url('keyboard.js') }}\"></script>\n    <script src=\"{{ cb_url('currency.js') }}\"></script>\n{% endif %}\n</html>\n"
  },
  {
    "path": "app/templates/error.html",
    "content": "{% if config.theme %}\n    {% if config.theme == 'system' %}\n        <style>\n            @import \"{{ cb_url('light-theme.css') }}\" screen;\n            @import \"{{ cb_url('dark-theme.css') }}\" screen and (prefers-color-scheme: dark);\n        </style>\n    {% else %}\n        <link rel=\"stylesheet\" href=\"{{ cb_url(config.theme + '-theme.css') }}\"/>\n    {% endif %}\n{% endif %}\n{% if bundle_static() %}\n<link rel=\"stylesheet\" href=\"/{{ cb_url('bundle.css') }}\">\n{% else %}\n<link rel=\"stylesheet\" href=\"{{ cb_url('main.css') }}\">\n<link rel=\"stylesheet\" href=\"{{ cb_url('error.css') }}\">\n{% endif %}\n<style>{{ config.style }}</style>\n<div>\n    <h1>Error</h1>\n    <p>\n        {{ error_message }}\n    </p>\n    <hr>\n    {% if query and translation %}\n        <p>\n            <h4><a class=\"link\" href=\"https://farside.link\">{{ translation['continue-search'] }}</a></h4>\n            <ul>\n                <li>\n                    <a href=\"https://github.com/benbusby/whoogle-search\">Whoogle</a>\n                    <ul>\n                        <li>\n                            <a class=\"link-color\" href=\"{{farside}}/whoogle/search?q={{query}}{{params}}\">\n                                {{farside}}/whoogle/search?q={{query}}\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n                <li>\n                    <a href=\"https://github.com/searxng/searxng\">SearXNG</a>\n                    <ul>\n                        <li>\n                            <a class=\"link-color\" href=\"{{farside}}/searxng/search?q={{query}}\">\n                                {{farside}}/searxng/search?q={{query}}\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n                <li>\n                    <a href=\"https://git.lolcat.ca/lolcat/4get\">4get</a>\n                    <ul>\n                        <li>\n                            <a class=\"link-color\" href=\"{{farside}}/4get/web?s={{query}}&scraper=google\">\n                                {{farside}}/4get/web?s={{query}}&scraper=google\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n            </ul>\n            <hr>\n            <h4>Other options:</h4>\n            <ul>\n                <li>\n                    <a href=\"https://kagi.com\">Kagi</a>\n                    <ul>\n                        <li>Requires account</li>\n                        <li>\n                            <a class=\"link-color\" href=\"https://kagi.com/search?q={{query}}\">\n                                kagi.com/search?q={{query}}\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n                <li>\n                    <a href=\"https://4get.ca\">4get</a>\n                    <ul>\n                        <li>\n                            <a class=\"link-color\" href=\"https://4get.ca/web?s={{query}}\">\n                                4get.ca/web?s={{query}}\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n                <li>\n                    <a href=\"https://duckduckgo.com\">DuckDuckGo</a>\n                    <ul>\n                        <li>\n                            <a class=\"link-color\" href=\"https://duckduckgo.com/?q={{query}}\">\n                                duckduckgo.com/?q={{query}}\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n                <li>\n                    <a href=\"https://search.brave.com\">Brave Search</a>\n                    <ul>\n                        <li>\n                            <a class=\"link-color\" href=\"https://search.brave.com/search?q={{query}}\">\n                                search.brave.com/search?q={{query}}\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n                <li>\n                    <a href=\"https://ecosia.com\">Ecosia</a>\n                    <ul>\n                        <li>\n                            <a class=\"link-color\" href=\"https://ecosia.com/search?q={{query}}\">\n                                ecosia.com/search?q={{query}}\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n                <li>\n                    <a href=\"https://google.com\">Google</a>\n                    <ul>\n                        <li>\n                            <a class=\"link-color\" href=\"https://google.com/search?q={{query}}\">\n                                google.com/search?q={{query}}\n                            </a>\n                        </li>\n                    </ul>\n                </li>\n            </ul>\n            <hr>\n        </p>\n    {% endif %}\n    <a class=\"link\" href=\"home\">Return Home</a>\n</div>\n"
  },
  {
    "path": "app/templates/footer.html",
    "content": "<footer>\n    <p class=\"footer\">\n        Whoogle Search v{{ version_number }} ||\n        <a class=\"link\" href=\"https://github.com/benbusby/whoogle-search\">{{ translation['github-link'] }}</a>\n        {% if has_update %}\n             || <span class=\"update_available\">Update Available 🟢</span>\n        {% endif %}\n        {% if config.show_user_agent and used_user_agent %}\n            <br><span class=\"user-agent-display\" style=\"font-size: 0.85em; color: #666;\">User Agent: {{ used_user_agent }}</span>\n        {% endif %}\n    </p>\n</footer>\n"
  },
  {
    "path": "app/templates/header.html",
    "content": "{% if mobile %}\n    <header>\n        <div class=\"header-div\">\n            <form class=\"search-form header\"\n                  id=\"search-form\"\n                  method=\"{{ 'GET' if config.get_only else 'POST' }}\">\n                <a class=\"logo-link mobile-logo\" href=\"{{ home_url }}\">\n                    <div id=\"mobile-header-logo\">\n                        {{ logo|safe }}\n                    </div>\n                </a>\n                <div class=\"H0PQec mobile-input-div\">\n                    <div class=\"autocomplete-mobile esbc autocomplete\">\n                        {% if config.preferences %}\n                            <input type=\"hidden\" name=\"preferences\" value=\"{{ config.preferences }}\" />\n                        {% endif %}\n                        <input\n                                id=\"search-bar\"\n                                class=\"mobile-search-bar\"\n                                autocapitalize=\"none\"\n                                autocomplete=\"off\"\n                                autocorrect=\"off\"\n                                spellcheck=\"false\"\n                                class=\"search-bar-input\"\n                                name=\"q\"\n                                type=\"text\"\n                                value=\"{{ clean_query(query) }}\"\n                                dir=\"auto\">\n                        <input id=\"search-reset\" type=\"reset\" value=\"x\">\n                        <input name=\"tbm\" value=\"{{ search_type }}\" style=\"display: none\">\n                        <input name=\"country\" value=\"{{ config.country }}\" style=\"display: none;\">\n                        <input type=\"submit\" style=\"display: none;\">\n                        <div class=\"sc\"></div>\n                    </div>\n                </div>\n            </form>\n        </div>\n      <div>\n        <div class=\"header-tab-div\">\n            <div class=\"header-tab-div-2\">\n                <div class=\"header-tab-div-3\">\n                    <div class=\"mobile-header header-tab\">\n                        {% for tab_id, tab_content in tabs.items() %}\n                            {% if tab_content['selected'] %}\n                                <span class=\"mobile-tab-span\">{{ tab_content['name'] }}</span>\n                            {% else %}\n                                <a class=\"header-tab-a\" href=\"{{ tab_content['href'] }}\">{{ tab_content['name'] }}</a>\n                            {% endif %}\n                        {% endfor %}\n                      <label for=\"adv-search-toggle\" id=\"adv-search-label\" class=\"adv-search\">⚙</label>\n                      <input id=\"adv-search-toggle\" type=\"checkbox\">\n                        <div class=\"header-tab-div-end\"></div>\n                    </div>\n                </div>\n            </div>\n        </div>\n        <div class=\"\" id=\"s\">\n      </div>\n    </header>\n{% else %}\n    <header>\n        <div class=\"logo-div\">\n            <a class=\"logo-link\" href=\"{{ home_url }}\">\n                <div class=\"desktop-header-logo\">\n                    {{ logo|safe }}\n                </div>\n            </a>\n        </div>\n        <div class=\"search-div\">\n            <form id=\"search-form\"\n                  class=\"search-form\"\n                  id=\"sf\"\n                  method=\"{{ 'GET' if config.get_only else 'POST' }}\">\n                <div class=\"autocomplete header-autocomplete\">\n                    <div style=\"width: 100%; display: flex\">\n                        {% if config.preferences %}\n                            <input type=\"hidden\" name=\"preferences\" value=\"{{ config.preferences }}\" />\n                        {% endif %}\n                        <input\n                                id=\"search-bar\"\n                                autocapitalize=\"none\"\n                                autocomplete=\"off\"\n                                autocorrect=\"off\"\n                                class=\"search-bar-desktop search-bar-input\"\n                                name=\"q\"\n                                spellcheck=\"false\"\n                                type=\"text\"\n                                value=\"{{ clean_query(query) }}\"\n                                dir=\"auto\">\n                        <input name=\"tbm\" value=\"{{ search_type }}\" style=\"display: none\">\n                        <input name=\"country\" value=\"{{ config.country }}\" style=\"display: none;\">\n                        <input name=\"tbs\" value=\"{{ config.tbs }}\" style=\"display: none;\">\n                        <input type=\"submit\" style=\"display: none;\">\n                        <div class=\"sc\"></div>\n                    </div>\n                </div>\n            </form>\n        </div>\n    </header>\n    <div>\n      <div class=\"header-tab-div\">\n          <div class=\"header-tab-div-2\">\n              <div class=\"header-tab-div-3\">\n                  <div class=\"desktop-header header-tab\">\n                      {% for tab_id, tab_content in tabs.items() %}\n                          {% if tab_content['selected'] %}\n                              <span class=\"header-tab-span\">{{ tab_content['name'] }}</span>\n                          {% else %}\n                              <a class=\"header-tab-a\" href=\"{{ tab_content['href'] }}\">{{ tab_content['name'] }}</a>\n                          {% endif %}\n                      {% endfor %}\n                      <label for=\"adv-search-toggle\" id=\"adv-search-label\" class=\"adv-search\">⚙</label>\n                      <input id=\"adv-search-toggle\" type=\"checkbox\">\n                      <div class=\"header-tab-div-end\"></div>\n                  </div>\n              </div>\n          </div>\n      </div>\n      <div class=\"\" id=\"s\">\n  </div>\n{% endif %}\n<div class=\"result-collapsible\" id=\"adv-search-div\">\n    <div class=\"result-config\">\n        <label for=\"config-country\">{{ translation['config-country'] }}: </label>\n        <select name=\"country\" id=\"result-country\">\n            {% for country in countries %}\n                <option value=\"{{ country.value }}\"\n                    {% if (\n                        config.country != '' and config.country in country.value\n                    ) or (\n                        config.country == '' and country.value == '')\n                    %}\n                    selected\n                    {% endif %}>\n                    {{ country.name }}\n                </option>\n            {% endfor %}\n        </select>\n        <br />\n        <label for=\"config-time-period\">{{ translation['config-time-period'] }}: </label>\n        <select name=\"tbs\" id=\"result-time-period\">\n            {% for time_period in time_periods %}\n                <option value=\"{{ time_period.value }}\"\n                        {% if (\n                            config.tbs != '' and config.tbs in time_period.value\n                        ) or (\n                            config.tbs == '' and time_period.value == '')\n                        %}\n                        selected\n                        {% endif %}>\n                {{ translation[time_period.value] }}\n                </option>\n            {% endfor %}\n        </select>\n    </div>\n</div>\n\n{% if bundle_static() %}\n<script src=\"/{{ cb_url('bundle.js') }}\" defer></script>\n{% else %}\n<script type=\"text/javascript\" src=\"{{ cb_url('header.js') }}\"></script>\n{% endif %}\n"
  },
  {
    "path": "app/templates/imageresults.html",
    "content": "<div>\n  <style>\n    html {\n      font-family: Roboto, Helvetica Neue, Arial, sans-serif;\n      font-size: 14px;\n      line-height: 20px;\n      text-size-adjust: 100%;\n      color: #3c4043;\n      word-wrap: break-word;\n      background-color: #fff;\n    }\n    body {\n      padding: 0 12px;\n      margin: 0 auto;\n      max-width: 1200px;\n    }\n    a {\n      text-decoration: none;\n      color: inherit;\n    }\n    a:hover {\n      text-decoration: underline;\n    }\n    a img {\n      border: 0;\n    }\n\n    .FbhRzb {\n      border-left: thin solid #dadce0;\n      border-right: thin solid #dadce0;\n      border-top: thin solid #dadce0;\n      height: 40px;\n      overflow: hidden;\n    }\n    .n692Zd {\n      margin-bottom: 10px;\n    }\n    .cvifge {\n      height: 40px;\n      border-spacing: 0;\n      width: 100%;\n    }\n    .QvGUP {\n      height: 40px;\n      padding: 0 8px 0 8px;\n      vertical-align: top;\n    }\n    .O4cRJf {\n      height: 40px;\n      width: 100%;\n      padding: 0;\n      padding-right: 16px;\n    }\n    .O1ePr {\n      height: 40px;\n      padding: 0;\n      vertical-align: top;\n    }\n    .kgJEQe {\n      height: 36px;\n      width: 98px;\n      vertical-align: top;\n      margin-top: 4px;\n    }\n    .lXLRf {\n      vertical-align: top;\n    }\n    .MhzMZd {\n      border: 0;\n      vertical-align: middle;\n      font-size: 14px;\n      height: 40px;\n      padding: 0;\n      width: 100%;\n      padding-left: 16px;\n    }\n    .xB0fq {\n      height: 40px;\n      border: none;\n      font-size: 14px;\n      background-color: #4285f4;\n      color: #fff;\n      padding: 0 16px;\n      margin: 0;\n      vertical-align: top;\n      cursor: pointer;\n    }\n    .xB0fq:focus {\n      border: 1px solid #000;\n    }\n    .M7pB2 {\n      border: thin solid #dadce0;\n      margin: 0 0 3px 0;\n      font-size: 13px;\n      font-weight: 500;\n      height: 40px;\n    }\n    .euZec {\n      width: 100%;\n      height: 40px;\n      text-align: center;\n      border-spacing: 0;\n    }\n    table.euZec td {\n      padding: 0;\n      width: 25%;\n    }\n    .QIqI7 {\n      display: inline-block;\n      padding-top: 4px;\n      font-weight: bold;\n      color: #4285f4;\n    }\n    .EY24We {\n      border-bottom: 2px solid #4285f4;\n    }\n    .CsQyDc {\n      display: inline-block;\n      color: #70757a;\n    }\n    .TuS8Ad {\n      font-size: 14px;\n    }\n    .HddGcc {\n      padding: 8px;\n      color: #70757a;\n    }\n    .dzp8ae {\n      font-weight: bold;\n      color: #3c4043;\n    }\n    .rEM8G {\n      color: #70757a;\n    }\n    .bookcf {\n      table-layout: fixed;\n      width: 100%;\n      border-spacing: 0;\n    }\n    .InWNIe {\n      text-align: center;\n    }\n    .uZgmoc {\n      border: thin solid #dadce0;\n      color: #70757a;\n      font-size: 14px;\n      text-align: center;\n      table-layout: fixed;\n      width: 100%;\n    }\n    .frGj1b {\n      display: block;\n      padding: 12px 0 12px 0;\n      width: 100%;\n    }\n    .BnJWBc {\n      text-align: center;\n      padding: 6px 0 13px 0;\n      height: 35px;\n    }\n    .e3goi {\n      vertical-align: top;\n      padding: 0;\n    }\n    .GpQGbf {\n      margin: auto;\n      border-collapse: collapse;\n      border-spacing: 0;\n      width: 100%;\n      table-layout: fixed;\n    }\n    .X6ZCif {\n      color: #202124;\n      font-size: 11px;\n      line-height: 16px;\n      display: inline-block;\n      padding-top: 2px;\n      overflow: hidden;\n      padding-bottom: 4px;\n      width: 100%;\n    }\n    .TwVfHd {\n      border-radius: 16px;\n      border: thin solid #dadce0;\n      display: inline-block;\n      padding: 8px 8px;\n      margin-right: 8px;\n      margin-bottom: 4px;\n    }\n    .yekiAe {\n      background-color: #dadce0;\n    }\n    .svla5d {\n      width: 100%;\n    }\n    .ezO2md {\n      border: thin solid #dadce0;\n      padding: 12px 16px 12px 16px;\n      margin-bottom: 10px;\n      font-family: Roboto, Helvetica, Arial, sans-serif;\n    }\n\n    .TxbwNb {\n      border-spacing: 0;\n    }\n    .K35ahc {\n      width: 100%;\n    }\n    .owohpf {\n      text-align: center;\n    }\n    .RAyV4b {\n      height: 220px;\n      line-height: 220px;\n      overflow: hidden;\n      text-align: center;\n    }\n    .t0fcAb {\n      text-align: center;\n      margin: auto;\n      vertical-align: middle;\n      object-fit: cover;\n      max-width: 100%;\n      height: auto;\n      max-height: 220px;\n      display: block;\n    }\n    .Tor4Ec {\n      padding-top: 2px;\n      padding-bottom: 8px;\n    }\n    .fYyStc {\n      word-break: break-word;\n    }\n    .ynsChf {\n      display: block;\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n    .Fj3V3b {\n      color: #1967d2;\n      font-size: 14px;\n      line-height: 20px;\n    }\n    .FrIlee {\n      color: #202124;\n      font-size: 11px;\n      line-height: 16px;\n    }\n    .F9iS2e {\n      color: #70757a;\n      font-size: 11px;\n      line-height: 16px;\n    }\n    .WMQ2Le {\n      color: #70757a;\n      font-size: 12px;\n      line-height: 16px;\n    }\n    .x3G5ab {\n      color: #202124;\n      font-size: 12px;\n      line-height: 16px;\n    }\n    .fuLhoc {\n      color: #1967d2;\n      font-size: 18px;\n      line-height: 24px;\n    }\n    .epoveb {\n      font-size: 32px;\n      line-height: 40px;\n      font-weight: 400;\n      color: #202124;\n    }\n    .dXDvrc {\n      color: #0d652d;\n      font-size: 14px;\n      line-height: 20px;\n      word-wrap: break-word;\n    }\n    .dloBPe {\n      font-weight: bold;\n    }\n    .YVIcad {\n      color: #70757a;\n    }\n    .JkVVdd {\n      color: #ea4335;\n    }\n    .oXZRFd {\n      color: #ea4335;\n    }\n    .MQHtg {\n      color: #fbbc04;\n    }\n    .pyMRrb {\n      color: #1e8e3e;\n    }\n    .EtTZid {\n      color: #1e8e3e;\n    }\n    .M3vVJe {\n      color: #1967d2;\n    }\n    .qXLe6d {\n      display: block;\n    }\n    .NHQNef {\n      font-style: italic;\n    }\n    .Cb8Z7c {\n      white-space: pre;\n    }\n    a.ZWRArf {\n      text-decoration: none;\n    }\n    a .CVA68e:hover {\n      text-decoration: underline;\n    }\n    .e3goi {\n      width: 25%;\n      padding: 10px;\n      box-sizing: border-box;\n    }\n    .svla5d {\n      max-width: 100%;\n    }\n    @media (max-width: 900px) {\n      .e3goi {\n        width: 50%;\n      }\n    }\n    @media (max-width: 600px) {\n      .e3goi {\n        width: 100%;\n      }\n    }\n  </style>\n  <div>\n    <div>\n      <div>\n        <div class=\"lIMUZd\">\n          <table class=\"By0U9\">\n            <!-- correction suggested -->\n          </table>\n        </div>\n      </div>\n    </div>\n    <table class=\"GpQGbf\">\n      {% for i in range((length // 4) + 1) %}\n      <tr>\n        {% for j in range([length - (i*4), 4]|min) %}\n        <td align=\"center\" class=\"e3goi\">\n          <div class=\"svla5d\">\n            <div>\n              <div class=\"lIMUZd\">\n                <div>\n                  <table class=\"TxbwNb\">\n                    <tr>\n                      <td>\n                        <a href=\"{{ results[(i*4)+j].web_page }}\">\n                          <div class=\"RAyV4b\">\n                            <img\n                              alt=\"\"\n                              class=\"t0fcAb\"\n                              src=\"{{ results[(i*4)+j].img_tbn }}\"\n                            />\n                          </div>\n                        </a>\n                      </td>\n                    </tr>\n                    <tr>\n                      <td>\n                        <a href=\"{{ results[(i*4)+j].web_page }}\">\n                          <div class=\"Tor4Ec\">\n                            <span class=\"qXLe6d x3G5ab\">\n                              <span class=\"fYyStc\">\n                                {{ results[(i*4)+j].domain }}\n                              </span>\n                            </span>\n                          </div>\n                        </a>\n                        <a href=\"{{ results[(i*4)+j].img_url }}\">\n                          <div class=\"Tor4Ec\">\n                            <span class=\"qXLe6d F9iS2e\">\n                              <span class=\"fYyStc\"> {{ view_label }} </span>\n                            </span>\n                          </div>\n                        </a>\n                      </td>\n                    </tr>\n                  </table>\n                </div>\n              </div>\n            </div>\n          </div>\n        </td>\n        {% endfor %}\n      </tr>\n      {% endfor %}\n    </table>\n  </div>\n  <table class=\"uZgmoc\">\n    <!-- next page object -->\n  </table>\n  <br />\n</div>\n"
  },
  {
    "path": "app/templates/index.html",
    "content": "<html style=\"background: #000;\">\n<head>\n    <link rel=\"apple-touch-icon\" sizes=\"57x57\" href=\"static/img/favicon/apple-icon-57x57.png\">\n    <link rel=\"apple-touch-icon\" sizes=\"60x60\" href=\"static/img/favicon/apple-icon-60x60.png\">\n    <link rel=\"apple-touch-icon\" sizes=\"72x72\" href=\"static/img/favicon/apple-icon-72x72.png\">\n    <link rel=\"apple-touch-icon\" sizes=\"76x76\" href=\"static/img/favicon/apple-icon-76x76.png\">\n    <link rel=\"apple-touch-icon\" sizes=\"114x114\" href=\"static/img/favicon/apple-icon-114x114.png\">\n    <link rel=\"apple-touch-icon\" sizes=\"120x120\" href=\"static/img/favicon/apple-icon-120x120.png\">\n    <link rel=\"apple-touch-icon\" sizes=\"144x144\" href=\"static/img/favicon/apple-icon-144x144.png\">\n    <link rel=\"apple-touch-icon\" sizes=\"152x152\" href=\"static/img/favicon/apple-icon-152x152.png\">\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"static/img/favicon/apple-icon-180x180.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"static/img/favicon/android-icon-192x192.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"static/img/favicon/favicon-32x32.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"96x96\" href=\"static/img/favicon/favicon-96x96.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"static/img/favicon/favicon-16x16.png\">\n    <link rel=\"manifest\" href=\"static/img/favicon/manifest.json\">\n    <meta name=\"referrer\" content=\"no-referrer\">\n    <meta name=\"msapplication-TileColor\" content=\"#ffffff\">\n    <meta name=\"msapplication-TileImage\" content=\"static/img/favicon/ms-icon-144x144.png\">\n    {% if bundle_static() %}\n        <script src=\"/{{ cb_url('bundle.js') }}\" defer></script>\n    {% else %}\n        {% if autocomplete_enabled == '1' %}\n            <script src=\"{{ cb_url('autocomplete.js') }}\"></script>\n        {% endif %}\n        <script type=\"text/javascript\" src=\"{{ cb_url('controller.js') }}\"></script>\n    {% endif %}\n    <link rel=\"search\" href=\"opensearch.xml\" type=\"application/opensearchdescription+xml\" title=\"Whoogle Search\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    {% if bundle_static() %}\n        <link rel=\"stylesheet\" href=\"/{{ cb_url('bundle.css') }}\">\n    {% else %}\n        <link rel=\"stylesheet\" href=\"{{ cb_url('logo.css') }}\">\n    {% endif %}\n    {% if config.theme %}\n        {% if config.theme == 'system' %}\n            <style>\n                @import \"{{ cb_url('light-theme.css') }}\" screen;\n                @import \"{{ cb_url('dark-theme.css') }}\" screen and (prefers-color-scheme: dark);\n            </style>\n        {% else %}\n            <link rel=\"stylesheet\" href=\"{{ cb_url(config.theme + '-theme.css') }}\"/>\n        {% endif %}\n    {% endif %}\n    {% if not bundle_static() %}\n        <link rel=\"stylesheet\" href=\"{{ cb_url('main.css') }}\">\n    {% endif %}\n    <noscript>\n        <style>\n            #main {\n                display: inherit !important;\n            }\n\n            .content {\n                max-height: 400px;\n                padding: 18px;\n                border-radius: 10px;\n                overflow-y: scroll;\n            }\n\n            .collapsible {\n                display: none;\n            }\n        </style>\n    </noscript>\n    <style>{{ config.style }}</style>\n    <title>Whoogle Search</title>\n</head>\n<body id=\"main\">\n<div class=\"search-container\">\n    <div class=\"logo-container\">\n        {{ logo|safe }}\n    </div>\n    <form id=\"search-form\" action=\"search\" method=\"{{ 'get' if config.get_only else 'post' }}\">\n        <div class=\"search-fields\">\n            <div class=\"autocomplete\">\n                {% if config.preferences %}\n                    <input type=\"hidden\" name=\"preferences\" value=\"{{ config.preferences }}\" />\n                {% endif %}\n                <input\n                        type=\"text\"\n                        name=\"q\"\n                        id=\"search-bar\"\n                        class=\"home-search\"\n                        autofocus=\"autofocus\"\n                        autocapitalize=\"none\"\n                        spellcheck=\"false\"\n                        autocorrect=\"off\"\n                        autocomplete=\"off\"\n                        dir=\"auto\">\n            </div>\n            <input type=\"submit\" id=\"search-submit\" value=\"{{ translation['search'] }}\">\n        </div>\n    </form>\n    {% if not config_disabled %}\n        <br/>\n        <button id=\"config-collapsible\" class=\"collapsible\">{{ translation['config'] }}</button>\n        <div class=\"content\">\n            <div class=\"config-fields\">\n                <form id=\"config-form\" action=\"config\" method=\"post\">\n                    <div class=\"config-options\">\n                        <div class=\"config-div config-div-country\">\n                            <label for=\"config-country\">{{ translation['config-country'] }}: </label>\n                            <select name=\"country\" id=\"config-country\">\n                                {% for country in countries %}\n                                    <option value=\"{{ country.value }}\"\n                                            {% if (\n                                                config.country != '' and config.country in country.value\n                                            ) or (\n                                                config.country == '' and country.value == '')\n                                            %}\n                                            selected\n                                            {% endif %}>\n                                        {{ country.name }}\n                                    </option>\n                                {% endfor %}\n                            </select>\n                        </div>\n                        <div class=\"config-div\">\n                            <label for=\"config-time-period\">{{ translation['config-time-period'] }}</label>\n                            <select name=\"tbs\" id=\"config-time-period\">\n                                {% for time_period in time_periods %}\n                                    <option value=\"{{ time_period.value }}\"\n                                            {% if (\n                                                config.tbs != '' and config.tbs in time_period.value\n                                            ) or (\n                                                config.tbs == '' and time_period.value == '')\n                                            %}\n                                            selected\n                                            {% endif %}>\n                                    {{ translation[time_period.value] }}\n                                    </option>\n                                {% endfor %}\n                            </select>\n                        </div>\n                        <div class=\"config-div config-div-lang\">\n                            <label for=\"config-lang-interface\">{{ translation['config-lang'] }}: </label>\n                            <select name=\"lang_interface\" id=\"config-lang-interface\">\n                                {% for lang in languages %}\n                                    <option value=\"{{ lang.value }}\"\n                                            {% if lang.value in config.lang_interface %}\n                                            selected\n                                            {% endif %}>\n                                        {{ lang.name }}\n                                    </option>\n                                {% endfor %}\n                            </select>\n                        </div>\n                        <div class=\"config-div config-div-search-lang\">\n                            <label for=\"config-lang-search\">{{ translation['config-lang-search'] }}: </label>\n                            <select name=\"lang_search\" id=\"config-lang-search\">\n                                {% for lang in languages %}\n                                    <option value=\"{{ lang.value }}\"\n                                            {% if lang.value in config.lang_search %}\n                                            selected\n                                            {% endif %}>\n                                        {{ lang.name }}\n                                    </option>\n                                {% endfor %}\n                            </select>\n                        </div>\n                        <div class=\"config-div config-div-near\">\n                            <label for=\"config-near\">{{ translation['config-near'] }}: </label>\n                            <input type=\"text\" name=\"near\" id=\"config-near\"\n                                   placeholder=\"{{ translation['config-near-help'] }}\" value=\"{{ config.near }}\">\n                        </div>\n                        <div class=\"config-div config-div-block\">\n                            <label for=\"config-block\">{{ translation['config-block'] }}: </label>\n                            <input type=\"text\" name=\"block\" id=\"config-block\"\n                                   placeholder=\"{{ translation['config-block-help'] }}\" value=\"{{ config.block }}\">\n                        </div>\n                        <div class=\"config-div config-div-block\">\n                            <label for=\"config-block-title\">{{ translation['config-block-title'] }}: </label>\n                            <input type=\"text\" name=\"block_title\" id=\"config-block\"\n                                   placeholder=\"{{ translation['config-block-title-help'] }}\"\n                                   value=\"{{ config.block_title }}\">\n                        </div>\n                        <div class=\"config-div config-div-block\">\n                            <label for=\"config-block-url\">{{ translation['config-block-url'] }}: </label>\n                            <input type=\"text\" name=\"block_url\" id=\"config-block\"\n                                   placeholder=\"{{ translation['config-block-url-help'] }}\" value=\"{{ config.block_url }}\">\n                        </div>\n                        <div class=\"config-div config-div-anon-view\">\n                            <label for=\"config-anon-view\">{{ translation['config-anon-view'] }}: </label>\n                            <input type=\"checkbox\" name=\"anon_view\" id=\"config-anon-view\" {{ 'checked' if config.anon_view else '' }}>\n                        </div>\n                        <div class=\"config-div config-div-nojs\">\n                            <label for=\"config-nojs\">{{ translation['config-nojs'] }}: </label>\n                            <input type=\"checkbox\" name=\"nojs\" id=\"config-nojs\" {{ 'checked' if config.nojs else '' }}>\n                        </div>\n                        <div class=\"config-div config-div-theme\">\n                            <label for=\"config-theme\">{{ translation['config-theme'] }}: </label>\n                            <select name=\"theme\" id=\"config-theme\">\n                                {% for theme in themes %}\n                                    <option value=\"{{ theme }}\"\n                                            {% if theme in config.theme %}\n                                            selected\n                                            {% endif %}>\n                                        {{ translation[theme].capitalize() }}\n                                    </option>\n                                {% endfor %}\n                            </select>\n                        </div>\n                        <!-- DEPRECATED -->\n                        <div class=\"config-div config-div-safe\">\n                            <label for=\"config-safe\">{{ translation['config-safe'] }}: </label>\n                            <input type=\"checkbox\" name=\"safe\" id=\"config-safe\" {{ 'checked' if config.safe else '' }}>\n                        </div>\n                        <div class=\"config-div config-div-alts\">\n                            <label class=\"tooltip\" for=\"config-alts\">{{ translation['config-alts'] }}: </label>\n                            <input type=\"checkbox\" name=\"alts\" id=\"config-alts\" {{ 'checked' if config.alts else '' }}>\n                            <div><span class=\"info-text\"> — {{ translation['config-alts-help'] }}</span></div>\n                        </div>\n                        <div class=\"config-div config-div-new-tab\">\n                            <label for=\"config-new-tab\">{{ translation['config-new-tab'] }}: </label>\n                            <input type=\"checkbox\" name=\"new_tab\"\n                                   id=\"config-new-tab\" {{ 'checked' if config.new_tab else '' }}>\n                        </div>\n                        <div class=\"config-div config-div-view-image\">\n                            <label for=\"config-view-image\">{{ translation['config-images'] }}: </label>\n                            <input type=\"checkbox\" name=\"view_image\"\n                                   id=\"config-view-image\" {{ 'checked' if config.view_image else '' }}>\n                            <div><span class=\"info-text\"> — {{ translation['config-images-help'] }}</span></div>\n                        </div>\n                        <div class=\"config-div config-div-tor\">\n                            <label for=\"config-tor\">{{ translation['config-tor'] }}: {{ '' if tor_available else 'Unavailable' }}</label>\n                            <input type=\"checkbox\" name=\"tor\"\n                                   id=\"config-tor\" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>\n                        </div>\n                        <div class=\"config-div config-div-get-only\">\n                            <label for=\"config-get-only\">{{ translation['config-get-only'] }}: </label>\n                            <input type=\"checkbox\" name=\"get_only\"\n                                   id=\"config-get-only\" {{ 'checked' if config.get_only else '' }}>\n                        </div>\n                        <div class=\"config-div config-div-user-agent\">\n                            <label for=\"config-user-agent\">User Agent: </label>\n                            <select name=\"user_agent\" id=\"config-user-agent\">\n                                <option value=\"env_conf\" {% if config.user_agent == 'env_conf' %}selected{% endif %}>Use ENV Conf</option>\n                                <option value=\"default\" {% if config.user_agent == 'default' %}selected{% endif %}>Default</option>\n                                <option value=\"custom\" {% if config.user_agent == 'custom' %}selected{% endif %}>Custom</option>\n                            </select>\n                        </div>\n                        <div class=\"config-div config-div-custom-user-agent\" {% if config.user_agent != 'custom' %}style=\"display: none;\"{% endif %}>\n                            <label for=\"config-custom-user-agent\">Custom User Agent: </label>\n                            <input type=\"text\" name=\"custom_user_agent\" id=\"config-custom-user-agent\"\n                                   value=\"{{ config.custom_user_agent }}\"\n                                   placeholder=\"Enter custom user agent string\">\n                            <div><span class=\"info-text\"> — <a href=\"https://github.com/benbusby/whoogle-search/wiki/User-Agents\">User Agent Wiki</a></span></div>\n                        </div>\n                        <div class=\"config-div config-div-accept-language\">\n                            <label for=\"config-accept-language\">Set Accept-Language: </label>\n                            <input type=\"checkbox\" name=\"accept_language\"\n                                   id=\"config-accept-language\" {{ 'checked' if config.accept_language else '' }}>\n                        </div>\n                        <div class=\"config-div config-div-show-user-agent\">\n                            <label for=\"config-show-user-agent\">Show User Agent in Footer: </label>\n                            <input type=\"checkbox\" name=\"show_user_agent\"\n                                   id=\"config-show-user-agent\" {{ 'checked' if config.show_user_agent else '' }}>\n                        </div>\n                        <!-- Google Custom Search Engine (BYOK) Settings -->\n                        <div class=\"config-div config-div-cse-header\" style=\"margin-top: 20px; border-top: 1px solid var(--result-bg); padding-top: 15px;\">\n                            <strong>Google Custom Search (BYOK)</strong>\n                            <div><span class=\"info-text\"> — <a href=\"https://github.com/benbusby/whoogle-search#google-custom-search-byok\">Setup Guide</a></span></div>\n                        </div>\n                        <div class=\"config-div config-div-use-cse\">\n                            <label for=\"config-use-cse\">Use Custom Search API: </label>\n                            <input type=\"checkbox\" name=\"use_cse\" id=\"config-use-cse\" {{ 'checked' if config.use_cse else '' }}>\n                            <div><span class=\"info-text\"> — Enable to use your own Google API key (100 free queries/day)</span></div>\n                        </div>\n                        <div class=\"config-div config-div-cse-api-key\">\n                            <label for=\"config-cse-api-key\">CSE API Key: </label>\n                            <input type=\"password\" name=\"cse_api_key\" id=\"config-cse-api-key\"\n                                   value=\"{{ config.cse_api_key }}\"\n                                   placeholder=\"AIza...\"\n                                   autocomplete=\"off\">\n                        </div>\n                        <div class=\"config-div config-div-cse-id\">\n                            <label for=\"config-cse-id\">CSE ID: </label>\n                            <input type=\"text\" name=\"cse_id\" id=\"config-cse-id\"\n                                   value=\"{{ config.cse_id }}\"\n                                   placeholder=\"abc123...\"\n                                   autocomplete=\"off\">\n                        </div>\n                        <div class=\"config-div config-div-root-url\">\n                            <label for=\"config-url\">{{ translation['config-url'] }}: </label>\n                            <input type=\"text\" name=\"url\" id=\"config-url\" value=\"{{ config.url }}\">\n                        </div>\n                        <div class=\"config-div config-div-custom-css\">\n                            <a id=\"css-link\"\n                               href=\"https://github.com/benbusby/whoogle-search/wiki/User-Contributed-CSS-Themes\">\n                                {{ translation['config-css'] }}:\n                            </a>\n                            <textarea\n                                    name=\"style_modified\"\n                                    id=\"config-style\"\n                                    autocapitalize=\"off\"\n                                    autocomplete=\"off\"\n                                    spellcheck=\"false\"\n                                    autocorrect=\"off\"\n                                    value=\"\">{{ config.style_modified.replace('\\t', '') }}</textarea>\n                        </div>\n                        <div class=\"config-div config-div-pref-url\">\n                            <label for=\"config-pref-encryption\">{{ translation['config-pref-encryption'] }}: </label>\n                            <input type=\"checkbox\" name=\"preferences_encrypted\"\n                                   id=\"config-pref-encryption\" {{ 'checked' if config.preferences_encrypted and config.preferences_key else '' }}>\n                            <div><span class=\"info-text\"> — {{ translation['config-pref-help'] }}</span></div>\n                            <label for=\"config-pref-url\">{{ translation['config-pref-url'] }}: </label>\n                            <input type=\"text\" name=\"pref-url\" id=\"config-pref-url\" value=\"{{ config.url }}?preferences={{ config.preferences }}\">\n                        </div>\n                    </div>\n                    <div class=\"config-div config-buttons\">\n                        <input type=\"submit\" id=\"config-load\" value=\"{{ translation['load'] }}\">&nbsp;\n                        <input type=\"submit\" id=\"config-submit\" value=\"{{ translation['apply'] }}\">&nbsp;\n                        <input type=\"submit\" id=\"config-save\" value=\"{{ translation['save-as'] }}\">\n                    </div>\n                </form>\n            </div>\n        </div>\n    {% endif %}\n</div>\n{% include 'footer.html' %}\n</body>\n</html>\n"
  },
  {
    "path": "app/templates/logo.html",
    "content": "    <svg id=\"Layer_1\" class=\"whoogle-svg\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1028 254\">\n        <defs>\n            <style>\n            </style>\n        </defs>\n        <path class=\"cls-1\" d=\"M1197,667H446V413H1474V667H1208a26.41,26.41,0,0,1,4.26-1.16c32.7-3.35,55.65-27.55,56.45-60.44.57-23.65.27-47.33.32-71,0-17.84-.16-35.67.11-53.5.07-4.92-1.57-6.54-6.3-6.11a74.65,74.65,0,0,1-11,0c-3.63-.2-5.18,1.13-5,4.87.22,4.22.05,8.45.05,12.68a6.16,6.16,0,0,1-3.78-2c-20-23.41-53.18-26.6-77.53-7.84-34,26.17-33.8,79.89-7.68,107.44,24.9,26.24,66,24.37,85.69-1.54a14.39,14.39,0,0,1,2.73-2c0,6.94.39,13.22-.08,19.42-1.18,15.5-7.79,28.06-22.32,34.72-15,6.85-30.27,7.21-44-2.92-5.82-4.28-10.1-10.66-15.66-16.71l-19.87,8.29c8.77,16.61,20.28,29.09,38.17,34.48C1187.28,665.12,1192.18,665.92,1197,667ZM447.16,414.27c.39,1.85.57,3,.86,4q25.22,91.07,50.4,182.12c.92,3.32,2.43,4.55,5.92,4.29a82,82,0,0,1,13.48,0c4.6.43,6.56-1.13,8-5.68,12.37-38.63,25-77.15,37.66-115.7.52-1.6,1.26-3.12,1.89-4.67l1.35.06c.81,2.26,1.68,4.51,2.42,6.79q18.62,57.13,37.12,114.31c1.13,3.5,2.61,5.23,6.58,4.89a80.69,80.69,0,0,1,14,0c4.15.37,5.75-1.19,6.79-5.11Q655,518.89,676.57,438.23c2.07-7.78,4.06-15.58,6.24-24-6.92,0-13.07.29-19.19-.11-4.21-.27-5.6,1.31-6.59,5.25q-17.61,70.1-35.6,140.11c-.42,1.61-1.07,3.17-1.62,4.75a10,10,0,0,1-3.16-4.88q-17.11-51.6-34.21-103.21c-1.72-5.19-2.29-12.33-6-14.86-3.9-2.7-10.86-.78-16.45-1.28-4.1-.37-5.73,1.25-7,5.08q-18.7,57.12-37.79,114.11c-.59,1.77-1.43,3.45-2.15,5.18a9.31,9.31,0,0,1-2.68-4.69Q500.5,522.88,490.62,486c-6-22.47-12-45-18.13-67.39-.44-1.63-2-4.13-3.12-4.19C462.13,414.08,454.86,414.27,447.16,414.27ZM1473.38,543.71c-1-8.62-1.16-16.45-2.77-24-5.08-23.65-18.41-40.82-42.31-47.12-24.75-6.52-47.33-2-65,18.14-15.82,18.09-19.77,39.44-16.45,62.6,4,27.73,26.6,52.65,58.1,54.81,21.42,1.46,39.91-3.91,54.24-20.46,3.51-4.05,6.13-8.88,9.54-13.92l-20.94-8.68c-13.71,20.22-30.84,26.7-50.55,19.53-17.08-6.21-29-23.88-27.23-40.92Zm-746-51.07-1.12-.55V414.65H703.69V604.22h23v-6.36c0-21.84-.08-43.68,0-65.52.07-11.59,3.84-21.92,11.82-30.46,9.41-10.07,21.15-11.89,34-8.78,11.13,2.72,17.67,10.23,20.26,21.14a55.72,55.72,0,0,1,1.46,12.34c.13,24,.07,48,.07,72v5.6h23.49v-4.87c0-24.84.05-49.68-.06-74.52a101.29,101.29,0,0,0-1.06-13.91c-2.8-19.45-15.29-34.48-32.34-38.55-21.17-5-39.58-.47-54.11,16.51C729.19,490.07,728.29,491.38,727.34,492.64Zm179.93-22.47c-38.65,0-66.92,28.86-67,68.47-.06,40.49,28.07,70,66.72,70,38.38,0,66.64-29.26,66.67-69C973.71,499.1,946.09,470.21,907.27,470.17Zm82.22,69.31c.57,5.12.76,10.32,1.76,15.35,10.69,53.81,69.71,66.73,104.35,41.39,20.15-14.74,27.8-35.52,27.31-60.14-.88-44.18-40.84-78.15-90-62.12C1006.24,482.67,989.72,508.59,989.49,539.48Zm333.81,64.95V414.62h-22.65V604.43Z\" transform=\"translate(-446 -413)\"></path>\n        <path id=\"whoogle-g\" d=\"M1197,667c-4.82-1.08-9.72-1.88-14.44-3.3-17.89-5.39-29.4-17.87-38.17-34.48l19.87-8.29c5.56,6.05,9.84,12.43,15.66,16.71,13.75,10.13,29.07,9.77,44,2.92,14.53-6.66,21.14-19.22,22.32-34.72.47-6.2.08-12.48.08-19.42a14.39,14.39,0,0,0-2.73,2c-19.7,25.91-60.79,27.78-85.69,1.54-26.12-27.55-26.3-81.27,7.68-107.44,24.35-18.76,57.56-15.57,77.53,7.84a6.16,6.16,0,0,0,3.78,2c0-4.23.17-8.46-.05-12.68-.19-3.74,1.36-5.07,5-4.87a74.65,74.65,0,0,0,11,0c4.73-.43,6.37,1.19,6.3,6.11-.27,17.83-.08,35.66-.11,53.5,0,23.67.25,47.35-.32,71-.8,32.89-23.75,57.09-56.45,60.44A26.41,26.41,0,0,0,1208,667Zm50-127.58c-.58-4.61-.86-9.29-1.79-13.83a42.26,42.26,0,0,0-37.31-33.75c-16.16-1.75-33.25,8.46-40.62,24.47-5.34,11.62-5.79,23.83-3.48,36.18,5.94,31.62,42.76,45.77,66.74,25.67C1242.58,568.08,1246.76,554.62,1247,539.42Z\" transform=\"translate(-446 -413)\"></path>\n        <path id=\"whoogle-w\" d=\"M447.16,414.27c7.7,0,15-.19,22.21.19,1.14.06,2.68,2.56,3.12,4.19,6.13,22.44,12.1,44.92,18.13,67.39q9.88,36.84,19.81,73.66a9.31,9.31,0,0,0,2.68,4.69c.72-1.73,1.56-3.41,2.15-5.18q19-57,37.79-114.11c1.25-3.83,2.88-5.45,7-5.08,5.59.5,12.55-1.42,16.45,1.28,3.67,2.53,4.24,9.67,6,14.86q17.14,51.58,34.21,103.21a10,10,0,0,0,3.16,4.88c.55-1.58,1.2-3.14,1.62-4.75q17.87-70,35.6-140.11c1-3.94,2.38-5.52,6.59-5.25,6.12.4,12.27.11,19.19.11-2.18,8.4-4.17,16.2-6.24,24q-21.5,80.68-42.93,161.39c-1,3.92-2.64,5.48-6.79,5.11a80.69,80.69,0,0,0-14,0c-4,.34-5.45-1.39-6.58-4.89q-18.43-57.2-37.12-114.31c-.74-2.28-1.61-4.53-2.42-6.79l-1.35-.06c-.63,1.55-1.37,3.07-1.89,4.67-12.61,38.55-25.29,77.07-37.66,115.7-1.46,4.55-3.42,6.11-8,5.68a82,82,0,0,0-13.48,0c-3.49.26-5-1-5.92-4.29Q473.31,509.34,448,418.3C447.73,417.23,447.55,416.12,447.16,414.27Z\" transform=\"translate(-446 -413)\"></path>\n        <path id=\"whoogle-e\" d=\"M1473.38,543.71H1370c-1.76,17,10.15,34.71,27.23,40.92,19.71,7.17,36.84.69,50.55-19.53l20.94,8.68c-3.41,5-6,9.87-9.54,13.92-14.33,16.55-32.82,21.92-54.24,20.46-31.5-2.16-54.12-27.08-58.1-54.81-3.32-23.16.63-44.51,16.45-62.6,17.64-20.17,40.22-24.66,65-18.14,23.9,6.3,37.23,23.47,42.31,47.12C1472.22,527.26,1472.43,535.09,1473.38,543.71Zm-26.69-19.8c2.09-14-14.21-30.54-31.43-32.19-22.21-2.13-43.06,13.12-43.63,32.19Z\" transform=\"translate(-446 -413)\"></path>\n        <path id=\"whoogle-h\" d=\"M727.34,492.64c.95-1.26,1.85-2.57,2.88-3.77,14.53-17,32.94-21.55,54.11-16.51,17,4.07,29.54,19.1,32.34,38.55a101.29,101.29,0,0,1,1.06,13.91c.11,24.84.06,49.68.06,74.52v4.87H794.3v-5.6c0-24,.06-48-.07-72a55.72,55.72,0,0,0-1.46-12.34c-2.59-10.91-9.13-18.42-20.26-21.14-12.81-3.11-24.55-1.29-34,8.78-8,8.54-11.75,18.87-11.82,30.46-.12,21.84,0,43.68,0,65.52v6.36h-23V414.65h22.53v77.44Z\" transform=\"translate(-446 -413)\"></path>\n        <path id=\"whoogle-o-1\" d=\"M907.27,470.17c38.82,0,66.44,28.93,66.41,69.47,0,39.73-28.29,69-66.67,69-38.65,0-66.78-29.5-66.72-70C840.35,499,868.62,470.13,907.27,470.17Zm43.24,69.26c-.43-3.79-.72-7.61-1.31-11.37-2.94-18.67-19.1-34.56-36.86-36.35-19.93-2-37.94,8.92-45,27.58-3.74,9.85-4.19,20-2.68,30.44,4,27.42,32.55,44.52,57.87,34.41C939.6,577.32,950.2,560.25,950.51,539.43Z\" transform=\"translate(-446 -413)\"></path>\n        <path id=\"whoogle-o-2\" d=\"M989.49,539.48c.23-30.89,16.75-56.81,43.45-65.52,49.13-16,89.09,17.94,90,62.12.49,24.62-7.16,45.4-27.31,60.14-34.64,25.34-93.66,12.42-104.35-41.39C990.25,549.8,990.06,544.6,989.49,539.48Zm110.22-.09c-.48-4.29-.7-8.62-1.5-12.84-3.43-18.06-19.37-33.16-36.57-34.84-20.05-2-37.75,8.9-45,27.62-3.51,9.06-3.74,18.45-3,28,2.23,27.4,30.07,46.21,55.87,37.67C1088,578.9,1099.32,561.53,1099.71,539.39Z\" transform=\"translate(-446 -413)\"></path>\n        <path id=\"whoogle-l\" d=\"M1323.3,604.43h-22.65V414.62h22.65Z\" transform=\"translate(-446 -413)\"></path>\n        <path class=\"cls-1\" d=\"M1247,539.42c-.24,15.2-4.42,28.66-16.46,38.74-24,20.1-60.8,6-66.74-25.67-2.31-12.35-1.86-24.56,3.48-36.18,7.37-16,24.46-26.22,40.62-24.47a42.26,42.26,0,0,1,37.31,33.75C1246.14,530.13,1246.42,534.81,1247,539.42Z\" transform=\"translate(-446 -413)\"></path>\n        <path class=\"cls-1\" d=\"M1446.69,523.91h-75.06c.57-19.07,21.42-34.32,43.63-32.19C1432.48,493.37,1448.78,509.88,1446.69,523.91Z\" transform=\"translate(-446 -413)\"></path>\n        <path class=\"cls-1\" d=\"M950.51,539.43c-.31,20.82-10.91,37.89-28,44.71-25.32,10.11-53.89-7-57.87-34.41-1.51-10.43-1.06-20.59,2.68-30.44,7.08-18.66,25.09-29.59,45-27.58,17.76,1.79,33.92,17.68,36.86,36.35C949.79,531.82,950.08,535.64,950.51,539.43Z\" transform=\"translate(-446 -413)\"></path>\n        <path class=\"cls-1\" d=\"M1099.71,539.39c-.39,22.14-11.74,39.51-30.16,45.6-25.8,8.54-53.64-10.27-55.87-37.67-.78-9.54-.55-18.93,3-28,7.25-18.72,24.95-29.59,45-27.62,17.2,1.68,33.14,16.78,36.57,34.84C1099,530.77,1099.23,535.1,1099.71,539.39Z\" transform=\"translate(-446 -413)\"></path>\n    </svg>\n"
  },
  {
    "path": "app/templates/opensearch.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<OpenSearchDescription xmlns=\"http://a9.com/-/spec/opensearch/1.1/\"\n                       xmlns:moz=\"http://www.mozilla.org/2006/browser/search/\">\n    {% if not search_type %}\n        <ShortName>Whoogle</ShortName>\n    {% else %}\n        <ShortName>Whoogle {{ search_name }}</ShortName>\n    {% endif %}\n    <Description>Whoogle: A self-hosted, ad-free, privacy-respecting metasearch engine</Description>\n    <InputEncoding>UTF-8</InputEncoding>\n    <Image width=\"60\" height=\"60\" type=\"image/png\">\n        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAYAAAA6/NlyAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAASAAAAEgARslrPgAADS9JREFUaN7Nm2lsXNd1x3/nzQxnOMNdJEeUuYjUZu2Lh7IrWZZsy46btN7ioEgRSHZapEybLgHStPDSAqntNg7yIahdyAjgJg4aB66tJDBsy4ocW5QrWzYtUQslUdx3cV9nONt7tx/ekNSQQ84MOTLzB0hg3nbv/55zzzn33HOFm4Sqx58GEcEwHIALpXIQcaNUFmCJPKYQ8aJULyJDgBcRH4ZhHHn1+ZvSL0kZwX/9KTQ3g6bZgFKU2ghsBnYDxUAOkAnYAW2aMAQBLzAK9ALngLPANUQa8Psncbk48soP/jAIV33rBzA8DE5nHkp9CXggQnI14FjkZ8NAT4T4B8BvsNnaMQx15L//bXkIVz3+NFgsQihUDDwEPALcDriWOoizEAIuAb9B5KiIXFFK6Ud+/twXR7jq0JMAuRGSfwXsBGwpJjobBtACvAr8Ap+vhcxMkpV4UoSrDj8FIhpK7UKpfwK+DDhvMtHZ0IEziPwQOAYEk5F2QoSr/u4FGBkBkUyUOgz8A7DmCyY6G4PATxF5EcPowmLhyM+ejfuSJf53wbOhEqAA+Hfge0DhMpMFU7P2AFsQqUWpPs/2fdScP7U0wlWHngSRMuA54DCmW/lDgQBrgW3ANUpLOzwVO6iprV7whfhklXoR+Eq856eglPlPoeZvWASRlIUBAHWIfBvDOLWQes/bYsQSFwAvYEo2bu+UoUAg3elgRX4uK1cVkJuXjcNhR0QIhcJMjHvp7emn9/ogE+NewuEwmqbF+3Si+Bj4C5S6Mh/pmCSqDj8FkIlSLwB/CVgXJKoUIsLKogI2bV3Htp0bcRflk5HhxGqLftUwDPyTAUaGx7h2tYXaz+toberE7w+kSuLHEPlrTBfGbAs+p4Wqw0+B1SqEQn+DKd30eGRdGU7uPOBh710eCtwrkuq4zzvJlUuNHH+nmraWrlSRfgX4W8A3OyafY7Q82/eBUh7gR4A7Htni0iK+9udf4c4DlWRlZyTdYVuajVXFbtbdWk4oFKanqw9dN5ZKfC3Qgq5f8Ow6EGW5owjfEEH9B7BvoS8ahqJibQnf+ObDbNi0Bosl9jwMh3UCgSChYAgFaJoWk0xGposNGyswDIPWli4MXbEEznZgPZr2MXD9RsLTn6x64hkIBsFi+SbwIguoslKK4pIivn74QdasL5tzPxAI0t7aRXNDO91dfYyOjKHrBi5XOgWFeZRVFLN2XRnZuVlzyPt8kxz91TE+PvU5hjG/lU8Q/4WmfRcIThmwGYui62CxlADfikc2MyuDh752/xyyhmHQ0tjB74+fpv5KMxPjXpSa22m7PQ13UQF793vYvWc7TudMc05nOg9+9T7Gx7ycP3t5qar9VQzjdeBk1aEnOfLq86ZKVz3+NBgGiBwCnmBmvRoTB+69nbvu3o1oM53RdZ1TH3zG//7P2zQ1tBEKhqZ97ew/wzAYGR6n/koTA33DlK5ehdM1Q9ruSCNvRTaXLzbgn1yS9c4ADDTtXcCoOX8qQkwp0LQ8zNXPvC5IKcXKogLuuHMX2g1zVinFJx+d462jJxgaHJ13nt4ITRP0sM5nn5zn6K+OMToyHnV/dUUxO27btBTpTuEgSm2e+mGpeuIZU53hYaAKSJvvTRHhnvv3sLNycxShK5caefO1dxkbGUfTkpOGiHC9ux/RhPW3lk8HIZqmkZ7uSIWUs4FOLSPj1G2b7kDDMKbSMg9gqkBMKKVwudJZv7E8qvHJST8fnviYwYHhKBVPBqaGnOXa1Zao6yWrV7FmfVlMO5Ak/tjweotgZq6WApXxOlVYVMDKouiFUktjBw31rYgsPjwUEUZHxqk5cwE9rE9fT0uzUV5RkorQcwewA6XQUIpIwq083lv5Bbk4XdFpqsZrbUz6/EvxmdOkG6+1MTIyFnXdXVSA3Z4GSxOyC6V2EQ6jqXAYzOziggk3ESErOyNqtM3IqDcVKgfA+NgEY6MTUddy87KwO+wLrrwSxG5sNpcmNls6ZpYxLtLTo8dED+tzOrhYiJhWO+APRLfpdGCzJZSniIe1QL6GmWUsTuSNOZIUUr2mZfbcUIYiRQqUCxRrKJUb+RGHLUz6/FGXrFYruXlZKemNUgpbmhWnM1qLvN5JQqFwKprIQKlSDZFCFnBHM3wVw0Oj6PqMFbVaLZSUrUqJlJWCvBU5ZOdED+DgwDD+SX8q2nAgcosW2etJKE812D/MxLgv6tqadWVkZrlSYLgU6zaUk5UVPfa9PQMEg6ElDyhgQ6lcDXOJGNfRaaLR1ztId2dv1PXS1avYvG39ktyGUopCdz6Vf7QtKniZ9PlpbmhLmRcALIl7dDGjqrqL19B1Y2bY0mzcfd8eVpW4MQwj4c/dSNZms7L/3tspK4+2nU2N7TQ3daTUMGqYskl4CGs+uUhrc0fUtbLyW3j0zx6gwL0iKdJKKaxWC/vu3s3eA54oYsFAiE9P1zIx7kslYaUhMoG5ZRkXIsLw0CgnT3xCIBD9yuZt6/nGE4+wZp0Z+6oFFu9KKQzDICPTyZ88cpA/ffTgHB9/6UI9F89dTSVZHRizolQ/5v5sQhCBC+eusvHTi9xx523TblNEuHXzGvLyc/jow884//llhoZGCU4NjALEXAVlZWVQVlHMXffsZuOWtVitc1ek3Z29kUxmqvjiB7qtiAyi1GjihAW/P8BbR9/H7rCzq3JL1P1C9woefux+9t29m7bmTrq7+vBO+DAMA4fDTl5+Dqsriim6pRCHY37nsHe/h9bmTi7W1ie95JwHPkS6rJjS7U3mTRFhcGCEX7/+HjablS3bN0SpnmbRKCjMo6Awb/qaUiQlrdy8bB567D4mxr20NnUueul5A0aADkvl7oMhdH0TcbKUsUh7J3w01reaBN0rSEuzLfB88j3MzslkZVEBTQ1tTEws2XjVIvKKxbNlD5ih5aMk4I9nk56c9FN/uZn21i5cGU5y8rKxWBIL9pVS9PYMMDoyTlZ27GAvLz+HrOwM6i83E4zkyRaJ13E43rF4tu8DM4/1IGY6JCmICEop+q4PcrWukbbmTgKBoLmMFDN3NZXj0nWdgD/IyPAYrc0dnHz/DO/+9gPqLjRQvqaYrOzMmG0UuvPRRGht6iQcDi+GdBB4iXC4zhrRtQaU+hwoWezwaZowPublXE0dF8/Xk+Eypb0iPwdXhhNN0wj4zT2lwYFhxsa85lIwYr3feO1dvn7oQdxF+XO+bbVauOdLe/H7Axx/+9RiApxriJwFsHh27gfDCCNSBNxLgpvk80l7SuL+CLmerl7amrtoae6ks6OHgf4hfD4/RmQ7ZepvoG8I74SX9RsrYtoCi0WjuGQlA31DdHf1JivlX6JpbwCGpaa2Gs+Ou8AsE7oHKFos4VjkRQTRBE2Lzk3Hev56zwCGbrB2/Wos1rnjnmZPo2z1LfT1DtJ3fSBR0oPAsyjVhIgpTc+uA6jR0VGx293AAVJYsJYMDMOgq+M6TqeD0vLimP7X6UqnpGwVLU0djAyPJUL6OCL/iUjoyM+fMwnX1FZTufsgiAwC9wN58b5yMyAihMM67W095OZlsap4ZUx3lpnlIic3m4b61nhr5QngWUyXRM35UzPz1bNzP2K19mMYOcB+lknKIkLAH6S9rZvi0pXkF8Qe+0L3CjIznTQ3thPwB+cj/Q4iP0YkMLUxPk24prYaz9a9AO2YSb1FW+xUkPZ6J+nrHaSsojimjxYRClfm09PdT1fH9ViEe4F/AS7Z3W7OnD4GzA40LBYwjJZI0dfgchEG0801N7bz5mvvMDgwPOd+wB+k+vdnqL/chMxVxhDwEpr2O0T4yY/+fobijU/V1Fbj2bkfoBkzCNnDMqk2mFIc6B/G651kw8YKbJF6EZ/Pz9u/fp/33q7G552MJd33gGdQajxuyUPN+VN4tu8zEKkHtmDmc5cVvT39WKwWVlcUMzo8zltv/o6PPqwhHIoZddUh8o+I1E8ZqgUJA1TuOgC6PoZILbAVKGMZoesG7S1d+LyTfHSyhnM1ddOVQ7PQBnxn5I03Tjq2bk28bAmm6z0A9gIvY27HLDvmIQowgMj3gZ+hlJqvon7e1dGRV58HTYOXn/s/RL6NWfS17JiHbDsi30fkF4iohY4PLBg319RW4+nSQal24DRmBe0altGQxUAd8B3gKEoZ8UqJ4y4Uamqrqdx1AKXrA6Jpp4EsTEO23EWmIcyw8Xtit1ejlEqkbjq5AnFzXrswkwX/DKSkCGMR6AVeAo4A/RCZggkgadW84azDDsw6zMf44uqnJ4D3gZfRtBMoFUr27MPiD3mYxwHsGMYdwCHgIGY4ejPm9xBQDfwSkeNK10cdbjc/+fF3k/7Q0o/xPP40mBtVm1Hqy5jnIHaw9NMtQaABOAH8FpFPEfFiGAmr700hPE185gCIG7OA5DbMQpm1mEnCDMyyCuusdnXMJLkPM5XaDnwGnEXkLCLti1Hdm044irwpdUEpJ5APFKNUKSK3RDbgZ47iwRjQjUgX0AFcR2QcMBI5tJEs/h/GMBxGKn9DKwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNC0xMlQyMDoyMDo0OSswMDowME0is3UAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDQtMTJUMjA6MjA6NDkrMDA6MDA8fwvJAAAARnRFWHRzb2Z0d2FyZQBJbWFnZU1hZ2ljayA2LjcuOC05IDIwMTQtMDUtMTIgUTE2IGh0dHA6Ly93d3cuaW1hZ2VtYWdpY2sub3Jn3IbtAAAAABh0RVh0VGh1bWI6OkRvY3VtZW50OjpQYWdlcwAxp/+7LwAAABh0RVh0VGh1bWI6OkltYWdlOjpoZWlnaHQAMTkyDwByhQAAABd0RVh0VGh1bWI6OkltYWdlOjpXaWR0aAAxOTLTrCEIAAAAGXRFWHRUaHVtYjo6TWltZXR5cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADE1ODY3MjI4NDlV2OpiAAAAD3RFWHRUaHVtYjo6U2l6ZQAwQkKUoj7sAAAAVnRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vbW50bG9nL2Zhdmljb25zLzIwMjAtMDQtMTIvNTdhMDYyNGFhNzAyYzk3ZWU1YTE5MjgwYWEwNTkwZDMuaWNvLnBuZ1EXWHMAAAAASUVORK5CYII=\n    </Image>\n    <Url type=\"text/html\" {{ request_type|safe }} template=\"{{ main_url }}/search\">\n        <Param name=\"q\" value=\"{searchTerms}\"/>\n        {% if search_type %}\n            <Param name=\"tbm\" value=\"{{ search_type }}\"/>\n        {% endif %}\n    </Url>\n    <Url type=\"application/x-suggestions+json\" {{ request_type|safe }} template=\"{{ main_url }}/autocomplete\">\n        <Param name=\"q\" value=\"{searchTerms}\"/>\n    </Url>\n    <moz:SearchForm>{{ main_url }}/search</moz:SearchForm>\n</OpenSearchDescription>\n\n"
  },
  {
    "path": "app/templates/search.html",
    "content": "<form id=\"search-form\" action=\"search\" method=\"post\">\n    <input\n            type=\"text\"\n            name=\"q\"\n            style=\"width: 90%;\"\n            autofocus=\"autofocus\"\n            autocapitalize=\"none\"\n            spellcheck=\"false\"\n            autocorrect=\"off\"\n            placeholder=\"Whoogle Search\"\n            autocomplete=\"off\"\n            dir=\"auto\">\n    <input type=\"submit\" style=\"width: 9%\" id=\"search-submit\" value=\"Search\">\n</form>\n"
  },
  {
    "path": "app/utils/__init__.py",
    "content": ""
  },
  {
    "path": "app/utils/bangs.py",
    "content": "import json\nimport httpx\nimport urllib.parse as urlparse\nimport os\nimport glob\n\nbangs_dict = {}\nDDG_BANGS = 'https://duckduckgo.com/bang.js'\n\n\ndef load_all_bangs(ddg_bangs_file: str, ddg_bangs: dict = {}):\n    \"\"\"Loads all the bang files in alphabetical order\n\n    Args:\n        ddg_bangs_file: The str path to the new DDG bangs json file\n        ddg_bangs: The dict of ddg bangs. If this is empty, it will load the\n                   bangs from the file\n\n    Returns:\n        None\n\n    \"\"\"\n    global bangs_dict\n    ddg_bangs_file = os.path.normpath(ddg_bangs_file)\n\n    if (bangs_dict and not ddg_bangs) or os.path.getsize(ddg_bangs_file) <= 4:\n        return\n\n    bangs = {}\n    bangs_dir = os.path.dirname(ddg_bangs_file)\n    bang_files = glob.glob(os.path.join(bangs_dir, '*.json'))\n\n    # Normalize the paths\n    bang_files = [os.path.normpath(f) for f in bang_files]\n\n    # Move the ddg bangs file to the beginning\n    bang_files = sorted([f for f in bang_files if f != ddg_bangs_file])\n\n    if ddg_bangs:\n        bangs |= ddg_bangs\n    else:\n        bang_files.insert(0, ddg_bangs_file)\n\n    for i, bang_file in enumerate(bang_files):\n        try:\n            with open(bang_file, 'r', encoding='utf-8') as f:\n                bangs |= json.load(f)\n        except json.decoder.JSONDecodeError:\n            # Ignore decoding error only for the ddg bangs file, since this can\n            # occur if file is still being written\n            if i != 0:\n                raise\n\n    bangs_dict = dict(sorted(bangs.items()))\n\n\ndef gen_bangs_json(bangs_file: str) -> None:\n    \"\"\"Generates a json file from the DDG bangs list\n\n    Args:\n        bangs_file: The str path to the new DDG bangs json file\n\n    Returns:\n        None\n\n    \"\"\"\n    # Request full list from DDG\n    r = httpx.get(DDG_BANGS)\n    r.raise_for_status()\n\n    # Convert to json\n    data = json.loads(r.text)\n\n    # Set up a json object (with better formatting) for all available bangs\n    bangs_data = {}\n\n    for row in data:\n        bang_command = '!' + row['t']\n        bangs_data[bang_command] = {\n            'url': row['u'].replace('{{{s}}}', '{}'),\n            'suggestion': bang_command + ' (' + row['s'] + ')'\n        }\n\n    with open(bangs_file, 'w', encoding='utf-8') as f:\n        json.dump(bangs_data, f)\n    print('* Finished creating ddg bangs json')\n    load_all_bangs(bangs_file, bangs_data)\n\n\ndef suggest_bang(query: str) -> list[str]:\n    \"\"\"Suggests bangs for a user's query\n\n    Args:\n        query: The search query\n\n    Returns:\n        list[str]: A list of bang suggestions\n\n    \"\"\"\n    global bangs_dict\n    return [bangs_dict[_]['suggestion'] for _ in bangs_dict if _.startswith(query)]\n\n\ndef resolve_bang(query: str) -> str:\n    \"\"\"Transform's a user's query to a bang search, if an operator is found\n\n    Args:\n        query: The search query\n\n    Returns:\n        str: A formatted redirect for a bang search, or an empty str if there\n             wasn't a match or didn't contain a bang operator\n\n    \"\"\"\n    global bangs_dict\n\n    #if ! not in query simply return (speed up processing)\n    if '!' not in query:\n        return ''\n\n    split_query = query.strip().split(' ')\n\n    # look for operator in query if one is found, list operator should be of\n    # length 1, operator should not be case-sensitive here to remove it later\n    operator = [\n        word\n        for word in split_query\n        if word.lower() in bangs_dict\n    ]\n    if len(operator) == 1:\n        # get operator\n        operator = operator[0]\n\n        # removes operator from query\n        split_query.remove(operator)\n\n        # rebuild the query string\n        bang_query = ' '.join(split_query).strip()\n\n        # Check if operator is a key in bangs and get bang if exists\n        bang = bangs_dict.get(operator.lower(), None)\n        if bang:\n            bang_url = bang['url']\n\n            if bang_query:\n                return bang_url.replace('{}', bang_query, 1)\n            else:\n                parsed_url = urlparse.urlparse(bang_url)\n                return f'{parsed_url.scheme}://{parsed_url.netloc}'\n    return ''\n"
  },
  {
    "path": "app/utils/misc.py",
    "content": "import base64\nimport hashlib\nimport contextlib\nimport io\nimport os\nimport re\n\nimport httpx\nfrom urllib.parse import urlparse\nfrom bs4 import BeautifulSoup as bsoup\nfrom cryptography.fernet import Fernet\nfrom flask import Request\n\nddg_favicon_site = 'http://icons.duckduckgo.com/ip2'\n\nempty_gif = base64.b64decode(\n    'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')\n\nplaceholder_img = base64.b64decode(\n    'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABF0lEQVRIS8XWPw9EMBQA8Eok' \\\n    'JBKrMFqMBt//GzAYLTZ/VomExPDu6uLiaPteqVynBn0/75W2Vp7nEIYhe6p1XcespmmAd7Is' \\\n    'M+4URcGiKPogvMMvmIS2eN9MOMKbKWgf54SYgI4vKkTuQKJKSJErkKzUSkQHUs0lilAg7GMh' \\\n    'ISoIA/hYMiKCKIA2soeowCWEMkfHtUmrXLcyGYYBfN9HF8djiaglWzNZlgVs21YisoAUaEXG' \\\n    'cQTP86QIFgi7vyLzPIPjOEIEC7ANQv/4aZrAdd0TUtc1i+MYnSsMWjPp+x6CIPgJVlUVS5KE' \\\n    'DKig/+wnVzM4pnzaGeHd+ENlWbI0TbVLJBtw2uMfP63wc9d2kDCWxi5Q27bsBerSJ9afJbeL' \\\n    'AAAAAElFTkSuQmCC'\n)\n\n\ndef fetch_favicon(url: str) -> bytes:\n    \"\"\"Fetches a favicon using DuckDuckGo's favicon retriever\n\n    Args:\n        url: The url to fetch the favicon from\n    Returns:\n        bytes - the favicon bytes, or a placeholder image if one\n        was not returned\n    \"\"\"\n    response = httpx.get(f'{ddg_favicon_site}/{urlparse(url).netloc}.ico')\n\n    if response.status_code == 200 and len(response.content) > 0:\n        tmp_mem = io.BytesIO()\n        tmp_mem.write(response.content)\n        tmp_mem.seek(0)\n\n        return tmp_mem.read()\n    return placeholder_img\n\n\ndef gen_file_hash(path: str, static_file: str) -> str:\n    with open(os.path.join(path, static_file), 'rb') as f:\n        file_contents = f.read()\n    file_hash = hashlib.md5(file_contents).hexdigest()[:8]\n    filename_split = os.path.splitext(static_file)\n\n    return f'{filename_split[0]}.{file_hash}{filename_split[-1]}'\n\n\ndef read_config_bool(var: str, default: bool=False) -> bool:\n    val = os.getenv(var, '1' if default else '0')\n    # user can specify one of the following values as 'true' inputs (all\n    # variants with upper case letters will also work):\n    # ('true', 't', '1', 'yes', 'y')\n    return val.lower() in ('true', 't', '1', 'yes', 'y')\n\n\ndef get_client_ip(r: Request) -> str:\n    if r.environ.get('HTTP_X_FORWARDED_FOR') is None:\n        return r.environ['REMOTE_ADDR']\n\n    return r.environ['HTTP_X_FORWARDED_FOR']\n\n\ndef get_request_url(url: str) -> str:\n    if os.getenv('HTTPS_ONLY', False):\n        return url.replace('http://', 'https://', 1)\n\n    return url\n\n\ndef get_proxy_host_url(r: Request, default: str, root=False) -> str:\n    scheme = r.headers.get('X-Forwarded-Proto', 'https')\n    http_host = r.headers.get('X-Forwarded-Host')\n\n    full_path = r.full_path if not root else ''\n    if full_path.startswith('/'):\n        full_path = f'/{full_path}'\n\n    if http_host:\n        prefix = os.environ.get('WHOOGLE_URL_PREFIX', '')\n        if prefix:\n            prefix = f'/{re.sub(\"[^0-9a-zA-Z]+\", \"\", prefix)}'\n        return f'{scheme}://{http_host}{prefix}{full_path}'\n\n    return default\n\n\ndef check_for_update(version_url: str, current: str) -> int:\n    # Check for the latest version of Whoogle\n    has_update = ''\n    with contextlib.suppress(httpx.RequestError, AttributeError):\n        update = bsoup(httpx.get(version_url).text, 'html.parser')\n        latest = update.select_one('[class=\"Link--primary\"]').string[1:]\n        current = int(''.join(filter(str.isdigit, current)))\n        latest = int(''.join(filter(str.isdigit, latest)))\n        has_update = '' if current >= latest else latest\n\n    return has_update\n\n\ndef get_abs_url(url, page_url):\n    # Creates a valid absolute URL using a partial or relative URL\n    urls = {\n        \"//\": f\"https:{url}\",\n        \"/\": f\"{urlparse(page_url).netloc}{url}\",\n        \"./\": f\"{page_url}{url[2:]}\"\n    }\n    for start in urls:\n        if url.startswith(start):\n            return urls[start]\n\n    return url\n\n\ndef list_to_dict(lst: list) -> dict:\n    if len(lst) < 2:\n        return {}\n    return {lst[i].replace(' ', ''): lst[i+1].replace(' ', '')\n            for i in range(0, len(lst), 2)}\n\n\ndef encrypt_string(key: bytes, string: str) -> str:\n    cipher_suite = Fernet(key)\n    return cipher_suite.encrypt(string.encode()).decode()\n\n\ndef decrypt_string(key: bytes, string: str) -> str:\n    cipher_suite = Fernet(g.session_key)\n    return cipher_suite.decrypt(string.encode()).decode()\n"
  },
  {
    "path": "app/utils/results.py",
    "content": "from app.models.config import Config\nfrom app.models.endpoint import Endpoint\nfrom app.utils.misc import list_to_dict\nfrom bs4 import BeautifulSoup, NavigableString, MarkupResemblesLocatorWarning\nimport warnings\nimport copy\nfrom flask import current_app\nimport html\nimport os\nimport urllib.parse as urlparse\nfrom urllib.parse import parse_qs\nimport re\nwarnings.filterwarnings('ignore', category=MarkupResemblesLocatorWarning)\n\nSKIP_ARGS = ['ref_src', 'utm']\nSKIP_PREFIX = ['//www.', '//mobile.', '//m.']\nGOOG_STATIC = 'www.gstatic.com'\nG_M_LOGO_URL = 'https://www.gstatic.com/m/images/icons/googleg.gif'\nGOOG_IMG = '/images/branding/searchlogo/1x/googlelogo'\nLOGO_URL = GOOG_IMG + '_desk'\nBLANK_B64 = ('data:image/png;base64,'\n             'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkw'\n             'AIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC')\n\n# Ad keywords\nBLACKLIST = [\n    'ad', 'ads', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告',\n    'Reklama', 'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan',\n    '広告', 'Augl.', 'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन',\n    'Reklam', 'آگهی', 'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés',\n    'Anúncio', 'Quảng cáo', 'โฆษณา', 'sponsored', 'patrocinado', 'gesponsert',\n    'Sponzorováno', '스폰서', 'Gesponsord', 'Sponsorisé'\n]\n\nSITE_ALTS = {\n    'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'farside.link/nitter'),\n    'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'farside.link/invidious'),\n    'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'farside.link/libreddit'),\n    **dict.fromkeys([\n        'medium.com',\n        'levelup.gitconnected.com'\n    ], os.getenv('WHOOGLE_ALT_MD', 'farside.link/scribe')),\n    'imgur.com': os.getenv('WHOOGLE_ALT_IMG', 'farside.link/rimgo'),\n    'wikipedia.org': os.getenv('WHOOGLE_ALT_WIKI', 'farside.link/wikiless'),\n    'imdb.com': os.getenv('WHOOGLE_ALT_IMDB', 'farside.link/libremdb'),\n    'quora.com': os.getenv('WHOOGLE_ALT_QUORA', 'farside.link/quetre'),\n    'stackoverflow.com': os.getenv('WHOOGLE_ALT_SO', 'farside.link/anonymousoverflow')\n}\n\n# Include custom site redirects from WHOOGLE_REDIRECTS\nSITE_ALTS.update(list_to_dict(re.split(',|:', os.getenv('WHOOGLE_REDIRECTS', ''))))\n\n\ndef contains_cjko(s: str) -> bool:\n    \"\"\"This function check whether or not a string contains Chinese, Japanese,\n    or Korean characters. It employs regex and uses the u escape sequence to\n    match any character in a set of Unicode ranges.\n\n    Args:\n        s (str): string to be checked\n\n    Returns:\n        bool: True if the input s contains the characters and False otherwise\n    \"\"\"\n    unicode_ranges = ('\\u4e00-\\u9fff' # Chinese characters\n                      '\\u3040-\\u309f' # Japanese hiragana\n                      '\\u30a0-\\u30ff' # Japanese katakana\n                      '\\u4e00-\\u9faf' # Japanese kanji\n                      '\\uac00-\\ud7af' # Korean hangul syllables\n                      '\\u1100-\\u11ff' # Korean hangul jamo\n                      )\n    return bool(re.search(fr'[{unicode_ranges}]', s))\n\n\ndef bold_search_terms(response: str, query: str) -> BeautifulSoup:\n    \"\"\"Wraps all search terms in bold tags (<b>). If any terms are wrapped\n    in quotes, only that exact phrase will be made bold.\n\n    Args:\n        response: The initial response body for the query\n        query: The original search query\n\n    Returns:\n        BeautifulSoup: modified soup object with bold items\n    \"\"\"\n    response = BeautifulSoup(response, 'html.parser')\n\n    def replace_any_case(element: NavigableString, target_word: str) -> None:\n        # Replace all instances of the word, but maintaining the same case in\n        # the replacement\n        if len(element) == len(target_word):\n            return\n\n        # Ensure target word is escaped for regex\n        target_word = re.escape(target_word)\n\n        # Check if the word contains Chinese, Japanese, or Korean characters\n        if contains_cjko(target_word):\n            reg_pattern = fr'((?![{{}}<>-]){target_word}(?![{{}}<>-]))'\n        else:\n            reg_pattern = fr'\\b((?![{{}}<>-]){target_word}(?![{{}}<>-]))\\b'\n\n        if re.match(r'.*[@_!#$%^&*()<>?/\\|}{~:].*', target_word) or (\n                element.parent and element.parent.name == 'style'):\n            return\n\n        element.replace_with(BeautifulSoup(\n            re.sub(reg_pattern,\n                   r'<b>\\1</b>',\n                   element,\n                   flags=re.I), 'html.parser')\n        )\n\n    # Split all words out of query, grouping the ones wrapped in quotes\n    for word in re.split(r'\\s+(?=[^\"]*(?:\"[^\"]*\"[^\"]*)*$)', query):\n        word = re.sub(r'[@_!#$%^&*()<>?/\\|}{~:]+', '', word)\n        target = response.find_all(\n            string=re.compile(r'' + re.escape(word), re.I))\n        for nav_str in target:\n            replace_any_case(nav_str, word)\n\n    return response\n\n\ndef has_ad_content(element: str) -> bool:\n    \"\"\"Inspects an HTML element for ad related content\n\n    Args:\n        element: The HTML element to inspect\n\n    Returns:\n        bool: True/False for the element containing an ad\n\n    \"\"\"\n    element_str = ''.join(filter(str.isalpha, element))\n    return (element_str.upper() in (value.upper() for value in BLACKLIST)\n            or 'ⓘ' in element)\n\n\ndef get_first_link(soup) -> str:\n    \"\"\"Retrieves the first result link from the query response\n\n    Args:\n        soup: The BeautifulSoup response body\n\n    Returns:\n        str: A str link to the first result\n\n    \"\"\"\n    first_link = ''\n\n    # Find the first valid search result link, excluding details elements\n    for a in soup.find_all('a', href=True):\n        # Skip links that are inside details elements (collapsible sections)\n        if a.find_parent('details'):\n            continue\n            \n        # Return the first search result URL\n        if a['href'].startswith('http://') or a['href'].startswith('https://'):\n            first_link = a['href']\n            break\n\n    return first_link\n\n\ndef get_site_alt(link: str, site_alts: dict = SITE_ALTS) -> str:\n    \"\"\"Returns an alternative to a particular site, if one is configured\n\n    Args:\n        link: A string result URL to check against the site_alts map\n        site_alts: A map of site alternatives to replace with. defaults to SITE_ALTS\n\n    Returns:\n        str: An updated (or ignored) result link\n\n    \"\"\"\n    # Need to replace full hostname with alternative to encapsulate\n    # subdomains as well\n    parsed_link = urlparse.urlparse(link)\n\n    # Extract subdomain separately from the domain+tld. The subdomain\n    # is used for wikiless translations.\n    split_host = parsed_link.netloc.split('.')\n    subdomain = split_host[0] if len(split_host) > 2 else ''\n    hostname = '.'.join(split_host[-2:])\n\n    # The full scheme + hostname is used when comparing against the list of\n    # available alternative services, due to how Medium links are constructed.\n    # (i.e. for medium.com: \"https://something.medium.com\" should match,\n    # \"https://medium.com/...\" should match, but \"philomedium.com\" should not)\n    hostcomp = f'{parsed_link.scheme}://{hostname}'\n\n    for site_key in site_alts.keys():\n        site_alt = f'{parsed_link.scheme}://{site_key}'\n        if not hostname or site_alt not in hostcomp or not site_alts[site_key]:\n            continue\n\n        # Wikipedia -> Wikiless replacements require the subdomain (if it's\n        # a 2-char language code) to be passed as a URL param to Wikiless\n        # in order to preserve the language setting.\n        params = ''\n        if 'wikipedia' in hostname and len(subdomain) == 2:\n            hostname = f'{subdomain}.{hostname}'\n            params = f'?lang={subdomain}'\n        elif 'medium' in hostname and len(subdomain) > 0:\n            hostname = f'{subdomain}.{hostname}'\n\n        parsed_alt = urlparse.urlparse(site_alts[site_key])\n        link = link.replace(hostname, site_alts[site_key]) + params\n        # If a scheme is specified in the alternative, this results in a\n        # replaced link that looks like \"https://http://altservice.tld\".\n        # In this case, we can remove the original scheme from the result\n        # and use the one specified for the alt.\n        if parsed_alt.scheme:\n            link = '//'.join(link.split('//')[1:])\n\n        for prefix in SKIP_PREFIX:\n            if parsed_alt.scheme:\n                # If a scheme is specified, remove everything before the\n                # first occurence of it\n                link = f'{parsed_alt.scheme}{link.split(parsed_alt.scheme, 1)[-1]}'\n            else:\n                # Otherwise, replace the first occurrence of the prefix\n                link = link.replace(prefix, '//', 1)\n        break\n\n    return link\n\n\ndef filter_link_args(link: str) -> str:\n    \"\"\"Filters out unnecessary URL args from a result link\n\n    Args:\n        link: The string result link to check for extraneous URL params\n\n    Returns:\n        str: An updated (or ignored) result link\n\n    \"\"\"\n    parsed_link = urlparse.urlparse(link)\n    link_args = parse_qs(parsed_link.query)\n    safe_args = {}\n\n    if len(link_args) == 0 and len(parsed_link) > 0:\n        return link\n\n    for arg in link_args.keys():\n        if arg in SKIP_ARGS:\n            continue\n\n        safe_args[arg] = link_args[arg]\n\n    # Remove original link query and replace with filtered args\n    link = link.replace(parsed_link.query, '')\n    if len(safe_args) > 0:\n        link = link + urlparse.urlencode(safe_args, doseq=True)\n    else:\n        link = link.replace('?', '')\n\n    return link\n\n\ndef append_nojs(result: BeautifulSoup) -> None:\n    \"\"\"Appends a no-Javascript alternative for a search result\n\n    Args:\n        result: The search result to append a no-JS link to\n\n    Returns:\n        None\n\n    \"\"\"\n    nojs_link = BeautifulSoup(features='html.parser').new_tag('a')\n    nojs_link['href'] = f'{Endpoint.window}?nojs=1&location=' + result['href']\n    nojs_link.string = ' NoJS Link'\n    result.append(nojs_link)\n\n\ndef append_anon_view(result: BeautifulSoup, config: Config) -> None:\n    \"\"\"Appends an 'anonymous view' for a search result, where all site\n    contents are viewed through Whoogle as a proxy.\n\n    Args:\n        result: The search result to append an anon view link to\n        nojs: Remove Javascript from Anonymous View\n\n    Returns:\n        None\n\n    \"\"\"\n    av_link = BeautifulSoup(features='html.parser').new_tag('a')\n    nojs = 'nojs=1' if config.nojs else 'nojs=0'\n    location = f'location={result[\"href\"]}'\n    av_link['href'] = f'{Endpoint.window}?{nojs}&{location}'\n    translation = current_app.config['TRANSLATIONS'][\n       config.get_localization_lang()\n    ]\n    av_link.string = f'{translation[\"anon-view\"]}'\n    av_link['class'] = 'anon-view'\n    result.append(av_link)\n\ndef check_currency(response: str) -> dict:\n    \"\"\"Check whether the results have currency conversion\n\n    Args:\n        response: Search query Result\n\n    Returns:\n        dict: Consists of currency names and values\n\n    \"\"\"\n    soup = BeautifulSoup(response, 'html.parser')\n    currency_link = soup.find('a', {'href': 'https://g.co/gfd'})\n    if currency_link:\n        while 'class' not in currency_link.attrs or \\\n                'ZINbbc' not in currency_link.attrs['class']:\n            if currency_link.parent:\n                currency_link = currency_link.parent\n            else:\n                return {}\n        currency_link = currency_link.find_all(class_='BNeawe')\n        currency1 = currency_link[0].text\n        currency2 = currency_link[1].text\n        currency1 = currency1.rstrip('=').split(' ', 1)\n        currency2 = currency2.split(' ', 1)\n\n        # Handle differences in currency formatting\n        # i.e. \"5.000\" vs \"5,000\"\n        if currency2[0][-3] == ',':\n            currency1[0] = currency1[0].replace('.', '')\n            currency1[0] = currency1[0].replace(',', '.')\n            currency2[0] = currency2[0].replace('.', '')\n            currency2[0] = currency2[0].replace(',', '.')\n        else:\n            currency1[0] = currency1[0].replace(',', '')\n            currency2[0] = currency2[0].replace(',', '')\n\n        currency1_value = float(re.sub(r'[^\\d\\.]', '', currency1[0]))\n        currency1_label = currency1[1]\n\n        currency2_value = float(re.sub(r'[^\\d\\.]', '', currency2[0]))\n        currency2_label = currency2[1]\n\n        return {'currencyValue1': currency1_value,\n                'currencyLabel1': currency1_label,\n                'currencyValue2': currency2_value,\n                'currencyLabel2': currency2_label\n                }\n    return {}\n\n\ndef add_currency_card(soup: BeautifulSoup,\n                      conversion_details: dict) -> BeautifulSoup:\n    \"\"\"Adds the currency conversion boxes\n    to response of the search query\n\n    Args:\n        soup: Parsed search result\n        conversion_details: Dictionary of currency\n        related information\n\n    Returns:\n        BeautifulSoup\n    \"\"\"\n    # Element before which the code will be changed\n    # (This is the 'disclaimer' link)\n    element1 = soup.find('a', {'href': 'https://g.co/gfd'})\n\n    while 'class' not in element1.attrs or \\\n            'nXE3Ob' not in element1.attrs['class']:\n        element1 = element1.parent\n\n    # Creating the conversion factor\n    conversion_factor = (conversion_details['currencyValue1'] /\n                         conversion_details['currencyValue2'])\n\n    # Creating a new div for the input boxes\n    conversion_box = soup.new_tag('div')\n    conversion_box['class'] = 'conversion_box'\n\n    # Currency to be converted from\n    input_box1 = soup.new_tag('input')\n    input_box1['id'] = 'cb1'\n    input_box1['type'] = 'number'\n    input_box1['class'] = 'cb'\n    input_box1['value'] = conversion_details['currencyValue1']\n    input_box1['oninput'] = f'convert(1, 2, {1 / conversion_factor})'\n\n    label_box1 = soup.new_tag('label')\n    label_box1['for'] = 'cb1'\n    label_box1['class'] = 'cb_label'\n    label_box1.append(conversion_details['currencyLabel1'])\n\n    br = soup.new_tag('br')\n\n    # Currency to be converted to\n    input_box2 = soup.new_tag('input')\n    input_box2['id'] = 'cb2'\n    input_box2['type'] = 'number'\n    input_box2['class'] = 'cb'\n    input_box2['value'] = conversion_details['currencyValue2']\n    input_box2['oninput'] = f'convert(2, 1, {conversion_factor})'\n\n    label_box2 = soup.new_tag('label')\n    label_box2['for'] = 'cb2'\n    label_box2['class'] = 'cb_label'\n    label_box2.append(conversion_details['currencyLabel2'])\n\n    conversion_box.append(input_box1)\n    conversion_box.append(label_box1)\n    conversion_box.append(br)\n    conversion_box.append(input_box2)\n    conversion_box.append(label_box2)\n\n    element1.insert_before(conversion_box)\n    return soup\n\n\ndef get_tabs_content(tabs: dict,\n                     full_query: str,\n                     search_type: str,\n                     preferences: str,\n                     translation: dict) -> dict:\n    \"\"\"Takes the default tabs content and updates it according to the query.\n\n    Args:\n        tabs: The default content for the tabs\n        full_query: The original search query\n        search_type: The current search_type\n        translation: The translation to get the names of the tabs\n\n    Returns:\n        dict: contains the name, the href and if the tab is selected or not\n    \"\"\"\n    map_query = full_query\n    if '-site:' in full_query:\n        block_idx = full_query.index('-site:')\n        map_query = map_query[:block_idx]\n    tabs = copy.deepcopy(tabs)\n    for tab_id, tab_content in tabs.items():\n        # update name to desired language\n        if tab_id in translation:\n            tab_content['name'] = translation[tab_id]\n\n        # update href with query\n        query = full_query.replace(f'&tbm={search_type}', '')\n\n        if tab_content['tbm'] is not None:\n            query = f\"{query}&tbm={tab_content['tbm']}\"\n\n        if preferences:\n            query = f\"{query}&preferences={preferences}\"\n\n        tab_content['href'] = tab_content['href'].format(\n            query=query,\n            map_query=map_query)\n\n        # update if selected tab (default all tab is selected)\n        if tab_content['tbm'] == search_type:\n            tabs['all']['selected'] = False\n            tab_content['selected'] = True\n    return tabs\n"
  },
  {
    "path": "app/utils/search.py",
    "content": "import os\nimport re\nfrom typing import Any\nfrom app.filter import Filter\nfrom app.request import gen_query\nfrom app.utils.misc import get_proxy_host_url\nfrom app.utils.results import get_first_link\nfrom app.services.cse_client import CSEClient, cse_results_to_html\nfrom bs4 import BeautifulSoup as bsoup\nfrom cryptography.fernet import Fernet, InvalidToken\nfrom flask import g\n\nTOR_BANNER = '<hr><h1 style=\"text-align: center\">You are using Tor</h1><hr>'\nCAPTCHA = 'div class=\"g-recaptcha\"'\n\n\ndef needs_https(url: str) -> bool:\n    \"\"\"Checks if the current instance needs to be upgraded to HTTPS\n\n    Note that all Heroku instances are available by default over HTTPS, but\n    do not automatically set up a redirect when visited over HTTP.\n\n    Args:\n        url: The instance url\n\n    Returns:\n        bool: True/False representing the need to upgrade\n\n    \"\"\"\n    https_only = bool(os.getenv('HTTPS_ONLY', 0))\n    is_heroku = url.endswith('.herokuapp.com')\n    is_http = url.startswith('http://')\n\n    return (is_heroku and is_http) or (https_only and is_http)\n\n\ndef has_captcha(results: str) -> bool:\n    \"\"\"Checks to see if the search results are blocked by a captcha\n\n    Args:\n        results: The search page html as a string\n\n    Returns:\n        bool: True/False indicating if a captcha element was found\n\n    \"\"\"\n    return CAPTCHA in results\n\n\nclass Search:\n    \"\"\"Search query preprocessor - used before submitting the query or\n    redirecting to another site\n\n    Attributes:\n        request: the incoming flask request\n        config: the current user config settings\n        session_key: the flask user fernet key\n    \"\"\"\n    def __init__(self, request, config, session_key, cookies_disabled=False, user_request=None):\n        method = request.method\n        self.request = request\n        self.request_params = request.args if method == 'GET' else request.form\n        self.user_agent = request.headers.get('User-Agent')\n        self.feeling_lucky = False\n        self.config = config\n        self.session_key = session_key\n        self.query = ''\n        self.widget = ''\n        self.cookies_disabled = cookies_disabled\n        self.user_request = user_request\n        self.search_type = self.request_params.get(\n            'tbm') if 'tbm' in self.request_params else ''\n\n    def __getitem__(self, name) -> Any:\n        return getattr(self, name)\n\n    def __setitem__(self, name, value) -> None:\n        return setattr(self, name, value)\n\n    def __delitem__(self, name) -> None:\n        return delattr(self, name)\n\n    def __contains__(self, name) -> bool:\n        return hasattr(self, name)\n\n    def new_search_query(self) -> str:\n        \"\"\"Parses a plaintext query into a valid string for submission\n\n        Also decrypts the query string, if encrypted (in the case of\n        paginated results).\n\n        Returns:\n            str: A valid query string\n\n        \"\"\"\n        q = self.request_params.get('q')\n\n        if q is None or len(q) == 0:\n            return ''\n        else:\n            # Attempt to decrypt if this is an internal link\n            try:\n                q = Fernet(self.session_key).decrypt(q.encode()).decode()\n            except InvalidToken:\n                pass\n\n        # Strip '!' for \"feeling lucky\" queries\n        if match := re.search(r\"(^|\\s)!($|\\s)\", q):\n            self.feeling_lucky = True\n            start, end = match.span()\n            self.query = \" \".join([seg for seg in [q[:start], q[end:]] if seg])\n        else:\n            self.feeling_lucky = False\n            self.query = q\n\n        # Check for possible widgets\n        self.widget = \"ip\" if re.search(\"([^a-z0-9]|^)my *[^a-z0-9] *(ip|internet protocol)\" +\n                        \"($|( *[^a-z0-9] *(((addres|address|adres|\" +\n                        \"adress)|a)? *$)))\", self.query.lower()) else self.widget\n        self.widget = 'calculator' if re.search(\n                r\"\\bcalculator\\b|\\bcalc\\b|\\bcalclator\\b|\\bmath\\b\",\n                self.query.lower()) else self.widget\n        return self.query\n\n    def generate_response(self) -> str:\n        \"\"\"Generates a response for the user's query\n\n        Returns:\n            str: A string response to the search query, in the form of a URL\n                 or string representation of HTML content.\n\n        \"\"\"\n        mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent\n        # reconstruct url if X-Forwarded-Host header present\n        root_url = get_proxy_host_url(\n            self.request,\n            self.request.url_root,\n            root=True)\n\n        content_filter = Filter(self.session_key,\n                                root_url=root_url,\n                                mobile=mobile,\n                                config=self.config,\n                                query=self.query,\n                                page_url=self.request.url)\n        \n        # Check if CSE (Custom Search Engine) should be used\n        use_cse = (\n            self.config.use_cse and \n            self.config.cse_api_key and \n            self.config.cse_id\n        )\n        \n        if use_cse:\n            # Use Google Custom Search API\n            return self._generate_cse_response(content_filter, root_url, mobile)\n        \n        # Default: Use traditional scraping method\n        return self._generate_scrape_response(content_filter, root_url, mobile)\n    \n    def _generate_cse_response(self, content_filter: Filter, root_url: str, mobile: bool) -> str:\n        \"\"\"Generate response using Google Custom Search API\n        \n        Args:\n            content_filter: Filter instance for processing results\n            root_url: Root URL of the instance\n            mobile: Whether this is a mobile request\n            \n        Returns:\n            str: HTML response string\n        \"\"\"\n        # Get pagination start index from request params\n        start = int(self.request_params.get('start', 1))\n        \n        # Determine safe search setting\n        safe = 'high' if self.config.safe else 'off'\n        \n        # Determine search type (web or image)\n        # tbm=isch or udm=2 indicates image search\n        search_type = ''\n        if self.search_type == 'isch' or self.request_params.get('udm') == '2':\n            search_type = 'image'\n        \n        # Create CSE client and perform search\n        with CSEClient(\n            api_key=self.config.cse_api_key,\n            cse_id=self.config.cse_id\n        ) as client:\n            response = client.search(\n                query=self.query,\n                start=start,\n                safe=safe,\n                language=self.config.lang_search,\n                country=self.config.country,\n                search_type=search_type\n            )\n        \n        # Convert CSE response to HTML\n        html_content = cse_results_to_html(response, self.query)\n        \n        # Store full query for tabs\n        self.full_query = self.query\n        \n        # Parse and filter the HTML\n        html_soup = bsoup(html_content, 'html.parser')\n        \n        # Handle feeling lucky\n        if self.feeling_lucky:\n            if response.has_results and response.results:\n                return response.results[0].link\n            self.feeling_lucky = False\n        \n        # Apply content filter (encrypts links, applies CSS, etc.)\n        formatted_results = content_filter.clean(html_soup)\n        \n        return str(formatted_results)\n    \n    def _generate_scrape_response(self, content_filter: Filter, root_url: str, mobile: bool) -> str:\n        \"\"\"Generate response using traditional HTML scraping\n        \n        Args:\n            content_filter: Filter instance for processing results\n            root_url: Root URL of the instance\n            mobile: Whether this is a mobile request\n            \n        Returns:\n            str: HTML response string\n        \"\"\"\n        full_query = gen_query(self.query,\n                               self.request_params,\n                               self.config)\n        self.full_query = full_query\n\n        # force mobile search when view image is true and\n        # the request is not already made by a mobile\n        is_image_query = ('tbm=isch' in full_query) or ('udm=2' in full_query)\n        # Always parse image results when hitting the images endpoint (udm=2)\n        # to avoid Google returning only text/AI blocks.\n        view_image = is_image_query\n\n        client = self.user_request or g.user_request\n        get_body = client.send(query=full_query,\n                               force_mobile=self.config.view_image,\n                               user_agent=self.user_agent)\n\n        # Produce cleanable html soup from response\n        get_body_safed = get_body.text.replace(\"&lt;\",\"andlt;\").replace(\"&gt;\",\"andgt;\")\n        html_soup = bsoup(get_body_safed, 'html.parser')\n        \n        # Ensure we extract only the content within <html> if it exists\n        # This prevents doctype declarations from appearing in the output\n        if html_soup.html:\n            html_soup = html_soup.html\n\n        # Replace current soup if view_image is active\n        if view_image:\n            html_soup = content_filter.view_image(html_soup)\n\n        # Indicate whether or not a Tor connection is active\n        if (self.user_request or g.user_request).tor_valid:\n            html_soup.insert(0, bsoup(TOR_BANNER, 'html.parser'))\n\n        formatted_results = content_filter.clean(html_soup)\n        if self.feeling_lucky:\n            if lucky_link := get_first_link(formatted_results):\n                return lucky_link\n\n            # Fall through to regular search if unable to find link\n            self.feeling_lucky = False\n\n        # Append user config to all search links, if available\n        param_str = ''.join('&{}={}'.format(k, v)\n                            for k, v in\n                            self.request_params.to_dict(flat=True).items()\n                            if self.config.is_safe_key(k))\n        for link in formatted_results.find_all('a', href=True):\n            link['rel'] = \"nofollow noopener noreferrer\"\n            if 'search?' not in link['href'] or link['href'].index(\n                    'search?') > 1:\n                continue\n            link['href'] += param_str\n\n        return str(formatted_results)\n"
  },
  {
    "path": "app/utils/session.py",
    "content": "from cryptography.fernet import Fernet\nfrom flask import current_app as app\n\nREQUIRED_SESSION_VALUES = ['uuid', 'config', 'key', 'auth']\n\n\ndef generate_key() -> bytes:\n    \"\"\"Generates a key for encrypting searches and element URLs\n\n    Args:\n        cookies_disabled: Flag for whether or not cookies are disabled by the\n                          user. If so, the user can only use the default key\n                          generated on app init for queries.\n\n    Returns:\n        str: A unique Fernet key\n\n    \"\"\"\n    # Generate/regenerate unique key per user\n    return Fernet.generate_key()\n\n\ndef valid_user_session(session: dict) -> bool:\n    \"\"\"Validates the current user session\n\n    Args:\n        session: The current Flask user session\n\n    Returns:\n        bool: True/False indicating that all required session values are\n              available\n\n    \"\"\"\n    # Generate secret key for user if unavailable\n    for value in REQUIRED_SESSION_VALUES:\n        if value not in session:\n            return False\n\n    return True\n"
  },
  {
    "path": "app/utils/ua_generator.py",
    "content": "\"\"\"\nUser Agent Generator for Opera-based UA strings.\n\nThis module generates realistic Opera User Agent strings based on patterns\nfound in working UA strings that successfully bypass Google's restrictions.\n\"\"\"\n\nimport json\nimport os\nimport random\nfrom datetime import datetime, timedelta\nfrom typing import List, Dict\n\n\n# Default fallback UA if generation fails\nDEFAULT_FALLBACK_UA = \"Opera/9.80 (iPad; Opera Mini/5.0.17381/503; U; eu) Presto/2.6.35 Version/11.10)\"\n\n# Opera UA Pattern Templates\nOPERA_PATTERNS = [\n    # Opera Mini (J2ME/MIDP)\n    \"Opera/9.80 (J2ME/MIDP; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}\",\n    \n    # Opera Mobile (Android)\n    \"Opera/9.80 (Android; Linux; Opera Mobi/{build}; U; {lang}) Presto/{presto} Version/{final}\",\n    \n    # Opera Mobile (iPhone)\n    \"Opera/9.80 (iPhone; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}\",\n    \n    # Opera Mobile (iPad)\n    \"Opera/9.80 (iPad; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}\",\n]\n\n# Randomization pools based on working UAs\nOPERA_MINI_VERSIONS = [\n    \"4.0\", \"4.1.11321\", \"4.1.12965\", \"4.1.13573\", \"4.1.13907\", \"4.1.14287\", \n    \"4.1.15082\", \"4.2.13057\", \"4.2.13221\", \"4.2.13265\", \"4.2.13337\", \n    \"4.2.13400\", \"4.2.13918\", \"4.2.13943\", \"4.2.14320\", \"4.2.14409\", \n    \"4.2.14753\", \"4.2.14881\", \"4.2.14885\", \"4.2.14912\", \"4.2.15066\",\n    \"4.2.15410\", \"4.2.16007\", \"4.2.16320\", \"4.2.18887\", \"4.2.19634\",\n    \"4.2.21465\", \"4.2.22228\", \"4.2.23453\", \"4.2.24721\", \"4.3.13337\",\n    \"4.3.24214\", \"4.4.26736\", \"4.4.29476\", \"4.5.33867\", \"4.5.40312\",\n    \"5.0.15650\", \"5.0.16823\", \"5.0.17381\", \"5.0.17443\", \"5.0.18635\",\n    \"5.0.18741\", \"5.0.19683\", \"5.0.19693\", \"5.0.20873\", \"5.0.22349\",\n    \"5.1.21051\", \"5.1.21126\", \"5.1.21214\", \"5.1.21415\", \"5.1.21594\",\n    \"5.1.21595\", \"5.1.22296\", \"5.1.22303\", \"5.1.22396\", \"5.1.22460\",\n    \"5.1.22783\", \"5.1.22784\", \"6.0.24095\", \"6.0.24212\", \"6.0.24455\",\n    \"6.1.25375\", \"6.1.25378\", \"6.1.25759\", \"6.24093\", \"6.24096\",\n    \"6.24209\", \"6.24288\", \"6.5.26955\", \"6.5.29702\", \"7.0.29952\",\n    \"7.1.32052\", \"7.1.32444\", \"7.1.32694\", \"7.29530\", \"7.5.33361\",\n    \"7.6.35766\", \"9.80\", \"36.2.2254\"\n]\n\nOPERA_MOBI_BUILDS = [\n    \"27\", \"49\", \"447\", \"498\", \"1181\", \"1209\", \"3730\",\n    \"ADR-1011151731\", \"ADR-1012211514\", \"ADR-1012221546\", \"ADR-1012272315\",\n    \"SYB-1103211396\", \"SYB-1104061449\", \"SYB-1107071606\",\n    \"ADR-1111101157\"\n]\n\nBUILD_NUMBERS = [\n    \"18.678\", \"18.684\", \"18.738\", \"18.794\", \"19.892\", \"19.916\",\n    \"20.2477\", \"20.2479\", \"20.2485\", \"20.2489\", \"21.529\", \"22.387\",\n    \"22.394\", \"22.401\", \"22.414\", \"22.453\", \"22.478\", \"23.317\",\n    \"23.333\", \"23.334\", \"23.377\", \"23.390\", \"24.741\", \"24.743\",\n    \"24.746\", \"24.783\", \"24.838\", \"24.871\", \"24.899\", \"25.657\",\n    \"25.677\", \"25.729\", \"25.872\", \"26.1305\", \"27.1366\", \"27.1407\",\n    \"27.1573\", \"28.2075\", \"28.2555\", \"28.2647\", \"28.2766\", \"29.3594\",\n    \"30.3316\", \"31.1350\", \"35.2883\", \"35.5706\", \"37.6584\", \"119.132\",\n    \"170.51\", \"170.54\", \"764\", \"870\", \"886\", \"490\", \"503\"\n]\n\nPRESTO_VERSIONS = [\n    \"2.2.0\", \"2.4.15\", \"2.4.154.15\", \"2.4.18\", \"2.5.25\", \"2.5.28\",\n    \"2.6.35\", \"2.7.60\", \"2.7.81\", \"2.8.119\", \"2.8.149\", \"2.8.191\",\n    \"2.9.201\", \"2.12.423\"\n]\n\nFINAL_VERSIONS = [\n    \"10.00\", \"10.1\", \"10.5\", \"10.54\", \"10.5454\", \"11.00\", \"11.10\",\n    \"12.02\", \"12.16\", \"13.00\"\n]\n\nLANGUAGES = [\n    # English variants\n    \"en\", \"en-US\", \"en-GB\", \"en-CA\", \"en-AU\", \"en-NZ\", \"en-ZA\", \"en-IN\", \"en-SG\",\n    # Western European\n    \"de\", \"de-DE\", \"de-AT\", \"de-CH\",\n    \"fr\", \"fr-FR\", \"fr-CA\", \"fr-BE\", \"fr-CH\", \"fr-LU\",\n    \"es\", \"es-ES\", \"es-MX\", \"es-AR\", \"es-CO\", \"es-CL\", \"es-PE\", \"es-VE\", \"es-LA\",\n    \"it\", \"it-IT\", \"it-CH\",\n    \"pt\", \"pt-PT\", \"pt-BR\",\n    \"nl\", \"nl-NL\", \"nl-BE\",\n    # Nordic languages\n    \"da\", \"da-DK\",\n    \"sv\", \"sv-SE\",\n    \"no\", \"no-NO\", \"nb\", \"nn\",\n    \"fi\", \"fi-FI\",\n    \"is\", \"is-IS\",\n    # Eastern European\n    \"pl\", \"pl-PL\",\n    \"cs\", \"cs-CZ\",\n    \"sk\", \"sk-SK\",\n    \"hu\", \"hu-HU\",\n    \"ro\", \"ro-RO\",\n    \"bg\", \"bg-BG\",\n    \"hr\", \"hr-HR\",\n    \"sr\", \"sr-RS\",\n    \"sl\", \"sl-SI\",\n    \"uk\", \"uk-UA\",\n    \"ru\", \"ru-RU\",\n    # Asian languages\n    \"zh\", \"zh-CN\", \"zh-TW\", \"zh-HK\",\n    \"ja\", \"ja-JP\",\n    \"ko\", \"ko-KR\",\n    \"th\", \"th-TH\",\n    \"vi\", \"vi-VN\",\n    \"id\", \"id-ID\",\n    \"ms\", \"ms-MY\",\n    \"fil\", \"tl\",\n    # Middle Eastern\n    \"tr\", \"tr-TR\",\n    \"ar\", \"ar-SA\", \"ar-AE\", \"ar-EG\",\n    \"he\", \"he-IL\",\n    \"fa\", \"fa-IR\",\n    # Other\n    \"hi\", \"hi-IN\",\n    \"bn\", \"bn-IN\",\n    \"ta\", \"ta-IN\",\n    \"te\", \"te-IN\",\n    \"mr\", \"mr-IN\",\n    \"el\", \"el-GR\",\n    \"ca\", \"ca-ES\",\n    \"eu\", \"eu-ES\"\n]\n\n\n\ndef generate_opera_ua() -> str:\n    \"\"\"\n    Generate a single random Opera User Agent string.\n    \n    Returns:\n        str: A randomly generated Opera UA string\n    \"\"\"\n    pattern = random.choice(OPERA_PATTERNS)\n    \n    # Determine which parameters to use based on the pattern\n    params = {\n        'lang': random.choice(LANGUAGES)\n    }\n    \n    if '{version}' in pattern:\n        params['version'] = random.choice(OPERA_MINI_VERSIONS)\n    \n    if '{build}' in pattern:\n        # Use MOBI build for \"Opera Mobi\", regular build for \"Opera Mini\"\n        if \"Opera Mobi\" in pattern:\n            params['build'] = random.choice(OPERA_MOBI_BUILDS)\n        else:\n            params['build'] = random.choice(BUILD_NUMBERS)\n    \n    if '{presto}' in pattern:\n        params['presto'] = random.choice(PRESTO_VERSIONS)\n    \n    if '{final}' in pattern:\n        params['final'] = random.choice(FINAL_VERSIONS)\n    \n    return pattern.format(**params)\n\n\ndef generate_ua_pool(count: int = 10) -> List[str]:\n    \"\"\"\n    Generate a pool of unique Opera User Agent strings.\n    \n    Args:\n        count: Number of UA strings to generate (default: 10)\n    \n    Returns:\n        List[str]: List of unique UA strings\n    \"\"\"\n    ua_pool = set()\n    \n    # Keep generating until we have enough unique UAs\n    # Add safety limit to prevent infinite loop\n    max_attempts = count * 100\n    attempts = 0\n    \n    try:\n        while len(ua_pool) < count and attempts < max_attempts:\n            ua = generate_opera_ua()\n            ua_pool.add(ua)\n            attempts += 1\n    except Exception:\n        # If generation fails entirely, return at least the default fallback\n        if not ua_pool:\n            return [DEFAULT_FALLBACK_UA]\n    \n    # If we couldn't generate enough, fill remaining with default\n    result = list(ua_pool)\n    while len(result) < count:\n        result.append(DEFAULT_FALLBACK_UA)\n    \n    return result\n\n\ndef save_ua_pool(uas: List[str], cache_path: str) -> None:\n    \"\"\"\n    Save UA pool to cache file.\n    \n    Args:\n        uas: List of UA strings to save\n        cache_path: Path to cache file\n    \"\"\"\n    cache_data = {\n        'generated_at': datetime.now().isoformat(),\n        'user_agents': uas\n    }\n    \n    # Ensure directory exists\n    cache_dir = os.path.dirname(cache_path)\n    if cache_dir and not os.path.exists(cache_dir):\n        os.makedirs(cache_dir, exist_ok=True)\n    \n    with open(cache_path, 'w', encoding='utf-8') as f:\n        json.dump(cache_data, f, indent=2)\n\n\ndef load_custom_ua_list(file_path: str) -> List[str]:\n    \"\"\"\n    Load custom UA list from a text file.\n    \n    Args:\n        file_path: Path to text file containing UA strings (one per line)\n    \n    Returns:\n        List[str]: List of UA strings, or empty list if file is invalid\n    \"\"\"\n    try:\n        with open(file_path, 'r', encoding='utf-8') as f:\n            uas = [line.strip() for line in f if line.strip()]\n        \n        # Validate that we have at least one UA\n        if not uas:\n            return []\n        \n        return uas\n    except (FileNotFoundError, PermissionError, UnicodeDecodeError):\n        return []\n\n\ndef load_ua_pool(cache_path: str, count: int = 10) -> List[str]:\n    \"\"\"\n    Load UA pool from custom list file, cache, or generate new one.\n    \n    Priority order:\n    1. Custom UA list file (if WHOOGLE_UA_LIST_FILE is set)\n    2. Cached auto-generated UAs\n    3. Newly generated UAs\n    \n    Args:\n        cache_path: Path to cache file\n        count: Number of UAs to generate if cache is invalid (default: 10)\n    \n    Returns:\n        List[str]: List of UA strings\n    \"\"\"\n    # Check for custom UA list file first (highest priority)\n    custom_ua_file = os.environ.get('WHOOGLE_UA_LIST_FILE', '').strip()\n    if custom_ua_file:\n        custom_uas = load_custom_ua_list(custom_ua_file)\n        if custom_uas:\n            # Custom list loaded successfully\n            return custom_uas\n        else:\n            # Custom file specified but invalid, log warning and fall back\n            print(f\"Warning: Custom UA list file '{custom_ua_file}' not found or invalid, falling back to auto-generated UAs\")\n    \n    # Check if we should use cache\n    use_cache = os.environ.get('WHOOGLE_UA_CACHE_PERSISTENT', '1') == '1'\n    refresh_days = int(os.environ.get('WHOOGLE_UA_CACHE_REFRESH_DAYS', '0'))\n    \n    # If cache disabled, always generate new\n    if not use_cache:\n        uas = generate_ua_pool(count)\n        save_ua_pool(uas, cache_path)\n        return uas\n    \n    # Try to load from cache\n    if os.path.exists(cache_path):\n        try:\n            with open(cache_path, 'r', encoding='utf-8') as f:\n                cache_data = json.load(f)\n            \n            # Check if cache is expired (if refresh_days > 0)\n            if refresh_days > 0:\n                generated_at = datetime.fromisoformat(cache_data['generated_at'])\n                age_days = (datetime.now() - generated_at).days\n                \n                if age_days >= refresh_days:\n                    # Cache expired, generate new\n                    uas = generate_ua_pool(count)\n                    save_ua_pool(uas, cache_path)\n                    return uas\n            \n            # Cache is valid, return it\n            return cache_data['user_agents']\n        except (json.JSONDecodeError, KeyError, ValueError):\n            # Cache file is corrupted, generate new\n            pass\n    \n    # No valid cache, generate new\n    uas = generate_ua_pool(count)\n    save_ua_pool(uas, cache_path)\n    return uas\n\n\ndef get_random_ua(ua_pool: List[str]) -> str:\n    \"\"\"\n    Get a random UA from the pool.\n    \n    Args:\n        ua_pool: List of UA strings\n    \n    Returns:\n        str: Random UA string from the pool\n    \"\"\"\n    if not ua_pool:\n        # Fallback to generating one if pool is empty\n        try:\n            return generate_opera_ua()\n        except Exception:\n            # If generation fails, use default fallback\n            return DEFAULT_FALLBACK_UA\n    \n    return random.choice(ua_pool)\n\n"
  },
  {
    "path": "app/utils/widgets.py",
    "content": "from pathlib import Path\nfrom bs4 import BeautifulSoup\n\n\n# root\nBASE_DIR = Path(__file__).parent.parent.parent\n\ndef add_ip_card(html_soup: BeautifulSoup, ip: str) -> BeautifulSoup:\n    \"\"\"Adds the client's IP address to the search results\n        if query contains keywords\n\n    Args:\n        html_soup: The parsed search result containing the keywords\n        ip: ip address of the client\n\n    Returns:\n        BeautifulSoup\n\n    \"\"\"\n    main_div = html_soup.select_one('#main')\n    if main_div:\n        # HTML IP card tag\n        ip_tag = html_soup.new_tag('div')\n        ip_tag['class'] = 'ZINbbc xpd O9g5cc uUPGi'\n\n        # For IP Address html tag\n        ip_address = html_soup.new_tag('div')\n        ip_address['class'] = 'kCrYT ip-address-div'\n        ip_address.string = ip\n\n        # Text below the IP address\n        ip_text = html_soup.new_tag('div')\n        ip_text.string = 'Your public IP address'\n        ip_text['class'] = 'kCrYT ip-text-div'\n\n        # Adding all the above html tags to the IP card\n        ip_tag.append(ip_address)\n        ip_tag.append(ip_text)\n\n        # Insert the element at the top of the result list\n        main_div.insert_before(ip_tag)\n    return html_soup\n\ndef add_calculator_card(html_soup: BeautifulSoup) -> BeautifulSoup:\n    \"\"\"Adds the a calculator widget to the search results\n        if query contains keywords\n\n    Args:\n        html_soup: The parsed search result containing the keywords\n\n    Returns:\n        BeautifulSoup\n    \"\"\"\n    main_div = html_soup.select_one('#main')\n    if main_div:\n        # absolute path\n        widget_file = open(BASE_DIR / 'app/static/widgets/calculator.html', encoding=\"utf8\")\n        widget_tag = html_soup.new_tag('div')\n        widget_tag['class'] = 'ZINbbc xpd O9g5cc uUPGi'\n        widget_tag['id'] = 'calculator-wrapper'\n        calculator_text = html_soup.new_tag('div')\n        calculator_text['class'] = 'kCrYT ip-address-div'\n        calculator_text.string = 'Calculator'\n        calculator_widget = html_soup.new_tag('div')\n        calculator_widget.append(BeautifulSoup(widget_file, 'html.parser'))\n        calculator_widget['class'] = 'kCrYT ip-text-div'\n        widget_tag.append(calculator_text)\n        widget_tag.append(calculator_widget)\n        main_div.insert_before(widget_tag)\n        widget_file.close()\n    return html_soup\n"
  },
  {
    "path": "app/version.py",
    "content": "import os\n\noptional_dev_tag = ''\nif os.getenv('DEV_BUILD'):\n    optional_dev_tag = '.dev' + os.getenv('DEV_BUILD')\n\n__version__ = '1.2.2' + optional_dev_tag\n\n"
  },
  {
    "path": "app.json",
    "content": "{\n  \"name\": \"Whoogle Search\",\n  \"description\": \"A lightweight, privacy-oriented, containerized Google search proxy for desktop/mobile that removes Javascript, AMP links, tracking, and ads/sponsored content\",\n  \"repository\": \"https://github.com/benbusby/whoogle-search\",\n  \"logo\": \"https://raw.githubusercontent.com/benbusby/whoogle-search/master/app/static/img/favicon/ms-icon-150x150.png\",\n  \"keywords\": [\n    \"search\",\n    \"metasearch\",\n    \"flask\",\n    \"docker\",\n    \"heroku\",\n    \"adblock\",\n    \"degoogle\",\n    \"privacy\"\n  ],\n  \"stack\": \"container\",\n  \"env\": {\n    \"WHOOGLE_URL_PREFIX\": {\n      \"description\": \"The URL prefix to use for the whoogle instance (i.e. \\\"/whoogle\\\")\",\n      \"value\": \"\",\n      \"required\": false\n    },\n    \"WHOOGLE_USER\": {\n      \"description\": \"The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.\",\n      \"value\": \"\",\n      \"required\": false\n    },\n    \"WHOOGLE_PASS\": {\n      \"description\": \"The password for basic auth. WHOOGLE_USER must also be set if used. Leave empty to disable.\",\n      \"value\": \"\",\n      \"required\": false\n    },\n    \"WHOOGLE_PROXY_USER\": {\n      \"description\": \"The username of the proxy server. Leave empty to disable.\",\n      \"value\": \"\",\n      \"required\": false\n    },\n    \"WHOOGLE_PROXY_PASS\": {\n      \"description\": \"The password of the proxy server. Leave empty to disable.\",\n      \"value\": \"\",\n      \"required\": false\n    },\n    \"WHOOGLE_PROXY_TYPE\": {\n      \"description\": \"The type of the proxy server. For example \\\"socks5\\\". Leave empty to disable.\",\n      \"value\": \"\",\n      \"required\": false\n    },\n    \"WHOOGLE_PROXY_LOC\": {\n      \"description\": \"The location of the proxy server (host or ip). Leave empty to disable.\",\n      \"value\": \"\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_TW\": {\n      \"description\": \"The site to use as a replacement for twitter.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/nitter\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_YT\": {\n      \"description\": \"The site to use as a replacement for youtube.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/invidious\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_RD\": {\n      \"description\": \"The site to use as a replacement for reddit.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/libreddit\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_MD\": {\n      \"description\": \"The site to use as a replacement for medium.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/scribe\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_TL\": {\n      \"description\": \"The Google Translate alternative to use for all searches following the 'translate ___' structure.\",\n      \"value\": \"farside.link/lingva\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_IMG\": {\n      \"description\": \"The site to use as a replacement for imgur.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/rimgo\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_WIKI\": {\n      \"description\": \"The site to use as a replacement for wikipedia.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/wikiless\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_IMDB\": {\n      \"description\": \"The site to use as a replacement for imdb.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/libremdb\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_QUORA\": {\n      \"description\": \"The site to use as a replacement for quora.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/quetre\",\n      \"required\": false\n    },\n    \"WHOOGLE_ALT_SO\": {\n      \"description\": \"The site to use as a replacement for stackoverflow.com when site alternatives are enabled in the config.\",\n      \"value\": \"farside.link/anonymousoverflow\",\n      \"required\": false\n    },\n    \"WHOOGLE_MINIMAL\": {\n        \"description\": \"Remove everything except basic result cards from all search queries (set to 1 or leave blank)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_COUNTRY\": {\n        \"description\": \"[CONFIG] The country to use for restricting search results (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/countries.json)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_TIME_PERIOD\" : {\n        \"description\": \"[CONFIG] The time period to use for restricting search results\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_LANGUAGE\": {\n        \"description\": \"[CONFIG] The language to use for the interface (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/languages.json)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_SEARCH_LANGUAGE\": {\n        \"description\": \"[CONFIG] The language to use for search results (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/languages.json)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_DISABLE\": {\n        \"description\": \"[CONFIG] Disable ability for client to change config (set to 1 or leave blank)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_BLOCK\": {\n        \"description\": \"[CONFIG] Block websites from search results (comma-separated list)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_THEME\": {\n        \"description\": \"[CONFIG] Set theme to 'dark', 'light', or 'system'\",\n        \"value\": \"system\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_SAFE\": {\n        \"description\": \"[CONFIG] Use safe mode for searches (set to 1 or leave blank)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_ALTS\": {\n        \"description\": \"[CONFIG] Use social media alternatives (set to 1 or leave blank)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_NEAR\": {\n        \"description\": \"[CONFIG] Restrict results to only those near a particular city\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_TOR\": {\n        \"description\": \"[CONFIG] Use Tor, if available (set to 1 or leave blank)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_NEW_TAB\": {\n        \"description\": \"[CONFIG] Always open results in new tab (set to 1 or leave blank)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_VIEW_IMAGE\": {\n      \"description\": \"[CONFIG] Enable View Image option (set to 1 or leave blank)\",\n      \"value\": \"\",\n      \"required\": false\n  },\n    \"WHOOGLE_CONFIG_GET_ONLY\": {\n        \"description\": \"[CONFIG] Search using GET requests only (set to 1 or leave blank)\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_STYLE\": {\n        \"description\": \"[CONFIG] Custom CSS styling (paste in CSS or leave blank)\",\n        \"value\": \":root { /* LIGHT THEME COLORS */ --whoogle-background: #d8dee9; --whoogle-accent: #2e3440; --whoogle-text: #3B4252; --whoogle-contrast-text: #eceff4; --whoogle-secondary-text: #70757a; --whoogle-result-bg: #fff; --whoogle-result-title: #4c566a; --whoogle-result-url: #81a1c1; --whoogle-result-visited: #a3be8c; /* DARK THEME COLORS */ --whoogle-dark-background: #222; --whoogle-dark-accent: #685e79; --whoogle-dark-text: #fff; --whoogle-dark-contrast-text: #000; --whoogle-dark-secondary-text: #bbb; --whoogle-dark-result-bg: #000; --whoogle-dark-result-title: #1967d2; --whoogle-dark-result-url: #4b11a8; --whoogle-dark-result-visited: #bbbbff; }\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED\": {\n        \"description\": \"[CONFIG] Encrypt preferences token, requires WHOOGLE_CONFIG_PREFERENCES_KEY to be set\",\n        \"value\": \"\",\n        \"required\": false\n    },\n    \"WHOOGLE_CONFIG_PREFERENCES_KEY\": {\n        \"description\": \"[CONFIG] Key to encrypt preferences\",\n        \"value\": \"NEEDS_TO_BE_MODIFIED\",\n        \"required\": false\n    }\n  }\n}\n"
  },
  {
    "path": "charts/whoogle/.helmignore",
    "content": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation (prefixed with !). Only one pattern per line.\n.DS_Store\n# Common VCS dirs\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n# Common backup files\n*.swp\n*.bak\n*.tmp\n*.orig\n*~\n# Various IDEs\n.project\n.idea/\n*.tmproj\n.vscode/\n"
  },
  {
    "path": "charts/whoogle/Chart.yaml",
    "content": "apiVersion: v2\nname: whoogle\ndescription: A self hosted search engine on Kubernetes\ntype: application\nversion: 0.1.0\nappVersion: 0.9.4\n\nicon: https://github.com/benbusby/whoogle-search/raw/main/app/static/img/favicon/favicon-96x96.png\n\nsources:\n  - https://github.com/benbusby/whoogle-search\n  - https://gitlab.com/benbusby/whoogle-search\n  - https://gogs.benbusby.com/benbusby/whoogle-search\n\nkeywords:\n  - whoogle\n  - degoogle\n  - search\n  - google\n  - search-engine\n  - privacy\n  - tor\n  - python\n"
  },
  {
    "path": "charts/whoogle/templates/NOTES.txt",
    "content": "1. Get the application URL by running these commands:\n{{- if .Values.ingress.enabled }}\n{{- range $host := .Values.ingress.hosts }}\n  {{- range .paths }}\n  http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}\n  {{- end }}\n{{- end }}\n{{- else if contains \"NodePort\" .Values.service.type }}\n  export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath=\"{.spec.ports[0].nodePort}\" services {{ include \"whoogle.fullname\" . }})\n  export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath=\"{.items[0].status.addresses[0].address}\")\n  echo http://$NODE_IP:$NODE_PORT\n{{- else if contains \"LoadBalancer\" .Values.service.type }}\n     NOTE: It may take a few minutes for the LoadBalancer IP to be available.\n           You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include \"whoogle.fullname\" . }}'\n  export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include \"whoogle.fullname\" . }} --template \"{{\"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}\"}}\")\n  echo http://$SERVICE_IP:{{ .Values.service.port }}\n{{- else if contains \"ClusterIP\" .Values.service.type }}\n  export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l \"app.kubernetes.io/name={{ include \"whoogle.name\" . }},app.kubernetes.io/instance={{ .Release.Name }}\" -o jsonpath=\"{.items[0].metadata.name}\")\n  export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath=\"{.spec.containers[0].ports[0].containerPort}\")\n  echo \"Visit http://127.0.0.1:8080 to use your application\"\n  kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT\n{{- end }}\n"
  },
  {
    "path": "charts/whoogle/templates/_helpers.tpl",
    "content": "{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"whoogle.name\" -}}\n{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCreate a default fully qualified app name.\nWe truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).\nIf release name contains chart name it will be used as a full name.\n*/}}\n{{- define \"whoogle.fullname\" -}}\n{{- if .Values.fullnameOverride }}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- $name := default .Chart.Name .Values.nameOverride }}\n{{- if contains $name .Release.Name }}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n{{- end }}\n{{- end }}\n\n{{/*\nCreate chart name and version as used by the chart label.\n*/}}\n{{- define \"whoogle.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"whoogle.labels\" -}}\nhelm.sh/chart: {{ include \"whoogle.chart\" . }}\n{{ include \"whoogle.selectorLabels\" . }}\n{{- if .Chart.AppVersion }}\napp.kubernetes.io/version: {{ .Chart.AppVersion | quote }}\n{{- end }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end }}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"whoogle.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"whoogle.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end }}\n\n{{/*\nCreate the name of the service account to use\n*/}}\n{{- define \"whoogle.serviceAccountName\" -}}\n{{- if .Values.serviceAccount.create }}\n{{- default (include \"whoogle.fullname\" .) .Values.serviceAccount.name }}\n{{- else }}\n{{- default \"default\" .Values.serviceAccount.name }}\n{{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/whoogle/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"whoogle.fullname\" . }}\n  labels:\n    {{- include \"whoogle.labels\" . | nindent 4 }}\nspec:\n  {{- if not .Values.autoscaling.enabled }}\n  replicas: {{ .Values.replicaCount }}\n  {{- end }}\n  selector:\n    matchLabels:\n      {{- include \"whoogle.selectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n      {{- with .Values.podAnnotations }}\n      annotations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      labels:\n        {{- include \"whoogle.selectorLabels\" . | nindent 8 }}\n    spec:\n      {{- with .Values.image.pullSecrets }}\n      imagePullSecrets:\n        {{- range .}}\n        - name: {{ . }}\n        {{- end }}\n      {{- end }}\n      serviceAccountName: {{ include \"whoogle.serviceAccountName\" . }}\n      securityContext:\n        {{- toYaml .Values.podSecurityContext | nindent 8 }}\n      containers:\n        - name: whoogle\n          securityContext:\n            {{- toYaml .Values.securityContext | nindent 12 }}\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n          imagePullPolicy: {{ .Values.image.pullPolicy }}\n          {{- with .Values.conf }}\n          env:\n            {{- range $k,$v := . }}\n            {{- if $v }}\n            - name: {{ $k }}\n              value: {{ tpl (toString $v) $ | quote }}\n            {{- end }}\n            {{- end }}\n          {{- end }}\n          ports:\n            - name: http\n              containerPort: {{ default 5000 .Values.conf.EXPOSE_PORT }}\n              protocol: TCP\n          livenessProbe:\n            httpGet:\n              path: /\n              port: http\n              {{- if and .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS }}\n              httpHeaders:\n                - name: Authorization\n                  value: Basic {{ b64enc (printf \"%s:%s\" .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS) }}\n              {{- end }}\n          readinessProbe:\n            httpGet:\n              path: /\n              port: http\n              {{- if and .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS }}\n              httpHeaders:\n                - name: Authorization\n                  value: Basic {{ b64enc (printf \"%s:%s\" .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS) }}\n              {{- end }}\n          resources:\n            {{- toYaml .Values.resources | nindent 12 }}\n      {{- with .Values.nodeSelector }}\n      nodeSelector:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.affinity }}\n      affinity:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.tolerations }}\n      tolerations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n"
  },
  {
    "path": "charts/whoogle/templates/hpa.yaml",
    "content": "{{- if .Values.autoscaling.enabled }}\n{{- if semverCompare \">=1.23-0\" .Capabilities.KubeVersion.GitVersion -}}\napiVersion: autoscaling/v2\n{{- else -}}\napiVersion: autoscaling/v2beta1\n{{- end }}\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: {{ include \"whoogle.fullname\" . }}\n  labels:\n    {{- include \"whoogle.labels\" . | nindent 4 }}\nspec:\n  scaleTargetRef:\n    apiVersion: apps/v1\n    kind: Deployment\n    name: {{ include \"whoogle.fullname\" . }}\n  minReplicas: {{ .Values.autoscaling.minReplicas }}\n  maxReplicas: {{ .Values.autoscaling.maxReplicas }}\n  metrics:\n    {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}\n    - type: Resource\n      resource:\n        name: cpu\n        {{- if semverCompare \">=1.23-0\" .Capabilities.KubeVersion.GitVersion }}\n        target:\n          type: Utilization\n          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}\n        {{- else -}}\n        targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}\n        {{- end }}\n    {{- end }}\n    {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}\n    - type: Resource\n      resource:\n        name: memory\n        {{- if semverCompare \">=1.23-0\" .Capabilities.KubeVersion.GitVersion }}\n        target:\n          type: Utilization\n          averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}\n        {{- else -}}\n        targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}\n        {{- end }}\n    {{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/whoogle/templates/ingress.yaml",
    "content": "{{- if .Values.ingress.enabled -}}\n{{- $fullName := include \"whoogle.fullname\" . -}}\n{{- $svcPort := .Values.service.port -}}\n{{- if and .Values.ingress.className (not (semverCompare \">=1.18-0\" .Capabilities.KubeVersion.GitVersion)) }}\n  {{- if not (hasKey .Values.ingress.annotations \"kubernetes.io/ingress.class\") }}\n  {{- $_ := set .Values.ingress.annotations \"kubernetes.io/ingress.class\" .Values.ingress.className}}\n  {{- end }}\n{{- end }}\n{{- if semverCompare \">=1.19-0\" .Capabilities.KubeVersion.GitVersion -}}\napiVersion: networking.k8s.io/v1\n{{- else if semverCompare \">=1.14-0\" .Capabilities.KubeVersion.GitVersion -}}\napiVersion: networking.k8s.io/v1beta1\n{{- else -}}\napiVersion: extensions/v1beta1\n{{- end }}\nkind: Ingress\nmetadata:\n  name: {{ $fullName }}\n  labels:\n    {{- include \"whoogle.labels\" . | nindent 4 }}\n  {{- with .Values.ingress.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\nspec:\n  {{- if and .Values.ingress.className (semverCompare \">=1.18-0\" .Capabilities.KubeVersion.GitVersion) }}\n  ingressClassName: {{ .Values.ingress.className }}\n  {{- end }}\n  {{- if .Values.ingress.tls }}\n  tls:\n    {{- range .Values.ingress.tls }}\n    - hosts:\n        {{- range .hosts }}\n        - {{ . | quote }}\n        {{- end }}\n      secretName: {{ .secretName }}\n    {{- end }}\n  {{- end }}\n  rules:\n    {{- range .Values.ingress.hosts }}\n    - host: {{ .host | quote }}\n      http:\n        paths:\n          {{- range .paths }}\n          - path: {{ .path }}\n            {{- if and .pathType (semverCompare \">=1.18-0\" $.Capabilities.KubeVersion.GitVersion) }}\n            pathType: {{ .pathType }}\n            {{- end }}\n            backend:\n              {{- if semverCompare \">=1.19-0\" $.Capabilities.KubeVersion.GitVersion }}\n              service:\n                name: {{ $fullName }}\n                port:\n                  number: {{ $svcPort }}\n              {{- else }}\n              serviceName: {{ $fullName }}\n              servicePort: {{ $svcPort }}\n              {{- end }}\n          {{- end }}\n    {{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/whoogle/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"whoogle.fullname\" . }}\n  labels:\n    {{- include \"whoogle.labels\" . | nindent 4 }}\nspec:\n  type: {{ .Values.service.type }}\n  ports:\n    - port: {{ .Values.service.port }}\n      targetPort: http\n      protocol: TCP\n      name: http\n  selector:\n    {{- include \"whoogle.selectorLabels\" . | nindent 4 }}\n"
  },
  {
    "path": "charts/whoogle/templates/serviceaccount.yaml",
    "content": "{{- if .Values.serviceAccount.create -}}\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: {{ include \"whoogle.serviceAccountName\" . }}\n  labels:\n    {{- include \"whoogle.labels\" . | nindent 4 }}\n  {{- with .Values.serviceAccount.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/whoogle/templates/tests/test-connection.yaml",
    "content": "apiVersion: v1\nkind: Pod\nmetadata:\n  name: \"{{ include \"whoogle.fullname\" . }}-test-connection\"\n  labels:\n    {{- include \"whoogle.labels\" . | nindent 4 }}\n  annotations:\n    \"helm.sh/hook\": test\nspec:\n  containers:\n    - name: wget\n      image: busybox\n      command: ['wget']\n      args: ['{{ include \"whoogle.fullname\" . }}:{{ .Values.service.port }}']\n  restartPolicy: Never\n"
  },
  {
    "path": "charts/whoogle/values.yaml",
    "content": "# Default values for whoogle.\n# This is a YAML-formatted file.\n# Declare variables to be passed into your templates.\n\nnameOverride: \"\"\nfullnameOverride: \"\"\n\nreplicaCount: 1\nimage:\n  repository: benbusby/whoogle-search\n  pullPolicy: IfNotPresent\n  # Overrides the image tag whose default is the chart appVersion.\n  tag: \"\"\n  pullSecrets: []\n    # - my-image-pull-secret\n\nserviceAccount:\n  # Specifies whether a service account should be created\n  create: true\n  # Annotations to add to the service account\n  annotations: {}\n  # The name of the service account to use.\n  # If not set and create is true, a name is generated using the fullname template\n  name: \"\"\n\nconf: {}\n  # WHOOGLE_URL_PREFIX: \"\"   # The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")\n  # WHOOGLE_DOTENV: \"\"       # Load environment variables in whoogle.env\n  # WHOOGLE_USER: \"\"         # The username for basic auth. WHOOGLE_PASS must also be set if used.\n  # WHOOGLE_PASS: \"\"         # The password for basic auth. WHOOGLE_USER must also be set if used.\n  # WHOOGLE_PROXY_USER: \"\"   # The username of the proxy server.\n  # WHOOGLE_PROXY_PASS: \"\"   # The password of the proxy server.\n  # WHOOGLE_PROXY_TYPE: \"\"   # The type of the proxy server. Can be \"socks5\", \"socks4\", or \"http\".\n  # WHOOGLE_PROXY_LOC: \"\"    # The location of the proxy server (host or ip).\n  # EXPOSE_PORT: \"\"          # The port where Whoogle will be exposed. (default 5000)\n  # HTTPS_ONLY: \"\"           # Enforce HTTPS. (See https://github.com/benbusby/whoogle-search#https-enforcement)\n  # WHOOGLE_ALT_TW: \"\"       # The twitter.com alternative to use when site alternatives are enabled in the config.\n  # WHOOGLE_ALT_YT: \"\"       # The youtube.com alternative to use when site alternatives are enabled in the config.\n  # WHOOGLE_ALT_RD: \"\"       # The reddit.com alternative to use when site alternatives are enabled in the config.\n  # WHOOGLE_ALT_TL: \"\"       # The Google Translate alternative to use. This is used for all \"translate ____\" searches.\n  # WHOOGLE_ALT_MD: \"\"       # The medium.com alternative to use when site alternatives are enabled in the config.\n  # WHOOGLE_ALT_IMG: \"\"      # The imgur.com alternative to use when site alternatives are enabled in the config.\n  # WHOOGLE_ALT_WIKI: \"\"     # The wikipedia.com alternative to use when site alternatives are enabled in the config.\n  # WHOOGLE_ALT_IMDB: \"\"     # The imdb.com alternative to use. Set to \"\" to continue using imdb.com when site alternatives are enabled.\n  # WHOOGLE_ALT_QUORA: \"\"    # The quora.com alternative to use. Set to \"\" to continue using quora.com when site alternatives are enabled.\n  # WHOOGLE_ALT_SO: \"\"       # The stackoverflow.com alternative to use. Set to \"\" to continue using stackoverflow.com when site alternatives are enabled. \n  # WHOOGLE_AUTOCOMPLETE: \"\" # Controls visibility of autocomplete/search suggestions. Default on -- use '0' to disable\n  # WHOOGLE_MINIMAL: \"\"      # Remove everything except basic result cards from all search queries.\n\n  # WHOOGLE_CONFIG_DISABLE: \"\"               # Hide config from UI and disallow changes to config by client\n  # WHOOGLE_CONFIG_COUNTRY: \"\"               # Filter results by hosting country\n  # WHOOGLE_CONFIG_LANGUAGE: \"\"              # Set interface language\n  # WHOOGLE_CONFIG_SEARCH_LANGUAGE: \"\"       # Set search result language\n  # WHOOGLE_CONFIG_BLOCK: \"\"                 # Block websites from search results (use comma-separated list)\n  # WHOOGLE_CONFIG_THEME: \"\"                 # Set theme mode (light, dark, or system)\n  # WHOOGLE_CONFIG_SAFE: \"\"                  # Enable safe searches\n  # WHOOGLE_CONFIG_ALTS: \"\"                  # Use social media site alternatives (nitter, invidious, etc)\n  # WHOOGLE_CONFIG_NEAR: \"\"                  # Restrict results to only those near a particular city\n  # WHOOGLE_CONFIG_TOR: \"\"                   # Use Tor routing (if available)\n  # WHOOGLE_CONFIG_NEW_TAB: \"\"               # Always open results in new tab\n  # WHOOGLE_CONFIG_VIEW_IMAGE: \"\"            # Enable View Image option\n  # WHOOGLE_CONFIG_GET_ONLY: \"\"              # Search using GET requests only\n  # WHOOGLE_CONFIG_URL: \"\"                   # The root url of the instance (https://<your url>/)\n  # WHOOGLE_CONFIG_STYLE: \"\"                 # The custom CSS to use for styling (should be single line)\n  # WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED: \"\" # Encrypt preferences token, requires key\n  # WHOOGLE_CONFIG_PREFERENCES_KEY: \"\"       # Key to encrypt preferences in URL (REQUIRED to show url)\n\npodAnnotations: {}\npodSecurityContext: {}\n  # fsGroup: 2000\nsecurityContext:\n  runAsUser: 0\n  # capabilities:\n  #   drop:\n  #   - ALL\n  # readOnlyRootFilesystem: true\n\nservice:\n  type: ClusterIP\n  port: 5000\n\ningress:\n  enabled: false\n  className: \"\"\n  annotations: {}\n    # kubernetes.io/ingress.class: nginx\n    # kubernetes.io/tls-acme: \"true\"\n  hosts:\n    - host: whoogle.example.com\n      paths:\n        - path: /\n          pathType: ImplementationSpecific\n  tls: []\n  #  - secretName: chart-example-tls\n  #    hosts:\n  #      - whoogle.example.com\n\nresources: {}\n  # requests:\n  #   cpu: 100m\n  #   memory: 128Mi\n  # limits:\n  #   cpu: 100m\n  #   memory: 128Mi\n\nautoscaling:\n  enabled: false\n  minReplicas: 1\n  maxReplicas: 100\n  targetCPUUtilizationPercentage: 80\n  # targetMemoryUtilizationPercentage: 80\n\nnodeSelector: {}\ntolerations: []\naffinity: {}\n"
  },
  {
    "path": "docker-compose-traefik.yaml",
    "content": "# can't use mem_limit in a 3.x docker-compose file in non swarm mode\n# see https://github.com/docker/compose/issues/4513\nversion: \"2.4\"\n\nservices:\n  traefik:\n    image: \"traefik:v2.7\"\n    container_name: \"traefik\"\n    command:\n      #- \"--log.level=DEBUG\"\n      - \"--api.insecure=true\"\n      - \"--providers.docker=true\"\n      - \"--providers.docker.exposedbydefault=false\"\n      - \"--entrypoints.websecure.address=:443\"\n      - \"--certificatesresolvers.myresolver.acme.tlschallenge=true\"\n      #- \"--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory\"\n      - \"--certificatesresolvers.myresolver.acme.email=change@domain.name\"\n      - \"--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json\"\n    ports:\n      - \"443:443\"\n      - \"8080:8080\"\n    volumes:\n      - \"./letsencrypt:/letsencrypt\"\n      - \"/var/run/docker.sock:/var/run/docker.sock:ro\"\n\n  whoogle-search:\n    labels:\n      - \"traefik.enable=true\"\n      - \"traefik.http.routers.whoami.rule=Host(`change.host.name`)\"\n      - \"traefik.http.routers.whoami.entrypoints=websecure\"\n      - \"traefik.http.routers.whoami.tls.certresolver=myresolver\"\n      - \"traefik.http.services.whoogle-search.loadbalancer.server.port=5000\"\n    image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search}\n    container_name: whoogle-search\n    restart: unless-stopped\n    pids_limit: 50\n    mem_limit: 256mb\n    memswap_limit: 256mb\n    # user debian-tor from tor package\n    user: whoogle\n    security_opt:\n      - no-new-privileges\n    cap_drop:\n      - ALL\n    tmpfs:\n      - /config/:size=10M,uid=927,gid=927,mode=1700\n      - /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700\n      - /run/tor/:size=1M,uid=927,gid=927,mode=1700\n    environment: # Uncomment to configure environment variables\n      # Basic auth configuration, uncomment to enable\n      #- WHOOGLE_USER=<auth username>\n      #- WHOOGLE_PASS=<auth password>\n      # Proxy configuration, uncomment to enable\n      #- WHOOGLE_PROXY_USER=<proxy username>\n      #- WHOOGLE_PROXY_PASS=<proxy password>\n      #- WHOOGLE_PROXY_TYPE=<proxy type (http|https|socks4|socks5)\n      #- WHOOGLE_PROXY_LOC=<proxy host/ip>\n      # Site alternative configurations, uncomment to enable\n      # Note: If not set, the feature will still be available\n      # with default values.\n      #- WHOOGLE_ALT_TW=farside.link/nitter\n      #- WHOOGLE_ALT_YT=farside.link/invidious\n      #- WHOOGLE_ALT_IG=farside.link/bibliogram/u\n      #- WHOOGLE_ALT_RD=farside.link/libreddit\n      #- WHOOGLE_ALT_MD=farside.link/scribe\n      #- WHOOGLE_ALT_TL=farside.link/lingva\n      #- WHOOGLE_ALT_IMG=farside.link/rimgo\n      #- WHOOGLE_ALT_WIKI=farside.link/wikiless\n      #- WHOOGLE_ALT_IMDB=farside.link/libremdb\n      #- WHOOGLE_ALT_QUORA=farside.link/quetre\n      #- WHOOGLE_ALT_SO=farside.link/anonymousoverflow\n      # - WHOOGLE_CONFIG_DISABLE=1\n      # - WHOOGLE_CONFIG_SEARCH_LANGUAGE=lang_en\n      # - WHOOGLE_CONFIG_GET_ONLY=1\n      # - WHOOGLE_CONFIG_COUNTRY=FR\n      # - WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED=1\n      # - WHOOGLE_CONFIG_PREFERENCES_KEY=\"NEEDS_TO_BE_MODIFIED\"\n    #env_file: # Alternatively, load variables from whoogle.env\n      #- whoogle.env\n    ports:\n      - 8000:5000\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "# Modern docker-compose format (v2+) does not require version specification\n# Memory limits are supported in Compose v2+ without version field\n\nservices:\n  whoogle-search:\n    image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search}\n    container_name: whoogle-search\n    restart: unless-stopped\n    pids_limit: 50\n    mem_limit: 256mb\n    memswap_limit: 256mb\n    # user debian-tor from tor package\n    user: whoogle\n    security_opt:\n      - no-new-privileges\n    cap_drop:\n      - ALL\n    tmpfs:\n      - /config/:size=10M,uid=927,gid=927,mode=1700\n      - /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700\n      - /run/tor/:size=1M,uid=927,gid=927,mode=1700\n    #environment: # Uncomment to configure environment variables\n      # Basic auth configuration, uncomment to enable\n      #- WHOOGLE_USER=<auth username>\n      #- WHOOGLE_PASS=<auth password>\n      # Proxy configuration, uncomment to enable\n      #- WHOOGLE_PROXY_USER=<proxy username>\n      #- WHOOGLE_PROXY_PASS=<proxy password>\n      #- WHOOGLE_PROXY_TYPE=<proxy type (http|https|socks4|socks5)\n      #- WHOOGLE_PROXY_LOC=<proxy host/ip>\n      # Site alternative configurations, uncomment to enable\n      # Note: If not set, the feature will still be available\n      # with default values.\n      #- WHOOGLE_ALT_TW=farside.link/nitter\n      #- WHOOGLE_ALT_YT=farside.link/invidious\n      #- WHOOGLE_ALT_IG=farside.link/bibliogram/u\n      #- WHOOGLE_ALT_RD=farside.link/libreddit\n      #- WHOOGLE_ALT_MD=farside.link/scribe\n      #- WHOOGLE_ALT_TL=farside.link/lingva\n      #- WHOOGLE_ALT_IMG=farside.link/rimgo\n      #- WHOOGLE_ALT_WIKI=farside.link/wikiless\n      #- WHOOGLE_ALT_IMDB=farside.link/libremdb\n      #- WHOOGLE_ALT_QUORA=farside.link/quetre\n      #- WHOOGLE_ALT_SO=farside.link/anonymousoverflow\n    #env_file: # Alternatively, load variables from whoogle.env\n      #- whoogle.env\n    ports:\n      - 5000:5000\n"
  },
  {
    "path": "heroku.yml",
    "content": "build:\n  docker:\n    web: Dockerfile\n\n"
  },
  {
    "path": "letsencrypt/acme.json",
    "content": ""
  },
  {
    "path": "misc/check_google_user_agents.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest User Agent strings against Google to find which ones return actual search results\ninstead of JavaScript pages or upgrade browser messages.\n\nUsage:\n    python test_google_user_agents.py <user_agent_file> [--output <output_file>] [--query <search_query>]\n\"\"\"\n\nimport argparse\nimport random\nimport sys\nimport time\nfrom typing import List, Tuple\nimport requests\n\n# Common search queries to cycle through for more realistic testing\nDEFAULT_SEARCH_QUERIES = [\n    \"python programming\",\n    \"weather today\",\n    \"news\",\n    \"how to cook pasta\",\n    \"best movies 2025\",\n    \"restaurants near me\",\n    \"translate hello\",\n    \"calculator\",\n    \"time\",\n    \"maps\",\n    \"images\",\n    \"videos\",\n    \"shopping\",\n    \"travel\",\n    \"sports scores\",\n    \"stock market\",\n    \"recipes\",\n    \"music\",\n    \"books\",\n    \"technology\",\n    \"AI\",\n    \"AI programming\",\n    \"Why does google hate users?\"\n]\n\n# Markers that indicate blocked/JS pages\nBLOCK_MARKERS = [\n    \"unusual traffic\",\n    \"sorry but your computer\",\n    \"solve the captcha\",\n    \"request looks automated\",\n    \"g-recaptcha\",\n    \"upgrade your browser\",\n    \"browser is not supported\",\n    \"please upgrade\",\n    \"isn't supported\",\n    \"isn\\\"t supported\",  # With escaped quote\n    \"upgrade to a recent version\",\n    \"update your browser\",\n    \"your browser isn't supported\",\n]\n\n# Markers that indicate actual search results\nSUCCESS_MARKERS = [\n    '<div class=\"g\"',  # Google search result container\n    '<div id=\"search\"',  # Search results container\n    '<div class=\"rc\"',  # Result container\n    'class=\"yuRUbf\"',  # Result link container\n    'class=\"LC20lb\"',  # Result title\n    '- Google Search</title>',  # Page title indicator\n    'id=\"rso\"',  # Results container\n    'class=\"g\"',  # Result class (without div tag)\n]\n\n\ndef read_user_agents(file_path: str) -> List[str]:\n    \"\"\"Read user agent strings from a file, one per line.\"\"\"\n    try:\n        with open(file_path, 'r', encoding='utf-8') as f:\n            user_agents = [line.strip() for line in f if line.strip()]\n        return user_agents\n    except FileNotFoundError:\n        print(f\"Error: File '{file_path}' not found.\", file=sys.stderr)\n        sys.exit(1)\n    except Exception as e:\n        print(f\"Error reading file: {e}\", file=sys.stderr)\n        sys.exit(1)\n\n\ndef test_user_agent(user_agent: str, query: str = \"test\", timeout: float = 10.0) -> Tuple[bool, str]:\n    \"\"\"\n    Test a user agent against Google search.\n    \n    Returns:\n        Tuple of (is_working: bool, reason: str)\n    \"\"\"\n    url = \"https://www.google.com/search\"\n    params = {\"q\": query, \"gbv\": \"1\", \"num\": \"10\"}\n    \n    headers = {\n        \"User-Agent\": user_agent,\n        \"Accept\": \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n        \"Accept-Language\": \"en-US,en;q=0.9\",\n        \"Accept-Encoding\": \"gzip, deflate, br\",\n        \"Connection\": \"keep-alive\",\n        \"Upgrade-Insecure-Requests\": \"1\",\n    }\n    \n    try:\n        response = requests.get(url, params=params, headers=headers, timeout=timeout)\n        \n        # Check HTTP status\n        if response.status_code == 429:\n            # Rate limited - raise this so we can handle it specially\n            raise Exception(f\"Rate limited (429)\")\n        if response.status_code >= 500:\n            return False, f\"Server error ({response.status_code})\"\n        if response.status_code == 403:\n            return False, f\"Blocked ({response.status_code})\"\n        if response.status_code >= 400:\n            return False, f\"HTTP {response.status_code}\"\n        \n        body_lower = response.text.lower()\n        \n        # Check for block markers\n        for marker in BLOCK_MARKERS:\n            if marker.lower() in body_lower:\n                return False, f\"Blocked: {marker}\"\n        \n        # Check for redirect indicators first - these indicate non-working responses\n        has_redirect = (\"window.location\" in body_lower or \"location.href\" in body_lower) and \"google.com\" not in body_lower\n        if has_redirect:\n            return False, \"JavaScript redirect detected\"\n        \n        # Check for noscript redirect (another indicator of JS-only page)\n        if 'noscript' in body_lower and 'http-equiv=\"refresh\"' in body_lower:\n            return False, \"NoScript redirect page\"\n        \n        # Check for success markers (actual search results)\n        # We need at least one strong indicator of search results\n        has_results = any(marker in response.text for marker in SUCCESS_MARKERS)\n        \n        if has_results:\n            return True, \"OK - Has search results\"\n        else:\n            # Check for very short responses (likely error pages)\n            if len(response.text) < 1000:\n                return False, \"Response too short (likely error page)\"\n            # If we don't have success markers, it's not a working response\n            # Even if it's substantial and doesn't have block markers, it might be a JS-only page\n            return False, \"No search results found\"\n            \n    except requests.Timeout:\n        return False, \"Request timeout\"\n    except requests.HTTPError as e:\n        if e.response and e.response.status_code == 429:\n            # Rate limited - raise this so we can handle it specially\n            raise Exception(f\"Rate limited (429) - {str(e)}\")\n        return False, f\"HTTP error: {str(e)}\"\n    except requests.RequestException as e:\n        # Check if it's a 429 in the response\n        if hasattr(e, 'response') and e.response and e.response.status_code == 429:\n            raise Exception(f\"Rate limited (429) - {str(e)}\")\n        return False, f\"Request error: {str(e)}\"\n\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description=\"Test User Agent strings against Google to find working ones.\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\nExamples:\n  python test_google_user_agents.py UAs.txt\n  python test_google_user_agents.py UAs.txt --output working_uas.txt\n  python test_google_user_agents.py UAs.txt --query \"python programming\"\n        \"\"\"\n    )\n    parser.add_argument(\n        \"user_agent_file\",\n        help=\"Path to file containing user agent strings (one per line)\"\n    )\n    parser.add_argument(\n        \"--output\", \"-o\",\n        help=\"Output file to write working user agents (default: stdout)\"\n    )\n    parser.add_argument(\n        \"--query\", \"-q\",\n        default=None,\n        help=\"Search query to use for testing (default: cycles through random queries)\"\n    )\n    parser.add_argument(\n        \"--random-queries\", \"-r\",\n        action=\"store_true\",\n        help=\"Use random queries from a predefined list (default: True if --query not specified)\"\n    )\n    parser.add_argument(\n        \"--timeout\", \"-t\",\n        type=float,\n        default=10.0,\n        help=\"Request timeout in seconds (default: 10.0)\"\n    )\n    parser.add_argument(\n        \"--delay\", \"-d\",\n        type=float,\n        default=0.5,\n        help=\"Delay between requests in seconds (default: 0.5)\"\n    )\n    parser.add_argument(\n        \"--verbose\", \"-v\",\n        action=\"store_true\",\n        help=\"Show detailed results for each user agent\"\n    )\n    \n    args = parser.parse_args()\n    \n    # Determine query strategy\n    use_random_queries = args.random_queries or (args.query is None)\n    if use_random_queries:\n        search_queries = DEFAULT_SEARCH_QUERIES.copy()\n        random.shuffle(search_queries)  # Shuffle for variety\n        current_query_idx = 0\n        query_display = f\"cycling through {len(search_queries)} random queries\"\n    else:\n        search_queries = [args.query]\n        query_display = f\"'{args.query}'\"\n    \n    # Read user agents\n    user_agents = read_user_agents(args.user_agent_file)\n    if not user_agents:\n        print(\"No user agents found in file.\", file=sys.stderr)\n        sys.exit(1)\n    \n    print(f\"Testing {len(user_agents)} user agents against Google...\", file=sys.stderr)\n    print(f\"Query: {query_display}\", file=sys.stderr)\n    if args.output:\n        print(f\"Output file: {args.output} (appending results incrementally)\", file=sys.stderr)\n    print(file=sys.stderr)\n    \n    # Load existing working user agents from output file to avoid duplicates\n    existing_working = set()\n    if args.output:\n        try:\n            with open(args.output, 'r', encoding='utf-8') as f:\n                existing_working = {line.strip() for line in f if line.strip()}\n            if existing_working:\n                print(f\"Found {len(existing_working)} existing user agents in output file\", file=sys.stderr)\n        except FileNotFoundError:\n            # File doesn't exist yet, that's fine\n            pass\n        except Exception as e:\n            print(f\"Warning: Could not read existing output file: {e}\", file=sys.stderr)\n    \n    # Open output file for incremental writing if specified (append mode)\n    output_file = None\n    if args.output:\n        try:\n            output_file = open(args.output, 'a', encoding='utf-8')\n        except Exception as e:\n            print(f\"Error opening output file: {e}\", file=sys.stderr)\n            sys.exit(1)\n    \n    working_agents = []\n    failed_count = 0\n    skipped_count = 0\n    last_successful_idx = 0\n    \n    try:\n        for idx, ua in enumerate(user_agents, 1):\n            # Skip testing if this UA is already in the working file\n            if args.output and ua in existing_working:\n                skipped_count += 1\n                if args.verbose:\n                    print(f\"[{idx}/{len(user_agents)}] ⊘ SKIPPED - Already in working file\", file=sys.stderr)\n                last_successful_idx = idx\n                continue\n            \n            try:\n                # Get the next query (cycle through if using random queries)\n                if use_random_queries:\n                    query = search_queries[current_query_idx % len(search_queries)]\n                    current_query_idx += 1\n                else:\n                    query = args.query\n                \n                is_working, reason = test_user_agent(ua, query, args.timeout)\n                \n                if is_working:\n                    working_agents.append(ua)\n                    status = \"✓\"\n                    # Write immediately to output file if specified (skip if duplicate)\n                    if output_file:\n                        if ua not in existing_working:\n                            output_file.write(ua + '\\n')\n                            output_file.flush()  # Ensure it's written to disk\n                            existing_working.add(ua)  # Track it to avoid duplicates\n                        else:\n                            if args.verbose:\n                                print(f\"[{idx}/{len(user_agents)}] {status} WORKING (duplicate, skipped) - {reason}\", file=sys.stderr)\n                    # Also print to stdout if no output file\n                    if not args.output:\n                        print(ua)\n                    \n                    if args.verbose:\n                        print(f\"[{idx}/{len(user_agents)}] {status} WORKING - {reason}\", file=sys.stderr)\n                else:\n                    failed_count += 1\n                    status = \"✗\"\n                    if args.verbose:\n                        print(f\"[{idx}/{len(user_agents)}] {status} FAILED - {reason}\", file=sys.stderr)\n                \n                last_successful_idx = idx\n                \n                # Progress indicator for non-verbose mode\n                if not args.verbose and idx % 10 == 0:\n                    print(f\"Progress: {idx}/{len(user_agents)} tested ({len(working_agents)} working, {failed_count} failed)\", file=sys.stderr)\n                \n                # Delay between requests to avoid rate limiting\n                if idx < len(user_agents):\n                    time.sleep(args.delay)\n                    \n            except KeyboardInterrupt:\n                print(file=sys.stderr)\n                print(f\"\\nInterrupted by user at index {idx}/{len(user_agents)}\", file=sys.stderr)\n                print(f\"Last successful test: {last_successful_idx}/{len(user_agents)}\", file=sys.stderr)\n                break\n            except Exception as e:\n                # Handle unexpected errors (like network issues or rate limits)\n                error_msg = str(e)\n                if \"429\" in error_msg or \"Rate limited\" in error_msg:\n                    print(file=sys.stderr)\n                    print(f\"\\n⚠️  RATE LIMIT DETECTED at index {idx}/{len(user_agents)}\", file=sys.stderr)\n                    print(f\"Last successful test: {last_successful_idx}/{len(user_agents)}\", file=sys.stderr)\n                    print(f\"Working user agents found so far: {len(working_agents)}\", file=sys.stderr)\n                    if args.output:\n                        print(f\"Results saved to: {args.output}\", file=sys.stderr)\n                    print(f\"\\nTo resume later, you can skip the first {last_successful_idx} user agents.\", file=sys.stderr)\n                    raise  # Re-raise to exit the loop\n                else:\n                    print(f\"[{idx}/{len(user_agents)}] ERROR - {error_msg}\", file=sys.stderr)\n                    failed_count += 1\n                    last_successful_idx = idx\n                    if idx < len(user_agents):\n                        time.sleep(args.delay)\n                    continue\n    \n    finally:\n        # Close output file if opened\n        if output_file:\n            output_file.close()\n    \n    # Summary\n    print(file=sys.stderr)\n    tested_count = last_successful_idx - skipped_count\n    print(f\"Summary: {len(working_agents)} working, {failed_count} failed, {skipped_count} skipped out of {last_successful_idx} processed (of {len(user_agents)} total)\", file=sys.stderr)\n    if last_successful_idx < len(user_agents):\n        print(f\"Note: Processing stopped at index {last_successful_idx}. {len(user_agents) - last_successful_idx} user agents not processed.\", file=sys.stderr)\n        if args.output:\n            print(f\"Results saved to: {args.output}\", file=sys.stderr)\n    \n    return 0 if working_agents else 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n\n"
  },
  {
    "path": "misc/generate_uas.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nStandalone Opera User Agent String Generator\n\nThis tool generates Opera-based User Agent strings that can be used with Whoogle.\nIt can be run independently to generate and display UA strings on demand.\n\nUsage:\n    python misc/generate_uas.py [count]\n    \nArguments:\n    count: Number of UA strings to generate (default: 10)\n\nExamples:\n    python misc/generate_uas.py        # Generate 10 UAs\n    python misc/generate_uas.py 20     # Generate 20 UAs\n\"\"\"\n\nimport sys\nimport os\n\n# Default fallback UA if generation fails\nDEFAULT_FALLBACK_UA = \"Opera/9.30 (Nintendo Wii; U; ; 3642; en)\"\n\n# Try to import from the app module if available\ntry:\n    # Add parent directory to path to allow imports\n    sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))\n    from app.utils.ua_generator import generate_ua_pool\n    USE_APP_MODULE = True\nexcept ImportError:\n    USE_APP_MODULE = False\n    # Self-contained version if app module is not available\n    import random\n    \n    # Opera UA Pattern Templates\n    OPERA_PATTERNS = [\n        \"Opera/9.80 (J2ME/MIDP; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}\",\n        \"Opera/9.80 (Android; Linux; Opera Mobi/{build}; U; {lang}) Presto/{presto} Version/{final}\",\n        \"Opera/9.80 (iPhone; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}\",\n        \"Opera/9.80 (iPad; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}\",\n    ]\n    \n    OPERA_MINI_VERSIONS = [\n        \"4.0\", \"4.1.11321\", \"4.2.13337\", \"4.2.14912\", \"4.2.15410\", \"4.3.24214\",\n        \"5.0.18741\", \"5.1.22296\", \"5.1.22783\", \"6.0.24095\", \"6.24093\", \"7.1.32444\",\n        \"7.6.35766\", \"36.2.2254\"\n    ]\n    \n    OPERA_MOBI_BUILDS = [\n        \"27\", \"49\", \"447\", \"1209\", \"3730\", \"ADR-1012221546\", \"SYB-1107071606\"\n    ]\n    \n    BUILD_NUMBERS = [\n        \"22.387\", \"22.478\", \"23.334\", \"23.377\", \"24.746\", \"24.783\", \"25.657\",\n        \"27.1407\", \"28.2647\", \"35.5706\", \"119.132\", \"870\", \"886\"\n    ]\n    \n    PRESTO_VERSIONS = [\n        \"2.4.15\", \"2.4.18\", \"2.5.25\", \"2.8.119\", \"2.12.423\"\n    ]\n    \n    FINAL_VERSIONS = [\n        \"10.00\", \"10.1\", \"10.54\", \"11.10\", \"12.16\", \"13.00\"\n    ]\n    \n    LANGUAGES = [\n        # English variants\n        \"en\", \"en-US\", \"en-GB\", \"en-CA\", \"en-AU\", \"en-NZ\", \"en-ZA\", \"en-IN\", \"en-SG\",\n        # Western European\n        \"de\", \"de-DE\", \"de-AT\", \"de-CH\",\n        \"fr\", \"fr-FR\", \"fr-CA\", \"fr-BE\", \"fr-CH\", \"fr-LU\",\n        \"es\", \"es-ES\", \"es-MX\", \"es-AR\", \"es-CO\", \"es-CL\", \"es-PE\", \"es-VE\", \"es-LA\",\n        \"it\", \"it-IT\", \"it-CH\",\n        \"pt\", \"pt-PT\", \"pt-BR\",\n        \"nl\", \"nl-NL\", \"nl-BE\",\n        # Nordic languages\n        \"da\", \"da-DK\",\n        \"sv\", \"sv-SE\",\n        \"no\", \"no-NO\", \"nb\", \"nn\",\n        \"fi\", \"fi-FI\",\n        \"is\", \"is-IS\",\n        # Eastern European\n        \"pl\", \"pl-PL\",\n        \"cs\", \"cs-CZ\",\n        \"sk\", \"sk-SK\",\n        \"hu\", \"hu-HU\",\n        \"ro\", \"ro-RO\",\n        \"bg\", \"bg-BG\",\n        \"hr\", \"hr-HR\",\n        \"sr\", \"sr-RS\",\n        \"sl\", \"sl-SI\",\n        \"uk\", \"uk-UA\",\n        \"ru\", \"ru-RU\",\n        # Asian languages\n        \"zh\", \"zh-CN\", \"zh-TW\", \"zh-HK\",\n        \"ja\", \"ja-JP\",\n        \"ko\", \"ko-KR\",\n        \"th\", \"th-TH\",\n        \"vi\", \"vi-VN\",\n        \"id\", \"id-ID\",\n        \"ms\", \"ms-MY\",\n        \"fil\", \"tl\",\n        # Middle Eastern\n        \"tr\", \"tr-TR\",\n        \"ar\", \"ar-SA\", \"ar-AE\", \"ar-EG\",\n        \"he\", \"he-IL\",\n        \"fa\", \"fa-IR\",\n        # Other\n        \"hi\", \"hi-IN\",\n        \"bn\", \"bn-IN\",\n        \"ta\", \"ta-IN\",\n        \"te\", \"te-IN\",\n        \"mr\", \"mr-IN\",\n        \"el\", \"el-GR\",\n        \"ca\", \"ca-ES\",\n        \"eu\", \"eu-ES\"\n    ]\n    \n    def generate_opera_ua():\n        \"\"\"Generate a single random Opera User Agent string.\"\"\"\n        pattern = random.choice(OPERA_PATTERNS)\n        params = {'lang': random.choice(LANGUAGES)}\n        \n        if '{version}' in pattern:\n            params['version'] = random.choice(OPERA_MINI_VERSIONS)\n        if '{build}' in pattern:\n            if \"Opera Mobi\" in pattern:\n                params['build'] = random.choice(OPERA_MOBI_BUILDS)\n            else:\n                params['build'] = random.choice(BUILD_NUMBERS)\n        if '{presto}' in pattern:\n            params['presto'] = random.choice(PRESTO_VERSIONS)\n        if '{final}' in pattern:\n            params['final'] = random.choice(FINAL_VERSIONS)\n        \n        return pattern.format(**params)\n    \n    def generate_ua_pool(count=10):\n        \"\"\"Generate a pool of unique Opera User Agent strings.\"\"\"\n        ua_pool = set()\n        max_attempts = count * 100\n        attempts = 0\n        \n        try:\n            while len(ua_pool) < count and attempts < max_attempts:\n                ua = generate_opera_ua()\n                ua_pool.add(ua)\n                attempts += 1\n        except Exception:\n            # If generation fails entirely, return at least the default fallback\n            if not ua_pool:\n                return [DEFAULT_FALLBACK_UA]\n        \n        # If we couldn't generate enough, fill remaining with default\n        result = list(ua_pool)\n        while len(result) < count:\n            result.append(DEFAULT_FALLBACK_UA)\n        \n        return result\n\n\ndef main():\n    \"\"\"Main function to generate and display UA strings.\"\"\"\n    # Parse command line argument\n    count = 10  # Default\n    if len(sys.argv) > 1:\n        try:\n            count = int(sys.argv[1])\n            if count < 1:\n                print(\"Error: Count must be a positive integer\", file=sys.stderr)\n                sys.exit(1)\n        except ValueError:\n            print(f\"Error: Invalid count '{sys.argv[1]}'. Must be an integer.\", file=sys.stderr)\n            sys.exit(1)\n    \n    # Show which mode we're using (to stderr so it doesn't interfere with output)\n    if USE_APP_MODULE:\n        print(f\"# Using app.utils.ua_generator module\", file=sys.stderr)\n    else:\n        print(f\"# Using standalone generator (app module not available)\", file=sys.stderr)\n    \n    print(f\"# Generating {count} Opera User Agent strings...\\n\", file=sys.stderr)\n    \n    # Generate UAs\n    uas = generate_ua_pool(count)\n    \n    # Display them (one per line, no numbering)\n    for ua in uas:\n        print(ua)\n    \n    # Summary to stderr so it doesn't interfere with piping\n    print(f\"\\n# Generated {len(uas)} unique User Agent strings\", file=sys.stderr)\n\n\nif __name__ == '__main__':\n    main()\n\n"
  },
  {
    "path": "misc/heroku-regen.sh",
    "content": "#!/bin/bash\n# Assumes this is being executed from a session that has already logged\n# into Heroku with \"heroku login -i\" beforehand.\n# \n# You can set this up to run every night when you aren't using the\n# instance with a cronjob. For example:\n# 0 3 * * * /home/pi/whoogle-search/config/heroku-regen.sh <app_name>\n\nHEROKU_CLI_SITE=\"https://devcenter.heroku.com/articles/heroku-cli\"\n\nif ! [[ -x \"$(command -v heroku)\" ]]; then\n    echo \"Must have heroku cli installed: $HEROKU_CLI_SITE\"\n    exit 1\nfi\n\ncd \"$(builtin cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd -P)/../\"\n\nif [[ $# -ne 1 ]]; then\n    echo -e \"Must provide the name of the Whoogle instance to regenerate\"\n    exit 1\nfi\n\nAPP_NAME=\"$1\"\n\nheroku apps:destroy \"$APP_NAME\" --confirm \"$APP_NAME\"\nheroku apps:create \"$APP_NAME\"\nheroku container:login\nheroku container:push web\nheroku container:release web\n"
  },
  {
    "path": "misc/instances.txt",
    "content": "https://search.garudalinux.org\nhttps://search.sethforprivacy.com\nhttps://whoogle.privacydev.net\nhttps://wg.vern.cc\nhttps://whoogle.lunar.icu\nhttps://whoogle.4040940.xyz\n"
  },
  {
    "path": "misc/replit.py",
    "content": "import subprocess\n\n# A plague upon Replit and all who have built it\nreplit_cmd = \"killall -q python3 > /dev/null 2>&1; pip install -r requirements.txt && ./run\"\nsubprocess.run(replit_cmd, shell=True)\n"
  },
  {
    "path": "misc/tor/start-tor.sh",
    "content": "#!/bin/sh\n\nFF_STRING=\"FascistFirewall 1\"\n\nif [ \"$WHOOGLE_TOR_SERVICE\" == \"0\" ]; then\n    echo \"Skipping Tor startup...\"\n    exit 0\nfi\n\nif [ \"$WHOOGLE_TOR_FF\" == \"1\" ]; then\n    if (grep -q \"$FF_STRING\" /etc/tor/torrc); then\n        echo \"FascistFirewall feature already enabled.\"\n    else\n        echo \"$FF_STRING\" >> /etc/tor/torrc\n\n        if [ \"$?\" -eq 0 ]; then\n            echo \"FascistFirewall added to /etc/tor/torrc\"\n        else\n            echo \"ERROR: Unable to modify /etc/tor/torrc with $FF_STRING.\"\n            exit 1\n        fi\n    fi\nfi\n\nif [ \"$(whoami)\" != \"root\" ]; then\n    tor -f /etc/tor/torrc\nelse\n    if (grep alpine /etc/os-release >/dev/null); then\n        rc-service tor start\n    else\n        service tor start\n    fi\nfi\n"
  },
  {
    "path": "misc/tor/torrc",
    "content": "DataDirectory /var/lib/tor\nControlPort 9051\nCookieAuthentication 1\nDataDirectoryGroupReadable 1\nCookieAuthFileGroupReadable 1\nExtORPortCookieAuthFileGroupReadable 1\nCacheDirectoryGroupReadable 1\nCookieAuthFile /var/lib/tor/control_auth_cookie\nLog debug-notice file /dev/null\n# UseBridges 1\n# ClientTransportPlugin obfs4 exec /usr/bin/obfs4proxy\n# Bridge obfs4 ip and so on\n"
  },
  {
    "path": "misc/update-translations.py",
    "content": "import json\nimport pathlib\nimport httpx\n\nlingva = 'https://lingva.ml/api/v1/en'\n\n\ndef format_lang(lang: str) -> str:\n    # Chinese (traditional and simplified) require\n    # a different format for lingva translations\n    if 'zh-' in lang:\n        if lang == 'zh-TW':\n            return 'zh_HANT'\n        return 'zh'\n\n    # Strip lang prefix to leave only the actual\n    # language code (i.e. 'en', 'fr', etc)\n    return lang.replace('lang_', '')\n\n\ndef translate(v: str, lang: str) -> str:\n    # Strip lang prefix to leave only the actual\n    #language code (i.e. \"es\", \"fr\", etc)\n    lang = format_lang(lang)\n\n    lingva_req = f'{lingva}/{lang}/{v}'\n\n    response = httpx.get(lingva_req).json()\n\n    if 'translation' in response:\n        return response['translation']\n    return ''\n\n\nif __name__ == '__main__':\n    file_path = pathlib.Path(__file__).parent.resolve()\n    tl_path = 'app/static/settings/translations.json'\n\n    with open(f'{file_path}/../{tl_path}', 'r+', encoding='utf-8') as tl_file:\n        tl_data = json.load(tl_file)\n\n        # If there are any english translations that don't\n        # exist for other languages, extract them and translate\n        # them now\n        en_tl = tl_data['lang_en']\n        for k, v in en_tl.items():\n            for lang in tl_data:\n                if lang == 'lang_en' or k in tl_data[lang]:\n                    continue\n\n                translation = ''\n                if len(k) == 0:\n                    # Special case for placeholder text that gets used\n                    # for translations without any key present\n                    translation = v\n                else:\n                    # Translate the string using lingva\n                    translation = translate(v, lang)\n\n                if len(translation) == 0:\n                    print(f'! Unable to translate {lang}[{k}]')\n                    continue\n                print(f'{lang}[{k}] = {translation}')\n                tl_data[lang][k] = translation\n\n        # Write out updated translations json\n        print(json.dumps(tl_data, indent=4, ensure_ascii=False))\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.ruff]\nline-length = 100\ntarget-version = \"py312\"\nlint.select = [\n  \"E\", \"F\", \"W\",  # pycodestyle/pyflakes\n  \"I\",              # isort\n]\nlint.ignore = []\n\n[tool.black]\nline-length = 100\ntarget-version = ['py312']\n"
  },
  {
    "path": "requirements.txt",
    "content": "attrs==25.3.0\nbeautifulsoup4==4.13.5\nbrotli==1.2.0\ncertifi==2025.8.3\ncffi==2.0.0\nclick==8.3.0\ncryptography==46.0.1\ncssutils==2.11.1\ndefusedxml==0.7.1\nFlask==3.1.2\nidna==3.10\nitsdangerous==2.2.0\nJinja2==3.1.6\nMarkupSafe==3.0.2\nmore-itertools==10.8.0\npackaging==25.0\npluggy==1.6.0\npycodestyle==2.14.0\npycparser==2.22\npyOpenSSL==25.3.0\npyparsing==3.2.5\npytest==8.3.3\npython-dateutil==2.9.0.post0\nhttpx[http2,socks]==0.28.1\ncachetools==6.2.0\nsoupsieve==2.8\nstem==1.8.2\nhttpcore>=1.0.9\nh11>=0.16.0\nvalidators==0.35.0\nwaitress==3.0.2\nwcwidth==0.2.14\nWerkzeug==3.1.4\npython-dotenv==1.1.1\n"
  },
  {
    "path": "run",
    "content": "#!/bin/sh\n# Usage:\n# ./run # Runs the full web app\n# ./run test # Runs the testing suite\n\nset -e\n\nSCRIPT_DIR=\"$(CDPATH= command cd -- \"$(dirname -- \"$0\")\" && pwd -P)\"\n\n# Set directory to serve static content from\nSUBDIR=\"${1:-app}\"\nexport APP_ROOT=\"$SCRIPT_DIR/$SUBDIR\"\nexport STATIC_FOLDER=\"$APP_ROOT/static\"\n\n# Clear out build directory\nrm -f \"$SCRIPT_DIR\"/app/static/build/*.js\nrm -f \"$SCRIPT_DIR\"/app/static/build/*.css\n\n# Check for regular vs test run\nif [ \"$SUBDIR\" = \"test\" ]; then\n    # Set up static files for testing\n    rm -rf \"$STATIC_FOLDER\"\n    ln -s \"$SCRIPT_DIR/app/static\" \"$STATIC_FOLDER\"\n    pytest -sv\nelse\n    mkdir -p \"$STATIC_FOLDER\"\n\n    if [ ! -z \"$UNIX_SOCKET\" ]; then\n        python3 -um app \\\n          --unix-socket \"$UNIX_SOCKET\"\n    else\n        echo \"Running on http://${ADDRESS:-0.0.0.0}:${PORT:-\"${EXPOSE_PORT:-5000}\"}\"\n        python3 -um app \\\n          --host \"${ADDRESS:-0.0.0.0}\" \\\n          --port \"${PORT:-\"${EXPOSE_PORT:-5000}\"}\"\n    fi\nfi\n"
  },
  {
    "path": "setup.cfg",
    "content": "[metadata]\nname = whoogle-search\nversion = attr: app.version.__version__\nurl = https://github.com/benbusby/whoogle-search\ndescription = Self-hosted, ad-free, privacy-respecting metasearch engine\nlong_description = file: README.md\nlong_description_content_type = text/markdown\nkeywords = search, metasearch, flask, adblock, degoogle, privacy\nauthor = Ben Busby\nauthor_email = contact@benbusby.com\nlicense = MIT\nclassifiers =\n    Programming Language :: Python :: 3\n    License :: OSI Approved :: MIT License\n    Operating System :: OS Independent\n\n[options]\npackages = find:\ninclude_package_data = True\ninstall_requires=\n    beautifulsoup4\n    brotli\n    cssutils\n    cryptography\n    defusedxml\n    Flask\n    python-dotenv\n    httpx[http2,socks]\n    stem\n    validators\n    waitress\n\n[options.extras_require]\ntest =\n    pytest\n    python-dateutil\ndev = pycodestyle\n\n[options.packages.find]\nexclude =\n    test*\n\n[options.entry_points]\nconsole_scripts =\n    whoogle-search = app.routes:run_app\n"
  },
  {
    "path": "test/__init__.py",
    "content": ""
  },
  {
    "path": "test/conftest.py",
    "content": "from app import app\nfrom app.request import Request\nfrom app.utils.session import generate_key\nfrom test.mock_google import build_mock_response\nimport httpx\nimport pytest\nimport random\n\ndemo_config = {\n    'near': random.choice(['Seattle', 'New York', 'San Francisco']),\n    'nojs': str(random.getrandbits(1)),\n    'lang_interface': random.choice(app.config['LANGUAGES'])['value'],\n    'lang_search': random.choice(app.config['LANGUAGES'])['value'],\n    'country': random.choice(app.config['COUNTRIES'])['value']\n}\n\n\n@pytest.fixture(autouse=True)\ndef mock_google(monkeypatch):\n    original_send = Request.send\n\n    def fake_send(self, base_url='', query='', attempt=0,\n                  force_mobile=False, user_agent=''):\n        use_mock = not base_url or 'google.com/search' in base_url\n        if not use_mock:\n            return original_send(self, base_url, query, attempt,\n                                 force_mobile, user_agent)\n\n        html = build_mock_response(query, getattr(self, 'language', ''), getattr(self, 'country', ''))\n        request_url = (base_url or self.search_url) + query\n        request = httpx.Request('GET', request_url)\n        return httpx.Response(200, request=request, text=html)\n\n    def fake_autocomplete(self, q):\n        normalized = q.replace('+', ' ').lower()\n        suggestions = []\n        if 'green eggs and' in normalized:\n            suggestions.append('green eggs and ham')\n        if 'the cat in the' in normalized:\n            suggestions.append('the cat in the hat')\n        if normalized.startswith('who'):\n            suggestions.extend(['whoogle', 'whoogle search'])\n        return suggestions\n\n    monkeypatch.setattr(Request, 'send', fake_send)\n    monkeypatch.setattr(Request, 'autocomplete', fake_autocomplete)\n    yield\n\n\n@pytest.fixture\ndef client():\n    with app.test_client() as client:\n        with client.session_transaction() as session:\n            session['uuid'] = 'test'\n            session['key'] = app.enc_key\n            session['config'] = {}\n            session['auth'] = False\n        yield client\n"
  },
  {
    "path": "test/mock_google.py",
    "content": "from urllib.parse import parse_qs, unquote, quote\n\nfrom app.models.config import Config\n\nDEFAULT_RESULTS = [\n    ('Example Domain', 'https://example.com/{slug}', 'Example information about {term}.'),\n    ('Whoogle Search', 'https://github.com/benbusby/whoogle-search', 'Private self-hosted Google proxy'),\n    ('Wikipedia', 'https://en.wikipedia.org/wiki/{title}', '{title} – encyclopedia entry.'),\n]\n\n\ndef _result_block(title, href, snippet):\n    encoded_href = quote(href, safe=':/')\n    return (\n        f'<div class=\"ZINbbc xpd O9g5cc uUPGi\">'\n        f'<div class=\"kCrYT\">'\n        f'<a href=\"/url?q={encoded_href}&sa=U&ved=2ahUKE\">'\n        f'<h3 class=\"BNeawe vvjwJb AP7Wnd\">{title}</h3>'\n        f'<span class=\"CVA68e\">{title}</span>'\n        f'</a>'\n        f'<div class=\"VwiC3b\">{snippet}</div>'\n        f'</div>'\n        f'</div>'\n    )\n\n\ndef _main_results(query, params, language='', country=''):\n    term = query.lower()\n    slug = query.replace(' ', '-')\n    results = []\n\n    pref_lang = ''\n    pref_country = ''\n    if 'preferences' in params:\n        try:\n            pref_data = Config(**{})._decode_preferences(params['preferences'][0])\n            pref_lang = str(pref_data.get('lang_interface', '') or '').lower()\n            pref_country = str(pref_data.get('country', '') or '').lower()\n        except Exception:\n            pref_lang = pref_country = ''\n    else:\n        pref_lang = pref_country = ''\n\n    if 'wikipedia' in term:\n        hl = str(params.get('hl', [''])[0] or '').lower()\n        gl = str(params.get('gl', [''])[0] or '').lower()\n        lr = str(params.get('lr', [''])[0] or '').lower()\n        language_code = str(language or '').lower()\n        country_code = str(country or '').lower()\n        is_japanese = (\n            hl.startswith('ja') or\n            gl.startswith('jp') or\n            lr.endswith('lang_ja') or\n            language_code.endswith('lang_ja') or\n            country_code.startswith('jp') or\n            pref_lang.endswith('lang_ja') or\n            pref_country.startswith('jp')\n        )\n        if is_japanese:\n            results.append((\n                'ウィキペディア',\n                'https://ja.wikipedia.org/wiki/ウィキペディア',\n                '日本語版ウィキペディアの記事です。'\n            ))\n        else:\n            results.append((\n                'Wikipedia',\n                'https://www.wikipedia.org/wiki/Wikipedia',\n                'Wikipedia is a free online encyclopedia.'\n            ))\n\n    if 'pinterest' in term:\n        results.append((\n            'Pinterest',\n            'https://www.pinterest.com/ideas/',\n            'Discover recipes, home ideas, style inspiration and other ideas.'\n        ))\n\n    if 'whoogle' in term:\n        results.append((\n            'Whoogle Search GitHub',\n            'https://github.com/benbusby/whoogle-search',\n            'Source code for Whoogle Search.'\n        ))\n\n    if 'github' in term:\n        results.append((\n            'GitHub',\n            f'https://github.com/search?q={slug}',\n            'GitHub is a development platform to host and review code.'\n        ))\n\n    for title, url, snippet in DEFAULT_RESULTS:\n        formatted_url = url.format(slug=slug, term=term, title=title.replace(' ', '_'))\n        formatted_snippet = snippet.format(term=query, title=title)\n        results.append((title, formatted_url, formatted_snippet))\n\n    unique = []\n    seen = set()\n    for entry in results:\n        if entry[1] in seen:\n            continue\n        seen.add(entry[1])\n        unique.append(entry)\n\n    return ''.join(_result_block(*entry) for entry in unique)\n\n\ndef build_mock_response(raw_query, language='', country=''):\n    if '&' in raw_query:\n        q_part, extra = raw_query.split('&', 1)\n    else:\n        q_part, extra = raw_query, ''\n\n    query = unquote(q_part)\n    params = parse_qs(extra)\n\n    results_html = _main_results(query, params, language, country)\n    safe_query = query.replace('\"', '')\n    pagination = (\n        f'<a href=\"/search?q={q_part}&start=10\">Next</a>'\n        f'<a href=\"/search?q={q_part}&start=20\">More</a>'\n    )\n\n    return (\n        '<html>'\n        '<head><title>Mock Google Results</title></head>'\n        '<body>'\n        f'<div id=\"main\">{results_html}</div>'\n        f'<form action=\"/search\" method=\"GET\">'\n        f'<input name=\"q\" value=\"{safe_query}\">'\n        '</form>'\n        f'<footer class=\"TuS8Ad\">{pagination}</footer>'\n        '</body>'\n        '</html>'\n    )\n"
  },
  {
    "path": "test/test_alts.py",
    "content": "import copy\nimport os\n\nfrom bs4 import BeautifulSoup\n\nfrom app import app\nfrom app.filter import Filter\nfrom app.models.config import Config\nfrom app.utils.session import generate_key\nfrom app.utils import results as results_mod\n\n\ndef build_soup(html: str):\n    return BeautifulSoup(html, 'html.parser')\n\n\ndef make_filter(soup: BeautifulSoup):\n    secret_key = generate_key()\n    with app.app_context():\n        cfg = Config(**{'alts': True})\n    f = Filter(user_key=secret_key, config=cfg)\n    f.soup = soup\n    return f\n\n\ndef test_no_duplicate_alt_prefix_reddit(monkeypatch):\n    original_site_alts = copy.deepcopy(results_mod.SITE_ALTS)\n    try:\n        # Simulate user setting alt to old.reddit.com\n        monkeypatch.setitem(results_mod.SITE_ALTS, 'reddit.com', 'old.reddit.com')\n\n        html = '''\n        <div id=\"main\">\n          <a href=\"https://www.reddit.com/r/whoogle\">www.reddit.com</a>\n          <div>www.reddit.com</div>\n          <div>old.reddit.com</div>\n        </div>\n        '''\n        soup = build_soup(html)\n        f = make_filter(soup)\n        f.site_alt_swap()\n\n        # Href replaced once\n        a = soup.find('a')\n        assert a['href'].startswith('https://old.reddit.com')\n\n        # Bare domain replaced, but already-alt text stays unchanged (no old.old...)\n        divs = [d.get_text() for d in soup.find_all('div') if d.get_text().strip()]\n        assert 'old.reddit.com' in divs\n        assert 'old.old.reddit.com' not in ''.join(divs)\n    finally:\n        results_mod.SITE_ALTS.clear()\n        results_mod.SITE_ALTS.update(original_site_alts)\n\n\ndef test_wikipedia_simple_no_lang_param(monkeypatch):\n    original_site_alts = copy.deepcopy(results_mod.SITE_ALTS)\n    try:\n        monkeypatch.setitem(results_mod.SITE_ALTS, 'wikipedia.org', 'https://wikiless.example')\n\n        html = '''\n        <div id=\"main\">\n          <a href=\"https://simple.wikipedia.org/wiki/Whoogle\">https://simple.wikipedia.org/wiki/Whoogle</a>\n          <div>simple.wikipedia.org</div>\n        </div>\n        '''\n        soup = build_soup(html)\n        f = make_filter(soup)\n        f.site_alt_swap()\n\n        a = soup.find('a')\n        # Should be rewritten to the alt host, without ?lang\n        assert a['href'].startswith('https://wikiless.example')\n        assert '?lang=' not in a['href']\n\n        # Description host replaced once\n        text = soup.find('div').get_text()\n        assert 'wikiless.example' in text\n        assert 'simple.wikipedia.org' not in text\n    finally:\n        results_mod.SITE_ALTS.clear()\n        results_mod.SITE_ALTS.update(original_site_alts)\n\n\ndef test_single_pass_description_replacement(monkeypatch):\n    original_site_alts = copy.deepcopy(results_mod.SITE_ALTS)\n    try:\n        monkeypatch.setitem(results_mod.SITE_ALTS, 'twitter.com', 'https://nitter.example')\n\n        html = '''\n        <div id=\"main\">\n          <a href=\"https://twitter.com/whoogle\">https://twitter.com/whoogle</a>\n          <div>https://www.twitter.com</div>\n        </div>\n        '''\n        soup = build_soup(html)\n        f = make_filter(soup)\n        f.site_alt_swap()\n\n        a = soup.find('a')\n        assert a['href'].startswith('https://nitter.example')\n\n        # Ensure description got host swapped once, no double scheme or duplication\n        main_div = soup.find('div', id='main')\n        # The description div is the first inner div under #main in this fixture\n        text = main_div.find_all('div')[0].get_text().strip()\n        assert text.startswith('https://nitter.example')\n        assert 'https://https://' not in text\n        assert 'nitter.examplenitter.example' not in text\n    finally:\n        results_mod.SITE_ALTS.clear()\n        results_mod.SITE_ALTS.update(original_site_alts)\n\n\n"
  },
  {
    "path": "test/test_autocomplete.py",
    "content": "from app.models.endpoint import Endpoint\n\n\ndef test_autocomplete_get(client):\n    rv = client.get(f'/{Endpoint.autocomplete}?q=green+eggs+and')\n    assert rv._status_code == 200\n    assert len(rv.data) >= 1\n    assert b'green eggs and ham' in rv.data\n\n\ndef test_autocomplete_post(client):\n    rv = client.post(f'/{Endpoint.autocomplete}',\n                     data=dict(q='the+cat+in+the'))\n    assert rv._status_code == 200\n    assert len(rv.data) >= 1\n    assert b'the cat in the hat' in rv.data\n"
  },
  {
    "path": "test/test_autocomplete_xml.py",
    "content": "from app import app\nfrom app.request import Request\nfrom app.models.config import Config\n\n\nclass FakeHttpClient:\n    def get(self, url, headers=None, cookies=None, retries=0, backoff_seconds=0.5, use_cache=False):\n        # Minimal XML in Google Toolbar Autocomplete format\n        xml = (\n            '<?xml version=\"1.0\"?>\\n'\n            '<topp>\\n'\n            '  <CompleteSuggestion><suggestion data=\"whoogle\"/></CompleteSuggestion>\\n'\n            '  <CompleteSuggestion><suggestion data=\"whoogle search\"/></CompleteSuggestion>\\n'\n            '</topp>'\n        )\n        class R:\n            text = xml\n        return R()\n\n    def close(self):\n        pass\n\n\ndef test_autocomplete_parsing():\n    with app.app_context():\n        cfg = Config(**{})\n    req = Request(normal_ua='UA', root_path='http://localhost:5000', config=cfg, http_client=FakeHttpClient())\n    suggestions = req.autocomplete('who')\n    assert 'whoogle' in suggestions\n    assert 'whoogle search' in suggestions\n\n"
  },
  {
    "path": "test/test_http_client.py",
    "content": "import types\n\nimport httpx\nimport pytest\n\nfrom app.services.http_client import HttpxClient\n\n\ndef test_httpxclient_follow_redirects_and_proxy(monkeypatch):\n    calls = []\n\n    class FakeClient:\n        def __init__(self, *args, **kwargs):\n            calls.append(kwargs)\n        def get(self, *args, **kwargs):\n            class R:\n                status_code = 200\n                text = ''\n            return R()\n        def close(self):\n            pass\n\n    monkeypatch.setattr(httpx, 'Client', FakeClient)\n\n    proxies = {'http': 'socks5://127.0.0.1:9050', 'https': 'socks5://127.0.0.1:9050'}\n    client = HttpxClient(proxies=proxies)\n\n    # Ensure the constructor attempted to set follow_redirects and one of proxy/proxies\n    assert len(calls) == 1\n    kwargs = calls[0]\n    assert kwargs.get('follow_redirects') is True\n    assert ('proxy' in kwargs) or ('proxies' in kwargs) or ('mounts' in kwargs)\n\n"
  },
  {
    "path": "test/test_json.py",
    "content": "import json\nimport types\n\nimport pytest\n\nfrom app.models.endpoint import Endpoint\nfrom app.utils import search as search_mod\n\n\n@pytest.fixture\ndef stubbed_search_response(monkeypatch):\n    # Stub Search.new_search_query to return a stable query\n    def fake_new_query(self):\n        self.query = 'whoogle'\n        return self.query\n\n    # Return a minimal filtered HTML snippet with a couple of links\n    html = (\n        '<div id=\"main\">'\n        '  <a href=\"https://example.com/page\">Example Page</a>'\n        '  <a href=\"/relative\">Relative</a>'\n        '  <a href=\"https://example.org/other\">Other</a>'\n        '</div>'\n    )\n\n    def fake_generate(self):\n        return html\n\n    monkeypatch.setattr(search_mod.Search, 'new_search_query', fake_new_query)\n    monkeypatch.setattr(search_mod.Search, 'generate_response', fake_generate)\n\n\ndef test_search_json_accept(client, stubbed_search_response):\n    rv = client.get(f'/{Endpoint.search}?q=whoogle', headers={'Accept': 'application/json'})\n    assert rv._status_code == 200\n    data = json.loads(rv.data)\n    assert data['query'] == 'whoogle'\n    assert isinstance(data['results'], list)\n    hrefs = {item['href'] for item in data['results']}\n    assert 'https://example.com/page' in hrefs\n    assert 'https://example.org/other' in hrefs\n    # Relative href should be excluded\n    assert not any(href.endswith('/relative') for href in hrefs)\n    # Verify new fields are present while maintaining backward compatibility\n    for result in data['results']:\n        assert 'href' in result\n        assert 'text' in result  # Original field maintained\n        assert 'title' in result  # New field\n        assert 'content' in result  # New field\n\n\ndef test_search_json_format_param(client, stubbed_search_response):\n    rv = client.get(f'/{Endpoint.search}?q=whoogle&format=json')\n    assert rv._status_code == 200\n    data = json.loads(rv.data)\n    assert data['query'] == 'whoogle'\n    assert len(data['results']) >= 2\n\n\ndef test_search_json_feeling_lucky(client, monkeypatch):\n    # Force query to be interpreted as feeling lucky and return a redirect URL\n    def fake_new_query(self):\n        self.query = 'whoogle !'\n        # emulate behavior of new_search_query setting feeling_lucky\n        self.feeling_lucky = True\n        return self.query\n\n    def fake_generate(self):\n        return 'https://example.com/lucky'\n\n    monkeypatch.setattr(search_mod.Search, 'new_search_query', fake_new_query)\n    monkeypatch.setattr(search_mod.Search, 'generate_response', fake_generate)\n\n    rv = client.get(f'/{Endpoint.search}?q=whoogle%20!', headers={'Accept': 'application/json'})\n    assert rv._status_code == 303\n    data = json.loads(rv.data)\n    assert data['redirect'] == 'https://example.com/lucky'\n\n\n"
  },
  {
    "path": "test/test_misc.py",
    "content": "from cryptography.fernet import Fernet\n\nfrom app import app\nfrom app.models.endpoint import Endpoint\nfrom app.utils.session import generate_key, valid_user_session\n\nJAPAN_PREFS = 'uG7IBICwK7FgMJNpUawp2tKDb1Omuv_euy-cJHVZ' \\\n  + 'BSydthgwxRFIHxiVA8qUGavKaDXyiM5uNuPIjKbEAW-zB_vzNXWVaafFhW7k2' \\\n  + 'fO2_mS5e5eK41XXWwiViTz2VVmGWje0UgQwwVPe1A7aH0s10FgARsd2xl5nlg' \\\n  + 'RLHT2krPUw-iLQ5uHZSnYXFuF4caYemWcj4vqB2ocHkt-aqn04jgnnlWWME_K' \\\n  + '9ySWdWmPyS66HtLt1tCwc_-xGZklvbHw=='\n\n\ndef test_generate_user_keys():\n    key = generate_key()\n    assert Fernet(key)\n    assert generate_key() != key\n\n\ndef test_valid_session(client):\n    assert not valid_user_session({'key': '', 'config': {}})\n    with client.session_transaction() as session:\n        assert valid_user_session(session)\n\n\ndef test_valid_translation_keys(client):\n    valid_lang_keys = [_['value'] for _ in app.config['LANGUAGES']]\n    en_keys = app.config['TRANSLATIONS']['lang_en'].keys()\n    for translation_key in app.config['TRANSLATIONS']:\n        # Ensure the translation is using a valid language value\n        assert translation_key in valid_lang_keys\n\n        # Ensure all translations match the same size/content of the original\n        # English translation\n        assert app.config['TRANSLATIONS'][translation_key].keys() == en_keys\n\n\ndef test_query_decryption(client):\n    # FIXME: Handle decryption errors in search.py and rewrite test\n    # This previously was used to test swapping decryption keys between\n    # queries. While this worked in theory and usually didn't cause problems,\n    # they were tied to session IDs and those are really unreliable (meaning\n    # that occasionally page navigation would break).\n    rv = client.get('/')\n    cookie = rv.headers['Set-Cookie']\n\n    rv = client.get(f'/{Endpoint.search}?q=test+1', headers={'Cookie': cookie})\n    assert rv._status_code == 200\n\n    with client.session_transaction() as session:\n        assert valid_user_session(session)\n\n    rv = client.get(f'/{Endpoint.search}?q=test+2', headers={'Cookie': cookie})\n    assert rv._status_code == 200\n\n    with client.session_transaction() as session:\n        assert valid_user_session(session)\n\n\ndef test_prefs_url(client):\n    base_url = f'/{Endpoint.search}?q=wikipedia'\n    rv = client.get(base_url)\n    assert rv._status_code == 200\n    assert b'wikipedia.org' in rv.data\n    assert b'ja.wikipedia.org' not in rv.data\n\n    rv = client.get(f'{base_url}&preferences={JAPAN_PREFS}')\n    assert rv._status_code == 200\n    assert b'ja.wikipedia.org' in rv.data\n\n"
  },
  {
    "path": "test/test_results.py",
    "content": "from bs4 import BeautifulSoup\nfrom app.filter import Filter\nfrom app.models.config import Config\nfrom app.models.endpoint import Endpoint\nfrom app.utils import results\nfrom app.utils import search as search_mod\nfrom app.utils.session import generate_key\nfrom datetime import datetime\nfrom dateutil.parser import ParserError, parse\nfrom urllib.parse import urlparse\n\nfrom test.conftest import demo_config\n\n\ndef get_search_results(data):\n    secret_key = generate_key()\n    soup = Filter(user_key=secret_key, config=Config(**demo_config)).clean(\n        BeautifulSoup(data, 'html.parser'))\n\n    main_divs = soup.find('div', {'id': 'main'})\n    assert len(main_divs) > 1\n\n    result_divs = []\n    for div in main_divs:\n        # Result divs should only have 1 inner div\n        if (len(list(div.children)) != 1\n                or not div.findChild()\n                or 'div' not in div.findChild().name):\n            continue\n\n        result_divs.append(div)\n\n    return result_divs\n\n\ndef test_get_results(client, monkeypatch):\n    def fake_generate(self):\n        # Build 10 results under #main, each with a single inner div\n        items = []\n        for i in range(10):\n            items.append(f'<div><div><a href=\"https://example.com/{i}\">Item {i}</a></div></div>')\n        return f'<div id=\"main\">{\"\".join(items)}</div>'\n\n    monkeypatch.setattr(search_mod.Search, 'generate_response', fake_generate)\n\n    rv = client.get(f'/{Endpoint.search}?q=test')\n    assert rv._status_code == 200\n\n    # Depending on the search, there can be more\n    # than 10 result divs\n    results_divs = get_search_results(rv.data)\n    assert len(results_divs) >= 10\n    assert len(results_divs) <= 15\n\n\ndef test_post_results(client):\n    rv = client.post(f'/{Endpoint.search}', data=dict(q='test'))\n    assert rv._status_code == 302\n\n\ndef test_translate_search(client):\n    rv = client.get(f'/{Endpoint.search}?q=translate hola')\n    assert rv._status_code == 200\n\n    # Pretty weak test, but better than nothing\n    str_data = str(rv.data)\n    assert 'iframe' in str_data\n    assert '/auto/en/ hola' in str_data\n\n\ndef test_block_results(client):\n    rv = client.get(f'/{Endpoint.search}?q=pinterest')\n    assert rv._status_code == 200\n\n    has_pinterest = False\n    for link in BeautifulSoup(rv.data, 'html.parser').find_all('a', href=True):\n        if 'pinterest.com' in urlparse(link['href']).netloc:\n            has_pinterest = True\n            break\n\n    assert has_pinterest\n\n    demo_config['block'] = 'pinterest.com'\n    rv = client.post(f'/{Endpoint.config}', data=demo_config)\n    assert rv._status_code == 302\n\n    rv = client.get(f'/{Endpoint.search}?q=pinterest')\n    assert rv._status_code == 200\n\n    for link in BeautifulSoup(rv.data, 'html.parser').find_all('a', href=True):\n        result_site = urlparse(link['href']).netloc\n        if not result_site:\n            continue\n        assert result_site not in 'pinterest.com'\n\n\ndef test_view_my_ip(client, monkeypatch):\n    def fake_generate(self):\n        # Minimal page; ip card is injected later by routes when widget == 'ip'\n        return '<div id=\"main\"></div>'\n\n    monkeypatch.setattr(search_mod.Search, 'generate_response', fake_generate)\n\n    rv = client.get(f'/{Endpoint.search}?q=my ip address')\n    assert rv._status_code == 200\n\n    # Pretty weak test, but better than nothing\n    str_data = str(rv.data)\n    assert 'Your public IP address' in str_data\n    assert '127.0.0.1' in str_data\n\n\ndef test_recent_results(client, monkeypatch):\n    def fake_generate(self):\n        # Create results with a span containing today's date so it passes all windows\n        today = datetime.now().strftime('%b %d, %Y')\n        items = []\n        for i in range(5):\n            items.append(f'<div><div><span>{today}</span></div></div>')\n        return f'<div id=\"main\">{\"\".join(items)}</div>'\n\n    monkeypatch.setattr(search_mod.Search, 'generate_response', fake_generate)\n\n    times = {\n        'tbs=qdr:y': 365,\n        'tbs=qdr:m': 31,\n        'tbs=qdr:w': 7\n    }\n\n    for time, num_days in times.items():\n        rv = client.get(f'/{Endpoint.search}?q=test&' + time)\n        result_divs = get_search_results(rv.data)\n\n        current_date = datetime.now()\n        for div in [_ for _ in result_divs if _.find('span')]:\n            date_span = div.find('span').decode_contents()\n            if not date_span or len(date_span) > 15 or len(date_span) < 7:\n                continue\n\n            try:\n                date = parse(date_span)\n                # Date can have a little bit of wiggle room\n                assert (current_date - date).days <= (num_days + 5)\n            except ParserError:\n                pass\n\n\ndef test_leading_slash_search(client):\n    # Ensure searches with a leading slash are interpreted\n    # correctly as queries and not endpoints\n    q = '/test'\n    rv = client.get(f'/{Endpoint.search}?q={q}')\n    assert rv._status_code == 200\n\n    soup = Filter(\n        user_key=generate_key(),\n        config=Config(**demo_config),\n        query=q\n    ).clean(BeautifulSoup(rv.data, 'html.parser'))\n\n    for link in soup.find_all('a', href=True):\n        if 'start=' not in link['href']:\n            continue\n\n        assert link['href'].startswith(f'{Endpoint.search}')\n\n\ndef test_site_alt_prefix_skip():\n    # Ensure prefixes are skipped correctly for site alts\n\n    # default silte_alts (farside.link)\n    assert results.get_site_alt(link = 'https://www.reddit.com') == 'https://farside.link/libreddit'\n    assert results.get_site_alt(link = 'https://www.twitter.com') == 'https://farside.link/nitter'\n    assert results.get_site_alt(link = 'https://www.youtube.com') == 'https://farside.link/invidious'\n\n    test_site_alts = {\n    'reddit.com': 'reddit.endswithmobile.domain',\n    'twitter.com': 'https://twitter.endswithm.domain',\n    'youtube.com': 'http://yt.endswithwww.domain',\n    }\n    # Domains with part of SKIP_PREFIX in them\n    assert results.get_site_alt(link = 'https://www.reddit.com', site_alts = test_site_alts) == 'https://reddit.endswithmobile.domain'\n    assert results.get_site_alt(link = 'https://www.twitter.com', site_alts = test_site_alts) == 'https://twitter.endswithm.domain'\n    assert results.get_site_alt(link = 'https://www.youtube.com', site_alts = test_site_alts) == 'http://yt.endswithwww.domain'\n"
  },
  {
    "path": "test/test_routes.py",
    "content": "from app import app\nfrom app.models.endpoint import Endpoint\n\nimport json\n\nfrom test.conftest import demo_config\n\n\ndef test_main(client):\n    rv = client.get('/')\n    assert rv._status_code == 200\n\n\ndef test_search(client):\n    rv = client.get(f'/{Endpoint.search}?q=test')\n    assert rv._status_code == 200\n\n\ndef test_feeling_lucky(client):\n    # Bang at beginning of query\n    rv = client.get(f'/{Endpoint.search}?q=!%20wikipedia')\n    assert rv._status_code == 303\n    assert rv.headers.get('Location').startswith('https://www.wikipedia.org')\n\n    # Move bang to end of query\n    rv = client.get(f'/{Endpoint.search}?q=github%20!')\n    assert rv._status_code == 303\n    assert rv.headers.get('Location').startswith('https://github.com')\n\n\ndef test_ddg_bang(client):\n    # Bang at beginning of query\n    rv = client.get(f'/{Endpoint.search}?q=!gh%20whoogle')\n    assert rv._status_code == 302\n    assert rv.headers.get('Location').startswith('https://github.com')\n\n    # Move bang to end of query\n    rv = client.get(f'/{Endpoint.search}?q=github%20!w')\n    assert rv._status_code == 302\n    assert rv.headers.get('Location').startswith('https://en.wikipedia.org')\n\n    # Move bang to middle of query\n    rv = client.get(f'/{Endpoint.search}?q=big%20!r%20chungus')\n    assert rv._status_code == 302\n    assert rv.headers.get('Location').startswith('https://www.reddit.com')\n\n    # Ensure bang is case insensitive\n    rv = client.get(f'/{Endpoint.search}?q=!GH%20whoogle')\n    assert rv._status_code == 302\n    assert rv.headers.get('Location').startswith('https://github.com')\n\n    # Ensure bang without a query still redirects to the result\n    rv = client.get(f'/{Endpoint.search}?q=!gh')\n    assert rv._status_code == 302\n    assert rv.headers.get('Location').startswith('https://github.com')\n\n\ndef test_custom_bang(client):\n    # Bang at beginning of query\n    rv = client.get(f'/{Endpoint.search}?q=!i%20whoogle')\n    assert rv._status_code == 302\n    assert rv.headers.get('Location').startswith('search?q=')\n\n\ndef test_config(client):\n    rv = client.post(f'/{Endpoint.config}', data=demo_config)\n    assert rv._status_code == 302\n\n    rv = client.get(f'/{Endpoint.config}')\n    assert rv._status_code == 200\n\n    config = json.loads(rv.data)\n    for key in demo_config.keys():\n        assert config[key] == demo_config[key]\n\n    # Test disabling changing config from client\n    app.config['CONFIG_DISABLE'] = 1\n    nojs_mod = not bool(int(demo_config['nojs']))\n    demo_config['nojs'] = str(int(nojs_mod))\n    rv = client.post(f'/{Endpoint.config}', data=demo_config)\n    assert rv._status_code == 403\n\n    rv = client.get(f'/{Endpoint.config}')\n    config = json.loads(rv.data)\n    assert config['nojs'] != nojs_mod\n\n\ndef test_opensearch(client):\n    rv = client.get(f'/{Endpoint.opensearch}')\n    assert rv._status_code == 200\n    assert '<ShortName>Whoogle</ShortName>' in str(rv.data)\n"
  },
  {
    "path": "test/test_routes_json.py",
    "content": "import json\n\nimport pytest\n\nfrom app.models.endpoint import Endpoint\nfrom app.utils import search as search_mod\n\n\ndef test_captcha_json_block(client, monkeypatch):\n    def fake_new_query(self):\n        self.query = 'test'\n        return self.query\n\n    def fake_generate(self):\n        # Inject a captcha marker into HTML so route returns 503 JSON\n        return '<div>div class=\"g-recaptcha\"</div>'\n\n    monkeypatch.setattr(search_mod.Search, 'new_search_query', fake_new_query)\n    monkeypatch.setattr(search_mod.Search, 'generate_response', fake_generate)\n\n    rv = client.get(f'/{Endpoint.search}?q=test&format=json')\n    assert rv._status_code == 503\n    data = json.loads(rv.data)\n    assert data['blocked'] is True\n    assert 'error_message' in data\n\n"
  },
  {
    "path": "test/test_tor.py",
    "content": "import pytest\n\nfrom app import app\nfrom app.request import Request, TorError\nfrom app.models.config import Config\n\n\nclass FakeResponse:\n    def __init__(self, text: str = '', status_code: int = 200, content: bytes = b''):\n        self.text = text\n        self.status_code = status_code\n        self.content = content or b''\n\n\nclass FakeHttpClient:\n    def __init__(self, tor_ok: bool):\n        self._tor_ok = tor_ok\n\n    def get(self, url, headers=None, cookies=None, retries=0, backoff_seconds=0.5, use_cache=False):\n        if 'check.torproject.org' in url:\n            return FakeResponse(text=('Congratulations' if self._tor_ok else 'Not Tor'))\n        return FakeResponse(text='', status_code=200, content=b'OK')\n\n    def close(self):\n        pass\n\n\ndef build_config(tor: bool) -> Config:\n    # Minimal config with tor flag\n    with app.app_context():\n        return Config(**{'tor': tor})\n\n\ndef test_tor_validation_success(monkeypatch):\n    # Prevent real Tor signal attempts\n    monkeypatch.setattr('app.request.send_tor_signal', lambda signal: True)\n    cfg = build_config(tor=True)\n    req = Request(normal_ua='TestUA', root_path='http://localhost:5000', config=cfg, http_client=FakeHttpClient(tor_ok=True))\n    # Avoid sending a Tor NEWNYM/HEARTBEAT in unit tests by setting attempt>0 false path\n    resp = req.send(base_url='https://example.com', query='')\n    assert req.tor_valid is True\n    assert resp.status_code == 200\n\n\ndef test_tor_validation_failure(monkeypatch):\n    # Prevent real Tor signal attempts\n    monkeypatch.setattr('app.request.send_tor_signal', lambda signal: True)\n    cfg = build_config(tor=True)\n    req = Request(normal_ua='TestUA', root_path='http://localhost:5000', config=cfg, http_client=FakeHttpClient(tor_ok=False))\n    with pytest.raises(TorError):\n        _ = req.send(base_url='https://example.com', query='')\n\n"
  },
  {
    "path": "whoogle.template.env",
    "content": "# ----------------------------------\n# Rename to \"whoogle.env\" before use\n# ----------------------------------\n# You can set Whoogle environment variables here, but must\n# modify your deployment to enable these values:\n#  - Local: Set WHOOGLE_DOTENV=1\n#  - docker-compose: Uncomment the env_file option\n#  - docker: Add \"--env-file ./whoogle.env\" to your build command\n\n#WHOOGLE_ALT_TW=farside.link/nitter\n#WHOOGLE_ALT_YT=farside.link/invidious\n#WHOOGLE_ALT_IG=farside.link/bibliogram/u\n#WHOOGLE_ALT_RD=farside.link/libreddit\n#WHOOGLE_ALT_MD=farside.link/scribe\n#WHOOGLE_ALT_TL=farside.link/lingva\n#WHOOGLE_ALT_IMG=farside.link/rimgo\n#WHOOGLE_ALT_WIKI=farside.link/wikiless\n#WHOOGLE_ALT_IMDB=farside.link/libremdb\n#WHOOGLE_ALT_QUORA=farside.link/quetre\n#WHOOGLE_ALT_SO=farside.link/anonymousoverflow\n#WHOOGLE_USER=\"\"\n#WHOOGLE_PASS=\"\"\n#WHOOGLE_PROXY_USER=\"\"\n#WHOOGLE_PROXY_PASS=\"\"\n#WHOOGLE_PROXY_TYPE=\"\"\n#WHOOGLE_PROXY_LOC=\"\"\n#WHOOGLE_CSP=1\n#HTTPS_ONLY=1\n\n# The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")\n#WHOOGLE_URL_PREFIX=\"\"\n\n# Restrict results to only those near a particular city\n#WHOOGLE_CONFIG_NEAR=denver\n\n# See app/static/settings/countries.json for values\n#WHOOGLE_CONFIG_COUNTRY=US\n\n# See app/static/settings/languages.json for values\n#WHOOGLE_CONFIG_LANGUAGE=lang_en\n\n# See app/static/settings/languages.json for values\n#WHOOGLE_CONFIG_SEARCH_LANGUAGE=lang_en\n\n# Disable changing of config from client\n#WHOOGLE_CONFIG_DISABLE=1\n\n# Block websites from search results (comma-separated list)\n#WHOOGLE_CONFIG_BLOCK=pinterest.com,whitehouse.gov\n\n# Theme (light, dark, or system)\n#WHOOGLE_CONFIG_THEME=system\n\n# Safe search mode\n#WHOOGLE_CONFIG_SAFE=1\n\n# Use social media site alternatives (nitter, bibliogram, etc)\n#WHOOGLE_CONFIG_ALTS=1\n\n# Use Tor if available\n#WHOOGLE_CONFIG_TOR=1\n\n# Open results in new tab\n#WHOOGLE_CONFIG_NEW_TAB=1\n\n# Enable View Image option\n#WHOOGLE_CONFIG_VIEW_IMAGE=1\n\n# Search using GET requests only (exposes query in logs)\n#WHOOGLE_CONFIG_GET_ONLY=1\n\n# Remove everything except basic result cards from all search queries\n#WHOOGLE_MINIMAL=0\n\n# Controls visibility of autocomplete/search suggestions\n#WHOOGLE_AUTOCOMPLETE=1\n\n# The port where Whoogle will be exposed\n#EXPOSE_PORT=5000\n\n# Set instance URL\n#WHOOGLE_CONFIG_URL=https://<whoogle url>/\n\n# Set custom CSS styling/theming\n#WHOOGLE_CONFIG_STYLE=\":root { /* LIGHT THEME COLORS */ --whoogle-background: #d8dee9; --whoogle-accent: #2e3440; --whoogle-text: #3B4252; --whoogle-contrast-text: #eceff4; --whoogle-secondary-text: #70757a; --whoogle-result-bg: #fff; --whoogle-result-title: #4c566a; --whoogle-result-url: #81a1c1; --whoogle-result-visited: #a3be8c; /* DARK THEME COLORS */ --whoogle-dark-background: #222; --whoogle-dark-accent: #685e79; --whoogle-dark-text: #fff; --whoogle-dark-contrast-text: #000; --whoogle-dark-secondary-text: #bbb; --whoogle-dark-result-bg: #000; --whoogle-dark-result-title: #1967d2; --whoogle-dark-result-url: #4b11a8; --whoogle-dark-result-visited: #bbbbff; }\"\n\n# Enable preferences encryption (requires key)\n#WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED=1\n\n# Set Key to encode config in url\n#WHOOGLE_CONFIG_PREFERENCES_KEY=\"NEEDS_TO_BE_MODIFIED\""
  }
]