Compare commits

..

No commits in common. "main" and "v0.5.1" have entirely different histories.
main ... v0.5.1

114 changed files with 1696 additions and 11115 deletions

View File

@ -1,3 +1,2 @@
.git/
venv/
test/

View File

@ -1,38 +0,0 @@
---
name: New theme
about: Create a new theme for Whoogle
title: "[THEME] <your theme name>"
labels: theme
assignees: benbusby
---
Use the following template to design your theme, replacing the blank spaces with the colors of your choice.
```css
:root {
/* LIGHT THEME COLORS */
--whoogle-logo: #______;
--whoogle-page-bg: #______;
--whoogle-element-bg: #______;
--whoogle-text: #______;
--whoogle-contrast-text: #______;
--whoogle-secondary-text: #______;
--whoogle-result-bg: #______;
--whoogle-result-title: #______;
--whoogle-result-url: #______;
--whoogle-result-visited: #______;
/* DARK THEME COLORS */
--whoogle-dark-logo: #______;
--whoogle-dark-page-bg: #______;
--whoogle-dark-element-bg: #______;
--whoogle-dark-text: #______;
--whoogle-dark-contrast-text: #______;
--whoogle-dark-secondary-text: #______;
--whoogle-dark-result-bg: #______;
--whoogle-dark-result-title: #______;
--whoogle-dark-result-url: #______;
--whoogle-dark-result-visited: #______;
}
```

View File

