Compare commits

..

No commits in common. 'main' and 'v0.5.4' have entirely different histories.
main ... v0.5.4

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

@ -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: #______;
}
```

@ -1,22 +1,13 @@
name: buildx name: buildx
on: on:
workflow_run:
workflows: ["docker_main"]
branches: [main]
types:
- completed
push: push:
tags: branches: develop
- '*'
jobs: jobs:
on-success: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Wait for tests to succeed
if: ${{ github.event.workflow_run.conclusion != 'success' && startsWith(github.ref, 'refs/tags') != true }}
run: exit 1
- name: checkout code - name: checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: install buildx - name: install buildx
@ -24,36 +15,14 @@ jobs:
uses: crazy-max/ghaction-docker-buildx@v1 uses: crazy-max/ghaction-docker-buildx@v1
with: with:
version: latest version: latest
- name: Login to Docker Hub - name: log in to docker hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to ghcr.io
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build and push the image
if: startsWith(github.ref, 'refs/heads/main') && github.actor == 'benbusby'
run: | run: |
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes echo "${{ secrets.DOCKER_PASSWORD }}" | \
docker buildx ls docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
docker buildx build --push \ - name: build and push the image
--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 tag
if: startsWith(github.ref, 'refs/tags')
run: | run: |
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx ls docker buildx ls
docker buildx build --push \ docker buildx build --push \
--tag benbusby/whoogle-search:${GITHUB_REF#refs/*/v}\ --tag benbusby/whoogle-search:buildx-experimental \
--platform linux/amd64,linux/arm/v7,linux/arm64 .
docker buildx build --push \
--tag ghcr.io/benbusby/whoogle-search:${GITHUB_REF#refs/*/v}\
--platform linux/amd64,linux/arm/v7,linux/arm64 . --platform linux/amd64,linux/arm/v7,linux/arm64 .

@ -1,28 +0,0 @@
name: docker_main
on:
workflow_run:
workflows: ["tests"]
branches: [main]
types:
- completed
# TODO: Needs refactoring to use reusable workflows and share w/ docker_tests
jobs:
on-success:
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

@ -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

@ -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/*

@ -1,68 +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@v2
- name: Set up Python 3.9
uses: actions/setup-python@v1
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:
name: Build and publish to PyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.9
- name: Install pypa/build
run: >-
python -m
pip install
build
--user
- name: Build binary wheel and source tarball
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
.
- name: Publish distribution to PyPI
if: startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.PYPI_API_TOKEN }}

@ -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

@ -1,17 +0,0 @@
name: tests
on: [push, pull_request]
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: pip install --upgrade pip && pip install -r requirements.txt
- name: Run tests
run: ./run test

14
.gitignore vendored

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

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

@ -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

@ -1,78 +1,60 @@
FROM python:3.11.0a5-alpine as builder FROM python:3.8-slim as builder
RUN apk --update add \ RUN apt-get update && apt-get install -y \
build-base \ build-essential \
libxml2-dev \ libxml2-dev \
libxslt-dev \ libxslt-dev \
openssl-dev \ libssl-dev \
libffi-dev libffi-dev
COPY requirements.txt . COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt RUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt
FROM python:3.11.0a5-alpine FROM python:3.8-slim
RUN apk add --update --no-cache tor curl openrc libstdc++ RUN apt-get update && apt-get install -y \
# git go //for obfs4proxy libcurl4-openssl-dev \
# libcurl4-openssl-dev tor \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN apk -U upgrade
# 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 ARG config_dir=/config
RUN mkdir -p $config_dir RUN mkdir -p $config_dir
RUN chmod a+w $config_dir
VOLUME $config_dir VOLUME $config_dir
ENV CONFIG_VOLUME=$config_dir
ARG url_prefix=''
ARG username='' ARG username=''
ENV WHOOGLE_USER=$username
ARG password='' ARG password=''
ENV WHOOGLE_PASS=$password
ARG proxyuser='' ARG proxyuser=''
ENV WHOOGLE_PROXY_USER=$proxyuser
ARG proxypass='' ARG proxypass=''
ENV WHOOGLE_PROXY_PASS=$proxypass
ARG proxytype='' ARG proxytype=''
ENV WHOOGLE_PROXY_TYPE=$proxytype
ARG proxyloc='' ARG proxyloc=''
ENV WHOOGLE_PROXY_LOC=$proxyloc
ARG whoogle_dotenv='' ARG whoogle_dotenv=''
ENV WHOOGLE_DOTENV=$whoogle_dotenv
ARG use_https='' ARG use_https=''
ENV HTTPS_ONLY=$use_https
ARG whoogle_port=5000 ARG whoogle_port=5000
ARG twitter_alt='farside.link/nitter' ENV EXPOSE_PORT=$whoogle_port
ARG youtube_alt='farside.link/invidious'
ARG reddit_alt='farside.link/libreddit' ARG twitter_alt='nitter.net'
ARG medium_alt='farside.link/scribe' ENV WHOOGLE_ALT_TW=$twitter_alt
ARG translate_alt='farside.link/lingva' ARG youtube_alt='invidious.snopyta.org'
ARG imgur_alt='farside.link/rimgo' ENV WHOOGLE_ALT_YT=$youtube_alt
ARG wikipedia_alt='farside.link/wikiless' ARG instagram_alt='bibliogram.art/u'
ARG imdb_alt='farside.link/libremdb' ENV WHOOGLE_ALT_IG=$instagram_alt
ARG quora_alt='farside.link/quetre' ARG reddit_alt='libredd.it'
ENV WHOOGLE_ALT_RD=$reddit_alt
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
WORKDIR /whoogle WORKDIR /whoogle
@ -80,22 +62,12 @@ COPY --from=builder /install /usr/local
COPY misc/tor/torrc /etc/tor/torrc COPY misc/tor/torrc /etc/tor/torrc
COPY misc/tor/start-tor.sh misc/tor/start-tor.sh COPY misc/tor/start-tor.sh misc/tor/start-tor.sh
COPY app/ app/ COPY app/ app/
COPY run whoogle.env* ./ COPY run .
COPY 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
EXPOSE $EXPOSE_PORT EXPOSE $EXPOSE_PORT
HEALTHCHECK --interval=30s --timeout=5s \ HEALTHCHECK --interval=30s --timeout=5s \
CMD curl -f http://localhost:${EXPOSE_PORT}/healthz || exit 1 CMD curl -f http://localhost:${EXPOSE_PORT}/healthz || exit 1
CMD misc/tor/start-tor.sh & ./run CMD misc/tor/start-tor.sh & ./run

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

@ -2,55 +2,44 @@
[![Latest Release](https://img.shields.io/github/v/release/benbusby/whoogle-search)](https://github.com/benbusby/shoogle/releases) [![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) [![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) [![Build Status](https://travis-ci.com/benbusby/whoogle-search.svg?branch=master)](https://travis-ci.com/benbusby/whoogle-search)
[![buildx](https://github.com/benbusby/whoogle-search/actions/workflows/buildx.yml/badge.svg)](https://github.com/benbusby/whoogle-search/actions/workflows/buildx.yml) [![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) [![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) [![Docker Pulls](https://img.shields.io/docker/pulls/benbusby/whoogle-search)](https://hub.docker.com/r/benbusby/whoogle-search)
<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.
<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.
Contents Contents
1. [Features](#features) 1. [Features](#features)
3. [Install/Deploy Options](#install) 2. [Dependencies](#dependencies)
1. [Heroku Quick Deploy](#heroku-quick-deploy) 3. [Install/Deploy](#install)
1. [Render.com](#render) 1. [Heroku Quick Deploy](#a-heroku-quick-deploy)
1. [Repl.it](#replit) 2. [Repl.it](#b-replit)
1. [Fly.io](#flyio) 3. [Fly.io](#c-flyio)
1. [Koyeb](#koyeb) 4. [pipx](#d-pipx)
1. [pipx](#pipx) 5. [pip](#e-pip)
1. [pip](#pip) 6. [Manual](#f-manual)
1. [Manual](#manual) 7. [Docker](#g-manual-docker)
1. [Docker](#manual-docker) 8. [Arch/AUR](#arch-linux--arch-based-distributions)
1. [Arch/AUR](#arch-linux--arch-based-distributions)
1. [Helm/Kubernetes](#helm-chart-for-kubernetes)
4. [Environment Variables and Configuration](#environment-variables) 4. [Environment Variables and Configuration](#environment-variables)
5. [Usage](#usage) 5. [Usage](#usage)
6. [Extra Steps](#extra-steps) 6. [Extra Steps](#extra-steps)
1. [Set Primary Search Engine](#set-whoogle-as-your-primary-search-engine) 1. [Set Primary Search Engine](#set-whoogle-as-your-primary-search-engine)
2. [Custom Redirecting](#custom-redirecting) 2. [Prevent Downtime (Heroku Only)](#prevent-downtime-heroku-only)
2. [Custom Bangs](#custom-bangs) 3. [Manual HTTPS Enforcement](#https-enforcement)
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)
7. [Contributing](#contributing) 7. [Contributing](#contributing)
8. [FAQ](#faq) 8. [FAQ](#faq)
9. [Public Instances](#public-instances) 9. [Public Instances](#public-instances)
10. [Screenshots](#screenshots) 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 ## Features
- No ads or sponsored content - No ads or sponsored content
- No JavaScript\* - No javascript
- No cookies\*\* - No cookies
- No tracking/linking of your personal IP address\*\*\* - No tracking/linking of your personal IP address\*
- No AMP links - No AMP links
- No URL tracking tags (i.e. utm=%s) - No URL tracking tags (i.e. utm=%s)
- No referrer header - No referrer header
@ -58,49 +47,40 @@ Contents
- Autocomplete/search suggestions - Autocomplete/search suggestions
- POST request search and suggestion queries (when possible) - POST request search and suggestion queries (when possible)
- View images at full res without site redirect (currently mobile only) - 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)) - Dark mode
- Randomly generated User Agent - Randomly generated User Agent
- Easy to install/deploy - Easy to install/deploy
- DDG-style bang (i.e. `!<tag> <query>`) searches - DDG-style bang (i.e. `!<tag> <query>`) searches
- User-defined [custom bangs](#custom-bangs)
- Optional location-based searching (i.e. results near \<city\>) - Optional location-based searching (i.e. results near \<city\>)
- Optional NoJS mode to view search results in a separate window with JavaScript blocked - 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 ## Install
There are a few different ways to begin using the app, depending on your preferences: 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) *Note: Requires a (free) Heroku account*
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/benbusby/whoogle-search/tree/main)
Provides: Provides:
- Easy Deployment of App - Free deployment of app
- A HTTPS url (https://\<your app name\>.herokuapp.com) - 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) [![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* *Note: Requires a (free) Replit account*
@ -109,40 +89,35 @@ Provides:
- Free deployment of app - Free deployment of app
- Free HTTPS url (https://\<app name\>.\<username\>\.repl\.co) - Free HTTPS url (https://\<app name\>.\<username\>\.repl\.co)
- Supports custom domains - 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)\)
___ ### C) [Fly.io](https://fly.io)
### [Fly.io](https://fly.io) You will need a [Fly.io](https://fly.io) account to do this. Fly requires a credit card to deploy anything, but you can have up to 3 shared-CPU VMs running full-time each month for free.
You will need a [Fly.io](https://fly.io) account to deploy Whoogle. The [free allowances](https://fly.io/docs/about/pricing/#free-allowances) are enough for personal use. #### Install the CLI:
#### Install the CLI: https://fly.io/docs/hands-on/installing/ ```bash
curl -L https://fly.io/install.sh | sh
```
#### Deploy the app #### Deploy your app
```bash ```bash
flyctl auth login fly apps create --org personal --port 5000
flyctl launch --image benbusby/whoogle-search:latest # Choose a name and the Image builder
# Enter `benbusby/whoogle-search:latest` as the image name
fly deploy
``` ```
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`. Your app is now available at `https://<app-name>.fly.dev`.
___ You can customize the `fly.toml`:
- Remove the non-https service
### [Koyeb](https://www.koyeb.com) - Add environment variables under the `[env]` key
- Use `fly secrets set NAME=value` for more sensitive values like `WHOOGLE_PASS` and `WHOOGLE_PROXY_PASS`.
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
___ ### D) [pipx](https://github.com/pipxproject/pipx#install-pipx)
### [pipx](https://github.com/pipxproject/pipx#install-pipx)
Persistent install: Persistent install:
`pipx install git+https://github.com/benbusby/whoogle-search.git` `pipx install git+https://github.com/benbusby/whoogle-search.git`
@ -151,9 +126,7 @@ Sandboxed temporary instance:
`pipx run --spec git+https://github.com/benbusby/whoogle-search.git whoogle-search` `pipx run --spec git+https://github.com/benbusby/whoogle-search.git whoogle-search`
___ ### E) pip
### pip
`pip install whoogle-search` `pip install whoogle-search`
```bash ```bash
@ -180,20 +153,9 @@ optional arguments:
``` ```
See the [available environment variables](#environment-variables) for additional configuration. See the [available environment variables](#environment-variables) for additional configuration.
___ ### F) Manual
### 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 *Note: `Content-Security-Policy` headers are already sent by Whoogle -- you don't/shouldn't need to apply a CSP header yourself*
Clone the repo and run the following commands to start the app in a local-only environment: Clone the repo and run the following commands to start the app in a local-only environment:
@ -208,9 +170,9 @@ pip install -r requirements.txt
See the [available environment variables](#environment-variables) for additional configuration. See the [available environment variables](#environment-variables) for additional configuration.
#### systemd 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] [Unit]
Description=Whoogle Description=Whoogle
@ -225,29 +187,17 @@ Description=Whoogle
#Environment=WHOOGLE_PROXY_LOC=<proxy host/ip> #Environment=WHOOGLE_PROXY_LOC=<proxy host/ip>
# Site alternative configurations, uncomment to enable # Site alternative configurations, uncomment to enable
# Note: If not set, the feature will still be available # Note: If not set, the feature will still be available
# with default values. # with default values.
#Environment=WHOOGLE_ALT_TW=farside.link/nitter #Environment=WHOOGLE_ALT_TW=nitter.net
#Environment=WHOOGLE_ALT_YT=farside.link/invidious #Environment=WHOOGLE_ALT_YT=invidious.snopyta.org
#Environment=WHOOGLE_ALT_RD=farside.link/libreddit #Environment=WHOOGLE_ALT_IG=bibliogram.art/u
#Environment=WHOOGLE_ALT_MD=farside.link/scribe #Environment=WHOOGLE_ALT_RD=libredd.it
#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
# Load values from dotenv only # Load values from dotenv only
#Environment=WHOOGLE_DOTENV=1 #Environment=WHOOGLE_DOTENV=1
Type=simple Type=simple
User=<username> User=root
# If installed as a package, add: WorkingDirectory=<whoogle_directory>
ExecStart=<python_install_dir>/python3 <whoogle_install_dir>/whoogle-search --host 127.0.0.1 --port 5000 ExecStart=<whoogle_directory>/venv/bin/python3 -um app --host 0.0.0.0 --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>
ExecReload=/bin/kill -HUP $MAINPID ExecReload=/bin/kill -HUP $MAINPID
Restart=always Restart=always
RestartSec=3 RestartSec=3
@ -263,54 +213,7 @@ sudo systemctl enable whoogle
sudo systemctl start whoogle sudo systemctl start whoogle
``` ```
#### Tor Configuration *optional* ### G) Manual (Docker)
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)
1. Ensure the Docker daemon is running, and is accessible by your user account 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` - 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). - 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).
@ -318,6 +221,8 @@ ___
#### Docker CLI #### Docker CLI
***Note:** For ARM machines, use the `buildx-experimental` Docker tag.*
Through Docker Hub: Through Docker Hub:
```bash ```bash
docker pull benbusby/whoogle-search docker pull benbusby/whoogle-search
@ -371,22 +276,9 @@ 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#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. 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). 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 install whoogle ./charts/whoogle`
___
#### Using your own server, or alternative container deployment #### 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. 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.
@ -403,66 +295,41 @@ There are a few optional environment variables available for customizing a Whoog
- With `docker-compose`: Uncomment the `env_file` option - With `docker-compose`: Uncomment the `env_file` option
- With `docker build/run`: Add `--env-file ./whoogle.env` to your command - With `docker build/run`: Add `--env-file ./whoogle.env` to your command
| Variable | Description | | 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 | Load environment variables in `whoogle.env` | | WHOOGLE_USER | The username for basic auth. WHOOGLE_PASS must also be set if used. |
| 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_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_USER | The username of the proxy server. | | WHOOGLE_PROXY_PASS | The password 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_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_PROXY_LOC | The location of the proxy server (host or ip). | | EXPOSE_PORT | The port where Whoogle will be exposed. |
| WHOOGLE_USER_AGENT | The desktop user agent to use. Defaults to a randomly generated one. | | HTTPS_ONLY | Enforce HTTPS. (See [here](https://github.com/benbusby/whoogle-search#https-enforcement)) |
| WHOOGLE_USER_AGENT_MOBILE | The mobile user agent to use. Defaults to a randomly generated one. | | WHOOGLE_ALT_TW | The twitter.com alternative to use when site alternatives are enabled in the config. |
| WHOOGLE_USE_CLIENT_USER_AGENT | Enable to use your own user agent for all requests. Defaults to false. | | WHOOGLE_ALT_YT | The youtube.com alternative to use when site alternatives are enabled in the config. |
| WHOOGLE_REDIRECTS | Specify sites that should be redirected elsewhere. See [custom redirecting](#custom-redirecting). | | WHOOGLE_ALT_IG | The instagram.com alternative to use when site alternatives are enabled in the config. |
| EXPOSE_PORT | The port where Whoogle will be exposed. | | WHOOGLE_ALT_RD | The reddit.com alternative to use when site alternatives are enabled in the config. |
| 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_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_RESULTS_PER_PAGE | Set the number of results per page |
| 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. |
### Config Environment Variables ### 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. 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 | | Variable | Description |
| ------------------------------------ | --------------------------------------------------------------- | | ------------------------------ | --------------------------------------------------------------- |
| WHOOGLE_CONFIG_DISABLE | Hide config from UI and disallow changes to config by client | | WHOOGLE_CONFIG_DISABLE | Hide config from UI and disallow changes to config by client |
| WHOOGLE_CONFIG_COUNTRY | Filter results by hosting country | | WHOOGLE_CONFIG_COUNTRY | Filter results by hosting country |
| WHOOGLE_CONFIG_LANGUAGE | Set interface language | | WHOOGLE_CONFIG_LANGUAGE | Set interface language |
| WHOOGLE_CONFIG_SEARCH_LANGUAGE | Set search result language | | WHOOGLE_CONFIG_SEARCH_LANGUAGE | Set search result language |
| WHOOGLE_CONFIG_BLOCK | Block websites from search results (use comma-separated list) | | 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_DARK | Enable dark theme |
| WHOOGLE_CONFIG_BLOCK_URL | Block search result with a REGEX filter on URL | | WHOOGLE_CONFIG_SAFE | Enable safe searches |
| WHOOGLE_CONFIG_THEME | Set theme mode (light, dark, or system) | | WHOOGLE_CONFIG_ALTS | Use social media site alternatives (nitter, invidious, etc) |
| WHOOGLE_CONFIG_SAFE | Enable safe searches | | WHOOGLE_CONFIG_TOR | Use Tor routing (if available) |
| WHOOGLE_CONFIG_ALTS | Use social media site alternatives (nitter, invidious, etc) | | WHOOGLE_CONFIG_NEW_TAB | Always open results in new tab |
| WHOOGLE_CONFIG_NEAR | Restrict results to only those near a particular city | | WHOOGLE_CONFIG_VIEW_IMAGE | Enable View Image option |
| WHOOGLE_CONFIG_TOR | Use Tor routing (if available) | | WHOOGLE_CONFIG_GET_ONLY | Search using GET requests only |
| WHOOGLE_CONFIG_NEW_TAB | Always open results in new tab | | WHOOGLE_CONFIG_URL | The root url of the instance (`https://<your url>/`) |
| WHOOGLE_CONFIG_VIEW_IMAGE | Enable View Image option | | WHOOGLE_CONFIG_STYLE | The custom CSS to use for styling (should be single line) |
| 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 |
## Usage ## Usage
Same as most search engines, with the exception of filtering by time range. Same as most search engines, with the exception of filtering by time range.
@ -470,18 +337,12 @@ 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` 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`
## Extra Steps ## Extra Steps
### Set Whoogle as your primary search engine ### 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.* *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: Browser settings:
- Firefox (Desktop) - Firefox (Desktop)
- Version 89+ - 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.
- 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.
- Firefox (iOS) - 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: - 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" - Title: "Whoogle"
@ -511,44 +372,10 @@ Browser settings:
2. Go to `Default Results` and click the `Setup fallback results` button. Click `+` and add Whoogle, then drag it to the top. 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 - Chrome/Chromium-based Browsers
- Automatic - 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 - Manual
- Under search engines > manage search engines > add, manually enter your Whoogle instance details with a `<whoogle url>/search?q=%s` formatted search URL. - 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) ### 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. 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.
@ -569,40 +396,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 - 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 - 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_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/).
## Contributing ## Contributing
Under the hood, Whoogle is a basic Flask app with the following structure: Under the hood, Whoogle is a basic Flask app with the following structure:
@ -623,12 +416,12 @@ Under the hood, Whoogle is a basic Flask app with the following structure:
- `search.html`: An iframe-able search page - `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) - `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). - `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>` - `static/<css|js>`
- CSS/JavaScript files, should be self-explanatory - CSS/Javascript files, should be self-explanatory
- `static/settings` - `static/settings`
- Key-value JSON files for establishing valid configuration values - 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. 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.
@ -647,7 +440,7 @@ def contains(x: list, y: int) -> bool:
""" """
return y in x return y in x
``` ```
#### Translating #### Translating
@ -664,58 +457,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?** **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 ## 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.* *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.*
| Website | Country | Language | Cloudflare |
|-|-|-|-|
| [https://search.albony.xyz](https://search.albony.xyz/) | 🇮🇳 IN | Multi-choice | |
| [https://search.garudalinux.org](https://search.garudalinux.org) | 🇫🇮 FI | Multi-choice | ✅ |
| [https://search.dr460nf1r3.org](https://search.dr460nf1r3.org) | 🇩🇪 DE | Multi-choice | ✅ |
| [https://s.tokhmi.xyz](https://s.tokhmi.xyz) | 🇺🇸 US | Multi-choice | ✅ |
| [https://search.sethforprivacy.com](https://search.sethforprivacy.com) | 🇩🇪 DE | English | |
| [https://whoogle.dcs0.hu](https://whoogle.dcs0.hu) | 🇭🇺 HU | Multi-choice | |
| [https://gowogle.voring.me](https://gowogle.voring.me) | 🇺🇸 US | Multi-choice | |
| [https://whoogle.privacydev.net](https://whoogle.privacydev.net) | 🇫🇷 FR | English | |
| [https://wg.vern.cc](https://wg.vern.cc) | 🇺🇸 US | English | |
| [https://whoogle.hxvy0.gq](https://whoogle.hxvy0.gq) | 🇨🇦 CA | Turkish Only | ✅ |
| [https://whoogle.hostux.net](https://whoogle.hostux.net) | 🇫🇷 FR | Multi-choice | |
| [https://whoogle.lunar.icu](https://whoogle.lunar.icu) | 🇩🇪 DE | Multi-choice | ✅ |
| [https://wgl.frail.duckdns.org](https://wgl.frail.duckdns.org) | 🇧🇷 BR | Multi-choice | |
| [https://whoogle.no-logs.com](https://whoogle.no-logs.com/) | 🇸🇪 SE | Multi-choice | |
| [https://whoogle.ftw.lol](https://whoogle.ftw.lol) | 🇩🇪 DE | Multi-choice | |
| [https://whoogle-search--replitcomreside.repl.co](https://whoogle-search--replitcomreside.repl.co) | 🇺🇸 US | English | |
| [https://search.notrustverify.ch](https://search.notrustverify.ch) | 🇨🇭 CH | Multi-choice | |
| [https://whoogle.datura.network](https://whoogle.datura.network) | 🇩🇪 DE | Multi-choice | |
| [https://whoogle.yepserver.xyz](https://whoogle.yepserver.xyz) | 🇺🇦 UA | Multi-choice | |
| [https://search.nezumi.party](https://search.nezumi.party) | 🇮🇹 IT | Multi-choice | |
| [https://search.snine.nl](https://search.snine.nl) | 🇳🇱 NL | Mult-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 |
|-|-|-|
| [http://whoglqjdkgt2an4tdepberwqz3hk7tjo4kqgdnuj77rt7nshw2xqhqad.onion](http://whoglqjdkgt2an4tdepberwqz3hk7tjo4kqgdnuj77rt7nshw2xqhqad.onion) | 🇺🇸 US | Multi-choice
| [http://nuifgsnbb2mcyza74o7illtqmuaqbwu4flam3cdmsrnudwcmkqur37qd.onion](http://nuifgsnbb2mcyza74o7illtqmuaqbwu4flam3cdmsrnudwcmkqur37qd.onion) | 🇩🇪 DE | English
| [http://whoogle.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://whoogle.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion/) | 🇺🇸 US | English |
| [http://whoogle.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion](http://whoogle.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion/) | 🇫🇷 FR | English |
| [http://whoogle.daturab6drmkhyeia4ch5gvfc2f3wgo6bhjrv3pz6n7kxmvoznlkq4yd.onion](http://whoogle.daturab6drmkhyeia4ch5gvfc2f3wgo6bhjrv3pz6n7kxmvoznlkq4yd.onion/) | 🇩🇪 DE | Multi-choice | |
#### I2P Instances
| Website | Country | Language |
|-|-|-|
| [http://verneks7rfjptpz5fpii7n7nrxilsidi2qxepeuuf66c3tsf4nhq.b32.i2p](http://verneks7rfjptpz5fpii7n7nrxilsidi2qxepeuuf66c3tsf4nhq.b32.i2p) | 🇺🇸 US | English |
- [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 ## Screenshots
#### Desktop #### Desktop
![Whoogle Desktop](docs/screenshot_desktop.png) ![Whoogle Desktop](docs/screenshot_desktop.jpg)
#### Mobile #### Mobile
![Whoogle Mobile](docs/screenshot_mobile.png) ![Whoogle Mobile](docs/screenshot_mobile.jpg)

@ -15,11 +15,6 @@
], ],
"stack": "container", "stack": "container",
"env": { "env": {
"WHOOGLE_URL_PREFIX": {
"description": "The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")",
"value": "",
"required": false
},
"WHOOGLE_USER": { "WHOOGLE_USER": {
"description": "The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.", "description": "The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.",
"value": "", "value": "",
@ -52,64 +47,29 @@
}, },
"WHOOGLE_ALT_TW": { "WHOOGLE_ALT_TW": {
"description": "The site to use as a replacement for twitter.com when site alternatives are enabled in the config.", "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 "required": false
}, },
"WHOOGLE_ALT_YT": { "WHOOGLE_ALT_YT": {
"description": "The site to use as a replacement for youtube.com when site alternatives are enabled in the config.", "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_RD": {
"description": "The site to use as a replacement for reddit.com when site alternatives are enabled in the config.",
"value": "farside.link/libreddit",
"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 "required": false
}, },
"WHOOGLE_ALT_IMDB": { "WHOOGLE_ALT_IG": {
"description": "The site to use as a replacement for imdb.com when site alternatives are enabled in the config.", "description": "The site to use as a replacement for instagram.com when site alternatives are enabled in the config.",
"value": "farside.link/libremdb", "value": "bibliogram.art/u",
"required": false "required": false
}, },
"WHOOGLE_ALT_QUORA": { "WHOOGLE_ALT_RD": {
"description": "The site to use as a replacement for quora.com when site alternatives are enabled in the config.", "description": "The site to use as a replacement for reddit.com when site alternatives are enabled in the config.",
"value": "farside.link/quetre", "value": "libredd.it",
"required": false "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": { "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)", "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": "", "value": "",
"required": false "required": false
}, },
"WHOOGLE_CONFIG_TIME_PERIOD" : {
"description": "[CONFIG] The time period to use for restricting search results",
"value": "",
"required": false
},
"WHOOGLE_CONFIG_LANGUAGE": { "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)", "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": "", "value": "",
@ -130,9 +90,9 @@
"value": "", "value": "",
"required": false "required": false
}, },
"WHOOGLE_CONFIG_THEME": { "WHOOGLE_CONFIG_DARK": {
"description": "[CONFIG] Set theme to 'dark', 'light', or 'system'", "description": "[CONFIG] Enable dark mode (set to 1 or leave blank)",
"value": "system", "value": "",
"required": false "required": false
}, },
"WHOOGLE_CONFIG_SAFE": { "WHOOGLE_CONFIG_SAFE": {
@ -145,11 +105,6 @@
"value": "", "value": "",
"required": false "required": false
}, },
"WHOOGLE_CONFIG_NEAR": {
"description": "[CONFIG] Restrict results to only those near a particular city",
"value": "",
"required": false
},
"WHOOGLE_CONFIG_TOR": { "WHOOGLE_CONFIG_TOR": {
"description": "[CONFIG] Use Tor, if available (set to 1 or leave blank)", "description": "[CONFIG] Use Tor, if available (set to 1 or leave blank)",
"value": "", "value": "",
@ -172,18 +127,8 @@
}, },
"WHOOGLE_CONFIG_STYLE": { "WHOOGLE_CONFIG_STYLE": {
"description": "[CONFIG] Custom CSS styling (paste in CSS or leave blank)", "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": "", "value": "",
"required": false "required": false
},
"WHOOGLE_CONFIG_PREFERENCES_KEY": {
"description": "[CONFIG] Key to encrypt preferences",
"value": "NEEDS_TO_BE_MODIFIED",
"required": false
} }
} }
} }

@ -1,196 +1,88 @@
from app.filter import clean_query from app.filter import clean_query
from app.request import send_tor_signal from app.request import send_tor_signal
from app.utils.session import generate_key from app.utils.session import generate_user_key
from app.utils.bangs import gen_bangs_json, load_all_bangs from app.utils.bangs import gen_bangs_json
from app.utils.misc import gen_file_hash, read_config_bool
from base64 import b64encode
from bs4 import MarkupResemblesLocatorWarning
from datetime import datetime, timedelta
from dotenv import load_dotenv
from flask import Flask from flask import Flask
from flask_session import Session
import json import json
import logging.config import logging.config
import os import os
from stem import Signal from stem import Signal
import threading from dotenv import load_dotenv
import warnings
from werkzeug.middleware.proxy_fix import ProxyFix
from app.utils.misc import read_config_bool
from app.version import __version__
app = Flask(__name__, static_folder=os.path.dirname( app = Flask(__name__, static_folder=os.path.dirname(
os.path.abspath(__file__)) + '/static') os.path.abspath(__file__)) + '/static')
app.wsgi_app = ProxyFix(app.wsgi_app)
dot_env_path = (
os.path.join(os.path.dirname(os.path.abspath(__file__)),
'../whoogle.env'))
# Load .env file if enabled # Load .env file if enabled
if os.path.exists(dot_env_path): if os.getenv("WHOOGLE_DOTENV", ''):
load_dotenv(dot_env_path) dotenv_path = '../whoogle.env'
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)),
app.enc_key = generate_key() dotenv_path))
if read_config_bool('HTTPS_ONLY'): app.default_key = generate_user_key()
app.config['SESSION_COOKIE_NAME'] = '__Secure-session' app.no_cookie_ips = []
app.config['SESSION_COOKIE_SECURE'] = True app.config['SECRET_KEY'] = os.urandom(32)
app.config['SESSION_TYPE'] = 'filesystem'
app.config['VERSION_NUMBER'] = __version__ app.config['VERSION_NUMBER'] = '0.5.4'
app.config['APP_ROOT'] = os.getenv( app.config['APP_ROOT'] = os.getenv(
'APP_ROOT', 'APP_ROOT',
os.path.dirname(os.path.abspath(__file__))) os.path.dirname(os.path.abspath(__file__)))
app.config['STATIC_FOLDER'] = os.getenv( app.config['STATIC_FOLDER'] = os.getenv(
'STATIC_FOLDER', 'STATIC_FOLDER',
os.path.join(app.config['APP_ROOT'], 'static')) 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['LANGUAGES'] = json.load(open( app.config['LANGUAGES'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json'), os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json')))
encoding='utf-8'))
app.config['COUNTRIES'] = json.load(open( app.config['COUNTRIES'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json'), os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json')))
encoding='utf-8'))
app.config['TIME_PERIODS'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/time_periods.json'),
encoding='utf-8'))
app.config['TRANSLATIONS'] = json.load(open( app.config['TRANSLATIONS'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json'), os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json')))
encoding='utf-8'))
app.config['THEMES'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/themes.json'),
encoding='utf-8'))
app.config['HEADER_TABS'] = json.load(open(
os.path.join(app.config['STATIC_FOLDER'], 'settings/header_tabs.json'),
encoding='utf-8'))
app.config['CONFIG_PATH'] = os.getenv( app.config['CONFIG_PATH'] = os.getenv(
'CONFIG_VOLUME', 'CONFIG_VOLUME',
os.path.join(app.config['STATIC_FOLDER'], 'config')) os.path.join(app.config['STATIC_FOLDER'], 'config'))
app.config['DEFAULT_CONFIG'] = os.path.join( app.config['DEFAULT_CONFIG'] = os.path.join(
app.config['CONFIG_PATH'], app.config['CONFIG_PATH'],
'config.json') '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['SESSION_FILE_DIR'] = os.path.join(
app.config['CONFIG_PATH'], app.config['CONFIG_PATH'],
'session') 'session')
app.config['MAX_SESSION_SIZE'] = 4000 # Sessions won't exceed 4KB
app.config['BANG_PATH'] = os.getenv( app.config['BANG_PATH'] = os.getenv(
'CONFIG_VOLUME', 'CONFIG_VOLUME',
os.path.join(app.config['STATIC_FOLDER'], 'bangs')) os.path.join(app.config['STATIC_FOLDER'], 'bangs'))
app.config['BANG_FILE'] = os.path.join( app.config['BANG_FILE'] = os.path.join(
app.config['BANG_PATH'], app.config['BANG_PATH'],
'bangs.json') 'bangs.json')
# 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'])
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'])
# Session values
app_key_path = os.path.join(app.config['CONFIG_PATH'], 'whoogle.key')
if os.path.exists(app_key_path):
try:
app.config['SECRET_KEY'] = open(app_key_path, 'r').read()
except PermissionError:
app.config['SECRET_KEY'] = str(b64encode(os.urandom(32)))
else:
app.config['SECRET_KEY'] = str(b64encode(os.urandom(32)))
with open(app_key_path, 'w') as key_file:
key_file.write(app.config['SECRET_KEY'])
key_file.close()
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\';' \ app.config['CSP'] = 'default-src \'none\';' \
'frame-src ' + translate_url + ';' \
'manifest-src \'self\';' \ 'manifest-src \'self\';' \
'img-src \'self\' data:;' \ 'img-src \'self\' data:;' \
'style-src \'self\' \'unsafe-inline\';' \ 'style-src \'self\' \'unsafe-inline\';' \
'script-src \'self\';' \ 'script-src \'self\';' \
'media-src \'self\';' \ 'media-src \'self\';' \
'connect-src \'self\';' 'connect-src \'self\';' \
'form-action \'self\';'
# Generate DDG bang filter # Templating functions
generating_bangs = False app.jinja_env.globals.update(clean_query=clean_query)
if not os.path.exists(app.config['BANG_FILE']):
generating_bangs = True
json.dump({}, open(app.config['BANG_FILE'], 'w'))
bangs_thread = threading.Thread(
target=gen_bangs_json,
args=(app.config['BANG_FILE'],))
bangs_thread.start()
# Build new mapping of static files for cache busting if not os.path.exists(app.config['CONFIG_PATH']):
cache_busting_dirs = ['css', 'js'] os.makedirs(app.config['CONFIG_PATH'])
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: if not os.path.exists(app.config['SESSION_FILE_DIR']):
os.symlink(full_cb_path, build_path) os.makedirs(app.config['SESSION_FILE_DIR'])
except FileExistsError:
# Symlink hasn't changed, ignore
pass
# Create mapping for relative path urls # Generate DDG bang filter, and create path if it doesn't exist yet
map_path = build_path.replace(app.config['APP_ROOT'], '') if not os.path.exists(app.config['BANG_PATH']):
if map_path.startswith('/'): os.makedirs(app.config['BANG_PATH'])
map_path = map_path[1:] if not os.path.exists(app.config['BANG_FILE']):
app.config['CACHE_BUSTING_MAP'][cb_file] = map_path gen_bangs_json(app.config['BANG_FILE'])
# Templating functions Session(app)
app.jinja_env.globals.update(clean_query=clean_query)
app.jinja_env.globals.update(
cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f])
# Attempt to acquire tor identity, to determine if Tor config is available # Attempt to acquire tor identity, to determine if Tor config is available
send_tor_signal(Signal.HEARTBEAT) send_tor_signal(Signal.HEARTBEAT)
# Suppress spurious warnings from BeautifulSoup
warnings.simplefilter('ignore', MarkupResemblesLocatorWarning)
from app import routes # noqa 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 # Disable logging from imported modules
logging.config.dictConfig({ logging.config.dictConfig({
'version': 1, 'version': 1,

@ -1,39 +1,12 @@
import cssutils from app.request import VALID_PARAMS, MAPS_URL
from app.utils.results import *
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from bs4.element import ResultSet, Tag from bs4.element import ResultSet, Tag
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from flask import render_template from flask import render_template
import html import re
import urllib.parse as urlparse import urllib.parse as urlparse
from urllib.parse import parse_qs from urllib.parse import parse_qs
import re
from app.models.g_classes import GClasses
from app.request import VALID_PARAMS, MAPS_URL
from app.utils.misc import get_abs_url, read_config_bool
from app.utils.results import (
BLANK_B64, GOOG_IMG, GOOG_STATIC, G_M_LOGO_URL, LOGO_URL, SITE_ALTS,
has_ad_content, filter_link_args, append_anon_view, get_site_alt,
)
from app.models.endpoint import Endpoint
from app.models.config import Config
MAPS_ARGS = ['q', 'daddr']
minimal_mode_sections = ['Top stories', 'Images', 'People also ask']
unsupported_g_pages = [
'support.google.com',
'accounts.google.com',
'policies.google.com',
'google.com/preferences',
'google.com/intl',
'advanced_search',
'tbm=shop',
'ageverification.google.co.kr'
]
unsupported_g_divs = ['google.com/preferences?hl=', 'ageverification.google.co.kr']
def extract_q(q_str: str, href: str) -> str: def extract_q(q_str: str, href: str) -> str:
@ -48,29 +21,7 @@ def extract_q(q_str: str, href: str) -> str:
Returns: Returns:
str: The 'q' element of the link, or an empty string str: The 'q' element of the link, or an empty string
""" """
return parse_qs(q_str, keep_blank_values=True)['q'][0] if ('&q=' in href or '?q=' in href) else '' return parse_qs(q_str)['q'][0] if ('&q=' in href or '?q=' in href) else ''
def build_map_url(href: str) -> str:
"""Tries to extract known args that explain the location in the url. If a
location is found, returns the default url with it. Otherwise, returns the
url unchanged.
Args:
href: The full url to check.
Returns:
str: The parsed url, or the url unchanged.
"""
# parse the url
parsed_url = parse_qs(href)
# iterate through the known parameters and try build the url
for param in MAPS_ARGS:
if param in parsed_url:
return MAPS_URL + "?q=" + parsed_url[param][0]
# query could not be extracted returning unchanged url
return href
def clean_query(query: str) -> str: def clean_query(query: str) -> str:
@ -86,54 +37,20 @@ def clean_query(query: str) -> str:
return query[:query.find('-site:')] if '-site:' in query else query return query[:query.find('-site:')] if '-site:' in query else query
def clean_css(css: str, page_url: str) -> str:
"""Removes all remote URLs from a CSS string.
Args:
css: The CSS string
Returns:
str: The filtered CSS, with URLs proxied through Whoogle
"""
sheet = cssutils.parseString(css)
urls = cssutils.getUrls(sheet)
for url in urls:
abs_url = get_abs_url(url, page_url)
if abs_url.startswith('data:'):
continue
css = css.replace(
url,
f'{Endpoint.element}?type=image/png&url={abs_url}'
)
return css
class Filter: class Filter:
# Limit used for determining if a result is a "regular" result or a list def __init__(self, user_key: str, mobile=False, config=None) -> None:
# type result (such as "people also asked", "related searches", etc) if config is None:
RESULT_CHILD_LIMIT = 7 config = {}
def __init__( self.near = config['near'] if 'near' in config else ''
self, self.dark = config['dark'] if 'dark' in config else False
user_key: str, self.nojs = config['nojs'] if 'nojs' in config else False
config: Config, self.new_tab = config['new_tab'] if 'new_tab' in config else False
root_url='', self.alt_redirect = config['alts'] if 'alts' in config else False
page_url='',
query='',
mobile=False) -> None:
self.soup = None
self.config = config
self.mobile = mobile self.mobile = mobile
self.user_key = user_key self.user_key = user_key
self.page_url = page_url
self.query = query
self.main_divs = ResultSet('') self.main_divs = ResultSet('')
self._elements = 0 self._elements = 0
self._av = set()
self.root_url = root_url[:-1] if root_url.endswith('/') else root_url
def __getitem__(self, name): def __getitem__(self, name):
return getattr(self, name) return getattr(self, name)
@ -142,6 +59,16 @@ class Filter:
def elements(self): def elements(self):
return self._elements return self._elements
def reskin(self, page: str) -> str:
# Aesthetic only re-skinning
if self.dark:
page = page.replace(
'fff', '000').replace(
'202124', 'ddd').replace(
'1967D2', '3b85ea')
return page
def encrypt_path(self, path, is_element=False) -> str: def encrypt_path(self, path, is_element=False) -> str:
# Encrypts path to avoid plaintext results in logs # Encrypts path to avoid plaintext results in logs
if is_element: if is_element:
@ -154,152 +81,40 @@ class Filter:
return Fernet(self.user_key).encrypt(path.encode()).decode() return Fernet(self.user_key).encrypt(path.encode()).decode()
def clean(self, soup) -> BeautifulSoup: def clean(self, soup) -> BeautifulSoup:
self.soup = soup self.main_divs = soup.find('div', {'id': 'main'})
self.main_divs = self.soup.find('div', {'id': 'main'})
self.remove_ads() self.remove_ads()
self.remove_block_titles() self.fix_question_section()
self.remove_block_url() self.update_styling(soup)
self.collapse_sections()
self.update_css() for img in [_ for _ in soup.find_all('img') if 'src' in _.attrs]:
self.update_styling()
self.remove_block_tabs()
# self.main_divs is only populated for the main page of search results
# (i.e. not images/news/etc).
if self.main_divs:
for div in self.main_divs:
self.sanitize_div(div)
for img in [_ for _ in self.soup.find_all('img') if 'src' in _.attrs]:
self.update_element_src(img, 'image/png') self.update_element_src(img, 'image/png')
for audio in [_ for _ in self.soup.find_all('audio') if 'src' in _.attrs]: for audio in [_ for _ in soup.find_all('audio') if 'src' in _.attrs]:
self.update_element_src(audio, 'audio/mpeg') self.update_element_src(audio, 'audio/mpeg')
audio['controls'] = ''
for link in self.soup.find_all('a', href=True): for link in soup.find_all('a', href=True):
self.update_link(link) self.update_link(link)
self.add_favicon(link)
if self.config.alts:
self.site_alt_swap()
input_form = self.soup.find('form') input_form = soup.find('form')
if input_form is not None: if input_form is not None:
input_form['method'] = 'GET' if self.config.get_only else 'POST' input_form['method'] = 'POST'
# Use a relative URI for submissions
input_form['action'] = 'search'
# Ensure no extra scripts passed through # Ensure no extra scripts passed through
for script in self.soup('script'): for script in soup('script'):
script.decompose() script.decompose()
# Update default footer and header # Update default footer and header
footer = self.soup.find('footer') footer = soup.find('footer')
if footer: if footer:
# Remove divs that have multiple links beyond just page navigation # Remove divs that have multiple links beyond just page navigation
[_.decompose() for _ in footer.find_all('div', recursive=False) [_.decompose() for _ in footer.find_all('div', recursive=False)
if len(_.find_all('a', href=True)) > 3] if len(_.find_all('a', href=True)) > 3]
for link in footer.find_all('a', href=True):
link['href'] = f'{link["href"]}&preferences={self.config.preferences}'
header = self.soup.find('header') header = soup.find('header')
if header: if header:
header.decompose() header.decompose()
self.remove_site_blocks(self.soup)
return self.soup
def sanitize_div(self, div) -> None:
"""Removes escaped script and iframe tags from results
Returns:
None (The soup object is modified directly)
"""
if not div:
return
for d in div.find_all('div', recursive=True):
d_text = d.find(text=True, recursive=False)
# Ensure we're working with tags that contain text content
if not d_text or not d.string:
continue
d.string = html.unescape(d_text)
div_soup = BeautifulSoup(d.string, 'html.parser')
# Remove all valid script or iframe tags in the div
for script in div_soup.find_all('script'):
script.decompose()
for iframe in div_soup.find_all('iframe'):
iframe.decompose()
d.string = str(div_soup)
def add_favicon(self, link) -> None:
"""Adds icons for each returned result, using the result site's favicon
Returns:
None (The soup object is modified directly)
"""
# Skip empty, parentless, or internal links
show_favicons = read_config_bool('WHOOGLE_SHOW_FAVICONS', True)
is_valid_link = link and link.parent and link['href'].startswith('http')
if not show_favicons or not is_valid_link:
return
parent = link.parent
is_result_div = False
# Check each parent to make sure that the div doesn't already have a
# favicon attached, and that the div is a result div
while parent:
p_cls = parent.attrs.get('class') or []
if 'has-favicon' in p_cls or GClasses.scroller_class in p_cls:
return
elif GClasses.result_class_a not in p_cls:
parent = parent.parent
else:
is_result_div = True
break
if not is_result_div:
return
# Construct the html for inserting the icon into the parent div
parsed = urlparse.urlparse(link['href'])
favicon = self.encrypt_path(
f'{parsed.scheme}://{parsed.netloc}/favicon.ico',
is_element=True)
src = f'{self.root_url}/{Endpoint.element}?url={favicon}' + \
'&type=image/x-icon'
html = f'<img class="site-favicon" src="{src}">'
favicon = BeautifulSoup(html, 'html.parser')
link.parent.insert(0, favicon)
# Update all parents to indicate that a favicon has been attached
parent = link.parent
while parent:
p_cls = parent.get('class') or []
p_cls.append('has-favicon')
parent['class'] = p_cls
parent = parent.parent
if GClasses.result_class_a in p_cls:
break
def remove_site_blocks(self, soup) -> None:
if not self.config.block or not soup.body:
return
search_string = ' '.join(['-site:' +
_ for _ in self.config.block.split(',')])
selected = soup.body.findAll(text=re.compile(search_string))
for result in selected: return soup
result.string.replace_with(result.string.replace(
search_string, ''))
def remove_ads(self) -> None: def remove_ads(self) -> None:
"""Removes ads found in the list of search result divs """Removes ads found in the list of search result divs
@ -315,124 +130,43 @@ class Filter:
if has_ad_content(_.text)] if has_ad_content(_.text)]
_ = div.decompose() if len(div_ads) else None _ = div.decompose() if len(div_ads) else None
def remove_block_titles(self) -> None: def fix_question_section(self) -> None:
if not self.main_divs or not self.config.block_title: """Collapses the "People Also Asked" section into a "details" element
return
block_title = re.compile(self.config.block_title)
for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]:
block_divs = [_ for _ in div.find_all('h3', recursive=True)
if block_title.search(_.text) is not None]
_ = div.decompose() if len(block_divs) else None
def remove_block_url(self) -> None:
if not self.main_divs or not self.config.block_url:
return
block_url = re.compile(self.config.block_url)
for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]:
block_divs = [_ for _ in div.find_all('a', recursive=True)
if block_url.search(_.attrs['href']) is not None]
_ = div.decompose() if len(block_divs) else None
def remove_block_tabs(self) -> None:
if self.main_divs:
for div in self.main_divs.find_all(
'div',
attrs={'class': f'{GClasses.main_tbm_tab}'}
):
_ = div.decompose()
else:
# when in images tab
for div in self.soup.find_all(
'div',
attrs={'class': f'{GClasses.images_tbm_tab}'}
):
_ = div.decompose()
def collapse_sections(self) -> None:
"""Collapses long result sections ("people also asked", "related
searches", etc) into "details" elements
These sections are typically the only sections in the results page that These sections are typically the only sections in the results page that
have more than ~5 child divs within a primary result div. are structured as <div><h2>Title</h2><div>...</div></div>, so they are
extracted by checking all result divs for h2 children.
Returns: Returns:
None (The soup object is modified directly) None (The soup object is modified directly)
""" """
minimal_mode = read_config_bool('WHOOGLE_MINIMAL') if not self.main_divs:
return
def pull_child_divs(result_div: BeautifulSoup): question_divs = [_ for _ in self.main_divs.find_all(
try: 'div', recursive=False
return result_div.findChildren( ) if len(_.find_all('h2')) > 0]
'div', recursive=False
)[0].findChildren(
'div', recursive=False)
except IndexError:
return []
if not self.main_divs: if len(question_divs) == 0:
return return
# Loop through results and check for the number of child divs in each # Wrap section in details element to allow collapse/expand
for result in self.main_divs.find_all(): details = BeautifulSoup(features='html.parser').new_tag('details')
result_children = pull_child_divs(result) summary = BeautifulSoup(features='html.parser').new_tag('summary')
if minimal_mode: summary.string = question_divs[0].find('h2').text
if any(f">{x}</span" in str(s) for s in result_children question_divs[0].find('h2').decompose()
for x in minimal_mode_sections): details.append(summary)
result.decompose() question_divs[0].wrap(details)
continue
for s in result_children: for question_div in question_divs:
if ('Twitter ' in str(s)): questions = [_ for _ in question_div.find_all(
result.decompose() 'div', recursive=True
continue ) if _.text.endswith('?')]
if len(result_children) < self.RESULT_CHILD_LIMIT:
continue
else:
if len(result_children) < self.RESULT_CHILD_LIMIT:
continue
# Find and decompose the first element with an inner HTML text val. for question in questions:
# This typically extracts the title of the section (i.e. "Related question['style'] = 'padding: 10px; font-style: italic;'
# Searches", "People also ask", etc)
# If there are more than one child tags with text def update_element_src(self, element: Tag, mime: str) -> None:
# parenthesize the rest except the first
label = 'Collapsed Results'
subtitle = None
for elem in result_children:
if elem.text:
content = list(elem.strings)
label = content[0]
if len(content) > 1:
subtitle = '<span> (' + \
''.join(content[1:]) + ')</span>'
elem.decompose()
break
# Create the new details element to wrap around the result's
# first parent
parent = None
idx = 0
while not parent and idx < len(result_children):
parent = result_children[idx].parent
idx += 1
details = BeautifulSoup(features='html.parser').new_tag('details')
summary = BeautifulSoup(features='html.parser').new_tag('summary')
summary.string = label
if subtitle:
soup = BeautifulSoup(subtitle, 'html.parser')
summary.append(soup)
details.append(summary)
if parent and not minimal_mode:
parent.wrap(details)
elif parent and minimal_mode:
# Remove parent element from document if "minimal mode" is
# enabled
parent.decompose()
def update_element_src(self, element: Tag, mime: str, attr='src') -> None:
"""Encrypts the original src of an element and rewrites the element src """Encrypts the original src of an element and rewrites the element src
to use the "/element?src=" pass-through. to use the "/element?src=" pass-through.
@ -440,66 +174,36 @@ class Filter:
None (The soup element is modified directly) None (The soup element is modified directly)
""" """
src = element[attr].split(' ')[0] src = element['src']
if src.startswith('//'): if src.startswith('//'):
src = 'https:' + src src = 'https:' + src
elif src.startswith('data:'):
return
if src.startswith(LOGO_URL): if src.startswith(LOGO_URL):
# Re-brand with Whoogle logo # Re-brand with Whoogle logo
element.replace_with(BeautifulSoup( element.replace_with(BeautifulSoup(
render_template('logo.html'), render_template('logo.html', dark=self.dark),
features='html.parser')) features='html.parser'))
return return
elif src.startswith(G_M_LOGO_URL):
# Re-brand with single-letter Whoogle logo
element['src'] = 'static/img/favicon/apple-icon.png'
element.parent['href'] = 'home'
return
elif src.startswith(GOOG_IMG) or GOOG_STATIC in src: elif src.startswith(GOOG_IMG) or GOOG_STATIC in src:
element['src'] = BLANK_B64 element['src'] = BLANK_B64
return return
element[attr] = f'{self.root_url}/{Endpoint.element}?url=' + ( element['src'] = 'element?url=' + self.encrypt_path(
self.encrypt_path( src,
src, is_element=True) + '&type=' + urlparse.quote(mime)
is_element=True
) + '&type=' + urlparse.quote(mime)
)
def update_css(self) -> None:
"""Updates URLs used in inline styles to be proxied by Whoogle
using the /element endpoint.
Returns:
None (The soup element is modified directly)
"""
# Filter all <style> tags
for style in self.soup.find_all('style'):
style.string = clean_css(style.string, self.page_url)
# TODO: Convert remote stylesheets to style tags and proxy all
# remote requests
# for link in soup.find_all('link', attrs={'rel': 'stylesheet'}):
# print(link)
def update_styling(self) -> None:
# Update CSS classes for result divs
soup = GClasses.replace_css_classes(self.soup)
def update_styling(self, soup) -> None:
# Remove unnecessary button(s) # Remove unnecessary button(s)
for button in self.soup.find_all('button'): for button in soup.find_all('button'):
button.decompose() button.decompose()
# Remove svg logos # Remove svg logos
for svg in self.soup.find_all('svg'): for svg in soup.find_all('svg'):
svg.decompose() svg.decompose()
# Update logo # Update logo
logo = self.soup.find('a', {'class': 'l'}) logo = soup.find('a', {'class': 'l'})
if logo and self.mobile: if logo and self.mobile:
logo['style'] = ('display:flex; justify-content:center; ' logo['style'] = ('display:flex; justify-content:center; '
'align-items:center; color:#685e79; ' 'align-items:center; color:#685e79; '
@ -507,32 +211,11 @@ class Filter:
# Fix search bar length on mobile # Fix search bar length on mobile
try: try:
search_bar = self.soup.find('header').find('form').find('div') search_bar = soup.find('header').find('form').find('div')
search_bar['style'] = 'width: 100%;' search_bar['style'] = 'width: 100%;'
except AttributeError: except AttributeError:
pass pass
# Fix body max width on images tab
style = self.soup.find('style')
div = self.soup.find('div', attrs={
'class': f'{GClasses.images_tbm_tab}'})
if style and div and not self.mobile:
css = style.string
css_html_tag = (
'html{'
'font-family: Roboto, Helvetica Neue, Arial, sans-serif;'
'font-size: 14px;'
'line-height: 20px;'
'text-size-adjust: 100%;'
'word-wrap: break-word;'
'}'
)
css = f"{css_html_tag}{css}"
css = re.sub('body{(.*?)}',
'body{padding:0 8px;margin:0 auto;max-width:736px;}',
css)
style.string = css
def update_link(self, link: Tag) -> None: def update_link(self, link: Tag) -> None:
"""Update internal link paths with encrypted path, otherwise remove """Update internal link paths with encrypted path, otherwise remove
unnecessary redirects and/or marketing params from the url unnecessary redirects and/or marketing params from the url
@ -544,58 +227,23 @@ class Filter:
None (the tag is updated directly) None (the tag is updated directly)
""" """
parsed_link = urlparse.urlparse(link['href']) # Replace href with only the intended destination (no "utm" type tags)
if '/url?q=' in link['href']: href = link['href'].replace('https://www.google.com', '')
link_netloc = extract_q(parsed_link.query, link['href']) if 'advanced_search' in href or 'tbm=shop' in href:
else:
link_netloc = parsed_link.netloc
# Remove any elements that direct to unsupported Google pages
if any(url in link_netloc for url in unsupported_g_pages):
# FIXME: The "Shopping" tab requires further filtering (see #136) # FIXME: The "Shopping" tab requires further filtering (see #136)
# Temporarily removing all links to that tab for now. # Temporarily removing all links to that tab for now.
link.decompose()
return
elif self.new_tab:
link['target'] = '_blank'
# Replaces the /url google unsupported link to the direct url
link['href'] = link_netloc
parent = link.parent
if any(divlink in link_netloc for divlink in unsupported_g_divs):
# Handle case where a search is performed in a different
# language than what is configured. This usually returns a
# div with the same classes as normal search results, but with
# a link to configure language preferences through Google.
# Since we want all language config done through Whoogle, we
# can safely decompose this element.
while parent:
p_cls = parent.attrs.get('class') or []
if f'{GClasses.result_class_a}' in p_cls:
parent.decompose()
break
parent = parent.parent
else:
# Remove cases where google links appear in the footer
while parent:
p_cls = parent.attrs.get('class') or []
if parent.name == 'footer' or f'{GClasses.footer}' in p_cls:
link.decompose()
parent = parent.parent
if link.decomposed:
return
# Replace href with only the intended destination (no "utm" type tags)
href = link['href'].replace('https://www.google.com', '')
result_link = urlparse.urlparse(href) result_link = urlparse.urlparse(href)
q = extract_q(result_link.query, href) q = extract_q(result_link.query, href)
if q.startswith('/') and q not in self.query and 'spell=1' not in href: if q.startswith('/'):
# Internal google links (i.e. mail, maps, etc) should still # Internal google links (i.e. mail, maps, etc) should still
# be forwarded to Google # be forwarded to Google
link['href'] = 'https://google.com' + q link['href'] = 'https://google.com' + q
elif q.startswith('https://accounts.google.com'):
# Remove Sign-in link
link.decompose()
return
elif '/search?q=' in href: elif '/search?q=' in href:
# "li:1" implies the query should be interpreted verbatim, # "li:1" implies the query should be interpreted verbatim,
# which is accomplished by wrapping the query in double quotes # which is accomplished by wrapping the query in double quotes
@ -614,85 +262,28 @@ class Filter:
# Strip unneeded arguments # Strip unneeded arguments
link['href'] = filter_link_args(q) link['href'] = filter_link_args(q)
# Add alternate viewing options for results, # Add no-js option
# if the result doesn't already have an AV link if self.nojs:
netloc = urlparse.urlparse(link['href']).netloc append_nojs(link)
if self.config.anon_view and netloc not in self._av:
self._av.add(netloc)
append_anon_view(link, self.config)
else: else:
if href.startswith(MAPS_URL): if href.startswith(MAPS_URL):
# Maps links don't work if a site filter is applied # Maps links don't work if a site filter is applied
link['href'] = build_map_url(link['href']) link['href'] = MAPS_URL + "?q=" + clean_query(q)
elif (href.startswith('/?') or href.startswith('/search?') or
href.startswith('/imgres?')):
# make sure that tags can be clicked as relative URLs
link['href'] = href[1:]
elif href.startswith('/intl/'):
# do nothing, keep original URL for ToS
pass
elif href.startswith('/preferences'):
# there is no config specific URL, remove this
link.decompose()
return
else: else:
link['href'] = href link['href'] = href
if self.config.new_tab and ( # Replace link location if "alts" config is enabled
link["href"].startswith("http") if self.alt_redirect:
or link["href"].startswith("imgres?") # Search and replace all link descriptions
): # with alternative location
link["target"] = "_blank" link['href'] = get_site_alt(link['href'])
link_desc = link.find_all(
def site_alt_swap(self) -> None: text=re.compile('|'.join(SITE_ALTS.keys())))
"""Replaces link locations and page elements if "alts" config if len(link_desc) == 0:
is enabled return
"""
for site, alt in SITE_ALTS.items():
if site != "medium.com" and alt != "":
# Ignore medium.com replacements since these are handled
# specifically in the link description replacement, and medium
# results are never given their own "card" result where this
# replacement would make sense.
# Also ignore if the alt is empty, since this is used to indicate
# that the alt is not enabled.
for div in self.soup.find_all('div', text=re.compile(site)):
# Use the number of words in the div string to determine if the
# string is a result description (shouldn't replace domains used
# in desc text).
if len(div.string.split(' ')) == 1:
div.string = div.string.replace(site, alt)
for link in self.soup.find_all('a', href=True):
# Search and replace all link descriptions
# with alternative location
link['href'] = get_site_alt(link['href'])
link_desc = link.find_all(
text=re.compile('|'.join(SITE_ALTS.keys())))
if len(link_desc) == 0:
continue
# Replace link description
link_desc = link_desc[0]
if site not in link_desc or not alt:
continue
new_desc = BeautifulSoup(features='html.parser').new_tag('div')
link_str = str(link_desc)
# Medium links should be handled differently, since 'medium.com'
# is a common substring of domain names, but shouldn't be
# replaced (i.e. 'philomedium.com' should stay as it is).
if 'medium.com' in link_str:
if link_str.startswith('medium.com') or '.medium.com' in link_str:
link_str = SITE_ALTS['medium.com'] + link_str[
link_str.find('medium.com') + len('medium.com'):]
new_desc.string = link_str
else:
new_desc.string = link_str.replace(site, alt)
link_desc.replace_with(new_desc) # Replace link destination
link_desc[0].replace_with(get_site_alt(link_desc[0]))
def view_image(self, soup) -> BeautifulSoup: def view_image(self, soup) -> BeautifulSoup:
"""Replaces the soup with a new one that handles mobile results and """Replaces the soup with a new one that handles mobile results and
@ -706,39 +297,28 @@ class Filter:
""" """
# get some tags that are unchanged between mobile and pc versions # get some tags that are unchanged between mobile and pc versions
search_input = soup.find_all('td', attrs={'class': "O4cRJf"})[0]
search_options = soup.find_all('div', attrs={'class': "M7pB2"})[0]
cor_suggested = soup.find_all('table', attrs={'class': "By0U9"}) cor_suggested = soup.find_all('table', attrs={'class': "By0U9"})
next_pages = soup.find('table', attrs={'class': "uZgmoc"}) next_pages = soup.find_all('table', attrs={'class': "uZgmoc"})[0]
information = soup.find_all('div', attrs={'class': "TuS8Ad"})[0]
results = [] results = []
# find results div # find results div
results_div = soup.find('div', attrs={'class': "nQvrDb"}) results_div = soup.find_all('div', attrs={'class': "nQvrDb"})[0]
# find all the results (if any) # find all the results
results_all = [] results_all = results_div.find_all('div', attrs={'class': "lIMUZd"})
if results_div:
results_all = results_div.find_all('div', attrs={'class': "lIMUZd"})
for item in results_all: for item in results_all:
urls = item.find('a')['href'].split('&imgrefurl=') urls = item.find('a')['href'].split('&imgrefurl=')
# Skip urls that are not two-element lists img_url = urlparse.unquote(urls[0].replace('/imgres?imgurl=', ''))
if len(urls) != 2: webpage = urlparse.unquote(urls[1].split('&')[0])
continue
img_url = urlparse.unquote(urls[0].replace(
f'/{Endpoint.imgres}?imgurl=', ''))
try:
# Try to strip out only the necessary part of the web page link
web_page = urlparse.unquote(urls[1].split('&')[0])
except IndexError:
web_page = urlparse.unquote(urls[1])
img_tbn = urlparse.unquote(item.find('a').find('img')['src']) img_tbn = urlparse.unquote(item.find('a').find('img')['src'])
results.append({ results.append({
'domain': urlparse.urlparse(web_page).netloc, 'domain': urlparse.urlparse(webpage).netloc,
'img_url': img_url, 'img_url': img_url,
'web_page': web_page, 'webpage': webpage,
'img_tbn': img_tbn 'img_tbn': img_tbn
}) })
@ -747,7 +327,12 @@ class Filter:
results=results, results=results,
view_label="View Image"), view_label="View Image"),
features='html.parser') features='html.parser')
# replace search input object
soup.find_all('td',
attrs={'class': "O4cRJf"})[0].replaceWith(search_input)
# replace search options object (All, Images, Videos, etc.)
soup.find_all('div',
attrs={'class': "M7pB2"})[0].replaceWith(search_options)
# replace correction suggested by google object if exists # replace correction suggested by google object if exists
if len(cor_suggested): if len(cor_suggested):
soup.find_all( soup.find_all(
@ -757,4 +342,7 @@ class Filter:
# replace next page object at the bottom of the page # replace next page object at the bottom of the page
soup.find_all('table', soup.find_all('table',
attrs={'class': "uZgmoc"})[0].replaceWith(next_pages) attrs={'class': "uZgmoc"})[0].replaceWith(next_pages)
# replace information about user connection at the bottom of the page
soup.find_all('div',
attrs={'class': "TuS8Ad"})[0].replaceWith(information)
return soup return soup

@ -1,57 +1,27 @@
from inspect import Attribute
from typing import Optional
from app.utils.misc import read_config_bool
from flask import current_app from flask import current_app
import os import os
import re
from base64 import urlsafe_b64encode, urlsafe_b64decode
import pickle
from cryptography.fernet import Fernet
import hashlib
import brotli
import logging
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: class Config:
def __init__(self, **kwargs): def __init__(self, **kwargs):
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 app_config = current_app.config
self.url = os.getenv('WHOOGLE_CONFIG_URL', '') self.url = os.getenv('WHOOGLE_CONFIG_URL', '')
self.lang_search = os.getenv('WHOOGLE_CONFIG_SEARCH_LANGUAGE', '') self.lang_search = os.getenv('WHOOGLE_CONFIG_SEARCH_LANGUAGE', '')
self.lang_interface = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '') self.lang_interface = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '')
self.style_modified = os.getenv( self.style = os.getenv(
'WHOOGLE_CONFIG_STYLE', '') 'WHOOGLE_CONFIG_STYLE',
open(os.path.join(app_config['STATIC_FOLDER'],
'css/variables.css')).read())
self.block = os.getenv('WHOOGLE_CONFIG_BLOCK', '') self.block = os.getenv('WHOOGLE_CONFIG_BLOCK', '')
self.block_title = os.getenv('WHOOGLE_CONFIG_BLOCK_TITLE', '') self.ctry = os.getenv('WHOOGLE_CONFIG_COUNTRY', '')
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.safe = read_config_bool('WHOOGLE_CONFIG_SAFE') self.safe = read_config_bool('WHOOGLE_CONFIG_SAFE')
self.dark = read_config_bool('WHOOGLE_CONFIG_DARK') # deprecated self.dark = read_config_bool('WHOOGLE_CONFIG_DARK')
self.alts = read_config_bool('WHOOGLE_CONFIG_ALTS') self.alts = read_config_bool('WHOOGLE_CONFIG_ALTS')
self.nojs = read_config_bool('WHOOGLE_CONFIG_NOJS') self.nojs = read_config_bool('WHOOGLE_CONFIG_NOJS')
self.tor = read_config_bool('WHOOGLE_CONFIG_TOR') self.tor = read_config_bool('WHOOGLE_CONFIG_TOR')
@ -59,26 +29,12 @@ class Config:
self.new_tab = read_config_bool('WHOOGLE_CONFIG_NEW_TAB') self.new_tab = read_config_bool('WHOOGLE_CONFIG_NEW_TAB')
self.view_image = read_config_bool('WHOOGLE_CONFIG_VIEW_IMAGE') self.view_image = read_config_bool('WHOOGLE_CONFIG_VIEW_IMAGE')
self.get_only = read_config_bool('WHOOGLE_CONFIG_GET_ONLY') 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', '')
self.accept_language = False
self.safe_keys = [ self.safe_keys = [
'lang_search', 'lang_search',
'lang_interface', 'lang_interface',
'country', 'ctry',
'theme', 'dark'
'alts',
'new_tab',
'view_image',
'block',
'safe',
'nojs',
'anon_view',
'preferences_encrypted',
'tbs'
] ]
# Skip setting custom config if there isn't one # Skip setting custom config if there isn't one
@ -107,51 +63,6 @@ class Config:
if not name.startswith("__") if not name.startswith("__")
and (type(attr) is bool or type(attr) is str)} 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
"""
style_sheet = cssutils.parseString(
open(os.path.join(current_app.config['STATIC_FOLDER'],
'css/variables.css')).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: def is_safe_key(self, key) -> bool:
"""Establishes a group of config options that are safe to set """Establishes a group of config options that are safe to set
in the url. in the url.
@ -190,82 +101,8 @@ class Config:
Returns: Returns:
Config -- a modified config object 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(): for param_key in params.keys():
if not self.is_safe_key(param_key): if not self.is_safe_key(param_key):
continue continue
param_val = params.get(param_key) self[param_key] = 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
return self 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:
hash_object = hashlib.md5(password.encode())
key = urlsafe_b64encode(hash_object.hexdigest().encode())
return key
def _encode_preferences(self) -> str:
encoded_preferences = brotli.compress(pickle.dumps(self.get_attrs()))
if self.preferences_encrypted:
if self.preferences_key != '':
key = self._get_fernet_key(self.preferences_key)
encoded_preferences = Fernet(key).encrypt(encoded_preferences)
encoded_preferences = brotli.compress(encoded_preferences)
return urlsafe_b64encode(encoded_preferences).decode()
def _decode_preferences(self, preferences: str) -> dict:
mode = preferences[0]
preferences = preferences[1:]
if mode == 'e': # preferences are encrypted
try:
key = self._get_fernet_key(self.preferences_key)
config = Fernet(key).decrypt(
brotli.decompress(urlsafe_b64decode(
preferences.encode() + b'=='))
)
config = pickle.loads(brotli.decompress(config))
except Exception:
config = {}
elif mode == 'u': # preferences are not encrypted
config = pickle.loads(
brotli.decompress(urlsafe_b64decode(
preferences.encode() + b'=='))
)
else: # preferences are incorrectly formatted
config = {}
return config

@ -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}')

@ -1,47 +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'
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

@ -1,17 +1,15 @@
from app.models.config import Config from app.models.config import Config
from app.utils.misc import read_config_bool
from datetime import datetime from datetime import datetime
from defusedxml import ElementTree as ET import xml.etree.ElementTree as ET
import random import random
import requests import requests
from requests import Response, ConnectionError from requests import Response, ConnectionError
import urllib.parse as urlparse import urllib.parse as urlparse
import os import os
from stem import Signal, SocketError from stem import Signal, SocketError
from stem.connection import AuthenticationFailure
from stem.control import Controller 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' MAPS_URL = 'https://maps.google.com/maps'
AUTOCOMPLETE_URL = ('https://suggestqueries.google.com/' AUTOCOMPLETE_URL = ('https://suggestqueries.google.com/'
'complete/search?client=toolbar&') 'complete/search?client=toolbar&')
@ -40,47 +38,19 @@ class TorError(Exception):
def send_tor_signal(signal: Signal) -> bool: 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: try:
with Controller.from_port(port=9051) as c: with Controller.from_port(port=9051) as c:
if use_pass: c.authenticate()
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.signal(signal) c.signal(signal)
os.environ['TOR_AVAILABLE'] = '1' os.environ['TOR_AVAILABLE'] = '1'
return True return True
except (SocketError, AuthenticationFailure, except (SocketError, ConnectionRefusedError, ConnectionError):
ConnectionRefusedError, ConnectionError):
# TODO: Handle Tor authentication (password and cookie)
os.environ['TOR_AVAILABLE'] = '0' os.environ['TOR_AVAILABLE'] = '0'
return False return False
def gen_user_agent(is_mobile) -> str: def gen_user_agent(is_mobile) -> str:
user_agent = os.environ.get('WHOOGLE_USER_AGENT', '')
user_agent_mobile = os.environ.get('WHOOGLE_USER_AGENT_MOBILE', '')
if user_agent and not is_mobile:
return user_agent
if user_agent_mobile and is_mobile:
return user_agent_mobile
firefox = random.choice(['Choir', 'Squier', 'Higher', 'Wire']) + 'fox' firefox = random.choice(['Choir', 'Squier', 'Higher', 'Wire']) + 'fox'
linux = random.choice(['Win', 'Sin', 'Gin', 'Fin', 'Kin']) + 'ux' linux = random.choice(['Win', 'Sin', 'Gin', 'Fin', 'Kin']) + 'ux'
@ -90,7 +60,7 @@ def gen_user_agent(is_mobile) -> str:
return DESKTOP_UA.format("Mozilla", linux, firefox) 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} param_dict = {key: '' for key in VALID_PARAMS}
# Use :past(hour/day/week/month/year) if available # Use :past(hour/day/week/month/year) if available
@ -99,8 +69,8 @@ def gen_query(query, args, config) -> str:
if ':past' in query and 'tbs' not in args: if ':past' in query and 'tbs' not in args:
time_range = str.strip(query.split(':past', 1)[-1]) time_range = str.strip(query.split(':past', 1)[-1])
param_dict['tbs'] = '&tbs=' + ('qdr:' + str.lower(time_range[0])) param_dict['tbs'] = '&tbs=' + ('qdr:' + str.lower(time_range[0]))
elif 'tbs' in args or 'tbs' in config: elif 'tbs' in args:
result_tbs = args.get('tbs') if 'tbs' in args else config['tbs'] result_tbs = args.get('tbs')
param_dict['tbs'] = '&tbs=' + result_tbs param_dict['tbs'] = '&tbs=' + result_tbs
# Occasionally the 'tbs' param provided by google also contains a # Occasionally the 'tbs' param provided by google also contains a
@ -127,8 +97,8 @@ def gen_query(query, args, config) -> str:
param_dict['start'] = '&start=' + args.get('start') param_dict['start'] = '&start=' + args.get('start')
# Search for results near a particular city, if available # Search for results near a particular city, if available
if config.near: if near_city:
param_dict['near'] = '&near=' + urlparse.quote(config.near) param_dict['near'] = '&near=' + urlparse.quote(near_city)
# Set language for results (lr) if source isn't set, otherwise use the # Set language for results (lr) if source isn't set, otherwise use the
# result language param provided in the results # result language param provided in the results
@ -138,25 +108,19 @@ def gen_query(query, args, config) -> str:
[_ for _ in lang if not _.isdigit()] [_ for _ in lang if not _.isdigit()]
)) if lang else '' )) if lang else ''
else: else:
param_dict['lr'] = ( param_dict['lr'] = '&lr=' + (
'&lr=' + config.lang_search config.lang_search if config.lang_search else ''
) if config.lang_search else '' )
# 'nfpr' defines the exclusion of results from an auto-corrected query # 'nfpr' defines the exclusion of results from an auto-corrected query
if 'nfpr' in args: if 'nfpr' in args:
param_dict['nfpr'] = '&nfpr=' + args.get('nfpr') param_dict['nfpr'] = '&nfpr=' + args.get('nfpr')
# 'chips' is used in image tabs to pass the optional 'filter' to add to the param_dict['cr'] = ('&cr=' + config.ctry) if config.ctry else ''
# given search term param_dict['hl'] = '&hl=' + (
if 'chips' in args: config.lang_interface.replace('lang_', '')
param_dict['chips'] = '&chips=' + args.get('chips') if config.lang_interface else ''
)
param_dict['gl'] = (
'&gl=' + config.country
) if config.country 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') param_dict['safe'] = '&safe=' + ('active' if config.safe else 'off')
# Block all sites specified in the user config # Block all sites specified in the user config
@ -186,8 +150,6 @@ class Request:
""" """
def __init__(self, normal_ua, root_path, config: Config): def __init__(self, normal_ua, root_path, config: Config):
self.search_url = 'https://www.google.com/search?gbv=1&num=' + str(
os.getenv('WHOOGLE_RESULTS_PER_PAGE', 10)) + '&q='
# Send heartbeat to Tor, used in determining if the user can or cannot # Send heartbeat to Tor, used in determining if the user can or cannot
# enable Tor for future requests # enable Tor for future requests
send_tor_signal(Signal.HEARTBEAT) send_tor_signal(Signal.HEARTBEAT)
@ -195,16 +157,7 @@ class Request:
self.language = ( self.language = (
config.lang_search if config.lang_search else '' config.lang_search if config.lang_search else ''
) )
self.mobile = 'Android' in normal_ua or 'iPhone' in normal_ua
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)
self.modified_user_agent = gen_user_agent(self.mobile) self.modified_user_agent = gen_user_agent(self.mobile)
if not self.mobile: if not self.mobile:
self.modified_user_agent_mobile = gen_user_agent(True) self.modified_user_agent_mobile = gen_user_agent(True)
@ -217,13 +170,19 @@ class Request:
proxy_pass = os.environ.get('WHOOGLE_PROXY_PASS', '') proxy_pass = os.environ.get('WHOOGLE_PROXY_PASS', '')
auth_str = '' auth_str = ''
if proxy_user: if proxy_user:
auth_str = f'{proxy_user}:{proxy_pass}@' auth_str = proxy_user + ':' + proxy_pass
proxy_str = f'{proxy_type}://{auth_str}{proxy_path}'
self.proxies = { self.proxies = {
'https': proxy_str, 'https': proxy_type + '://' +
'http': proxy_str ((auth_str + '@') if auth_str else '') + proxy_path,
} }
# Need to ensure both HTTP and HTTPS are in the proxy dict,
# regardless of underlying protocol
if proxy_type == 'https':
self.proxies['http'] = self.proxies['https'].replace(
'https', 'http')
else:
self.proxies['http'] = self.proxies['https']
else: else:
self.proxies = { self.proxies = {
'http': 'socks5://127.0.0.1:9050', 'http': 'socks5://127.0.0.1:9050',
@ -246,30 +205,19 @@ class Request:
list: The list of matches for possible search suggestions list: The list of matches for possible search suggestions
""" """
ac_query = dict(q=query) ac_query = dict(hl=self.language, 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
response = self.send(base_url=AUTOCOMPLETE_URL, response = self.send(base_url=AUTOCOMPLETE_URL,
query=urlparse.urlencode(ac_query)).text query=urlparse.urlencode(ac_query)).text
if not response: if not response:
return [] return []
try: root = ET.fromstring(response)
root = ET.fromstring(response) return [_.attrib['data'] for _ in
return [_.attrib['data'] for _ in root.findall('.//suggestion/[@data]')]
root.findall('.//suggestion/[@data]')]
except ET.ParseError:
# Malformed XML response
return []
def send(self, base_url='', query='', attempt=0, def send(self, base_url=SEARCH_URL, query='', attempt=0,
force_mobile=False, user_agent='') -> Response: force_mobile=False) -> Response:
"""Sends an outbound request to a URL. Optionally sends the request """Sends an outbound request to a URL. Optionally sends the request
using Tor, if enabled by the user. using Tor, if enabled by the user.
@ -285,30 +233,22 @@ class Request:
Response: The Response object returned by the requests call Response: The Response object returned by the requests call
""" """
use_client_user_agent = int(os.environ.get('WHOOGLE_USE_CLIENT_USER_AGENT', '0')) if force_mobile and not self.mobile:
if user_agent and use_client_user_agent == 1: modified_user_agent = self.modified_user_agent_mobile
modified_user_agent = user_agent
else: else:
if force_mobile and not self.mobile: modified_user_agent = self.modified_user_agent
modified_user_agent = self.modified_user_agent_mobile
else:
modified_user_agent = self.modified_user_agent
headers = { headers = {
'User-Agent': modified_user_agent 'User-Agent': modified_user_agent
} }
# Adding the Accept-Language to the Header if possible # FIXME: Should investigate this further to ensure the consent
if self.lang_interface:
headers.update({'Accept-Language':
self.lang_interface.replace('lang_', '')
+ ';q=1.0'})
# view is suppressed correctly # view is suppressed correctly
now = datetime.now() now = datetime.now()
cookies = { cookies = {
'CONSENT': 'PENDING+987', 'CONSENT': 'YES+cb.{:d}{:02d}{:02d}-17-p0.de+F+678'.format(
'SOCS': 'CAESHAgBEhIaAB', now.year, now.month, now.day
)
} }
# Validate Tor conn and request new identity if the last one failed # Validate Tor conn and request new identity if the last one failed
@ -321,23 +261,18 @@ class Request:
# Make sure that the tor connection is valid, if enabled # Make sure that the tor connection is valid, if enabled
if self.tor: if self.tor:
try: tor_check = requests.get('https://check.torproject.org/',
tor_check = requests.get('https://check.torproject.org/', proxies=self.proxies, headers=headers)
proxies=self.proxies, headers=headers) self.tor_valid = 'Congratulations' in tor_check.text
self.tor_valid = 'Congratulations' in tor_check.text
if not self.tor_valid:
if not self.tor_valid:
raise TorError(
"Tor connection succeeded, but the connection could "
"not be validated by torproject.org",
disable=True)
except ConnectionError:
raise TorError( raise TorError(
"Error raised during Tor connection validation", "Tor connection succeeded, but the connection could not "
"be validated by torproject.org",
disable=True) disable=True)
response = requests.get( response = requests.get(
(base_url or self.search_url) + query, base_url + query,
proxies=self.proxies, proxies=self.proxies,
headers=headers, headers=headers,
cookies=cookies) cookies=cookies)
@ -347,6 +282,6 @@ class Request:
attempt += 1 attempt += 1
if attempt > 10: if attempt > 10:
raise TorError("Tor query failed -- max attempts exceeded 10") raise TorError("Tor query failed -- max attempts exceeded 10")
return self.send((base_url or self.search_url), query, attempt) return self.send(base_url, query, attempt)
return response return response

@ -2,63 +2,30 @@ import argparse
import base64 import base64
import io import io
import json import json
import os
import pickle import pickle
import re
import urllib.parse as urlparse import urllib.parse as urlparse
import uuid import uuid
import validators
import sys
import traceback
from datetime import datetime, timedelta
from functools import wraps from functools import wraps
import waitress import waitress
from app import app
from app.models.config import Config
from app.models.endpoint import Endpoint
from app.request import Request, TorError
from app.utils.bangs import suggest_bang, resolve_bang
from app.utils.misc import empty_gif, placeholder_img, get_proxy_host_url, \
fetch_favicon
from app.filter import Filter
from app.utils.misc import read_config_bool, get_client_ip, get_request_url, \
check_for_update, encrypt_string
from app.utils.widgets import *
from app.utils.results import bold_search_terms,\
add_currency_card, check_currency, get_tabs_content
from app.utils.search import Search, needs_https, has_captcha
from app.utils.session import valid_user_session
from bs4 import BeautifulSoup as bsoup
from flask import jsonify, make_response, request, redirect, render_template, \ from flask import jsonify, make_response, request, redirect, render_template, \
send_file, session, url_for, g send_file, session, url_for
from requests import exceptions from requests import exceptions
from requests.models import PreparedRequest
from cryptography.fernet import Fernet, InvalidToken
from cryptography.exceptions import InvalidSignature
from werkzeug.datastructures import MultiDict
ac_var = 'WHOOGLE_AUTOCOMPLETE'
autocomplete_enabled = os.getenv(ac_var, '1')
from app import app
from app.models.config import Config
from app.request import Request, TorError
from app.utils.bangs import resolve_bang
from app.utils.session import generate_user_key, valid_user_session
from app.utils.search import *
def get_search_name(tbm): # Load DDG bang json files only on init
for tab in app.config['HEADER_TABS'].values(): bang_json = json.load(open(app.config['BANG_FILE']))
if tab['tbm'] == tbm:
return tab['name']
def auth_required(f): def auth_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
# do not ask password if cookies already present
if (
valid_user_session(session)
and 'cookies_disabled' not in request.args
and session['auth']
):
return f(*args, **kwargs)
auth = request.authorization auth = request.authorization
# Skip if username/password not set # Skip if username/password not set
@ -68,7 +35,6 @@ def auth_required(f):
auth auth
and whoogle_user == auth.username and whoogle_user == auth.username
and whoogle_pass == auth.password): and whoogle_pass == auth.password):
session['auth'] = True
return f(*args, **kwargs) return f(*args, **kwargs)
else: else:
return make_response('Not logged in', 401, { return make_response('Not logged in', 401, {
@ -77,95 +43,40 @@ def auth_required(f):
return decorated return decorated
def session_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not valid_user_session(session):
session.pop('_permanent', None)
# Note: This sets all requests to use the encryption key determined per
# instance on app init. This can be updated in the future to use a key
# that is unique for their session (session['key']) but this should use
# a config setting to enable the session based key. Otherwise there can
# be problems with searches performed by users with cookies blocked if
# a session based key is always used.
g.session_key = app.enc_key
# Clear out old sessions
invalid_sessions = []
for user_session in os.listdir(app.config['SESSION_FILE_DIR']):
file_path = os.path.join(
app.config['SESSION_FILE_DIR'],
user_session)
try:
# Ignore files that are larger than the max session file size
if os.path.getsize(file_path) > app.config['MAX_SESSION_SIZE']:
continue
with open(file_path, 'rb') as session_file:
_ = pickle.load(session_file)
data = pickle.load(session_file)
if isinstance(data, dict) and 'valid' in data:
continue
invalid_sessions.append(file_path)
except Exception:
# Broad exception handling here due to how instances installed
# with pip seem to have issues storing unrelated files in the
# same directory as sessions
pass
for invalid_session in invalid_sessions:
try:
os.remove(invalid_session)
except FileNotFoundError:
# Don't throw error if the invalid session has been removed
pass
return f(*args, **kwargs)
return decorated
@app.before_request @app.before_request
def before_request_func(): def before_request_func():
session.permanent = True
# Check for latest version if needed
now = datetime.now()
needs_update_check = now - timedelta(hours=24) > app.config['LAST_UPDATE_CHECK']
if read_config_bool('WHOOGLE_UPDATE_CHECK', True) and needs_update_check:
app.config['LAST_UPDATE_CHECK'] = now
app.config['HAS_UPDATE'] = check_for_update(
app.config['RELEASES_URL'],
app.config['VERSION_NUMBER'])
g.request_params = ( g.request_params = (
request.args if request.method == 'GET' else request.form request.args if request.method == 'GET' else request.form
) )
g.cookies_disabled = False
default_config = json.load(open(app.config['DEFAULT_CONFIG'])) \
if os.path.exists(app.config['DEFAULT_CONFIG']) else {}
# Generate session values for user if unavailable # Generate session values for user if unavailable
if not valid_user_session(session): if not valid_user_session(session):
session['config'] = default_config session['config'] = json.load(open(app.config['DEFAULT_CONFIG'])) \
if os.path.exists(app.config['DEFAULT_CONFIG']) else {}
session['uuid'] = str(uuid.uuid4()) session['uuid'] = str(uuid.uuid4())
session['key'] = app.enc_key session['key'] = generate_user_key(True)
session['auth'] = False
# Establish config values per user session # Flag cookies as possibly disabled in order to prevent against
g.user_config = Config(**session['config']) # unnecessary session directory expansion
g.cookies_disabled = True
# Update user config if specified in search args # Handle https upgrade
g.user_config = g.user_config.from_params(g.request_params) if needs_https(request.url):
return redirect(
request.url.replace('http://', 'https://', 1),
code=308)
g.user_config = Config(**session['config'])
if not g.user_config.url: if not g.user_config.url:
g.user_config.url = get_request_url(request.url_root) g.user_config.url = request.url_root.replace(
'http://',
'https://') if os.getenv('HTTPS_ONLY', False) else request.url_root
g.user_request = Request( g.user_request = Request(
request.headers.get('User-Agent'), request.headers.get('User-Agent'),
get_request_url(request.url_root), request.url_root,
config=g.user_config) config=g.user_config)
g.app_location = g.user_config.url g.app_location = g.user_config.url
@ -173,15 +84,22 @@ def before_request_func():
@app.after_request @app.after_request
def after_request_func(resp): def after_request_func(resp):
resp.headers['X-Content-Type-Options'] = 'nosniff' # Check if address consistently has cookies blocked,
resp.headers['X-Frame-Options'] = 'DENY' # in which case start removing session files after creation.
resp.headers['Cache-Control'] = 'max-age=86400' #
# Note: This is primarily done to prevent overpopulation of session
if os.getenv('WHOOGLE_CSP', False): # directories, since browsers that block cookies will still trigger
resp.headers['Content-Security-Policy'] = app.config['CSP'] # Flask's session creation routine with every request.
if os.environ.get('HTTPS_ONLY', False): if g.cookies_disabled and request.remote_addr not in app.no_cookie_ips:
resp.headers['Content-Security-Policy'] += \ app.no_cookie_ips.append(request.remote_addr)
'upgrade-insecure-requests' elif g.cookies_disabled and request.remote_addr in app.no_cookie_ips:
session_list = list(session.keys())
for key in session_list:
session.pop(key)
resp.headers['Content-Security-Policy'] = app.config['CSP']
if os.environ.get('HTTPS_ONLY', False):
resp.headers['Content-Security-Policy'] += 'upgrade-insecure-requests'
return resp return resp
@ -192,15 +110,17 @@ def unknown_page(e):
return redirect(g.app_location) return redirect(g.app_location)
@app.route(f'/{Endpoint.healthz}', methods=['GET']) @app.route('/healthz', methods=['GET'])
def healthz(): def healthz():
return '' return ''
@app.route('/', methods=['GET']) @app.route('/', methods=['GET'])
@app.route(f'/{Endpoint.home}', methods=['GET'])
@auth_required @auth_required
def index(): def index():
# Reset keys
session['key'] = generate_user_key(g.cookies_disabled)
# Redirect if an error was raised # Redirect if an error was raised
if 'error_message' in session and session['error_message']: if 'error_message' in session and session['error_message']:
error_message = session['error_message'] error_message = session['error_message']
@ -208,27 +128,21 @@ def index():
return render_template('error.html', error_message=error_message) return render_template('error.html', error_message=error_message)
return render_template('index.html', return render_template('index.html',
has_update=app.config['HAS_UPDATE'],
languages=app.config['LANGUAGES'], languages=app.config['LANGUAGES'],
countries=app.config['COUNTRIES'], countries=app.config['COUNTRIES'],
time_periods=app.config['TIME_PERIODS'],
themes=app.config['THEMES'],
autocomplete_enabled=autocomplete_enabled,
translation=app.config['TRANSLATIONS'][ translation=app.config['TRANSLATIONS'][
g.user_config.get_localization_lang() g.user_config.get_localization_lang()
], ],
logo=render_template( logo=render_template(
'logo.html', 'logo.html',
dark=g.user_config.dark), dark=g.user_config.dark),
config_disabled=( config_disabled=app.config['CONFIG_DISABLE'],
app.config['CONFIG_DISABLE'] or
not valid_user_session(session)),
config=g.user_config, config=g.user_config,
tor_available=int(os.environ.get('TOR_AVAILABLE')), tor_available=int(os.environ.get('TOR_AVAILABLE')),
version_number=app.config['VERSION_NUMBER']) version_number=app.config['VERSION_NUMBER'])
@app.route(f'/{Endpoint.opensearch}', methods=['GET']) @app.route('/opensearch.xml', methods=['GET'])
def opensearch(): def opensearch():
opensearch_url = g.app_location opensearch_url = g.app_location
if opensearch_url.endswith('/'): if opensearch_url.endswith('/'):
@ -244,13 +158,11 @@ def opensearch():
return render_template( return render_template(
'opensearch.xml', 'opensearch.xml',
main_url=opensearch_url, main_url=opensearch_url,
request_type='' if get_only else 'method="post"', request_type='' if get_only else 'method="post"'
search_type=request.args.get('tbm'), ), 200, {'Content-Disposition': 'attachment; filename="opensearch.xml"'}
search_name=get_search_name(request.args.get('tbm'))
), 200, {'Content-Type': 'application/xml'}
@app.route(f'/{Endpoint.search_html}', methods=['GET']) @app.route('/search.html', methods=['GET'])
def search_html(): def search_html():
search_url = g.app_location search_url = g.app_location
if search_url.endswith('/'): if search_url.endswith('/'):
@ -258,11 +170,8 @@ def search_html():
return render_template('search.html', url=search_url) return render_template('search.html', url=search_url)
@app.route(f'/{Endpoint.autocomplete}', methods=['GET', 'POST']) @app.route('/autocomplete', methods=['GET', 'POST'])
def autocomplete(): def autocomplete():
if os.getenv(ac_var) and not read_config_bool(ac_var):
return jsonify({})
q = g.request_params.get('q') q = g.request_params.get('q')
if not q: if not q:
# FF will occasionally (incorrectly) send the q field without a # FF will occasionally (incorrectly) send the q field without a
@ -271,7 +180,8 @@ def autocomplete():
# Search bangs if the query begins with "!", but not "! " (feeling lucky) # Search bangs if the query begins with "!", but not "! " (feeling lucky)
if q.startswith('!') and len(q) > 1 and not q.startswith('! '): if q.startswith('!') and len(q) > 1 and not q.startswith('! '):
return jsonify([q, suggest_bang(q)]) return jsonify([q, [bang_json[_]['suggestion'] for _ in bang_json if
_.startswith(q)]])
if not q and not request.data: if not q and not request.data:
return jsonify({'?': []}) return jsonify({'?': []})
@ -288,27 +198,24 @@ def autocomplete():
g.user_request.autocomplete(q) if not g.user_config.tor else [] g.user_request.autocomplete(q) if not g.user_config.tor else []
]) ])
@app.route(f'/{Endpoint.search}', methods=['GET', 'POST'])
@session_required @app.route('/search', methods=['GET', 'POST'])
@auth_required @auth_required
def search(): def search():
if request.method == 'POST': # Update user config if specified in search args
# Redirect as a GET request with an encrypted query g.user_config = g.user_config.from_params(g.request_params)
post_data = MultiDict(request.form)
post_data['q'] = encrypt_string(g.session_key, post_data['q']) search_util = Search(request, g.user_config, session,
get_req_str = urlparse.urlencode(post_data) cookies_disabled=g.cookies_disabled)
return redirect(url_for('.search') + '?' + get_req_str)
search_util = Search(request, g.user_config, g.session_key)
query = search_util.new_search_query() query = search_util.new_search_query()
bang = resolve_bang(query) bang = resolve_bang(query=query, bangs_dict=bang_json)
if bang: if bang != '':
return redirect(bang) return redirect(bang)
# Redirect to home if invalid/blank search # Redirect to home if invalid/blank search
if not query: if not query:
return redirect(url_for('.index')) return redirect('/')
# Generate response and number of external elements from the page # Generate response and number of external elements from the page
try: try:
@ -323,116 +230,40 @@ def search():
if search_util.feeling_lucky: if search_util.feeling_lucky:
return redirect(response, code=303) return redirect(response, code=303)
# If the user is attempting to translate a string, determine the correct
# string for formatting the lingva.ml url
localization_lang = g.user_config.get_localization_lang()
translation = app.config['TRANSLATIONS'][localization_lang]
translate_to = localization_lang.replace('lang_', '')
# removing st-card to only use whoogle time selector
soup = bsoup(response, "html.parser");
for x in soup.find_all(attrs={"id": "st-card"}):
x.replace_with("")
response = str(soup)
# Return 503 if temporarily blocked by captcha # Return 503 if temporarily blocked by captcha
if has_captcha(str(response)): resp_code = 503 if has_captcha(str(response)) else 200
app.logger.error('503 (CAPTCHA)')
return render_template(
'error.html',
blocked=True,
error_message=translation['ratelimit'],
translation=translation,
farside='https://farside.link',
config=g.user_config,
query=urlparse.unquote(query),
params=g.user_config.to_params(keys=['preferences'])), 503
response = bold_search_terms(response, query)
# check for widgets and add if requested
if search_util.widget != '':
html_soup = bsoup(str(response), 'html.parser')
if search_util.widget == 'ip':
response = add_ip_card(html_soup, get_client_ip(request))
elif search_util.widget == 'calculator' and not 'nojs' in request.args:
response = add_calculator_card(html_soup)
# Update tabs content
tabs = get_tabs_content(app.config['HEADER_TABS'],
search_util.full_query,
search_util.search_type,
g.user_config.preferences,
translation)
# Feature to display currency_card
# Since this is determined by more than just the
# query is it not defined as a standard widget
conversion = check_currency(str(response))
if conversion:
html_soup = bsoup(str(response), 'html.parser')
response = add_currency_card(html_soup, conversion)
preferences = g.user_config.preferences
home_url = f"home?preferences={preferences}" if preferences else "home"
cleanresponse = str(response).replace("andlt;","&lt;").replace("andgt;","&gt;")
return render_template( return render_template(
'display.html', 'display.html',
has_update=app.config['HAS_UPDATE'],
query=urlparse.unquote(query), query=urlparse.unquote(query),
search_type=search_util.search_type, search_type=search_util.search_type,
search_name=get_search_name(search_util.search_type),
config=g.user_config, config=g.user_config,
autocomplete_enabled=autocomplete_enabled, translation=app.config['TRANSLATIONS'][
lingva_url=app.config['TRANSLATE_URL'], g.user_config.get_localization_lang()
translation=translation, ],
translate_to=translate_to, response=response,
translate_str=query.replace(
'translate', ''
).replace(
translation['translate'], ''
),
is_translation=any(
_ in query.lower() for _ in [translation['translate'], 'translate']
) and not search_util.search_type, # Standard search queries only
response=cleanresponse,
version_number=app.config['VERSION_NUMBER'], version_number=app.config['VERSION_NUMBER'],
search_header=render_template( search_header=(render_template(
'header.html', 'header.html',
home_url=home_url,
config=g.user_config, config=g.user_config,
translation=translation,
languages=app.config['LANGUAGES'],
countries=app.config['COUNTRIES'],
time_periods=app.config['TIME_PERIODS'],
logo=render_template('logo.html', dark=g.user_config.dark), logo=render_template('logo.html', dark=g.user_config.dark),
query=urlparse.unquote(query), query=urlparse.unquote(query),
search_type=search_util.search_type, search_type=search_util.search_type,
mobile=g.user_request.mobile, mobile=g.user_request.mobile)
tabs=tabs)).replace(" ", "") if 'isch' not in search_util.search_type else '')), resp_code
@app.route(f'/{Endpoint.config}', methods=['GET', 'POST', 'PUT']) @app.route('/config', methods=['GET', 'POST', 'PUT'])
@session_required
@auth_required @auth_required
def config(): def config():
config_disabled = ( config_disabled = app.config['CONFIG_DISABLE']
app.config['CONFIG_DISABLE'] or
not valid_user_session(session))
name = ''
if 'name' in request.args:
name = os.path.normpath(request.args.get('name'))
if not re.match(r'^[A-Za-z0-9_.+-]+$', name):
return make_response('Invalid config name', 400)
if request.method == 'GET': if request.method == 'GET':
return json.dumps(g.user_config.__dict__) return json.dumps(g.user_config.__dict__)
elif request.method == 'PUT' and not config_disabled: elif request.method == 'PUT' and not config_disabled:
if name: if 'name' in request.args:
config_pkl = os.path.join(app.config['CONFIG_PATH'], name) config_pkl = os.path.join(
app.config['CONFIG_PATH'],
request.args.get('name'))
session['config'] = (pickle.load(open(config_pkl, 'rb')) session['config'] = (pickle.load(open(config_pkl, 'rb'))
if os.path.exists(config_pkl) if os.path.exists(config_pkl)
else session['config']) else session['config'])
@ -450,7 +281,12 @@ def config():
config_data, config_data,
open(os.path.join( open(os.path.join(
app.config['CONFIG_PATH'], app.config['CONFIG_PATH'],
name), 'wb')) request.args.get('name')), 'wb'))
# Overwrite default config if user has cookies disabled
if g.cookies_disabled:
open(app.config['DEFAULT_CONFIG'], 'w').write(
json.dumps(config_data, indent=4))
session['config'] = config_data session['config'] = config_data
return redirect(config_data['url']) return redirect(config_data['url'])
@ -458,46 +294,36 @@ def config():
return redirect(url_for('.index'), code=403) return redirect(url_for('.index'), code=403)
@app.route(f'/{Endpoint.imgres}') @app.route('/url', methods=['GET'])
@session_required @auth_required
def url():
if 'url' in request.args:
return redirect(request.args.get('url'))
q = request.args.get('q')
if len(q) > 0 and 'http' in q:
return redirect(q)
else:
return render_template(
'error.html',
error_message='Unable to resolve query: ' + q)
@app.route('/imgres')
@auth_required @auth_required
def imgres(): def imgres():
return redirect(request.args.get('imgurl')) return redirect(request.args.get('imgurl'))
@app.route(f'/{Endpoint.element}') @app.route('/element')
@session_required
@auth_required @auth_required
def element(): def element():
element_url = src_url = request.args.get('url') cipher_suite = Fernet(session['key'])
if element_url.startswith('gAAAAA'): src_url = cipher_suite.decrypt(request.args.get('url').encode()).decode()
try:
cipher_suite = Fernet(g.session_key)
src_url = cipher_suite.decrypt(element_url.encode()).decode()
except (InvalidSignature, InvalidToken) as e:
return render_template(
'error.html',
error_message=str(e)), 401
src_type = request.args.get('type') src_type = request.args.get('type')
# Ensure requested element is from a valid domain
domain = urlparse.urlparse(src_url).netloc
if not validators.domain(domain):
return send_file(io.BytesIO(empty_gif), mimetype='image/gif')
try: try:
response = g.user_request.send(base_url=src_url) file_data = g.user_request.send(base_url=src_url).content
# Display an empty gif if the requested element couldn't be retrieved
if response.status_code != 200 or len(response.content) == 0:
if 'favicon' in src_url:
favicon = fetch_favicon(src_url)
return send_file(io.BytesIO(favicon), mimetype='image/png')
else:
return send_file(io.BytesIO(empty_gif), mimetype='image/gif')
file_data = response.content
tmp_mem = io.BytesIO() tmp_mem = io.BytesIO()
tmp_mem.write(file_data) tmp_mem.write(file_data)
tmp_mem.seek(0) tmp_mem.seek(0)
@ -506,129 +332,26 @@ def element():
except exceptions.RequestException: except exceptions.RequestException:
pass pass
empty_gif = base64.b64decode(
'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')
return send_file(io.BytesIO(empty_gif), mimetype='image/gif') return send_file(io.BytesIO(empty_gif), mimetype='image/gif')
@app.route(f'/{Endpoint.window}') @app.route('/window')
@session_required
@auth_required @auth_required
def window(): def window():
target_url = request.args.get('location') get_body = g.user_request.send(base_url=request.args.get('location')).text
if target_url.startswith('gAAAAA'): get_body = get_body.replace('src="/',
cipher_suite = Fernet(g.session_key) 'src="' + request.args.get('location') + '"')
target_url = cipher_suite.decrypt(target_url.encode()).decode() get_body = get_body.replace('href="/',
'href="' + request.args.get('location') + '"')
content_filter = Filter(
g.session_key,
root_url=request.url_root,
config=g.user_config)
target = urlparse.urlparse(target_url)
# Ensure requested URL has a valid domain
if not validators.domain(target.netloc):
return render_template(
'error.html',
error_message='Invalid location'), 400
host_url = f'{target.scheme}://{target.netloc}'
get_body = g.user_request.send(base_url=target_url).text
results = bsoup(get_body, 'html.parser') results = bsoup(get_body, 'html.parser')
src_attrs = ['src', 'href', 'srcset', 'data-srcset', 'data-src']
# Parse HTML response and replace relative links w/ absolute
for element in results.find_all():
for attr in src_attrs:
if not element.has_attr(attr) or not element[attr].startswith('/'):
continue
element[attr] = host_url + element[attr]
# Replace or remove javascript sources
for script in results.find_all('script', {'src': True}):
if 'nojs' in request.args:
script.decompose()
else:
content_filter.update_element_src(script, 'application/javascript')
# Replace all possible image attributes
img_sources = ['src', 'data-src', 'data-srcset', 'srcset']
for img in results.find_all('img'):
_ = [
content_filter.update_element_src(img, 'image/png', attr=_)
for _ in img_sources if img.has_attr(_)
]
# Replace all stylesheet sources
for link in results.find_all('link', {'href': True}):
content_filter.update_element_src(link, 'text/css', attr='href')
# Use anonymous view for all links on page
for a in results.find_all('a', {'href': True}):
a['href'] = f'{Endpoint.window}?location=' + a['href'] + (
'&nojs=1' if 'nojs' in request.args else '')
# Remove all iframes -- these are commonly used inside of <noscript> tags
# to enforce loading Google Analytics
for iframe in results.find_all('iframe'):
iframe.decompose()
return render_template(
'display.html',
response=results,
translation=app.config['TRANSLATIONS'][
g.user_config.get_localization_lang()
]
)
@app.route('/robots.txt')
def robots():
response = make_response(
'''User-Agent: *
Disallow: /''', 200)
response.mimetype = 'text/plain'
return response
@app.route('/favicon.ico') for script in results('script'):
def favicon(): script.decompose()
return app.send_static_file('img/favicon.ico')
return render_template('display.html', response=results)
@app.errorhandler(404)
def page_not_found(e):
return render_template('error.html', error_message=str(e)), 404
@app.errorhandler(Exception)
def internal_error(e):
query = ''
if request.method == 'POST':
query = request.form.get('q')
else:
query = request.args.get('q')
# Attempt to parse the query
try:
search_util = Search(request, g.user_config, g.session_key)
query = search_util.new_search_query()
except Exception:
pass
print(traceback.format_exc(), file=sys.stderr)
localization_lang = g.user_config.get_localization_lang()
translation = app.config['TRANSLATIONS'][localization_lang]
return render_template(
'error.html',
error_message='Internal server error (500)',
translation=translation,
farside='https://farside.link',
config=g.user_config,
query=urlparse.unquote(query),
params=g.user_config.to_params(keys=['preferences'])), 500
def run_app() -> None: def run_app() -> None:
@ -644,16 +367,6 @@ def run_app() -> None:
default='127.0.0.1', default='127.0.0.1',
metavar='<ip address>', metavar='<ip address>',
help='Specifies the host address to use (default 127.0.0.1)') help='Specifies the host address to use (default 127.0.0.1)')
parser.add_argument(
'--unix-socket',
default='',
metavar='</path/to/unix.sock>',
help='Listen for app on unix socket instead of host:port')
parser.add_argument(
'--unix-socket-perms',
default='600',
metavar='<octal permissions>',
help='Octal permissions to use for the Unix domain socket (default 600)')
parser.add_argument( parser.add_argument(
'--debug', '--debug',
default=False, default=False,
@ -699,15 +412,9 @@ def run_app() -> None:
os.environ['WHOOGLE_PROXY_TYPE'] = args.proxytype os.environ['WHOOGLE_PROXY_TYPE'] = args.proxytype
os.environ['WHOOGLE_PROXY_LOC'] = args.proxyloc os.environ['WHOOGLE_PROXY_LOC'] = args.proxyloc
if args.https_only: os.environ['HTTPS_ONLY'] = '1' if args.https_only else ''
os.environ['HTTPS_ONLY'] = '1'
if args.debug: if args.debug:
app.run(host=args.host, port=args.port, debug=args.debug) app.run(host=args.host, port=args.port, debug=args.debug)
elif args.unix_socket:
waitress.serve(app, unix_socket=args.unix_socket, unix_socket_perms=args.unix_socket_perms)
else: else:
waitress.serve( waitress.serve(app, listen="{}:{}".format(args.host, args.port))
app,
listen="{}:{}".format(args.host, args.port),
url_prefix=os.environ.get('WHOOGLE_URL_PREFIX', ''))

@ -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)"
}
}

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

@ -22,11 +22,6 @@ li {
color: var(--whoogle-dark-text) !important; color: var(--whoogle-dark-text) !important;
} }
.anon-view {
color: var(--whoogle-dark-text) !important;
text-decoration: underline;
}
textarea { textarea {
background: var(--whoogle-dark-page-bg) !important; background: var(--whoogle-dark-page-bg) !important;
color: var(--whoogle-dark-text) !important; color: var(--whoogle-dark-text) !important;
@ -63,31 +58,17 @@ select {
} }
.ZINbbc { .ZINbbc {
overflow: hidden; background-color: var(--whoogle-dark-result-bg) !important;
box-shadow: 0 0 0 0 !important;
background-color: var(--whoogle-dark-result-bg) !important;
margin-bottom: 10px !important;
border-radius: 8px !important;
} }
.KP7LCb { .bRsWnc {
box-shadow: 0 0 0 0 !important; background-color: var(--whoogle-dark-result-bg) !important;
} }
.BVG0Nb { .BVG0Nb {
box-shadow: 0 0 0 0 !important;
background-color: var(--whoogle-dark-page-bg) !important; background-color: var(--whoogle-dark-page-bg) !important;
} }
.ZINbbc.luh4tb {
background: var(--whoogle-dark-result-bg) !important;
margin-bottom: 24px !important;
}
.bRsWnc {
background-color: var(--whoogle-dark-result-bg) !important;
}
.x54gtf { .x54gtf {
background-color: var(--whoogle-dark-divider) !important; background-color: var(--whoogle-dark-divider) !important;
} }
@ -100,19 +81,9 @@ select {
background-color: var(--whoogle-dark-divider) !important; 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 { #search-bar {
border-color: var(--whoogle-dark-element-bg) !important; border-color: var(--whoogle-dark-element-bg) !important;
color: var(--whoogle-dark-text) !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 { #search-bar:focus {
@ -131,11 +102,11 @@ select {
} }
.collapsible { .collapsible {
color: var(--whoogle-dark-text) !important; color: var(--whoogle-dark-text);
} }
.collapsible:after { .collapsible:after {
color: var(--whoogle-dark-text) !important; color: var(--whoogle-dark-text);
} }
.active { .active {
@ -143,7 +114,7 @@ select {
color: var(--whoogle-dark-contrast-text) !important; color: var(--whoogle-dark-contrast-text) !important;
} }
.content, .result-config { .content {
background-color: var(--whoogle-dark-element-bg) !important; background-color: var(--whoogle-dark-element-bg) !important;
color: var(--whoogle-contrast-text) !important; color: var(--whoogle-contrast-text) !important;
} }
@ -152,14 +123,10 @@ select {
color: var(--whoogle-dark-contrast-text) !important; color: var(--whoogle-dark-contrast-text) !important;
} }
.link { #gh-link {
color: var(--whoogle-dark-contrast-text); color: var(--whoogle-dark-contrast-text);
} }
.link-color {
color: var(--whoogle-dark-result-url) !important;
}
.autocomplete-items { .autocomplete-items {
border: 1px solid var(--whoogle-dark-element-bg); border: 1px solid var(--whoogle-dark-element-bg);
} }
@ -179,40 +146,3 @@ select {
background-color: var(--whoogle-dark-element-bg) !important; background-color: var(--whoogle-dark-element-bg) !important;
color: var(--whoogle-dark-contrast-text) !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;
}

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

@ -13,18 +13,9 @@ header {
border-radius: 2px 0 0 0; border-radius: 2px 0 0 0;
} }
.result-config {
margin-bottom: 10px;
padding: 10px;
border-radius: 8px;
}
.mobile-logo { .mobile-logo {
font: 22px/36px Futura, Arial, sans-serif; font: 22px/36px Futura, Arial, sans-serif;
padding-left: 5px; padding-left: 5px;
display: flex;
justify-content: center;
align-items: center;
} }
.logo-div { .logo-div {
@ -80,171 +71,3 @@ header {
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 6px 1px #2375e8; 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
}
}

@ -12,30 +12,3 @@
height: 40px; height: 40px;
width: 50px; 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;
}

@ -22,11 +22,6 @@ li {
color: var(--whoogle-text) !important; color: var(--whoogle-text) !important;
} }
.anon-view {
color: var(--whoogle-text) !important;
text-decoration: underline;
}
textarea { textarea {
background: var(--whoogle-page-bg) !important; background: var(--whoogle-page-bg) !important;
color: var(--whoogle-text) !important; color: var(--whoogle-text) !important;
@ -38,24 +33,11 @@ select {
} }
.ZINbbc { .ZINbbc {
overflow: hidden; background-color: var(--whoogle-result-bg) !important;
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;
}
.BVG0Nb {
background-color: var(--whoogle-result-bg) !important;
}
.ZINbbc.luh4tb {
background: var(--whoogle-result-bg) !important;
margin-bottom: 24px !important;
} }
.bRsWnc { .bRsWnc {
background-color: var(--whoogle-result-bg) !important; background-color: var(--whoogle-result-bg) !important;
} }
.x54gtf { .x54gtf {
@ -98,7 +80,7 @@ input {
} }
.home-search { .home-search {
border-color: var(--whoogle-element-bg) !important; border: 3px solid var(--whoogle-element-bg) !important;
} }
.search-container { .search-container {
@ -129,7 +111,7 @@ input {
color: var(--whoogle-contrast-text) !important; color: var(--whoogle-contrast-text) !important;
} }
.content, .result-config { .content {
background-color: var(--whoogle-element-bg) !important; background-color: var(--whoogle-element-bg) !important;
color: var(--whoogle-contrast-text) !important; color: var(--whoogle-contrast-text) !important;
} }
@ -138,14 +120,10 @@ input {
color: var(--whoogle-contrast-text); color: var(--whoogle-contrast-text);
} }
.link { #gh-link {
color: var(--whoogle-element-bg); color: var(--whoogle-element-bg);
} }
.link-color {
color: var(--whoogle-result-url) !important;
}
.autocomplete-items { .autocomplete-items {
border: 1px solid var(--whoogle-element-bg); border: 1px solid var(--whoogle-element-bg);
} }
@ -164,42 +142,3 @@ input {
background-color: var(--whoogle-element-bg) !important; background-color: var(--whoogle-element-bg) !important;
color: var(--whoogle-contrast-text) !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;
}

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

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

@ -1,12 +1,3 @@
body {
display: block !important;
margin: auto !important;
}
.vvjwJb {
font-size: 16px !important;
}
.autocomplete { .autocomplete {
position: relative; position: relative;
display: inline-block; display: inline-block;
@ -31,61 +22,6 @@ body {
} }
details summary { details summary {
margin-bottom: 20px; padding: 10px;
font-weight: bold; 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
}
} }

@ -3,7 +3,7 @@
/* LIGHT THEME COLORS */ /* LIGHT THEME COLORS */
--whoogle-logo: #685e79; --whoogle-logo: #685e79;
--whoogle-page-bg: #ffffff; --whoogle-page-bg: #ffffff;
--whoogle-element-bg: #4285f4; --whoogle-element-bg: #685e79;
--whoogle-text: #000000; --whoogle-text: #000000;
--whoogle-contrast-text: #ffffff; --whoogle-contrast-text: #ffffff;
--whoogle-secondary-text: #70757a; --whoogle-secondary-text: #70757a;
@ -11,44 +11,18 @@
--whoogle-result-title: #1967d2; --whoogle-result-title: #1967d2;
--whoogle-result-url: #0d652d; --whoogle-result-url: #0d652d;
--whoogle-result-visited: #4b11a8; --whoogle-result-visited: #4b11a8;
--whoogle-divider: #dfe1e5;
/* DARK THEME COLORS */ /* DARK THEME COLORS */
--whoogle-dark-logo: #685e79; --whoogle-dark-logo: #888888;
--whoogle-dark-page-bg: #101020; --whoogle-dark-page-bg: #080808;
--whoogle-dark-element-bg: #4285f4; --whoogle-dark-element-bg: #111111;
--whoogle-dark-text: #ffffff; --whoogle-dark-text: #dddddd;
--whoogle-dark-contrast-text: #ffffff; --whoogle-dark-contrast-text: #aaaaaa;
--whoogle-dark-secondary-text: #bbbbbb; --whoogle-dark-secondary-text: #8a8b8c;
--whoogle-dark-result-bg: #212131; --whoogle-dark-result-bg: #111111;
--whoogle-dark-result-title: #64a7f6; --whoogle-dark-result-title: #dddddd;
--whoogle-dark-result-url: #34a853; --whoogle-dark-result-url: #eceff4;
--whoogle-dark-result-visited: #bbbbff; --whoogle-dark-result-visited: #959595;
} --whoogle-dark-divider: #111111;
#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;
} }

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

@ -2,8 +2,6 @@ const setupSearchLayout = () => {
// Setup search field // Setup search field
const searchBar = document.getElementById("search-bar"); const searchBar = document.getElementById("search-bar");
const searchBtn = document.getElementById("search-submit"); const searchBtn = document.getElementById("search-submit");
const arrowKeys = [37, 38, 39, 40];
let searchValue = searchBar.value;
// Automatically focus on search field // Automatically focus on search field
searchBar.focus(); searchBar.focus();
@ -13,9 +11,8 @@ const setupSearchLayout = () => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
event.preventDefault(); event.preventDefault();
searchBtn.click(); searchBtn.click();
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) { } else {
searchValue = searchBar.value; handleUserInput(searchBar);
handleUserInput();
} }
}); });
}; };
@ -29,7 +26,7 @@ const setupConfigLayout = () => {
if (content.style.maxHeight) { if (content.style.maxHeight) {
content.style.maxHeight = null; content.style.maxHeight = null;
} else { } else {
content.style.maxHeight = "400px"; content.style.maxHeight = content.scrollHeight + "px";
} }
content.classList.toggle("open"); content.classList.toggle("open");

@ -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));
}

@ -1,67 +1,11 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const advSearchToggle = document.getElementById("adv-search-toggle");
const advSearchDiv = document.getElementById("adv-search-div");
const searchBar = document.getElementById("search-bar"); 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 = () => { searchBar.addEventListener("keyup", function (event) {
let str = window.location.href; if (event.keyCode !== 13) {
n = str.lastIndexOf("/search"); handleUserInput(searchBar);
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";
} else { } 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(); document.getElementById("search-form").submit();
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
searchValue = searchBar.value;
handleUserInput();
} }
}); });
}); });

@ -1,62 +1,44 @@
(function () { (function () {
let searchBar, results; let searchBar, results;
let shift = false; const keymap = {
const keymap = { ArrowUp: goUp,
ArrowUp: goUp, ArrowDown: goDown,
ArrowDown: goDown, k: goUp,
ShiftTab: goUp, j: goDown,
Tab: goDown, '/': focusSearch,
k: goUp, };
j: goDown, let activeIdx = -1;
'/': focusSearch,
}; document.addEventListener('DOMContentLoaded', () => {
let activeIdx = -1; 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.target.tagName === 'INPUT') return true;
if (typeof keymap[e.key] === 'function') {
document.addEventListener('keydown', (e) => { e.preventDefault();
if (e.key === 'Shift') { keymap[e.key]();
shift = true; }
} });
if (e.target.tagName === 'INPUT') return true; function goUp () {
if (typeof keymap[e.key] === 'function') { if (activeIdx > 0) focusResult(activeIdx - 1);
e.preventDefault(); 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') { function focusResult (idx) {
shift = false; activeIdx = idx;
} results[activeIdx].scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
}); results[activeIdx].focus();
}
function goUp () {
if (activeIdx > 0) focusResult(activeIdx - 1); function focusSearch () {
else focusSearch(); activeIdx = -1;
} searchBar.focus();
}
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();
}
}()); }());

@ -1,10 +1,6 @@
const checkForTracking = () => { const checkForTracking = () => {
const mainDiv = document.getElementById("main"); const mainDiv = document.getElementById("main");
const searchBar = document.getElementById("search-bar"); const query = document.getElementById("search-bar").value.replace(/\s+/g, '');
// some pages (e.g. images) do not have these
if (!mainDiv || !searchBar)
return;
const query = searchBar.value.replace(/\s+/g, '');
// Note: regex functions for checking for tracking queries were derived // Note: regex functions for checking for tracking queries were derived
// from here -- https://stackoverflow.com/questions/619977 // from here -- https://stackoverflow.com/questions/619977
@ -63,14 +59,11 @@ document.addEventListener("DOMContentLoaded", function() {
checkForTracking(); checkForTracking();
// Clear input if reset button tapped // Clear input if reset button tapped
const searchBar = document.getElementById("search-bar"); const search = document.getElementById("search-bar");
const resetBtn = document.getElementById("search-reset"); const resetBtn = document.getElementById("search-reset");
// some pages (e.g. images) do not have these
if (!searchBar || !resetBtn)
return;
resetBtn.addEventListener("click", event => { resetBtn.addEventListener("click", event => {
event.preventDefault(); event.preventDefault();
searchBar.value = ""; search.value = "";
searchBar.focus(); search.focus();
}); });
}); });

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

@ -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
}
}

@ -4,12 +4,11 @@
{"name": "Afrikaans (Afrikaans)", "value": "lang_af"}, {"name": "Afrikaans (Afrikaans)", "value": "lang_af"},
{"name": "Arabic (عربى)", "value": "lang_ar"}, {"name": "Arabic (عربى)", "value": "lang_ar"},
{"name": "Armenian (հայերեն)", "value": "lang_hy"}, {"name": "Armenian (հայերեն)", "value": "lang_hy"},
{"name": "Azerbaijani (Azərbaycanca)", "value": "lang_az"},
{"name": "Belarusian (Беларуская)", "value": "lang_be"}, {"name": "Belarusian (Беларуская)", "value": "lang_be"},
{"name": "Bulgarian (български)", "value": "lang_bg"}, {"name": "Bulgarian (български)", "value": "lang_bg"},
{"name": "Catalan (Català)", "value": "lang_ca"}, {"name": "Catalan (Català)", "value": "lang_ca"},
{"name": "Chinese, Simplified (简体中文)", "value": "lang_zh-CN"}, {"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": "Croatian (Hrvatski)", "value": "lang_hr"},
{"name": "Czech (čeština)", "value": "lang_cs"}, {"name": "Czech (čeština)", "value": "lang_cs"},
{"name": "Danish (Dansk)", "value": "lang_da"}, {"name": "Danish (Dansk)", "value": "lang_da"},
@ -29,17 +28,15 @@
{"name": "Italian (Italiano)", "value": "lang_it"}, {"name": "Italian (Italiano)", "value": "lang_it"},
{"name": "Japanese (日本語)", "value": "lang_ja"}, {"name": "Japanese (日本語)", "value": "lang_ja"},
{"name": "Korean (한국어)", "value": "lang_ko"}, {"name": "Korean (한국어)", "value": "lang_ko"},
{"name": "Kurdish (Kurdî)", "value": "lang_ku"},
{"name": "Latvian (Latvietis)", "value": "lang_lv"}, {"name": "Latvian (Latvietis)", "value": "lang_lv"},
{"name": "Lithuanian (Lietuvis)", "value": "lang_lt"}, {"name": "Lithuanian (Lietuvis)", "value": "lang_lt"},
{"name": "Norwegian (Norwegian)", "value": "lang_no"}, {"name": "Norwegian (Norwegian)", "value": "lang_no"},
{"name": "Persian (فارسی)", "value": "lang_fa"}, {"name": "Persian (فارسی)", "value": "lang_fa"},
{"name": "Polish (Polskie)", "value": "lang_pl"}, {"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": "Romanian (Română)", "value": "lang_ro"},
{"name": "Russian (русский)", "value": "lang_ru"}, {"name": "Russian (русский)", "value": "lang_ru"},
{"name": "Serbian (Српски)", "value": "lang_sr"}, {"name": "Serbian (Српски)", "value": "lang_sr"},
{"name": "Sinhala (සිංහල)", "value": "lang_si"},
{"name": "Slovak (Slovák)", "value": "lang_sk"}, {"name": "Slovak (Slovák)", "value": "lang_sk"},
{"name": "Slovenian (Slovenščina)", "value": "lang_sl"}, {"name": "Slovenian (Slovenščina)", "value": "lang_sl"},
{"name": "Spanish (Español)", "value": "lang_es"}, {"name": "Spanish (Español)", "value": "lang_es"},
@ -47,9 +44,6 @@
{"name": "Swedish (Svenska)", "value": "lang_sv"}, {"name": "Swedish (Svenska)", "value": "lang_sv"},
{"name": "Thai (ไทย)", "value": "lang_th"}, {"name": "Thai (ไทย)", "value": "lang_th"},
{"name": "Turkish (Türk)", "value": "lang_tr"}, {"name": "Turkish (Türk)", "value": "lang_tr"},
{"name": "Ukrainian (Український)", "value": "lang_uk"}, {"name": "Ukranian (Український)", "value": "lang_uk"},
{"name": "Vietnamese (Tiếng Việt)", "value": "lang_vi"}, {"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"}
] ]

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

@ -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

@ -1,260 +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.
console.log("calculating [" + statement + "]");
try {
var result = eval(statement);
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>

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

@ -1,106 +1,6 @@
{% if config.theme %} <h1>Error</h1>
{% if config.theme == 'system' %} <hr>
<style> <p>
@import "{{ cb_url('light-theme.css') }}" screen; Error: "{{ error_message|safe }}"
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark); </p>
</style> <a href="/">Return Home</a>
{% else %}
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
{% endif %}
{% else %}
<link rel="stylesheet" href="{{ cb_url(('dark' if config.dark else 'light') + '-theme.css') }}"/>
{% endif %}
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
<link rel="stylesheet" href="{{ cb_url('error.css') }}">
<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>
</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://duckduckgo.com">DuckDuckGo</a>
<ul>
<li>
<a class="link-color" href="https://duckduckgo.com/search?q={{query}}">
duckduckgo.com/search?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>

@ -1,9 +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 %}
</p>
</footer>

@ -1,95 +1,64 @@
{% if mobile %} {% if mobile %}
<header> <header>
<div class="header-div"> <div style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;" class="bz1lBb">
<form class="search-form header" <form class="search-form Pg70bf" id="search-form" method="POST">
id="search-form" <a class="logo-link mobile-logo"
method="{{ 'GET' if config.get_only else 'POST' }}"> href="/"
<a class="logo-link mobile-logo" href="{{ home_url }}"> style="display:flex; justify-content:center; align-items:center;">
<div id="mobile-header-logo"> <div style="height: 1.75em;">
{{ logo|safe }} {{ logo|safe }}
</div> </div>
</a> </a>
<div class="H0PQec mobile-input-div"> <div class="H0PQec" style="width: 100%;">
<div class="autocomplete-mobile esbc autocomplete"> <div class="sbc esbc autocomplete">
{% if config.preferences %} <input
<input type="hidden" name="preferences" value="{{ config.preferences }}" /> id="search-bar"
{% endif %} autocapitalize="none"
<input autocomplete="off"
id="search-bar" autocorrect="off"
class="mobile-search-bar" spellcheck="false"
autocapitalize="none" class="noHIxc"
autocomplete="off" name="q"
autocorrect="off" style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;
spellcheck="false" color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};"
class="search-bar-input" type="text"
name="q" value="{{ clean_query(query) }}">
type="text" <input style="color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }}" id="search-reset" type="reset" value="x">
value="{{ clean_query(query) }}"
dir="auto">
<input id="search-reset" type="reset" value="x">
<input name="tbm" value="{{ search_type }}" style="display: none"> <input name="tbm" value="{{ search_type }}" style="display: none">
<input name="country" value="{{ config.country }}" style="display: none;">
<input type="submit" style="display: none;"> <input type="submit" style="display: none;">
<div class="sc"></div> <div class="sc"></div>
</div> </div>
</div> </div>
</form> </form>
</div> </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> </header>
{% else %} {% else %}
<header> <header>
<div class="logo-div"> <div class="logo-div">
<a class="logo-link" href="{{ home_url }}"> <a class="logo-link" href="/">
<div class="desktop-header-logo"> <div style="height: 1.65em;">
{{ logo|safe }} {{ logo|safe }}
</div> </div>
</a> </a>
</div> </div>
<div class="search-div"> <div class="search-div">
<form id="search-form" <form id="search-form" class="search-form" id="sf" method="POST">
class="search-form" <div class="autocomplete" style="width: 100%; flex: 1">
id="sf"
method="{{ 'GET' if config.get_only else 'POST' }}">
<div class="autocomplete header-autocomplete">
<div style="width: 100%; display: flex"> <div style="width: 100%; display: flex">
{% if config.preferences %} <input
<input type="hidden" name="preferences" value="{{ config.preferences }}" /> id="search-bar"
{% endif %} autocapitalize="none"
<input autocomplete="off"
id="search-bar" autocorrect="off"
autocapitalize="none" class="search-bar-desktop noHIxc"
autocomplete="off" name="q"
autocorrect="off" spellcheck="false"
class="search-bar-desktop search-bar-input" type="text"
name="q" value="{{ clean_query(query) }}"
spellcheck="false" style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;
type="text" color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};
value="{{ clean_query(query) }}" border-bottom: {{ '2px solid var(--whoogle-dark-element-bg)' if config.dark else '0px' }};">
dir="auto">
<input name="tbm" value="{{ search_type }}" style="display: none"> <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;"> <input type="submit" style="display: none;">
<div class="sc"></div> <div class="sc"></div>
</div> </div>
@ -97,62 +66,6 @@
</form> </form>
</div> </div>
</header> </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 %} {% 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>
<script type="text/javascript" src="{{ cb_url('header.js') }}"></script> <script type="text/javascript" src="static/js/header.js"></script>

@ -1,390 +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> <style>
html { 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%}
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 8px;
margin: 0 auto;
max-width: 736px;
}
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;
height: 180px;
}
.GpQGbf {
margin: auto;
border-collapse: collapse;
border-spacing: 0;
width: 100%;
}
.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 {
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> </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>
<div> <div>
<div class="lIMUZd"> <div class="lIMUZd">
<table class="By0U9"> <table class="By0U9">
<!-- correction suggested --> <!-- correction suggested -->
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<table class="GpQGbf"> <table class="GpQGbf">
{% for i in range((length // 4) + 1) %} {% for i in range((length // 4) + 1) %}
<tr> <tr>
{% for j in range([length - (i*4), 4]|min) %} {% for j in range([length - (i*4), 4]|min) %}
<td align="center" class="e3goi"> <td align="center" class="e3goi">
<div class="svla5d"> <div class="svla5d">
<div> <div>
<div class="lIMUZd"> <div class="lIMUZd">
<div> <div>
<table class="TxbwNb"> <table class="TxbwNb">
<tr> <tr>
<td> <td>
<a href="{{ results[(i*4)+j].web_page }}"> <a href="{{ results[(i*4)+j].webpage }}">
<div class="RAyV4b"> <div class="RAyV4b">
<img <img alt="" class="t0fcAb" src="{{ results[(i*4)+j].img_tbn }}"/>
alt="" </div>
class="t0fcAb" </a>
src="{{ results[(i*4)+j].img_tbn }}" </td>
/> </tr>
</div> <tr>
</a> <td>
</td> <a href="{{ results[(i*4)+j].webpage }}">
</tr> <div class="Tor4Ec">
<tr> <span class="qXLe6d x3G5ab">
<td> <span class="fYyStc">
<a href="{{ results[(i*4)+j].web_page }}"> {{ results[(i*4)+j].domain }}
<div class="Tor4Ec"> </span>
<span class="qXLe6d x3G5ab"> </span>
<span class="fYyStc"> </div>
{{ results[(i*4)+j].domain }} </a>
</span> <a href="{{ results[(i*4)+j].img_url }}">
</span> <div class="Tor4Ec">
</div> <span class="qXLe6d F9iS2e">
</a> <span class="fYyStc">
<a href="{{ results[(i*4)+j].img_url }}"> {{ view_label }}
<div class="Tor4Ec"> </span>
<span class="qXLe6d F9iS2e"> </span>
<span class="fYyStc"> {{ view_label }} </span> </div>
</span> </a>
</div> </td>
</a> </tr>
</td> </table>
</tr>
</table>
</div>
</div> </div>
</div> </div>
</div>
</div> </div>
</td> </td>
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div> </div>
<table class="uZgmoc"> <table class="uZgmoc">
<!-- next page object --> <!-- next page object -->
</table> </table>
<br /> <br/>
</div> <div class="TuS8Ad">
<!-- information about user connection -->
<div>
</div>
</body>
</html>

@ -1,275 +1,181 @@
<html style="background: #000;"> <html>
<head> <head>
<link rel="apple-touch-icon" sizes="57x57" href="static/img/favicon/apple-icon-57x57.png"> <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="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="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="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="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="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="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="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="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="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="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="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="icon" type="image/png" sizes="16x16" href="static/img/favicon/favicon-16x16.png">
<link rel="manifest" href="static/img/favicon/manifest.json"> <link rel="manifest" href="static/img/favicon/manifest.json">
<meta name="referrer" content="no-referrer"> <meta name="referrer" content="no-referrer">
<meta name="msapplication-TileColor" content="#ffffff"> <meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png"> <meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png">
{% if autocomplete_enabled == '1' %} <script type="text/javascript" src="static/js/autocomplete.js"></script>
<script src="{{ cb_url('autocomplete.js') }}"></script> <script type="text/javascript" src="static/js/controller.js"></script>
{% endif %} <link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
<script type="text/javascript" src="{{ cb_url('controller.js') }}"></script> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search"> <link rel="stylesheet" href="static/css/variables.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="static/css/main.css">
<link rel="stylesheet" href="{{ cb_url('logo.css') }}"> <link rel="stylesheet" href="static/css/{{ 'dark' if config.dark else 'light' }}-theme.css"/>
{% if config.theme %} <noscript>
{% if config.theme == 'system' %} <style>
<style> #main { display: inherit !important; }
@import "{{ cb_url('light-theme.css') }}" screen; .content { max-height: 720px; padding: 18px; border-radius: 10px; }
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark); .collapsible { display: none; }
</style> </style>
{% else %} </noscript>
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/> <style>{{ config.style }}</style>
{% endif %} <title>Whoogle Search</title>
{% else %} </head>
<link rel="stylesheet" href="{{ cb_url(('dark' if config.dark else 'light') + '-theme.css') }}"/> <body id="main" style="display: none; background-color: {{ '#000' if config.dark else '#fff' }}">
{% endif %} <div class="search-container">
<link rel="stylesheet" href="{{ cb_url('main.css') }}"> <div class="logo-container">
<noscript> {{ logo|safe }}
<style> </div>
#main { <form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}">
display: inherit !important; <div class="search-fields">
} <div class="autocomplete">
<input
.content { type="text"
max-height: 400px; name="q"
padding: 18px; id="search-bar"
border-radius: 10px; class="home-search"
overflow-y: scroll; autofocus="autofocus"
} autocapitalize="none"
spellcheck="false"
.collapsible { autocorrect="off"
display: none; autocomplete="off">
} </div>
</style> <input type="submit" id="search-submit" value="{{ translation['search'] }}">
</noscript> </div>
<style>{{ config.style }}</style> </form>
<title>Whoogle Search</title> {% if not config_disabled %}
</head> <br/>
<body id="main"> <button id="config-collapsible" class="collapsible">{{ translation['config'] }}</button>
<div class="search-container"> <div class="content">
<div class="logo-container"> <div class="config-fields">
{{ logo|safe }} <form id="config-form" action="config" method="post">
</div> <div class="config-div config-div-ctry">
<form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}"> <label for="config-ctry">{{ translation['config-country'] }}: </label>
<div class="search-fields"> <select name="ctry" id="config-ctry">
<div class="autocomplete"> {% for ctry in countries %}
{% if config.preferences %} <option value="{{ ctry.value }}"
<input type="hidden" name="preferences" value="{{ config.preferences }}" /> {% if ctry.value in config.ctry %}
{% endif %} selected
<input {% endif %}>
type="text" {{ ctry.name }}
name="q" </option>
id="search-bar" {% endfor %}
class="home-search" </select>
autofocus="autofocus" <div><span class="info-text"> — {{ translation['config-country-help'] }}</span></div>
autocapitalize="none" </div>
spellcheck="false" <div class="config-div config-div-lang">
autocorrect="off" <label for="config-lang-interface">{{ translation['config-lang'] }}: </label>
autocomplete="off" <select name="lang_interface" id="config-lang-interface">
dir="auto"> {% for lang in languages %}
</div> <option value="{{ lang.value }}"
<input type="submit" id="search-submit" value="{{ translation['search'] }}"> {% if lang.value in config.lang_interface %}
</div> selected
</form> {% endif %}>
{% if not config_disabled %} {{ lang.name }}
<br/> </option>
<button id="config-collapsible" class="collapsible">{{ translation['config'] }}</button> {% endfor %}
<div class="content"> </select>
<div class="config-fields"> </div>
<form id="config-form" action="config" method="post"> <div class="config-div config-div-search-lang">
<div class="config-options"> <label for="config-lang-search">{{ translation['config-lang-search'] }}: </label>
<div class="config-div config-div-country"> <select name="lang_search" id="config-lang-search">
<label for="config-country">{{ translation['config-country'] }}: </label> {% for lang in languages %}
<select name="country" id="config-country"> <option value="{{ lang.value }}"
{% for country in countries %} {% if lang.value in config.lang_search %}
<option value="{{ country.value }}" selected
{% if ( {% endif %}>
config.country != '' and config.country in country.value {{ lang.name }}
) or ( </option>
config.country == '' and country.value == '') {% endfor %}
%} </select>
selected </div>
{% endif %}> <div class="config-div config-div-near">
{{ country.name }} <label for="config-near">{{ translation['config-near'] }}: </label>
</option> <input type="text" name="near" id="config-near" placeholder="City Name" value="{{ config.near }}">
{% endfor %} </div>
</select> <div class="config-div config-div-block">
</div> <label for="config-block">{{ translation['config-block'] }}: </label>
<div class="config-div"> <input type="text" name="block" id="config-block" placeholder="Comma-separated site list" value="{{ config.block }}">
<label for="config-time-period">{{ translation['config-time-period'] }}</label> </div>
<select name="tbs" id="config-time-period"> <div class="config-div config-div-nojs">
{% for time_period in time_periods %} <label for="config-nojs">{{ translation['config-nojs'] }}: </label>
<option value="{{ time_period.value }}" <input type="checkbox" name="nojs" id="config-nojs" {{ 'checked' if config.nojs else '' }}>
{% if ( </div>
config.tbs != '' and config.tbs in time_period.value <div class="config-div config-div-dark">
) or ( <label for="config-dark">{{ translation['config-dark'] }}: </label>
config.tbs == '' and time_period.value == '') <input type="checkbox" name="dark" id="config-dark" {{ 'checked' if config.dark else '' }}>
%} </div>
selected <div class="config-div config-div-safe">
{% endif %}> <label for="config-safe">{{ translation['config-safe'] }}: </label>
{{ translation[time_period.value] }} <input type="checkbox" name="safe" id="config-safe" {{ 'checked' if config.safe else '' }}>
</option> </div>
{% endfor %} <div class="config-div config-div-alts">
</select> <label class="tooltip" for="config-alts">{{ translation['config-alts'] }}: </label>
</div> <input type="checkbox" name="alts" id="config-alts" {{ 'checked' if config.alts else '' }}>
<div class="config-div config-div-lang"> <div><span class="info-text"> — {{ translation['config-alts-help'] }}</span></div>
<label for="config-lang-interface">{{ translation['config-lang'] }}: </label> </div>
<select name="lang_interface" id="config-lang-interface"> <div class="config-div config-div-new-tab">
{% for lang in languages %} <label for="config-new-tab">{{ translation['config-new-tab'] }}: </label>
<option value="{{ lang.value }}" <input type="checkbox" name="new_tab" id="config-new-tab" {{ 'checked' if config.new_tab else '' }}>
{% if lang.value in config.lang_interface %} </div>
selected <div class="config-div config-div-view-image">
{% endif %}> <label for="config-view-image">{{ translation['config-images'] }}: </label>
{{ lang.name }} <input type="checkbox" name="view_image" id="config-view-image" {{ 'checked' if config.view_image else '' }}>
</option> <div><span class="info-text"> — {{ translation['config-images-help'] }}</span></div>
{% endfor %} </div>
</select> <div class="config-div config-div-tor">
</div> <label for="config-tor">{{ translation['config-tor'] }}: {{ '' if tor_available else 'Unavailable' }}</label>
<div class="config-div config-div-search-lang"> <input type="checkbox" name="tor" id="config-tor" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>
<label for="config-lang-search">{{ translation['config-lang-search'] }}: </label> </div>
<select name="lang_search" id="config-lang-search"> <div class="config-div config-div-get-only">
{% for lang in languages %} <label for="config-get-only">{{ translation['config-get-only'] }}: </label>
<option value="{{ lang.value }}" <input type="checkbox" name="get_only" id="config-get-only" {{ 'checked' if config.get_only else '' }}>
{% if lang.value in config.lang_search %} </div>
selected <div class="config-div config-div-root-url">
{% endif %}> <label for="config-url">{{ translation['config-url'] }}: </label>
{{ lang.name }} <input type="text" name="url" id="config-url" value="{{ config.url }}">
</option> </div>
{% endfor %} <div class="config-div config-div-custom-css">
</select> <label for="config-style">{{ translation['config-css'] }}:</label>
</div> <textarea
<div class="config-div config-div-near"> name="style"
<label for="config-near">{{ translation['config-near'] }}: </label> id="config-style"
<input type="text" name="near" id="config-near" autocapitalize="off"
placeholder="{{ translation['config-near-help'] }}" value="{{ config.near }}"> autocomplete="off"
</div> spellcheck="false"
<div class="config-div config-div-block"> autocorrect="off"
<label for="config-block">{{ translation['config-block'] }}: </label> value="">
<input type="text" name="block" id="config-block" {{ config.style.replace('\t', '') }}
placeholder="{{ translation['config-block-help'] }}" value="{{ config.block }}"> </textarea>
</div> </div>
<div class="config-div config-div-block"> <div class="config-div">
<label for="config-block-title">{{ translation['config-block-title'] }}: </label> <input type="submit" id="config-load" value="{{ translation['load'] }}">&nbsp;
<input type="text" name="block_title" id="config-block" <input type="submit" id="config-submit" value="{{ translation['apply'] }}">&nbsp;
placeholder="{{ translation['config-block-title-help'] }}" <input type="submit" id="config-save" value="{{ translation['save-as'] }}">
value="{{ config.block_title }}"> </div>
</div> </form>
<div class="config-div config-div-block"> </div>
<label for="config-block-url">{{ translation['config-block-url'] }}: </label> </div>
<input type="text" name="block_url" id="config-block" {% endif %}
placeholder="{{ translation['config-block-url-help'] }}" value="{{ config.block_url }}"> </div>
</div> <footer>
<div class="config-div config-div-anon-view"> <p style="color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};">
<label for="config-anon-view">{{ translation['config-anon-view'] }}: </label> Whoogle Search v{{ version_number }} ||
<input type="checkbox" name="anon_view" id="config-anon-view" {{ 'checked' if config.anon_view else '' }}> <a id="gh-link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
</div> </p>
<div class="config-div config-div-nojs"> </footer>
<label for="config-nojs">{{ translation['config-nojs'] }}: </label> </body>
<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-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-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-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> </html>

@ -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"> <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> <defs>
<style> <style>
</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="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> <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> </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

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

@ -1,56 +1,7 @@
import json import json
import requests import requests
import urllib.parse as urlparse
import os
import glob
bangs_dict = {} DDG_BANGS = 'https://duckduckgo.com/bang.v255.js'
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:
bangs |= json.load(open(bang_file))
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()))
def gen_bangs_json(bangs_file: str) -> None: def gen_bangs_json(bangs_file: str) -> None:
@ -84,68 +35,27 @@ def gen_bangs_json(bangs_file: str) -> None:
} }
json.dump(bangs_data, open(bangs_file, 'w')) json.dump(bangs_data, open(bangs_file, 'w'))
print('* Finished creating ddg bangs json')
load_all_bangs(bangs_file, bangs_data)
def suggest_bang(query: str) -> list[str]:
"""Suggests bangs for a user's query
Args:
query: The search query
Returns: def resolve_bang(query: str, bangs_dict: dict) -> str:
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:
"""Transform's a user's query to a bang search, if an operator is found """Transform's a user's query to a bang search, if an operator is found
Args: Args:
query: The search query 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: Returns:
str: A formatted redirect for a bang search, or an empty str if there 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 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 ! not in query simply return (speed up processing) if operator not in split_query:
if '!' not in query: continue
return ''
return bangs_dict[operator]['url'].format(
split_query = query.strip().split(' ') query.replace(operator, '').strip())
# 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 '' return ''

@ -1,139 +0,0 @@
import base64
from bs4 import BeautifulSoup as bsoup
from cryptography.fernet import Fernet
from flask import Request
import hashlib
import io
import os
import re
from requests import exceptions, get
from urllib.parse import urlparse
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
"""
domain = urlparse(url).netloc
response = get(f'{ddg_favicon_site}/{domain}.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()
else:
return placeholder_img
def gen_file_hash(path: str, static_file: str) -> str:
file_contents = open(os.path.join(path, static_file), 'rb').read()
file_hash = hashlib.md5(file_contents).hexdigest()[:8]
filename_split = os.path.splitext(static_file)
return 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')
val = val.lower() in ('true', 't', '1', 'yes', 'y')
return val
def get_client_ip(r: Request) -> str:
if r.environ.get('HTTP_X_FORWARDED_FOR') is None:
return r.environ['REMOTE_ADDR']
else:
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
try:
update = bsoup(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
except (exceptions.ConnectionError, AttributeError):
# Ignore failures, assume current version is up to date
has_update = ''
return has_update
def get_abs_url(url, page_url):
# Creates a valid absolute URL using a partial or relative URL
if url.startswith('//'):
return f'https:{url}'
elif url.startswith('/'):
return f'{urlparse(page_url).netloc}{url}'
elif url.startswith('./'):
return f'{page_url}{url[2:]}'
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()

@ -1,123 +1,34 @@
from app.models.config import Config from bs4 import BeautifulSoup
from app.models.endpoint import Endpoint
from app.utils.misc import list_to_dict
from bs4 import BeautifulSoup, NavigableString
import copy
from flask import current_app
import html
import os import os
import urllib.parse as urlparse import urllib.parse as urlparse
from urllib.parse import parse_qs from urllib.parse import parse_qs
import re
import warnings
SKIP_ARGS = ['ref_src', 'utm'] SKIP_ARGS = ['ref_src', 'utm']
SKIP_PREFIX = ['//www.', '//mobile.', '//m.'] SKIP_PREFIX = ['//www.', '//mobile.', '//m.']
GOOG_STATIC = 'www.gstatic.com' 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' GOOG_IMG = '/images/branding/searchlogo/1x/googlelogo'
LOGO_URL = GOOG_IMG + '_desk' LOGO_URL = GOOG_IMG + '_desk'
BLANK_B64 = ('data:image/png;base64,' BLANK_B64 = ('data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkw' 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkw'
'AIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC') 'AIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC')
# Ad keywords # Ad keywords
BLACKLIST = [ BLACKLIST = [
'ad', 'ads', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告', 'ad', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告', 'Reklama',
'Reklama', 'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan', 'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan', '広告', 'Augl.',
'広告', 'Augl.', 'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन', 'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन', 'Reklam', 'آگهی',
'Reklam', 'آگهی', 'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés', 'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés', 'Anúncio'
'Anúncio', 'Quảng cáo','โฆษณา', 'sponsored', 'patrocinado', 'gesponsert'
] ]
SITE_ALTS = { SITE_ALTS = {
'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'farside.link/nitter'), 'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'nitter.net'),
'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'farside.link/invidious'), 'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'invidious.snopyta.org'),
'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'farside.link/libreddit'), 'instagram.com': os.getenv('WHOOGLE_ALT_IG', 'bibliogram.art/u'),
**dict.fromkeys([ 'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'libredd.it')
'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')
} }
# 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('.*[@_!#$%^&*()<>?/\|}{~:].*', 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(
text=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: def has_ad_content(element: str) -> bool:
"""Inspects an HTML element for ad related content """Inspects an HTML element for ad related content
@ -129,8 +40,7 @@ def has_ad_content(element: str) -> bool:
bool: True/False for the element containing an ad bool: True/False for the element containing an ad
""" """
element_str = ''.join(filter(str.isalpha, element)) return (element.upper() in (value.upper() for value in BLACKLIST)
return (element_str.upper() in (value.upper() for value in BLACKLIST)
or '' in element) or '' in element)
@ -144,89 +54,35 @@ def get_first_link(soup: BeautifulSoup) -> str:
str: A str link to the first result str: A str link to the first result
""" """
first_link = ''
orig_details = []
# Temporarily remove details so we don't grab those links
for details in soup.find_all('details'):
temp_details = soup.new_tag('removed_details')
orig_details.append(details.replace_with(temp_details))
# Replace hrefs with only the intended destination (no "utm" type tags) # Replace hrefs with only the intended destination (no "utm" type tags)
for a in soup.find_all('a', href=True): for a in soup.find_all('a', href=True):
# Return the first search result URL # Return the first search result URL
if a['href'].startswith('http://') or a['href'].startswith('https://'): if 'url?q=' in a['href']:
first_link = a['href'] return filter_link_args(a['href'])
break return ''
# Add the details back
for orig_detail, details in zip(orig_details, soup.find_all('removed_details')):
details.replace_with(orig_detail)
return first_link
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 """Returns an alternative to a particular site, if one is configured
Args: Args:
link: A string result URL to check against the site_alts map 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
Returns: Returns:
str: An updated (or ignored) result link 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 for site_key in SITE_ALTS.keys():
# is used for wikiless translations. if site_key not in link:
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]:
continue continue
# Wikipedia -> Wikiless replacements require the subdomain (if it's link = link.replace(site_key, SITE_ALTS[site_key])
# 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)
break break
for prefix in SKIP_PREFIX:
link = link.replace(prefix, '//')
return link return link
@ -274,192 +130,8 @@ def append_nojs(result: BeautifulSoup) -> None:
""" """
nojs_link = BeautifulSoup(features='html.parser').new_tag('a') nojs_link = BeautifulSoup(features='html.parser').new_tag('a')
nojs_link['href'] = f'{Endpoint.window}?nojs=1&location=' + result['href'] nojs_link['href'] = '/window?location=' + result['href']
nojs_link.string = ' NoJS Link' 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) 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

@ -1,14 +1,13 @@
import os import os
import re
from typing import Any 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 bs4 import BeautifulSoup as bsoup from bs4 import BeautifulSoup as bsoup
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from flask import g 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>' TOR_BANNER = '<hr><h1 style="text-align: center">You are using Tor</h1><hr>'
CAPTCHA = 'div class="g-recaptcha"' CAPTCHA = 'div class="g-recaptcha"'
@ -53,18 +52,16 @@ class Search:
Attributes: Attributes:
request: the incoming flask request request: the incoming flask request
config: the current user config settings 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): def __init__(self, request, config, session, cookies_disabled=False):
method = request.method method = request.method
self.request = request
self.request_params = request.args if method == 'GET' else request.form self.request_params = request.args if method == 'GET' else request.form
self.user_agent = request.headers.get('User-Agent') self.user_agent = request.headers.get('User-Agent')
self.feeling_lucky = False self.feeling_lucky = False
self.config = config self.config = config
self.session_key = session_key self.session = session
self.query = '' self.query = ''
self.widget = ''
self.cookies_disabled = cookies_disabled self.cookies_disabled = cookies_disabled
self.search_type = self.request_params.get( self.search_type = self.request_params.get(
'tbm') if 'tbm' in self.request_params else '' 'tbm') if 'tbm' in self.request_params else ''
@ -98,26 +95,13 @@ class Search:
else: else:
# Attempt to decrypt if this is an internal link # Attempt to decrypt if this is an internal link
try: try:
q = Fernet(self.session_key).decrypt(q.encode()).decode() q = Fernet(self.session['key']).decrypt(q.encode()).decode()
except InvalidToken: except InvalidToken:
pass pass
# Strip '!' for "feeling lucky" queries # Strip leading '! ' for "feeling lucky" queries
if match := re.search("(^|\s)!($|\s)", q): self.feeling_lucky = q.startswith('! ')
self.feeling_lucky = True self.query = q[2:] if self.feeling_lucky else q
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
return self.query return self.query
def generate_response(self) -> str: def generate_response(self) -> str:
@ -129,21 +113,14 @@ class Search:
""" """
mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent 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( content_filter = Filter(self.session['key'],
self.request,
self.request.url_root,
root=True)
content_filter = Filter(self.session_key,
root_url=root_url,
mobile=mobile, mobile=mobile,
config=self.config, config=self.config)
query=self.query)
full_query = gen_query(self.query, full_query = gen_query(self.query,
self.request_params, self.request_params,
self.config) self.config,
self.full_query = full_query content_filter.near)
# force mobile search when view image is true and # force mobile search when view image is true and
# the request is not already made by a mobile # the request is not already made by a mobile
@ -152,40 +129,35 @@ class Search:
and not g.user_request.mobile) and not g.user_request.mobile)
get_body = g.user_request.send(query=full_query, get_body = g.user_request.send(query=full_query,
force_mobile=view_image, force_mobile=view_image)
user_agent=self.user_agent)
# Produce cleanable html soup from response # Produce cleanable html soup from response
get_body_safed = get_body.text.replace("&lt;","andlt;").replace("&gt;","andgt;") html_soup = bsoup(content_filter.reskin(get_body.text), 'html.parser')
html_soup = bsoup(get_body_safed, 'html.parser')
# Replace current soup if view_image is active # Replace current soup if view_image is active
if view_image: if view_image:
html_soup = content_filter.view_image(html_soup) html_soup = content_filter.view_image(html_soup)
# Indicate whether or not a Tor connection is active # Indicate whether or not a Tor connection is active
tor_banner = bsoup('', 'html.parser')
if g.user_request.tor_valid: if g.user_request.tor_valid:
html_soup.insert(0, bsoup(TOR_BANNER, 'html.parser')) 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 self.feeling_lucky:
if lucky_link := get_first_link(formatted_results): return get_first_link(html_soup)
return lucky_link 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)
# Append user config to all search links, if available for k, v in
param_str = ''.join('&{}={}'.format(k, v) self.request_params.to_dict(flat=True).items()
for k, v in if self.config.is_safe_key(k))
self.request_params.to_dict(flat=True).items() for link in formatted_results.find_all('a', href=True):
if self.config.is_safe_key(k)) if 'search?' not in link['href'] or link['href'].index(
for link in formatted_results.find_all('a', href=True): 'search?') > 1:
link['rel'] = "nofollow noopener noreferrer" continue
if 'search?' not in link['href'] or link['href'].index( link['href'] += param_str
'search?') > 1:
continue return str(formatted_results)
link['href'] += param_str
return str(formatted_results)

@ -1,10 +1,10 @@
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from flask import current_app as app 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 """Generates a key for encrypting searches and element URLs
Args: Args:
@ -16,6 +16,9 @@ def generate_key() -> bytes:
str: A unique Fernet key str: A unique Fernet key
""" """
if cookies_disabled:
return app.default_key
# Generate/regenerate unique key per user # Generate/regenerate unique key per user
return Fernet.generate_key() return Fernet.generate_key()

@ -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

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

@ -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/

@ -1,23 +0,0 @@
apiVersion: v2
name: whoogle
description: A self hosted search engine on Kubernetes
type: application
version: 0.1.0
appVersion: 0.8.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

@ -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 }}

@ -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 }}

@ -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 }}

@ -1,28 +0,0 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2beta1
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
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

@ -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 }}

@ -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 }}

@ -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 }}

@ -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

@ -1,114 +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_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: {}

@ -1,80 +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_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

@ -1,25 +1,26 @@
# can't use mem_limit in a 3.x docker-compose file in non swarm mode # cant use mem_limit in a 3.x docker-compose file in non swarm mode
# see https://github.com/docker/compose/issues/4513 # see https://github.com/docker/compose/issues/4513
version: "2.4" version: "2.4"
services: services:
whoogle-search: whoogle-search:
image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search} image: benbusby/whoogle-search
container_name: whoogle-search container_name: whoogle-search
restart: unless-stopped restart: unless-stopped
pids_limit: 50 pids_limit: 50
mem_limit: 256mb mem_limit: 256mb
memswap_limit: 256mb memswap_limit: 256mb
# user debian-tor from tor package # user debian-tor from tor package
user: whoogle user: '102'
security_opt: security_opt:
- no-new-privileges - no-new-privileges
cap_drop: cap_drop:
- ALL - ALL
read_only: true
tmpfs: tmpfs:
- /config/:size=10M,uid=927,gid=927,mode=1700 - /config/:size=10M,uid=102,gid=102,mode=1700
- /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700 - /var/lib/tor/:size=10M,uid=102,gid=102,mode=1700
- /run/tor/:size=1M,uid=927,gid=927,mode=1700 - /run/tor/:size=1M,uid=102,gid=102,mode=1700
#environment: # Uncomment to configure environment variables #environment: # Uncomment to configure environment variables
# Basic auth configuration, uncomment to enable # Basic auth configuration, uncomment to enable
#- WHOOGLE_USER=<auth username> #- WHOOGLE_USER=<auth username>
@ -31,17 +32,11 @@ services:
#- WHOOGLE_PROXY_LOC=<proxy host/ip> #- WHOOGLE_PROXY_LOC=<proxy host/ip>
# Site alternative configurations, uncomment to enable # Site alternative configurations, uncomment to enable
# Note: If not set, the feature will still be available # Note: If not set, the feature will still be available
# with default values. # with default values.
#- WHOOGLE_ALT_TW=farside.link/nitter #- WHOOGLE_ALT_TW=nitter.net
#- WHOOGLE_ALT_YT=farside.link/invidious #- WHOOGLE_ALT_YT=invidious.snopyta.org
#- WHOOGLE_ALT_IG=farside.link/bibliogram/u #- WHOOGLE_ALT_IG=bibliogram.art/u
#- WHOOGLE_ALT_RD=farside.link/libreddit #- WHOOGLE_ALT_RD=libredd.it
#- 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
#env_file: # Alternatively, load variables from whoogle.env #env_file: # Alternatively, load variables from whoogle.env
#- whoogle.env #- whoogle.env
ports: ports:

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

@ -1,24 +0,0 @@
https://search.albony.xyz
https://search.garudalinux.org
https://search.dr460nf1r3.org
https://search.nezumi.party
https://s.tokhmi.xyz
https://search.sethforprivacy.com
https://whoogle.dcs0.hu
https://whoogle.lunar.icu
https://gowogle.voring.me
https://whoogle.privacydev.net
https://whoogle.hostux.net
https://wg.vern.cc
https://whoogle.hxvy0.gq
https://whoogle.ungovernable.men
https://whoogle2.ungovernable.men
https://whoogle3.ungovernable.men
https://wgl.frail.duckdns.org
https://whoogle.no-logs.com
https://whoogle.ftw.lol
https://whoogle-search--replitcomreside.repl.co
https://search.notrustverify.ch
https://whoogle.datura.network
https://whoogle.yepserver.xyz
https://search.snine.nl

@ -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)

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

@ -1,33 +1,7 @@
#!/bin/sh #!/bin/bash
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
if [ "$(whoami)" != "root" ]; then if [ "$(whoami)" != "root" ]; then
tor -f /etc/tor/torrc tor -f /etc/tor/torrc
else else
if (grep alpine /etc/os-release >/dev/null); then service tor start
rc-service tor start
else
service tor start
fi
fi fi

@ -6,7 +6,3 @@ CookieAuthFileGroupReadable 1
ExtORPortCookieAuthFileGroupReadable 1 ExtORPortCookieAuthFileGroupReadable 1
CacheDirectoryGroupReadable 1 CacheDirectoryGroupReadable 1
CookieAuthFile /var/lib/tor/control_auth_cookie 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

@ -1,67 +0,0 @@
import json
import pathlib
import requests
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 = requests.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))

@ -1,3 +0,0 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

@ -1,37 +1,34 @@
attrs==22.2.0 attrs==19.3.0
beautifulsoup4==4.11.2 beautifulsoup4==4.8.2
brotli==1.0.9 bs4==0.0.1
cachelib==0.10.2 cachelib==0.1
certifi==2023.7.22 certifi==2020.4.5.1
cffi==1.15.1 cffi==1.13.2
chardet==5.1.0 chardet==3.0.4
click==8.1.3 Click==7.0
cryptography==3.3.2; platform_machine == 'armv7l' cryptography==3.3.2
cryptography==42.0.4; platform_machine != 'armv7l' Flask==1.1.1
cssutils==2.6.0 Flask-Session==0.3.2
defusedxml==0.7.1 idna==2.9
Flask==2.3.2 itsdangerous==1.1.0
idna==3.4 Jinja2==2.11.3
itsdangerous==2.1.2 MarkupSafe==1.1.1
Jinja2==3.1.3 more-itertools==8.3.0
MarkupSafe==2.1.2 packaging==20.4
more-itertools==9.0.0 pluggy==0.13.1
packaging==23.0 py==1.10.0
pluggy==1.0.0 pycodestyle==2.6.0
pycodestyle==2.10.0 pycparser==2.19
pycparser==2.21 pyOpenSSL==19.1.0
pyOpenSSL==19.1.0; platform_machine == 'armv7l' pyparsing==2.4.7
pyOpenSSL==24.0.0; platform_machine != 'armv7l'
pyparsing==3.0.9
PySocks==1.7.1 PySocks==1.7.1
pytest==7.2.1 pytest==5.4.1
python-dateutil==2.8.2 python-dateutil==2.8.1
requests==2.31.0 requests==2.25.1
soupsieve==2.4 soupsieve==1.9.5
stem==1.8.1 stem==1.8.0
urllib3==1.26.18 urllib3==1.26.5
validators==0.22.0 waitress==1.4.3
waitress==2.1.2 wcwidth==0.1.9
wcwidth==0.2.6 Werkzeug==0.16.0
Werkzeug==3.0.1 python-dotenv==0.16.0
python-dotenv==0.21.1

25
run

@ -1,37 +1,26 @@
#!/bin/sh #!/bin/bash
# Usage: # Usage:
# ./run # Runs the full web app # ./run # Runs the full web app
# ./run test # Runs the testing suite # ./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 # Set directory to serve static content from
SUBDIR="${1:-app}" SUBDIR="${1:-app}"
export APP_ROOT="$SCRIPT_DIR/$SUBDIR" export APP_ROOT="$SCRIPT_DIR/$SUBDIR"
export STATIC_FOLDER="$APP_ROOT/static" 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 # Check for regular vs test run
if [ "$SUBDIR" = "test" ]; then if [[ "$SUBDIR" == "test" ]]; then
# Set up static files for testing # Set up static files for testing
rm -rf "$STATIC_FOLDER" rm -rf "$STATIC_FOLDER"
ln -s "$SCRIPT_DIR/app/static" "$STATIC_FOLDER" ln -s "$SCRIPT_DIR/app/static" "$STATIC_FOLDER"
pytest -sv pytest -sv
else else
mkdir -p "$STATIC_FOLDER" mkdir -p "$STATIC_FOLDER"
python3 -um app \
if [ ! -z "$UNIX_SOCKET" ]; then --host "${ADDRESS:-0.0.0.0}" \
python3 -um app \ --port "${PORT:-"${EXPOSE_PORT:-5000}"}"
--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
fi fi

@ -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
requests
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

@ -0,0 +1,29 @@
import setuptools
long_description = open('README.md', 'r').read()
requirements = list(open('requirements.txt', 'r'))
setuptools.setup(
author='Ben Busby',
author_email='benbusby@protonmail.com',
name='whoogle-search',
version='0.5.4',
include_package_data=True,
install_requires=requirements,
description='Self-hosted, ad-free, privacy-respecting metasearch engine',
long_description=long_description,
long_description_content_type='text/markdown',
url='https://github.com/benbusby/whoogle-search',
entry_points={
'console_scripts': [
'whoogle-search=app.routes:run_app',
]
},
packages=setuptools.find_packages(),
classifiers=[
'Programming Language :: Python :: 3',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
],
)

@ -1,5 +1,5 @@
from app import app from app import app
from app.utils.session import generate_key from app.utils.session import generate_user_key
import pytest import pytest
import random import random
@ -9,7 +9,7 @@ demo_config = {
'nojs': str(random.getrandbits(1)), 'nojs': str(random.getrandbits(1)),
'lang_interface': random.choice(app.config['LANGUAGES'])['value'], 'lang_interface': random.choice(app.config['LANGUAGES'])['value'],
'lang_search': random.choice(app.config['LANGUAGES'])['value'], 'lang_search': random.choice(app.config['LANGUAGES'])['value'],
'country': random.choice(app.config['COUNTRIES'])['value'] 'ctry': random.choice(app.config['COUNTRIES'])['value']
} }
@ -18,7 +18,6 @@ def client():
with app.test_client() as client: with app.test_client() as client:
with client.session_transaction() as session: with client.session_transaction() as session:
session['uuid'] = 'test' session['uuid'] = 'test'
session['key'] = app.enc_key session['key'] = generate_user_key()
session['config'] = {} session['config'] = {}
session['auth'] = False
yield client yield client

@ -1,16 +1,12 @@
from app.models.endpoint import Endpoint
def test_autocomplete_get(client): def test_autocomplete_get(client):
rv = client.get(f'/{Endpoint.autocomplete}?q=green+eggs+and') rv = client.get('/autocomplete?q=green+eggs+and')
assert rv._status_code == 200 assert rv._status_code == 200
assert len(rv.data) >= 1 assert len(rv.data) >= 1
assert b'green eggs and ham' in rv.data assert b'green eggs and ham' in rv.data
def test_autocomplete_post(client): def test_autocomplete_post(client):
rv = client.post(f'/{Endpoint.autocomplete}', rv = client.post('/autocomplete', data=dict(q='the+cat+in+the'))
data=dict(q='the+cat+in+the'))
assert rv._status_code == 200 assert rv._status_code == 200
assert len(rv.data) >= 1 assert len(rv.data) >= 1
assert b'the cat in the hat' in rv.data assert b'the cat in the hat' in rv.data

@ -1,28 +1,13 @@
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from app import app from app import app
from app.models.endpoint import Endpoint from app.utils.session import generate_user_key, valid_user_session
from app.utils.session import generate_key, valid_user_session
JAPAN_PREFS = 'uG-gGIJwHdqxl6DrS3mnu_511HlQcRpxYlG03Xs-' \
+ '_znXNiJWI9nLOkRLkiiFwIpeUYMTGfUF5-t9fP5DGmzDLEt04DCx703j3nPf' \
+ '29v_RWkU7gXw_44m2oAFIaKGmYlu4Z0bKyu9k5WXfL9Dy6YKKnpcR5CiaFsG' \
+ 'rccNRkAPYm-eYGAFUV8M59f8StsGd_M-gHKGS9fLok7EhwBWjHxBJ2Kv8hsT' \
+ '87zftP2gMJOevTdNnezw2Y5WOx-ZotgeheCW1BYCFcRqatlov21PHp22NGVG' \
+ '8ZuBNAFW0bE99WSdyT7dUIvzeWCLJpbdSsq-3FUUZkxbRdFYlGd8vY1UgVAp' \
+ 'OSie2uAmpgLFXygO-VfNBBZ68Q7gAap2QtzHCiKD5cFYwH3LPgVJ-DoZvJ6k' \
+ 'alt34TaYiJphgiqFKV4SCeVmLWTkr0SF3xakSR78yYJU_d41D2ng-TojA9XZ' \
+ 'uR2ZqjSvPKOWvjimu89YhFOgJxG1Po8Henj5h9OL9VXXvdvlJwBSAKw1E3FV' \
+ '7UHWiglMxPblfxqou1cYckMYkFeIMCD2SBtju68mBiQh2k328XRPTsQ_ocby' \
+ 'cgVKnleGperqbD6crRk3Z9xE5sVCjujn9JNVI-7mqOITMZ0kntq9uJ3R5n25' \
+ 'Vec0TJ0P19nEtvjY0nJIrIjtnBg=='
def test_generate_user_keys(): def test_generate_user_keys():
key = generate_key() key = generate_user_key()
assert Fernet(key) assert Fernet(key)
assert generate_key() != key assert generate_user_key() != key
def test_valid_session(client): def test_valid_session(client):
@ -52,27 +37,14 @@ def test_query_decryption(client):
rv = client.get('/') rv = client.get('/')
cookie = rv.headers['Set-Cookie'] cookie = rv.headers['Set-Cookie']
rv = client.get(f'/{Endpoint.search}?q=test+1', headers={'Cookie': cookie}) rv = client.get('/search?q=test+1', headers={'Cookie': cookie})
assert rv._status_code == 200 assert rv._status_code == 200
with client.session_transaction() as session: with client.session_transaction() as session:
assert valid_user_session(session) assert valid_user_session(session)
rv = client.get(f'/{Endpoint.search}?q=test+2', headers={'Cookie': cookie}) rv = client.get('/search?q=test+2', headers={'Cookie': cookie})
assert rv._status_code == 200 assert rv._status_code == 200
with client.session_transaction() as session: with client.session_transaction() as session:
assert valid_user_session(session) assert valid_user_session(session)
def test_prefs_url(client):
base_url = f'/{Endpoint.search}?q=wikipedia'
rv = client.get(base_url)
assert rv._status_code == 200
assert b'wikipedia.org' in rv.data
assert b'ja.wikipedia.org' not in rv.data
rv = client.get(f'{base_url}&preferences={JAPAN_PREFS}')
assert rv._status_code == 200
assert b'ja.wikipedia.org' in rv.data

@ -1,19 +1,16 @@
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from app.filter import Filter from app.filter import Filter
from app.models.config import Config from app.utils.session import generate_user_key
from app.models.endpoint import Endpoint
from app.utils import results
from app.utils.session import generate_key
from datetime import datetime from datetime import datetime
from dateutil.parser import ParserError, parse from dateutil.parser import *
from urllib.parse import urlparse from urllib.parse import urlparse
from test.conftest import demo_config from test.conftest import demo_config
def get_search_results(data): def get_search_results(data):
secret_key = generate_key() secret_key = generate_user_key()
soup = Filter(user_key=secret_key, config=Config(**demo_config)).clean( soup = Filter(user_key=secret_key).clean(
BeautifulSoup(data, 'html.parser')) BeautifulSoup(data, 'html.parser'))
main_divs = soup.find('div', {'id': 'main'}) main_divs = soup.find('div', {'id': 'main'})
@ -33,33 +30,27 @@ def get_search_results(data):
def test_get_results(client): def test_get_results(client):
rv = client.get(f'/{Endpoint.search}?q=test') rv = client.get('/search?q=test')
assert rv._status_code == 200 assert rv._status_code == 200
# Depending on the search, there can be more # Depending on the search, there can be more
# than 10 result divs # than 10 result divs
results = get_search_results(rv.data) assert len(get_search_results(rv.data)) >= 10
assert len(results) >= 10 assert len(get_search_results(rv.data)) <= 15
assert len(results) <= 15
def test_post_results(client): def test_post_results(client):
rv = client.post(f'/{Endpoint.search}', data=dict(q='test')) rv = client.post('/search', data=dict(q='test'))
assert rv._status_code == 302
def test_translate_search(client):
rv = client.get(f'/{Endpoint.search}?q=translate hola')
assert rv._status_code == 200 assert rv._status_code == 200
# Pretty weak test, but better than nothing # Depending on the search, there can be more
str_data = str(rv.data) # than 10 result divs
assert 'iframe' in str_data assert len(get_search_results(rv.data)) >= 10
assert '/auto/en/ hola' in str_data assert len(get_search_results(rv.data)) <= 15
def test_block_results(client): def test_block_results(client):
rv = client.get(f'/{Endpoint.search}?q=pinterest') rv = client.post('/search', data=dict(q='pinterest'))
assert rv._status_code == 200 assert rv._status_code == 200
has_pinterest = False has_pinterest = False
@ -71,38 +62,39 @@ def test_block_results(client):
assert has_pinterest assert has_pinterest
demo_config['block'] = 'pinterest.com' demo_config['block'] = 'pinterest.com'
rv = client.post(f'/{Endpoint.config}', data=demo_config) rv = client.post('/config', data=demo_config)
assert rv._status_code == 302 assert rv._status_code == 302
rv = client.get(f'/{Endpoint.search}?q=pinterest') rv = client.post('/search', data=dict(q='pinterest'))
assert rv._status_code == 200 assert rv._status_code == 200
for link in BeautifulSoup(rv.data, 'html.parser').find_all('a', href=True): for link in BeautifulSoup(rv.data, 'html.parser').find_all('a', href=True):
result_site = urlparse(link['href']).netloc assert 'pinterest.com' not in urlparse(link['href']).netloc
if not result_site:
continue
assert result_site not in 'pinterest.com'
def test_view_my_ip(client): # TODO: Unit test the site alt method instead -- the results returned
rv = client.get(f'/{Endpoint.search}?q=my ip address') # are too unreliable for this test in particular.
assert rv._status_code == 200 # def test_site_alts(client):
# rv = client.post('/search', data=dict(q='twitter official account'))
# assert b'twitter.com/Twitter' in rv.data
# client.post('/config', data=dict(alts=True))
# assert json.loads(client.get('/config').data)['alts']
# Pretty weak test, but better than nothing # rv = client.post('/search', data=dict(q='twitter official account'))
str_data = str(rv.data) # assert b'twitter.com/Twitter' not in rv.data
assert 'Your public IP address' in str_data # assert b'nitter.net/Twitter' in rv.data
assert '127.0.0.1' in str_data
def test_recent_results(client): def test_recent_results(client):
times = { times = {
'tbs=qdr:y': 365, 'past year': 365,
'tbs=qdr:m': 31, 'past month': 31,
'tbs=qdr:w': 7 'past week': 7
} }
for time, num_days in times.items(): for time, num_days in times.items():
rv = client.get(f'/{Endpoint.search}?q=test&' + time) rv = client.post('/search', data=dict(q='test :' + time))
result_divs = get_search_results(rv.data) result_divs = get_search_results(rv.data)
current_date = datetime.now() current_date = datetime.now()
@ -117,42 +109,3 @@ def test_recent_results(client):
assert (current_date - date).days <= (num_days + 5) assert (current_date - date).days <= (num_days + 5)
except ParserError: except ParserError:
pass pass
def test_leading_slash_search(client):
# Ensure searches with a leading slash are interpreted
# correctly as queries and not endpoints
q = '/test'
rv = client.get(f'/{Endpoint.search}?q={q}')
assert rv._status_code == 200
soup = Filter(
user_key=generate_key(),
config=Config(**demo_config),
query=q
).clean(BeautifulSoup(rv.data, 'html.parser'))
for link in soup.find_all('a', href=True):
if 'start=' not in link['href']:
continue
assert link['href'].startswith(f'{Endpoint.search}')
def test_site_alt_prefix_skip():
# Ensure prefixes are skipped correctly for site alts
# default silte_alts (farside.link)
assert results.get_site_alt(link = 'https://www.reddit.com') == 'https://farside.link/libreddit'
assert results.get_site_alt(link = 'https://www.twitter.com') == 'https://farside.link/nitter'
assert results.get_site_alt(link = 'https://www.youtube.com') == 'https://farside.link/invidious'
test_site_alts = {
'reddit.com': 'reddit.endswithmobile.domain',
'twitter.com': 'https://twitter.endswithm.domain',
'youtube.com': 'http://yt.endswithwww.domain',
}
# Domains with part of SKIP_PREFIX in them
assert results.get_site_alt(link = 'https://www.reddit.com', site_alts = test_site_alts) == 'https://reddit.endswithmobile.domain'
assert results.get_site_alt(link = 'https://www.twitter.com', site_alts = test_site_alts) == 'https://twitter.endswithm.domain'
assert results.get_site_alt(link = 'https://www.youtube.com', site_alts = test_site_alts) == 'http://yt.endswithwww.domain'

@ -1,5 +1,4 @@
from app import app from app import app
from app.models.endpoint import Endpoint
import json import json
@ -12,80 +11,62 @@ def test_main(client):
def test_search(client): def test_search(client):
rv = client.get(f'/{Endpoint.search}?q=test') rv = client.get('/search?q=test')
assert rv._status_code == 200 assert rv._status_code == 200
def test_feeling_lucky(client): def test_feeling_lucky(client):
# Bang at beginning of query rv = client.get('/search?q=!%20test')
rv = client.get(f'/{Endpoint.search}?q=!%20wikipedia')
assert rv._status_code == 303 assert rv._status_code == 303
assert rv.headers.get('Location').startswith('https://www.wikipedia.org')
# Move bang to end of query
rv = client.get(f'/{Endpoint.search}?q=github%20!')
assert rv._status_code == 303
assert rv.headers.get('Location').startswith('https://github.com')
def test_ddg_bang(client): def test_ddg_bang(client):
# Bang at beginning of query # Bang at beginning of query
rv = client.get(f'/{Endpoint.search}?q=!gh%20whoogle') rv = client.get('/search?q=!gh%20whoogle')
assert rv._status_code == 302 assert rv._status_code == 302
assert rv.headers.get('Location').startswith('https://github.com') assert rv.headers.get('Location').startswith('https://github.com')
# Move bang to end of query # Move bang to end of query
rv = client.get(f'/{Endpoint.search}?q=github%20!w') rv = client.get('/search?q=github%20!w')
assert rv._status_code == 302 assert rv._status_code == 302
assert rv.headers.get('Location').startswith('https://en.wikipedia.org') assert rv.headers.get('Location').startswith('https://en.wikipedia.org')
# Move bang to middle of query # Move bang to middle of query
rv = client.get(f'/{Endpoint.search}?q=big%20!r%20chungus') rv = client.get('/search?q=big%20!r%20chungus')
assert rv._status_code == 302 assert rv._status_code == 302
assert rv.headers.get('Location').startswith('https://www.reddit.com') assert rv.headers.get('Location').startswith('https://www.reddit.com')
# Ensure bang is case insensitive
rv = client.get(f'/{Endpoint.search}?q=!GH%20whoogle')
assert rv._status_code == 302
assert rv.headers.get('Location').startswith('https://github.com')
# Ensure bang without a query still redirects to the result
rv = client.get(f'/{Endpoint.search}?q=!gh')
assert rv._status_code == 302
assert rv.headers.get('Location').startswith('https://github.com')
def test_custom_bang(client):
# Bang at beginning of query
rv = client.get(f'/{Endpoint.search}?q=!i%20whoogle')
assert rv._status_code == 302
assert rv.headers.get('Location').startswith('search?q=')
def test_config(client): def test_config(client):
rv = client.post(f'/{Endpoint.config}', data=demo_config) rv = client.post('/config', data=demo_config)
assert rv._status_code == 302 assert rv._status_code == 302
rv = client.get(f'/{Endpoint.config}') rv = client.get('/config')
assert rv._status_code == 200 assert rv._status_code == 200
config = json.loads(rv.data) config = json.loads(rv.data)
for key in demo_config.keys(): for key in demo_config.keys():
assert config[key] == demo_config[key] assert config[key] == demo_config[key]
# Test setting config via search
custom_config = '&dark=1&lang_interface=lang_en'
rv = client.get('/search?q=test' + custom_config)
assert rv._status_code == 200
assert custom_config.replace('&', '&amp;') in str(rv.data)
# Test disabling changing config from client # Test disabling changing config from client
app.config['CONFIG_DISABLE'] = 1 app.config['CONFIG_DISABLE'] = 1
dark_mod = not demo_config['dark'] dark_mod = not demo_config['dark']
demo_config['dark'] = dark_mod demo_config['dark'] = dark_mod
rv = client.post(f'/{Endpoint.config}', data=demo_config) rv = client.post('/config', data=demo_config)
assert rv._status_code == 403 assert rv._status_code == 403
rv = client.get(f'/{Endpoint.config}') rv = client.get('/config')
config = json.loads(rv.data) config = json.loads(rv.data)
assert config['dark'] != dark_mod assert config['dark'] != dark_mod
def test_opensearch(client): def test_opensearch(client):
rv = client.get(f'/{Endpoint.opensearch}') rv = client.get('/opensearch.xml')
assert rv._status_code == 200 assert rv._status_code == 200
assert '<ShortName>Whoogle</ShortName>' in str(rv.data) assert '<ShortName>Whoogle</ShortName>' in str(rv.data)

@ -1,39 +1,23 @@
# ----------------------------------
# Rename to "whoogle.env" before use
# ----------------------------------
# You can set Whoogle environment variables here, but must # You can set Whoogle environment variables here, but must
# modify your deployment to enable these values: # modify your deployment to enable these values:
# - Local: Set WHOOGLE_DOTENV=1 # - Local: Set WHOOGLE_DOTENV=1
# - docker-compose: Uncomment the env_file option # - docker-compose: Uncomment the env_file option
# - docker: Add "--env-file ./whoogle.env" to your build command # - docker: Add "--env-file ./whoogle.env" to your build command
#WHOOGLE_ALT_TW=farside.link/nitter #WHOOGLE_ALT_TW=nitter.net
#WHOOGLE_ALT_YT=farside.link/invidious #WHOOGLE_ALT_YT=invidious.snopyta.org
#WHOOGLE_ALT_IG=farside.link/bibliogram/u #WHOOGLE_ALT_IG=bibliogram.art/u
#WHOOGLE_ALT_RD=farside.link/libreddit #WHOOGLE_ALT_RD=libredd.it
#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_USER="" #WHOOGLE_USER=""
#WHOOGLE_PASS="" #WHOOGLE_PASS=""
#WHOOGLE_PROXY_USER="" #WHOOGLE_PROXY_USER=""
#WHOOGLE_PROXY_PASS="" #WHOOGLE_PROXY_PASS=""
#WHOOGLE_PROXY_TYPE="" #WHOOGLE_PROXY_TYPE=""
#WHOOGLE_PROXY_LOC="" #WHOOGLE_PROXY_LOC=""
#WHOOGLE_CSP=1
#HTTPS_ONLY=1 #HTTPS_ONLY=1
# The URL prefix to use for the whoogle instance (i.e. "/whoogle")
#WHOOGLE_URL_PREFIX=""
# Restrict results to only those near a particular city
#WHOOGLE_CONFIG_NEAR=denver
# See app/static/settings/countries.json for values # See app/static/settings/countries.json for values
#WHOOGLE_CONFIG_COUNTRY=US #WHOOGLE_CONFIG_COUNTRY=countryUK
# See app/static/settings/languages.json for values # See app/static/settings/languages.json for values
#WHOOGLE_CONFIG_LANGUAGE=lang_en #WHOOGLE_CONFIG_LANGUAGE=lang_en
@ -47,8 +31,8 @@
# Block websites from search results (comma-separated list) # Block websites from search results (comma-separated list)
#WHOOGLE_CONFIG_BLOCK=pinterest.com,whitehouse.gov #WHOOGLE_CONFIG_BLOCK=pinterest.com,whitehouse.gov
# Theme (light, dark, or system) # Dark mode
#WHOOGLE_CONFIG_THEME=system #WHOOGLE_CONFIG_DARK=1
# Safe search mode # Safe search mode
#WHOOGLE_CONFIG_SAFE=1 #WHOOGLE_CONFIG_SAFE=1
@ -63,31 +47,13 @@
#WHOOGLE_CONFIG_NEW_TAB=1 #WHOOGLE_CONFIG_NEW_TAB=1
# Enable View Image option # Enable View Image option
#WHOOGLE_CONFIG_VIEW_IMAGE=1 #WHOOGLE_CONFIG_VIEW_IMAGE=1
# Search using GET requests only (exposes query in logs) # Search using GET requests only (exposes query in logs)
#WHOOGLE_CONFIG_GET_ONLY=1 #WHOOGLE_CONFIG_GET_ONLY=1
# Remove everything except basic result cards from all search queries
#WHOOGLE_MINIMAL=0
# Set the number of results per page
#WHOOGLE_RESULTS_PER_PAGE=10
# Controls visibility of autocomplete/search suggestions
#WHOOGLE_AUTOCOMPLETE=1
# The port where Whoogle will be exposed
#EXPOSE_PORT=5000
# Set instance URL # Set instance URL
#WHOOGLE_CONFIG_URL=https://<whoogle url>/ #WHOOGLE_CONFIG_URL=https://<whoogle url>/
# Set custom CSS styling/theming # Set custom CSS styling/theming
#WHOOGLE_CONFIG_STYLE=":root { /* LIGHT THEME COLORS */ --whoogle-background: #d8dee9; --whoogle-accent: #2e3440; --whoogle-text: #3B4252; --whoogle-contrast-text: #eceff4; --whoogle-secondary-text: #70757a; --whoogle-result-bg: #fff; --whoogle-result-title: #4c566a; --whoogle-result-url: #81a1c1; --whoogle-result-visited: #a3be8c; /* DARK THEME COLORS */ --whoogle-dark-background: #222; --whoogle-dark-accent: #685e79; --whoogle-dark-text: #fff; --whoogle-dark-contrast-text: #000; --whoogle-dark-secondary-text: #bbb; --whoogle-dark-result-bg: #000; --whoogle-dark-result-title: #1967d2; --whoogle-dark-result-url: #4b11a8; --whoogle-dark-result-visited: #bbbbff; }" #WHOOGLE_CONFIG_STYLE=":root { /* LIGHT THEME COLORS */ --whoogle-background: #d8dee9; --whoogle-accent: #2e3440; --whoogle-text: #3B4252; --whoogle-contrast-text: #eceff4; --whoogle-secondary-text: #70757a; --whoogle-result-bg: #fff; --whoogle-result-title: #4c566a; --whoogle-result-url: #81a1c1; --whoogle-result-visited: #a3be8c; /* DARK THEME COLORS */ --whoogle-dark-background: #222; --whoogle-dark-accent: #685e79; --whoogle-dark-text: #fff; --whoogle-dark-contrast-text: #000; --whoogle-dark-secondary-text: #bbb; --whoogle-dark-result-bg: #000; --whoogle-dark-result-title: #1967d2; --whoogle-dark-result-url: #4b11a8; --whoogle-dark-result-visited: #bbbbff; }"
# Enable preferences encryption (requires key)
#WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED=1
# Set Key to encode config in url
#WHOOGLE_CONFIG_PREFERENCES_KEY="NEEDS_TO_BE_MODIFIED"
Loading…
Cancel
Save