@ -1,92 +1,28 @@
name: buildx
on:
workflow_run:
workflows: ["docker_main"]
branches: [main, updates]
types:
- completed
push:
tags:
- '*'
release:
types:
- published
branches: develop
jobs:
on-success:
build:
runs-on: ubuntu-latest
steps:
- name: Wait for tests to succeed
if: ${{ github.event.workflow_run.conclusion != 'success' && startsWith(github.ref, 'refs/tags') != true }}
run: exit 1
- name: Debug workflow context
run: |
echo "Event name: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "Actor: ${{ github.actor }}"
echo "Branch: ${{ github.event.workflow_run.head_branch }}"
echo "Conclusion: ${{ github.event.workflow_run.conclusion }}"
- name: checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: actions/checkout@v2
- name: install buildx
id: buildx
uses: crazy-max/ghaction-docker-buildx@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Disabled: only build on release events now
# - name: build and push the image
# if: startsWith(github.ref, 'refs/heads/main') && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
# run: |
# docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
# docker buildx ls
# docker buildx build --push \
# --tag benbusby/whoogle-search:latest \
# --platform linux/amd64,linux/arm64 .
# docker buildx build --push \
# --tag ghcr.io/benbusby/whoogle-search:latest \
# --platform linux/amd64,linux/arm64 .
- name: build and push updates branch (update-testing tag)
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')
version: latest
- name: log in to docker hub
run: |
docker buildx build --push \
--tag benbusby/whoogle-search:update-testing \
--tag ghcr.io/benbusby/whoogle-search:update-testing \
--platform linux/amd64,linux/arm64 .
- name: build and push release (version + latest)
if: github.event_name == 'release' && github.event.release.prerelease == false && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
echo "${{ secrets.DOCKER_PASSWORD }}" | \
docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: build and push the image
run: |
TAG="${{ github.event.release.tag_name }}"
VERSION="${TAG#v}"
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx ls
docker buildx build --push \
--tag benbusby/whoogle-search:${VERSION} \
--tag benbusby/whoogle-search:latest \
--tag ghcr.io/benbusby/whoogle-search:${VERSION} \
--tag ghcr.io/benbusby/whoogle-search:latest \
--platform linux/amd64,linux/arm64 .
- name: build and push pre-release (version only)
if: github.event_name == 'release' && github.event.release.prerelease == true && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
run: |
TAG="${{ github.event.release.tag_name }}"
VERSION="${TAG#v}"
docker buildx build --push \
--tag benbusby/whoogle-search:${VERSION} \
--tag ghcr.io/benbusby/whoogle-search:${VERSION} \
--platform linux/amd64,linux/arm64 .
- name: build and push tag
if: startsWith(github.ref, 'refs/tags')
run: |
docker buildx build --push \
--tag benbusby/whoogle-search:${GITHUB_REF#refs/*/v} \
--tag ghcr.io/benbusby/whoogle-search:${GITHUB_REF#refs/*/v} \
--platform linux/amd64,linux/arm64 .
--tag benbusby/whoogle-search:buildx-experimental \
--platform linux/amd64,linux/arm/v7,linux/arm64 .

View File

@ -1,29 +0,0 @@
name: docker_main
on:
workflow_run:
workflows: ["tests"]
branches: [main, updates]
types:
- completed
# TODO: Needs refactoring to use reusable workflows and share w/ docker_tests
jobs:
on-success:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: checkout code
uses: actions/checkout@v4
- name: build and test (docker)
run: |
docker build --tag whoogle-search:test .
docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test
sleep 15
docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1
- name: build and test (docker-compose)
run: |
docker rm -f whoogle-search-nocompose
WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach
sleep 15
docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1

View File

@ -1,26 +0,0 @@
name: docker_tests
on:
push:
branches: main
pull_request:
branches: main
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v2
- name: build and test (docker)
run: |
docker build --tag whoogle-search:test .
docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test
sleep 15
docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1
- name: build and test (docker compose)
run: |
docker rm -f whoogle-search-nocompose
WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach
sleep 15
docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1

22
.github/workflows/pep8.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: pep8
on:
push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pycodestyle
- name: Run pycodestyle
run: |
pycodestyle --show-source --show-pep8 app/*
pycodestyle --show-source --show-pep8 test/*

View File

@ -1,83 +0,0 @@
name: pypi
on:
push:
branches: main
tags: v*
jobs:
publish-test:
name: Build and publish to TestPyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install pypa/build
run: >-
python -m
pip install
build
setuptools
--user
- name: Set dev timestamp
run: echo "DEV_BUILD=$(date +%s)" >> $GITHUB_ENV
- name: Build binary wheel and source tarball
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
.
- name: Publish distribution to TestPyPI
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
repository_url: https://test.pypi.org/legacy/
publish:
# Gate real PyPI publishing to stable SemVer tags only
if: startsWith(github.ref, 'refs/tags/')
name: Build and publish to PyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check if stable release
id: check_tag
run: |
TAG="${{ github.ref_name }}"
if echo "$TAG" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "is_stable=true" >> $GITHUB_OUTPUT
echo "Tag '$TAG' is a stable release. Will publish to PyPI."
else
echo "is_stable=false" >> $GITHUB_OUTPUT
echo "Tag '$TAG' is not a stable release (contains pre-release suffix). Skipping PyPI publish."
fi
- name: Set up Python 3.9
if: steps.check_tag.outputs.is_stable == 'true'
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install pypa/build
if: steps.check_tag.outputs.is_stable == 'true'
run: >-
python -m
pip install
build
--user
- name: Build binary wheel and source tarball
if: steps.check_tag.outputs.is_stable == 'true'
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
.
- name: Publish distribution to PyPI
if: steps.check_tag.outputs.is_stable == 'true'
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.PYPI_API_TOKEN }}

View File

@ -1,19 +0,0 @@
name: scan
on:
schedule:
- cron: '0 0 * * *'
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build the container image
run: |
docker build --tag whoogle-search:test .
- name: Initiate grype scan
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b .
chmod +x ./grype
./grype whoogle-search:test --only-fixed

View File

@ -1,33 +0,0 @@
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
#
# You can adjust the behavior by modifying this file.
# For more information, see:
# https://github.com/actions/stale
name: Mark stale issues and pull requests
on:
schedule:
- cron: '35 10 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: 90
days-before-close: 7
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.'
close-issue-message: 'Closing this issue due to prolonged inactivity.'
# Disabled PR Closing for now, but pre-staged the settings
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 100
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."
close-pr-message: "This PR was closed as stale."
exempt-issue-labels: 'keep-open,enhancement,critical,dependencies,documentation'

View File

@ -1,17 +0,0 @@
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: pip install --upgrade pip && pip install -r requirements.txt
- name: Run tests
run: ./run test

14
.gitignore vendored
View File

@ -1,27 +1,17 @@
venv/
.venv/
.idea/
__pycache__/
*.pyc
*.pem
*.conf
*.key
config.json
test/static
flask_session/
app/static/config
app/static/custom_config
app/static/bangs/*
!app/static/bangs/00-whoogle.json
app/static/bangs
# pip stuff
/build/
build/
dist/
*.egg-info/
# env
whoogle.env
# vim
*~
*.swp

View File

@ -1,13 +0,0 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/psf/black
rev: 24.8.0
hooks:
- id: black
args: [--quiet]

View File

@ -1 +1,3 @@
entrypoint = "misc/replit.py"
language = "python3"
run = "./run"
onBoot = "./run"

15
.travis.yml Normal file
View File

@ -0,0 +1,15 @@
language: python
python: 3.6
before_install:
- sudo apt-get -y install libgnutls28-dev
install:
- pip install -r requirements.txt
script:
- "./run test"
deploy:
provider: pypi
user: __token__
password:
secure: WNEH2Gg84MZF/AZEberFDGPPWb4cYyHAeD/XV8En94QRSI9Aznz6qiDKOvV4eVgjMAIEW5uB3TL1LHf6KU+Hrg6SmhF7JquqP1gsBOCDNFPTljO+k2Hc53uDdSnhi/HLgY7cnFNX4lc2nNrbyxZxMHuSA2oNz/tosyNGBEeyU+JA5va7uX0albGsLiNjimO4aeau83fsI0Hn2eN6ag68pewUMXNxzpyTeO2bRcCd5d5iILs07jMVwFoC2j7W11oNqrVuSWAs8CPe4+kwvNvXWxljUGiBGppNZ7RAsKNLwi6U6kGGUTWjQm09rY/2JBpJ2WEGmIWGIrno75iiFRbjnRp3mnXPvtVTyWhh+hQIUd7bJOVKM34i9eHotYTrkMJObgW1gnRzvI9VYldtgL/iP/Isn2Pv2EeMX8V+C9/8pxv0jkQkZMnFhE6gGlzpz37zTl04B2J7xyV5znM35Lx2Pn3zxdcmdCvD3yT8I4MuBbKqq2/v4emYCfPfOmfwnS0BEVSqr9lbx4xfUZV76tcvLcj4n86DJbx77pA2Ch8FRprpOOBcf0WuqTbZp8c3mb8prFp2EupUknXu7+C2VQ6sqrnzNuDeTGm/nyjjRQ81rlvlD4tqkwsEGEDDO44FF2eUTc5D2MvoHs4cnz095FWjy63gn5IxUjhMi31b5tGRz2Q=
on:
tags: true

View File

@ -1,93 +1,60 @@
# NOTE: ARMv7 support has been dropped due to lack of pre-built cryptography wheels for Alpine/musl.
# To restore ARMv7 support for local builds:
# 1. Change requirements.txt:
# cryptography==3.3.2; platform_machine == 'armv7l'
# cryptography==46.0.1; platform_machine != 'armv7l'
# pyOpenSSL==19.1.0; platform_machine == 'armv7l'
# pyOpenSSL==25.3.0; platform_machine != 'armv7l'
# 2. Add linux/arm/v7 to --platform flag when building:
# docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64 .
FROM python:3.8-slim as builder
FROM python:3.12-alpine3.22 AS builder
RUN apk --no-cache add \
build-base \
RUN apt-get update && apt-get install -y \
build-essential \
libxml2-dev \
libxslt-dev \
openssl-dev \
libssl-dev \
libffi-dev
COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt
FROM python:3.12-alpine3.22
FROM python:3.8-slim
# Remove bridge package to avoid CVEs (not needed for Docker containers)
RUN apk add --no-cache --no-scripts tor curl openrc libstdc++ && \
apk del --no-cache bridge || true
# git go //for obfs4proxy
# libcurl4-openssl-dev
RUN pip install --upgrade pip
RUN apk --no-cache upgrade && \
apk del --no-cache --rdepends bridge || true
RUN apt-get update && apt-get install -y \
libcurl4-openssl-dev \
tor \
curl \
&& rm -rf /var/lib/apt/lists/*
# uncomment to build obfs4proxy
# RUN git clone https://gitlab.com/yawning/obfs4.git
# WORKDIR /obfs4
# RUN go build -o obfs4proxy/obfs4proxy ./obfs4proxy
# RUN cp ./obfs4proxy/obfs4proxy /usr/bin/obfs4proxy
ARG DOCKER_USER=whoogle
ARG DOCKER_USERID=927
ARG config_dir=/config
RUN mkdir -p $config_dir
RUN chmod a+w $config_dir
VOLUME $config_dir
ENV CONFIG_VOLUME=$config_dir
ARG url_prefix=''
ARG username=''
ENV WHOOGLE_USER=$username
ARG password=''
ARG proxyuser=''
ARG proxypass=''
ARG proxytype=''
ARG proxyloc=''
ARG whoogle_dotenv=''
ARG use_https=''
ARG whoogle_port=5000
ARG twitter_alt='farside.link/nitter'
ARG youtube_alt='farside.link/invidious'
ARG reddit_alt='farside.link/libreddit'
ARG medium_alt='farside.link/scribe'
ARG translate_alt='farside.link/lingva'
ARG imgur_alt='farside.link/rimgo'
ARG wikipedia_alt='farside.link/wikiless'
ARG imdb_alt='farside.link/libremdb'
ARG quora_alt='farside.link/quetre'
ARG so_alt='farside.link/anonymousoverflow'
ENV WHOOGLE_PASS=$password
ENV CONFIG_VOLUME=$config_dir \
WHOOGLE_URL_PREFIX=$url_prefix \
WHOOGLE_USER=$username \
WHOOGLE_PASS=$password \
WHOOGLE_PROXY_USER=$proxyuser \
WHOOGLE_PROXY_PASS=$proxypass \
WHOOGLE_PROXY_TYPE=$proxytype \
WHOOGLE_PROXY_LOC=$proxyloc \
WHOOGLE_DOTENV=$whoogle_dotenv \
HTTPS_ONLY=$use_https \
EXPOSE_PORT=$whoogle_port \
WHOOGLE_ALT_TW=$twitter_alt \
WHOOGLE_ALT_YT=$youtube_alt \
WHOOGLE_ALT_RD=$reddit_alt \
WHOOGLE_ALT_MD=$medium_alt \
WHOOGLE_ALT_TL=$translate_alt \
WHOOGLE_ALT_IMG=$imgur_alt \
WHOOGLE_ALT_WIKI=$wikipedia_alt \
WHOOGLE_ALT_IMDB=$imdb_alt \
WHOOGLE_ALT_QUORA=$quora_alt \
WHOOGLE_ALT_SO=$so_alt
ARG proxyuser=''
ENV WHOOGLE_PROXY_USER=$proxyuser
ARG proxypass=''
ENV WHOOGLE_PROXY_PASS=$proxypass
ARG proxytype=''
ENV WHOOGLE_PROXY_TYPE=$proxytype
ARG proxyloc=''
ENV WHOOGLE_PROXY_LOC=$proxyloc
ARG whoogle_dotenv=''
ENV WHOOGLE_DOTENV=$whoogle_dotenv
ARG use_https=''
ENV HTTPS_ONLY=$use_https
ARG whoogle_port=5000
ENV EXPOSE_PORT=$whoogle_port
ARG twitter_alt='nitter.net'
ENV WHOOGLE_ALT_TW=$twitter_alt
ARG youtube_alt='invidious.snopyta.org'
ENV WHOOGLE_ALT_YT=$youtube_alt
ARG instagram_alt='bibliogram.art/u'
ENV WHOOGLE_ALT_IG=$instagram_alt
ARG reddit_alt='libredd.it'
ENV WHOOGLE_ALT_RD=$reddit_alt
WORKDIR /whoogle
@ -95,22 +62,12 @@ COPY --from=builder /install /usr/local
COPY misc/tor/torrc /etc/tor/torrc
COPY misc/tor/start-tor.sh misc/tor/start-tor.sh
COPY app/ app/
COPY run whoogle.env* ./
# Create user/group to run as
RUN adduser -D -g $DOCKER_USERID -u $DOCKER_USERID $DOCKER_USER
# Fix ownership / permissions
RUN chown -R ${DOCKER_USER}:${DOCKER_USER} /whoogle /var/lib/tor
# Allow writing symlinks to build dir
RUN chown $DOCKER_USERID:$DOCKER_USERID app/static/build
USER $DOCKER_USER:$DOCKER_USER
COPY run .
COPY whoogle.env .
EXPOSE $EXPOSE_PORT
HEALTHCHECK --interval=30s --timeout=5s \
HEALTHCHECK --interval=30s --timeout=5s \
CMD curl -f http://localhost:${EXPOSE_PORT}/healthz || exit 1
CMD ["/bin/sh", "-c", "misc/tor/start-tor.sh & ./run"]
CMD misc/tor/start-tor.sh & ./run

View File

@ -2,5 +2,4 @@ graft app/static
graft app/templates
graft app/misc
include requirements.txt
recursive-include test
global-exclude *.pyc

793
README.md
View File

@ -1,66 +1,44 @@
>[!WARNING]
>
>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
>works -- Whoogle requests the JavaScript-free search results, then filters out garbage from the results page and proxies all external content for the user.
>
>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
>exhausted or a better method is found. If you know of a better way, please review and comment in our Way Forward Discussion
___
![Whoogle Search](docs/banner.png)
[![Latest Release](https://img.shields.io/github/v/release/benbusby/whoogle-search)](https://github.com/benbusby/shoogle/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![tests](https://github.com/benbusby/whoogle-search/actions/workflows/tests.yml/badge.svg)](https://github.com/benbusby/whoogle-search/actions/workflows/tests.yml)
[![buildx](https://github.com/benbusby/whoogle-search/actions/workflows/buildx.yml/badge.svg)](https://github.com/benbusby/whoogle-search/actions/workflows/buildx.yml)
[![Build Status](https://travis-ci.com/benbusby/whoogle-search.svg?branch=master)](https://travis-ci.com/benbusby/whoogle-search)
[![pep8](https://github.com/benbusby/whoogle-search/workflows/pep8/badge.svg)](https://github.com/benbusby/whoogle-search/actions?query=workflow%3Apep8)
[![codebeat badge](https://codebeat.co/badges/e96cada2-fb6f-4528-8285-7d72abd74e8d)](https://codebeat.co/projects/github-com-benbusby-shoogle-master)
[![Docker Pulls](https://img.shields.io/docker/pulls/benbusby/whoogle-search)](https://hub.docker.com/r/benbusby/whoogle-search)
<table>
<tr>
<td><a href="https://sr.ht/~benbusby/whoogle-search">SourceHut</a></td>
<td><a href="https://github.com/benbusby/whoogle-search">GitHub</a></td>
</tr>
</table>
Get 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.
Get 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.
Contents
1. [Features](#features)
3. [Install/Deploy Options](#install)
1. [Heroku Quick Deploy](#heroku-quick-deploy)
1. [Render.com](#render)
1. [Repl.it](#replit)
1. [Fly.io](#flyio)
1. [Koyeb](#koyeb)
1. [pipx](#pipx)
1. [pip](#pip)
1. [Manual](#manual)
1. [Docker](#manual-docker)
1. [Arch/AUR](#arch-linux--arch-based-distributions)
1. [Helm/Kubernetes](#helm-chart-for-kubernetes)
2. [Dependencies](#dependencies)
3. [Install/Deploy](#install)
1. [Heroku Quick Deploy](#a-heroku-quick-deploy)
2. [Repl.it](#b-replit)
3. [pipx](#c-pipx)
4. [pip](#d-pip)
5. [Manual](#e-manual)
6. [Docker](#f-manual-docker)
7. [Arch/AUR](#arch-linux--arch-based-distributions)
4. [Environment Variables and Configuration](#environment-variables)
5. [Google Custom Search (BYOK)](#google-custom-search-byok)
6. [Usage](#usage)
7. [Extra Steps](#extra-steps)
5. [Usage](#usage)
6. [Extra Steps](#extra-steps)
1. [Set Primary Search Engine](#set-whoogle-as-your-primary-search-engine)
2. [Custom Redirecting](#custom-redirecting)
2. [Custom Bangs](#custom-bangs)
3. [Prevent Downtime (Heroku Only)](#prevent-downtime-heroku-only)
4. [Manual HTTPS Enforcement](#https-enforcement)
5. [Using with Firefox Containers](#using-with-firefox-containers)
6. [Reverse Proxying](#reverse-proxying)
1. [Nginx](#nginx)
8. [Contributing](#contributing)
9. [FAQ](#faq)
10. [Public Instances](#public-instances)
11. [Screenshots](#screenshots)
2. [Prevent Downtime (Heroku Only)](#prevent-downtime-heroku-only)
3. [Manual HTTPS Enforcement](#https-enforcement)
7. [Contributing](#contributing)
8. [FAQ](#faq)
9. [Public Instances](#public-instances)
10. [Screenshots](#screenshots)
11. Mirrors (read-only)
1. [GitLab](https://gitlab.com/benbusby/whoogle-search)
2. [Gogs](https://gogs.benbusby.com/benbusby/whoogle-search)
## Features
- No ads or sponsored content
- No JavaScript\*
- No cookies\*\*
- No tracking/linking of your personal IP address\*\*\*
- No javascript
- No cookies
- No tracking/linking of your personal IP address\*
- No AMP links
- No URL tracking tags (i.e. utm=%s)
- No referrer header
@ -68,66 +46,40 @@ Contents
- Autocomplete/search suggestions
- POST request search and suggestion queries (when possible)
- View images at full res without site redirect (currently mobile only)
- Light/Dark/System theme modes (with support for [custom CSS theming](https://github.com/benbusby/whoogle-search/wiki/User-Contributed-CSS-Themes))
- Auto-generated Opera User Agents with random rotation
- 10 unique Opera-based UAs generated on startup from 115 language variants
- Randomly rotated for each search request to avoid detection patterns
- Cached across restarts with configurable refresh options
- Fallback to safe default UA if generation fails
- Optional display of current UA in search results footer
- Dark mode
- Randomly generated User Agent
- Easy to install/deploy
- DDG-style bang (i.e. `!<tag> <query>`) searches
- User-defined [custom bangs](#custom-bangs)
- Optional location-based searching (i.e. results near \<city\>)
- Optional NoJS mode to view search results in a separate window with JavaScript blocked
- JSON output for results via content negotiation (see "JSON results (API)")
- Optional NoJS mode to disable all Javascript in results
<sup>*No third party JavaScript. Whoogle can be used with JavaScript disabled, but if enabled, uses JavaScript for things like presenting search suggestions.</sup>
<sup>*If deployed to a remote server, or configured to send requests through a VPN, Tor, proxy, etc.</sup>
<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>
## Dependencies
If using Heroku Quick Deploy, **you can skip this section**.
<sup>***If deployed to a remote server, or configured to send requests through a VPN, Tor, proxy, etc.</sup>
- Docker ([Windows](https://docs.docker.com/docker-for-windows/install/), [macOS](https://docs.docker.com/docker-for-mac/install/), [Ubuntu](https://docs.docker.com/engine/install/ubuntu/), [other Linux distros](https://docs.docker.com/engine/install/binaries/))
- Only needed if you intend on deploying the app as a Docker image
- [Python3](https://www.python.org/downloads/)
- `libcurl4-openssl-dev` and `libssl-dev`
- macOS: `brew install openssl curl-openssl`
- Ubuntu: `sudo apt-get install -y libcurl4-openssl-dev libssl-dev`
- Arch: `pacman -S curl openssl`
## Install
### Supported Platforms
Official Docker images are built for:
- **linux/amd64** (x86_64)
- **linux/arm64** (ARM 64-bit, Raspberry Pi 3/4/5, Apple Silicon)
**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:
- Use an older version (v1.1.x or earlier)
- Build locally with pinned dependencies (see notes in Dockerfile)
- Upgrade to a 64-bit OS if hardware supports it (Raspberry Pi 3+)
There are a few different ways to begin using the app, depending on your preferences:
___
### A) [Heroku Quick Deploy](https://heroku.com/about)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/benbusby/whoogle-search/tree/heroku-app-beta)
### [Heroku Quick Deploy](https://heroku.com/about)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/benbusby/whoogle-search/tree/main)
*Note: Requires a (free) Heroku account*
Provides:
- Easy Deployment of App
- A HTTPS url (https://\<your app name\>.herokuapp.com)
- Free deployment of app
- Free HTTPS url (https://\<your app name\>.herokuapp.com)
- Downtime after periods of inactivity \([solution](https://github.com/benbusby/whoogle-search#prevent-downtime-heroku-only)\)
Notes:
- Requires a **PAID** Heroku Account.
- 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.
___
### [Render](https://render.com)
Create an account on [render.com](https://render.com) and import the Whoogle repo with the following settings:
- Runtime: `Python 3`
- Build Command: `pip install -r requirements.txt`
- Run Command: `./run`
___
### [Repl.it](https://repl.it)
### B) [Repl.it](https://repl.it)
[![Run on Repl.it](https://repl.it/badge/github/benbusby/whoogle-search)](https://repl.it/github/benbusby/whoogle-search)
*Note: Requires a (free) Replit account*
@ -136,61 +88,18 @@ Provides:
- Free deployment of app
- Free HTTPS url (https://\<app name\>.\<username\>\.repl\.co)
- Supports custom domains
- Downtime after periods of inactivity ([solution](https://repl.it/talk/learn/How-to-use-and-setup-UptimeRobot/9003)\)
- Downtime after periods of inactivity \([solution 1](https://repl.it/talk/ask/use-this-pingmat1replco-just-enter/28821/101298), [solution 2](https://repl.it/talk/learn/How-to-use-and-setup-UptimeRobot/9003)\)
___
### [Fly.io](https://fly.io)
You will need a [Fly.io](https://fly.io) account to deploy Whoogle.
#### Install the CLI: https://fly.io/docs/hands-on/installing/
#### Deploy the app
```bash
flyctl auth login
flyctl launch --image benbusby/whoogle-search:latest
```
The first deploy won't succeed because the default `internal_port` is wrong.
To fix this, open the generated `fly.toml` file, set `services.internal_port` to `5000` and run `flyctl launch` again.
Your app is now available at `https://<app-name>.fly.dev`.
Notes:
- Requires a [**PAID**](https://fly.io/docs/about/pricing/#free-allowances) Fly.io Account.
___
### [Koyeb](https://www.koyeb.com)
Use one of the following guides to install Whoogle on Koyeb:
1. Using GitHub: https://www.koyeb.com/docs/quickstart/deploy-with-git
2. Using Docker: https://www.koyeb.com/docs/quickstart/deploy-a-docker-application
___
### [RepoCloud](https://repocloud.io)
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=309)
1. Sign up for a free [RepoCloud account](https://repocloud.io) and receive free credits to get started.
2. Click "Deploy" to launch the app and access it instantly via your RepoCloud URL.
___
### [pipx](https://github.com/pipxproject/pipx#install-pipx)
### C) [pipx](https://github.com/pipxproject/pipx#install-pipx)
Persistent install:
`pipx install https://github.com/benbusby/whoogle-search/archive/refs/heads/main.zip`
`pipx install git+https://github.com/benbusby/whoogle-search.git`
Sandboxed temporary instance:
`pipx run --spec git+https://github.com/benbusby/whoogle-search.git whoogle-search`
___
### pip
### D) pip
`pip install whoogle-search`
```bash
@ -217,21 +126,7 @@ optional arguments:
```
See the [available environment variables](#environment-variables) for additional configuration.
___
### Manual
*Note: `Content-Security-Policy` headers can be sent by Whoogle if you set `WHOOGLE_CSP`.*
#### Dependencies
- [Python3](https://www.python.org/downloads/)
- `libcurl4-openssl-dev` and `libssl-dev`
- macOS: `brew install openssl curl-openssl`
- Ubuntu: `sudo apt-get install -y libcurl4-openssl-dev libssl-dev`
- Arch: `pacman -S curl openssl`
#### Install
### E) Manual
Clone the repo and run the following commands to start the app in a local-only environment:
```bash
@ -245,9 +140,9 @@ pip install -r requirements.txt
See the [available environment variables](#environment-variables) for additional configuration.
#### systemd Configuration
After 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:
After building the virtual environment, you can add the following to `/lib/systemd/system/whoogle.service` to set up a Whoogle Search systemd service:
```ini
```
[Unit]
Description=Whoogle
@ -258,36 +153,21 @@ Description=Whoogle
# Proxy configuration, uncomment to enable
#Environment=WHOOGLE_PROXY_USER=<proxy username>
#Environment=WHOOGLE_PROXY_PASS=<proxy password>
#Environment=WHOOGLE_PROXY_TYPE=<proxy type (http|https|proxy4|proxy5)
#Environment=WHOOGLE_PROXY_TYPE=<proxy type (http|proxy4|proxy5)
#Environment=WHOOGLE_PROXY_LOC=<proxy host/ip>
# Site alternative configurations, uncomment to enable
# Note: If not set, the feature will still be available
# with default values.
#Environment=WHOOGLE_ALT_TW=farside.link/nitter
#Environment=WHOOGLE_ALT_YT=farside.link/invidious
#Environment=WHOOGLE_ALT_RD=farside.link/libreddit
#Environment=WHOOGLE_ALT_MD=farside.link/scribe
#Environment=WHOOGLE_ALT_TL=farside.link/lingva
#Environment=WHOOGLE_ALT_IMG=farside.link/rimgo
#Environment=WHOOGLE_ALT_WIKI=farside.link/wikiless
#Environment=WHOOGLE_ALT_IMDB=farside.link/libremdb
#Environment=WHOOGLE_ALT_QUORA=farside.link/quetre
#Environment=WHOOGLE_ALT_SO=farside.link/anonymousoverflow
# with default values.
#Environment=WHOOGLE_ALT_TW=nitter.net
#Environment=WHOOGLE_ALT_YT=invidious.snopyta.org
#Environment=WHOOGLE_ALT_IG=bibliogram.art/u
#Environment=WHOOGLE_ALT_RD=libredd.it
# Load values from dotenv only
#Environment=WHOOGLE_DOTENV=1
# specify dotenv location if not in default location
#Environment=WHOOGLE_DOTENV_PATH=<path/to>/whoogle.env
Type=simple
User=<username>
# If installed as a package, add:
ExecStart=<python_install_dir>/python3 <whoogle_install_dir>/whoogle-search --host 127.0.0.1 --port 5000
# For example:
# ExecStart=/usr/bin/python3 /home/my_username/.local/bin/whoogle-search --host 127.0.0.1 --port 5000
# Otherwise if running the app from source, add:
ExecStart=<whoogle_repo_dir>/run
# For example:
# ExecStart=/var/www/whoogle-search/run
WorkingDirectory=<whoogle_repo_dir>
User=root
WorkingDirectory=<whoogle_directory>
ExecStart=<whoogle_directory>/venv/bin/python3 -um app --host 0.0.0.0 --port 5000
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=3
@ -303,54 +183,7 @@ sudo systemctl enable whoogle
sudo systemctl start whoogle
```
#### Tor Configuration *optional*
If routing your request through Tor you will need to make the following adjustments.
Due 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.
There are two authentication methods, password and cookie. You will need to make changes to your torrc:
* Cookie
1. Uncomment or add the following lines in your torrc:
- `ControlPort 9051`
- `CookieAuthentication 1`
- `DataDirectoryGroupReadable 1`
- `CookieAuthFileGroupReadable 1`
2. Make the tor auth cookie readable:
- 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.
1. `chmod tor:whoogle /var/lib/tor`
2. `chmod tor:whoogle /var/lib/tor/control_auth_cookie`
3. Restart the tor service:
- `systemctl restart tor`
4. Set the Tor environment variable to 1, `WHOOGLE_CONFIG_TOR`. Refer to the [Environment Variables](#environment-variables) section for more details.
- This may be added in the systemd unit file or env file `WHOOGLE_CONFIG_TOR=1`
* Password
1. Run this command:
- `tor --hash-password {Your Password Here}`; put your password in place of `{Your Password Here}`.
- Keep the output of this command, you will be placing it in your torrc.
- Keep the password input of this command, you will be using it later.
2. Uncomment or add the following lines in your torrc:
- `ControlPort 9051`
- `HashedControlPassword {Place output here}`; put the output of the previous command in place of `{Place output here}`.
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)
- 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.
4. Heavily restrict access to control.conf to only be readable by the user running whoogle:
- `chmod 400 control.conf`
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.
- These may be added to the systemd unit file or env file:
- `WHOOGLE_CONFIG_TOR=1`
- `WHOOGLE_TOR_USE_PASS=1`
___
### Manual (Docker)
### F) Manual (Docker)
1. Ensure the Docker daemon is running, and is accessible by your user account
- To add user permissions, you can execute `sudo usermod -aG docker yourusername`
- 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).
@ -358,6 +191,8 @@ ___
#### Docker CLI
***Note:** For ARM machines, use the `buildx-experimental` Docker tag.*
Through Docker Hub:
```bash
docker pull benbusby/whoogle-search
@ -408,25 +243,12 @@ heroku container:release web
heroku open
```
This 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).
This 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#set-whoogle-as-your-primary-search-engine).
You may also edit environment variables from your apps Settings tab in the Heroku Dashboard.
___
### Arch Linux & Arch-based Distributions
#### Arch Linux & Arch-based Distributions
There 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).
___
### Helm chart for Kubernetes
To use the Kubernetes Helm Chart:
1. Ensure you have [Helm](https://helm.sh/docs/intro/install/) `>=3.0.0` installed
2. Clone this repository
3. Update [charts/whoogle/values.yaml](./charts/whoogle/values.yaml) as desired
4. Run `helm upgrade --install whoogle ./charts/whoogle`
___
#### Using your own server, or alternative container deployment
There 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.
@ -443,212 +265,54 @@ There are a few optional environment variables available for customizing a Whoog
- With `docker-compose`: Uncomment the `env_file` option
- With `docker build/run`: Add `--env-file ./whoogle.env` to your command
| Variable | Description |
| -------------------- | ----------------------------------------------------------------------------------------- |
| WHOOGLE_URL_PREFIX | The URL prefix to use for the whoogle instance (i.e. "/whoogle") |
| WHOOGLE_DOTENV | Load environment variables in `whoogle.env` |
| WHOOGLE_DOTENV_PATH | The path to `whoogle.env` if not in default location |
| WHOOGLE_USER | The username for basic auth. WHOOGLE_PASS must also be set if used. |
| WHOOGLE_PASS | The password for basic auth. WHOOGLE_USER must also be set if used. |
| WHOOGLE_PROXY_USER | The username of the proxy server. |
| WHOOGLE_PROXY_PASS | The password of the proxy server. |
| WHOOGLE_PROXY_TYPE | The type of the proxy server. Can be "socks5", "socks4", or "http". |
| WHOOGLE_PROXY_LOC | The location of the proxy server (host or ip). |
| WHOOGLE_USER_AGENT | The desktop user agent to use when using 'env_conf' option. Leave empty to use auto-generated Opera UAs. |
| WHOOGLE_USER_AGENT_MOBILE | The mobile user agent to use when using 'env_conf' option. Leave empty to use auto-generated Opera UAs. |
| WHOOGLE_USE_CLIENT_USER_AGENT | Enable to use your own user agent for all requests. Defaults to false. |
| WHOOGLE_UA_CACHE_PERSISTENT | Whether to persist auto-generated UAs across restarts. Set to '0' to regenerate on each startup. Default '1'. |
| WHOOGLE_UA_CACHE_REFRESH_DAYS | Auto-refresh UA cache after N days. Set to '0' to never refresh (cache persists indefinitely). Default '0'. |
| WHOOGLE_UA_LIST_FILE | Path to text file containing custom UA strings (one per line). When set, uses these instead of auto-generated UAs. |
| WHOOGLE_REDIRECTS | Specify sites that should be redirected elsewhere. See [custom redirecting](#custom-redirecting). |
| EXPOSE_PORT | The port where Whoogle will be exposed. |
| HTTPS_ONLY | Enforce HTTPS. (See [here](https://github.com/benbusby/whoogle-search#https-enforcement)) |
| WHOOGLE_ALT_TW | The twitter.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_ALT_YT | The youtube.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_ALT_RD | The reddit.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_ALT_TL | The Google Translate alternative to use. This is used for all "translate ____" searches. Set to "" to disable. |
| WHOOGLE_ALT_MD | The medium.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_ALT_IMG | The imgur.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_ALT_WIKI | The wikipedia.org alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_ALT_IMDB | The imdb.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_ALT_QUORA | The quora.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_ALT_SO | The stackoverflow.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
| WHOOGLE_AUTOCOMPLETE | Controls visibility of autocomplete/search suggestions. Default on -- use '0' to disable. |
| WHOOGLE_MINIMAL | Remove everything except basic result cards from all search queries. |
| WHOOGLE_CSP | Sets a default set of 'Content-Security-Policy' headers |
| WHOOGLE_TOR_SERVICE | Enable/disable the Tor service on startup. Default on -- use '0' to disable. |
| WHOOGLE_TOR_USE_PASS | Use password authentication for tor control port. |
| 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.|
| WHOOGLE_SHOW_FAVICONS | Show/hide favicons next to search result URLs. Default on. |
| WHOOGLE_UPDATE_CHECK | Enable/disable the automatic daily check for new versions of Whoogle. Default on. |
| 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=). |
| WHOOGLE_BUNDLE_STATIC | When set to 1, serve a single bundled CSS and JS file generated at startup to reduce requests. Default off. |
| WHOOGLE_HTTP2 | Enable HTTP/2 for upstream requests (via httpx). Default on — set to 0 to force HTTP/1.1. |
| Variable | Description |
| ------------------ | ----------------------------------------------------------------------------------------- |
| WHOOGLE_DOTENV | Load environment variables in `whoogle.env` |
| WHOOGLE_USER | The username for basic auth. WHOOGLE_PASS must also be set if used. |
| WHOOGLE_PASS | The password for basic auth. WHOOGLE_USER must also be set if used. |
| WHOOGLE_PROXY_USER | The username of the proxy server. |
| WHOOGLE_PROXY_PASS | The password of the proxy server. |
| WHOOGLE_PROXY_TYPE | The type of the proxy server. Can be "socks5", "socks4", or "http". |
| WHOOGLE_PROXY_LOC | The location of the proxy server (host or ip). |
| EXPOSE_PORT | The port where Whoogle will be exposed. |
| HTTPS_ONLY | Enforce HTTPS. (See [here](https://github.com/benbusby/whoogle-search#https-enforcement)) |
| WHOOGLE_ALT_TW | The twitter.com alternative to use when site alternatives are enabled in the config. |
| WHOOGLE_ALT_YT | The youtube.com alternative to use when site alternatives are enabled in the config. |
| WHOOGLE_ALT_IG | The instagram.com alternative to use when site alternatives are enabled in the config. |
| WHOOGLE_ALT_RD | The reddit.com alternative to use when site alternatives are enabled in the config. |
### Config Environment Variables
These 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.
| Variable | Description |
| ------------------------------------ | --------------------------------------------------------------- |
| WHOOGLE_CONFIG_DISABLE | Hide config from UI and disallow changes to config by client |
| WHOOGLE_CONFIG_COUNTRY | Filter results by hosting country |
| WHOOGLE_CONFIG_LANGUAGE | Set interface language |
| WHOOGLE_CONFIG_SEARCH_LANGUAGE | Set search result language |
| WHOOGLE_CONFIG_BLOCK | Block websites from search results (use comma-separated list) |
| WHOOGLE_CONFIG_BLOCK_TITLE | Block search result with a REGEX filter on title |
| WHOOGLE_CONFIG_BLOCK_URL | Block search result with a REGEX filter on URL |
| WHOOGLE_CONFIG_THEME | Set theme mode (light, dark, or system) |
| WHOOGLE_CONFIG_SAFE | Enable safe searches |
| WHOOGLE_CONFIG_ALTS | Use social media site alternatives (nitter, invidious, etc) |
| WHOOGLE_CONFIG_NEAR | Restrict results to only those near a particular city |
| WHOOGLE_CONFIG_TOR | Use Tor routing (if available) |
| WHOOGLE_CONFIG_NEW_TAB | Always open results in new tab |
| WHOOGLE_CONFIG_VIEW_IMAGE | Enable View Image option |
| WHOOGLE_CONFIG_GET_ONLY | Search using GET requests only |
| WHOOGLE_CONFIG_URL | The root url of the instance (`https://<your url>/`) |
| WHOOGLE_CONFIG_STYLE | The custom CSS to use for styling (should be single line) |
| WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED | Encrypt preferences token, requires preferences key |
| WHOOGLE_CONFIG_PREFERENCES_KEY | Key to encrypt preferences in URL (REQUIRED to show url) |
| WHOOGLE_CONFIG_ANON_VIEW | Include the "anonymous view" option for each search result |
| WHOOGLE_CONFIG_SHOW_USER_AGENT | Display the User Agent string used for search in results footer |
### Google Custom Search (BYOK) Environment Variables
These environment variables configure the "Bring Your Own Key" feature for Google Custom Search API:
| Variable | Description |
| -------------------- | ----------------------------------------------------------------------------------------- |
| WHOOGLE_CSE_API_KEY | Your Google API key with Custom Search API enabled |
| WHOOGLE_CSE_ID | Your Custom Search Engine ID (cx parameter) |
| WHOOGLE_USE_CSE | Enable Custom Search API by default (set to '1' to enable) |
## Google Custom Search (BYOK)
If 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.
### Why Use This?
- **Reliability**: Official API never gets blocked or rate-limited (within quota)
- **Speed**: Direct JSON responses are faster than HTML scraping
- **Fallback**: Works when all scraping workarounds fail
- **Privacy**: Your searches still don't go through third parties—they go directly to Google with your own API key
### Limitations vs Standard Whoogle
| Feature | Standard Scraping | CSE API |
|------------------|--------------------------|---------------------|
| Daily limit | None (until blocked) | 100 free, then paid |
| Image search | ✅ Full support | ✅ Supported |
| News/Videos tabs | ✅ | ❌ Web results only |
| Speed | Slower (HTML parsing) | Faster (JSON) |
| Reliability | Can be blocked | Always works |
### Setup Steps
#### 1. Create a Custom Search Engine
1. Go to [Programmable Search Engine](https://programmablesearchengine.google.com/controlpanel/all)
2. Click **"Add"** to create a new search engine
3. Under "What to search?", select **"Search the entire web"**
4. Give it a name (e.g., "My Whoogle CSE")
5. Click **"Create"**
6. Copy your **Search Engine ID**
#### 2. Get an API Key
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Go to **APIs & Services** → **Library**
4. Search for **"Custom Search API"** and click **Enable**
5. Go to **APIs & Services** → **Credentials**
6. Click **"Create Credentials"** → **"API Key"**
7. Copy your API key (looks like `AIza...`)
#### 3. (Recommended) Restrict Your API Key
To prevent misuse if your key is exposed:
1. Click on your API key in Credentials
2. Under **"API restrictions"**, select **"Restrict key"**
3. Choose only **"Custom Search API"**
4. Under **"Application restrictions"**, consider adding IP restrictions if using on a server
5. Click **Save**
#### 4. Configure Whoogle
**Option A: Via Settings UI**
1. Open your Whoogle instance
2. Click the **Config** button
3. Scroll to "Google Custom Search (BYOK)" section
4. Enter your API Key and CSE ID
5. Check "Use Custom Search API"
6. Click **Apply**
**Option B: Via Environment Variables**
```bash
WHOOGLE_CSE_API_KEY=AIza...
WHOOGLE_CSE_ID=23f...
WHOOGLE_USE_CSE=1
```
### Pricing & Avoiding Charges
| Tier | Queries | Cost |
|------|------------------|-----------------------|
| Free | 100/day | $0 |
| Paid | Up to 10,000/day | $5 per 1,000 queries |
**⚠️ To avoid unexpected charges:**
1. **Don't add a payment method** to Google Cloud (safest option—API stops at 100/day)
2. **Set a billing budget alert**: [Billing → Budgets & Alerts](https://console.cloud.google.com/billing/budgets)
3. **Cap API usage**: APIs & Services → Custom Search API → Quotas → Set "Queries per day" to 100
4. **Monitor usage**: APIs & Services → Custom Search API → Metrics
### Troubleshooting
| Error | Cause | Solution |
|---------------------|---------------------------|-----------------------------------------------------------------|
| "API key not valid" | Invalid or restricted key | Check key in Cloud Console, ensure Custom Search API is enabled |
| "Quota exceeded" | Hit 100/day limit | Wait until midnight PT, or enable billing |
| "Invalid CSE ID" | Wrong cx parameter | Copy ID from Programmable Search Engine control panel |
| Variable | Description |
| ------------------------------ | --------------------------------------------------------------- |
| WHOOGLE_CONFIG_DISABLE | Hide config from UI and disallow changes to config by client |
| WHOOGLE_CONFIG_COUNTRY | Filter results by hosting country |
| WHOOGLE_CONFIG_LANGUAGE | Set interface language |
| WHOOGLE_CONFIG_SEARCH_LANGUAGE | Set search result language |
| WHOOGLE_CONFIG_BLOCK | Block websites from search results (use comma-separated list) |
| WHOOGLE_CONFIG_DARK | Enable dark theme |
| WHOOGLE_CONFIG_SAFE | Enable safe searches |
| WHOOGLE_CONFIG_ALTS | Use social media site alternatives (nitter, invidious, etc) |
| WHOOGLE_CONFIG_TOR | Use Tor routing (if available) |
| WHOOGLE_CONFIG_NEW_TAB | Always open results in new tab |
| WHOOGLE_CONFIG_VIEW_IMAGE | Enable View Image option |
| WHOOGLE_CONFIG_GET_ONLY | Search using GET requests only |
| WHOOGLE_CONFIG_URL | The root url of the instance (`https://<your url>/`) |
| WHOOGLE_CONFIG_STYLE | The custom CSS to use for styling (should be single line) |
## Usage
Same as most search engines, with the exception of filtering by time range.
To 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`
### JSON results (API)
Whoogle can return filtered results as JSON using the same sanitization rules as the HTML view.
- Send `Accept: application/json` or append `format=json` to the search URL.
- Example: `/search?q=whoogle` with `Accept: application/json`, or `/search?q=whoogle&format=json`.
- Response shape:
```
{
"query": "whoogle",
"search_type": "",
"results": [
{"href": "https://example.com/page", "text": "Example Page"},
...
]
}
```
Special cases:
- Feeling Lucky returns HTTP 303 with body `{ "redirect": "<url>" }`.
- Temporary blocks (captcha) return HTTP 503 with `{ "blocked": true, "error_message": "...", "query": "..." }`.
## Extra Steps
### Set Whoogle as your primary search engine
*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.*
Browser settings:
- Firefox (Desktop)
- Version 89+
- Navigate to your app's url, right click the address bar, and select "Add Search Engine".
- Previous versions
- 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".
- 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.
- **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.
- 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". Once you've clicked this, open your Firefox Preferences menu, click "Search" in the left menu, and use the available dropdown to select "Whoogle" from the list.
- Firefox (iOS)
- 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:
- Title: "Whoogle"
@ -671,51 +335,17 @@ Browser settings:
- Search string to use: `https://\<your whoogle url\>/search?q=%s`
- [Alfred](https://www.alfredapp.com/) (Mac OS X)
1. Go to `Alfred Preferences` > `Features` > `Web Search` and click `Add Custom Search`. Then configure these settings
- Search URL: `https://\<your whoogle url\>/search?q={query}`
- Search URL: `https://\<your whoogle url\>/search?q={query}
- Title: `Whoogle for '{query}'` (or whatever you want)
- Keyword: `whoogle`
2. Go to `Default Results` and click the `Setup fallback results` button. Click `+` and add Whoogle, then drag it to the top.
- Chrome/Chromium-based Browsers
- Automatic
- 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.
- Visit the home page of your Whoogle Search instance -- this may automatically add the search engine to your list of search engines. If not, you can add it manually.
- Manual
- Under search engines > manage search engines > add, manually enter your Whoogle instance details with a `<whoogle url>/search?q=%s` formatted search URL.
### Custom Redirecting
You can set custom site redirects using the `WHOOGLE_REDIRECTS` environment
variable. A lot of sites, such as Twitter, Reddit, etc, have built-in redirects
to [Farside links](https://sr.ht/~benbusby/farside), but you may want to define
your own.
To do this, you can use the following syntax:
```
WHOOGLE_REDIRECTS="<parent_domain>:<new_domain>"
```
For example, if you want to redirect from "badsite.com" to "goodsite.com":
```
WHOOGLE_REDIRECTS="badsite.com:goodsite.com"
```
This can be used for multiple sites as well, with comma separation:
```
WHOOGLE_REDIRECTS="badA.com:goodA.com,badB.com:goodB.com"
```
NOTE: Do not include "http(s)://" when defining your redirect.
### Custom Bangs
You can create your own custom bangs. By default, bangs are stored in
`app/static/bangs`. See [`00-whoogle.json`](https://github.com/benbusby/whoogle-search/blob/main/app/static/bangs/00-whoogle.json)
for an example. These are parsed in alphabetical order with later files
overriding bangs set in earlier files, with the exception that DDG bangs
(downloaded to `app/static/bangs/bangs.json`) are always parsed first. Thus,
any custom bangs will always override the DDG ones.
### Prevent Downtime (Heroku only)
Part 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.
@ -736,183 +366,6 @@ Note: You should have your own domain name and [an https certificate](https://le
- Pip/Pipx: Add the `--https-only` flag to the end of the `whoogle-search` command
- Default `run` script: Modify the script locally to include the `--https-only` flag at the end of the python run command
### Using with Firefox Containers
Unfortunately, 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:
1. Remove any existing Whoogle search engines from Firefox settings
2. Enable `GET Requests Only` in Whoogle config
3. Clear Firefox cache
4. Restart Firefox
5. Navigate to Whoogle instance and [re-add the engine](#set-whoogle-as-your-primary-search-engine)
### Reverse Proxying
#### Nginx
Here is a sample Nginx config for Whoogle:
```
server {
server_name your_domain_name.com;
access_log /dev/null;
error_log /dev/null;
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header X-Forwarded-Host $http_host;
proxy_pass http://localhost:5000;
}
}
```
You 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/).
### Static asset bundling (optional)
Whoogle can optionally serve a single bundled CSS and JS to reduce the number of HTTP requests.
- Enable by setting `WHOOGLE_BUNDLE_STATIC=1` and restarting the app.
- On startup, Whoogle concatenates local CSS/JS into hashed files under `app/static/build/` and templates will prefer those bundles.
- When disabled (default), templates load individual CSS/JS files for easier development.
- Note: Theme CSS (`*-theme.css`) are still loaded separately to honor user theme selection.
## User Agent Generator Tool
A standalone command-line tool is available for generating Opera User Agent strings on demand:
```bash
# Generate 10 User Agent strings (default)
python misc/generate_uas.py
# Generate custom number of UAs
python misc/generate_uas.py 20
```
This tool is useful for:
- Testing different UA strings
- Generating UAs for other projects
- Verifying UA generation patterns
- Debugging UA-related issues
## Using Custom User Agent Lists
Instead of using auto-generated Opera UA strings, you can provide your own list of User Agent strings for Whoogle to use.
### Setup
1. Create a text file with your preferred UA strings (one per line):
```
Opera/9.80 (J2ME/MIDP; Opera Mini/4.2.13337/22.478; U; en) Presto/2.4.15 Version/10.00
Opera/9.80 (Android; Linux; Opera Mobi/498; U; en) Presto/2.12.423 Version/10.1
```
2. Set the `WHOOGLE_UA_LIST_FILE` environment variable to point to your file:
```bash
# Docker
docker run -e WHOOGLE_UA_LIST_FILE=/config/my_user_agents.txt ...
# Docker Compose
environment:
- WHOOGLE_UA_LIST_FILE=/config/my_user_agents.txt
# Manual/systemd
export WHOOGLE_UA_LIST_FILE=/path/to/my_user_agents.txt
```
### Priority Order
Whoogle uses the following priority when loading User Agent strings:
1. **Custom UA list file** (if `WHOOGLE_UA_LIST_FILE` is set and valid)
2. **Cached auto-generated UAs** (if cache exists and is valid)
3. **Newly generated UAs** (if no cache or cache expired)
### Tips
- You can use the output from `misc/check_google_user_agents.py` as your custom UA list
- Generate a list with `python misc/generate_uas.py 50 2>/dev/null > my_uas.txt`
- Mix different UA types (Opera, Firefox, Chrome) for more variety
- Keep the file readable by Whoogle (proper permissions)
- One UA string per line, blank lines are ignored
### Example Workflow
```bash
# Generate and test UAs, save working ones
python misc/generate_uas.py 100 2>/dev/null > candidate_uas.txt
python misc/check_google_user_agents.py candidate_uas.txt --output working_uas.txt
# Use the working UAs with Whoogle
export WHOOGLE_UA_LIST_FILE=./working_uas.txt
./run
```
## User Agent Testing Tool
Whoogle 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.
### Usage
```bash
# Test all UAs from a file
python misc/check_google_user_agents.py UAs.txt
# Save working UAs to a file (appends incrementally)
python misc/check_google_user_agents.py UAs.txt --output working_uas.txt
# Use a specific search query
python misc/check_google_user_agents.py UAs.txt --query "python programming"
# Verbose mode to see detailed results
python misc/check_google_user_agents.py UAs.txt --output working.txt --verbose
# Adjust delay between requests (default: 0.5 seconds)
python misc/check_google_user_agents.py UAs.txt --delay 1.0
# Set request timeout (default: 10 seconds)
python misc/check_google_user_agents.py UAs.txt --timeout 15.0
```
### Features
- **Incremental Results**: Working UAs are saved immediately to the output file (append mode), so progress is preserved even if interrupted
- **Duplicate Detection**: Automatically skips UAs already in the output file when resuming
- **Random Query Cycling**: By default, cycles through diverse search queries to simulate realistic usage patterns
- **Rate Limit Detection**: Detects and reports Google rate limiting with recovery instructions
- **Comprehensive Validation**: Checks for:
- HTTP status codes (blocks, server errors, rate limits)
- Block markers (unusual traffic, upgrade browser messages)
- Success markers (actual search result HTML elements)
- JavaScript-only pages and redirects
- Response size validation
### Testing Methodology
The tool evaluates UAs against multiple criteria:
1. **HTTP Status**: Rejects 4xx/5xx errors, detects 429 rate limits
2. **Block Detection**: Searches for Google's block messages (CAPTCHA, unusual traffic, etc.)
3. **JavaScript Detection**: Identifies JS-only pages and noscript redirects
4. **Result Validation**: Confirms presence of actual search result HTML elements
5. **Content Analysis**: Validates response size and structure
This tool was used to discover and validate the working Opera UA patterns that power Whoogle's auto-generation feature.
## Known Issues
### User Agent Strings and Image Search
**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.
**Impact**:
- Regular web searches work correctly with generated UAs
- Image search may return errors or no results
## Contributing
Under the hood, Whoogle is a basic Flask app with the following structure:
@ -926,7 +379,6 @@ Under the hood, Whoogle is a basic Flask app with the following structure:
- `results.py`: Utility functions for interpreting/modifying individual search results
- `search.py`: Creates and handles new search queries
- `session.py`: Miscellaneous methods related to user sessions
- `ua_generator.py`: Auto-generates Opera User Agent strings with pattern-based randomization
- `templates/`
- `index.html`: The home page template
- `display.html`: The search results template
@ -934,12 +386,12 @@ Under the hood, Whoogle is a basic Flask app with the following structure:
- `search.html`: An iframe-able search page
- `logo.html`: A template consisting mostly of the Whoogle logo as an SVG (separated to help keep `index.html` a bit cleaner)
- `opensearch.xml`: A template used for supporting [OpenSearch](https://developer.mozilla.org/en-US/docs/Web/OpenSearch).
- `imageresults.html`: An "experimental" template used for supporting the "Full Size" image feature on desktop.
- `imageresults.html`: An "exprimental" template used for supporting the "Full Size" image feature on desktop.
- `static/<css|js>`
- CSS/JavaScript files, should be self-explanatory
- CSS/Javascript files, should be self-explanatory
- `static/settings`
- Key-value JSON files for establishing valid configuration values
If 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.
@ -958,11 +410,11 @@ def contains(x: list, y: int) -> bool:
"""
return y in x
```
```
#### Translating
Whoogle 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.
Whoogle currently supports translations using [`translations.json`](https://github.com/benbusby/whoogle-search/blob/main/app/static/settings/languages.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.
## FAQ
**What's the difference between this and [Searx](https://github.com/asciimoo/searx)?**
@ -975,30 +427,23 @@ I'm a huge fan of Searx though and encourage anyone to use that instead if they
**Why does the image results page look different?**
A 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.
A 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.
## Public Instances
*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.*
| Website | Country | Language | Cloudflare |
|-|-|-|-|
| [https://search.garudalinux.org](https://search.garudalinux.org) | 🇫🇮 FI | Multi-choice | ✅ |
| [https://whoogle.privacydev.net](https://whoogle.privacydev.net) | 🇫🇷 FR | English | |
| [https://whoogle.lunar.icu](https://whoogle.lunar.icu) | 🇩🇪 DE | Multi-choice | ✅ |
* 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.
#### Onion Instances
| Website | Country | Language |
|-|-|-|
NONE of the existing Onion accessible sites appear to be live anymore
*Note: Use public instances at your own discretion. Maintainers of Whoogle do not personally validate the integrity of these instances, and popular public instances are more likely to be rate-limited or blocked.*
- [https://whoogle.sdf.org](https://whoogle.sdf.org)
- [https://whoogle.himiko.cloud](https://whoogle.himiko.cloud)
- [https://whoogle.kavin.rocks](https://whoogle.kavin.rocks) or [http://whoogledq5f5wly5p4i2ohnvjwlihnlg4oajjum2oeddfwqdwupbuhqd.onion](http://whoogledq5f5wly5p4i2ohnvjwlihnlg4oajjum2oeddfwqdwupbuhqd.onion)
- [https://search.garudalinux.org](https://search.garudalinux.org)
- [https://whooglesearch.net/](https://whooglesearch.net/)
- [https://search.flawcra.cc/](https://search.flawcra.cc/)
- [https://search.exonip.de/](https://search.exonip.de/)
- [https://whoogle.silkky.cloud/](https://whoogle.silkky.cloud/)
## Screenshots
#### Desktop
![Whoogle Desktop](docs/screenshot_desktop.png)
![Whoogle Desktop](docs/screenshot_desktop.jpg)
#### Mobile
![Whoogle Mobile](docs/screenshot_mobile.png)
![Whoogle Mobile](docs/screenshot_mobile.jpg)

View File

@ -15,11 +15,6 @@
],
"stack": "container",
"env": {
"WHOOGLE_URL_PREFIX": {
"description": "The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")",
"value": "",
"required": false
},
"WHOOGLE_USER": {
"description": "The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.",
"value": "",
@ -52,69 +47,29 @@
},
"WHOOGLE_ALT_TW": {
"description": "The site to use as a replacement for twitter.com when site alternatives are enabled in the config.",
"value": "farside.link/nitter",
"value": "nitter.net",
"required": false
},
"WHOOGLE_ALT_YT": {
"description": "The site to use as a replacement for youtube.com when site alternatives are enabled in the config.",
"value": "farside.link/invidious",
"value": "invidious.snopyta.org",
"required": false
},
"WHOOGLE_ALT_IG": {
"description": "The site to use as a replacement for instagram.com when site alternatives are enabled in the config.",
"value": "bibliogram.art/u",
"required": false
},
"WHOOGLE_ALT_RD": {
"description": "The site to use as a replacement for reddit.com when site alternatives are enabled in the config.",
"value": "farside.link/libreddit",
"value": "libredd.it",
"required": false
},
"WHOOGLE_ALT_MD": {
"description": "The site to use as a replacement for medium.com when site alternatives are enabled in the config.",
"value": "farside.link/scribe",
"required": false
},
"WHOOGLE_ALT_TL": {
"description": "The Google Translate alternative to use for all searches following the 'translate ___' structure.",
"value": "farside.link/lingva",
"required": false
},
"WHOOGLE_ALT_IMG": {
"description": "The site to use as a replacement for imgur.com when site alternatives are enabled in the config.",
"value": "farside.link/rimgo",
"required": false
},
"WHOOGLE_ALT_WIKI": {
"description": "The site to use as a replacement for wikipedia.com when site alternatives are enabled in the config.",
"value": "farside.link/wikiless",
"required": false
},
"WHOOGLE_ALT_IMDB": {
"description": "The site to use as a replacement for imdb.com when site alternatives are enabled in the config.",
"value": "farside.link/libremdb",
"required": false
},
"WHOOGLE_ALT_QUORA": {
"description": "The site to use as a replacement for quora.com when site alternatives are enabled in the config.",
"value": "farside.link/quetre",
"required": false
},
"WHOOGLE_ALT_SO": {
"description": "The site to use as a replacement for stackoverflow.com when site alternatives are enabled in the config.",
"value": "farside.link/anonymousoverflow",
"required": false
},
"WHOOGLE_MINIMAL": {
"description": "Remove everything except basic result cards from all search queries (set to 1 or leave blank)",
"value": "",
"required": false
},
"WHOOGLE_CONFIG_COUNTRY": {
"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)",
"value": "",
"required": false
},
"WHOOGLE_CONFIG_TIME_PERIOD" : {
"description": "[CONFIG] The time period to use for restricting search results",
"value": "",
"required": false
},
"WHOOGLE_CONFIG_LANGUAGE": {
"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)",
"value": "",
@ -135,9 +90,9 @@
"value": "",
"required": false
},
"WHOOGLE_CONFIG_THEME": {
"description": "[CONFIG] Set theme to 'dark', 'light', or 'system'",
"value": "system",
"WHOOGLE_CONFIG_DARK": {
"description": "[CONFIG] Enable dark mode (set to 1 or leave blank)",
"value": "",
"required": false
},
"WHOOGLE_CONFIG_SAFE": {
@ -150,11 +105,6 @@
"value": "",
"required": false
},
"WHOOGLE_CONFIG_NEAR": {
"description": "[CONFIG] Restrict results to only those near a particular city",
"value": "",
"required": false
},
"WHOOGLE_CONFIG_TOR": {
"description": "[CONFIG] Use Tor, if available (set to 1 or leave blank)",
"value": "",
@ -177,18 +127,8 @@
},
"WHOOGLE_CONFIG_STYLE": {
"description": "[CONFIG] Custom CSS styling (paste in CSS or leave blank)",
"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; }",
"required": false
},
"WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED": {
"description": "[CONFIG] Encrypt preferences token, requires WHOOGLE_CONFIG_PREFERENCES_KEY to be set",
"value": "",
"required": false
},
"WHOOGLE_CONFIG_PREFERENCES_KEY": {
"description": "[CONFIG] Key to encrypt preferences",
"value": "NEEDS_TO_BE_MODIFIED",
"required": false
}
}
}

View File

@ -1,303 +1,84 @@
from app.filter import clean_query
from app.request import send_tor_signal
from app.utils.session import generate_key
from app.utils.bangs import gen_bangs_json, load_all_bangs
from app.utils.misc import gen_file_hash, read_config_bool
from app.utils.ua_generator import load_ua_pool
from base64 import b64encode
from bs4 import MarkupResemblesLocatorWarning
from datetime import datetime, timedelta
from dotenv import load_dotenv
from app.utils.session import generate_user_key
from app.utils.bangs import gen_bangs_json
from flask import Flask
from flask_session import Session
import json
import logging.config
import os
import sys
from stem import Signal
import threading
import warnings
from dotenv import load_dotenv
from werkzeug.middleware.proxy_fix import ProxyFix
from app.services.http_client import HttpxClient
from app.services.provider import close_all_clients
from app.version import __version__
app = Flask(__name__, static_folder=os.path.join(
os.path.dirname(os.path.abspath(__file__)), 'static'))
app.wsgi_app = ProxyFix(app.wsgi_app)
# look for WHOOGLE_ENV, else look in parent directory
dot_env_path = os.getenv(
"WHOOGLE_DOTENV_PATH",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../whoogle.env"))
app = Flask(__name__, static_folder=os.path.dirname(
os.path.abspath(__file__)) + '/static')
# Load .env file if enabled
if os.path.exists(dot_env_path):
load_dotenv(dot_env_path)
if os.getenv("WHOOGLE_DOTENV", ''):
dotenv_path = '../whoogle.env'
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)),
dotenv_path))
app.enc_key = generate_key()
if read_config_bool('HTTPS_ONLY'):
app.config['SESSION_COOKIE_NAME'] = '__Secure-session'
app.config['SESSION_COOKIE_SECURE'] = True
app.config['VERSION_NUMBER'] = __version__
app.default_key = generate_user_key()
app.no_cookie_ips = []
app.config['SECRET_KEY'] = os.urandom(32)
app.config['SESSION_TYPE'] = 'filesystem'
app.config['VERSION_NUMBER'] = '0.5.1'
app.config['APP_ROOT'] = os.getenv(
'APP_ROOT',
os.path.dirname(os.path.abspath(__file__)))
app.config['STATIC_FOLDER'] = os.getenv(
'STATIC_FOLDER',
os.path.join(app.config['APP_ROOT'], 'static'))
app.config['BUILD_FOLDER'] = os.path.join(
app.config['STATIC_FOLDER'], 'build')
app.config['CACHE_BUSTING_MAP'] = {}
app.config['BUNDLE_STATIC'] = read_config_bool('WHOOGLE_BUNDLE_STATIC')
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json'), 'r', encoding='utf-8') as f:
app.config['LANGUAGES'] = json.load(f)
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json'), 'r', encoding='utf-8') as f:
app.config['COUNTRIES'] = json.load(f)
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/time_periods.json'), 'r', encoding='utf-8') as f:
app.config['TIME_PERIODS'] = json.load(f)
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json'), 'r', encoding='utf-8') as f:
app.config['TRANSLATIONS'] = json.load(f)
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/themes.json'), 'r', encoding='utf-8') as f:
app.config['THEMES'] = json.load(f)
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/header_tabs.json'), 'r', encoding='utf-8') as f:
app.config['HEADER_TABS'] = json.load(f)
app.config['LANGUAGES'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json')))
app.config['COUNTRIES'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json')))
app.config['TRANSLATIONS'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json')))
app.config['CONFIG_PATH'] = os.getenv(
'CONFIG_VOLUME',
os.path.join(app.config['STATIC_FOLDER'], 'config'))
app.config['DEFAULT_CONFIG'] = os.path.join(
app.config['CONFIG_PATH'],
'config.json')
app.config['CONFIG_DISABLE'] = read_config_bool('WHOOGLE_CONFIG_DISABLE')
app.config['CONFIG_DISABLE'] = os.getenv('WHOOGLE_CONFIG_DISABLE', '')
app.config['SESSION_FILE_DIR'] = os.path.join(
app.config['CONFIG_PATH'],
'session')
# Maximum session file size in bytes (4KB limit to prevent abuse and disk exhaustion)
# Session files larger than this are ignored during cleanup to avoid processing
# potentially malicious or corrupted files
app.config['MAX_SESSION_SIZE'] = 4000
app.config['BANG_PATH'] = os.getenv(
'CONFIG_VOLUME',
os.path.join(app.config['STATIC_FOLDER'], 'bangs'))
app.config['BANG_FILE'] = os.path.join(
app.config['BANG_PATH'],
'bangs.json')
app.config['CSP'] = 'default-src \'none\';' \
'manifest-src \'self\';' \
'img-src \'self\' data:;' \
'style-src \'self\' \'unsafe-inline\';' \
'script-src \'self\';' \
'media-src \'self\';' \
'connect-src \'self\';' \
'form-action \'self\';'
# Global services registry (simple DI)
app.services = {}
@app.teardown_appcontext
def _teardown_clients(exception):
try:
close_all_clients()
except Exception:
pass
# Ensure all necessary directories exist
if not os.path.exists(app.config['CONFIG_PATH']):
os.makedirs(app.config['CONFIG_PATH'])
if not os.path.exists(app.config['SESSION_FILE_DIR']):
os.makedirs(app.config['SESSION_FILE_DIR'])
# Generate DDG bang filter, and create path if it doesn't exist yet
if not os.path.exists(app.config['BANG_PATH']):
os.makedirs(app.config['BANG_PATH'])
if not os.path.exists(app.config['BUILD_FOLDER']):
os.makedirs(app.config['BUILD_FOLDER'])
# Initialize User Agent pool
app.config['UA_CACHE_PATH'] = os.path.join(app.config['CONFIG_PATH'], 'ua_cache.json')
try:
app.config['UA_POOL'] = load_ua_pool(app.config['UA_CACHE_PATH'], count=10)
except Exception as e:
# If UA pool loading fails, log warning and set empty pool
# The gen_user_agent function will handle the fallback
print(f"Warning: Could not initialize UA pool: {e}")
app.config['UA_POOL'] = []
# Session values - Secret key management
# Priority: environment variable → file → generate new
def get_secret_key():
"""Load or generate secret key with validation.
Priority order:
1. WHOOGLE_SECRET_KEY environment variable
2. Existing key file
3. Generate new key and save to file
Returns:
str: Valid secret key for Flask sessions
"""
# Check environment variable first
env_key = os.getenv('WHOOGLE_SECRET_KEY', '').strip()
if env_key:
# Validate env key has minimum length
if len(env_key) >= 32:
return env_key
else:
print(f"Warning: WHOOGLE_SECRET_KEY too short ({len(env_key)} chars, need 32+). Using file/generated key instead.", file=sys.stderr)
# Check file-based key
app_key_path = os.path.join(app.config['CONFIG_PATH'], 'whoogle.key')
if os.path.exists(app_key_path):
try:
with open(app_key_path, 'r', encoding='utf-8') as f:
key = f.read().strip()
# Validate file key
if len(key) >= 32:
return key
else:
print(f"Warning: Key file too short, regenerating", file=sys.stderr)
except (PermissionError, IOError) as e:
print(f"Warning: Could not read key file: {e}", file=sys.stderr)
# Generate new key
new_key = str(b64encode(os.urandom(32)))
try:
with open(app_key_path, 'w', encoding='utf-8') as key_file:
key_file.write(new_key)
except (PermissionError, IOError) as e:
print(f"Warning: Could not save key file: {e}. Key will not persist across restarts.", file=sys.stderr)
return new_key
app.config['SECRET_KEY'] = get_secret_key()
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365)
# NOTE: SESSION_COOKIE_SAMESITE must be set to 'lax' to allow the user's
# previous session to persist when accessing the instance from an external
# link. Setting this value to 'strict' causes Whoogle to revalidate a new
# session, and fail, resulting in cookies being disabled.
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# Config fields that are used to check for updates
app.config['RELEASES_URL'] = 'https://github.com/' \
'benbusby/whoogle-search/releases'
app.config['LAST_UPDATE_CHECK'] = datetime.now() - timedelta(hours=24)
app.config['HAS_UPDATE'] = ''
# The alternative to Google Translate is treated a bit differently than other
# social media site alternatives, in that it is used for any translation
# related searches.
translate_url = os.getenv('WHOOGLE_ALT_TL', 'https://farside.link/lingva')
if not translate_url.startswith('http'):
translate_url = 'https://' + translate_url
app.config['TRANSLATE_URL'] = translate_url
app.config['CSP'] = 'default-src \'none\';' \
'frame-src ' + translate_url + ';' \
'manifest-src \'self\';' \
'img-src \'self\' data:;' \
'style-src \'self\' \'unsafe-inline\';' \
'script-src \'self\';' \
'media-src \'self\';' \
'connect-src \'self\';'
# Generate DDG bang filter
generating_bangs = False
if not os.path.exists(app.config['BANG_FILE']):
generating_bangs = True
with open(app.config['BANG_FILE'], 'w', encoding='utf-8') as f:
json.dump({}, f)
bangs_thread = threading.Thread(
target=gen_bangs_json,
args=(app.config['BANG_FILE'],))
bangs_thread.start()
gen_bangs_json(app.config['BANG_FILE'])
# Build new mapping of static files for cache busting
cache_busting_dirs = ['css', 'js']
for cb_dir in cache_busting_dirs:
full_cb_dir = os.path.join(app.config['STATIC_FOLDER'], cb_dir)
for cb_file in os.listdir(full_cb_dir):
# Create hash from current file state
full_cb_path = os.path.join(full_cb_dir, cb_file)
cb_file_link = gen_file_hash(full_cb_dir, cb_file)
build_path = os.path.join(app.config['BUILD_FOLDER'], cb_file_link)
try:
os.symlink(full_cb_path, build_path)
except FileExistsError:
# Symlink hasn't changed, ignore
pass
# Create mapping for relative path urls
map_path = build_path.replace(app.config['APP_ROOT'], '')
if map_path.startswith('/'):
map_path = map_path[1:]
app.config['CACHE_BUSTING_MAP'][cb_file] = map_path
# Optionally create simple bundled assets (opt-in via WHOOGLE_BUNDLE_STATIC=1)
if app.config['BUNDLE_STATIC']:
# CSS bundle: include all css except theme files (end with -theme.css)
css_dir = os.path.join(app.config['STATIC_FOLDER'], 'css')
css_parts = []
for name in sorted(os.listdir(css_dir)):
if not name.endswith('.css'):
continue
if name.endswith('-theme.css'):
continue
try:
with open(os.path.join(css_dir, name), 'r', encoding='utf-8') as f:
css_parts.append(f.read())
except Exception:
pass
css_bundle = '\n'.join(css_parts)
if css_bundle:
css_tmp = os.path.join(app.config['BUILD_FOLDER'], 'app.css')
with open(css_tmp, 'w', encoding='utf-8') as f:
f.write(css_bundle)
css_hashed = gen_file_hash(app.config['BUILD_FOLDER'], 'app.css')
os.replace(css_tmp, os.path.join(app.config['BUILD_FOLDER'], css_hashed))
map_path = os.path.join('app/static/build', css_hashed)
app.config['CACHE_BUSTING_MAP']['bundle.css'] = map_path
# JS bundle: include all js files
js_dir = os.path.join(app.config['STATIC_FOLDER'], 'js')
js_parts = []
for name in sorted(os.listdir(js_dir)):
if not name.endswith('.js'):
continue
try:
with open(os.path.join(js_dir, name), 'r', encoding='utf-8') as f:
js_parts.append(f.read())
except Exception:
pass
js_bundle = '\n;'.join(js_parts)
if js_bundle:
js_tmp = os.path.join(app.config['BUILD_FOLDER'], 'app.js')
with open(js_tmp, 'w', encoding='utf-8') as f:
f.write(js_bundle)
js_hashed = gen_file_hash(app.config['BUILD_FOLDER'], 'app.js')
os.replace(js_tmp, os.path.join(app.config['BUILD_FOLDER'], js_hashed))
map_path = os.path.join('app/static/build', js_hashed)
app.config['CACHE_BUSTING_MAP']['bundle.js'] = map_path
# Templating functions
app.jinja_env.globals.update(clean_query=clean_query)
app.jinja_env.globals.update(
cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f.lower()])
app.jinja_env.globals.update(
bundle_static=lambda: app.config.get('BUNDLE_STATIC', False))
Session(app)
# Attempt to acquire tor identity, to determine if Tor config is available
send_tor_signal(Signal.HEARTBEAT)
# Suppress spurious warnings from BeautifulSoup
warnings.simplefilter('ignore', MarkupResemblesLocatorWarning)
from app import routes # noqa
# The gen_bangs_json function takes care of loading bangs, so skip it here if
# it's already being loaded
if not generating_bangs:
load_all_bangs(app.config['BANG_FILE'])
# Disable logging from imported modules
logging.config.dictConfig({
'version': 1,

File diff suppressed because it is too large Load Diff

View File

@ -1,88 +1,27 @@
from inspect import Attribute
from typing import Optional
from app.utils.misc import read_config_bool
from flask import current_app
import os
from base64 import urlsafe_b64encode, urlsafe_b64decode
from cryptography.fernet import Fernet
import hashlib
import brotli
import logging
import json
import cssutils
from cssutils.css.cssstylesheet import CSSStyleSheet
from cssutils.css.cssstylerule import CSSStyleRule
# removes warnings from cssutils
cssutils.log.setLevel(logging.CRITICAL)
def get_rule_for_selector(stylesheet: CSSStyleSheet,
selector: str) -> Optional[CSSStyleRule]:
"""Search for a rule that matches a given selector in a stylesheet.
Args:
stylesheet (CSSStyleSheet) -- the stylesheet to search
selector (str) -- the selector to search for
Returns:
Optional[CSSStyleRule] -- the rule that matches the selector or None
"""
for rule in stylesheet.cssRules:
if hasattr(rule, "selectorText") and selector == rule.selectorText:
return rule
return None
class Config:
def __init__(self, **kwargs):
# User agent configuration - default to env_conf if environment variables exist, otherwise default
env_user_agent = os.getenv('WHOOGLE_USER_AGENT', '')
env_mobile_agent = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')
default_ua_option = 'env_conf' if (env_user_agent or env_mobile_agent) else 'default'
self.user_agent = kwargs.get('user_agent', default_ua_option)
self.custom_user_agent = kwargs.get('custom_user_agent', '')
self.use_custom_user_agent = kwargs.get('use_custom_user_agent', False)
self.show_user_agent = read_config_bool('WHOOGLE_CONFIG_SHOW_USER_AGENT')
# Add user agent related keys to safe_keys
# Note: CSE credentials (cse_api_key, cse_id) are intentionally NOT included
# in safe_keys for security - they should not be shareable via URL
self.safe_keys = [
'lang_search',
'lang_interface',
'country',
'theme',
'alts',
'new_tab',
'view_image',
'block',
'safe',
'nojs',
'anon_view',
'preferences_encrypted',
'tbs',
'user_agent',
'custom_user_agent',
'use_custom_user_agent',
'show_user_agent'
]
def read_config_bool(var: str) -> bool:
val = os.getenv(var, '0')
if val.isdigit():
return bool(int(val))
return False
app_config = current_app.config
self.url = os.getenv('WHOOGLE_CONFIG_URL', '')
self.lang_search = os.getenv('WHOOGLE_CONFIG_SEARCH_LANGUAGE', '')
self.lang_interface = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '')
self.style_modified = os.getenv(
'WHOOGLE_CONFIG_STYLE', '')
self.style = os.getenv(
'WHOOGLE_CONFIG_STYLE',
open(os.path.join(app_config['STATIC_FOLDER'],
'css/variables.css')).read())
self.block = os.getenv('WHOOGLE_CONFIG_BLOCK', '')
self.block_title = os.getenv('WHOOGLE_CONFIG_BLOCK_TITLE', '')
self.block_url = os.getenv('WHOOGLE_CONFIG_BLOCK_URL', '')
self.country = os.getenv('WHOOGLE_CONFIG_COUNTRY', '')
self.tbs = os.getenv('WHOOGLE_CONFIG_TIME_PERIOD', '')
self.theme = os.getenv('WHOOGLE_CONFIG_THEME', 'system')
self.ctry = os.getenv('WHOOGLE_CONFIG_COUNTRY', '')
self.safe = read_config_bool('WHOOGLE_CONFIG_SAFE')
self.dark = read_config_bool('WHOOGLE_CONFIG_DARK')
self.alts = read_config_bool('WHOOGLE_CONFIG_ALTS')
self.nojs = read_config_bool('WHOOGLE_CONFIG_NOJS')
self.tor = read_config_bool('WHOOGLE_CONFIG_TOR')
@ -90,25 +29,19 @@ class Config:
self.new_tab = read_config_bool('WHOOGLE_CONFIG_NEW_TAB')
self.view_image = read_config_bool('WHOOGLE_CONFIG_VIEW_IMAGE')
self.get_only = read_config_bool('WHOOGLE_CONFIG_GET_ONLY')
self.anon_view = read_config_bool('WHOOGLE_CONFIG_ANON_VIEW')
self.preferences_encrypted = read_config_bool('WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED')
self.preferences_key = os.getenv('WHOOGLE_CONFIG_PREFERENCES_KEY', '')
# Google Custom Search Engine (CSE) BYOK settings
self.cse_api_key = os.getenv('WHOOGLE_CSE_API_KEY', '')
self.cse_id = os.getenv('WHOOGLE_CSE_ID', '')
self.use_cse = read_config_bool('WHOOGLE_USE_CSE')
self.accept_language = False
self.safe_keys = [
'lang_search',
'lang_interface',
'ctry',
'dark'
]
# Skip setting custom config if there isn't one
if kwargs:
mutable_attrs = self.get_mutable_attrs()
for attr in mutable_attrs:
if attr == 'show_user_agent':
# Handle show_user_agent as boolean
self.show_user_agent = bool(kwargs.get(attr))
elif attr in kwargs.keys():
if attr in kwargs.keys():
setattr(self, attr, kwargs[attr])
elif attr not in kwargs.keys() and mutable_attrs[attr] == bool:
setattr(self, attr, False)
@ -130,50 +63,6 @@ class Config:
if not name.startswith("__")
and (type(attr) is bool or type(attr) is str)}
def get_attrs(self):
return {name: attr for name, attr in self.__dict__.items()
if not name.startswith("__")
and (type(attr) is bool or type(attr) is str)}
@property
def style(self) -> str:
"""Returns the default style updated with specified modifications.
Returns:
str -- the new style
"""
vars_path = os.path.join(current_app.config['STATIC_FOLDER'], 'css/variables.css')
with open(vars_path, 'r', encoding='utf-8') as f:
style_sheet = cssutils.parseString(f.read())
modified_sheet = cssutils.parseString(self.style_modified)
for rule in modified_sheet:
rule_default = get_rule_for_selector(style_sheet,
rule.selectorText)
# if modified rule is in default stylesheet, update it
if rule_default is not None:
# TODO: update this in a smarter way to handle :root better
# for now if we change a varialbe in :root all other default
# variables need to be also present
rule_default.style = rule.style
# else add the new rule to the default stylesheet
else:
style_sheet.add(rule)
return str(style_sheet.cssText, 'utf-8')
@property
def preferences(self) -> str:
# if encryption key is not set will uncheck preferences encryption
if self.preferences_encrypted:
self.preferences_encrypted = bool(self.preferences_key)
# add a tag for visibility if preferences token startswith 'e' it means
# the token is encrypted, 'u' means the token is unencrypted and can be
# used by other whoogle instances
encrypted_flag = "e" if self.preferences_encrypted else 'u'
preferences_digest = self._encode_preferences()
return f"{encrypted_flag}{preferences_digest}"
def is_safe_key(self, key) -> bool:
"""Establishes a group of config options that are safe to set
in the url.
@ -212,104 +101,8 @@ class Config:
Returns:
Config -- a modified config object
"""
if 'preferences' in params:
params_new = self._decode_preferences(params['preferences'])
# if preferences leads to an empty dictionary it means preferences
# parameter was not decrypted successfully
if len(params_new):
params = params_new
for param_key in params.keys():
if not self.is_safe_key(param_key):
continue
param_val = params.get(param_key)
if param_val == 'off':
param_val = False
elif isinstance(param_val, str):
if param_val.isdigit():
param_val = int(param_val)
self[param_key] = param_val
self[param_key] = params.get(param_key)
return self
def to_params(self, keys: list = []) -> str:
"""Generates a set of safe params for using in Whoogle URLs
Args:
keys (list) -- optional list of keys of URL parameters
Returns:
str -- a set of URL parameters
"""
if not len(keys):
keys = self.safe_keys
param_str = ''
for safe_key in keys:
if not self[safe_key]:
continue
param_str = param_str + f'&{safe_key}={self[safe_key]}'
return param_str
def _get_fernet_key(self, password: str) -> bytes:
"""Derive a Fernet-compatible key from a password using PBKDF2.
Note: This uses a static salt for simplicity. This is a breaking change
from the previous MD5-based implementation. Existing encrypted preferences
will need to be re-encrypted.
Args:
password: The password to derive the key from
Returns:
bytes: A URL-safe base64 encoded 32-byte key suitable for Fernet
"""
# Use a static salt derived from app context
# In a production system, you'd want to store per-user salts
salt = b'whoogle-preferences-salt-v2'
# Derive a 32-byte key using PBKDF2 with SHA256
# 100,000 iterations is a reasonable balance of security and performance
kdf_key = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt,
100000,
dklen=32
)
# Fernet requires a URL-safe base64 encoded key
return urlsafe_b64encode(kdf_key)
def _encode_preferences(self) -> str:
preferences_json = json.dumps(self.get_attrs()).encode()
compressed_preferences = brotli.compress(preferences_json)
if self.preferences_encrypted and self.preferences_key:
key = self._get_fernet_key(self.preferences_key)
encrypted_preferences = Fernet(key).encrypt(compressed_preferences)
compressed_preferences = brotli.compress(encrypted_preferences)
return urlsafe_b64encode(compressed_preferences).decode()
def _decode_preferences(self, preferences: str) -> dict:
mode = preferences[0]
preferences = preferences[1:]
try:
decoded_data = brotli.decompress(urlsafe_b64decode(preferences.encode() + b'=='))
if mode == 'e' and self.preferences_key:
# preferences are encrypted
key = self._get_fernet_key(self.preferences_key)
decrypted_data = Fernet(key).decrypt(decoded_data)
decoded_data = brotli.decompress(decrypted_data)
config = json.loads(decoded_data)
except Exception:
config = {}
return config

View File

@ -1,22 +0,0 @@
from enum import Enum
class Endpoint(Enum):
autocomplete = 'autocomplete'
home = 'home'
healthz = 'healthz'
config = 'config'
opensearch = 'opensearch.xml'
search = 'search'
search_html = 'search.html'
url = 'url'
imgres = 'imgres'
element = 'element'
window = 'window'
def __str__(self):
return self.value
def in_path(self, path: str) -> bool:
return path.startswith(self.value) or \
path.startswith(f'/{self.value}')

View File

@ -1,48 +0,0 @@
from bs4 import BeautifulSoup
class GClasses:
"""A class for tracking obfuscated class names used in Google results that
are directly referenced in Whoogle's filtering code.
Note: Using these should be a last resort. It is always preferred to filter
results using structural cues instead of referencing class names, as these
are liable to change at any moment.
"""
main_tbm_tab = 'KP7LCb'
images_tbm_tab = 'n692Zd'
footer = 'TuS8Ad'
result_class_a = 'ZINbbc'
result_class_b = 'luh4td'
scroller_class = 'idg8be'
line_tag = 'BsXmcf'
result_classes = {
result_class_a: ['Gx5Zad'],
result_class_b: ['fP1Qef']
}
@classmethod
def replace_css_classes(cls, soup: BeautifulSoup) -> BeautifulSoup:
"""Replace updated Google classes with the original class names that
Whoogle relies on for styling.
Args:
soup: The result page as a BeautifulSoup object
Returns:
BeautifulSoup: The new BeautifulSoup
"""
result_divs = soup.find_all('div', {
'class': [_ for c in cls.result_classes.values() for _ in c]
})
for div in result_divs:
new_class = ' '.join(div['class'])
for key, val in cls.result_classes.items():
new_class = ' '.join(new_class.replace(_, key) for _ in val)
div['class'] = new_class.split(' ')
return soup
def __str__(self):
return self.value

View File

@ -1,20 +1,22 @@
from app.models.config import Config
from app.utils.misc import read_config_bool
from app.services.provider import get_http_client
from app.utils.ua_generator import load_ua_pool, get_random_ua, DEFAULT_FALLBACK_UA
from defusedxml import ElementTree as ET
import httpx
from datetime import datetime
import xml.etree.ElementTree as ET
import random
import requests
from requests import Response, ConnectionError
import urllib.parse as urlparse
import os
from stem import Signal, SocketError
from stem.connection import AuthenticationFailure
from stem.control import Controller
from stem.connection import authenticate_cookie, authenticate_password
SEARCH_URL = 'https://www.google.com/search?gbv=1&q='
MAPS_URL = 'https://maps.google.com/maps'
AUTOCOMPLETE_URL = ('https://suggestqueries.google.com/'
'complete/search?client=toolbar&')
MOBILE_UA = '{}/5.0 (Android 0; Mobile; rv:54.0) Gecko/54.0 {}/59.0'
DESKTOP_UA = '{}/5.0 (X11; {} x86_64; rv:75.0) Gecko/20100101 {}/75.0'
# Valid query params
VALID_PARAMS = ['tbs', 'tbm', 'start', 'near', 'source', 'nfpr']
@ -36,87 +38,29 @@ class TorError(Exception):
def send_tor_signal(signal: Signal) -> bool:
use_pass = read_config_bool('WHOOGLE_TOR_USE_PASS')
confloc = './misc/tor/control.conf'
# Check that the custom location of conf is real.
temp = os.getenv('WHOOGLE_TOR_CONF', '')
if os.path.isfile(temp):
confloc = temp
# Attempt to authenticate and send signal.
try:
with Controller.from_port(port=9051) as c:
if use_pass:
with open(confloc, "r") as conf:
# Scan for the last line of the file.
for line in conf:
pass
secret = line.strip('\n')
authenticate_password(c, password=secret)
else:
cookie_path = '/var/lib/tor/control_auth_cookie'
authenticate_cookie(c, cookie_path=cookie_path)
c.authenticate()
c.signal(signal)
os.environ['TOR_AVAILABLE'] = '1'
return True
except (SocketError, AuthenticationFailure,
ConnectionRefusedError, ConnectionError):
# TODO: Handle Tor authentication (password and cookie)
except (SocketError, ConnectionRefusedError, ConnectionError):
os.environ['TOR_AVAILABLE'] = '0'
return False
def gen_user_agent(config, is_mobile) -> str:
# If using custom user agent, return the custom string
if config.user_agent == 'custom' and config.custom_user_agent:
return config.custom_user_agent
def gen_user_agent(is_mobile) -> str:
firefox = random.choice(['Choir', 'Squier', 'Higher', 'Wire']) + 'fox'
linux = random.choice(['Win', 'Sin', 'Gin', 'Fin', 'Kin']) + 'ux'
# If using environment configuration
if config.user_agent == 'env_conf':
if is_mobile:
env_ua = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')
if env_ua:
return env_ua
else:
env_ua = os.getenv('WHOOGLE_USER_AGENT', '')
if env_ua:
return env_ua
# If env vars are not set, fall back to Opera UA
return DEFAULT_FALLBACK_UA
if is_mobile:
return MOBILE_UA.format("Mozilla", firefox)
# If using default user agent - use auto-generated Opera UA pool
if config.user_agent == 'default':
try:
# Try to load UA pool from cache (lazy loading if not in app.config)
# First check if we have access to Flask app context
try:
from flask import current_app
if hasattr(current_app, 'config') and 'UA_POOL' in current_app.config:
ua_pool = current_app.config['UA_POOL']
else:
# Fall back to loading from disk
raise ImportError("UA_POOL not in app config")
except (ImportError, RuntimeError):
# No Flask context available or UA_POOL not in config, load from disk
config_path = os.environ.get('CONFIG_VOLUME',
os.path.join(os.path.dirname(os.path.abspath(__file__)),
'static', 'config'))
cache_path = os.path.join(config_path, 'ua_cache.json')
ua_pool = load_ua_pool(cache_path, count=10)
return get_random_ua(ua_pool)
except Exception as e:
# If anything goes wrong, fall back to default Opera UA
print(f"Warning: Could not load UA pool, using fallback Opera UA: {e}")
return DEFAULT_FALLBACK_UA
# Fallback for backwards compatibility (old configs or invalid user_agent values)
return DEFAULT_FALLBACK_UA
return DESKTOP_UA.format("Mozilla", linux, firefox)
def gen_query(query, args, config) -> str:
def gen_query(query, args, config, near_city=None) -> str:
param_dict = {key: '' for key in VALID_PARAMS}
# Use :past(hour/day/week/month/year) if available
@ -125,8 +69,8 @@ def gen_query(query, args, config) -> str:
if ':past' in query and 'tbs' not in args:
time_range = str.strip(query.split(':past', 1)[-1])
param_dict['tbs'] = '&tbs=' + ('qdr:' + str.lower(time_range[0]))
elif 'tbs' in args or 'tbs' in config:
result_tbs = args.get('tbs') if 'tbs' in args else config['tbs']
elif 'tbs' in args:
result_tbs = args.get('tbs')
param_dict['tbs'] = '&tbs=' + result_tbs
# Occasionally the 'tbs' param provided by google also contains a
@ -147,18 +91,14 @@ def gen_query(query, args, config) -> str:
# Pass along type of results (news, images, books, etc)
if 'tbm' in args:
param_dict['tbm'] = '&tbm=' + args.get('tbm')
# Google Images now expects the modern udm=2 layout; force it when
# requesting images to avoid redirects to the new AI/text layout.
if args.get('tbm') == 'isch' and 'udm' not in args:
param_dict['udm'] = '&udm=2'
# Get results page start value (10 per page, ie page 2 start val = 20)
if 'start' in args:
param_dict['start'] = '&start=' + args.get('start')
# Search for results near a particular city, if available
if config.near:
param_dict['near'] = '&near=' + urlparse.quote(config.near)
if near_city:
param_dict['near'] = '&near=' + urlparse.quote(near_city)
# Set language for results (lr) if source isn't set, otherwise use the
# result language param provided in the results
@ -176,26 +116,15 @@ def gen_query(query, args, config) -> str:
if 'nfpr' in args:
param_dict['nfpr'] = '&nfpr=' + args.get('nfpr')
# 'chips' is used in image tabs to pass the optional 'filter' to add to the
# given search term
if 'chips' in args:
param_dict['chips'] = '&chips=' + args.get('chips')
param_dict['gl'] = (
'&gl=' + config.country
) if config.country else ''
param_dict['cr'] = ('&cr=' + config.ctry) if config.ctry else ''
param_dict['hl'] = (
'&hl=' + config.lang_interface.replace('lang_', '')
) if config.lang_interface else ''
param_dict['safe'] = '&safe=' + ('active' if config.safe else 'off')
# Block all sites specified in the user config
unquoted_query = urlparse.unquote(query)
for blocked_site in config.block.replace(' ', '').split(','):
if not blocked_site:
continue
block = (' -site:' + blocked_site)
query += block if block not in unquoted_query else ''
for blocked in config.block.split(','):
query += (' -site:' + blocked) if blocked else ''
for val in param_dict.values():
if not val:
@ -215,66 +144,37 @@ class Request:
config: the user's current whoogle configuration
"""
def __init__(self, normal_ua, root_path, config: Config, http_client=None):
self.search_url = 'https://www.google.com/search?gbv=1&q='
# Google Images rejects the lightweight gbv=1 interface. Use the
# modern udm=2 entrypoint specifically for image searches to avoid the
# "update your browser" interstitial.
self.image_search_url = 'https://www.google.com/search?udm=2&q='
# Optionally send heartbeat to Tor to determine availability
# Only when Tor is enabled in config to avoid unnecessary socket usage
if config.tor:
send_tor_signal(Signal.HEARTBEAT)
def __init__(self, normal_ua, root_path, config: Config):
# Send heartbeat to Tor, used in determining if the user can or cannot
# enable Tor for future requests
send_tor_signal(Signal.HEARTBEAT)
self.language = config.lang_search if config.lang_search else ''
self.country = config.country if config.country else ''
# For setting Accept-language Header
self.lang_interface = ''
if config.accept_language:
self.lang_interface = config.lang_interface
self.mobile = bool(normal_ua) and ('Android' in normal_ua
or 'iPhone' in normal_ua)
# Generate user agent based on config
self.modified_user_agent = gen_user_agent(config, self.mobile)
self.language = config.lang_search
self.mobile = 'Android' in normal_ua or 'iPhone' in normal_ua
self.modified_user_agent = gen_user_agent(self.mobile)
if not self.mobile:
self.modified_user_agent_mobile = gen_user_agent(config, True)
self.modified_user_agent_mobile = gen_user_agent(True)
# Dedicated modern UA to use when Google rejects legacy ones (e.g. Images)
self.image_user_agent = (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/127.0.0.0 Safari/537.36'
)
# Set up proxy configuration
proxy_path = os.environ.get('WHOOGLE_PROXY_LOC', '')
if proxy_path:
proxy_type = os.environ.get('WHOOGLE_PROXY_TYPE', '')
proxy_user = os.environ.get('WHOOGLE_PROXY_USER', '')
proxy_pass = os.environ.get('WHOOGLE_PROXY_PASS', '')
# Set up proxy, if previously configured
if os.environ.get('WHOOGLE_PROXY_LOC'):
auth_str = ''
if proxy_user:
auth_str = f'{proxy_user}:{proxy_pass}@'
proxy_str = f'{proxy_type}://{auth_str}{proxy_path}'
if os.environ.get('WHOOGLE_PROXY_USER', ''):
auth_str = os.environ.get('WHOOGLE_PROXY_USER', '') + \
':' + os.environ.get('WHOOGLE_PROXY_PASS', '')
self.proxies = {
'https': proxy_str,
'http': proxy_str
'http': os.environ.get('WHOOGLE_PROXY_TYPE', '') + '://' +
auth_str + '@' + os.environ.get('WHOOGLE_PROXY_LOC', ''),
}
self.proxies['https'] = self.proxies['http'].replace('http',
'https')
else:
self.proxies = {
'http': 'socks5://127.0.0.1:9050',
'https': 'socks5://127.0.0.1:9050'
} if config.tor else {}
self.tor = config.tor
self.tor_valid = False
self.root_path = root_path
# Initialize HTTP client (shared per proxies)
self.http_client = http_client or get_http_client(self.proxies)
def __getitem__(self, name):
return getattr(self, name)
@ -289,39 +189,19 @@ class Request:
list: The list of matches for possible search suggestions
"""
# Check if autocomplete is disabled via environment variable
if os.environ.get('WHOOGLE_AUTOCOMPLETE', '1') == '0':
return []
try:
ac_query = dict(q=query)
if self.language:
ac_query['lr'] = self.language
if self.country:
ac_query['gl'] = self.country
if self.lang_interface:
ac_query['hl'] = self.lang_interface
ac_query = dict(hl=self.language, q=query)
response = self.send(base_url=AUTOCOMPLETE_URL,
query=urlparse.urlencode(ac_query)).text
response = self.send(base_url=AUTOCOMPLETE_URL,
query=urlparse.urlencode(ac_query)).text
if not response:
return []
try:
root = ET.fromstring(response)
return [_.attrib['data'] for _ in
root.findall('.//suggestion/[@data]')]
except ET.ParseError:
# Malformed XML response
return []
except Exception as e:
# Log the error but don't crash - autocomplete is non-essential
print(f"Autocomplete error: {str(e)}")
if not response:
return []
def send(self, base_url='', query='', attempt=0,
force_mobile=False, user_agent=''):
root = ET.fromstring(response)
return [_.attrib['data'] for _ in
root.findall('.//suggestion/[@data]')]
def send(self, base_url=SEARCH_URL, query='', attempt=0,
force_mobile=False) -> Response:
"""Sends an outbound request to a URL. Optionally sends the request
using Tor, if enabled by the user.
@ -330,69 +210,27 @@ class Request:
query: The optional query string for the request
attempt: The number of attempts made for the request
(used for cycling through Tor identities, if enabled)
force_mobile: Optional flag to enable a mobile user agent
(used for fetching full size images in search results)
Returns:
Response: The Response object returned by the requests call
"""
use_client_user_agent = int(os.environ.get('WHOOGLE_USE_CLIENT_USER_AGENT', '0'))
if user_agent and use_client_user_agent == 1:
modified_user_agent = user_agent
if force_mobile and not self.mobile:
modified_user_agent = self.modified_user_agent_mobile
else:
if force_mobile and not self.mobile:
modified_user_agent = self.modified_user_agent_mobile
else:
modified_user_agent = self.modified_user_agent
# Some Google endpoints (notably Images) now refuse legacy user agents.
# If an image search is detected and the generated UA isn't Chromium-
# like, retry with a modern Chrome string to avoid the "update your
# browser" interstitial.
if (('tbm=isch' in query) or ('udm=2' in query)) and 'Chrome' not in modified_user_agent:
modified_user_agent = self.image_user_agent
modified_user_agent = self.modified_user_agent
headers = {
'User-Agent': modified_user_agent,
'Accept': ('text/html,application/xhtml+xml,application/xml;'
'q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8'),
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Cache-Control': 'max-age=0',
'Pragma': 'no-cache',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-User': '?1',
'Sec-Fetch-Dest': 'document'
'User-Agent': modified_user_agent
}
# Only attach client hints when using a Chromium-like user agent to
# avoid sending conflicting information that can trigger unsupported
# browser pages.
if 'Chrome' in headers['User-Agent']:
headers.update({
'Sec-CH-UA': (
'"Not/A)Brand";v="8", '
'"Chromium";v="127", '
'"Google Chrome";v="127"'
),
'Sec-CH-UA-Mobile': '?0',
'Sec-CH-UA-Platform': '"Windows"'
})
# Add Accept-Language header tied to the current config if requested
if self.lang_interface:
headers['Accept-Language'] = (
self.lang_interface.replace('lang_', '') + ';q=1.0'
# FIXME: Should investigate this further to ensure the consent
# view is suppressed correctly
now = datetime.now()
cookies = {
'CONSENT': 'YES+cb.{:d}{:02d}{:02d}-17-p0.de+F+678'.format(
now.year, now.month, now.day
)
# Consent cookies keep Google from showing the interstitial consent wall
consent_cookies = {
'CONSENT': 'PENDING+987',
'SOCS': 'CAESHAgBEhIaAB'
}
# Validate Tor conn and request new identity if the last one failed
@ -405,39 +243,27 @@ class Request:
# Make sure that the tor connection is valid, if enabled
if self.tor:
try:
tor_check = self.http_client.get('https://check.torproject.org/',
headers=headers,
retries=1)
self.tor_valid = 'Congratulations' in tor_check.text
tor_check = requests.get('https://check.torproject.org/',
proxies=self.proxies, headers=headers)
self.tor_valid = 'Congratulations' in tor_check.text
if not self.tor_valid:
raise TorError(
"Tor connection succeeded, but the connection could "
"not be validated by torproject.org",
disable=True)
except httpx.RequestError:
if not self.tor_valid:
raise TorError(
"Error raised during Tor connection validation",
"Tor connection succeeded, but the connection could not "
"be validated by torproject.org",
disable=True)
search_base = base_url or self.search_url
if not base_url and ('tbm=isch' in query or 'udm=2' in query):
search_base = self.image_search_url
try:
response = self.http_client.get(
search_base + query,
headers=headers,
cookies=consent_cookies)
except httpx.HTTPError as e:
raise
response = requests.get(
base_url + query,
proxies=self.proxies,
headers=headers,
cookies=cookies)
# Retry query with new identity if using Tor (max 10 attempts)
if 'form id="captcha-form"' in response.text and self.tor:
attempt += 1
if attempt > 10:
raise TorError("Tor query failed -- max attempts exceeded 10")
return self.send(search_base, query, attempt)
return self.send(base_url, query, attempt)
return response

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +0,0 @@

View File

@ -1,452 +0,0 @@
"""Google Custom Search Engine (CSE) API Client
This module provides a client for Google's Custom Search JSON API,
allowing users to bring their own API key (BYOK) for search functionality.
"""
import httpx
from typing import Optional
from dataclasses import dataclass
from urllib.parse import urlparse
from flask import render_template
# Google Custom Search API endpoint
CSE_API_URL = 'https://www.googleapis.com/customsearch/v1'
class CSEException(Exception):
"""Exception raised for CSE API errors"""
def __init__(self, message: str, code: int = 500, is_quota_error: bool = False):
self.message = message
self.code = code
self.is_quota_error = is_quota_error
super().__init__(self.message)
@dataclass
class CSEError:
"""Represents an error from the CSE API"""
code: int
message: str
@property
def is_quota_exceeded(self) -> bool:
return self.code == 429 or 'quota' in self.message.lower()
@property
def is_invalid_key(self) -> bool:
return self.code == 400 or 'invalid' in self.message.lower()
@dataclass
class CSEResult:
"""Represents a single search result from CSE API"""
title: str
link: str
snippet: str
display_link: str
html_title: Optional[str] = None
html_snippet: Optional[str] = None
# Image-specific fields (populated for image search)
image_url: Optional[str] = None
thumbnail_url: Optional[str] = None
image_width: Optional[int] = None
image_height: Optional[int] = None
context_link: Optional[str] = None # Page where image was found
@dataclass
class CSEResponse:
"""Represents a complete CSE API response"""
results: list[CSEResult]
total_results: str
search_time: float
query: str
start_index: int
is_image_search: bool = False
error: Optional[CSEError] = None
@property
def has_error(self) -> bool:
return self.error is not None
@property
def has_results(self) -> bool:
return len(self.results) > 0
class CSEClient:
"""Client for Google Custom Search Engine API
Usage:
client = CSEClient(api_key='your-key', cse_id='your-cse-id')
response = client.search('python programming')
if response.has_error:
print(f"Error: {response.error.message}")
else:
for result in response.results:
print(f"{result.title}: {result.link}")
"""
def __init__(self, api_key: str, cse_id: str, timeout: float = 10.0):
"""Initialize CSE client
Args:
api_key: Google API key with Custom Search API enabled
cse_id: Custom Search Engine ID (cx parameter)
timeout: Request timeout in seconds
"""
self.api_key = api_key
self.cse_id = cse_id
self.timeout = timeout
self._client = httpx.Client(timeout=timeout)
def search(
self,
query: str,
start: int = 1,
num: int = 10,
safe: str = 'off',
language: str = '',
country: str = '',
search_type: str = ''
) -> CSEResponse:
"""Execute a search query against the CSE API
Args:
query: Search query string
start: Starting result index (1-based, for pagination)
num: Number of results to return (max 10)
safe: Safe search setting ('off', 'medium', 'high')
language: Language restriction (e.g., 'lang_en')
country: Country restriction (e.g., 'countryUS')
search_type: Type of search ('image' for image search, '' for web)
Returns:
CSEResponse with results or error information
"""
params = {
'key': self.api_key,
'cx': self.cse_id,
'q': query,
'start': start,
'num': min(num, 10), # API max is 10
'safe': safe,
}
# Add search type for image search
if search_type == 'image':
params['searchType'] = 'image'
# Add optional parameters
if language:
# CSE uses 'lr' for language restrict
params['lr'] = language
if country:
# CSE uses 'cr' for country restrict
params['cr'] = country
try:
response = self._client.get(CSE_API_URL, params=params)
data = response.json()
# Check for API errors
if 'error' in data:
error_info = data['error']
return CSEResponse(
results=[],
total_results='0',
search_time=0.0,
query=query,
start_index=start,
error=CSEError(
code=error_info.get('code', 500),
message=error_info.get('message', 'Unknown error')
)
)
# Parse successful response
search_info = data.get('searchInformation', {})
items = data.get('items', [])
is_image = search_type == 'image'
results = []
for item in items:
# Extract image-specific data if present
image_data = item.get('image', {})
results.append(CSEResult(
title=item.get('title', ''),
link=item.get('link', ''),
snippet=item.get('snippet', ''),
display_link=item.get('displayLink', ''),
html_title=item.get('htmlTitle'),
html_snippet=item.get('htmlSnippet'),
# Image fields
image_url=item.get('link') if is_image else None,
thumbnail_url=image_data.get('thumbnailLink'),
image_width=image_data.get('width'),
image_height=image_data.get('height'),
context_link=image_data.get('contextLink')
))
return CSEResponse(
results=results,
total_results=search_info.get('totalResults', '0'),
search_time=float(search_info.get('searchTime', 0)),
query=query,
start_index=start,
is_image_search=is_image
)
except httpx.TimeoutException:
return CSEResponse(
results=[],
total_results='0',
search_time=0.0,
query=query,
start_index=start,
error=CSEError(code=408, message='Request timed out')
)
except httpx.RequestError as e:
return CSEResponse(
results=[],
total_results='0',
search_time=0.0,
query=query,
start_index=start,
error=CSEError(code=500, message=f'Request failed: {str(e)}')
)
except Exception as e:
return CSEResponse(
results=[],
total_results='0',
search_time=0.0,
query=query,
start_index=start,
error=CSEError(code=500, message=f'Unexpected error: {str(e)}')
)
def close(self):
"""Close the HTTP client"""
self._client.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def cse_results_to_html(response: CSEResponse, query: str) -> str:
"""Convert CSE API response to HTML matching Whoogle's result format
This generates HTML that mimics the structure expected by Whoogle's
existing filter and result processing pipeline.
Args:
response: CSEResponse from the API
query: Original search query
Returns:
HTML string formatted like Google search results
"""
if response.has_error:
error = response.error
if error.is_quota_exceeded:
return _error_html(
'API Quota Exceeded',
'Your Google Custom Search API quota has been exceeded. '
'Free tier allows 100 queries/day. Wait until midnight PT '
'or enable billing in Google Cloud Console.'
)
elif error.is_invalid_key:
return _error_html(
'Invalid API Key',
'Your Google Custom Search API key is invalid. '
'Please check your API key and CSE ID in settings.'
)
else:
return _error_html('Search Error', error.message)
if not response.has_results:
return _no_results_html(query)
# Use different HTML structure for image vs web results
if response.is_image_search:
return _image_results_html(response, query)
# Build HTML results matching Whoogle's expected structure
results_html = []
for result in response.results:
# Escape HTML in content
title = _escape_html(result.title)
snippet = _escape_html(result.snippet)
link = result.link
display_link = _escape_html(result.display_link)
# Use HTML versions if available (they have bold tags for query terms)
if result.html_title:
title = result.html_title
if result.html_snippet:
snippet = result.html_snippet
# Match the structure used by Google/mock results
result_html = f'''
<div class="ZINbbc xpd O9g5cc uUPGi">
<div class="kCrYT">
<a href="{link}">
<h3 class="BNeawe vvjwJb AP7Wnd">{title}</h3>
<div class="BNeawe UPmit AP7Wnd luh4tb" style="color: var(--whoogle-result-url);">{display_link}</div>
</a>
</div>
<div class="kCrYT">
<div class="BNeawe s3v9rd AP7Wnd">
<span class="VwiC3b">{snippet}</span>
</div>
</div>
</div>
'''
results_html.append(result_html)
# Build pagination if needed
pagination_html = ''
if int(response.total_results) > 10:
pagination_html = _pagination_html(response.start_index, response.query)
# Wrap in expected structure
# Add data-cse attribute to prevent collapse_sections from collapsing these results
return f'''
<html>
<body>
<div id="main" data-cse="true">
<div id="cnt">
<div id="rcnt">
<div id="center_col">
<div id="res">
<div id="search">
<div id="rso">
{''.join(results_html)}
</div>
</div>
</div>
{pagination_html}
</div>
</div>
</div>
</div>
</body>
</html>
'''
def _escape_html(text: str) -> str:
"""Escape HTML special characters"""
if not text:
return ''
return (text
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#39;'))
def _error_html(title: str, message: str) -> str:
"""Generate error HTML"""
return f'''
<html>
<body>
<div id="main">
<div style="padding: 20px; text-align: center;">
<h2 style="color: #d93025;">{_escape_html(title)}</h2>
<p>{_escape_html(message)}</p>
</div>
</div>
</body>
</html>
'''
def _no_results_html(query: str) -> str:
"""Generate no results HTML"""
return f'''
<html>
<body>
<div id="main">
<div style="padding: 20px;">
<p>No results found for <b>{_escape_html(query)}</b></p>
</div>
</div>
</body>
</html>
'''
def _image_results_html(response: CSEResponse, query: str) -> str:
"""Generate HTML for image search results using the imageresults template
Args:
response: CSEResponse with image results
query: Original search query
Returns:
HTML string formatted for image results display
"""
# Convert CSE results to the format expected by imageresults.html template
results = []
for result in response.results:
image_url = result.image_url or result.link
thumbnail_url = result.thumbnail_url or image_url
web_page = result.context_link or result.link
domain = urlparse(web_page).netloc if web_page else result.display_link
results.append({
'domain': domain,
'img_url': image_url,
'web_page': web_page,
'img_tbn': thumbnail_url
})
# Build pagination link if needed
next_link = None
if int(response.total_results) > response.start_index + len(response.results) - 1:
next_start = response.start_index + 10
next_link = f'search?q={query}&tbm=isch&start={next_start}'
# Use the same template as regular image results
return render_template(
'imageresults.html',
length=len(results),
results=results,
view_label="View Image",
next_link=next_link
)
def _pagination_html(current_start: int, query: str) -> str:
"""Generate pagination links"""
# CSE API uses 1-based indexing, 10 results per page
current_page = (current_start - 1) // 10 + 1
prev_link = ''
next_link = ''
if current_page > 1:
prev_start = (current_page - 2) * 10 + 1
prev_link = f'<a href="search?q={query}&start={prev_start}">Previous</a>'
next_start = current_page * 10 + 1
next_link = f'<a href="search?q={query}&start={next_start}">Next</a>'
return f'''
<div id="foot" style="text-align: center; padding: 20px;">
{prev_link}
<span style="margin: 0 20px;">Page {current_page}</span>
{next_link}
</div>
'''

View File

@ -1,219 +0,0 @@
import threading
import time
from typing import Any, Dict, Optional, Tuple
import httpx
from cachetools import TTLCache
import ssl
import os
# Import h2 exceptions for better error handling
try:
from h2.exceptions import ProtocolError as H2ProtocolError
except ImportError:
H2ProtocolError = None
class HttpxClient:
"""Thin wrapper around httpx.Client providing simple retries and optional TTL caching.
The client is intended to be safe for reuse across requests. Per-request
overrides for headers/cookies are supported.
"""
def __init__(
self,
proxies: Optional[Dict[str, str]] = None,
timeout_seconds: float = 15.0,
cache_ttl_seconds: int = 30,
cache_maxsize: int = 256,
http2: bool = True) -> None:
# Allow disabling HTTP/2 via environment variable
# HTTP/2 can sometimes cause protocol errors with certain servers
if os.environ.get('WHOOGLE_DISABLE_HTTP2', '').lower() in ('1', 'true', 't', 'yes', 'y'):
http2 = False
client_kwargs = dict(http2=http2,
timeout=timeout_seconds,
follow_redirects=True)
# Prefer future-proof mounts when proxies are provided; fall back to proxies=
self._proxies = proxies or {}
self._http2 = http2
# Determine verify behavior and initialize client with fallbacks
self._verify = self._determine_verify_setting()
try:
self._client = self._build_client(client_kwargs, self._verify)
except ssl.SSLError:
# Fallback to system trust store
try:
system_ctx = ssl.create_default_context()
self._client = self._build_client(client_kwargs, system_ctx)
self._verify = system_ctx
except ssl.SSLError:
insecure_fallback = os.environ.get('WHOOGLE_INSECURE_FALLBACK', '0').lower() in ('1', 'true', 't', 'yes', 'y')
if insecure_fallback:
self._client = self._build_client(client_kwargs, False)
self._verify = False
else:
raise
self._timeout_seconds = timeout_seconds
self._cache = TTLCache(maxsize=cache_maxsize, ttl=cache_ttl_seconds)
self._cache_lock = threading.Lock()
def _determine_verify_setting(self):
"""Determine SSL verification setting from environment.
Honors:
- WHOOGLE_CA_BUNDLE: path to CA bundle file
- WHOOGLE_SSL_VERIFY: '0' to disable verification
- WHOOGLE_SSL_BACKEND: 'system' to prefer system trust store
"""
ca_bundle = os.environ.get('WHOOGLE_CA_BUNDLE', '').strip()
if ca_bundle:
return ca_bundle
verify_env = os.environ.get('WHOOGLE_SSL_VERIFY', '1').lower()
if verify_env in ('0', 'false', 'no', 'n'):
return False
backend = os.environ.get('WHOOGLE_SSL_BACKEND', '').lower()
if backend == 'system':
return ssl.create_default_context()
return True
def _build_client(self, client_kwargs: Dict[str, Any], verify: Any) -> httpx.Client:
"""Construct httpx.Client with proxies and provided verify setting."""
kwargs = dict(client_kwargs)
kwargs['verify'] = verify
if self._proxies:
proxy_values = list(self._proxies.values())
single_proxy = proxy_values[0] if proxy_values and all(v == proxy_values[0] for v in proxy_values) else None
if single_proxy:
try:
return httpx.Client(proxy=single_proxy, **kwargs)
except TypeError:
try:
return httpx.Client(proxies=self._proxies, **kwargs)
except TypeError:
mounts: Dict[str, httpx.Proxy] = {}
for scheme_key, url in self._proxies.items():
prefix = f"{scheme_key}://"
mounts[prefix] = httpx.Proxy(url)
return httpx.Client(mounts=mounts, **kwargs)
else:
try:
return httpx.Client(proxies=self._proxies, **kwargs)
except TypeError:
mounts: Dict[str, httpx.Proxy] = {}
for scheme_key, url in self._proxies.items():
prefix = f"{scheme_key}://"
mounts[prefix] = httpx.Proxy(url)
return httpx.Client(mounts=mounts, **kwargs)
else:
return httpx.Client(**kwargs)
@property
def proxies(self) -> Dict[str, str]:
return self._proxies
def _cache_key(self, method: str, url: str, headers: Optional[Dict[str, str]]) -> Tuple[str, str, Tuple[Tuple[str, str], ...]]:
normalized_headers = tuple(sorted((headers or {}).items()))
return (method.upper(), url, normalized_headers)
def get(self,
url: str,
headers: Optional[Dict[str, str]] = None,
cookies: Optional[Dict[str, str]] = None,
retries: int = 2,
backoff_seconds: float = 0.5,
use_cache: bool = False) -> httpx.Response:
if use_cache:
key = self._cache_key('GET', url, headers)
with self._cache_lock:
cached = self._cache.get(key)
if cached is not None:
return cached
last_exc: Optional[Exception] = None
attempt = 0
while attempt <= retries:
try:
# Check if client is closed and recreate if needed
if self._client.is_closed:
self._recreate_client()
response = self._client.get(url, headers=headers, cookies=cookies)
if use_cache and response.status_code == 200:
with self._cache_lock:
self._cache[key] = response
return response
except Exception as exc:
last_exc = exc
# Check for specific errors that require client recreation
should_recreate = False
if isinstance(exc, (httpx.HTTPError, RuntimeError)):
if "client has been closed" in str(exc).lower():
should_recreate = True
# Handle H2 protocol errors (connection state issues)
if H2ProtocolError and isinstance(exc, H2ProtocolError):
should_recreate = True
# Also check if the error message contains h2 protocol error info
if "ProtocolError" in str(exc) or "ConnectionState.CLOSED" in str(exc):
should_recreate = True
if should_recreate:
self._recreate_client()
if attempt < retries:
time.sleep(backoff_seconds * (2 ** attempt))
attempt += 1
continue
# For non-recoverable errors or last attempt, raise
if attempt == retries:
raise
# For other errors, still retry with backoff
time.sleep(backoff_seconds * (2 ** attempt))
attempt += 1
# Should not reach here
if last_exc:
raise last_exc
raise httpx.HTTPError('Unknown HTTP error')
def _recreate_client(self) -> None:
"""Recreate the HTTP client when it has been closed."""
try:
self._client.close()
except Exception:
pass # Client might already be closed
# Recreate with same configuration
client_kwargs = dict(timeout=self._timeout_seconds,
follow_redirects=True,
http2=self._http2)
try:
self._client = self._build_client(client_kwargs, self._verify)
except ssl.SSLError:
try:
system_ctx = ssl.create_default_context()
self._client = self._build_client(client_kwargs, system_ctx)
self._verify = system_ctx
except ssl.SSLError:
insecure_fallback = os.environ.get('WHOOGLE_INSECURE_FALLBACK', '0').lower() in ('1', 'true', 't', 'yes', 'y')
if insecure_fallback:
self._client = self._build_client(client_kwargs, False)
self._verify = False
else:
raise
def close(self) -> None:
self._client.close()

View File

@ -1,40 +0,0 @@
import os
from typing import Dict, Tuple
from app.services.http_client import HttpxClient
_clients: Dict[tuple, HttpxClient] = {}
def _proxies_key(proxies: Dict[str, str]) -> Tuple[Tuple[str, str], Tuple[str, str]]:
if not proxies:
return tuple(), tuple()
# Separate http/https for stable key
items = sorted((proxies or {}).items())
return tuple(items), tuple(items)
def get_http_client(proxies: Dict[str, str]) -> HttpxClient:
# Determine HTTP/2 enablement from env (default on)
http2_env = os.environ.get('WHOOGLE_HTTP2', '1').lower()
http2_enabled = http2_env in ('1', 'true', 't', 'yes', 'y')
key = (_proxies_key(proxies or {}), http2_enabled)
client = _clients.get(key)
if client is not None:
return client
client = HttpxClient(proxies=proxies or None, http2=http2_enabled)
_clients[key] = client
return client
def close_all_clients() -> None:
for client in list(_clients.values()):
try:
client.close()
except Exception:
pass
_clients.clear()

View File

@ -1,14 +0,0 @@
{
"!i": {
"url": "search?q={}&tbm=isch",
"suggestion": "!i (Whoogle Images)"
},
"!v": {
"url": "search?q={}&tbm=vid",
"suggestion": "!v (Whoogle Videos)"
},
"!n": {
"url": "search?q={}&tbm=nws",
"suggestion": "!n (Whoogle News)"
}
}

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -22,25 +22,20 @@ li {
color: var(--whoogle-dark-text) !important;
}
.anon-view {
color: var(--whoogle-dark-text) !important;
text-decoration: underline;
}
textarea {
background: var(--whoogle-dark-page-bg) !important;
color: var(--whoogle-dark-text) !important;
}
a:visited h3 div, a:visited .qXLe6d {
a:visited h3 div {
color: var(--whoogle-dark-result-visited) !important;
}
a:link h3 div, a:link .qXLe6d {
a:link h3 div {
color: var(--whoogle-dark-result-title) !important;
}
a:link div, a:link .fYyStc {
a:link div {
color: var(--whoogle-dark-result-url) !important;
}
@ -62,34 +57,16 @@ select {
background-color: var(--whoogle-dark-page-bg) !important;
}
.ZINbbc, .ezO2md {
overflow: hidden;
box-shadow: 0 0 0 0 !important;
background-color: var(--whoogle-dark-result-bg) !important;
margin-bottom: 10px !important;
border-radius: 8px !important;
}
.BsXmcf {
background-color: unset !important;
}
.KP7LCb {
box-shadow: 0 0 0 0 !important;
}
.BVG0Nb {
box-shadow: 0 0 0 0 !important;
background-color: var(--whoogle-dark-page-bg) !important;
}
.ZINbbc.luh4tb {
background: var(--whoogle-dark-result-bg) !important;
margin-bottom: 24px !important;
.ZINbbc {
background-color: var(--whoogle-dark-result-bg) !important;
}
.bRsWnc {
background-color: var(--whoogle-dark-result-bg) !important;
background-color: var(--whoogle-dark-result-bg) !important;
}
.BVG0Nb {
background-color: var(--whoogle-dark-page-bg) !important;
}
.x54gtf {
@ -104,19 +81,9 @@ select {
background-color: var(--whoogle-dark-divider) !important;
}
.home-search {
border-color: var(--whoogle-dark-element-bg) !important;
}
.sa1toc {
background: var(--whoogle-dark-page-bg) !important;
}
#search-bar {
border-color: var(--whoogle-dark-element-bg) !important;
color: var(--whoogle-dark-text) !important;
background-color: var(--whoogle-dark-result-bg) !important;
border-bottom: 2px solid var(--whoogle-dark-element-bg);
}
#search-bar:focus {
@ -135,11 +102,11 @@ select {
}
.collapsible {
color: var(--whoogle-dark-text) !important;
color: var(--whoogle-dark-text);
}
.collapsible:after {
color: var(--whoogle-dark-text) !important;
color: var(--whoogle-dark-text);
}
.active {
@ -147,7 +114,7 @@ select {
color: var(--whoogle-dark-contrast-text) !important;
}
.content, .result-config {
.content {
background-color: var(--whoogle-dark-element-bg) !important;
color: var(--whoogle-contrast-text) !important;
}
@ -156,14 +123,10 @@ select {
color: var(--whoogle-dark-contrast-text) !important;
}
.link {
#gh-link {
color: var(--whoogle-dark-contrast-text);
}
.link-color {
color: var(--whoogle-dark-result-url) !important;
}
.autocomplete-items {
border: 1px solid var(--whoogle-dark-element-bg);
}
@ -183,40 +146,3 @@ select {
background-color: var(--whoogle-dark-element-bg) !important;
color: var(--whoogle-dark-contrast-text) !important;
}
.footer {
color: var(--whoogle-dark-text);
}
path {
fill: var(--whoogle-dark-logo);
}
.header-div {
background-color: var(--whoogle-dark-result-bg) !important;
}
#search-reset {
color: var(--whoogle-dark-text) !important;
}
.mobile-search-bar {
background-color: var(--whoogle-dark-result-bg) !important;
color: var(--whoogle-dark-text) !important;
}
.search-bar-desktop {
color: var(--whoogle-dark-text) !important;
}
.ip-text-div, .update_available, .cb_label, .cb {
color: var(--whoogle-dark-secondary-text) !important;
}
.cb:focus {
color: var(--whoogle-dark-contrast-text) !important;
}
.desktop-header, .mobile-header {
background-color: var(--whoogle-dark-result-bg) !important;
}

View File

@ -1,9 +0,0 @@
html {
font-size: 1.3rem;
}
@media (max-width: 1000px) {
html {
font-size: 3rem;
}
}

View File

@ -13,18 +13,9 @@ header {
border-radius: 2px 0 0 0;
}
.result-config {
margin-bottom: 10px;
padding: 10px;
border-radius: 8px;
}
.mobile-logo {
font: 22px/36px Futura, Arial, sans-serif;
padding-left: 5px;
display: flex;
justify-content: center;
align-items: center;
}
.logo-div {
@ -80,171 +71,3 @@ header {
border-radius: 8px;
box-shadow: 0 0 6px 1px #2375e8;
}
#mobile-header-logo {
height: 1.75em;
}
.mobile-input-div {
width: 100%;
}
.mobile-search-bar {
display: block;
font-size: 16px;
padding: 0 0 0 8px;
padding-right: 0px;
-webkit-box-flex: 1;
height: 35px;
outline: none;
border: none;
width: 100%;
-webkit-tap-highlight-color: rgba(0,0,0,.00);
overflow: hidden;
border: 0px !important;
}
.autocomplete-mobile{
display: -webkit-box;
width: 100%;
}
.desktop-header-logo {
height: 1.65em;
}
.header-autocomplete {
width: 100%;
flex: 1
}
a {
color: #1967D2;
text-decoration: none;
tap-highlight-color: rgba(0, 0, 0, .10);
}
.header-tab-div {
border-radius: 0 0 8px 8px;
box-shadow: 0 2px 3px rgba(32, 33, 36, 0.18);
overflow: hidden;
margin-bottom: 10px;
}
.header-tab-div-2 {
border-top: 1px solid #dadce0;
height: 39px;
overflow: hidden;
}
.header-tab-div-3 {
height: 51px;
overflow-x: auto;
overflow-y: hidden;
}
.desktop-header {
height: 39px;
display: box;
display: flex;
width: 100%;
}
.header-tab {
box-pack: justify;
font-size: 14px;
line-height: 37px;
justify-content: space-between;
}
.desktop-header a, .desktop-header span {
color: #70757a;
display: block;
flex: none;
padding: 0 16px;
text-align: center;
text-transform: uppercase;
}
span.header-tab-span {
border-bottom: 2px solid #4285f4;
color: #4285f4;
font-weight: bold;
}
.mobile-header {
height: 39px;
display: box;
display: flex;
overflow-x: scroll;
width: 100%;
padding-left: 12px;
}
.mobile-header a, .mobile-header span {
color: #70757a;
text-decoration: none;
display: inline-block;
/* padding: 8px 12px 8px 12px; */
}
span.mobile-tab-span {
border-bottom: 2px solid #202124;
color: #202124;
height: 26px;
/* margin: 0 12px; */
/* padding: 0; */
}
.desktop-header input {
margin: 2px 4px 2px 8px;
}
a.header-tab-a:visited {
color: #70757a;
}
.header-tab-div-end {
border-left: 1px solid rgba(0, 0, 0, 0.12);
}
.adv-search {
font-size: 30px;
}
.adv-search:hover {
cursor: pointer;
}
#adv-search-toggle {
display: none;
}
.result-collapsible {
max-height: 0px;
overflow: hidden;
transition: max-height .25s linear;
}
.search-bar-input {
display: block;
font-size: 16px;
padding: 0 0 0 8px;
flex: 1;
height: 35px;
outline: none;
border: none;
width: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
overflow: hidden;
}
#result-country {
max-width: 200px;
}
@media (max-width: 801px) {
.header-tab-div {
margin-bottom: 10px !important
}
}

View File

@ -12,30 +12,3 @@
height: 40px;
width: 50px;
}
.ZINbbc.xpd.O9g5cc.uUPGi input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.cb {
width: 40%;
overflow: hidden;
text-align: left;
line-height: 28px;
background: transparent;
border-radius: 6px;
border: 1px solid #5f6368;
font-size: 14px !important;
height: 36px;
padding: 0 0 0 12px;
margin: 10px 10px 10px 0;
}
.conversion_box {
margin-top: 15px;
}
.ZINbbc.xpd.O9g5cc.uUPGi input:focus-visible {
outline: 0;
}

View File

@ -22,11 +22,6 @@ li {
color: var(--whoogle-text) !important;
}
.anon-view {
color: var(--whoogle-text) !important;
text-decoration: underline;
}
textarea {
background: var(--whoogle-page-bg) !important;
color: var(--whoogle-text) !important;
@ -38,28 +33,11 @@ select {
}
.ZINbbc {
overflow: hidden;
background-color: var(--whoogle-result-bg) !important;
margin-bottom: 10px !important;
border-radius: 8px !important;
box-shadow: 0 1px 6px rgba(32,33,36,0.28) !important;
}
.BsXmcf {
background-color: unset !important;
}
.BVG0Nb {
background-color: var(--whoogle-result-bg) !important;
}
.ZINbbc.luh4tb {
background: var(--whoogle-result-bg) !important;
margin-bottom: 24px !important;
background-color: var(--whoogle-result-bg) !important;
}
.bRsWnc {
background-color: var(--whoogle-result-bg) !important;
background-color: var(--whoogle-result-bg) !important;
}
.x54gtf {
@ -75,15 +53,15 @@ select {
}
a:visited div, a:visited .qXLe6d {
a:visited h3 div {
color: var(--whoogle-result-visited) !important;
}
a:link div, a:link .qXLe6d {
a:link h3 div {
color: var(--whoogle-result-title) !important;
}
a:link div, a:link .fYyStc {
a:link div {
color: var(--whoogle-result-url) !important;
}
@ -102,7 +80,7 @@ input {
}
.home-search {
border-color: var(--whoogle-element-bg) !important;
border: 3px solid var(--whoogle-element-bg) !important;
}
.search-container {
@ -133,7 +111,7 @@ input {
color: var(--whoogle-contrast-text) !important;
}
.content, .result-config {
.content {
background-color: var(--whoogle-element-bg) !important;
color: var(--whoogle-contrast-text) !important;
}
@ -142,14 +120,10 @@ input {
color: var(--whoogle-contrast-text);
}
.link {
#gh-link {
color: var(--whoogle-element-bg);
}
.link-color {
color: var(--whoogle-result-url) !important;
}
.autocomplete-items {
border: 1px solid var(--whoogle-element-bg);
}
@ -168,42 +142,3 @@ input {
background-color: var(--whoogle-element-bg) !important;
color: var(--whoogle-contrast-text) !important;
}
.footer {
color: var(--whoogle-text);
}
path {
fill: var(--whoogle-logo);
}
.header-div {
background-color: var(--whoogle-result-bg) !important;
}
#search-reset {
color: var(--whoogle-text) !important;
}
.mobile-search-bar {
background-color: var(--whoogle-result-bg) !important;
color: var(--whoogle-text) !important;
}
.search-bar-desktop {
background-color: var(--whoogle-result-bg) !important;
color: var(--whoogle-text);
border-bottom: 0px;
}
.ip-text-div, .update_available, .cb_label, .cb {
color: var(--whoogle-secondary-text) !important;
}
.cb:focus {
color: var(--whoogle-text) !important;
}
.desktop-header, .mobile-header {
background-color: var(--whoogle-result-bg) !important;
}

View File

@ -12,7 +12,6 @@ a {
@media (max-width: 1000px) {
svg {
margin-top: .3em;
height: 70%;
margin-top: .7em;
}
}

View File

@ -13,11 +13,6 @@ body {
max-height: 500px;
}
.home-search {
background: transparent !important;
border: 3px solid;
}
.search-container {
background: transparent !important;
width: 80%;
@ -61,15 +56,6 @@ body {
-webkit-appearance: none;
}
.config-options {
max-height: 370px;
overflow-y: scroll;
}
.config-buttons {
max-height: 30px;
}
.config-div {
padding: 5px;
}
@ -144,7 +130,6 @@ footer {
.whoogle-svg {
width: 80%;
height: initial;
display: block;
margin: auto;
padding-bottom: 10px;
@ -177,14 +162,3 @@ details summary {
padding: 10px;
font-weight: bold;
}
/* Mobile styles */
@media (max-width: 1000px) {
select {
width: 100%;
}
#search-bar {
font-size: 20px;
}
}

View File

@ -1,18 +1,3 @@
body {
display: block !important;
margin: auto !important;
}
.vvjwJb {
font-size: 16px !important;
}
.ezO2md {
border-radius: 10px;
border: 0 !important;
box-shadow: 0 3px 5px rgb(0 0 0 / 0.2);
}
.autocomplete {
position: relative;
display: inline-block;
@ -37,61 +22,6 @@ body {
}
details summary {
margin-bottom: 20px;
padding: 10px;
font-weight: bold;
padding-left: 10px;
}
details summary span {
font-weight: normal;
}
#lingva-iframe {
width: 100%;
height: 650px;
border: 0;
}
.ip-address-div {
padding-bottom: 0 !important;
}
.ip-text-div {
padding-top: 0 !important;
}
.footer {
text-align: center;
}
.site-favicon {
float: left;
width: 25px;
padding-right: 5px;
}
.has-favicon .sCuL3 {
padding-left: 30px;
}
#flex_text_audio_icon_chunk {
display: none;
}
audio {
display: block;
margin-right: auto;
padding-bottom: 5px;
}
@media (min-width: 801px) {
body {
min-width: 736px !important;
}
}
@media (max-width: 801px) {
details summary {
margin-bottom: 10px !important
}
}

View File

@ -3,7 +3,7 @@
/* LIGHT THEME COLORS */
--whoogle-logo: #685e79;
--whoogle-page-bg: #ffffff;
--whoogle-element-bg: #4285f4;
--whoogle-element-bg: #685e79;
--whoogle-text: #000000;
--whoogle-contrast-text: #ffffff;
--whoogle-secondary-text: #70757a;
@ -11,44 +11,18 @@
--whoogle-result-title: #1967d2;
--whoogle-result-url: #0d652d;
--whoogle-result-visited: #4b11a8;
--whoogle-divider: #dfe1e5;
/* DARK THEME COLORS */
--whoogle-dark-logo: #685e79;
--whoogle-dark-page-bg: #101020;
--whoogle-dark-element-bg: #4285f4;
--whoogle-dark-text: #ffffff;
--whoogle-dark-contrast-text: #ffffff;
--whoogle-dark-secondary-text: #bbbbbb;
--whoogle-dark-result-bg: #212131;
--whoogle-dark-result-title: #64a7f6;
--whoogle-dark-result-url: #34a853;
--whoogle-dark-result-visited: #bbbbff;
}
#whoogle-w {
fill: #4285f4;
}
#whoogle-h {
fill: #ea4335;
}
#whoogle-o-1 {
fill: #fbbc05;
}
#whoogle-o-2 {
fill: #4285f4;
}
#whoogle-g {
fill: #34a853;
}
#whoogle-l {
fill: #ea4335;
}
#whoogle-e {
fill: #fbbc05;
--whoogle-dark-logo: #888888;
--whoogle-dark-page-bg: #080808;
--whoogle-dark-element-bg: #111111;
--whoogle-dark-text: #dddddd;
--whoogle-dark-contrast-text: #aaaaaa;
--whoogle-dark-secondary-text: #8a8b8c;
--whoogle-dark-result-bg: #111111;
--whoogle-dark-result-title: #dddddd;
--whoogle-dark-result-url: #eceff4;
--whoogle-dark-result-visited: #959595;
--whoogle-dark-divider: #111111;
}

View File

@ -1,9 +1,4 @@
let searchInput;
let currentFocus;
let originalSearch;
let autocompleteResults;
const handleUserInput = () => {
const handleUserInput = searchBar => {
let xhrRequest = new XMLHttpRequest();
xhrRequest.open("POST", "autocomplete");
xhrRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
@ -14,114 +9,118 @@ const handleUserInput = () => {
}
// Fill autocomplete with fetched results
autocompleteResults = JSON.parse(xhrRequest.responseText)[1];
updateAutocompleteList();
let autocompleteResults = JSON.parse(xhrRequest.responseText);
autocomplete(searchBar, autocompleteResults[1]);
};
xhrRequest.send('q=' + searchInput.value);
xhrRequest.send('q=' + searchBar.value);
};
const removeActive = suggestion => {
// Remove "autocomplete-active" class from previously active suggestion
for (let i = 0; i < suggestion.length; i++) {
suggestion[i].classList.remove("autocomplete-active");
}
};
const autocomplete = (searchInput, autocompleteResults) => {
let currentFocus;
let originalSearch;
const addActive = (suggestion) => {
// Handle navigation outside of suggestion list
if (!suggestion || !suggestion[currentFocus]) {
if (currentFocus >= suggestion.length) {
// Move selection back to the beginning
currentFocus = 0;
} else if (currentFocus < 0) {
// Retrieve original search and remove active suggestion selection
currentFocus = -1;
searchInput.value = originalSearch;
removeActive(suggestion);
return;
} else {
return;
searchInput.addEventListener("input", function () {
let autocompleteList, autocompleteItem, i, val = this.value;
closeAllLists();
if (!val || !autocompleteResults) {
return false;
}
}
removeActive(suggestion);
suggestion[currentFocus].classList.add("autocomplete-active");
currentFocus = -1;
autocompleteList = document.createElement("div");
autocompleteList.setAttribute("id", this.id + "-autocomplete-list");
autocompleteList.setAttribute("class", "autocomplete-items");
this.parentNode.appendChild(autocompleteList);
// Autofill search bar with suggestion content (minus the "bang name" if using a bang operator)
let searchContent = suggestion[currentFocus].textContent;
if (searchContent.indexOf('(') > 0) {
searchInput.value = searchContent.substring(0, searchContent.indexOf('('));
} else {
searchInput.value = searchContent;
}
searchInput.focus();
};
const autocompleteInput = (e) => {
// Handle navigation between autocomplete suggestions
let suggestion = document.getElementById("autocomplete-list");
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
if (e.keyCode === 40) { // down
e.preventDefault();
currentFocus++;
addActive(suggestion);
} else if (e.keyCode === 38) { //up
e.preventDefault();
currentFocus--;
addActive(suggestion);
} else if (e.keyCode === 13) { // enter
e.preventDefault();
if (currentFocus > -1) {
if (suggestion) suggestion[currentFocus].click();
for (i = 0; i < autocompleteResults.length; i++) {
if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {
autocompleteItem = document.createElement("div");
autocompleteItem.innerHTML = "<strong>" + autocompleteResults[i].substr(0, val.length) + "</strong>";
autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);
autocompleteItem.innerHTML += "<input type=\"hidden\" value=\"" + autocompleteResults[i] + "\">";
autocompleteItem.addEventListener("click", function () {
searchInput.value = this.getElementsByTagName("input")[0].value;
closeAllLists();
document.getElementById("search-form").submit();
});
autocompleteList.appendChild(autocompleteItem);
}
}
} else {
originalSearch = searchInput.value;
}
};
const updateAutocompleteList = () => {
let autocompleteItem, i;
let val = originalSearch;
let autocompleteList = document.getElementById("autocomplete-list");
autocompleteList.innerHTML = "";
if (!val || !autocompleteResults) {
return false;
}
currentFocus = -1;
for (i = 0; i < autocompleteResults.length; i++) {
if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {
autocompleteItem = document.createElement("div");
autocompleteItem.setAttribute("class", "autocomplete-item");
autocompleteItem.innerHTML = "<strong>" + autocompleteResults[i].substr(0, val.length) + "</strong>";
autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);
autocompleteItem.innerHTML += "<input type=\"hidden\" value=\"" + autocompleteResults[i] + "\">";
autocompleteItem.addEventListener("click", function () {
searchInput.value = this.getElementsByTagName("input")[0].value;
autocompleteList.innerHTML = "";
document.getElementById("search-form").submit();
});
autocompleteList.appendChild(autocompleteItem);
}
}
};
document.addEventListener("DOMContentLoaded", function() {
let autocompleteList = document.createElement("div");
autocompleteList.setAttribute("id", "autocomplete-list");
autocompleteList.setAttribute("class", "autocomplete-items");
searchInput = document.getElementById("search-bar");
searchInput.parentNode.appendChild(autocompleteList);
searchInput.addEventListener("keydown", (event) => autocompleteInput(event));
document.addEventListener("click", function (e) {
autocompleteList.innerHTML = "";
});
});
searchInput.addEventListener("keydown", function (e) {
let suggestion = document.getElementById(this.id + "-autocomplete-list");
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
if (e.keyCode === 40) { // down
e.preventDefault();
currentFocus++;
addActive(suggestion);
} else if (e.keyCode === 38) { //up
e.preventDefault();
currentFocus--;
addActive(suggestion);
} else if (e.keyCode === 13) { // enter
e.preventDefault();
if (currentFocus > -1) {
if (suggestion) suggestion[currentFocus].click();
}
} else {
originalSearch = document.getElementById("search-bar").value;
}
});
const addActive = suggestion => {
let searchBar = document.getElementById("search-bar");
// Handle navigation outside of suggestion list
if (!suggestion || !suggestion[currentFocus]) {
if (currentFocus >= suggestion.length) {
// Move selection back to the beginning
currentFocus = 0;
} else if (currentFocus < 0) {
// Retrieve original search and remove active suggestion selection
currentFocus = -1;
searchBar.value = originalSearch;
removeActive(suggestion);
return;
} else {
return;
}
}
removeActive(suggestion);
suggestion[currentFocus].classList.add("autocomplete-active");
// Autofill search bar with suggestion content (minus the "bang name" if using a bang operator)
let searchContent = suggestion[currentFocus].textContent;
if (searchContent.indexOf('(') > 0) {
searchBar.value = searchContent.substring(0, searchContent.indexOf('('));
} else {
searchBar.value = searchContent;
}
searchBar.focus();
};
const removeActive = suggestion => {
for (let i = 0; i < suggestion.length; i++) {
suggestion[i].classList.remove("autocomplete-active");
}
};
const closeAllLists = el => {
let suggestions = document.getElementsByClassName("autocomplete-items");
for (let i = 0; i < suggestions.length; i++) {
if (el !== suggestions[i] && el !== searchInput) {
suggestions[i].parentNode.removeChild(suggestions[i]);
}
}
};
// Close lists and search when user selects a suggestion
document.addEventListener("click", function (e) {
closeAllLists(e.target);
});
};

View File

@ -2,8 +2,6 @@ const setupSearchLayout = () => {
// Setup search field
const searchBar = document.getElementById("search-bar");
const searchBtn = document.getElementById("search-submit");
const arrowKeys = [37, 38, 39, 40];
let searchValue = searchBar.value;
// Automatically focus on search field
searchBar.focus();
@ -13,9 +11,8 @@ const setupSearchLayout = () => {
if (event.keyCode === 13) {
event.preventDefault();
searchBtn.click();
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
searchValue = searchBar.value;
handleUserInput();
} else {
handleUserInput(searchBar);
}
});
};
@ -29,25 +26,11 @@ const setupConfigLayout = () => {
if (content.style.maxHeight) {
content.style.maxHeight = null;
} else {
content.style.maxHeight = "400px";
content.style.maxHeight = content.scrollHeight + "px";
}
content.classList.toggle("open");
});
// Setup user agent dropdown handler
const userAgentSelect = document.getElementById("config-user-agent");
const customUserAgentDiv = document.querySelector(".config-div-custom-user-agent");
if (userAgentSelect && customUserAgentDiv) {
userAgentSelect.addEventListener("change", function() {
if (this.value === "custom") {
customUserAgentDiv.style.display = "block";
} else {
customUserAgentDiv.style.display = "none";
}
});
}
};
const loadConfig = event => {

View File

@ -1,9 +0,0 @@
const convert = (n1, n2, conversionFactor) => {
// id's for currency input boxes
let id1 = "cb" + n1;
let id2 = "cb" + n2;
// getting the value of the input box that just got filled
let inputBox = document.getElementById(id1).value;
// updating the other input box after conversion
document.getElementById(id2).value = ((inputBox * conversionFactor).toFixed(2));
}

View File

@ -1,67 +1,11 @@
document.addEventListener("DOMContentLoaded", () => {
const advSearchToggle = document.getElementById("adv-search-toggle");
const advSearchDiv = document.getElementById("adv-search-div");
const searchBar = document.getElementById("search-bar");
const countrySelect = document.getElementById("result-country");
const timePeriodSelect = document.getElementById("result-time-period");
const arrowKeys = [37, 38, 39, 40];
let searchValue = searchBar.value;
countrySelect.onchange = () => {
let str = window.location.href;
n = str.lastIndexOf("/search");
if (n > 0) {
str = str.substring(0, n) + `/search?q=${searchBar.value}`;
str = tackOnParams(str);
window.location.href = str;
}
}
timePeriodSelect.onchange = () => {
let str = window.location.href;
n = str.lastIndexOf("/search");
if (n > 0) {
str = str.substring(0, n) + `/search?q=${searchBar.value}`;
str = tackOnParams(str);
window.location.href = str;
}
}
function tackOnParams(str) {
if (timePeriodSelect.value != "") {
str = str + `&tbs=${timePeriodSelect.value}`;
}
if (countrySelect.value != "") {
str = str + `&country=${countrySelect.value}`;
}
return str;
}
const toggleAdvancedSearch = on => {
if (on) {
advSearchDiv.style.maxHeight = "70px";
searchBar.addEventListener("keyup", function (event) {
if (event.keyCode !== 13) {
handleUserInput(searchBar);
} else {
advSearchDiv.style.maxHeight = "0px";
}
localStorage.advSearchToggled = on;
}
try {
toggleAdvancedSearch(JSON.parse(localStorage.advSearchToggled));
} catch (error) {
console.warn("Did not recover advanced search toggle state");
}
advSearchToggle.onclick = () => {
toggleAdvancedSearch(advSearchToggle.checked);
}
searchBar.addEventListener("keyup", function(event) {
if (event.keyCode === 13) {
document.getElementById("search-form").submit();
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
searchValue = searchBar.value;
handleUserInput();
}
});
});

View File

@ -1,62 +1,44 @@
(function () {
let searchBar, results;
let shift = false;
const keymap = {
ArrowUp: goUp,
ArrowDown: goDown,
ShiftTab: goUp,
Tab: goDown,
k: goUp,
j: goDown,
'/': focusSearch,
};
let activeIdx = -1;
let searchBar, results;
const keymap = {
ArrowUp: goUp,
ArrowDown: goDown,
k: goUp,
j: goDown,
'/': focusSearch,
};
let activeIdx = -1;
document.addEventListener('DOMContentLoaded', () => {
searchBar = document.querySelector('#search-bar');
results = document.querySelectorAll('#main>div>div>div>a');
});
document.addEventListener('DOMContentLoaded', () => {
searchBar = document.querySelector('#search-bar');
results = document.querySelectorAll('#main>div>div>div>a');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Shift') {
shift = true;
}
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return true;
if (typeof keymap[e.key] === 'function') {
e.preventDefault();
keymap[e.key]();
}
});
if (e.target.tagName === 'INPUT') return true;
if (typeof keymap[e.key] === 'function') {
e.preventDefault();
function goUp () {
if (activeIdx > 0) focusResult(activeIdx - 1);
else focusSearch();
}
keymap[`${shift && e.key == 'Tab' ? 'Shift' : ''}${e.key}`]();
}
});
function goDown () {
if (activeIdx < results.length - 1) focusResult(activeIdx + 1);
}
document.addEventListener('keyup', (e) => {
if (e.key === 'Shift') {
shift = false;
}
});
function focusResult (idx) {
activeIdx = idx;
results[activeIdx].scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
results[activeIdx].focus();
}
function goUp () {
if (activeIdx > 0) focusResult(activeIdx - 1);
else focusSearch();
}
function goDown () {
if (activeIdx < results.length - 1) focusResult(activeIdx + 1);
}
function focusResult (idx) {
activeIdx = idx;
results[activeIdx].scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
results[activeIdx].focus();
}
function focusSearch () {
if (window.usingCalculator) {
// if this function exists, it means the calculator widget has been displayed
if (usingCalculator()) return;
}
activeIdx = -1;
searchBar.focus();
}
function focusSearch () {
activeIdx = -1;
searchBar.focus();
}
}());

View File

@ -1,10 +1,6 @@
const checkForTracking = () => {
const mainDiv = document.getElementById("main");
const searchBar = document.getElementById("search-bar");
// some pages (e.g. images) do not have these
if (!mainDiv || !searchBar)
return;
const query = searchBar.value.replace(/\s+/g, '');
const query = document.getElementById("search-bar").value.replace(/\s+/g, '');
// Note: regex functions for checking for tracking queries were derived
// from here -- https://stackoverflow.com/questions/619977
@ -16,7 +12,7 @@ const checkForTracking = () => {
]
},
"usps": {
"link": `https://tools.usps.com/go/TrackConfirmAction_input?origTrackNum=${query}`,
"link": `https://tools.usps.com/go/TrackConfirmAction?tLabels=${query}`,
"expr": [
/(\b\d{30}\b)|(\b91\d+\b)|(\b\d{20}\b)/,
/^E\D{1}\d{9}\D{2}$|^9\d{15,21}$/,
@ -63,14 +59,11 @@ document.addEventListener("DOMContentLoaded", function() {
checkForTracking();
// Clear input if reset button tapped
const searchBar = document.getElementById("search-bar");
const search = document.getElementById("search-bar");
const resetBtn = document.getElementById("search-reset");
// some pages (e.g. images) do not have these
if (!searchBar || !resetBtn)
return;
resetBtn.addEventListener("click", event => {
event.preventDefault();
searchBar.value = "";
searchBar.focus();
search.value = "";
search.focus();
});
});

View File

@ -1,245 +1,248 @@
[
{"name": "-------", "value": ""},
{"name": "Afghanistan", "value": "AF"},
{"name": "Albania", "value": "AL"},
{"name": "Algeria", "value": "DZ"},
{"name": "American Samoa", "value": "AS"},
{"name": "Andorra", "value": "AD"},
{"name": "Angola", "value": "AO"},
{"name": "Anguilla", "value": "AI"},
{"name": "Antarctica", "value": "AQ"},
{"name": "Antigua and Barbuda", "value": "AG"},
{"name": "Argentina", "value": "AR"},
{"name": "Armenia", "value": "AM"},
{"name": "Aruba", "value": "AW"},
{"name": "Australia", "value": "AU"},
{"name": "Austria", "value": "AT"},
{"name": "Azerbaijan", "value": "AZ"},
{"name": "Bahamas", "value": "BS"},
{"name": "Bahrain", "value": "BH"},
{"name": "Bangladesh", "value": "BD"},
{"name": "Barbados", "value": "BB"},
{"name": "Belarus", "value": "BY"},
{"name": "Belgium", "value": "BE"},
{"name": "Belize", "value": "BZ"},
{"name": "Benin", "value": "BJ"},
{"name": "Bermuda", "value": "BM"},
{"name": "Bhutan", "value": "BT"},
{"name": "Bolivia", "value": "BO"},
{"name": "Bosnia and Herzegovina", "value": "BA"},
{"name": "Botswana", "value": "BW"},
{"name": "Bouvet Island", "value": "BV"},
{"name": "Brazil", "value": "BR"},
{"name": "British Indian Ocean Territory", "value": "IO"},
{"name": "Brunei Darussalam", "value": "BN"},
{"name": "Bulgaria", "value": "BG"},
{"name": "Burkina Faso", "value": "BF"},
{"name": "Burundi", "value": "BI"},
{"name": "Cambodia", "value": "KH"},
{"name": "Cameroon", "value": "CM"},
{"name": "Canada", "value": "CA"},
{"name": "Cape Verde", "value": "CV"},
{"name": "Cayman Islands", "value": "KY"},
{"name": "Central African Republic", "value": "CF"},
{"name": "Chad", "value": "TD"},
{"name": "Chile", "value": "CL"},
{"name": "China", "value": "CN"},
{"name": "Christmas Island", "value": "CX"},
{"name": "Cocos (Keeling) Islands", "value": "CC"},
{"name": "Colombia", "value": "CO"},
{"name": "Comoros", "value": "KM"},
{"name": "Congo", "value": "CG"},
{"name": "Congo, Democratic Republic of the", "value": "CD"},
{"name": "Cook Islands", "value": "CK"},
{"name": "Costa Rica", "value": "CR"},
{"name": "Cote D'ivoire", "value": "CI"},
{"name": "Croatia (Hrvatska)", "value": "HR"},
{"name": "Cuba", "value": "CU"},
{"name": "Cyprus", "value": "CY"},
{"name": "Czech Republic", "value": "CZ"},
{"name": "Denmark", "value": "DK"},
{"name": "Djibouti", "value": "DJ"},
{"name": "Dominica", "value": "DM"},
{"name": "Dominican Republic", "value": "DO"},
{"name": "East Timor", "value": "TP"},
{"name": "Ecuador", "value": "EC"},
{"name": "Egypt", "value": "EG"},
{"name": "El Salvador", "value": "SV"},
{"name": "Equatorial Guinea", "value": "GQ"},
{"name": "Eritrea", "value": "ER"},
{"name": "Estonia", "value": "EE"},
{"name": "Ethiopia", "value": "ET"},
{"name": "European Union", "value": "EU"},
{"name": "Falkland Islands (Malvinas)", "value": "FK"},
{"name": "Faroe Islands", "value": "FO"},
{"name": "Fiji", "value": "FJ"},
{"name": "Finland", "value": "FI"},
{"name": "France", "value": "FR"},
{"name": "France, Metropolitan", "value": "FX"},
{"name": "French Guiana", "value": "GF"},
{"name": "French Polynesia", "value": "PF"},
{"name": "French Southern Territories", "value": "TF"},
{"name": "Gabon", "value": "GA"},
{"name": "Gambia", "value": "GM"},
{"name": "Georgia", "value": "GE"},
{"name": "Germany", "value": "DE"},
{"name": "Ghana", "value": "GH"},
{"name": "Gibraltar", "value": "GI"},
{"name": "Greece", "value": "GR"},
{"name": "Greenland", "value": "GL"},
{"name": "Grenada", "value": "GD"},
{"name": "Guadeloupe", "value": "GP"},
{"name": "Guam", "value": "GU"},
{"name": "Guatemala", "value": "GT"},
{"name": "Guinea", "value": "GN"},
{"name": "Guinea-Bissau", "value": "GW"},
{"name": "Guyana", "value": "GY"},
{"name": "Haiti", "value": "HT"},
{"name": "Heard Island and Mcdonald Islands", "value": "HM"},
{"name": "Holy See (Vatican City State)", "value": "VA"},
{"name": "Honduras", "value": "HN"},
{"name": "Hong Kong", "value": "HK"},
{"name": "Hungary", "value": "HU"},
{"name": "Iceland", "value": "IS"},
{"name": "India", "value": "IN"},
{"name": "Indonesia", "value": "ID"},
{"name": "Iran, Islamic Republic of", "value": "IR"},
{"name": "Iraq", "value": "IQ"},
{"name": "Ireland", "value": "IE"},
{"name": "Israel", "value": "IL"},
{"name": "Italy", "value": "IT"},
{"name": "Jamaica", "value": "JM"},
{"name": "Japan", "value": "JP"},
{"name": "Jordan", "value": "JO"},
{"name": "Kazakhstan", "value": "KZ"},
{"name": "Kenya", "value": "KE"},
{"name": "Kiribati", "value": "KI"},
{"name": "Korea, Democratic People's Republic of", "value": "KP"},
{"name": "Korea, Republic of", "value": "KR"},
{"name": "Kuwait", "value": "KW"},
{"name": "Kyrgyzstan", "value": "KG"},
{"name": "Lao People's Democratic Republic", "value": "LA"},
{"name": "Latvia", "value": "LV"},
{"name": "Lebanon", "value": "LB"},
{"name": "Lesotho", "value": "LS"},
{"name": "Liberia", "value": "LR"},
{"name": "Libyan Arab Jamahiriya", "value": "LY"},
{"name": "Liechtenstein", "value": "LI"},
{"name": "Lithuania", "value": "LT"},
{"name": "Luxembourg", "value": "LU"},
{"name": "Macao", "value": "MO"},
{"name": "Madagascar", "value": "MG"},
{"name": "Malawi", "value": "MW"},
{"name": "Malaysia", "value": "MY"},
{"name": "Maldives", "value": "MV"},
{"name": "Mali", "value": "ML"},
{"name": "Malta", "value": "MT"},
{"name": "Marshall Islands", "value": "MH"},
{"name": "Martinique", "value": "MQ"},
{"name": "Mauritania", "value": "MR"},
{"name": "Mauritius", "value": "MU"},
{"name": "Mayotte", "value": "YT"},
{"name": "Mexico", "value": "MX"},
{"name": "Micronesia, Federated States of", "value": "FM"},
{"name": "Moldova, Republic of", "value": "MD"},
{"name": "Monaco", "value": "MC"},
{"name": "Mongolia", "value": "MN"},
{"name": "Montserrat", "value": "MS"},
{"name": "Morocco", "value": "MA"},
{"name": "Mozambique", "value": "MZ"},
{"name": "Myanmar", "value": "MM"},
{"name": "Namibia", "value": "NA"},
{"name": "Nauru", "value": "NR"},
{"name": "Nepal", "value": "NP"},
{"name": "Netherlands", "value": "NL"},
{"name": "Netherlands Antilles", "value": "AN"},
{"name": "New Caledonia", "value": "NC"},
{"name": "New Zealand", "value": "NZ"},
{"name": "Nicaragua", "value": "NI"},
{"name": "Niger", "value": "NE"},
{"name": "Nigeria", "value": "NG"},
{"name": "Niue", "value": "NU"},
{"name": "Norfolk Island", "value": "NF"},
{"name": "North Macedonia", "value": "MK"},
{"name": "Northern Mariana Islands", "value": "MP"},
{"name": "Norway", "value": "NO"},
{"name": "Oman", "value": "OM"},
{"name": "Pakistan", "value": "PK"},
{"name": "Palau", "value": "PW"},
{"name": "Palestinian Territory", "value": "PS"},
{"name": "Panama", "value": "PA"},
{"name": "Papua New Guinea", "value": "PG"},
{"name": "Paraguay", "value": "PY"},
{"name": "Peru", "value": "PE"},
{"name": "Philippines", "value": "PH"},
{"name": "Pitcairn", "value": "PN"},
{"name": "Poland", "value": "PL"},
{"name": "Portugal", "value": "PT"},
{"name": "Puerto Rico", "value": "PR"},
{"name": "Qatar", "value": "QA"},
{"name": "Reunion", "value": "RE"},
{"name": "Romania", "value": "RO"},
{"name": "Russian Federation", "value": "RU"},
{"name": "Rwanda", "value": "RW"},
{"name": "Saint Helena", "value": "SH"},
{"name": "Saint Kitts and Nevis", "value": "KN"},
{"name": "Saint Lucia", "value": "LC"},
{"name": "Saint Pierre and Miquelon", "value": "PM"},
{"name": "Saint Vincent and the Grenadines", "value": "VC"},
{"name": "Samoa", "value": "WS"},
{"name": "San Marino", "value": "SM"},
{"name": "Sao Tome and Principe", "value": "ST"},
{"name": "Saudi Arabia", "value": "SA"},
{"name": "Senegal", "value": "SN"},
{"name": "Serbia and Montenegro", "value": "CS"},
{"name": "Seychelles", "value": "SC"},
{"name": "Sierra Leone", "value": "SL"},
{"name": "Singapore", "value": "SG"},
{"name": "Slovakia", "value": "SK"},
{"name": "Slovenia", "value": "SI"},
{"name": "Solomon Islands", "value": "SB"},
{"name": "Somalia", "value": "SO"},
{"name": "South Africa", "value": "ZA"},
{"name": "South Georgia and the South Sandwich Islands", "value": "GS"},
{"name": "Spain", "value": "ES"},
{"name": "Sri Lanka", "value": "LK"},
{"name": "Sudan", "value": "SD"},
{"name": "Suriname", "value": "SR"},
{"name": "Svalbard and Jan Mayen", "value": "SJ"},
{"name": "Swaziland", "value": "SZ"},
{"name": "Sweden", "value": "SE"},
{"name": "Switzerland", "value": "CH"},
{"name": "Syrian Arab Republic", "value": "SY"},
{"name": "Taiwan", "value": "TW"},
{"name": "Tajikistan", "value": "TJ"},
{"name": "Tanzania, United Republic of", "value": "TZ"},
{"name": "Thailand", "value": "TH"},
{"name": "Togo", "value": "TG"},
{"name": "Tokelau", "value": "TK"},
{"name": "Tonga", "value": "TO"},
{"name": "Trinidad and Tobago", "value": "TT"},
{"name": "Tunisia", "value": "TN"},
{"name": "Turkmenistan", "value": "TM"},
{"name": "Turks and Caicos Islands", "value": "TC"},
{"name": "Tuvalu", "value": "TV"},
{"name": "Türkiye", "value": "TR"},
{"name": "Uganda", "value": "UG"},
{"name": "Ukraine", "value": "UA"},
{"name": "United Arab Emirates", "value": "AE"},
{"name": "United Kingdom", "value": "UK"},
{"name": "United States", "value": "US"},
{"name": "United States Minor Outlying Islands", "value": "UM"},
{"name": "Uruguay", "value": "UY"},
{"name": "Uzbekistan", "value": "UZ"},
{"name": "Vanuatu", "value": "VU"},
{"name": "Venezuela", "value": "VE"},
{"name": "Vietnam", "value": "VN"},
{"name": "Virgin Islands, British", "value": "VG"},
{"name": "Virgin Islands, U.S.", "value": "VI"},
{"name": "Wallis and Futuna", "value": "WF"},
{"name": "Western Sahara", "value": "EH"},
{"name": "Yemen", "value": "YE"},
{"name": "Yugoslavia", "value": "YU"},
{"name": "Zambia", "value": "ZM"},
{"name": "Zimbabwe", "value": "ZW"}
{"name": "Afghanistan", "value": "countryAF"},
{"name": "Albania", "value": "countryAL"},
{"name": "Algeria", "value": "countryDZ"},
{"name": "American Samoa", "value": "countryAS"},
{"name": "Andorra", "value": "countryAD"},
{"name": "Angola", "value": "countryAO"},
{"name": "Anguilla", "value": "countryAI"},
{"name": "Antarctica", "value": "countryAQ"},
{"name": "Antigua and Barbuda", "value": "countryAG"},
{"name": "Argentina", "value": "countryAR"},
{"name": "Armenia", "value": "countryAM"},
{"name": "Aruba", "value": "countryAW"},
{"name": "Australia", "value": "countryAU"},
{"name": "Austria", "value": "countryAT"},
{"name": "Azerbaijan", "value": "countryAZ"},
{"name": "Bahamas", "value": "countryBS"},
{"name": "Bahrain", "value": "countryBH"},
{"name": "Bangladesh", "value": "countryBD"},
{"name": "Barbados", "value": "countryBB"},
{"name": "Belarus", "value": "countryBY"},
{"name": "Belgium", "value": "countryBE"},
{"name": "Belize", "value": "countryBZ"},
{"name": "Benin", "value": "countryBJ"},
{"name": "Bermuda", "value": "countryBM"},
{"name": "Bhutan", "value": "countryBT"},
{"name": "Bolivia", "value": "countryBO"},
{"name": "Bosnia and Herzegovina", "value": "countryBA"},
{"name": "Botswana", "value": "countryBW"},
{"name": "Bouvet Island", "value": "countryBV"},
{"name": "Brazil", "value": "countryBR"},
{"name": "British Indian Ocean Territory", "value": "countryIO"},
{"name": "Brunei Darussalam", "value": "countryBN"},
{"name": "Bulgaria", "value": "countryBG"},
{"name": "Burkina Faso", "value": "countryBF"},
{"name": "Burundi", "value": "countryBI"},
{"name": "Cambodia", "value": "countryKH"},
{"name": "Cameroon", "value": "countryCM"},
{"name": "Canada", "value": "countryCA"},
{"name": "Cape Verde", "value": "countryCV"},
{"name": "Cayman Islands", "value": "countryKY"},
{"name": "Central African Republic", "value": "countryCF"},
{"name": "Chad", "value": "countryTD"},
{"name": "Chile", "value": "countryCL"},
{"name": "China", "value": "countryCN"},
{"name": "Christmas Island", "value": "countryCX"},
{"name": "Cocos (Keeling) Islands", "value": "countryCC"},
{"name": "Colombia", "value": "countryCO"},
{"name": "Comoros", "value": "countryKM"},
{"name": "Congo", "value": "countryCG"},
{"name": "Congo, Democratic Republic of the", "value": "countryCD"},
{"name": "Cook Islands", "value": "countryCK"},
{"name": "Costa Rica", "value": "countryCR"},
{"name": "Cote D\"ivoire", "value": "countryCI"},
{"name": "Croatia (Hrvatska)", "value": "countryHR"},
{"name": "Cuba", "value": "countryCU"},
{"name": "Cyprus", "value": "countryCY"},
{"name": "Czech Republic", "value": "countryCZ"},
{"name": "Denmark", "value": "countryDK"},
{"name": "Djibouti", "value": "countryDJ"},
{"name": "Dominica", "value": "countryDM"},
{"name": "Dominican Republic", "value": "countryDO"},
{"name": "East Timor", "value": "countryTP"},
{"name": "Ecuador", "value": "countryEC"},
{"name": "Egypt", "value": "countryEG"},
{"name": "El Salvador", "value": "countrySV"},
{"name": "Equatorial Guinea", "value": "countryGQ"},
{"name": "Eritrea", "value": "countryER"},
{"name": "Estonia", "value": "countryEE"},
{"name": "Ethiopia", "value": "countryET"},
{"name": "European Union", "value": "countryEU"},
{"name": "Falkland Islands (Malvinas)", "value": "countryFK"},
{"name": "Faroe Islands", "value": "countryFO"},
{"name": "Fiji", "value": "countryFJ"},
{"name": "Finland", "value": "countryFI"},
{"name": "France", "value": "countryFR"},
{"name": "France, Metropolitan", "value": "countryFX"},
{"name": "French Guiana", "value": "countryGF"},
{"name": "French Polynesia", "value": "countryPF"},
{"name": "French Southern Territories", "value": "countryTF"},
{"name": "Gabon", "value": "countryGA"},
{"name": "Gambia", "value": "countryGM"},
{"name": "Georgia", "value": "countryGE"},
{"name": "Germany", "value": "countryDE"},
{"name": "Ghana", "value": "countryGH"},
{"name": "Gibraltar", "value": "countryGI"},
{"name": "Greece", "value": "countryGR"},
{"name": "Greenland", "value": "countryGL"},
{"name": "Grenada", "value": "countryGD"},
{"name": "Guadeloupe", "value": "countryGP"},
{"name": "Guam", "value": "countryGU"},
{"name": "Guatemala", "value": "countryGT"},
{"name": "Guinea", "value": "countryGN"},
{"name": "Guinea-Bissau", "value": "countryGW"},
{"name": "Guyana", "value": "countryGY"},
{"name": "Haiti", "value": "countryHT"},
{"name": "Heard Island and Mcdonald Islands", "value": "countryHM"},
{"name": "Holy See (Vatican City State)", "value": "countryVA"},
{"name": "Honduras", "value": "countryHN"},
{"name": "Hong Kong", "value": "countryHK"},
{"name": "Hungary", "value": "countryHU"},
{"name": "Iceland", "value": "countryIS"},
{"name": "India", "value": "countryIN"},
{"name": "Indonesia", "value": "countryID"},
{"name": "Iran, Islamic Republic of", "value": "countryIR"},
{"name": "Iraq", "value": "countryIQ"},
{"name": "Ireland", "value": "countryIE"},
{"name": "Israel", "value": "countryIL"},
{"name": "Italy", "value": "countryIT"},
{"name": "Jamaica", "value": "countryJM"},
{"name": "Japan", "value": "countryJP"},
{"name": "Jordan", "value": "countryJO"},
{"name": "Kazakhstan", "value": "countryKZ"},
{"name": "Kenya", "value": "countryKE"},
{"name": "Kiribati", "value": "countryKI"},
{"name": "Korea, Democratic People\"s Republic of",
"value": "countryKP"},
{"name": "Korea, Republic of", "value": "countryKR"},
{"name": "Kuwait", "value": "countryKW"},
{"name": "Kyrgyzstan", "value": "countryKG"},
{"name": "Lao People\"s Democratic Republic", "value": "countryLA"},
{"name": "Latvia", "value": "countryLV"},
{"name": "Lebanon", "value": "countryLB"},
{"name": "Lesotho", "value": "countryLS"},
{"name": "Liberia", "value": "countryLR"},
{"name": "Libyan Arab Jamahiriya", "value": "countryLY"},
{"name": "Liechtenstein", "value": "countryLI"},
{"name": "Lithuania", "value": "countryLT"},
{"name": "Luxembourg", "value": "countryLU"},
{"name": "Macao", "value": "countryMO"},
{"name": "Macedonia, the Former Yugosalv Republic of",
"value": "countryMK"},
{"name": "Madagascar", "value": "countryMG"},
{"name": "Malawi", "value": "countryMW"},
{"name": "Malaysia", "value": "countryMY"},
{"name": "Maldives", "value": "countryMV"},
{"name": "Mali", "value": "countryML"},
{"name": "Malta", "value": "countryMT"},
{"name": "Marshall Islands", "value": "countryMH"},
{"name": "Martinique", "value": "countryMQ"},
{"name": "Mauritania", "value": "countryMR"},
{"name": "Mauritius", "value": "countryMU"},
{"name": "Mayotte", "value": "countryYT"},
{"name": "Mexico", "value": "countryMX"},
{"name": "Micronesia, Federated States of", "value": "countryFM"},
{"name": "Moldova, Republic of", "value": "countryMD"},
{"name": "Monaco", "value": "countryMC"},
{"name": "Mongolia", "value": "countryMN"},
{"name": "Montserrat", "value": "countryMS"},
{"name": "Morocco", "value": "countryMA"},
{"name": "Mozambique", "value": "countryMZ"},
{"name": "Myanmar", "value": "countryMM"},
{"name": "Namibia", "value": "countryNA"},
{"name": "Nauru", "value": "countryNR"},
{"name": "Nepal", "value": "countryNP"},
{"name": "Netherlands", "value": "countryNL"},
{"name": "Netherlands Antilles", "value": "countryAN"},
{"name": "New Caledonia", "value": "countryNC"},
{"name": "New Zealand", "value": "countryNZ"},
{"name": "Nicaragua", "value": "countryNI"},
{"name": "Niger", "value": "countryNE"},
{"name": "Nigeria", "value": "countryNG"},
{"name": "Niue", "value": "countryNU"},
{"name": "Norfolk Island", "value": "countryNF"},
{"name": "Northern Mariana Islands", "value": "countryMP"},
{"name": "Norway", "value": "countryNO"},
{"name": "Oman", "value": "countryOM"},
{"name": "Pakistan", "value": "countryPK"},
{"name": "Palau", "value": "countryPW"},
{"name": "Palestinian Territory", "value": "countryPS"},
{"name": "Panama", "value": "countryPA"},
{"name": "Papua New Guinea", "value": "countryPG"},
{"name": "Paraguay", "value": "countryPY"},
{"name": "Peru", "value": "countryPE"},
{"name": "Philippines", "value": "countryPH"},
{"name": "Pitcairn", "value": "countryPN"},
{"name": "Poland", "value": "countryPL"},
{"name": "Portugal", "value": "countryPT"},
{"name": "Puerto Rico", "value": "countryPR"},
{"name": "Qatar", "value": "countryQA"},
{"name": "Reunion", "value": "countryRE"},
{"name": "Romania", "value": "countryRO"},
{"name": "Russian Federation", "value": "countryRU"},
{"name": "Rwanda", "value": "countryRW"},
{"name": "Saint Helena", "value": "countrySH"},
{"name": "Saint Kitts and Nevis", "value": "countryKN"},
{"name": "Saint Lucia", "value": "countryLC"},
{"name": "Saint Pierre and Miquelon", "value": "countryPM"},
{"name": "Saint Vincent and the Grenadines", "value": "countryVC"},
{"name": "Samoa", "value": "countryWS"},
{"name": "San Marino", "value": "countrySM"},
{"name": "Sao Tome and Principe", "value": "countryST"},
{"name": "Saudi Arabia", "value": "countrySA"},
{"name": "Senegal", "value": "countrySN"},
{"name": "Serbia and Montenegro", "value": "countryCS"},
{"name": "Seychelles", "value": "countrySC"},
{"name": "Sierra Leone", "value": "countrySL"},
{"name": "Singapore", "value": "countrySG"},
{"name": "Slovakia", "value": "countrySK"},
{"name": "Slovenia", "value": "countrySI"},
{"name": "Solomon Islands", "value": "countrySB"},
{"name": "Somalia", "value": "countrySO"},
{"name": "South Africa", "value": "countryZA"},
{"name": "South Georgia and the South Sandwich Islands",
"value": "countryGS"},
{"name": "Spain", "value": "countryES"},
{"name": "Sri Lanka", "value": "countryLK"},
{"name": "Sudan", "value": "countrySD"},
{"name": "Suriname", "value": "countrySR"},
{"name": "Svalbard and Jan Mayen", "value": "countrySJ"},
{"name": "Swaziland", "value": "countrySZ"},
{"name": "Sweden", "value": "countrySE"},
{"name": "Switzerland", "value": "countryCH"},
{"name": "Syrian Arab Republic", "value": "countrySY"},
{"name": "Taiwan, Province of China", "value": "countryTW"},
{"name": "Tajikistan", "value": "countryTJ"},
{"name": "Tanzania, United Republic of", "value": "countryTZ"},
{"name": "Thailand", "value": "countryTH"},
{"name": "Togo", "value": "countryTG"},
{"name": "Tokelau", "value": "countryTK"},
{"name": "Tonga", "value": "countryTO"},
{"name": "Trinidad and Tobago", "value": "countryTT"},
{"name": "Tunisia", "value": "countryTN"},
{"name": "Turkey", "value": "countryTR"},
{"name": "Turkmenistan", "value": "countryTM"},
{"name": "Turks and Caicos Islands", "value": "countryTC"},
{"name": "Tuvalu", "value": "countryTV"},
{"name": "Uganda", "value": "countryUG"},
{"name": "Ukraine", "value": "countryUA"},
{"name": "United Arab Emirates", "value": "countryAE"},
{"name": "United Kingdom", "value": "countryUK"},
{"name": "United States", "value": "countryUS"},
{"name": "United States Minor Outlying Islands", "value": "countryUM"},
{"name": "Uruguay", "value": "countryUY"},
{"name": "Uzbekistan", "value": "countryUZ"},
{"name": "Vanuatu", "value": "countryVU"},
{"name": "Venezuela", "value": "countryVE"},
{"name": "Vietnam", "value": "countryVN"},
{"name": "Virgin Islands, British", "value": "countryVG"},
{"name": "Virgin Islands, U.S.", "value": "countryVI"},
{"name": "Wallis and Futuna", "value": "countryWF"},
{"name": "Western Sahara", "value": "countryEH"},
{"name": "Yemen", "value": "countryYE"},
{"name": "Yugoslavia", "value": "countryYU"},
{"name": "Zambia", "value": "countryZM"},
{"name": "Zimbabwe", "value": "countryZW"}
]

View File

@ -1,32 +0,0 @@
{
"all": {
"tbm": null,
"href": "search?q={query}",
"name": "All",
"selected": true
},
"images": {
"tbm": "isch",
"href": "search?q={query}",
"name": "Images",
"selected": false
},
"maps": {
"tbm": null,
"href": "https://maps.google.com/maps?q={map_query}",
"name": "Maps",
"selected": false
},
"videos": {
"tbm": "vid",
"href": "search?q={query}",
"name": "Videos",
"selected": false
},
"news": {
"tbm": "nws",
"href": "search?q={query}",
"name": "News",
"selected": false
}
}

View File

@ -4,12 +4,11 @@
{"name": "Afrikaans (Afrikaans)", "value": "lang_af"},
{"name": "Arabic (عربى)", "value": "lang_ar"},
{"name": "Armenian (հայերեն)", "value": "lang_hy"},
{"name": "Azerbaijani (Azərbaycanca)", "value": "lang_az"},
{"name": "Belarusian (Беларуская)", "value": "lang_be"},
{"name": "Bulgarian (български)", "value": "lang_bg"},
{"name": "Catalan (Català)", "value": "lang_ca"},
{"name": "Chinese, Simplified (简体中文)", "value": "lang_zh-CN"},
{"name": "Chinese, Traditional (正體中文)", "value": "lang_zh-TW"},
{"name": "Chinese, Traditional (繁体中文)", "value": "lang_zh-TW"},
{"name": "Croatian (Hrvatski)", "value": "lang_hr"},
{"name": "Czech (čeština)", "value": "lang_cs"},
{"name": "Danish (Dansk)", "value": "lang_da"},
@ -19,7 +18,7 @@
{"name": "Filipino (Pilipino)", "value": "lang_tl"},
{"name": "Finnish (Suomalainen)", "value": "lang_fi"},
{"name": "French (Français)", "value": "lang_fr"},
{"name": "German (Deutsch)", "value": "lang_de"},
{"name": "German (Deutsche)", "value": "lang_de"},
{"name": "Greek (Ελληνικά)", "value": "lang_el"},
{"name": "Hebrew (עִברִית)", "value": "lang_iw"},
{"name": "Hindi (हिंदी)", "value": "lang_hi"},
@ -29,27 +28,22 @@
{"name": "Italian (Italiano)", "value": "lang_it"},
{"name": "Japanese (日本語)", "value": "lang_ja"},
{"name": "Korean (한국어)", "value": "lang_ko"},
{"name": "Kurdish (Kurdî)", "value": "lang_ku"},
{"name": "Latvian (Latvietis)", "value": "lang_lv"},
{"name": "Lithuanian (Lietuvis)", "value": "lang_lt"},
{"name": "Norwegian (Norwegian)", "value": "lang_no"},
{"name": "Persian (فارسی)", "value": "lang_fa"},
{"name": "Polish (Polskie)", "value": "lang_pl"},
{"name": "Portuguese (Português)", "value": "lang_pt"},
{"name": "Portugese (Português)", "value": "lang_pt"},
{"name": "Romanian (Română)", "value": "lang_ro"},
{"name": "Russian (русский)", "value": "lang_ru"},
{"name": "Serbian (Српски)", "value": "lang_sr"},
{"name": "Sinhala (සිංහල)", "value": "lang_si"},
{"name": "Slovak (Slovák)", "value": "lang_sk"},
{"name": "Slovenian (Slovenščina)", "value": "lang_sl"},
{"name": "Spanish (Español)", "value": "lang_es"},
{"name": "Swahili (Kiswahili)", "value": "lang_sw"},
{"name": "Swedish (Svenska)", "value": "lang_sv"},
{"name": "Thai (ไทย)", "value": "lang_th"},
{"name": "Turkish (Türkçe)", "value": "lang_tr"},
{"name": "Ukrainian (Українська)", "value": "lang_uk"},
{"name": "Vietnamese (Tiếng Việt)", "value": "lang_vi"},
{"name": "Welsh (Cymraeg)", "value": "lang_cy"},
{"name": "Xhosa (isiXhosa)", "value": "lang_xh"},
{"name": "Zulu (isiZulu)", "value": "lang_zu"}
{"name": "Turkish (Türk)", "value": "lang_tr"},
{"name": "Ukranian (Український)", "value": "lang_uk"},
{"name": "Vietnamese (Tiếng Việt)", "value": "lang_vi"}
]

View File

@ -1,5 +0,0 @@
[
"light",
"dark",
"system"
]

View File

@ -1,8 +0,0 @@
[
{"name": "Any time", "value": ""},
{"name": "Past hour", "value": "qdr:h"},
{"name": "Past 24 hours", "value": "qdr:d"},
{"name": "Past week", "value": "qdr:w"},
{"name": "Past month", "value": "qdr:m"},
{"name": "Past year", "value": "qdr:y"}
]

File diff suppressed because it is too large Load Diff

View File

@ -1,263 +0,0 @@
<!--
Calculator widget.
This file should contain all required
CSS, HTML, and JS for it.
-->
<style>
#calc-text {
background: var(--whoogle-dark-page-bg);
padding: 8px;
border-radius: 8px;
text-align: right;
font-family: monospace;
font-size: 16px;
color: var(--whoogle-dark-text);
}
#prev-equation {
text-align: right;
}
.error-border {
border: 1px solid red;
}
#calc-btns {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-rows: repeat(5, 1fr);
gap: 5px;
}
#calc-btns button {
background: #313141;
color: var(--whoogle-dark-text);
border: none;
border-radius: 8px;
padding: 8px;
cursor: pointer;
}
#calc-btns button:hover {
background: #414151;
}
#calc-btns .common {
background: #51516a;
}
#calc-btns .common:hover {
background: #61617a;
}
#calc-btn-0 { grid-row: 5; grid-column: 3; }
#calc-btn-1 { grid-row: 4; grid-column: 3; }
#calc-btn-2 { grid-row: 4; grid-column: 4; }
#calc-btn-3 { grid-row: 4; grid-column: 5; }
#calc-btn-4 { grid-row: 3; grid-column: 3; }
#calc-btn-5 { grid-row: 3; grid-column: 4; }
#calc-btn-6 { grid-row: 3; grid-column: 5; }
#calc-btn-7 { grid-row: 2; grid-column: 3; }
#calc-btn-8 { grid-row: 2; grid-column: 4; }
#calc-btn-9 { grid-row: 2; grid-column: 5; }
#calc-btn-EQ { grid-row: 5; grid-column: 5; }
#calc-btn-PT { grid-row: 5; grid-column: 4; }
#calc-btn-BCK { grid-row: 5; grid-column: 6; }
#calc-btn-ADD { grid-row: 4; grid-column: 6; }
#calc-btn-SUB { grid-row: 3; grid-column: 6; }
#calc-btn-MLT { grid-row: 2; grid-column: 6; }
#calc-btn-DIV { grid-row: 1; grid-column: 6; }
#calc-btn-CLR { grid-row: 1; grid-column: 5; }
#calc-btn-PRC{ grid-row: 1; grid-column: 4; }
#calc-btn-RP { grid-row: 1; grid-column: 3; }
#calc-btn-LP { grid-row: 1; grid-column: 2; }
#calc-btn-ABS { grid-row: 1; grid-column: 1; }
#calc-btn-SIN { grid-row: 2; grid-column: 2; }
#calc-btn-COS { grid-row: 3; grid-column: 2; }
#calc-btn-TAN { grid-row: 4; grid-column: 2; }
#calc-btn-SQR { grid-row: 5; grid-column: 2; }
#calc-btn-EXP { grid-row: 2; grid-column: 1; }
#calc-btn-E { grid-row: 3; grid-column: 1; }
#calc-btn-PI { grid-row: 4; grid-column: 1; }
#calc-btn-LOG { grid-row: 5; grid-column: 1; }
</style>
<p id="prev-equation"></p>
<div id="calculator-widget">
<p id="calc-text">0</p>
<div id="calc-btns">
<button id="calc-btn-0" class="common">0</button>
<button id="calc-btn-1" class="common">1</button>
<button id="calc-btn-2" class="common">2</button>
<button id="calc-btn-3" class="common">3</button>
<button id="calc-btn-4" class="common">4</button>
<button id="calc-btn-5" class="common">5</button>
<button id="calc-btn-6" class="common">6</button>
<button id="calc-btn-7" class="common">7</button>
<button id="calc-btn-8" class="common">8</button>
<button id="calc-btn-9" class="common">9</button>
<button id="calc-btn-EQ" class="common">=</button>
<button id="calc-btn-PT" class="common">.</button>
<button id="calc-btn-BCK"></button>
<button id="calc-btn-ADD">+</button>
<button id="calc-btn-SUB">-</button>
<button id="calc-btn-MLT">x</button>
<button id="calc-btn-DIV">/</button>
<button id="calc-btn-CLR">C</button>
<button id="calc-btn-PRC">%</button>
<button id="calc-btn-RP">)</button>
<button id="calc-btn-LP">(</button>
<button id="calc-btn-ABS">|x|</button>
<button id="calc-btn-SIN">sin</button>
<button id="calc-btn-COS">cos</button>
<button id="calc-btn-TAN">tan</button>
<button id="calc-btn-SQR"></button>
<button id="calc-btn-EXP">^</button>
<button id="calc-btn-E"></button>
<button id="calc-btn-PI">π</button>
<button id="calc-btn-LOG">log</button>
</div>
</div>
<script>
// JS does not have this by default.
// from https://www.freecodecamp.org/news/how-to-factorialize-a-number-in-javascript-9263c89a4b38/
function factorial(num) {
if (num < 0)
return -1;
else if (num === 0)
return 1;
else {
return (num * factorial(num - 1));
}
}
// returns true if the user is currently focused on the calculator widget
function usingCalculator() {
let activeElement = document.activeElement;
while (true) {
if (!activeElement) return false;
if (activeElement.id === "calculator-wrapper") return true;
activeElement = activeElement.parentElement;
}
}
const $ = q => document.querySelectorAll(q);
// key bindings for commonly used buttons
const keybindings = {
"0": "0",
"1": "1",
"2": "2",
"3": "3",
"4": "4",
"5": "5",
"6": "6",
"7": "7",
"8": "8",
"9": "9",
"Enter": "EQ",
".": "PT",
"+": "ADD",
"-": "SUB",
"*": "MLT",
"/": "DIV",
"%": "PRC",
"c": "CLR",
"(": "LP",
")": "RP",
"Backspace": "BCK",
}
window.addEventListener("keydown", event => {
if (!usingCalculator()) return;
if (event.key === "Enter" && document.activeElement.id !== "search-bar")
event.preventDefault();
if (keybindings[event.key])
document.getElementById("calc-btn-" + keybindings[event.key]).click();
})
// calculates the string
const calc = () => {
var mathtext = document.getElementById("calc-text");
var statement = mathtext.innerHTML
// remove empty ()
.replace("()", "")
// special constants
.replace("π", "(Math.PI)")
.replace("ℇ", "(Math.E)")
// turns 3(1+2) into 3*(1+2) (for example)
.replace(/(?<=[0-9\)])(?<=[^+\-x*\/%^])\(/, "x(")
// same except reversed
.replace(/\)(?=[0-9\(])(?=[^+\-x*\/%^])/, ")x")
// replace human friendly x with JS *
.replace("x", "*")
// trig & misc functions
.replace("sin", "Math.sin")
.replace("cos", "Math.cos")
.replace("tan", "Math.tan")
.replace("√", "Math.sqrt")
.replace("^", "**")
.replace("abs", "Math.abs")
.replace("log", "Math.log")
;
// add any missing )s to the end
while(true) if (
(statement.match(/\(/g) || []).length >
(statement.match(/\)/g) || []).length
) statement += ")"; else break;
// evaluate the expression using a safe evaluator (no eval())
console.log("calculating [" + statement + "]");
try {
// Safe evaluation: create a sandboxed function with only Math object available
// This prevents arbitrary code execution while allowing mathematical operations
const safeEval = new Function('Math', `'use strict'; return (${statement})`);
var result = safeEval(Math);
document.getElementById("prev-equation").innerHTML = mathtext.innerHTML + " = ";
mathtext.innerHTML = result;
mathtext.classList.remove("error-border");
} catch (e) {
mathtext.classList.add("error-border");
console.error(e);
}
}
const updateCalc = (e) => {
// character(s) recieved from button
var c = event.target.innerHTML;
var mathtext = document.getElementById("calc-text");
if (mathtext.innerHTML === "0") mathtext.innerHTML = "";
// special cases
switch (c) {
case "C":
// Clear
mathtext.innerHTML = "0";
break;
case "⬅":
// Delete
mathtext.innerHTML = mathtext.innerHTML.slice(0, -1);
if (mathtext.innerHTML.length === 0) {
mathtext.innerHTML = "0";
}
break;
case "=":
calc()
break;
case "sin":
case "cos":
case "tan":
case "log":
case "√":
mathtext.innerHTML += `${c}(`;
break;
case "|x|":
mathtext.innerHTML += "abs("
break;
case "+":
case "-":
case "x":
case "/":
case "%":
case "^":
if (mathtext.innerHTML.length === 0) mathtext.innerHTML = "0";
// prevent typing 2 operators in a row
if (mathtext.innerHTML.match(/[+\-x\/%^] $/))
mathtext.innerHTML = mathtext.innerHTML.slice(0, -3);
mathtext.innerHTML += ` ${c} `;
break;
default:
mathtext.innerHTML += c;
}
}
for (let i of $("#calc-btns button")) {
i.addEventListener('click', event => {
updateCalc(event);
})
}
</script>

View File

@ -1,58 +1,29 @@
<html>
<head>
<link rel="shortcut icon" href="static/img/favicon.ico" type="image/x-icon">
<link rel="icon" href="static/img/favicon.ico" type="image/x-icon">
{% if not search_type %}
<head>
<link rel="shortcut icon" href="static/img/favicon.ico" type="image/x-icon">
<link rel="icon" href="static/img/favicon.ico" type="image/x-icon">
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
{% else %}
<link rel="search" href="opensearch.xml?tbm={{ search_type }}" type="application/opensearchdescription+xml" title="Whoogle Search ({{ search_name }})">
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer">
{% if bundle_static() %}
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
{% else %}
<link rel="stylesheet" href="{{ cb_url('logo.css') }}">
<link rel="stylesheet" href="{{ cb_url('input.css') }}">
<link rel="stylesheet" href="{{ cb_url('search.css') }}">
<link rel="stylesheet" href="{{ cb_url('header.css') }}">
{% endif %}
{% if config.theme %}
{% if config.theme == 'system' %}
<style>
@import "{{ cb_url('light-theme.css') }}" screen;
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
</style>
{% else %}
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
{% endif %}
{% endif %}
{% if config.style %}
<style>
{{ config.style }}
</style>
{% endif %}
<title>{{ clean_query(query) }} - Whoogle Search</title>
</head>
<body>
{{ search_header|safe }}
{% if is_translation %}
<iframe
id="lingva-iframe"
src="{{ lingva_url }}/auto/{{ translate_to }}/{{ translate_str }}">
</iframe>
{% endif %}
{{ response|safe }}
</body>
{% include 'footer.html' %}
{% if bundle_static() %}
<script src="/{{ cb_url('bundle.js') }}" defer></script>
{% else %}
{% if autocomplete_enabled == '1' %}
<script src="{{ cb_url('autocomplete.js') }}"></script>
{% endif %}
<script src="{{ cb_url('utils.js') }}"></script>
<script src="{{ cb_url('keyboard.js') }}"></script>
<script src="{{ cb_url('currency.js') }}"></script>
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<link rel="stylesheet" href="static/css/input.css">
<link rel="stylesheet" href="static/css/search.css">
<link rel="stylesheet" href="static/css/variables.css">
<link rel="stylesheet" href="static/css/header.css">
<link rel="stylesheet" href="static/css/{{ 'dark' if config.dark else 'light' }}-theme.css"/>
<style>{{ config.style }}</style>
<title>{{ query }} - Whoogle Search</title>
</head>
<body>
{{ search_header|safe }}
{{ response|safe }}
</body>
<footer>
<p style="color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};">
Whoogle Search v{{ version_number }} ||
<a id="gh-link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
</p>
</footer>
<script src="static/js/autocomplete.js"></script>
<script src="static/js/utils.js"></script>
<script src="static/js/keyboard.js"></script>
</html>

View File

@ -1,128 +1,6 @@
{% if config.theme %}
{% if config.theme == 'system' %}
<style>
@import "{{ cb_url('light-theme.css') }}" screen;
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
</style>
{% else %}
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
{% endif %}
{% endif %}
{% if bundle_static() %}
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
{% else %}
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
<link rel="stylesheet" href="{{ cb_url('error.css') }}">
{% endif %}
<style>{{ config.style }}</style>
<div>
<h1>Error</h1>
<p>
{{ error_message }}
</p>
<hr>
{% if query and translation %}
<p>
<h4><a class="link" href="https://farside.link">{{ translation['continue-search'] }}</a></h4>
<ul>
<li>
<a href="https://github.com/benbusby/whoogle-search">Whoogle</a>
<ul>
<li>
<a class="link-color" href="{{farside}}/whoogle/search?q={{query}}{{params}}">
{{farside}}/whoogle/search?q={{query}}
</a>
</li>
</ul>
</li>
<li>
<a href="https://github.com/searxng/searxng">SearXNG</a>
<ul>
<li>
<a class="link-color" href="{{farside}}/searxng/search?q={{query}}">
{{farside}}/searxng/search?q={{query}}
</a>
</li>
</ul>
</li>
<li>
<a href="https://git.lolcat.ca/lolcat/4get">4get</a>
<ul>
<li>
<a class="link-color" href="{{farside}}/4get/web?s={{query}}&scraper=google">
{{farside}}/4get/web?s={{query}}&scraper=google
</a>
</li>
</ul>
</li>
</ul>
<hr>
<h4>Other options:</h4>
<ul>
<li>
<a href="https://kagi.com">Kagi</a>
<ul>
<li>Requires account</li>
<li>
<a class="link-color" href="https://kagi.com/search?q={{query}}">
kagi.com/search?q={{query}}
</a>
</li>
</ul>
</li>
<li>
<a href="https://4get.ca">4get</a>
<ul>
<li>
<a class="link-color" href="https://4get.ca/web?s={{query}}">
4get.ca/web?s={{query}}
</a>
</li>
</ul>
</li>
<li>
<a href="https://duckduckgo.com">DuckDuckGo</a>
<ul>
<li>
<a class="link-color" href="https://duckduckgo.com/?q={{query}}">
duckduckgo.com/?q={{query}}
</a>
</li>
</ul>
</li>
<li>
<a href="https://search.brave.com">Brave Search</a>
<ul>
<li>
<a class="link-color" href="https://search.brave.com/search?q={{query}}">
search.brave.com/search?q={{query}}
</a>
</li>
</ul>
</li>
<li>
<a href="https://ecosia.com">Ecosia</a>
<ul>
<li>
<a class="link-color" href="https://ecosia.com/search?q={{query}}">
ecosia.com/search?q={{query}}
</a>
</li>
</ul>
</li>
<li>
<a href="https://google.com">Google</a>
<ul>
<li>
<a class="link-color" href="https://google.com/search?q={{query}}">
google.com/search?q={{query}}
</a>
</li>
</ul>
</li>
</ul>
<hr>
</p>
{% endif %}
<a class="link" href="home">Return Home</a>
</div>
<h1>Error</h1>
<hr>
<p>
Error: "{{ error_message|safe }}"
</p>
<a href="/">Return Home</a>

View File

@ -1,12 +0,0 @@
<footer>
<p class="footer">
Whoogle Search v{{ version_number }} ||
<a class="link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
{% if has_update %}
|| <span class="update_available">Update Available 🟢</span>
{% endif %}
{% if config.show_user_agent and used_user_agent %}
<br><span class="user-agent-display" style="font-size: 0.85em; color: #666;">User Agent: {{ used_user_agent }}</span>
{% endif %}
</p>
</footer>

View File

@ -1,95 +1,64 @@
{% if mobile %}
<header>
<div class="header-div">
<form class="search-form header"
id="search-form"
method="{{ 'GET' if config.get_only else 'POST' }}">
<a class="logo-link mobile-logo" href="{{ home_url }}">
<div id="mobile-header-logo">
<div style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;" class="bz1lBb">
<form class="search-form Pg70bf" id="search-form" method="POST">
<a class="logo-link mobile-logo"
href="/"
style="display:flex; justify-content:center; align-items:center;">
<div style="height: 1.75em;">
{{ logo|safe }}
</div>
</a>
<div class="H0PQec mobile-input-div">
<div class="autocomplete-mobile esbc autocomplete">
{% if config.preferences %}
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
{% endif %}
<input
id="search-bar"
class="mobile-search-bar"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
spellcheck="false"
class="search-bar-input"
name="q"
type="text"
value="{{ clean_query(query) }}"
dir="auto">
<input id="search-reset" type="reset" value="x">
<div class="H0PQec" style="width: 100%;">
<div class="sbc esbc autocomplete">
<input
id="search-bar"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
spellcheck="false"
class="noHIxc"
name="q"
style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;
color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};"
type="text"
value="{{ query }}">
<input style="color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }}" id="search-reset" type="reset" value="x">
<input name="tbm" value="{{ search_type }}" style="display: none">
<input name="country" value="{{ config.country }}" style="display: none;">
<input type="submit" style="display: none;">
<div class="sc"></div>
</div>
</div>
</form>
</div>
<div>
<div class="header-tab-div">
<div class="header-tab-div-2">
<div class="header-tab-div-3">
<div class="mobile-header header-tab">
{% for tab_id, tab_content in tabs.items() %}
{% if tab_content['selected'] %}
<span class="mobile-tab-span">{{ tab_content['name'] }}</span>
{% else %}
<a class="header-tab-a" href="{{ tab_content['href'] }}">{{ tab_content['name'] }}</a>
{% endif %}
{% endfor %}
<label for="adv-search-toggle" id="adv-search-label" class="adv-search"></label>
<input id="adv-search-toggle" type="checkbox">
<div class="header-tab-div-end"></div>
</div>
</div>
</div>
</div>
<div class="" id="s">
</div>
</header>
{% else %}
<header>
<div class="logo-div">
<a class="logo-link" href="{{ home_url }}">
<div class="desktop-header-logo">
<a class="logo-link" href="/">
<div style="height: 1.65em;">
{{ logo|safe }}
</div>
</a>
</div>
<div class="search-div">
<form id="search-form"
class="search-form"
id="sf"
method="{{ 'GET' if config.get_only else 'POST' }}">
<div class="autocomplete header-autocomplete">
<form id="search-form" class="search-form" id="sf" method="POST">
<div class="autocomplete" style="width: 100%; flex: 1">
<div style="width: 100%; display: flex">
{% if config.preferences %}
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
{% endif %}
<input
id="search-bar"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="search-bar-desktop search-bar-input"
name="q"
spellcheck="false"
type="text"
value="{{ clean_query(query) }}"
dir="auto">
<input
id="search-bar"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="search-bar-desktop noHIxc"
name="q"
spellcheck="false"
type="text"
value="{{ query }}"
style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;
color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};
border-bottom: {{ '2px solid var(--whoogle-dark-element-bg)' if config.dark else '0px' }};">
<input name="tbm" value="{{ search_type }}" style="display: none">
<input name="country" value="{{ config.country }}" style="display: none;">
<input name="tbs" value="{{ config.tbs }}" style="display: none;">
<input type="submit" style="display: none;">
<div class="sc"></div>
</div>
@ -97,66 +66,6 @@
</form>
</div>
</header>
<div>
<div class="header-tab-div">
<div class="header-tab-div-2">
<div class="header-tab-div-3">
<div class="desktop-header header-tab">
{% for tab_id, tab_content in tabs.items() %}
{% if tab_content['selected'] %}
<span class="header-tab-span">{{ tab_content['name'] }}</span>
{% else %}
<a class="header-tab-a" href="{{ tab_content['href'] }}">{{ tab_content['name'] }}</a>
{% endif %}
{% endfor %}
<label for="adv-search-toggle" id="adv-search-label" class="adv-search"></label>
<input id="adv-search-toggle" type="checkbox">
<div class="header-tab-div-end"></div>
</div>
</div>
</div>
</div>
<div class="" id="s">
</div>
{% endif %}
<div class="result-collapsible" id="adv-search-div">
<div class="result-config">
<label for="config-country">{{ translation['config-country'] }}: </label>
<select name="country" id="result-country">
{% for country in countries %}
<option value="{{ country.value }}"
{% if (
config.country != '' and config.country in country.value
) or (
config.country == '' and country.value == '')
%}
selected
{% endif %}>
{{ country.name }}
</option>
{% endfor %}
</select>
<br />
<label for="config-time-period">{{ translation['config-time-period'] }}: </label>
<select name="tbs" id="result-time-period">
{% for time_period in time_periods %}
<option value="{{ time_period.value }}"
{% if (
config.tbs != '' and config.tbs in time_period.value
) or (
config.tbs == '' and time_period.value == '')
%}
selected
{% endif %}>
{{ translation[time_period.value] }}
</option>
{% endfor %}
</select>
</div>
</div>
{% if bundle_static() %}
<script src="/{{ cb_url('bundle.js') }}" defer></script>
{% else %}
<script type="text/javascript" src="{{ cb_url('header.js') }}"></script>
{% endif %}
<script type="text/javascript" src="static/js/header.js"></script>

View File

@ -1,409 +1,116 @@
<div>
<!DOCTYPE html>
<html>
<head>
<meta content="application/xhtml+xml; charset=utf-8" http-equiv="Content-Type"/>
<meta content="no-cache" name="Cache-Control"/>
<title>
</title>
<style>
html {
font-family: Roboto, Helvetica Neue, Arial, sans-serif;
font-size: 14px;
line-height: 20px;
text-size-adjust: 100%;
color: #3c4043;
word-wrap: break-word;
background-color: #fff;
}
body {
padding: 0 12px;
margin: 0 auto;
max-width: 1200px;
}
a {
text-decoration: none;
color: inherit;
}
a:hover {
text-decoration: underline;
}
a img {
border: 0;
}
.FbhRzb {
border-left: thin solid #dadce0;
border-right: thin solid #dadce0;
border-top: thin solid #dadce0;
height: 40px;
overflow: hidden;
}
.n692Zd {
margin-bottom: 10px;
}
.cvifge {
height: 40px;
border-spacing: 0;
width: 100%;
}
.QvGUP {
height: 40px;
padding: 0 8px 0 8px;
vertical-align: top;
}
.O4cRJf {
height: 40px;
width: 100%;
padding: 0;
padding-right: 16px;
}
.O1ePr {
height: 40px;
padding: 0;
vertical-align: top;
}
.kgJEQe {
height: 36px;
width: 98px;
vertical-align: top;
margin-top: 4px;
}
.lXLRf {
vertical-align: top;
}
.MhzMZd {
border: 0;
vertical-align: middle;
font-size: 14px;
height: 40px;
padding: 0;
width: 100%;
padding-left: 16px;
}
.xB0fq {
height: 40px;
border: none;
font-size: 14px;
background-color: #4285f4;
color: #fff;
padding: 0 16px;
margin: 0;
vertical-align: top;
cursor: pointer;
}
.xB0fq:focus {
border: 1px solid #000;
}
.M7pB2 {
border: thin solid #dadce0;
margin: 0 0 3px 0;
font-size: 13px;
font-weight: 500;
height: 40px;
}
.euZec {
width: 100%;
height: 40px;
text-align: center;
border-spacing: 0;
}
table.euZec td {
padding: 0;
width: 25%;
}
.QIqI7 {
display: inline-block;
padding-top: 4px;
font-weight: bold;
color: #4285f4;
}
.EY24We {
border-bottom: 2px solid #4285f4;
}
.CsQyDc {
display: inline-block;
color: #70757a;
}
.TuS8Ad {
font-size: 14px;
}
.HddGcc {
padding: 8px;
color: #70757a;
}
.dzp8ae {
font-weight: bold;
color: #3c4043;
}
.rEM8G {
color: #70757a;
}
.bookcf {
table-layout: fixed;
width: 100%;
border-spacing: 0;
}
.InWNIe {
text-align: center;
}
.uZgmoc {
border: thin solid #dadce0;
color: #70757a;
font-size: 14px;
text-align: center;
table-layout: fixed;
width: 100%;
}
.frGj1b {
display: block;
padding: 12px 0 12px 0;
width: 100%;
}
.BnJWBc {
text-align: center;
padding: 6px 0 13px 0;
height: 35px;
}
.e3goi {
vertical-align: top;
padding: 0;
}
.GpQGbf {
margin: auto;
border-collapse: collapse;
border-spacing: 0;
width: 100%;
table-layout: fixed;
}
.X6ZCif {
color: #202124;
font-size: 11px;
line-height: 16px;
display: inline-block;
padding-top: 2px;
overflow: hidden;
padding-bottom: 4px;
width: 100%;
}
.TwVfHd {
border-radius: 16px;
border: thin solid #dadce0;
display: inline-block;
padding: 8px 8px;
margin-right: 8px;
margin-bottom: 4px;
}
.yekiAe {
background-color: #dadce0;
}
.svla5d {
width: 100%;
}
.ezO2md {
border: thin solid #dadce0;
padding: 12px 16px 12px 16px;
margin-bottom: 10px;
font-family: Roboto, Helvetica, Arial, sans-serif;
}
.TxbwNb {
border-spacing: 0;
}
.K35ahc {
width: 100%;
}
.owohpf {
text-align: center;
}
.RAyV4b {
height: 220px;
line-height: 220px;
overflow: hidden;
text-align: center;
}
.t0fcAb {
text-align: center;
margin: auto;
vertical-align: middle;
object-fit: cover;
max-width: 100%;
height: auto;
max-height: 220px;
display: block;
}
.Tor4Ec {
padding-top: 2px;
padding-bottom: 8px;
}
.fYyStc {
word-break: break-word;
}
.ynsChf {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.Fj3V3b {
color: #1967d2;
font-size: 14px;
line-height: 20px;
}
.FrIlee {
color: #202124;
font-size: 11px;
line-height: 16px;
}
.F9iS2e {
color: #70757a;
font-size: 11px;
line-height: 16px;
}
.WMQ2Le {
color: #70757a;
font-size: 12px;
line-height: 16px;
}
.x3G5ab {
color: #202124;
font-size: 12px;
line-height: 16px;
}
.fuLhoc {
color: #1967d2;
font-size: 18px;
line-height: 24px;
}
.epoveb {
font-size: 32px;
line-height: 40px;
font-weight: 400;
color: #202124;
}
.dXDvrc {
color: #0d652d;
font-size: 14px;
line-height: 20px;
word-wrap: break-word;
}
.dloBPe {
font-weight: bold;
}
.YVIcad {
color: #70757a;
}
.JkVVdd {
color: #ea4335;
}
.oXZRFd {
color: #ea4335;
}
.MQHtg {
color: #fbbc04;
}
.pyMRrb {
color: #1e8e3e;
}
.EtTZid {
color: #1e8e3e;
}
.M3vVJe {
color: #1967d2;
}
.qXLe6d {
display: block;
}
.NHQNef {
font-style: italic;
}
.Cb8Z7c {
white-space: pre;
}
a.ZWRArf {
text-decoration: none;
}
a .CVA68e:hover {
text-decoration: underline;
}
.e3goi {
width: 25%;
padding: 10px;
box-sizing: border-box;
}
.svla5d {
max-width: 100%;
}
@media (max-width: 900px) {
.e3goi {
width: 50%;
}
}
@media (max-width: 600px) {
.e3goi {
width: 100%;
}
}
a{text-decoration:none;color:inherit}a:hover{text-decoration:underline}a img{border:0}body{font-family:Roboto,Helvetica,Arial,sans-serif;padding:8px;margin:0 auto;max-width:700px;min-width:240px;}.FbhRzb{border-left:thin solid #dadce0;border-right:thin solid #dadce0;border-top:thin solid #dadce0;height:40px;overflow:hidden}.n692Zd{margin-bottom:10px}.cvifge{height:40px;border-spacing:0;width:100%;}.QvGUP{height:40px;padding:0 8px 0 8px;vertical-align:top}.O4cRJf{height:40px;width:100%;padding:0;padding-right:16px}.O1ePr{height:40px;padding:0;vertical-align:top}.kgJEQe{height:36px;width:98px;vertical-align:top;margin-top:4px}.lXLRf{vertical-align:top}.MhzMZd{border:0;vertical-align:middle;font-size:14px;height:40px;padding:0;width:100%;padding-left:16px}.xB0fq{height:40px;border:none;font-size:14px;background-color:#4285f4;color:#fff;padding:0 16px;margin:0;vertical-align:top;cursor:pointer}.xB0fq:focus{border:1px solid #000}.M7pB2{border:thin solid #dadce0;margin:0 0 3px 0;font-size:13px;font-weight:500;height:40px}.euZec{width:100%;height:40px;text-align:center;border-spacing:0}table.euZec td{padding:0;width:25%}.QIqI7{display:inline-block;padding-top:4px;font-weight:bold;color:#4285f4}.EY24We{border-bottom:2px solid #4285f4}.CsQyDc{display:inline-block;color:#70757a}.TuS8Ad{font-size:14px}.HddGcc{padding:8px;color:#70757a}.dzp8ae{font-weight:bold;color:#3c4043}.rEM8G{color:#70757a}.bookcf{table-layout:fixed;width:100%;border-spacing:0}.InWNIe{text-align:center}.uZgmoc{border:thin solid #dadce0;color:#70757a;font-size:14px;text-align:center;table-layout:fixed;width:100%}.frGj1b{display:block;padding:12px 0 12px 0;width:100%}.BnJWBc{text-align:center;padding:6px 0 13px 0;height:35px}.e3goi{vertical-align:top;padding:0;height:180px}.GpQGbf{margin:auto;border-collapse:collapse;border-spacing:0;width:100%}
</style>
</head>
<body>
<style>
.X6ZCif{color:#202124;font-size:11px;line-height:16px;display:inline-block;padding-top:2px;overflow:hidden;padding-bottom:4px;width:100%}.TwVfHd{border-radius:16px;border:thin solid #dadce0;display:inline-block;padding:8px 8px;margin-right:8px;margin-bottom:4px}.yekiAe{background-color:#dadce0}.svla5d{width:100%}.ezO2md{border:thin solid #dadce0;padding:12px 16px 12px 16px;margin-bottom:10px;font-family:Roboto,Helvetica,Arial,sans-serif}.lIMUZd{font-family:Roboto,Helvetica,Arial,sans-serif}.TxbwNb{border-spacing:0}.K35ahc{width:100%}.owohpf{text-align:center}.RAyV4b{width:162px;height:140px;line-height:140px;overflow:'hidden';text-align:center;}.t0fcAb{text-align:center;margin:auto;vertical-align:middle;width:100%;height:100%;object-fit: contain}.Tor4Ec{padding-top:2px;padding-bottom:8px;}.fYyStc{word-break:break-word}.ynsChf{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.Fj3V3b{color:#1967D2;font-size:14px;line-height:20px}.FrIlee{color:#202124;font-size:11px;line-height:16px}.F9iS2e{color:#70757a;font-size:11px;line-height:16px}.WMQ2Le{color:#70757a;font-size:12px;line-height:16px}.x3G5ab{color:#202124;font-size:12px;line-height:16px}.fuLhoc{color:#1967D2;font-size:18px;line-height:24px}.epoveb{font-size:32px;line-height:40px;font-weight:400;color:#202124}.dXDvrc{color:#0d652d;font-size:14px;line-height:20px;word-wrap:break-word}.dloBPe{font-weight:bold}.YVIcad{color:#70757a}.JkVVdd{color:#ea4335}.oXZRFd{color:#ea4335}.MQHtg{color:#fbbc04}.pyMRrb{color:#1e8e3e}.EtTZid{color:#1e8e3e}.M3vVJe{color:#1967D2}.qXLe6d{display:block}.NHQNef{font-style:italic}.Cb8Z7c{white-space:pre}a.ZWRArf{text-decoration:none}a .CVA68e:hover{text-decoration:underline}
</style>
<div class="n692Zd">
<div class="BnJWBc">
<a class="lXLRf" href="/?safe=off&amp;gbv=1&amp;output=images&amp;ie=UTF-8&amp;tbm=isch&amp;sa=X&amp;ved=0ahUKEwjhh7TZyd_vAhWShf0HHeYzCmsQPAgC">
<img alt="Google" class="kgJEQe" src="/images/branding/searchlogo/1x/googlelogo_desk_heirloom_color_150x55dp.gif"/>
</a>
</div>
<div class="FbhRzb">
<form action="/search">
<input name="safe" type="hidden" value="off"/>
<input name="gbv" type="hidden" value="1"/>
<input name="ie" type="hidden" value="ISO-8859-1"/>
<input name="tbm" type="hidden" value="isch"/>
<input name="oq" type="hidden"/>
<input name="aqs" type="hidden"/>
<table class="cvifge">
<tr>
<td class="O4cRJf">
<!-- search input -->
</td>
</tr>
</table>
</form>
</div>
<div class="M7pB2">
<!-- search options -->
</div>
</div>
<!-- <div class="X6ZCif"> Not present in mobile
</div> -->
<div>
<div>
<div>
<div class="lIMUZd">
<table class="By0U9">
<!-- correction suggested -->
</table>
</div>
<div class="lIMUZd">
<table class="By0U9">
<!-- correction suggested -->
</table>
</div>
</div>
</div>
<table class="GpQGbf">
{% for i in range((length // 4) + 1) %}
<tr>
{% for j in range([length - (i*4), 4]|min) %}
<td align="center" class="e3goi">
<td align="center" class="e3goi">
<div class="svla5d">
<div>
<div class="lIMUZd">
<div>
<table class="TxbwNb">
<tr>
<td>
<a href="{{ results[(i*4)+j].web_page }}">
<div class="RAyV4b">
<img
alt=""
class="t0fcAb"
src="{{ results[(i*4)+j].img_tbn }}"
/>
</div>
</a>
</td>
</tr>
<tr>
<td>
<a href="{{ results[(i*4)+j].web_page }}">
<div class="Tor4Ec">
<span class="qXLe6d x3G5ab">
<span class="fYyStc">
{{ results[(i*4)+j].domain }}
</span>
</span>
</div>
</a>
<a href="{{ results[(i*4)+j].img_url }}">
<div class="Tor4Ec">
<span class="qXLe6d F9iS2e">
<span class="fYyStc"> {{ view_label }} </span>
</span>
</div>
</a>
</td>
</tr>
</table>
</div>
<div class="lIMUZd">
<div>
<table class="TxbwNb">
<tr>
<td>
<a href="{{ results[(i*4)+j].webpage }}">
<div class="RAyV4b">
<img alt="" class="t0fcAb" src="{{ results[(i*4)+j].img_tbn }}"/>
</div>
</a>
</td>
</tr>
<tr>
<td>
<a href="{{ results[(i*4)+j].webpage }}">
<div class="Tor4Ec">
<span class="qXLe6d x3G5ab">
<span class="fYyStc">
{{ results[(i*4)+j].domain }}
</span>
</span>
</div>
</a>
<a href="{{ results[(i*4)+j].img_url }}">
<div class="Tor4Ec">
<span class="qXLe6d F9iS2e">
<span class="fYyStc">
{{ view_label }}
</span>
</span>
</div>
</a>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</td>
{% endfor %}
</tr>
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
</div>
<table class="uZgmoc">
<!-- next page object -->
</table>
<br />
</div>
<br/>
<div class="TuS8Ad">
<!-- information about user connection -->
<div>
</div>
</body>
</html>

View File

@ -1,323 +1,181 @@
<html style="background: #000;">
<head>
<link rel="apple-touch-icon" sizes="57x57" href="static/img/favicon/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="static/img/favicon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="static/img/favicon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="static/img/favicon/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="static/img/favicon/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="static/img/favicon/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="static/img/favicon/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="static/img/favicon/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="static/img/favicon/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="static/img/favicon/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="static/img/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="static/img/favicon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="static/img/favicon/favicon-16x16.png">
<link rel="manifest" href="static/img/favicon/manifest.json">
<meta name="referrer" content="no-referrer">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png">
{% if bundle_static() %}
<script src="/{{ cb_url('bundle.js') }}" defer></script>
{% else %}
{% if autocomplete_enabled == '1' %}
<script src="{{ cb_url('autocomplete.js') }}"></script>
{% endif %}
<script type="text/javascript" src="{{ cb_url('controller.js') }}"></script>
{% endif %}
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if bundle_static() %}
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
{% else %}
<link rel="stylesheet" href="{{ cb_url('logo.css') }}">
{% endif %}
{% if config.theme %}
{% if config.theme == 'system' %}
<style>
@import "{{ cb_url('light-theme.css') }}" screen;
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
</style>
{% else %}
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
{% endif %}
{% endif %}
{% if not bundle_static() %}
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
{% endif %}
<noscript>
<style>
#main {
display: inherit !important;
}
.content {
max-height: 400px;
padding: 18px;
border-radius: 10px;
overflow-y: scroll;
}
.collapsible {
display: none;
}
</style>
</noscript>
<style>{{ config.style }}</style>
<title>Whoogle Search</title>
</head>
<body id="main">
<div class="search-container">
<div class="logo-container">
{{ logo|safe }}
</div>
<form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}">
<div class="search-fields">
<div class="autocomplete">
{% if config.preferences %}
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
{% endif %}
<input
type="text"
name="q"
id="search-bar"
class="home-search"
autofocus="autofocus"
autocapitalize="none"
spellcheck="false"
autocorrect="off"
autocomplete="off"
dir="auto">
</div>
<input type="submit" id="search-submit" value="{{ translation['search'] }}">
</div>
</form>
{% if not config_disabled %}
<br/>
<button id="config-collapsible" class="collapsible">{{ translation['config'] }}</button>
<div class="content">
<div class="config-fields">
<form id="config-form" action="config" method="post">
<div class="config-options">
<div class="config-div config-div-country">
<label for="config-country">{{ translation['config-country'] }}: </label>
<select name="country" id="config-country">
{% for country in countries %}
<option value="{{ country.value }}"
{% if (
config.country != '' and config.country in country.value
) or (
config.country == '' and country.value == '')
%}
selected
{% endif %}>
{{ country.name }}
</option>
{% endfor %}
</select>
</div>
<div class="config-div">
<label for="config-time-period">{{ translation['config-time-period'] }}</label>
<select name="tbs" id="config-time-period">
{% for time_period in time_periods %}
<option value="{{ time_period.value }}"
{% if (
config.tbs != '' and config.tbs in time_period.value
) or (
config.tbs == '' and time_period.value == '')
%}
selected
{% endif %}>
{{ translation[time_period.value] }}
</option>
{% endfor %}
</select>
</div>
<div class="config-div config-div-lang">
<label for="config-lang-interface">{{ translation['config-lang'] }}: </label>
<select name="lang_interface" id="config-lang-interface">
{% for lang in languages %}
<option value="{{ lang.value }}"
{% if lang.value in config.lang_interface %}
selected
{% endif %}>
{{ lang.name }}
</option>
{% endfor %}
</select>
</div>
<div class="config-div config-div-search-lang">
<label for="config-lang-search">{{ translation['config-lang-search'] }}: </label>
<select name="lang_search" id="config-lang-search">
{% for lang in languages %}
<option value="{{ lang.value }}"
{% if lang.value in config.lang_search %}
selected
{% endif %}>
{{ lang.name }}
</option>
{% endfor %}
</select>
</div>
<div class="config-div config-div-near">
<label for="config-near">{{ translation['config-near'] }}: </label>
<input type="text" name="near" id="config-near"
placeholder="{{ translation['config-near-help'] }}" value="{{ config.near }}">
</div>
<div class="config-div config-div-block">
<label for="config-block">{{ translation['config-block'] }}: </label>
<input type="text" name="block" id="config-block"
placeholder="{{ translation['config-block-help'] }}" value="{{ config.block }}">
</div>
<div class="config-div config-div-block">
<label for="config-block-title">{{ translation['config-block-title'] }}: </label>
<input type="text" name="block_title" id="config-block"
placeholder="{{ translation['config-block-title-help'] }}"
value="{{ config.block_title }}">
</div>
<div class="config-div config-div-block">
<label for="config-block-url">{{ translation['config-block-url'] }}: </label>
<input type="text" name="block_url" id="config-block"
placeholder="{{ translation['config-block-url-help'] }}" value="{{ config.block_url }}">
</div>
<div class="config-div config-div-anon-view">
<label for="config-anon-view">{{ translation['config-anon-view'] }}: </label>
<input type="checkbox" name="anon_view" id="config-anon-view" {{ 'checked' if config.anon_view else '' }}>
</div>
<div class="config-div config-div-nojs">
<label for="config-nojs">{{ translation['config-nojs'] }}: </label>
<input type="checkbox" name="nojs" id="config-nojs" {{ 'checked' if config.nojs else '' }}>
</div>
<div class="config-div config-div-theme">
<label for="config-theme">{{ translation['config-theme'] }}: </label>
<select name="theme" id="config-theme">
{% for theme in themes %}
<option value="{{ theme }}"
{% if theme in config.theme %}
selected
{% endif %}>
{{ translation[theme].capitalize() }}
</option>
{% endfor %}
</select>
</div>
<!-- DEPRECATED -->
<div class="config-div config-div-safe">
<label for="config-safe">{{ translation['config-safe'] }}: </label>
<input type="checkbox" name="safe" id="config-safe" {{ 'checked' if config.safe else '' }}>
</div>
<div class="config-div config-div-alts">
<label class="tooltip" for="config-alts">{{ translation['config-alts'] }}: </label>
<input type="checkbox" name="alts" id="config-alts" {{ 'checked' if config.alts else '' }}>
<div><span class="info-text"> — {{ translation['config-alts-help'] }}</span></div>
</div>
<div class="config-div config-div-new-tab">
<label for="config-new-tab">{{ translation['config-new-tab'] }}: </label>
<input type="checkbox" name="new_tab"
id="config-new-tab" {{ 'checked' if config.new_tab else '' }}>
</div>
<div class="config-div config-div-view-image">
<label for="config-view-image">{{ translation['config-images'] }}: </label>
<input type="checkbox" name="view_image"
id="config-view-image" {{ 'checked' if config.view_image else '' }}>
<div><span class="info-text"> — {{ translation['config-images-help'] }}</span></div>
</div>
<div class="config-div config-div-tor">
<label for="config-tor">{{ translation['config-tor'] }}: {{ '' if tor_available else 'Unavailable' }}</label>
<input type="checkbox" name="tor"
id="config-tor" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>
</div>
<div class="config-div config-div-get-only">
<label for="config-get-only">{{ translation['config-get-only'] }}: </label>
<input type="checkbox" name="get_only"
id="config-get-only" {{ 'checked' if config.get_only else '' }}>
</div>
<div class="config-div config-div-user-agent">
<label for="config-user-agent">User Agent: </label>
<select name="user_agent" id="config-user-agent">
<option value="env_conf" {% if config.user_agent == 'env_conf' %}selected{% endif %}>Use ENV Conf</option>
<option value="default" {% if config.user_agent == 'default' %}selected{% endif %}>Default</option>
<option value="custom" {% if config.user_agent == 'custom' %}selected{% endif %}>Custom</option>
</select>
</div>
<div class="config-div config-div-custom-user-agent" {% if config.user_agent != 'custom' %}style="display: none;"{% endif %}>
<label for="config-custom-user-agent">Custom User Agent: </label>
<input type="text" name="custom_user_agent" id="config-custom-user-agent"
value="{{ config.custom_user_agent }}"
placeholder="Enter custom user agent string">
<div><span class="info-text"><a href="https://github.com/benbusby/whoogle-search/wiki/User-Agents">User Agent Wiki</a></span></div>
</div>
<div class="config-div config-div-accept-language">
<label for="config-accept-language">Set Accept-Language: </label>
<input type="checkbox" name="accept_language"
id="config-accept-language" {{ 'checked' if config.accept_language else '' }}>
</div>
<div class="config-div config-div-show-user-agent">
<label for="config-show-user-agent">Show User Agent in Footer: </label>
<input type="checkbox" name="show_user_agent"
id="config-show-user-agent" {{ 'checked' if config.show_user_agent else '' }}>
</div>
<!-- Google Custom Search Engine (BYOK) Settings -->
<div class="config-div config-div-cse-header" style="margin-top: 20px; border-top: 1px solid var(--result-bg); padding-top: 15px;">
<strong>Google Custom Search (BYOK)</strong>
<div><span class="info-text"><a href="https://github.com/benbusby/whoogle-search#google-custom-search-byok">Setup Guide</a></span></div>
</div>
<div class="config-div config-div-use-cse">
<label for="config-use-cse">Use Custom Search API: </label>
<input type="checkbox" name="use_cse" id="config-use-cse" {{ 'checked' if config.use_cse else '' }}>
<div><span class="info-text"> — Enable to use your own Google API key (100 free queries/day)</span></div>
</div>
<div class="config-div config-div-cse-api-key">
<label for="config-cse-api-key">CSE API Key: </label>
<input type="password" name="cse_api_key" id="config-cse-api-key"
value="{{ config.cse_api_key }}"
placeholder="AIza..."
autocomplete="off">
</div>
<div class="config-div config-div-cse-id">
<label for="config-cse-id">CSE ID: </label>
<input type="text" name="cse_id" id="config-cse-id"
value="{{ config.cse_id }}"
placeholder="abc123..."
autocomplete="off">
</div>
<div class="config-div config-div-root-url">
<label for="config-url">{{ translation['config-url'] }}: </label>
<input type="text" name="url" id="config-url" value="{{ config.url }}">
</div>
<div class="config-div config-div-custom-css">
<a id="css-link"
href="https://github.com/benbusby/whoogle-search/wiki/User-Contributed-CSS-Themes">
{{ translation['config-css'] }}:
</a>
<textarea
name="style_modified"
id="config-style"
autocapitalize="off"
autocomplete="off"
spellcheck="false"
autocorrect="off"
value="">{{ config.style_modified.replace('\t', '') }}</textarea>
</div>
<div class="config-div config-div-pref-url">
<label for="config-pref-encryption">{{ translation['config-pref-encryption'] }}: </label>
<input type="checkbox" name="preferences_encrypted"
id="config-pref-encryption" {{ 'checked' if config.preferences_encrypted and config.preferences_key else '' }}>
<div><span class="info-text"> — {{ translation['config-pref-help'] }}</span></div>
<label for="config-pref-url">{{ translation['config-pref-url'] }}: </label>
<input type="text" name="pref-url" id="config-pref-url" value="{{ config.url }}?preferences={{ config.preferences }}">
</div>
</div>
<div class="config-div config-buttons">
<input type="submit" id="config-load" value="{{ translation['load'] }}">&nbsp;
<input type="submit" id="config-submit" value="{{ translation['apply'] }}">&nbsp;
<input type="submit" id="config-save" value="{{ translation['save-as'] }}">
</div>
</form>
</div>
</div>
{% endif %}
</div>
{% include 'footer.html' %}
</body>
<html>
<head>
<link rel="apple-touch-icon" sizes="57x57" href="static/img/favicon/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="static/img/favicon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="static/img/favicon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="static/img/favicon/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="static/img/favicon/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="static/img/favicon/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="static/img/favicon/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="static/img/favicon/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="static/img/favicon/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="static/img/favicon/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="static/img/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="static/img/favicon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="static/img/favicon/favicon-16x16.png">
<link rel="manifest" href="static/img/favicon/manifest.json">
<meta name="referrer" content="no-referrer">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png">
<script type="text/javascript" src="static/js/autocomplete.js"></script>
<script type="text/javascript" src="static/js/controller.js"></script>
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="static/css/variables.css">
<link rel="stylesheet" href="static/css/main.css">
<link rel="stylesheet" href="static/css/{{ 'dark' if config.dark else 'light' }}-theme.css"/>
<noscript>
<style>
#main { display: inherit !important; }
.content { max-height: 720px; padding: 18px; border-radius: 10px; }
.collapsible { display: none; }
</style>
</noscript>
<style>{{ config.style }}</style>
<title>Whoogle Search</title>
</head>
<body id="main" style="display: none; background-color: {{ '#000' if config.dark else '#fff' }}">
<div class="search-container">
<div class="logo-container">
{{ logo|safe }}
</div>
<form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}">
<div class="search-fields">
<div class="autocomplete">
<input
type="text"
name="q"
id="search-bar"
class="home-search"
autofocus="autofocus"
autocapitalize="none"
spellcheck="false"
autocorrect="off"
autocomplete="off">
</div>
<input type="submit" id="search-submit" value="{{ translation['search'] }}">
</div>
</form>
{% if not config_disabled %}
<br/>
<button id="config-collapsible" class="collapsible">{{ translation['config'] }}</button>
<div class="content">
<div class="config-fields">
<form id="config-form" action="config" method="post">
<div class="config-div config-div-ctry">
<label for="config-ctry">{{ translation['config-country'] }}: </label>
<select name="ctry" id="config-ctry">
{% for ctry in countries %}
<option value="{{ ctry.value }}"
{% if ctry.value in config.ctry %}
selected
{% endif %}>
{{ ctry.name }}
</option>
{% endfor %}
</select>
<div><span class="info-text"> — {{ translation['config-country-help'] }}</span></div>
</div>
<div class="config-div config-div-lang">
<label for="config-lang-interface">{{ translation['config-lang'] }}: </label>
<select name="lang_interface" id="config-lang-interface">
{% for lang in languages %}
<option value="{{ lang.value }}"
{% if lang.value in config.lang_interface %}
selected
{% endif %}>
{{ lang.name }}
</option>
{% endfor %}
</select>
</div>
<div class="config-div config-div-search-lang">
<label for="config-lang-search">{{ translation['config-lang-search'] }}: </label>
<select name="lang_search" id="config-lang-search">
{% for lang in languages %}
<option value="{{ lang.value }}"
{% if lang.value in config.lang_search %}
selected
{% endif %}>
{{ lang.name }}
</option>
{% endfor %}
</select>
</div>
<div class="config-div config-div-near">
<label for="config-near">{{ translation['config-near'] }}: </label>
<input type="text" name="near" id="config-near" placeholder="City Name" value="{{ config.near }}">
</div>
<div class="config-div config-div-block">
<label for="config-block">{{ translation['config-block'] }}: </label>
<input type="text" name="block" id="config-block" placeholder="Comma-separated site list" value="{{ config.block }}">
</div>
<div class="config-div config-div-nojs">
<label for="config-nojs">{{ translation['config-nojs'] }}: </label>
<input type="checkbox" name="nojs" id="config-nojs" {{ 'checked' if config.nojs else '' }}>
</div>
<div class="config-div config-div-dark">
<label for="config-dark">{{ translation['config-dark'] }}: </label>
<input type="checkbox" name="dark" id="config-dark" {{ 'checked' if config.dark else '' }}>
</div>
<div class="config-div config-div-safe">
<label for="config-safe">{{ translation['config-safe'] }}: </label>
<input type="checkbox" name="safe" id="config-safe" {{ 'checked' if config.safe else '' }}>
</div>
<div class="config-div config-div-alts">
<label class="tooltip" for="config-alts">{{ translation['config-alts'] }}: </label>
<input type="checkbox" name="alts" id="config-alts" {{ 'checked' if config.alts else '' }}>
<div><span class="info-text"> — {{ translation['config-alts-help'] }}</span></div>
</div>
<div class="config-div config-div-new-tab">
<label for="config-new-tab">{{ translation['config-new-tab'] }}: </label>
<input type="checkbox" name="new_tab" id="config-new-tab" {{ 'checked' if config.new_tab else '' }}>
</div>
<div class="config-div config-div-view-image">
<label for="config-view-image">{{ translation['config-images'] }}: </label>
<input type="checkbox" name="view_image" id="config-view-image" {{ 'checked' if config.view_image else '' }}>
<div><span class="info-text"> — {{ translation['config-images-help'] }}</span></div>
</div>
<div class="config-div config-div-tor">
<label for="config-tor">{{ translation['config-tor'] }}: {{ '' if tor_available else 'Unavailable' }}</label>
<input type="checkbox" name="tor" id="config-tor" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>
</div>
<div class="config-div config-div-get-only">
<label for="config-get-only">{{ translation['config-get-only'] }}: </label>
<input type="checkbox" name="get_only" id="config-get-only" {{ 'checked' if config.get_only else '' }}>
</div>
<div class="config-div config-div-root-url">
<label for="config-url">{{ translation['config-url'] }}: </label>
<input type="text" name="url" id="config-url" value="{{ config.url }}">
</div>
<div class="config-div config-div-custom-css">
<label for="config-style">{{ translation['config-css'] }}:</label>
<textarea
name="style"
id="config-style"
autocapitalize="off"
autocomplete="off"
spellcheck="false"
autocorrect="off"
value="">
{{ config.style }}
</textarea>
</div>
<div class="config-div">
<input type="submit" id="config-load" value="{{ translation['load'] }}">&nbsp;
<input type="submit" id="config-submit" value="{{ translation['apply'] }}">&nbsp;
<input type="submit" id="config-save" value="{{ translation['save-as'] }}">
</div>
</form>
</div>
</div>
{% endif %}
</div>
<footer>
<p style="color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};">
Whoogle Search v{{ version_number }} ||
<a id="gh-link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
</p>
</footer>
</body>
</html>

View File

@ -1,4 +1,10 @@
<link rel="stylesheet" href="static/css/logo.css">
<svg id="Layer_1" class="whoogle-svg" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1028 254">
<style>
path {
fill: {{ 'var(--whoogle-dark-logo)' if dark else 'var(--whoogle-logo)' }};
}
</style>
<defs>
<style>
</style>
@ -16,3 +22,4 @@
<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>
<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>
</svg>
</a>

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
<form id="search-form" action="search" method="post">
<form id="search-form" action="{{ url }}/search" method="post">
<input
type="text"
name="q"
@ -8,7 +8,6 @@
spellcheck="false"
autocorrect="off"
placeholder="Whoogle Search"
autocomplete="off"
dir="auto">
autocomplete="off">
<input type="submit" style="width: 9%" id="search-submit" value="Search">
</form>

View File

@ -1,57 +1,7 @@
import json
import httpx
import urllib.parse as urlparse
import os
import glob
import requests
bangs_dict = {}
DDG_BANGS = 'https://duckduckgo.com/bang.js'
def load_all_bangs(ddg_bangs_file: str, ddg_bangs: dict = {}):
"""Loads all the bang files in alphabetical order
Args:
ddg_bangs_file: The str path to the new DDG bangs json file
ddg_bangs: The dict of ddg bangs. If this is empty, it will load the
bangs from the file
Returns:
None
"""
global bangs_dict
ddg_bangs_file = os.path.normpath(ddg_bangs_file)
if (bangs_dict and not ddg_bangs) or os.path.getsize(ddg_bangs_file) <= 4:
return
bangs = {}
bangs_dir = os.path.dirname(ddg_bangs_file)
bang_files = glob.glob(os.path.join(bangs_dir, '*.json'))
# Normalize the paths
bang_files = [os.path.normpath(f) for f in bang_files]
# Move the ddg bangs file to the beginning
bang_files = sorted([f for f in bang_files if f != ddg_bangs_file])
if ddg_bangs:
bangs |= ddg_bangs
else:
bang_files.insert(0, ddg_bangs_file)
for i, bang_file in enumerate(bang_files):
try:
with open(bang_file, 'r', encoding='utf-8') as f:
bangs |= json.load(f)
except json.decoder.JSONDecodeError:
# Ignore decoding error only for the ddg bangs file, since this can
# occur if file is still being written
if i != 0:
raise
bangs_dict = dict(sorted(bangs.items()))
DDG_BANGS = 'https://duckduckgo.com/bang.v255.js'
def gen_bangs_json(bangs_file: str) -> None:
@ -64,9 +14,12 @@ def gen_bangs_json(bangs_file: str) -> None:
None
"""
# Request full list from DDG
r = httpx.get(DDG_BANGS)
r.raise_for_status()
try:
# Request full list from DDG
r = requests.get(DDG_BANGS)
r.raise_for_status()
except requests.exceptions.HTTPError as err:
raise SystemExit(err)
# Convert to json
data = json.loads(r.text)
@ -81,70 +34,28 @@ def gen_bangs_json(bangs_file: str) -> None:
'suggestion': bang_command + ' (' + row['s'] + ')'
}
with open(bangs_file, 'w', encoding='utf-8') as f:
json.dump(bangs_data, f)
print('* Finished creating ddg bangs json')
load_all_bangs(bangs_file, bangs_data)
json.dump(bangs_data, open(bangs_file, 'w'))
def suggest_bang(query: str) -> list[str]:
"""Suggests bangs for a user's query
Args:
query: The search query
Returns:
list[str]: A list of bang suggestions
"""
global bangs_dict
return [bangs_dict[_]['suggestion'] for _ in bangs_dict if _.startswith(query)]
def resolve_bang(query: str) -> str:
def resolve_bang(query: str, bangs_dict: dict) -> str:
"""Transform's a user's query to a bang search, if an operator is found
Args:
query: The search query
bangs_dict: The dict of available bang operators, with corresponding
format string search URLs
(i.e. "!w": "https://en.wikipedia.org...?search={}")
Returns:
str: A formatted redirect for a bang search, or an empty str if there
wasn't a match or didn't contain a bang operator
"""
global bangs_dict
split_query = query.split(' ')
for operator in bangs_dict.keys():
if operator not in split_query:
continue
#if ! not in query simply return (speed up processing)
if '!' not in query:
return ''
split_query = query.strip().split(' ')
# look for operator in query if one is found, list operator should be of
# length 1, operator should not be case-sensitive here to remove it later
operator = [
word
for word in split_query
if word.lower() in bangs_dict
]
if len(operator) == 1:
# get operator
operator = operator[0]
# removes operator from query
split_query.remove(operator)
# rebuild the query string
bang_query = ' '.join(split_query).strip()
# Check if operator is a key in bangs and get bang if exists
bang = bangs_dict.get(operator.lower(), None)
if bang:
bang_url = bang['url']
if bang_query:
return bang_url.replace('{}', bang_query, 1)
else:
parsed_url = urlparse.urlparse(bang_url)
return f'{parsed_url.scheme}://{parsed_url.netloc}'
return bangs_dict[operator]['url'].format(
query.replace(operator, '').strip())
return ''

View File

@ -1,139 +0,0 @@
import base64
import hashlib
import contextlib
import io
import os
import re
import httpx
from urllib.parse import urlparse
from bs4 import BeautifulSoup as bsoup
from cryptography.fernet import Fernet
from flask import Request
ddg_favicon_site = 'http://icons.duckduckgo.com/ip2'
empty_gif = base64.b64decode(
'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')
placeholder_img = base64.b64decode(
'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABF0lEQVRIS8XWPw9EMBQA8Eok' \
'JBKrMFqMBt//GzAYLTZ/VomExPDu6uLiaPteqVynBn0/75W2Vp7nEIYhe6p1XcespmmAd7Is' \
'M+4URcGiKPogvMMvmIS2eN9MOMKbKWgf54SYgI4vKkTuQKJKSJErkKzUSkQHUs0lilAg7GMh' \
'ISoIA/hYMiKCKIA2soeowCWEMkfHtUmrXLcyGYYBfN9HF8djiaglWzNZlgVs21YisoAUaEXG' \
'cQTP86QIFgi7vyLzPIPjOEIEC7ANQv/4aZrAdd0TUtc1i+MYnSsMWjPp+x6CIPgJVlUVS5KE' \
'DKig/+wnVzM4pnzaGeHd+ENlWbI0TbVLJBtw2uMfP63wc9d2kDCWxi5Q27bsBerSJ9afJbeL' \
'AAAAAElFTkSuQmCC'
)
def fetch_favicon(url: str) -> bytes:
"""Fetches a favicon using DuckDuckGo's favicon retriever
Args:
url: The url to fetch the favicon from
Returns:
bytes - the favicon bytes, or a placeholder image if one
was not returned
"""
response = httpx.get(f'{ddg_favicon_site}/{urlparse(url).netloc}.ico')
if response.status_code == 200 and len(response.content) > 0:
tmp_mem = io.BytesIO()
tmp_mem.write(response.content)
tmp_mem.seek(0)
return tmp_mem.read()
return placeholder_img
def gen_file_hash(path: str, static_file: str) -> str:
with open(os.path.join(path, static_file), 'rb') as f:
file_contents = f.read()
file_hash = hashlib.md5(file_contents).hexdigest()[:8]
filename_split = os.path.splitext(static_file)
return f'{filename_split[0]}.{file_hash}{filename_split[-1]}'
def read_config_bool(var: str, default: bool=False) -> bool:
val = os.getenv(var, '1' if default else '0')
# user can specify one of the following values as 'true' inputs (all
# variants with upper case letters will also work):
# ('true', 't', '1', 'yes', 'y')
return val.lower() in ('true', 't', '1', 'yes', 'y')
def get_client_ip(r: Request) -> str:
if r.environ.get('HTTP_X_FORWARDED_FOR') is None:
return r.environ['REMOTE_ADDR']
return r.environ['HTTP_X_FORWARDED_FOR']
def get_request_url(url: str) -> str:
if os.getenv('HTTPS_ONLY', False):
return url.replace('http://', 'https://', 1)
return url
def get_proxy_host_url(r: Request, default: str, root=False) -> str:
scheme = r.headers.get('X-Forwarded-Proto', 'https')
http_host = r.headers.get('X-Forwarded-Host')
full_path = r.full_path if not root else ''
if full_path.startswith('/'):
full_path = f'/{full_path}'
if http_host:
prefix = os.environ.get('WHOOGLE_URL_PREFIX', '')
if prefix:
prefix = f'/{re.sub("[^0-9a-zA-Z]+", "", prefix)}'
return f'{scheme}://{http_host}{prefix}{full_path}'
return default
def check_for_update(version_url: str, current: str) -> int:
# Check for the latest version of Whoogle
has_update = ''
with contextlib.suppress(httpx.RequestError, AttributeError):
update = bsoup(httpx.get(version_url).text, 'html.parser')
latest = update.select_one('[class="Link--primary"]').string[1:]
current = int(''.join(filter(str.isdigit, current)))
latest = int(''.join(filter(str.isdigit, latest)))
has_update = '' if current >= latest else latest
return has_update
def get_abs_url(url, page_url):
# Creates a valid absolute URL using a partial or relative URL
urls = {
"//": f"https:{url}",
"/": f"{urlparse(page_url).netloc}{url}",
"./": f"{page_url}{url[2:]}"
}
for start in urls:
if url.startswith(start):
return urls[start]
return url
def list_to_dict(lst: list) -> dict:
if len(lst) < 2:
return {}
return {lst[i].replace(' ', ''): lst[i+1].replace(' ', '')
for i in range(0, len(lst), 2)}
def encrypt_string(key: bytes, string: str) -> str:
cipher_suite = Fernet(key)
return cipher_suite.encrypt(string.encode()).decode()
def decrypt_string(key: bytes, string: str) -> str:
cipher_suite = Fernet(g.session_key)
return cipher_suite.decrypt(string.encode()).decode()

View File

@ -1,126 +1,34 @@
from app.models.config import Config
from app.models.endpoint import Endpoint
from app.utils.misc import list_to_dict
from bs4 import BeautifulSoup, NavigableString, MarkupResemblesLocatorWarning
import warnings
import copy
from flask import current_app
import html
from bs4 import BeautifulSoup
import os
import urllib.parse as urlparse
from urllib.parse import parse_qs
import re
warnings.filterwarnings('ignore', category=MarkupResemblesLocatorWarning)
SKIP_ARGS = ['ref_src', 'utm']
SKIP_PREFIX = ['//www.', '//mobile.', '//m.']
GOOG_STATIC = 'www.gstatic.com'
G_M_LOGO_URL = 'https://www.gstatic.com/m/images/icons/googleg.gif'
GOOG_IMG = '/images/branding/searchlogo/1x/googlelogo'
LOGO_URL = GOOG_IMG + '_desk'
BLANK_B64 = ('data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkw'
'AIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC')
# Ad keywords
BLACKLIST = [
'ad', 'ads', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告',
'Reklama', 'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan',
'広告', 'Augl.', 'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन',
'Reklam', 'آگهی', 'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés',
'Anúncio', 'Quảng cáo', 'โฆษณา', 'sponsored', 'patrocinado', 'gesponsert',
'Sponzorováno', '스폰서', 'Gesponsord', 'Sponsorisé'
'ad', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告', 'Reklama',
'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan', '広告', 'Augl.',
'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन', 'Reklam', 'آگهی',
'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés', 'Anúncio'
]
SITE_ALTS = {
'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'farside.link/nitter'),
'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'farside.link/invidious'),
'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'farside.link/libreddit'),
**dict.fromkeys([
'medium.com',
'levelup.gitconnected.com'
], os.getenv('WHOOGLE_ALT_MD', 'farside.link/scribe')),
'imgur.com': os.getenv('WHOOGLE_ALT_IMG', 'farside.link/rimgo'),
'wikipedia.org': os.getenv('WHOOGLE_ALT_WIKI', 'farside.link/wikiless'),
'imdb.com': os.getenv('WHOOGLE_ALT_IMDB', 'farside.link/libremdb'),
'quora.com': os.getenv('WHOOGLE_ALT_QUORA', 'farside.link/quetre'),
'stackoverflow.com': os.getenv('WHOOGLE_ALT_SO', 'farside.link/anonymousoverflow')
'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'nitter.net'),
'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'invidious.snopyta.org'),
'instagram.com': os.getenv('WHOOGLE_ALT_IG', 'bibliogram.art/u'),
'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'libredd.it')
}
# Include custom site redirects from WHOOGLE_REDIRECTS
SITE_ALTS.update(list_to_dict(re.split(',|:', os.getenv('WHOOGLE_REDIRECTS', ''))))
def contains_cjko(s: str) -> bool:
"""This function check whether or not a string contains Chinese, Japanese,
or Korean characters. It employs regex and uses the u escape sequence to
match any character in a set of Unicode ranges.
Args:
s (str): string to be checked
Returns:
bool: True if the input s contains the characters and False otherwise
"""
unicode_ranges = ('\u4e00-\u9fff' # Chinese characters
'\u3040-\u309f' # Japanese hiragana
'\u30a0-\u30ff' # Japanese katakana
'\u4e00-\u9faf' # Japanese kanji
'\uac00-\ud7af' # Korean hangul syllables
'\u1100-\u11ff' # Korean hangul jamo
)
return bool(re.search(fr'[{unicode_ranges}]', s))
def bold_search_terms(response: str, query: str) -> BeautifulSoup:
"""Wraps all search terms in bold tags (<b>). If any terms are wrapped
in quotes, only that exact phrase will be made bold.
Args:
response: The initial response body for the query
query: The original search query
Returns:
BeautifulSoup: modified soup object with bold items
"""
response = BeautifulSoup(response, 'html.parser')
def replace_any_case(element: NavigableString, target_word: str) -> None:
# Replace all instances of the word, but maintaining the same case in
# the replacement
if len(element) == len(target_word):
return
# Ensure target word is escaped for regex
target_word = re.escape(target_word)
# Check if the word contains Chinese, Japanese, or Korean characters
if contains_cjko(target_word):
reg_pattern = fr'((?![{{}}<>-]){target_word}(?![{{}}<>-]))'
else:
reg_pattern = fr'\b((?![{{}}<>-]){target_word}(?![{{}}<>-]))\b'
if re.match(r'.*[@_!#$%^&*()<>?/\|}{~:].*', target_word) or (
element.parent and element.parent.name == 'style'):
return
element.replace_with(BeautifulSoup(
re.sub(reg_pattern,
r'<b>\1</b>',
element,
flags=re.I), 'html.parser')
)
# Split all words out of query, grouping the ones wrapped in quotes
for word in re.split(r'\s+(?=[^"]*(?:"[^"]*"[^"]*)*$)', query):
word = re.sub(r'[@_!#$%^&*()<>?/\|}{~:]+', '', word)
target = response.find_all(
string=re.compile(r'' + re.escape(word), re.I))
for nav_str in target:
replace_any_case(nav_str, word)
return response
def has_ad_content(element: str) -> bool:
"""Inspects an HTML element for ad related content
@ -132,12 +40,11 @@ def has_ad_content(element: str) -> bool:
bool: True/False for the element containing an ad
"""
element_str = ''.join(filter(str.isalpha, element))
return (element_str.upper() in (value.upper() for value in BLACKLIST)
return (element.upper() in (value.upper() for value in BLACKLIST)
or '' in element)
def get_first_link(soup) -> str:
def get_first_link(soup: BeautifulSoup) -> str:
"""Retrieves the first result link from the query response
Args:
@ -147,83 +54,35 @@ def get_first_link(soup) -> str:
str: A str link to the first result
"""
first_link = ''
# Find the first valid search result link, excluding details elements
# Replace hrefs with only the intended destination (no "utm" type tags)
for a in soup.find_all('a', href=True):
# Skip links that are inside details elements (collapsible sections)
if a.find_parent('details'):
continue
# Return the first search result URL
if a['href'].startswith('http://') or a['href'].startswith('https://'):
first_link = a['href']
break
return first_link
if 'url?q=' in a['href']:
return filter_link_args(a['href'])
return ''
def get_site_alt(link: str, site_alts: dict = SITE_ALTS) -> str:
def get_site_alt(link: str) -> str:
"""Returns an alternative to a particular site, if one is configured
Args:
link: A string result URL to check against the site_alts map
site_alts: A map of site alternatives to replace with. defaults to SITE_ALTS
link: A string result URL to check against the SITE_ALTS map
Returns:
str: An updated (or ignored) result link
"""
# Need to replace full hostname with alternative to encapsulate
# subdomains as well
parsed_link = urlparse.urlparse(link)
# Extract subdomain separately from the domain+tld. The subdomain
# is used for wikiless translations.
split_host = parsed_link.netloc.split('.')
subdomain = split_host[0] if len(split_host) > 2 else ''
hostname = '.'.join(split_host[-2:])
# The full scheme + hostname is used when comparing against the list of
# available alternative services, due to how Medium links are constructed.
# (i.e. for medium.com: "https://something.medium.com" should match,
# "https://medium.com/..." should match, but "philomedium.com" should not)
hostcomp = f'{parsed_link.scheme}://{hostname}'
for site_key in site_alts.keys():
site_alt = f'{parsed_link.scheme}://{site_key}'
if not hostname or site_alt not in hostcomp or not site_alts[site_key]:
for site_key in SITE_ALTS.keys():
if site_key not in link:
continue
# Wikipedia -> Wikiless replacements require the subdomain (if it's
# a 2-char language code) to be passed as a URL param to Wikiless
# in order to preserve the language setting.
params = ''
if 'wikipedia' in hostname and len(subdomain) == 2:
hostname = f'{subdomain}.{hostname}'
params = f'?lang={subdomain}'
elif 'medium' in hostname and len(subdomain) > 0:
hostname = f'{subdomain}.{hostname}'
parsed_alt = urlparse.urlparse(site_alts[site_key])
link = link.replace(hostname, site_alts[site_key]) + params
# If a scheme is specified in the alternative, this results in a
# replaced link that looks like "https://http://altservice.tld".
# In this case, we can remove the original scheme from the result
# and use the one specified for the alt.
if parsed_alt.scheme:
link = '//'.join(link.split('//')[1:])
for prefix in SKIP_PREFIX:
if parsed_alt.scheme:
# If a scheme is specified, remove everything before the
# first occurence of it
link = f'{parsed_alt.scheme}{link.split(parsed_alt.scheme, 1)[-1]}'
else:
# Otherwise, replace the first occurrence of the prefix
link = link.replace(prefix, '//', 1)
link = link.replace(site_key, SITE_ALTS[site_key])
break
for prefix in SKIP_PREFIX:
link = link.replace(prefix, '//')
return link
@ -271,192 +130,8 @@ def append_nojs(result: BeautifulSoup) -> None:
"""
nojs_link = BeautifulSoup(features='html.parser').new_tag('a')
nojs_link['href'] = f'{Endpoint.window}?nojs=1&location=' + result['href']
nojs_link.string = ' NoJS Link'
nojs_link['href'] = '/window?location=' + result['href']
nojs_link['style'] = 'display:block;width:100%;'
nojs_link.string = 'NoJS Link: ' + nojs_link['href']
result.append(BeautifulSoup('<br><hr><br>', 'html.parser'))
result.append(nojs_link)
def append_anon_view(result: BeautifulSoup, config: Config) -> None:
"""Appends an 'anonymous view' for a search result, where all site
contents are viewed through Whoogle as a proxy.
Args:
result: The search result to append an anon view link to
nojs: Remove Javascript from Anonymous View
Returns:
None
"""
av_link = BeautifulSoup(features='html.parser').new_tag('a')
nojs = 'nojs=1' if config.nojs else 'nojs=0'
location = f'location={result["href"]}'
av_link['href'] = f'{Endpoint.window}?{nojs}&{location}'
translation = current_app.config['TRANSLATIONS'][
config.get_localization_lang()
]
av_link.string = f'{translation["anon-view"]}'
av_link['class'] = 'anon-view'
result.append(av_link)
def check_currency(response: str) -> dict:
"""Check whether the results have currency conversion
Args:
response: Search query Result
Returns:
dict: Consists of currency names and values
"""
soup = BeautifulSoup(response, 'html.parser')
currency_link = soup.find('a', {'href': 'https://g.co/gfd'})
if currency_link:
while 'class' not in currency_link.attrs or \
'ZINbbc' not in currency_link.attrs['class']:
if currency_link.parent:
currency_link = currency_link.parent
else:
return {}
currency_link = currency_link.find_all(class_='BNeawe')
currency1 = currency_link[0].text
currency2 = currency_link[1].text
currency1 = currency1.rstrip('=').split(' ', 1)
currency2 = currency2.split(' ', 1)
# Handle differences in currency formatting
# i.e. "5.000" vs "5,000"
if currency2[0][-3] == ',':
currency1[0] = currency1[0].replace('.', '')
currency1[0] = currency1[0].replace(',', '.')
currency2[0] = currency2[0].replace('.', '')
currency2[0] = currency2[0].replace(',', '.')
else:
currency1[0] = currency1[0].replace(',', '')
currency2[0] = currency2[0].replace(',', '')
currency1_value = float(re.sub(r'[^\d\.]', '', currency1[0]))
currency1_label = currency1[1]
currency2_value = float(re.sub(r'[^\d\.]', '', currency2[0]))
currency2_label = currency2[1]
return {'currencyValue1': currency1_value,
'currencyLabel1': currency1_label,
'currencyValue2': currency2_value,
'currencyLabel2': currency2_label
}
return {}
def add_currency_card(soup: BeautifulSoup,
conversion_details: dict) -> BeautifulSoup:
"""Adds the currency conversion boxes
to response of the search query
Args:
soup: Parsed search result
conversion_details: Dictionary of currency
related information
Returns:
BeautifulSoup
"""
# Element before which the code will be changed
# (This is the 'disclaimer' link)
element1 = soup.find('a', {'href': 'https://g.co/gfd'})
while 'class' not in element1.attrs or \
'nXE3Ob' not in element1.attrs['class']:
element1 = element1.parent
# Creating the conversion factor
conversion_factor = (conversion_details['currencyValue1'] /
conversion_details['currencyValue2'])
# Creating a new div for the input boxes
conversion_box = soup.new_tag('div')
conversion_box['class'] = 'conversion_box'
# Currency to be converted from
input_box1 = soup.new_tag('input')
input_box1['id'] = 'cb1'
input_box1['type'] = 'number'
input_box1['class'] = 'cb'
input_box1['value'] = conversion_details['currencyValue1']
input_box1['oninput'] = f'convert(1, 2, {1 / conversion_factor})'
label_box1 = soup.new_tag('label')
label_box1['for'] = 'cb1'
label_box1['class'] = 'cb_label'
label_box1.append(conversion_details['currencyLabel1'])
br = soup.new_tag('br')
# Currency to be converted to
input_box2 = soup.new_tag('input')
input_box2['id'] = 'cb2'
input_box2['type'] = 'number'
input_box2['class'] = 'cb'
input_box2['value'] = conversion_details['currencyValue2']
input_box2['oninput'] = f'convert(2, 1, {conversion_factor})'
label_box2 = soup.new_tag('label')
label_box2['for'] = 'cb2'
label_box2['class'] = 'cb_label'
label_box2.append(conversion_details['currencyLabel2'])
conversion_box.append(input_box1)
conversion_box.append(label_box1)
conversion_box.append(br)
conversion_box.append(input_box2)
conversion_box.append(label_box2)
element1.insert_before(conversion_box)
return soup
def get_tabs_content(tabs: dict,
full_query: str,
search_type: str,
preferences: str,
translation: dict) -> dict:
"""Takes the default tabs content and updates it according to the query.
Args:
tabs: The default content for the tabs
full_query: The original search query
search_type: The current search_type
translation: The translation to get the names of the tabs
Returns:
dict: contains the name, the href and if the tab is selected or not
"""
map_query = full_query
if '-site:' in full_query:
block_idx = full_query.index('-site:')
map_query = map_query[:block_idx]
tabs = copy.deepcopy(tabs)
for tab_id, tab_content in tabs.items():
# update name to desired language
if tab_id in translation:
tab_content['name'] = translation[tab_id]
# update href with query
query = full_query.replace(f'&tbm={search_type}', '')
if tab_content['tbm'] is not None:
query = f"{query}&tbm={tab_content['tbm']}"
if preferences:
query = f"{query}&preferences={preferences}"
tab_content['href'] = tab_content['href'].format(
query=query,
map_query=map_query)
# update if selected tab (default all tab is selected)
if tab_content['tbm'] == search_type:
tabs['all']['selected'] = False
tab_content['selected'] = True
return tabs

View File

@ -1,15 +1,13 @@
import os
import re
from typing import Any
from app.filter import Filter
from app.request import gen_query
from app.utils.misc import get_proxy_host_url
from app.utils.results import get_first_link
from app.services.cse_client import CSEClient, cse_results_to_html
from bs4 import BeautifulSoup as bsoup
from cryptography.fernet import Fernet, InvalidToken
from flask import g
from app.filter import Filter, get_first_link
from app.request import gen_query
TOR_BANNER = '<hr><h1 style="text-align: center">You are using Tor</h1><hr>'
CAPTCHA = 'div class="g-recaptcha"'
@ -54,20 +52,17 @@ class Search:
Attributes:
request: the incoming flask request
config: the current user config settings
session_key: the flask user fernet key
session: the flask user session
"""
def __init__(self, request, config, session_key, cookies_disabled=False, user_request=None):
def __init__(self, request, config, session, cookies_disabled=False):
method = request.method
self.request = request
self.request_params = request.args if method == 'GET' else request.form
self.user_agent = request.headers.get('User-Agent')
self.feeling_lucky = False
self.config = config
self.session_key = session_key
self.session = session
self.query = ''
self.widget = ''
self.cookies_disabled = cookies_disabled
self.user_request = user_request
self.search_type = self.request_params.get(
'tbm') if 'tbm' in self.request_params else ''
@ -100,26 +95,13 @@ class Search:
else:
# Attempt to decrypt if this is an internal link
try:
q = Fernet(self.session_key).decrypt(q.encode()).decode()
q = Fernet(self.session['key']).decrypt(q.encode()).decode()
except InvalidToken:
pass
# Strip '!' for "feeling lucky" queries
if match := re.search(r"(^|\s)!($|\s)", q):
self.feeling_lucky = True
start, end = match.span()
self.query = " ".join([seg for seg in [q[:start], q[end:]] if seg])
else:
self.feeling_lucky = False
self.query = q
# Check for possible widgets
self.widget = "ip" if re.search("([^a-z0-9]|^)my *[^a-z0-9] *(ip|internet protocol)" +
"($|( *[^a-z0-9] *(((addres|address|adres|" +
"adress)|a)? *$)))", self.query.lower()) else self.widget
self.widget = 'calculator' if re.search(
r"\bcalculator\b|\bcalc\b|\bcalclator\b|\bmath\b",
self.query.lower()) else self.widget
# Strip leading '! ' for "feeling lucky" queries
self.feeling_lucky = q.startswith('! ')
self.query = q[2:] if self.feeling_lucky else q
return self.query
def generate_response(self) -> str:
@ -131,153 +113,51 @@ class Search:
"""
mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent
# reconstruct url if X-Forwarded-Host header present
root_url = get_proxy_host_url(
self.request,
self.request.url_root,
root=True)
content_filter = Filter(self.session_key,
root_url=root_url,
content_filter = Filter(self.session['key'],
mobile=mobile,
config=self.config,
query=self.query,
page_url=self.request.url)
# Check if CSE (Custom Search Engine) should be used
use_cse = (
self.config.use_cse and
self.config.cse_api_key and
self.config.cse_id
)
if use_cse:
# Use Google Custom Search API
return self._generate_cse_response(content_filter, root_url, mobile)
# Default: Use traditional scraping method
return self._generate_scrape_response(content_filter, root_url, mobile)
def _generate_cse_response(self, content_filter: Filter, root_url: str, mobile: bool) -> str:
"""Generate response using Google Custom Search API
Args:
content_filter: Filter instance for processing results
root_url: Root URL of the instance
mobile: Whether this is a mobile request
Returns:
str: HTML response string
"""
# Get pagination start index from request params
start = int(self.request_params.get('start', 1))
# Determine safe search setting
safe = 'high' if self.config.safe else 'off'
# Determine search type (web or image)
# tbm=isch or udm=2 indicates image search
search_type = ''
if self.search_type == 'isch' or self.request_params.get('udm') == '2':
search_type = 'image'
# Create CSE client and perform search
with CSEClient(
api_key=self.config.cse_api_key,
cse_id=self.config.cse_id
) as client:
response = client.search(
query=self.query,
start=start,
safe=safe,
language=self.config.lang_search,
country=self.config.country,
search_type=search_type
)
# Convert CSE response to HTML
html_content = cse_results_to_html(response, self.query)
# Store full query for tabs
self.full_query = self.query
# Parse and filter the HTML
html_soup = bsoup(html_content, 'html.parser')
# Handle feeling lucky
if self.feeling_lucky:
if response.has_results and response.results:
return response.results[0].link
self.feeling_lucky = False
# Apply content filter (encrypts links, applies CSS, etc.)
formatted_results = content_filter.clean(html_soup)
return str(formatted_results)
def _generate_scrape_response(self, content_filter: Filter, root_url: str, mobile: bool) -> str:
"""Generate response using traditional HTML scraping
Args:
content_filter: Filter instance for processing results
root_url: Root URL of the instance
mobile: Whether this is a mobile request
Returns:
str: HTML response string
"""
config=self.config)
full_query = gen_query(self.query,
self.request_params,
self.config)
self.full_query = full_query
self.config,
content_filter.near)
# force mobile search when view image is true and
# the request is not already made by a mobile
is_image_query = ('tbm=isch' in full_query) or ('udm=2' in full_query)
# Always parse image results when hitting the images endpoint (udm=2)
# to avoid Google returning only text/AI blocks.
view_image = is_image_query
view_image = ('tbm=isch' in full_query
and self.config.view_image
and not g.user_request.mobile)
client = self.user_request or g.user_request
get_body = client.send(query=full_query,
force_mobile=self.config.view_image,
user_agent=self.user_agent)
get_body = g.user_request.send(query=full_query,
force_mobile=view_image)
# Produce cleanable html soup from response
get_body_safed = get_body.text.replace("&lt;","andlt;").replace("&gt;","andgt;")
html_soup = bsoup(get_body_safed, 'html.parser')
# Ensure we extract only the content within <html> if it exists
# This prevents doctype declarations from appearing in the output
if html_soup.html:
html_soup = html_soup.html
html_soup = bsoup(content_filter.reskin(get_body.text), 'html.parser')
# Replace current soup if view_image is active
if view_image:
html_soup = content_filter.view_image(html_soup)
# Indicate whether or not a Tor connection is active
if (self.user_request or g.user_request).tor_valid:
html_soup.insert(0, bsoup(TOR_BANNER, 'html.parser'))
tor_banner = bsoup('', 'html.parser')
if g.user_request.tor_valid:
tor_banner = bsoup(TOR_BANNER, 'html.parser')
html_soup.insert(0, tor_banner)
formatted_results = content_filter.clean(html_soup)
if self.feeling_lucky:
if lucky_link := get_first_link(formatted_results):
return lucky_link
return get_first_link(html_soup)
else:
formatted_results = content_filter.clean(html_soup)
# Fall through to regular search if unable to find link
self.feeling_lucky = False
# Append user config to all search links, if available
param_str = ''.join('&{}={}'.format(k, v)
for k, v in
self.request_params.to_dict(flat=True).items()
if self.config.is_safe_key(k))
for link in formatted_results.find_all('a', href=True):
if 'search?' not in link['href'] or link['href'].index(
'search?') > 1:
continue
link['href'] += param_str
# Append user config to all search links, if available
param_str = ''.join('&{}={}'.format(k, v)
for k, v in
self.request_params.to_dict(flat=True).items()
if self.config.is_safe_key(k))
for link in formatted_results.find_all('a', href=True):
link['rel'] = "nofollow noopener noreferrer"
if 'search?' not in link['href'] or link['href'].index(
'search?') > 1:
continue
link['href'] += param_str
return str(formatted_results)
return str(formatted_results)

View File

@ -1,10 +1,10 @@
from cryptography.fernet import Fernet
from flask import current_app as app
REQUIRED_SESSION_VALUES = ['uuid', 'config', 'key', 'auth']
REQUIRED_SESSION_VALUES = ['uuid', 'config', 'key']
def generate_key() -> bytes:
def generate_user_key(cookies_disabled=False) -> bytes:
"""Generates a key for encrypting searches and element URLs
Args:
@ -16,6 +16,9 @@ def generate_key() -> bytes:
str: A unique Fernet key
"""
if cookies_disabled:
return app.default_key
# Generate/regenerate unique key per user
return Fernet.generate_key()

View File

@ -1,336 +0,0 @@
"""
User Agent Generator for Opera-based UA strings.
This module generates realistic Opera User Agent strings based on patterns
found in working UA strings that successfully bypass Google's restrictions.
"""
import json
import os
import random
from datetime import datetime, timedelta
from typing import List, Dict
# Default fallback UA if generation fails
DEFAULT_FALLBACK_UA = "Opera/9.80 (iPad; Opera Mini/5.0.17381/503; U; eu) Presto/2.6.35 Version/11.10)"
# Opera UA Pattern Templates
OPERA_PATTERNS = [
# Opera Mini (J2ME/MIDP)
"Opera/9.80 (J2ME/MIDP; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
# Opera Mobile (Android)
"Opera/9.80 (Android; Linux; Opera Mobi/{build}; U; {lang}) Presto/{presto} Version/{final}",
# Opera Mobile (iPhone)
"Opera/9.80 (iPhone; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
# Opera Mobile (iPad)
"Opera/9.80 (iPad; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
]
# Randomization pools based on working UAs
OPERA_MINI_VERSIONS = [
"4.0", "4.1.11321", "4.1.12965", "4.1.13573", "4.1.13907", "4.1.14287",
"4.1.15082", "4.2.13057", "4.2.13221", "4.2.13265", "4.2.13337",
"4.2.13400", "4.2.13918", "4.2.13943", "4.2.14320", "4.2.14409",
"4.2.14753", "4.2.14881", "4.2.14885", "4.2.14912", "4.2.15066",
"4.2.15410", "4.2.16007", "4.2.16320", "4.2.18887", "4.2.19634",
"4.2.21465", "4.2.22228", "4.2.23453", "4.2.24721", "4.3.13337",
"4.3.24214", "4.4.26736", "4.4.29476", "4.5.33867", "4.5.40312",
"5.0.15650", "5.0.16823", "5.0.17381", "5.0.17443", "5.0.18635",
"5.0.18741", "5.0.19683", "5.0.19693", "5.0.20873", "5.0.22349",
"5.1.21051", "5.1.21126", "5.1.21214", "5.1.21415", "5.1.21594",
"5.1.21595", "5.1.22296", "5.1.22303", "5.1.22396", "5.1.22460",
"5.1.22783", "5.1.22784", "6.0.24095", "6.0.24212", "6.0.24455",
"6.1.25375", "6.1.25378", "6.1.25759", "6.24093", "6.24096",
"6.24209", "6.24288", "6.5.26955", "6.5.29702", "7.0.29952",
"7.1.32052", "7.1.32444", "7.1.32694", "7.29530", "7.5.33361",
"7.6.35766", "9.80", "36.2.2254"
]
OPERA_MOBI_BUILDS = [
"27", "49", "447", "498", "1181", "1209", "3730",
"ADR-1011151731", "ADR-1012211514", "ADR-1012221546", "ADR-1012272315",
"SYB-1103211396", "SYB-1104061449", "SYB-1107071606",
"ADR-1111101157"
]
BUILD_NUMBERS = [
"18.678", "18.684", "18.738", "18.794", "19.892", "19.916",
"20.2477", "20.2479", "20.2485", "20.2489", "21.529", "22.387",
"22.394", "22.401", "22.414", "22.453", "22.478", "23.317",
"23.333", "23.334", "23.377", "23.390", "24.741", "24.743",
"24.746", "24.783", "24.838", "24.871", "24.899", "25.657",
"25.677", "25.729", "25.872", "26.1305", "27.1366", "27.1407",
"27.1573", "28.2075", "28.2555", "28.2647", "28.2766", "29.3594",
"30.3316", "31.1350", "35.2883", "35.5706", "37.6584", "119.132",
"170.51", "170.54", "764", "870", "886", "490", "503"
]
PRESTO_VERSIONS = [
"2.2.0", "2.4.15", "2.4.154.15", "2.4.18", "2.5.25", "2.5.28",
"2.6.35", "2.7.60", "2.7.81", "2.8.119", "2.8.149", "2.8.191",
"2.9.201", "2.12.423"
]
FINAL_VERSIONS = [
"10.00", "10.1", "10.5", "10.54", "10.5454", "11.00", "11.10",
"12.02", "12.16", "13.00"
]
LANGUAGES = [
# English variants
"en", "en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-ZA", "en-IN", "en-SG",
# Western European
"de", "de-DE", "de-AT", "de-CH",
"fr", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU",
"es", "es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-LA",
"it", "it-IT", "it-CH",
"pt", "pt-PT", "pt-BR",
"nl", "nl-NL", "nl-BE",
# Nordic languages
"da", "da-DK",
"sv", "sv-SE",
"no", "no-NO", "nb", "nn",
"fi", "fi-FI",
"is", "is-IS",
# Eastern European
"pl", "pl-PL",
"cs", "cs-CZ",
"sk", "sk-SK",
"hu", "hu-HU",
"ro", "ro-RO",
"bg", "bg-BG",
"hr", "hr-HR",
"sr", "sr-RS",
"sl", "sl-SI",
"uk", "uk-UA",
"ru", "ru-RU",
# Asian languages
"zh", "zh-CN", "zh-TW", "zh-HK",
"ja", "ja-JP",
"ko", "ko-KR",
"th", "th-TH",
"vi", "vi-VN",
"id", "id-ID",
"ms", "ms-MY",
"fil", "tl",
# Middle Eastern
"tr", "tr-TR",
"ar", "ar-SA", "ar-AE", "ar-EG",
"he", "he-IL",
"fa", "fa-IR",
# Other
"hi", "hi-IN",
"bn", "bn-IN",
"ta", "ta-IN",
"te", "te-IN",
"mr", "mr-IN",
"el", "el-GR",
"ca", "ca-ES",
"eu", "eu-ES"
]
def generate_opera_ua() -> str:
"""
Generate a single random Opera User Agent string.
Returns:
str: A randomly generated Opera UA string
"""
pattern = random.choice(OPERA_PATTERNS)
# Determine which parameters to use based on the pattern
params = {
'lang': random.choice(LANGUAGES)
}
if '{version}' in pattern:
params['version'] = random.choice(OPERA_MINI_VERSIONS)
if '{build}' in pattern:
# Use MOBI build for "Opera Mobi", regular build for "Opera Mini"
if "Opera Mobi" in pattern:
params['build'] = random.choice(OPERA_MOBI_BUILDS)
else:
params['build'] = random.choice(BUILD_NUMBERS)
if '{presto}' in pattern:
params['presto'] = random.choice(PRESTO_VERSIONS)
if '{final}' in pattern:
params['final'] = random.choice(FINAL_VERSIONS)
return pattern.format(**params)
def generate_ua_pool(count: int = 10) -> List[str]:
"""
Generate a pool of unique Opera User Agent strings.
Args:
count: Number of UA strings to generate (default: 10)
Returns:
List[str]: List of unique UA strings
"""
ua_pool = set()
# Keep generating until we have enough unique UAs
# Add safety limit to prevent infinite loop
max_attempts = count * 100
attempts = 0
try:
while len(ua_pool) < count and attempts < max_attempts:
ua = generate_opera_ua()
ua_pool.add(ua)
attempts += 1
except Exception:
# If generation fails entirely, return at least the default fallback
if not ua_pool:
return [DEFAULT_FALLBACK_UA]
# If we couldn't generate enough, fill remaining with default
result = list(ua_pool)
while len(result) < count:
result.append(DEFAULT_FALLBACK_UA)
return result
def save_ua_pool(uas: List[str], cache_path: str) -> None:
"""
Save UA pool to cache file.
Args:
uas: List of UA strings to save
cache_path: Path to cache file
"""
cache_data = {
'generated_at': datetime.now().isoformat(),
'user_agents': uas
}
# Ensure directory exists
cache_dir = os.path.dirname(cache_path)
if cache_dir and not os.path.exists(cache_dir):
os.makedirs(cache_dir, exist_ok=True)
with open(cache_path, 'w', encoding='utf-8') as f:
json.dump(cache_data, f, indent=2)
def load_custom_ua_list(file_path: str) -> List[str]:
"""
Load custom UA list from a text file.
Args:
file_path: Path to text file containing UA strings (one per line)
Returns:
List[str]: List of UA strings, or empty list if file is invalid
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
uas = [line.strip() for line in f if line.strip()]
# Validate that we have at least one UA
if not uas:
return []
return uas
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
return []
def load_ua_pool(cache_path: str, count: int = 10) -> List[str]:
"""
Load UA pool from custom list file, cache, or generate new one.
Priority order:
1. Custom UA list file (if WHOOGLE_UA_LIST_FILE is set)
2. Cached auto-generated UAs
3. Newly generated UAs
Args:
cache_path: Path to cache file
count: Number of UAs to generate if cache is invalid (default: 10)
Returns:
List[str]: List of UA strings
"""
# Check for custom UA list file first (highest priority)
custom_ua_file = os.environ.get('WHOOGLE_UA_LIST_FILE', '').strip()
if custom_ua_file:
custom_uas = load_custom_ua_list(custom_ua_file)
if custom_uas:
# Custom list loaded successfully
return custom_uas
else:
# Custom file specified but invalid, log warning and fall back
print(f"Warning: Custom UA list file '{custom_ua_file}' not found or invalid, falling back to auto-generated UAs")
# Check if we should use cache
use_cache = os.environ.get('WHOOGLE_UA_CACHE_PERSISTENT', '1') == '1'
refresh_days = int(os.environ.get('WHOOGLE_UA_CACHE_REFRESH_DAYS', '0'))
# If cache disabled, always generate new
if not use_cache:
uas = generate_ua_pool(count)
save_ua_pool(uas, cache_path)
return uas
# Try to load from cache
if os.path.exists(cache_path):
try:
with open(cache_path, 'r', encoding='utf-8') as f:
cache_data = json.load(f)
# Check if cache is expired (if refresh_days > 0)
if refresh_days > 0:
generated_at = datetime.fromisoformat(cache_data['generated_at'])
age_days = (datetime.now() - generated_at).days
if age_days >= refresh_days:
# Cache expired, generate new
uas = generate_ua_pool(count)
save_ua_pool(uas, cache_path)
return uas
# Cache is valid, return it
return cache_data['user_agents']
except (json.JSONDecodeError, KeyError, ValueError):
# Cache file is corrupted, generate new
pass
# No valid cache, generate new
uas = generate_ua_pool(count)
save_ua_pool(uas, cache_path)
return uas
def get_random_ua(ua_pool: List[str]) -> str:
"""
Get a random UA from the pool.
Args:
ua_pool: List of UA strings
Returns:
str: Random UA string from the pool
"""
if not ua_pool:
# Fallback to generating one if pool is empty
try:
return generate_opera_ua()
except Exception:
# If generation fails, use default fallback
return DEFAULT_FALLBACK_UA
return random.choice(ua_pool)

View File

@ -1,71 +0,0 @@
from pathlib import Path
from bs4 import BeautifulSoup
# root
BASE_DIR = Path(__file__).parent.parent.parent
def add_ip_card(html_soup: BeautifulSoup, ip: str) -> BeautifulSoup:
"""Adds the client's IP address to the search results
if query contains keywords
Args:
html_soup: The parsed search result containing the keywords
ip: ip address of the client
Returns:
BeautifulSoup
"""
main_div = html_soup.select_one('#main')
if main_div:
# HTML IP card tag
ip_tag = html_soup.new_tag('div')
ip_tag['class'] = 'ZINbbc xpd O9g5cc uUPGi'
# For IP Address html tag
ip_address = html_soup.new_tag('div')
ip_address['class'] = 'kCrYT ip-address-div'
ip_address.string = ip
# Text below the IP address
ip_text = html_soup.new_tag('div')
ip_text.string = 'Your public IP address'
ip_text['class'] = 'kCrYT ip-text-div'
# Adding all the above html tags to the IP card
ip_tag.append(ip_address)
ip_tag.append(ip_text)
# Insert the element at the top of the result list
main_div.insert_before(ip_tag)
return html_soup
def add_calculator_card(html_soup: BeautifulSoup) -> BeautifulSoup:
"""Adds the a calculator widget to the search results
if query contains keywords
Args:
html_soup: The parsed search result containing the keywords
Returns:
BeautifulSoup
"""
main_div = html_soup.select_one('#main')
if main_div:
# absolute path
widget_file = open(BASE_DIR / 'app/static/widgets/calculator.html', encoding="utf8")
widget_tag = html_soup.new_tag('div')
widget_tag['class'] = 'ZINbbc xpd O9g5cc uUPGi'
widget_tag['id'] = 'calculator-wrapper'
calculator_text = html_soup.new_tag('div')
calculator_text['class'] = 'kCrYT ip-address-div'
calculator_text.string = 'Calculator'
calculator_widget = html_soup.new_tag('div')
calculator_widget.append(BeautifulSoup(widget_file, 'html.parser'))
calculator_widget['class'] = 'kCrYT ip-text-div'
widget_tag.append(calculator_text)
widget_tag.append(calculator_widget)
main_div.insert_before(widget_tag)
widget_file.close()
return html_soup

View File

@ -1,8 +0,0 @@
import os
optional_dev_tag = ''
if os.getenv('DEV_BUILD'):
optional_dev_tag = '.dev' + os.getenv('DEV_BUILD')
__version__ = '1.2.2' + optional_dev_tag

View File

@ -1,23 +0,0 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@ -1,23 +0,0 @@
apiVersion: v2
name: whoogle
description: A self hosted search engine on Kubernetes
type: application
version: 0.1.0
appVersion: 0.9.4
icon: https://github.com/benbusby/whoogle-search/raw/main/app/static/img/favicon/favicon-96x96.png
sources:
- https://github.com/benbusby/whoogle-search
- https://gitlab.com/benbusby/whoogle-search
- https://gogs.benbusby.com/benbusby/whoogle-search
keywords:
- whoogle
- degoogle
- search
- google
- search-engine
- privacy
- tor
- python

View File

@ -1,22 +0,0 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "whoogle.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "whoogle.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "whoogle.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
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}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@ -1,62 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "whoogle.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "whoogle.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "whoogle.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "whoogle.labels" -}}
helm.sh/chart: {{ include "whoogle.chart" . }}
{{ include "whoogle.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "whoogle.selectorLabels" -}}
app.kubernetes.io/name: {{ include "whoogle.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "whoogle.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "whoogle.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@ -1,82 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "whoogle.fullname" . }}
labels:
{{- include "whoogle.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "whoogle.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "whoogle.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.image.pullSecrets }}
imagePullSecrets:
{{- range .}}
- name: {{ . }}
{{- end }}
{{- end }}
serviceAccountName: {{ include "whoogle.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: whoogle
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.conf }}
env:
{{- range $k,$v := . }}
{{- if $v }}
- name: {{ $k }}
value: {{ tpl (toString $v) $ | quote }}
{{- end }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ default 5000 .Values.conf.EXPOSE_PORT }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
{{- if and .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS }}
httpHeaders:
- name: Authorization
value: Basic {{ b64enc (printf "%s:%s" .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS) }}
{{- end }}
readinessProbe:
httpGet:
path: /
port: http
{{- if and .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS }}
httpHeaders:
- name: Authorization
value: Basic {{ b64enc (printf "%s:%s" .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS) }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@ -1,44 +0,0 @@
{{- if .Values.autoscaling.enabled }}
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: autoscaling/v2
{{- else -}}
apiVersion: autoscaling/v2beta1
{{- end }}
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "whoogle.fullname" . }}
labels:
{{- include "whoogle.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "whoogle.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- else -}}
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- else -}}
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -1,61 +0,0 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "whoogle.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "whoogle.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "whoogle.fullname" . }}
labels:
{{- include "whoogle.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "whoogle.selectorLabels" . | nindent 4 }}

View File

@ -1,12 +0,0 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "whoogle.serviceAccountName" . }}
labels:
{{- include "whoogle.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -1,15 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "whoogle.fullname" . }}-test-connection"
labels:
{{- include "whoogle.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "whoogle.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@ -1,115 +0,0 @@
# Default values for whoogle.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
nameOverride: ""
fullnameOverride: ""
replicaCount: 1
image:
repository: benbusby/whoogle-search
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
pullSecrets: []
# - my-image-pull-secret
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
conf: {}
# WHOOGLE_URL_PREFIX: "" # The URL prefix to use for the whoogle instance (i.e. "/whoogle")
# WHOOGLE_DOTENV: "" # Load environment variables in whoogle.env
# WHOOGLE_USER: "" # The username for basic auth. WHOOGLE_PASS must also be set if used.
# WHOOGLE_PASS: "" # The password for basic auth. WHOOGLE_USER must also be set if used.
# WHOOGLE_PROXY_USER: "" # The username of the proxy server.
# WHOOGLE_PROXY_PASS: "" # The password of the proxy server.
# WHOOGLE_PROXY_TYPE: "" # The type of the proxy server. Can be "socks5", "socks4", or "http".
# WHOOGLE_PROXY_LOC: "" # The location of the proxy server (host or ip).
# EXPOSE_PORT: "" # The port where Whoogle will be exposed. (default 5000)
# HTTPS_ONLY: "" # Enforce HTTPS. (See https://github.com/benbusby/whoogle-search#https-enforcement)
# WHOOGLE_ALT_TW: "" # The twitter.com alternative to use when site alternatives are enabled in the config.
# WHOOGLE_ALT_YT: "" # The youtube.com alternative to use when site alternatives are enabled in the config.
# WHOOGLE_ALT_RD: "" # The reddit.com alternative to use when site alternatives are enabled in the config.
# WHOOGLE_ALT_TL: "" # The Google Translate alternative to use. This is used for all "translate ____" searches.
# WHOOGLE_ALT_MD: "" # The medium.com alternative to use when site alternatives are enabled in the config.
# WHOOGLE_ALT_IMG: "" # The imgur.com alternative to use when site alternatives are enabled in the config.
# WHOOGLE_ALT_WIKI: "" # The wikipedia.com alternative to use when site alternatives are enabled in the config.
# WHOOGLE_ALT_IMDB: "" # The imdb.com alternative to use. Set to "" to continue using imdb.com when site alternatives are enabled.
# WHOOGLE_ALT_QUORA: "" # The quora.com alternative to use. Set to "" to continue using quora.com when site alternatives are enabled.
# WHOOGLE_ALT_SO: "" # The stackoverflow.com alternative to use. Set to "" to continue using stackoverflow.com when site alternatives are enabled.
# WHOOGLE_AUTOCOMPLETE: "" # Controls visibility of autocomplete/search suggestions. Default on -- use '0' to disable
# WHOOGLE_MINIMAL: "" # Remove everything except basic result cards from all search queries.
# WHOOGLE_CONFIG_DISABLE: "" # Hide config from UI and disallow changes to config by client
# WHOOGLE_CONFIG_COUNTRY: "" # Filter results by hosting country
# WHOOGLE_CONFIG_LANGUAGE: "" # Set interface language
# WHOOGLE_CONFIG_SEARCH_LANGUAGE: "" # Set search result language
# WHOOGLE_CONFIG_BLOCK: "" # Block websites from search results (use comma-separated list)
# WHOOGLE_CONFIG_THEME: "" # Set theme mode (light, dark, or system)
# WHOOGLE_CONFIG_SAFE: "" # Enable safe searches
# WHOOGLE_CONFIG_ALTS: "" # Use social media site alternatives (nitter, invidious, etc)
# WHOOGLE_CONFIG_NEAR: "" # Restrict results to only those near a particular city
# WHOOGLE_CONFIG_TOR: "" # Use Tor routing (if available)
# WHOOGLE_CONFIG_NEW_TAB: "" # Always open results in new tab
# WHOOGLE_CONFIG_VIEW_IMAGE: "" # Enable View Image option
# WHOOGLE_CONFIG_GET_ONLY: "" # Search using GET requests only
# WHOOGLE_CONFIG_URL: "" # The root url of the instance (https://<your url>/)
# WHOOGLE_CONFIG_STYLE: "" # The custom CSS to use for styling (should be single line)
# WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED: "" # Encrypt preferences token, requires key
# WHOOGLE_CONFIG_PREFERENCES_KEY: "" # Key to encrypt preferences in URL (REQUIRED to show url)
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext:
runAsUser: 0
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
service:
type: ClusterIP
port: 5000
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: whoogle.example.com
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - whoogle.example.com
resources: {}
# requests:
# cpu: 100m
# memory: 128Mi
# limits:
# cpu: 100m
# memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}

View File

@ -1,81 +0,0 @@
# can't use mem_limit in a 3.x docker-compose file in non swarm mode
# see https://github.com/docker/compose/issues/4513
version: "2.4"
services:
traefik:
image: "traefik:v2.7"
container_name: "traefik"
command:
#- "--log.level=DEBUG"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
#- "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
- "--certificatesresolvers.myresolver.acme.email=change@domain.name"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
ports:
- "443:443"
- "8080:8080"
volumes:
- "./letsencrypt:/letsencrypt"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
whoogle-search:
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`change.host.name`)"
- "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls.certresolver=myresolver"
- "traefik.http.services.whoogle-search.loadbalancer.server.port=5000"
image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search}
container_name: whoogle-search
restart: unless-stopped
pids_limit: 50
mem_limit: 256mb
memswap_limit: 256mb
# user debian-tor from tor package
user: whoogle
security_opt:
- no-new-privileges
cap_drop:
- ALL
tmpfs:
- /config/:size=10M,uid=927,gid=927,mode=1700
- /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700
- /run/tor/:size=1M,uid=927,gid=927,mode=1700
environment: # Uncomment to configure environment variables
# Basic auth configuration, uncomment to enable
#- WHOOGLE_USER=<auth username>
#- WHOOGLE_PASS=<auth password>
# Proxy configuration, uncomment to enable
#- WHOOGLE_PROXY_USER=<proxy username>
#- WHOOGLE_PROXY_PASS=<proxy password>
#- WHOOGLE_PROXY_TYPE=<proxy type (http|https|socks4|socks5)
#- WHOOGLE_PROXY_LOC=<proxy host/ip>
# Site alternative configurations, uncomment to enable
# Note: If not set, the feature will still be available
# with default values.
#- WHOOGLE_ALT_TW=farside.link/nitter
#- WHOOGLE_ALT_YT=farside.link/invidious
#- WHOOGLE_ALT_IG=farside.link/bibliogram/u
#- WHOOGLE_ALT_RD=farside.link/libreddit
#- WHOOGLE_ALT_MD=farside.link/scribe
#- WHOOGLE_ALT_TL=farside.link/lingva
#- WHOOGLE_ALT_IMG=farside.link/rimgo
#- WHOOGLE_ALT_WIKI=farside.link/wikiless
#- WHOOGLE_ALT_IMDB=farside.link/libremdb
#- WHOOGLE_ALT_QUORA=farside.link/quetre
#- WHOOGLE_ALT_SO=farside.link/anonymousoverflow
# - WHOOGLE_CONFIG_DISABLE=1
# - WHOOGLE_CONFIG_SEARCH_LANGUAGE=lang_en
# - WHOOGLE_CONFIG_GET_ONLY=1
# - WHOOGLE_CONFIG_COUNTRY=FR
# - WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED=1
# - WHOOGLE_CONFIG_PREFERENCES_KEY="NEEDS_TO_BE_MODIFIED"
#env_file: # Alternatively, load variables from whoogle.env
#- whoogle.env
ports:
- 8000:5000

View File

@ -1,24 +1,26 @@
# Modern docker-compose format (v2+) does not require version specification
# Memory limits are supported in Compose v2+ without version field
# cant use mem_limit in a 3.x docker-compose file in non swarm mode
# see https://github.com/docker/compose/issues/4513
version: "2.4"
services:
whoogle-search:
image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search}
image: benbusby/whoogle-search
container_name: whoogle-search
restart: unless-stopped
pids_limit: 50
mem_limit: 256mb
memswap_limit: 256mb
# user debian-tor from tor package
user: whoogle
user: '102'
security_opt:
- no-new-privileges
cap_drop:
- ALL
read_only: true
tmpfs:
- /config/:size=10M,uid=927,gid=927,mode=1700
- /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700
- /run/tor/:size=1M,uid=927,gid=927,mode=1700
- /config/:size=10M,uid=102,gid=102,mode=1700
- /var/lib/tor/:size=10M,uid=102,gid=102,mode=1700
- /run/tor/:size=1M,uid=102,gid=102,mode=1700
#environment: # Uncomment to configure environment variables
# Basic auth configuration, uncomment to enable
#- WHOOGLE_USER=<auth username>
@ -26,22 +28,15 @@ services:
# Proxy configuration, uncomment to enable
#- WHOOGLE_PROXY_USER=<proxy username>
#- WHOOGLE_PROXY_PASS=<proxy password>
#- WHOOGLE_PROXY_TYPE=<proxy type (http|https|socks4|socks5)
#- WHOOGLE_PROXY_TYPE=<proxy type (http|socks4|socks5)
#- WHOOGLE_PROXY_LOC=<proxy host/ip>
# Site alternative configurations, uncomment to enable
# Note: If not set, the feature will still be available
# with default values.
#- WHOOGLE_ALT_TW=farside.link/nitter
#- WHOOGLE_ALT_YT=farside.link/invidious
#- WHOOGLE_ALT_IG=farside.link/bibliogram/u
#- WHOOGLE_ALT_RD=farside.link/libreddit
#- WHOOGLE_ALT_MD=farside.link/scribe
#- WHOOGLE_ALT_TL=farside.link/lingva
#- WHOOGLE_ALT_IMG=farside.link/rimgo
#- WHOOGLE_ALT_WIKI=farside.link/wikiless
#- WHOOGLE_ALT_IMDB=farside.link/libremdb
#- WHOOGLE_ALT_QUORA=farside.link/quetre
#- WHOOGLE_ALT_SO=farside.link/anonymousoverflow
# with default values.
#- WHOOGLE_ALT_TW=nitter.net
#- WHOOGLE_ALT_YT=invidious.snopyta.org
#- WHOOGLE_ALT_IG=bibliogram.art/u
#- WHOOGLE_ALT_RD=libredd.it
#env_file: # Alternatively, load variables from whoogle.env
#- whoogle.env
ports:

BIN
docs/screenshot_desktop.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

BIN
docs/screenshot_mobile.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View File

View File

@ -1,363 +0,0 @@
#!/usr/bin/env python3
"""
Test User Agent strings against Google to find which ones return actual search results
instead of JavaScript pages or upgrade browser messages.
Usage:
python test_google_user_agents.py <user_agent_file> [--output <output_file>] [--query <search_query>]
"""
import argparse
import random
import sys
import time
from typing import List, Tuple
import requests
# Common search queries to cycle through for more realistic testing
DEFAULT_SEARCH_QUERIES = [
"python programming",
"weather today",
"news",
"how to cook pasta",
"best movies 2025",
"restaurants near me",
"translate hello",
"calculator",
"time",
"maps",
"images",
"videos",
"shopping",
"travel",
"sports scores",
"stock market",
"recipes",
"music",
"books",
"technology",
"AI",
"AI programming",
"Why does google hate users?"
]
# Markers that indicate blocked/JS pages
BLOCK_MARKERS = [
"unusual traffic",
"sorry but your computer",
"solve the captcha",
"request looks automated",
"g-recaptcha",
"upgrade your browser",
"browser is not supported",
"please upgrade",
"isn't supported",
"isn\"t supported", # With escaped quote
"upgrade to a recent version",
"update your browser",
"your browser isn't supported",
]
# Markers that indicate actual search results
SUCCESS_MARKERS = [
'<div class="g"', # Google search result container
'<div id="search"', # Search results container
'<div class="rc"', # Result container
'class="yuRUbf"', # Result link container
'class="LC20lb"', # Result title
'- Google Search</title>', # Page title indicator
'id="rso"', # Results container
'class="g"', # Result class (without div tag)
]
def read_user_agents(file_path: str) -> List[str]:
"""Read user agent strings from a file, one per line."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
user_agents = [line.strip() for line in f if line.strip()]
return user_agents
except FileNotFoundError:
print(f"Error: File '{file_path}' not found.", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error reading file: {e}", file=sys.stderr)
sys.exit(1)
def test_user_agent(user_agent: str, query: str = "test", timeout: float = 10.0) -> Tuple[bool, str]:
"""
Test a user agent against Google search.
Returns:
Tuple of (is_working: bool, reason: str)
"""
url = "https://www.google.com/search"
params = {"q": query, "gbv": "1", "num": "10"}
headers = {
"User-Agent": user_agent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
try:
response = requests.get(url, params=params, headers=headers, timeout=timeout)
# Check HTTP status
if response.status_code == 429:
# Rate limited - raise this so we can handle it specially
raise Exception(f"Rate limited (429)")
if response.status_code >= 500:
return False, f"Server error ({response.status_code})"
if response.status_code == 403:
return False, f"Blocked ({response.status_code})"
if response.status_code >= 400:
return False, f"HTTP {response.status_code}"
body_lower = response.text.lower()
# Check for block markers
for marker in BLOCK_MARKERS:
if marker.lower() in body_lower:
return False, f"Blocked: {marker}"
# Check for redirect indicators first - these indicate non-working responses
has_redirect = ("window.location" in body_lower or "location.href" in body_lower) and "google.com" not in body_lower
if has_redirect:
return False, "JavaScript redirect detected"
# Check for noscript redirect (another indicator of JS-only page)
if 'noscript' in body_lower and 'http-equiv="refresh"' in body_lower:
return False, "NoScript redirect page"
# Check for success markers (actual search results)
# We need at least one strong indicator of search results
has_results = any(marker in response.text for marker in SUCCESS_MARKERS)
if has_results:
return True, "OK - Has search results"
else:
# Check for very short responses (likely error pages)
if len(response.text) < 1000:
return False, "Response too short (likely error page)"
# If we don't have success markers, it's not a working response
# Even if it's substantial and doesn't have block markers, it might be a JS-only page
return False, "No search results found"
except requests.Timeout:
return False, "Request timeout"
except requests.HTTPError as e:
if e.response and e.response.status_code == 429:
# Rate limited - raise this so we can handle it specially
raise Exception(f"Rate limited (429) - {str(e)}")
return False, f"HTTP error: {str(e)}"
except requests.RequestException as e:
# Check if it's a 429 in the response
if hasattr(e, 'response') and e.response and e.response.status_code == 429:
raise Exception(f"Rate limited (429) - {str(e)}")
return False, f"Request error: {str(e)}"
def main():
parser = argparse.ArgumentParser(
description="Test User Agent strings against Google to find working ones.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python test_google_user_agents.py UAs.txt
python test_google_user_agents.py UAs.txt --output working_uas.txt
python test_google_user_agents.py UAs.txt --query "python programming"
"""
)
parser.add_argument(
"user_agent_file",
help="Path to file containing user agent strings (one per line)"
)
parser.add_argument(
"--output", "-o",
help="Output file to write working user agents (default: stdout)"
)
parser.add_argument(
"--query", "-q",
default=None,
help="Search query to use for testing (default: cycles through random queries)"
)
parser.add_argument(
"--random-queries", "-r",
action="store_true",
help="Use random queries from a predefined list (default: True if --query not specified)"
)
parser.add_argument(
"--timeout", "-t",
type=float,
default=10.0,
help="Request timeout in seconds (default: 10.0)"
)
parser.add_argument(
"--delay", "-d",
type=float,
default=0.5,
help="Delay between requests in seconds (default: 0.5)"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show detailed results for each user agent"
)
args = parser.parse_args()
# Determine query strategy
use_random_queries = args.random_queries or (args.query is None)
if use_random_queries:
search_queries = DEFAULT_SEARCH_QUERIES.copy()
random.shuffle(search_queries) # Shuffle for variety
current_query_idx = 0
query_display = f"cycling through {len(search_queries)} random queries"
else:
search_queries = [args.query]
query_display = f"'{args.query}'"
# Read user agents
user_agents = read_user_agents(args.user_agent_file)
if not user_agents:
print("No user agents found in file.", file=sys.stderr)
sys.exit(1)
print(f"Testing {len(user_agents)} user agents against Google...", file=sys.stderr)
print(f"Query: {query_display}", file=sys.stderr)
if args.output:
print(f"Output file: {args.output} (appending results incrementally)", file=sys.stderr)
print(file=sys.stderr)
# Load existing working user agents from output file to avoid duplicates
existing_working = set()
if args.output:
try:
with open(args.output, 'r', encoding='utf-8') as f:
existing_working = {line.strip() for line in f if line.strip()}
if existing_working:
print(f"Found {len(existing_working)} existing user agents in output file", file=sys.stderr)
except FileNotFoundError:
# File doesn't exist yet, that's fine
pass
except Exception as e:
print(f"Warning: Could not read existing output file: {e}", file=sys.stderr)
# Open output file for incremental writing if specified (append mode)
output_file = None
if args.output:
try:
output_file = open(args.output, 'a', encoding='utf-8')
except Exception as e:
print(f"Error opening output file: {e}", file=sys.stderr)
sys.exit(1)
working_agents = []
failed_count = 0
skipped_count = 0
last_successful_idx = 0
try:
for idx, ua in enumerate(user_agents, 1):
# Skip testing if this UA is already in the working file
if args.output and ua in existing_working:
skipped_count += 1
if args.verbose:
print(f"[{idx}/{len(user_agents)}] ⊘ SKIPPED - Already in working file", file=sys.stderr)
last_successful_idx = idx
continue
try:
# Get the next query (cycle through if using random queries)
if use_random_queries:
query = search_queries[current_query_idx % len(search_queries)]
current_query_idx += 1
else:
query = args.query
is_working, reason = test_user_agent(ua, query, args.timeout)
if is_working:
working_agents.append(ua)
status = ""
# Write immediately to output file if specified (skip if duplicate)
if output_file:
if ua not in existing_working:
output_file.write(ua + '\n')
output_file.flush() # Ensure it's written to disk
existing_working.add(ua) # Track it to avoid duplicates
else:
if args.verbose:
print(f"[{idx}/{len(user_agents)}] {status} WORKING (duplicate, skipped) - {reason}", file=sys.stderr)
# Also print to stdout if no output file
if not args.output:
print(ua)
if args.verbose:
print(f"[{idx}/{len(user_agents)}] {status} WORKING - {reason}", file=sys.stderr)
else:
failed_count += 1
status = ""
if args.verbose:
print(f"[{idx}/{len(user_agents)}] {status} FAILED - {reason}", file=sys.stderr)
last_successful_idx = idx
# Progress indicator for non-verbose mode
if not args.verbose and idx % 10 == 0:
print(f"Progress: {idx}/{len(user_agents)} tested ({len(working_agents)} working, {failed_count} failed)", file=sys.stderr)
# Delay between requests to avoid rate limiting
if idx < len(user_agents):
time.sleep(args.delay)
except KeyboardInterrupt:
print(file=sys.stderr)
print(f"\nInterrupted by user at index {idx}/{len(user_agents)}", file=sys.stderr)
print(f"Last successful test: {last_successful_idx}/{len(user_agents)}", file=sys.stderr)
break
except Exception as e:
# Handle unexpected errors (like network issues or rate limits)
error_msg = str(e)
if "429" in error_msg or "Rate limited" in error_msg:
print(file=sys.stderr)
print(f"\n⚠️ RATE LIMIT DETECTED at index {idx}/{len(user_agents)}", file=sys.stderr)
print(f"Last successful test: {last_successful_idx}/{len(user_agents)}", file=sys.stderr)
print(f"Working user agents found so far: {len(working_agents)}", file=sys.stderr)
if args.output:
print(f"Results saved to: {args.output}", file=sys.stderr)
print(f"\nTo resume later, you can skip the first {last_successful_idx} user agents.", file=sys.stderr)
raise # Re-raise to exit the loop
else:
print(f"[{idx}/{len(user_agents)}] ERROR - {error_msg}", file=sys.stderr)
failed_count += 1
last_successful_idx = idx
if idx < len(user_agents):
time.sleep(args.delay)
continue
finally:
# Close output file if opened
if output_file:
output_file.close()
# Summary
print(file=sys.stderr)
tested_count = last_successful_idx - skipped_count
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)
if last_successful_idx < len(user_agents):
print(f"Note: Processing stopped at index {last_successful_idx}. {len(user_agents) - last_successful_idx} user agents not processed.", file=sys.stderr)
if args.output:
print(f"Results saved to: {args.output}", file=sys.stderr)
return 0 if working_agents else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,198 +0,0 @@
#!/usr/bin/env python3
"""
Standalone Opera User Agent String Generator
This tool generates Opera-based User Agent strings that can be used with Whoogle.
It can be run independently to generate and display UA strings on demand.
Usage:
python misc/generate_uas.py [count]
Arguments:
count: Number of UA strings to generate (default: 10)
Examples:
python misc/generate_uas.py # Generate 10 UAs
python misc/generate_uas.py 20 # Generate 20 UAs
"""
import sys
import os
# Default fallback UA if generation fails
DEFAULT_FALLBACK_UA = "Opera/9.30 (Nintendo Wii; U; ; 3642; en)"
# Try to import from the app module if available
try:
# Add parent directory to path to allow imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from app.utils.ua_generator import generate_ua_pool
USE_APP_MODULE = True
except ImportError:
USE_APP_MODULE = False
# Self-contained version if app module is not available
import random
# Opera UA Pattern Templates
OPERA_PATTERNS = [
"Opera/9.80 (J2ME/MIDP; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
"Opera/9.80 (Android; Linux; Opera Mobi/{build}; U; {lang}) Presto/{presto} Version/{final}",
"Opera/9.80 (iPhone; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
"Opera/9.80 (iPad; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
]
OPERA_MINI_VERSIONS = [
"4.0", "4.1.11321", "4.2.13337", "4.2.14912", "4.2.15410", "4.3.24214",
"5.0.18741", "5.1.22296", "5.1.22783", "6.0.24095", "6.24093", "7.1.32444",
"7.6.35766", "36.2.2254"
]
OPERA_MOBI_BUILDS = [
"27", "49", "447", "1209", "3730", "ADR-1012221546", "SYB-1107071606"
]
BUILD_NUMBERS = [
"22.387", "22.478", "23.334", "23.377", "24.746", "24.783", "25.657",
"27.1407", "28.2647", "35.5706", "119.132", "870", "886"
]
PRESTO_VERSIONS = [
"2.4.15", "2.4.18", "2.5.25", "2.8.119", "2.12.423"
]
FINAL_VERSIONS = [
"10.00", "10.1", "10.54", "11.10", "12.16", "13.00"
]
LANGUAGES = [
# English variants
"en", "en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-ZA", "en-IN", "en-SG",
# Western European
"de", "de-DE", "de-AT", "de-CH",
"fr", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU",
"es", "es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-LA",
"it", "it-IT", "it-CH",
"pt", "pt-PT", "pt-BR",
"nl", "nl-NL", "nl-BE",
# Nordic languages
"da", "da-DK",
"sv", "sv-SE",
"no", "no-NO", "nb", "nn",
"fi", "fi-FI",
"is", "is-IS",
# Eastern European
"pl", "pl-PL",
"cs", "cs-CZ",
"sk", "sk-SK",
"hu", "hu-HU",
"ro", "ro-RO",
"bg", "bg-BG",
"hr", "hr-HR",
"sr", "sr-RS",
"sl", "sl-SI",
"uk", "uk-UA",
"ru", "ru-RU",
# Asian languages
"zh", "zh-CN", "zh-TW", "zh-HK",
"ja", "ja-JP",
"ko", "ko-KR",
"th", "th-TH",
"vi", "vi-VN",
"id", "id-ID",
"ms", "ms-MY",
"fil", "tl",
# Middle Eastern
"tr", "tr-TR",
"ar", "ar-SA", "ar-AE", "ar-EG",
"he", "he-IL",
"fa", "fa-IR",
# Other
"hi", "hi-IN",
"bn", "bn-IN",
"ta", "ta-IN",
"te", "te-IN",
"mr", "mr-IN",
"el", "el-GR",
"ca", "ca-ES",
"eu", "eu-ES"
]
def generate_opera_ua():
"""Generate a single random Opera User Agent string."""
pattern = random.choice(OPERA_PATTERNS)
params = {'lang': random.choice(LANGUAGES)}
if '{version}' in pattern:
params['version'] = random.choice(OPERA_MINI_VERSIONS)
if '{build}' in pattern:
if "Opera Mobi" in pattern:
params['build'] = random.choice(OPERA_MOBI_BUILDS)
else:
params['build'] = random.choice(BUILD_NUMBERS)
if '{presto}' in pattern:
params['presto'] = random.choice(PRESTO_VERSIONS)
if '{final}' in pattern:
params['final'] = random.choice(FINAL_VERSIONS)
return pattern.format(**params)
def generate_ua_pool(count=10):
"""Generate a pool of unique Opera User Agent strings."""
ua_pool = set()
max_attempts = count * 100
attempts = 0
try:
while len(ua_pool) < count and attempts < max_attempts:
ua = generate_opera_ua()
ua_pool.add(ua)
attempts += 1
except Exception:
# If generation fails entirely, return at least the default fallback
if not ua_pool:
return [DEFAULT_FALLBACK_UA]
# If we couldn't generate enough, fill remaining with default
result = list(ua_pool)
while len(result) < count:
result.append(DEFAULT_FALLBACK_UA)
return result
def main():
"""Main function to generate and display UA strings."""
# Parse command line argument
count = 10 # Default
if len(sys.argv) > 1:
try:
count = int(sys.argv[1])
if count < 1:
print("Error: Count must be a positive integer", file=sys.stderr)
sys.exit(1)
except ValueError:
print(f"Error: Invalid count '{sys.argv[1]}'. Must be an integer.", file=sys.stderr)
sys.exit(1)
# Show which mode we're using (to stderr so it doesn't interfere with output)
if USE_APP_MODULE:
print(f"# Using app.utils.ua_generator module", file=sys.stderr)
else:
print(f"# Using standalone generator (app module not available)", file=sys.stderr)
print(f"# Generating {count} Opera User Agent strings...\n", file=sys.stderr)
# Generate UAs
uas = generate_ua_pool(count)
# Display them (one per line, no numbering)
for ua in uas:
print(ua)
# Summary to stderr so it doesn't interfere with piping
print(f"\n# Generated {len(uas)} unique User Agent strings", file=sys.stderr)
if __name__ == '__main__':
main()

View File

@ -1,6 +0,0 @@
https://search.garudalinux.org
https://search.sethforprivacy.com
https://whoogle.privacydev.net
https://wg.vern.cc
https://whoogle.lunar.icu
https://whoogle.4040940.xyz

View File

@ -1,5 +0,0 @@
import subprocess
# A plague upon Replit and all who have built it
replit_cmd = "killall -q python3 > /dev/null 2>&1; pip install -r requirements.txt && ./run"
subprocess.run(replit_cmd, shell=True)

View File

@ -1 +0,0 @@
# Place password here. Keep this safe.

View File

@ -1,33 +1,7 @@
#!/bin/sh
FF_STRING="FascistFirewall 1"
if [ "$WHOOGLE_TOR_SERVICE" == "0" ]; then
echo "Skipping Tor startup..."
exit 0
fi
if [ "$WHOOGLE_TOR_FF" == "1" ]; then
if (grep -q "$FF_STRING" /etc/tor/torrc); then
echo "FascistFirewall feature already enabled."
else
echo "$FF_STRING" >> /etc/tor/torrc
if [ "$?" -eq 0 ]; then
echo "FascistFirewall added to /etc/tor/torrc"
else
echo "ERROR: Unable to modify /etc/tor/torrc with $FF_STRING."
exit 1
fi
fi
fi
#!/bin/bash
if [ "$(whoami)" != "root" ]; then
tor -f /etc/tor/torrc
else
if (grep alpine /etc/os-release >/dev/null); then
rc-service tor start
else
service tor start
fi
service tor start
fi

View File

@ -6,7 +6,3 @@ CookieAuthFileGroupReadable 1
ExtORPortCookieAuthFileGroupReadable 1
CacheDirectoryGroupReadable 1
CookieAuthFile /var/lib/tor/control_auth_cookie
Log debug-notice file /dev/null
# UseBridges 1
# ClientTransportPlugin obfs4 exec /usr/bin/obfs4proxy
# Bridge obfs4 ip and so on

View File

@ -1,67 +0,0 @@
import json
import pathlib
import httpx
lingva = 'https://lingva.ml/api/v1/en'
def format_lang(lang: str) -> str:
# Chinese (traditional and simplified) require
# a different format for lingva translations
if 'zh-' in lang:
if lang == 'zh-TW':
return 'zh_HANT'
return 'zh'
# Strip lang prefix to leave only the actual
# language code (i.e. 'en', 'fr', etc)
return lang.replace('lang_', '')
def translate(v: str, lang: str) -> str:
# Strip lang prefix to leave only the actual
#language code (i.e. "es", "fr", etc)
lang = format_lang(lang)
lingva_req = f'{lingva}/{lang}/{v}'
response = httpx.get(lingva_req).json()
if 'translation' in response:
return response['translation']
return ''
if __name__ == '__main__':
file_path = pathlib.Path(__file__).parent.resolve()
tl_path = 'app/static/settings/translations.json'
with open(f'{file_path}/../{tl_path}', 'r+', encoding='utf-8') as tl_file:
tl_data = json.load(tl_file)
# If there are any english translations that don't
# exist for other languages, extract them and translate
# them now
en_tl = tl_data['lang_en']
for k, v in en_tl.items():
for lang in tl_data:
if lang == 'lang_en' or k in tl_data[lang]:
continue
translation = ''
if len(k) == 0:
# Special case for placeholder text that gets used
# for translations without any key present
translation = v
else:
# Translate the string using lingva
translation = translate(v, lang)
if len(translation) == 0:
print(f'! Unable to translate {lang}[{k}]')
continue
print(f'{lang}[{k}] = {translation}')
tl_data[lang][k] = translation
# Write out updated translations json
print(json.dumps(tl_data, indent=4, ensure_ascii=False))

View File

@ -1,16 +0,0 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.ruff]
line-length = 100
target-version = "py312"
lint.select = [
"E", "F", "W", # pycodestyle/pyflakes
"I", # isort
]
lint.ignore = []
[tool.black]
line-length = 100
target-version = ['py312']

View File

@ -1,34 +1,34 @@
attrs==25.3.0
beautifulsoup4==4.13.5
brotli==1.2.0
certifi==2025.8.3
cffi==2.0.0
click==8.3.0
cryptography==46.0.1
cssutils==2.11.1
defusedxml==0.7.1
Flask==3.1.2
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
more-itertools==10.8.0
packaging==25.0
pluggy==1.6.0
pycodestyle==2.14.0
pycparser==2.22
pyOpenSSL==25.3.0
pyparsing==3.2.5
pytest==8.3.3
python-dateutil==2.9.0.post0
httpx[http2,socks]==0.28.1
cachetools==6.2.0
soupsieve==2.8
stem==1.8.2
httpcore>=1.0.9
h11>=0.16.0
validators==0.35.0
waitress==3.0.2
wcwidth==0.2.14
Werkzeug==3.1.4
python-dotenv==1.1.1
attrs==19.3.0
beautifulsoup4==4.8.2
bs4==0.0.1
cachelib==0.1
certifi==2020.4.5.1
cffi==1.13.2
chardet==3.0.4
Click==7.0
cryptography==3.3.2
Flask==1.1.1
Flask-Session==0.3.2
idna==2.9
itsdangerous==1.1.0
Jinja2==2.11.3
MarkupSafe==1.1.1
more-itertools==8.3.0
packaging==20.4
pluggy==0.13.1
py==1.10.0
pycodestyle==2.6.0
pycparser==2.19
pyOpenSSL==19.1.0
pyparsing==2.4.7
PySocks==1.7.1
pytest==5.4.1
python-dateutil==2.8.1
requests==2.23.0
soupsieve==1.9.5
stem==1.8.0
urllib3==1.25.9
waitress==1.4.3
wcwidth==0.1.9
Werkzeug==0.16.0
python-dotenv==0.16.0

25
run
View File

@ -1,37 +1,26 @@
#!/bin/sh
#!/bin/bash
# Usage:
# ./run # Runs the full web app
# ./run test # Runs the testing suite
set -e
set -euo pipefail
SCRIPT_DIR="$(CDPATH= command cd -- "$(dirname -- "$0")" && pwd -P)"
SCRIPT_DIR="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# Set directory to serve static content from
SUBDIR="${1:-app}"
export APP_ROOT="$SCRIPT_DIR/$SUBDIR"
export STATIC_FOLDER="$APP_ROOT/static"
# Clear out build directory
rm -f "$SCRIPT_DIR"/app/static/build/*.js
rm -f "$SCRIPT_DIR"/app/static/build/*.css
# Check for regular vs test run
if [ "$SUBDIR" = "test" ]; then
if [[ "$SUBDIR" == "test" ]]; then
# Set up static files for testing
rm -rf "$STATIC_FOLDER"
ln -s "$SCRIPT_DIR/app/static" "$STATIC_FOLDER"
pytest -sv
else
mkdir -p "$STATIC_FOLDER"
if [ ! -z "$UNIX_SOCKET" ]; then
python3 -um app \
--unix-socket "$UNIX_SOCKET"
else
echo "Running on http://${ADDRESS:-0.0.0.0}:${PORT:-"${EXPOSE_PORT:-5000}"}"
python3 -um app \
--host "${ADDRESS:-0.0.0.0}" \
--port "${PORT:-"${EXPOSE_PORT:-5000}"}"
fi
python3 -um app \
--host "${ADDRESS:-0.0.0.0}" \
--port "${PORT:-"${EXPOSE_PORT:-5000}"}"
fi

View File

@ -1,45 +0,0 @@
[metadata]
name = whoogle-search
version = attr: app.version.__version__
url = https://github.com/benbusby/whoogle-search
description = Self-hosted, ad-free, privacy-respecting metasearch engine
long_description = file: README.md
long_description_content_type = text/markdown
keywords = search, metasearch, flask, adblock, degoogle, privacy
author = Ben Busby
author_email = contact@benbusby.com
license = MIT
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: MIT License
Operating System :: OS Independent
[options]
packages = find:
include_package_data = True
install_requires=
beautifulsoup4
brotli
cssutils
cryptography
defusedxml
Flask
python-dotenv
httpx[http2,socks]
stem
validators
waitress
[options.extras_require]
test =
pytest
python-dateutil
dev = pycodestyle
[options.packages.find]
exclude =
test*
[options.entry_points]
console_scripts =
whoogle-search = app.routes:run_app

Some files were not shown because too many files have changed in this diff Show More