mirror of
https://github.com/benbusby/whoogle-search.git
synced 2026-01-09 13:40:27 -05:00
Compare commits
No commits in common. "main" and "v0.5.1" have entirely different histories.
@ -1,3 +1,2 @@
|
||||
.git/
|
||||
venv/
|
||||
test/
|
||||
|
||||
38
.github/ISSUE_TEMPLATE/new-theme.md
vendored
38
.github/ISSUE_TEMPLATE/new-theme.md
vendored
@ -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: #______;
|
||||
}
|
||||
```
|
||||
94
.github/workflows/buildx.yml
vendored
94
.github/workflows/buildx.yml
vendored
@ -1,92 +1,28 @@
|
||||
name: buildx
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["docker_main"]
|
||||
branches: [main, updates]
|
||||
types:
|
||||
- completed
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
branches: develop
|
||||
|
||||
jobs:
|
||||
on-success:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for tests to succeed
|
||||
if: ${{ github.event.workflow_run.conclusion != 'success' && startsWith(github.ref, 'refs/tags') != true }}
|
||||
run: exit 1
|
||||
- name: Debug workflow context
|
||||
run: |
|
||||
echo "Event name: ${{ github.event_name }}"
|
||||
echo "Ref: ${{ github.ref }}"
|
||||
echo "Actor: ${{ github.actor }}"
|
||||
echo "Branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
echo "Conclusion: ${{ github.event.workflow_run.conclusion }}"
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: actions/checkout@v2
|
||||
- name: install buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disabled: only build on release events now
|
||||
# - name: build and push the image
|
||||
# if: startsWith(github.ref, 'refs/heads/main') && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||
# run: |
|
||||
# docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
# docker buildx ls
|
||||
# docker buildx build --push \
|
||||
# --tag benbusby/whoogle-search:latest \
|
||||
# --platform linux/amd64,linux/arm64 .
|
||||
# docker buildx build --push \
|
||||
# --tag ghcr.io/benbusby/whoogle-search:latest \
|
||||
# --platform linux/amd64,linux/arm64 .
|
||||
- name: build and push updates branch (update-testing tag)
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'updates' && github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.actor.login == 'benbusby' || github.event.workflow_run.actor.login == 'Don-Swanson')
|
||||
version: latest
|
||||
- name: log in to docker hub
|
||||
run: |
|
||||
docker buildx build --push \
|
||||
--tag benbusby/whoogle-search:update-testing \
|
||||
--tag ghcr.io/benbusby/whoogle-search:update-testing \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
- name: build and push release (version + latest)
|
||||
if: github.event_name == 'release' && github.event.release.prerelease == false && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||
echo "${{ secrets.DOCKER_PASSWORD }}" | \
|
||||
docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
- name: build and push the image
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx ls
|
||||
docker buildx build --push \
|
||||
--tag benbusby/whoogle-search:${VERSION} \
|
||||
--tag benbusby/whoogle-search:latest \
|
||||
--tag ghcr.io/benbusby/whoogle-search:${VERSION} \
|
||||
--tag ghcr.io/benbusby/whoogle-search:latest \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
- name: build and push pre-release (version only)
|
||||
if: github.event_name == 'release' && github.event.release.prerelease == true && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
docker buildx build --push \
|
||||
--tag benbusby/whoogle-search:${VERSION} \
|
||||
--tag ghcr.io/benbusby/whoogle-search:${VERSION} \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
- name: build and push tag
|
||||
if: startsWith(github.ref, 'refs/tags')
|
||||
run: |
|
||||
docker buildx build --push \
|
||||
--tag benbusby/whoogle-search:${GITHUB_REF#refs/*/v} \
|
||||
--tag ghcr.io/benbusby/whoogle-search:${GITHUB_REF#refs/*/v} \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
--tag benbusby/whoogle-search:buildx-experimental \
|
||||
--platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||
|
||||
29
.github/workflows/docker_main.yml
vendored
29
.github/workflows/docker_main.yml
vendored
@ -1,29 +0,0 @@
|
||||
name: docker_main
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["tests"]
|
||||
branches: [main, updates]
|
||||
types:
|
||||
- completed
|
||||
|
||||
# TODO: Needs refactoring to use reusable workflows and share w/ docker_tests
|
||||
jobs:
|
||||
on-success:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: build and test (docker)
|
||||
run: |
|
||||
docker build --tag whoogle-search:test .
|
||||
docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test
|
||||
sleep 15
|
||||
docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1
|
||||
- name: build and test (docker-compose)
|
||||
run: |
|
||||
docker rm -f whoogle-search-nocompose
|
||||
WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach
|
||||
sleep 15
|
||||
docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1
|
||||
26
.github/workflows/docker_tests.yml
vendored
26
.github/workflows/docker_tests.yml
vendored
@ -1,26 +0,0 @@
|
||||
name: docker_tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
pull_request:
|
||||
branches: main
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: build and test (docker)
|
||||
run: |
|
||||
docker build --tag whoogle-search:test .
|
||||
docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test
|
||||
sleep 15
|
||||
docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1
|
||||
- name: build and test (docker compose)
|
||||
run: |
|
||||
docker rm -f whoogle-search-nocompose
|
||||
WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach
|
||||
sleep 15
|
||||
docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1
|
||||
22
.github/workflows/pep8.yml
vendored
Normal file
22
.github/workflows/pep8.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
name: pep8
|
||||
|
||||
on:
|
||||
push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pycodestyle
|
||||
- name: Run pycodestyle
|
||||
run: |
|
||||
pycodestyle --show-source --show-pep8 app/*
|
||||
pycodestyle --show-source --show-pep8 test/*
|
||||
83
.github/workflows/pypi.yml
vendored
83
.github/workflows/pypi.yml
vendored
@ -1,83 +0,0 @@
|
||||
name: pypi
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
tags: v*
|
||||
|
||||
jobs:
|
||||
publish-test:
|
||||
name: Build and publish to TestPyPI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install pypa/build
|
||||
run: >-
|
||||
python -m
|
||||
pip install
|
||||
build
|
||||
setuptools
|
||||
--user
|
||||
- name: Set dev timestamp
|
||||
run: echo "DEV_BUILD=$(date +%s)" >> $GITHUB_ENV
|
||||
- name: Build binary wheel and source tarball
|
||||
run: >-
|
||||
python -m
|
||||
build
|
||||
--sdist
|
||||
--wheel
|
||||
--outdir dist/
|
||||
.
|
||||
- name: Publish distribution to TestPyPI
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||
repository_url: https://test.pypi.org/legacy/
|
||||
publish:
|
||||
# Gate real PyPI publishing to stable SemVer tags only
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
name: Build and publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check if stable release
|
||||
id: check_tag
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if echo "$TAG" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "is_stable=true" >> $GITHUB_OUTPUT
|
||||
echo "Tag '$TAG' is a stable release. Will publish to PyPI."
|
||||
else
|
||||
echo "is_stable=false" >> $GITHUB_OUTPUT
|
||||
echo "Tag '$TAG' is not a stable release (contains pre-release suffix). Skipping PyPI publish."
|
||||
fi
|
||||
- name: Set up Python 3.9
|
||||
if: steps.check_tag.outputs.is_stable == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install pypa/build
|
||||
if: steps.check_tag.outputs.is_stable == 'true'
|
||||
run: >-
|
||||
python -m
|
||||
pip install
|
||||
build
|
||||
--user
|
||||
- name: Build binary wheel and source tarball
|
||||
if: steps.check_tag.outputs.is_stable == 'true'
|
||||
run: >-
|
||||
python -m
|
||||
build
|
||||
--sdist
|
||||
--wheel
|
||||
--outdir dist/
|
||||
.
|
||||
- name: Publish distribution to PyPI
|
||||
if: steps.check_tag.outputs.is_stable == 'true'
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
19
.github/workflows/scan.yml
vendored
19
.github/workflows/scan.yml
vendored
@ -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
|
||||
33
.github/workflows/stale.yml
vendored
33
.github/workflows/stale.yml
vendored
@ -1,33 +0,0 @@
|
||||
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
|
||||
#
|
||||
# You can adjust the behavior by modifying this file.
|
||||
# For more information, see:
|
||||
# https://github.com/actions/stale
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '35 10 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
stale-issue-message: 'This issue has been automatically marked as stale due to inactivity. If it is still valid please comment within 7 days or it will be auto-closed.'
|
||||
close-issue-message: 'Closing this issue due to prolonged inactivity.'
|
||||
# Disabled PR Closing for now, but pre-staged the settings
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 100
|
||||
stale-pr-message: "This PR appears to be stale. If it is still valid please comment within 14 days or it will be auto-closed."
|
||||
close-pr-message: "This PR was closed as stale."
|
||||
exempt-issue-labels: 'keep-open,enhancement,critical,dependencies,documentation'
|
||||
17
.github/workflows/tests.yml
vendored
17
.github/workflows/tests.yml
vendored
@ -1,17 +0,0 @@
|
||||
name: tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
run: pip install --upgrade pip && pip install -r requirements.txt
|
||||
- name: Run tests
|
||||
run: ./run test
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,27 +1,17 @@
|
||||
venv/
|
||||
.venv/
|
||||
.idea/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pem
|
||||
*.conf
|
||||
*.key
|
||||
config.json
|
||||
test/static
|
||||
flask_session/
|
||||
app/static/config
|
||||
app/static/custom_config
|
||||
app/static/bangs/*
|
||||
!app/static/bangs/00-whoogle.json
|
||||
app/static/bangs
|
||||
|
||||
# pip stuff
|
||||
/build/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# env
|
||||
whoogle.env
|
||||
|
||||
# vim
|
||||
*~
|
||||
*.swp
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.9
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.8.0
|
||||
hooks:
|
||||
- id: black
|
||||
args: [--quiet]
|
||||
|
||||
4
.replit
4
.replit
@ -1 +1,3 @@
|
||||
entrypoint = "misc/replit.py"
|
||||
language = "python3"
|
||||
run = "./run"
|
||||
onBoot = "./run"
|
||||
|
||||
15
.travis.yml
Normal file
15
.travis.yml
Normal file
@ -0,0 +1,15 @@
|
||||
language: python
|
||||
python: 3.6
|
||||
before_install:
|
||||
- sudo apt-get -y install libgnutls28-dev
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
script:
|
||||
- "./run test"
|
||||
deploy:
|
||||
provider: pypi
|
||||
user: __token__
|
||||
password:
|
||||
secure: WNEH2Gg84MZF/AZEberFDGPPWb4cYyHAeD/XV8En94QRSI9Aznz6qiDKOvV4eVgjMAIEW5uB3TL1LHf6KU+Hrg6SmhF7JquqP1gsBOCDNFPTljO+k2Hc53uDdSnhi/HLgY7cnFNX4lc2nNrbyxZxMHuSA2oNz/tosyNGBEeyU+JA5va7uX0albGsLiNjimO4aeau83fsI0Hn2eN6ag68pewUMXNxzpyTeO2bRcCd5d5iILs07jMVwFoC2j7W11oNqrVuSWAs8CPe4+kwvNvXWxljUGiBGppNZ7RAsKNLwi6U6kGGUTWjQm09rY/2JBpJ2WEGmIWGIrno75iiFRbjnRp3mnXPvtVTyWhh+hQIUd7bJOVKM34i9eHotYTrkMJObgW1gnRzvI9VYldtgL/iP/Isn2Pv2EeMX8V+C9/8pxv0jkQkZMnFhE6gGlzpz37zTl04B2J7xyV5znM35Lx2Pn3zxdcmdCvD3yT8I4MuBbKqq2/v4emYCfPfOmfwnS0BEVSqr9lbx4xfUZV76tcvLcj4n86DJbx77pA2Ch8FRprpOOBcf0WuqTbZp8c3mb8prFp2EupUknXu7+C2VQ6sqrnzNuDeTGm/nyjjRQ81rlvlD4tqkwsEGEDDO44FF2eUTc5D2MvoHs4cnz095FWjy63gn5IxUjhMi31b5tGRz2Q=
|
||||
on:
|
||||
tags: true
|
||||
129
Dockerfile
129
Dockerfile
@ -1,93 +1,60 @@
|
||||
# NOTE: ARMv7 support has been dropped due to lack of pre-built cryptography wheels for Alpine/musl.
|
||||
# To restore ARMv7 support for local builds:
|
||||
# 1. Change requirements.txt:
|
||||
# cryptography==3.3.2; platform_machine == 'armv7l'
|
||||
# cryptography==46.0.1; platform_machine != 'armv7l'
|
||||
# pyOpenSSL==19.1.0; platform_machine == 'armv7l'
|
||||
# pyOpenSSL==25.3.0; platform_machine != 'armv7l'
|
||||
# 2. Add linux/arm/v7 to --platform flag when building:
|
||||
# docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||
FROM python:3.8-slim as builder
|
||||
|
||||
FROM python:3.12-alpine3.22 AS builder
|
||||
|
||||
RUN apk --no-cache add \
|
||||
build-base \
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
openssl-dev \
|
||||
libssl-dev \
|
||||
libffi-dev
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.12-alpine3.22
|
||||
FROM python:3.8-slim
|
||||
|
||||
# Remove bridge package to avoid CVEs (not needed for Docker containers)
|
||||
RUN apk add --no-cache --no-scripts tor curl openrc libstdc++ && \
|
||||
apk del --no-cache bridge || true
|
||||
# git go //for obfs4proxy
|
||||
# libcurl4-openssl-dev
|
||||
RUN pip install --upgrade pip
|
||||
RUN apk --no-cache upgrade && \
|
||||
apk del --no-cache --rdepends bridge || true
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libcurl4-openssl-dev \
|
||||
tor \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# uncomment to build obfs4proxy
|
||||
# RUN git clone https://gitlab.com/yawning/obfs4.git
|
||||
# WORKDIR /obfs4
|
||||
# RUN go build -o obfs4proxy/obfs4proxy ./obfs4proxy
|
||||
# RUN cp ./obfs4proxy/obfs4proxy /usr/bin/obfs4proxy
|
||||
|
||||
ARG DOCKER_USER=whoogle
|
||||
ARG DOCKER_USERID=927
|
||||
ARG config_dir=/config
|
||||
RUN mkdir -p $config_dir
|
||||
RUN chmod a+w $config_dir
|
||||
VOLUME $config_dir
|
||||
ENV CONFIG_VOLUME=$config_dir
|
||||
|
||||
ARG url_prefix=''
|
||||
ARG username=''
|
||||
ENV WHOOGLE_USER=$username
|
||||
ARG password=''
|
||||
ARG proxyuser=''
|
||||
ARG proxypass=''
|
||||
ARG proxytype=''
|
||||
ARG proxyloc=''
|
||||
ARG whoogle_dotenv=''
|
||||
ARG use_https=''
|
||||
ARG whoogle_port=5000
|
||||
ARG twitter_alt='farside.link/nitter'
|
||||
ARG youtube_alt='farside.link/invidious'
|
||||
ARG reddit_alt='farside.link/libreddit'
|
||||
ARG medium_alt='farside.link/scribe'
|
||||
ARG translate_alt='farside.link/lingva'
|
||||
ARG imgur_alt='farside.link/rimgo'
|
||||
ARG wikipedia_alt='farside.link/wikiless'
|
||||
ARG imdb_alt='farside.link/libremdb'
|
||||
ARG quora_alt='farside.link/quetre'
|
||||
ARG so_alt='farside.link/anonymousoverflow'
|
||||
ENV WHOOGLE_PASS=$password
|
||||
|
||||
ENV CONFIG_VOLUME=$config_dir \
|
||||
WHOOGLE_URL_PREFIX=$url_prefix \
|
||||
WHOOGLE_USER=$username \
|
||||
WHOOGLE_PASS=$password \
|
||||
WHOOGLE_PROXY_USER=$proxyuser \
|
||||
WHOOGLE_PROXY_PASS=$proxypass \
|
||||
WHOOGLE_PROXY_TYPE=$proxytype \
|
||||
WHOOGLE_PROXY_LOC=$proxyloc \
|
||||
WHOOGLE_DOTENV=$whoogle_dotenv \
|
||||
HTTPS_ONLY=$use_https \
|
||||
EXPOSE_PORT=$whoogle_port \
|
||||
WHOOGLE_ALT_TW=$twitter_alt \
|
||||
WHOOGLE_ALT_YT=$youtube_alt \
|
||||
WHOOGLE_ALT_RD=$reddit_alt \
|
||||
WHOOGLE_ALT_MD=$medium_alt \
|
||||
WHOOGLE_ALT_TL=$translate_alt \
|
||||
WHOOGLE_ALT_IMG=$imgur_alt \
|
||||
WHOOGLE_ALT_WIKI=$wikipedia_alt \
|
||||
WHOOGLE_ALT_IMDB=$imdb_alt \
|
||||
WHOOGLE_ALT_QUORA=$quora_alt \
|
||||
WHOOGLE_ALT_SO=$so_alt
|
||||
ARG proxyuser=''
|
||||
ENV WHOOGLE_PROXY_USER=$proxyuser
|
||||
ARG proxypass=''
|
||||
ENV WHOOGLE_PROXY_PASS=$proxypass
|
||||
ARG proxytype=''
|
||||
ENV WHOOGLE_PROXY_TYPE=$proxytype
|
||||
ARG proxyloc=''
|
||||
ENV WHOOGLE_PROXY_LOC=$proxyloc
|
||||
|
||||
ARG whoogle_dotenv=''
|
||||
ENV WHOOGLE_DOTENV=$whoogle_dotenv
|
||||
|
||||
ARG use_https=''
|
||||
ENV HTTPS_ONLY=$use_https
|
||||
|
||||
ARG whoogle_port=5000
|
||||
ENV EXPOSE_PORT=$whoogle_port
|
||||
|
||||
ARG twitter_alt='nitter.net'
|
||||
ENV WHOOGLE_ALT_TW=$twitter_alt
|
||||
ARG youtube_alt='invidious.snopyta.org'
|
||||
ENV WHOOGLE_ALT_YT=$youtube_alt
|
||||
ARG instagram_alt='bibliogram.art/u'
|
||||
ENV WHOOGLE_ALT_IG=$instagram_alt
|
||||
ARG reddit_alt='libredd.it'
|
||||
ENV WHOOGLE_ALT_RD=$reddit_alt
|
||||
|
||||
WORKDIR /whoogle
|
||||
|
||||
@ -95,22 +62,12 @@ COPY --from=builder /install /usr/local
|
||||
COPY misc/tor/torrc /etc/tor/torrc
|
||||
COPY misc/tor/start-tor.sh misc/tor/start-tor.sh
|
||||
COPY app/ app/
|
||||
COPY run whoogle.env* ./
|
||||
|
||||
# Create user/group to run as
|
||||
RUN adduser -D -g $DOCKER_USERID -u $DOCKER_USERID $DOCKER_USER
|
||||
|
||||
# Fix ownership / permissions
|
||||
RUN chown -R ${DOCKER_USER}:${DOCKER_USER} /whoogle /var/lib/tor
|
||||
|
||||
# Allow writing symlinks to build dir
|
||||
RUN chown $DOCKER_USERID:$DOCKER_USERID app/static/build
|
||||
|
||||
USER $DOCKER_USER:$DOCKER_USER
|
||||
COPY run .
|
||||
COPY whoogle.env .
|
||||
|
||||
EXPOSE $EXPOSE_PORT
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s \
|
||||
HEALTHCHECK --interval=30s --timeout=5s \
|
||||
CMD curl -f http://localhost:${EXPOSE_PORT}/healthz || exit 1
|
||||
|
||||
CMD ["/bin/sh", "-c", "misc/tor/start-tor.sh & ./run"]
|
||||
CMD misc/tor/start-tor.sh & ./run
|
||||
|
||||
@ -2,5 +2,4 @@ graft app/static
|
||||
graft app/templates
|
||||
graft app/misc
|
||||
include requirements.txt
|
||||
recursive-include test
|
||||
global-exclude *.pyc
|
||||
|
||||
793
README.md
793
README.md
@ -1,66 +1,44 @@
|
||||
>[!WARNING]
|
||||
>
|
||||
>Since 16 January, 2025, Google has been attacking the ability to perform search queries without JavaScript enabled. This is a fundamental part of how Whoogle
|
||||
>works -- Whoogle requests the JavaScript-free search results, then filters out garbage from the results page and proxies all external content for the user.
|
||||
>
|
||||
>This is possibly a breaking change that may mean the end for Whoogle. We'll continue fighting back and releasing workarounds until all workarounds are
|
||||
>exhausted or a better method is found. If you know of a better way, please review and comment in our Way Forward Discussion
|
||||
|
||||
___
|
||||
|
||||

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

|
||||

|
||||
|
||||
#### Mobile
|
||||

|
||||

|
||||
|
||||
82
app.json
82
app.json
@ -15,11 +15,6 @@
|
||||
],
|
||||
"stack": "container",
|
||||
"env": {
|
||||
"WHOOGLE_URL_PREFIX": {
|
||||
"description": "The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_USER": {
|
||||
"description": "The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.",
|
||||
"value": "",
|
||||
@ -52,69 +47,29 @@
|
||||
},
|
||||
"WHOOGLE_ALT_TW": {
|
||||
"description": "The site to use as a replacement for twitter.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/nitter",
|
||||
"value": "nitter.net",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_YT": {
|
||||
"description": "The site to use as a replacement for youtube.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/invidious",
|
||||
"value": "invidious.snopyta.org",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_IG": {
|
||||
"description": "The site to use as a replacement for instagram.com when site alternatives are enabled in the config.",
|
||||
"value": "bibliogram.art/u",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_RD": {
|
||||
"description": "The site to use as a replacement for reddit.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/libreddit",
|
||||
"value": "libredd.it",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_MD": {
|
||||
"description": "The site to use as a replacement for medium.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/scribe",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_TL": {
|
||||
"description": "The Google Translate alternative to use for all searches following the 'translate ___' structure.",
|
||||
"value": "farside.link/lingva",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_IMG": {
|
||||
"description": "The site to use as a replacement for imgur.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/rimgo",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_WIKI": {
|
||||
"description": "The site to use as a replacement for wikipedia.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/wikiless",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_IMDB": {
|
||||
"description": "The site to use as a replacement for imdb.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/libremdb",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_QUORA": {
|
||||
"description": "The site to use as a replacement for quora.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/quetre",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_SO": {
|
||||
"description": "The site to use as a replacement for stackoverflow.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/anonymousoverflow",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_MINIMAL": {
|
||||
"description": "Remove everything except basic result cards from all search queries (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_COUNTRY": {
|
||||
"description": "[CONFIG] The country to use for restricting search results (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/countries.json)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_TIME_PERIOD" : {
|
||||
"description": "[CONFIG] The time period to use for restricting search results",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_LANGUAGE": {
|
||||
"description": "[CONFIG] The language to use for the interface (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/languages.json)",
|
||||
"value": "",
|
||||
@ -135,9 +90,9 @@
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_THEME": {
|
||||
"description": "[CONFIG] Set theme to 'dark', 'light', or 'system'",
|
||||
"value": "system",
|
||||
"WHOOGLE_CONFIG_DARK": {
|
||||
"description": "[CONFIG] Enable dark mode (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_SAFE": {
|
||||
@ -150,11 +105,6 @@
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_NEAR": {
|
||||
"description": "[CONFIG] Restrict results to only those near a particular city",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_TOR": {
|
||||
"description": "[CONFIG] Use Tor, if available (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
@ -177,18 +127,8 @@
|
||||
},
|
||||
"WHOOGLE_CONFIG_STYLE": {
|
||||
"description": "[CONFIG] Custom CSS styling (paste in CSS or leave blank)",
|
||||
"value": ":root { /* LIGHT THEME COLORS */ --whoogle-background: #d8dee9; --whoogle-accent: #2e3440; --whoogle-text: #3B4252; --whoogle-contrast-text: #eceff4; --whoogle-secondary-text: #70757a; --whoogle-result-bg: #fff; --whoogle-result-title: #4c566a; --whoogle-result-url: #81a1c1; --whoogle-result-visited: #a3be8c; /* DARK THEME COLORS */ --whoogle-dark-background: #222; --whoogle-dark-accent: #685e79; --whoogle-dark-text: #fff; --whoogle-dark-contrast-text: #000; --whoogle-dark-secondary-text: #bbb; --whoogle-dark-result-bg: #000; --whoogle-dark-result-title: #1967d2; --whoogle-dark-result-url: #4b11a8; --whoogle-dark-result-visited: #bbbbff; }",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED": {
|
||||
"description": "[CONFIG] Encrypt preferences token, requires WHOOGLE_CONFIG_PREFERENCES_KEY to be set",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_PREFERENCES_KEY": {
|
||||
"description": "[CONFIG] Key to encrypt preferences",
|
||||
"value": "NEEDS_TO_BE_MODIFIED",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
285
app/__init__.py
285
app/__init__.py
@ -1,303 +1,84 @@
|
||||
from app.filter import clean_query
|
||||
from app.request import send_tor_signal
|
||||
from app.utils.session import generate_key
|
||||
from app.utils.bangs import gen_bangs_json, load_all_bangs
|
||||
from app.utils.misc import gen_file_hash, read_config_bool
|
||||
from app.utils.ua_generator import load_ua_pool
|
||||
from base64 import b64encode
|
||||
from bs4 import MarkupResemblesLocatorWarning
|
||||
from datetime import datetime, timedelta
|
||||
from dotenv import load_dotenv
|
||||
from app.utils.session import generate_user_key
|
||||
from app.utils.bangs import gen_bangs_json
|
||||
from flask import Flask
|
||||
from flask_session import Session
|
||||
import json
|
||||
import logging.config
|
||||
import os
|
||||
import sys
|
||||
from stem import Signal
|
||||
import threading
|
||||
import warnings
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from app.services.http_client import HttpxClient
|
||||
from app.services.provider import close_all_clients
|
||||
from app.version import __version__
|
||||
|
||||
app = Flask(__name__, static_folder=os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'static'))
|
||||
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app)
|
||||
|
||||
# look for WHOOGLE_ENV, else look in parent directory
|
||||
dot_env_path = os.getenv(
|
||||
"WHOOGLE_DOTENV_PATH",
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../whoogle.env"))
|
||||
app = Flask(__name__, static_folder=os.path.dirname(
|
||||
os.path.abspath(__file__)) + '/static')
|
||||
|
||||
# Load .env file if enabled
|
||||
if os.path.exists(dot_env_path):
|
||||
load_dotenv(dot_env_path)
|
||||
if os.getenv("WHOOGLE_DOTENV", ''):
|
||||
dotenv_path = '../whoogle.env'
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
dotenv_path))
|
||||
|
||||
app.enc_key = generate_key()
|
||||
|
||||
if read_config_bool('HTTPS_ONLY'):
|
||||
app.config['SESSION_COOKIE_NAME'] = '__Secure-session'
|
||||
app.config['SESSION_COOKIE_SECURE'] = True
|
||||
|
||||
app.config['VERSION_NUMBER'] = __version__
|
||||
app.default_key = generate_user_key()
|
||||
app.no_cookie_ips = []
|
||||
app.config['SECRET_KEY'] = os.urandom(32)
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
app.config['VERSION_NUMBER'] = '0.5.1'
|
||||
app.config['APP_ROOT'] = os.getenv(
|
||||
'APP_ROOT',
|
||||
os.path.dirname(os.path.abspath(__file__)))
|
||||
app.config['STATIC_FOLDER'] = os.getenv(
|
||||
'STATIC_FOLDER',
|
||||
os.path.join(app.config['APP_ROOT'], 'static'))
|
||||
app.config['BUILD_FOLDER'] = os.path.join(
|
||||
app.config['STATIC_FOLDER'], 'build')
|
||||
app.config['CACHE_BUSTING_MAP'] = {}
|
||||
app.config['BUNDLE_STATIC'] = read_config_bool('WHOOGLE_BUNDLE_STATIC')
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['LANGUAGES'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['COUNTRIES'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/time_periods.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['TIME_PERIODS'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['TRANSLATIONS'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/themes.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['THEMES'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/header_tabs.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['HEADER_TABS'] = json.load(f)
|
||||
app.config['LANGUAGES'] = json.load(open(
|
||||
os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json')))
|
||||
app.config['COUNTRIES'] = json.load(open(
|
||||
os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json')))
|
||||
app.config['TRANSLATIONS'] = json.load(open(
|
||||
os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json')))
|
||||
app.config['CONFIG_PATH'] = os.getenv(
|
||||
'CONFIG_VOLUME',
|
||||
os.path.join(app.config['STATIC_FOLDER'], 'config'))
|
||||
app.config['DEFAULT_CONFIG'] = os.path.join(
|
||||
app.config['CONFIG_PATH'],
|
||||
'config.json')
|
||||
app.config['CONFIG_DISABLE'] = read_config_bool('WHOOGLE_CONFIG_DISABLE')
|
||||
app.config['CONFIG_DISABLE'] = os.getenv('WHOOGLE_CONFIG_DISABLE', '')
|
||||
app.config['SESSION_FILE_DIR'] = os.path.join(
|
||||
app.config['CONFIG_PATH'],
|
||||
'session')
|
||||
# Maximum session file size in bytes (4KB limit to prevent abuse and disk exhaustion)
|
||||
# Session files larger than this are ignored during cleanup to avoid processing
|
||||
# potentially malicious or corrupted files
|
||||
app.config['MAX_SESSION_SIZE'] = 4000
|
||||
app.config['BANG_PATH'] = os.getenv(
|
||||
'CONFIG_VOLUME',
|
||||
os.path.join(app.config['STATIC_FOLDER'], 'bangs'))
|
||||
app.config['BANG_FILE'] = os.path.join(
|
||||
app.config['BANG_PATH'],
|
||||
'bangs.json')
|
||||
app.config['CSP'] = 'default-src \'none\';' \
|
||||
'manifest-src \'self\';' \
|
||||
'img-src \'self\' data:;' \
|
||||
'style-src \'self\' \'unsafe-inline\';' \
|
||||
'script-src \'self\';' \
|
||||
'media-src \'self\';' \
|
||||
'connect-src \'self\';' \
|
||||
'form-action \'self\';'
|
||||
|
||||
# Global services registry (simple DI)
|
||||
app.services = {}
|
||||
|
||||
|
||||
@app.teardown_appcontext
|
||||
def _teardown_clients(exception):
|
||||
try:
|
||||
close_all_clients()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure all necessary directories exist
|
||||
if not os.path.exists(app.config['CONFIG_PATH']):
|
||||
os.makedirs(app.config['CONFIG_PATH'])
|
||||
|
||||
if not os.path.exists(app.config['SESSION_FILE_DIR']):
|
||||
os.makedirs(app.config['SESSION_FILE_DIR'])
|
||||
|
||||
# Generate DDG bang filter, and create path if it doesn't exist yet
|
||||
if not os.path.exists(app.config['BANG_PATH']):
|
||||
os.makedirs(app.config['BANG_PATH'])
|
||||
|
||||
if not os.path.exists(app.config['BUILD_FOLDER']):
|
||||
os.makedirs(app.config['BUILD_FOLDER'])
|
||||
|
||||
# Initialize User Agent pool
|
||||
app.config['UA_CACHE_PATH'] = os.path.join(app.config['CONFIG_PATH'], 'ua_cache.json')
|
||||
try:
|
||||
app.config['UA_POOL'] = load_ua_pool(app.config['UA_CACHE_PATH'], count=10)
|
||||
except Exception as e:
|
||||
# If UA pool loading fails, log warning and set empty pool
|
||||
# The gen_user_agent function will handle the fallback
|
||||
print(f"Warning: Could not initialize UA pool: {e}")
|
||||
app.config['UA_POOL'] = []
|
||||
|
||||
# Session values - Secret key management
|
||||
# Priority: environment variable → file → generate new
|
||||
def get_secret_key():
|
||||
"""Load or generate secret key with validation.
|
||||
|
||||
Priority order:
|
||||
1. WHOOGLE_SECRET_KEY environment variable
|
||||
2. Existing key file
|
||||
3. Generate new key and save to file
|
||||
|
||||
Returns:
|
||||
str: Valid secret key for Flask sessions
|
||||
"""
|
||||
# Check environment variable first
|
||||
env_key = os.getenv('WHOOGLE_SECRET_KEY', '').strip()
|
||||
if env_key:
|
||||
# Validate env key has minimum length
|
||||
if len(env_key) >= 32:
|
||||
return env_key
|
||||
else:
|
||||
print(f"Warning: WHOOGLE_SECRET_KEY too short ({len(env_key)} chars, need 32+). Using file/generated key instead.", file=sys.stderr)
|
||||
|
||||
# Check file-based key
|
||||
app_key_path = os.path.join(app.config['CONFIG_PATH'], 'whoogle.key')
|
||||
if os.path.exists(app_key_path):
|
||||
try:
|
||||
with open(app_key_path, 'r', encoding='utf-8') as f:
|
||||
key = f.read().strip()
|
||||
# Validate file key
|
||||
if len(key) >= 32:
|
||||
return key
|
||||
else:
|
||||
print(f"Warning: Key file too short, regenerating", file=sys.stderr)
|
||||
except (PermissionError, IOError) as e:
|
||||
print(f"Warning: Could not read key file: {e}", file=sys.stderr)
|
||||
|
||||
# Generate new key
|
||||
new_key = str(b64encode(os.urandom(32)))
|
||||
try:
|
||||
with open(app_key_path, 'w', encoding='utf-8') as key_file:
|
||||
key_file.write(new_key)
|
||||
except (PermissionError, IOError) as e:
|
||||
print(f"Warning: Could not save key file: {e}. Key will not persist across restarts.", file=sys.stderr)
|
||||
|
||||
return new_key
|
||||
|
||||
app.config['SECRET_KEY'] = get_secret_key()
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365)
|
||||
|
||||
# NOTE: SESSION_COOKIE_SAMESITE must be set to 'lax' to allow the user's
|
||||
# previous session to persist when accessing the instance from an external
|
||||
# link. Setting this value to 'strict' causes Whoogle to revalidate a new
|
||||
# session, and fail, resulting in cookies being disabled.
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
|
||||
# Config fields that are used to check for updates
|
||||
app.config['RELEASES_URL'] = 'https://github.com/' \
|
||||
'benbusby/whoogle-search/releases'
|
||||
app.config['LAST_UPDATE_CHECK'] = datetime.now() - timedelta(hours=24)
|
||||
app.config['HAS_UPDATE'] = ''
|
||||
|
||||
# The alternative to Google Translate is treated a bit differently than other
|
||||
# social media site alternatives, in that it is used for any translation
|
||||
# related searches.
|
||||
translate_url = os.getenv('WHOOGLE_ALT_TL', 'https://farside.link/lingva')
|
||||
if not translate_url.startswith('http'):
|
||||
translate_url = 'https://' + translate_url
|
||||
app.config['TRANSLATE_URL'] = translate_url
|
||||
|
||||
app.config['CSP'] = 'default-src \'none\';' \
|
||||
'frame-src ' + translate_url + ';' \
|
||||
'manifest-src \'self\';' \
|
||||
'img-src \'self\' data:;' \
|
||||
'style-src \'self\' \'unsafe-inline\';' \
|
||||
'script-src \'self\';' \
|
||||
'media-src \'self\';' \
|
||||
'connect-src \'self\';'
|
||||
|
||||
# Generate DDG bang filter
|
||||
generating_bangs = False
|
||||
if not os.path.exists(app.config['BANG_FILE']):
|
||||
generating_bangs = True
|
||||
with open(app.config['BANG_FILE'], 'w', encoding='utf-8') as f:
|
||||
json.dump({}, f)
|
||||
bangs_thread = threading.Thread(
|
||||
target=gen_bangs_json,
|
||||
args=(app.config['BANG_FILE'],))
|
||||
bangs_thread.start()
|
||||
gen_bangs_json(app.config['BANG_FILE'])
|
||||
|
||||
# Build new mapping of static files for cache busting
|
||||
cache_busting_dirs = ['css', 'js']
|
||||
for cb_dir in cache_busting_dirs:
|
||||
full_cb_dir = os.path.join(app.config['STATIC_FOLDER'], cb_dir)
|
||||
for cb_file in os.listdir(full_cb_dir):
|
||||
# Create hash from current file state
|
||||
full_cb_path = os.path.join(full_cb_dir, cb_file)
|
||||
cb_file_link = gen_file_hash(full_cb_dir, cb_file)
|
||||
build_path = os.path.join(app.config['BUILD_FOLDER'], cb_file_link)
|
||||
|
||||
try:
|
||||
os.symlink(full_cb_path, build_path)
|
||||
except FileExistsError:
|
||||
# Symlink hasn't changed, ignore
|
||||
pass
|
||||
|
||||
# Create mapping for relative path urls
|
||||
map_path = build_path.replace(app.config['APP_ROOT'], '')
|
||||
if map_path.startswith('/'):
|
||||
map_path = map_path[1:]
|
||||
app.config['CACHE_BUSTING_MAP'][cb_file] = map_path
|
||||
|
||||
# Optionally create simple bundled assets (opt-in via WHOOGLE_BUNDLE_STATIC=1)
|
||||
if app.config['BUNDLE_STATIC']:
|
||||
# CSS bundle: include all css except theme files (end with -theme.css)
|
||||
css_dir = os.path.join(app.config['STATIC_FOLDER'], 'css')
|
||||
css_parts = []
|
||||
for name in sorted(os.listdir(css_dir)):
|
||||
if not name.endswith('.css'):
|
||||
continue
|
||||
if name.endswith('-theme.css'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(css_dir, name), 'r', encoding='utf-8') as f:
|
||||
css_parts.append(f.read())
|
||||
except Exception:
|
||||
pass
|
||||
css_bundle = '\n'.join(css_parts)
|
||||
if css_bundle:
|
||||
css_tmp = os.path.join(app.config['BUILD_FOLDER'], 'app.css')
|
||||
with open(css_tmp, 'w', encoding='utf-8') as f:
|
||||
f.write(css_bundle)
|
||||
css_hashed = gen_file_hash(app.config['BUILD_FOLDER'], 'app.css')
|
||||
os.replace(css_tmp, os.path.join(app.config['BUILD_FOLDER'], css_hashed))
|
||||
map_path = os.path.join('app/static/build', css_hashed)
|
||||
app.config['CACHE_BUSTING_MAP']['bundle.css'] = map_path
|
||||
|
||||
# JS bundle: include all js files
|
||||
js_dir = os.path.join(app.config['STATIC_FOLDER'], 'js')
|
||||
js_parts = []
|
||||
for name in sorted(os.listdir(js_dir)):
|
||||
if not name.endswith('.js'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(js_dir, name), 'r', encoding='utf-8') as f:
|
||||
js_parts.append(f.read())
|
||||
except Exception:
|
||||
pass
|
||||
js_bundle = '\n;'.join(js_parts)
|
||||
if js_bundle:
|
||||
js_tmp = os.path.join(app.config['BUILD_FOLDER'], 'app.js')
|
||||
with open(js_tmp, 'w', encoding='utf-8') as f:
|
||||
f.write(js_bundle)
|
||||
js_hashed = gen_file_hash(app.config['BUILD_FOLDER'], 'app.js')
|
||||
os.replace(js_tmp, os.path.join(app.config['BUILD_FOLDER'], js_hashed))
|
||||
map_path = os.path.join('app/static/build', js_hashed)
|
||||
app.config['CACHE_BUSTING_MAP']['bundle.js'] = map_path
|
||||
|
||||
# Templating functions
|
||||
app.jinja_env.globals.update(clean_query=clean_query)
|
||||
app.jinja_env.globals.update(
|
||||
cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f.lower()])
|
||||
app.jinja_env.globals.update(
|
||||
bundle_static=lambda: app.config.get('BUNDLE_STATIC', False))
|
||||
Session(app)
|
||||
|
||||
# Attempt to acquire tor identity, to determine if Tor config is available
|
||||
send_tor_signal(Signal.HEARTBEAT)
|
||||
|
||||
# Suppress spurious warnings from BeautifulSoup
|
||||
warnings.simplefilter('ignore', MarkupResemblesLocatorWarning)
|
||||
|
||||
from app import routes # noqa
|
||||
|
||||
# The gen_bangs_json function takes care of loading bangs, so skip it here if
|
||||
# it's already being loaded
|
||||
if not generating_bangs:
|
||||
load_all_bangs(app.config['BANG_FILE'])
|
||||
|
||||
# Disable logging from imported modules
|
||||
logging.config.dictConfig({
|
||||
'version': 1,
|
||||
|
||||
943
app/filter.py
943
app/filter.py
File diff suppressed because it is too large
Load Diff
@ -1,88 +1,27 @@
|
||||
from inspect import Attribute
|
||||
from typing import Optional
|
||||
from app.utils.misc import read_config_bool
|
||||
from flask import current_app
|
||||
import os
|
||||
from base64 import urlsafe_b64encode, urlsafe_b64decode
|
||||
from cryptography.fernet import Fernet
|
||||
import hashlib
|
||||
import brotli
|
||||
import logging
|
||||
import json
|
||||
|
||||
import cssutils
|
||||
from cssutils.css.cssstylesheet import CSSStyleSheet
|
||||
from cssutils.css.cssstylerule import CSSStyleRule
|
||||
|
||||
# removes warnings from cssutils
|
||||
cssutils.log.setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
def get_rule_for_selector(stylesheet: CSSStyleSheet,
|
||||
selector: str) -> Optional[CSSStyleRule]:
|
||||
"""Search for a rule that matches a given selector in a stylesheet.
|
||||
|
||||
Args:
|
||||
stylesheet (CSSStyleSheet) -- the stylesheet to search
|
||||
selector (str) -- the selector to search for
|
||||
|
||||
Returns:
|
||||
Optional[CSSStyleRule] -- the rule that matches the selector or None
|
||||
"""
|
||||
for rule in stylesheet.cssRules:
|
||||
if hasattr(rule, "selectorText") and selector == rule.selectorText:
|
||||
return rule
|
||||
return None
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, **kwargs):
|
||||
# User agent configuration - default to env_conf if environment variables exist, otherwise default
|
||||
env_user_agent = os.getenv('WHOOGLE_USER_AGENT', '')
|
||||
env_mobile_agent = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')
|
||||
default_ua_option = 'env_conf' if (env_user_agent or env_mobile_agent) else 'default'
|
||||
|
||||
self.user_agent = kwargs.get('user_agent', default_ua_option)
|
||||
self.custom_user_agent = kwargs.get('custom_user_agent', '')
|
||||
self.use_custom_user_agent = kwargs.get('use_custom_user_agent', False)
|
||||
self.show_user_agent = read_config_bool('WHOOGLE_CONFIG_SHOW_USER_AGENT')
|
||||
|
||||
# Add user agent related keys to safe_keys
|
||||
# Note: CSE credentials (cse_api_key, cse_id) are intentionally NOT included
|
||||
# in safe_keys for security - they should not be shareable via URL
|
||||
self.safe_keys = [
|
||||
'lang_search',
|
||||
'lang_interface',
|
||||
'country',
|
||||
'theme',
|
||||
'alts',
|
||||
'new_tab',
|
||||
'view_image',
|
||||
'block',
|
||||
'safe',
|
||||
'nojs',
|
||||
'anon_view',
|
||||
'preferences_encrypted',
|
||||
'tbs',
|
||||
'user_agent',
|
||||
'custom_user_agent',
|
||||
'use_custom_user_agent',
|
||||
'show_user_agent'
|
||||
]
|
||||
def read_config_bool(var: str) -> bool:
|
||||
val = os.getenv(var, '0')
|
||||
if val.isdigit():
|
||||
return bool(int(val))
|
||||
return False
|
||||
|
||||
app_config = current_app.config
|
||||
self.url = os.getenv('WHOOGLE_CONFIG_URL', '')
|
||||
self.lang_search = os.getenv('WHOOGLE_CONFIG_SEARCH_LANGUAGE', '')
|
||||
self.lang_interface = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '')
|
||||
self.style_modified = os.getenv(
|
||||
'WHOOGLE_CONFIG_STYLE', '')
|
||||
self.style = os.getenv(
|
||||
'WHOOGLE_CONFIG_STYLE',
|
||||
open(os.path.join(app_config['STATIC_FOLDER'],
|
||||
'css/variables.css')).read())
|
||||
self.block = os.getenv('WHOOGLE_CONFIG_BLOCK', '')
|
||||
self.block_title = os.getenv('WHOOGLE_CONFIG_BLOCK_TITLE', '')
|
||||
self.block_url = os.getenv('WHOOGLE_CONFIG_BLOCK_URL', '')
|
||||
self.country = os.getenv('WHOOGLE_CONFIG_COUNTRY', '')
|
||||
self.tbs = os.getenv('WHOOGLE_CONFIG_TIME_PERIOD', '')
|
||||
self.theme = os.getenv('WHOOGLE_CONFIG_THEME', 'system')
|
||||
self.ctry = os.getenv('WHOOGLE_CONFIG_COUNTRY', '')
|
||||
self.safe = read_config_bool('WHOOGLE_CONFIG_SAFE')
|
||||
self.dark = read_config_bool('WHOOGLE_CONFIG_DARK')
|
||||
self.alts = read_config_bool('WHOOGLE_CONFIG_ALTS')
|
||||
self.nojs = read_config_bool('WHOOGLE_CONFIG_NOJS')
|
||||
self.tor = read_config_bool('WHOOGLE_CONFIG_TOR')
|
||||
@ -90,25 +29,19 @@ class Config:
|
||||
self.new_tab = read_config_bool('WHOOGLE_CONFIG_NEW_TAB')
|
||||
self.view_image = read_config_bool('WHOOGLE_CONFIG_VIEW_IMAGE')
|
||||
self.get_only = read_config_bool('WHOOGLE_CONFIG_GET_ONLY')
|
||||
self.anon_view = read_config_bool('WHOOGLE_CONFIG_ANON_VIEW')
|
||||
self.preferences_encrypted = read_config_bool('WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED')
|
||||
self.preferences_key = os.getenv('WHOOGLE_CONFIG_PREFERENCES_KEY', '')
|
||||
|
||||
# Google Custom Search Engine (CSE) BYOK settings
|
||||
self.cse_api_key = os.getenv('WHOOGLE_CSE_API_KEY', '')
|
||||
self.cse_id = os.getenv('WHOOGLE_CSE_ID', '')
|
||||
self.use_cse = read_config_bool('WHOOGLE_USE_CSE')
|
||||
|
||||
self.accept_language = False
|
||||
self.safe_keys = [
|
||||
'lang_search',
|
||||
'lang_interface',
|
||||
'ctry',
|
||||
'dark'
|
||||
]
|
||||
|
||||
# Skip setting custom config if there isn't one
|
||||
if kwargs:
|
||||
mutable_attrs = self.get_mutable_attrs()
|
||||
for attr in mutable_attrs:
|
||||
if attr == 'show_user_agent':
|
||||
# Handle show_user_agent as boolean
|
||||
self.show_user_agent = bool(kwargs.get(attr))
|
||||
elif attr in kwargs.keys():
|
||||
if attr in kwargs.keys():
|
||||
setattr(self, attr, kwargs[attr])
|
||||
elif attr not in kwargs.keys() and mutable_attrs[attr] == bool:
|
||||
setattr(self, attr, False)
|
||||
@ -130,50 +63,6 @@ class Config:
|
||||
if not name.startswith("__")
|
||||
and (type(attr) is bool or type(attr) is str)}
|
||||
|
||||
def get_attrs(self):
|
||||
return {name: attr for name, attr in self.__dict__.items()
|
||||
if not name.startswith("__")
|
||||
and (type(attr) is bool or type(attr) is str)}
|
||||
|
||||
@property
|
||||
def style(self) -> str:
|
||||
"""Returns the default style updated with specified modifications.
|
||||
|
||||
Returns:
|
||||
str -- the new style
|
||||
"""
|
||||
vars_path = os.path.join(current_app.config['STATIC_FOLDER'], 'css/variables.css')
|
||||
with open(vars_path, 'r', encoding='utf-8') as f:
|
||||
style_sheet = cssutils.parseString(f.read())
|
||||
|
||||
modified_sheet = cssutils.parseString(self.style_modified)
|
||||
for rule in modified_sheet:
|
||||
rule_default = get_rule_for_selector(style_sheet,
|
||||
rule.selectorText)
|
||||
# if modified rule is in default stylesheet, update it
|
||||
if rule_default is not None:
|
||||
# TODO: update this in a smarter way to handle :root better
|
||||
# for now if we change a varialbe in :root all other default
|
||||
# variables need to be also present
|
||||
rule_default.style = rule.style
|
||||
# else add the new rule to the default stylesheet
|
||||
else:
|
||||
style_sheet.add(rule)
|
||||
return str(style_sheet.cssText, 'utf-8')
|
||||
|
||||
@property
|
||||
def preferences(self) -> str:
|
||||
# if encryption key is not set will uncheck preferences encryption
|
||||
if self.preferences_encrypted:
|
||||
self.preferences_encrypted = bool(self.preferences_key)
|
||||
|
||||
# add a tag for visibility if preferences token startswith 'e' it means
|
||||
# the token is encrypted, 'u' means the token is unencrypted and can be
|
||||
# used by other whoogle instances
|
||||
encrypted_flag = "e" if self.preferences_encrypted else 'u'
|
||||
preferences_digest = self._encode_preferences()
|
||||
return f"{encrypted_flag}{preferences_digest}"
|
||||
|
||||
def is_safe_key(self, key) -> bool:
|
||||
"""Establishes a group of config options that are safe to set
|
||||
in the url.
|
||||
@ -212,104 +101,8 @@ class Config:
|
||||
Returns:
|
||||
Config -- a modified config object
|
||||
"""
|
||||
if 'preferences' in params:
|
||||
params_new = self._decode_preferences(params['preferences'])
|
||||
# if preferences leads to an empty dictionary it means preferences
|
||||
# parameter was not decrypted successfully
|
||||
if len(params_new):
|
||||
params = params_new
|
||||
|
||||
for param_key in params.keys():
|
||||
if not self.is_safe_key(param_key):
|
||||
continue
|
||||
param_val = params.get(param_key)
|
||||
|
||||
if param_val == 'off':
|
||||
param_val = False
|
||||
elif isinstance(param_val, str):
|
||||
if param_val.isdigit():
|
||||
param_val = int(param_val)
|
||||
|
||||
self[param_key] = param_val
|
||||
self[param_key] = params.get(param_key)
|
||||
return self
|
||||
|
||||
def to_params(self, keys: list = []) -> str:
|
||||
"""Generates a set of safe params for using in Whoogle URLs
|
||||
|
||||
Args:
|
||||
keys (list) -- optional list of keys of URL parameters
|
||||
|
||||
Returns:
|
||||
str -- a set of URL parameters
|
||||
"""
|
||||
if not len(keys):
|
||||
keys = self.safe_keys
|
||||
|
||||
param_str = ''
|
||||
for safe_key in keys:
|
||||
if not self[safe_key]:
|
||||
continue
|
||||
param_str = param_str + f'&{safe_key}={self[safe_key]}'
|
||||
|
||||
return param_str
|
||||
|
||||
def _get_fernet_key(self, password: str) -> bytes:
|
||||
"""Derive a Fernet-compatible key from a password using PBKDF2.
|
||||
|
||||
Note: This uses a static salt for simplicity. This is a breaking change
|
||||
from the previous MD5-based implementation. Existing encrypted preferences
|
||||
will need to be re-encrypted.
|
||||
|
||||
Args:
|
||||
password: The password to derive the key from
|
||||
|
||||
Returns:
|
||||
bytes: A URL-safe base64 encoded 32-byte key suitable for Fernet
|
||||
"""
|
||||
# Use a static salt derived from app context
|
||||
# In a production system, you'd want to store per-user salts
|
||||
salt = b'whoogle-preferences-salt-v2'
|
||||
|
||||
# Derive a 32-byte key using PBKDF2 with SHA256
|
||||
# 100,000 iterations is a reasonable balance of security and performance
|
||||
kdf_key = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
password.encode('utf-8'),
|
||||
salt,
|
||||
100000,
|
||||
dklen=32
|
||||
)
|
||||
|
||||
# Fernet requires a URL-safe base64 encoded key
|
||||
return urlsafe_b64encode(kdf_key)
|
||||
|
||||
def _encode_preferences(self) -> str:
|
||||
preferences_json = json.dumps(self.get_attrs()).encode()
|
||||
compressed_preferences = brotli.compress(preferences_json)
|
||||
|
||||
if self.preferences_encrypted and self.preferences_key:
|
||||
key = self._get_fernet_key(self.preferences_key)
|
||||
encrypted_preferences = Fernet(key).encrypt(compressed_preferences)
|
||||
compressed_preferences = brotli.compress(encrypted_preferences)
|
||||
|
||||
return urlsafe_b64encode(compressed_preferences).decode()
|
||||
|
||||
def _decode_preferences(self, preferences: str) -> dict:
|
||||
mode = preferences[0]
|
||||
preferences = preferences[1:]
|
||||
|
||||
try:
|
||||
decoded_data = brotli.decompress(urlsafe_b64decode(preferences.encode() + b'=='))
|
||||
|
||||
if mode == 'e' and self.preferences_key:
|
||||
# preferences are encrypted
|
||||
key = self._get_fernet_key(self.preferences_key)
|
||||
decrypted_data = Fernet(key).decrypt(decoded_data)
|
||||
decoded_data = brotli.decompress(decrypted_data)
|
||||
|
||||
config = json.loads(decoded_data)
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@ -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,48 +0,0 @@
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
class GClasses:
|
||||
"""A class for tracking obfuscated class names used in Google results that
|
||||
are directly referenced in Whoogle's filtering code.
|
||||
|
||||
Note: Using these should be a last resort. It is always preferred to filter
|
||||
results using structural cues instead of referencing class names, as these
|
||||
are liable to change at any moment.
|
||||
"""
|
||||
main_tbm_tab = 'KP7LCb'
|
||||
images_tbm_tab = 'n692Zd'
|
||||
footer = 'TuS8Ad'
|
||||
result_class_a = 'ZINbbc'
|
||||
result_class_b = 'luh4td'
|
||||
scroller_class = 'idg8be'
|
||||
line_tag = 'BsXmcf'
|
||||
|
||||
result_classes = {
|
||||
result_class_a: ['Gx5Zad'],
|
||||
result_class_b: ['fP1Qef']
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def replace_css_classes(cls, soup: BeautifulSoup) -> BeautifulSoup:
|
||||
"""Replace updated Google classes with the original class names that
|
||||
Whoogle relies on for styling.
|
||||
|
||||
Args:
|
||||
soup: The result page as a BeautifulSoup object
|
||||
|
||||
Returns:
|
||||
BeautifulSoup: The new BeautifulSoup
|
||||
"""
|
||||
result_divs = soup.find_all('div', {
|
||||
'class': [_ for c in cls.result_classes.values() for _ in c]
|
||||
})
|
||||
|
||||
for div in result_divs:
|
||||
new_class = ' '.join(div['class'])
|
||||
for key, val in cls.result_classes.items():
|
||||
new_class = ' '.join(new_class.replace(_, key) for _ in val)
|
||||
div['class'] = new_class.split(' ')
|
||||
return soup
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
322
app/request.py
322
app/request.py
@ -1,20 +1,22 @@
|
||||
from app.models.config import Config
|
||||
from app.utils.misc import read_config_bool
|
||||
from app.services.provider import get_http_client
|
||||
from app.utils.ua_generator import load_ua_pool, get_random_ua, DEFAULT_FALLBACK_UA
|
||||
from defusedxml import ElementTree as ET
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
import random
|
||||
import requests
|
||||
from requests import Response, ConnectionError
|
||||
import urllib.parse as urlparse
|
||||
import os
|
||||
from stem import Signal, SocketError
|
||||
from stem.connection import AuthenticationFailure
|
||||
from stem.control import Controller
|
||||
from stem.connection import authenticate_cookie, authenticate_password
|
||||
|
||||
SEARCH_URL = 'https://www.google.com/search?gbv=1&q='
|
||||
MAPS_URL = 'https://maps.google.com/maps'
|
||||
AUTOCOMPLETE_URL = ('https://suggestqueries.google.com/'
|
||||
'complete/search?client=toolbar&')
|
||||
|
||||
MOBILE_UA = '{}/5.0 (Android 0; Mobile; rv:54.0) Gecko/54.0 {}/59.0'
|
||||
DESKTOP_UA = '{}/5.0 (X11; {} x86_64; rv:75.0) Gecko/20100101 {}/75.0'
|
||||
|
||||
# Valid query params
|
||||
VALID_PARAMS = ['tbs', 'tbm', 'start', 'near', 'source', 'nfpr']
|
||||
|
||||
@ -36,87 +38,29 @@ class TorError(Exception):
|
||||
|
||||
|
||||
def send_tor_signal(signal: Signal) -> bool:
|
||||
use_pass = read_config_bool('WHOOGLE_TOR_USE_PASS')
|
||||
|
||||
confloc = './misc/tor/control.conf'
|
||||
# Check that the custom location of conf is real.
|
||||
temp = os.getenv('WHOOGLE_TOR_CONF', '')
|
||||
if os.path.isfile(temp):
|
||||
confloc = temp
|
||||
|
||||
# Attempt to authenticate and send signal.
|
||||
try:
|
||||
with Controller.from_port(port=9051) as c:
|
||||
if use_pass:
|
||||
with open(confloc, "r") as conf:
|
||||
# Scan for the last line of the file.
|
||||
for line in conf:
|
||||
pass
|
||||
secret = line.strip('\n')
|
||||
authenticate_password(c, password=secret)
|
||||
else:
|
||||
cookie_path = '/var/lib/tor/control_auth_cookie'
|
||||
authenticate_cookie(c, cookie_path=cookie_path)
|
||||
c.authenticate()
|
||||
c.signal(signal)
|
||||
os.environ['TOR_AVAILABLE'] = '1'
|
||||
return True
|
||||
except (SocketError, AuthenticationFailure,
|
||||
ConnectionRefusedError, ConnectionError):
|
||||
# TODO: Handle Tor authentication (password and cookie)
|
||||
except (SocketError, ConnectionRefusedError, ConnectionError):
|
||||
os.environ['TOR_AVAILABLE'] = '0'
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def gen_user_agent(config, is_mobile) -> str:
|
||||
# If using custom user agent, return the custom string
|
||||
if config.user_agent == 'custom' and config.custom_user_agent:
|
||||
return config.custom_user_agent
|
||||
def gen_user_agent(is_mobile) -> str:
|
||||
firefox = random.choice(['Choir', 'Squier', 'Higher', 'Wire']) + 'fox'
|
||||
linux = random.choice(['Win', 'Sin', 'Gin', 'Fin', 'Kin']) + 'ux'
|
||||
|
||||
# If using environment configuration
|
||||
if config.user_agent == 'env_conf':
|
||||
if is_mobile:
|
||||
env_ua = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')
|
||||
if env_ua:
|
||||
return env_ua
|
||||
else:
|
||||
env_ua = os.getenv('WHOOGLE_USER_AGENT', '')
|
||||
if env_ua:
|
||||
return env_ua
|
||||
# If env vars are not set, fall back to Opera UA
|
||||
return DEFAULT_FALLBACK_UA
|
||||
if is_mobile:
|
||||
return MOBILE_UA.format("Mozilla", firefox)
|
||||
|
||||
# If using default user agent - use auto-generated Opera UA pool
|
||||
if config.user_agent == 'default':
|
||||
try:
|
||||
# Try to load UA pool from cache (lazy loading if not in app.config)
|
||||
# First check if we have access to Flask app context
|
||||
try:
|
||||
from flask import current_app
|
||||
if hasattr(current_app, 'config') and 'UA_POOL' in current_app.config:
|
||||
ua_pool = current_app.config['UA_POOL']
|
||||
else:
|
||||
# Fall back to loading from disk
|
||||
raise ImportError("UA_POOL not in app config")
|
||||
except (ImportError, RuntimeError):
|
||||
# No Flask context available or UA_POOL not in config, load from disk
|
||||
config_path = os.environ.get('CONFIG_VOLUME',
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'static', 'config'))
|
||||
cache_path = os.path.join(config_path, 'ua_cache.json')
|
||||
ua_pool = load_ua_pool(cache_path, count=10)
|
||||
|
||||
return get_random_ua(ua_pool)
|
||||
except Exception as e:
|
||||
# If anything goes wrong, fall back to default Opera UA
|
||||
print(f"Warning: Could not load UA pool, using fallback Opera UA: {e}")
|
||||
return DEFAULT_FALLBACK_UA
|
||||
|
||||
# Fallback for backwards compatibility (old configs or invalid user_agent values)
|
||||
return DEFAULT_FALLBACK_UA
|
||||
return DESKTOP_UA.format("Mozilla", linux, firefox)
|
||||
|
||||
|
||||
def gen_query(query, args, config) -> str:
|
||||
def gen_query(query, args, config, near_city=None) -> str:
|
||||
param_dict = {key: '' for key in VALID_PARAMS}
|
||||
|
||||
# Use :past(hour/day/week/month/year) if available
|
||||
@ -125,8 +69,8 @@ def gen_query(query, args, config) -> str:
|
||||
if ':past' in query and 'tbs' not in args:
|
||||
time_range = str.strip(query.split(':past', 1)[-1])
|
||||
param_dict['tbs'] = '&tbs=' + ('qdr:' + str.lower(time_range[0]))
|
||||
elif 'tbs' in args or 'tbs' in config:
|
||||
result_tbs = args.get('tbs') if 'tbs' in args else config['tbs']
|
||||
elif 'tbs' in args:
|
||||
result_tbs = args.get('tbs')
|
||||
param_dict['tbs'] = '&tbs=' + result_tbs
|
||||
|
||||
# Occasionally the 'tbs' param provided by google also contains a
|
||||
@ -147,18 +91,14 @@ def gen_query(query, args, config) -> str:
|
||||
# Pass along type of results (news, images, books, etc)
|
||||
if 'tbm' in args:
|
||||
param_dict['tbm'] = '&tbm=' + args.get('tbm')
|
||||
# Google Images now expects the modern udm=2 layout; force it when
|
||||
# requesting images to avoid redirects to the new AI/text layout.
|
||||
if args.get('tbm') == 'isch' and 'udm' not in args:
|
||||
param_dict['udm'] = '&udm=2'
|
||||
|
||||
# Get results page start value (10 per page, ie page 2 start val = 20)
|
||||
if 'start' in args:
|
||||
param_dict['start'] = '&start=' + args.get('start')
|
||||
|
||||
# Search for results near a particular city, if available
|
||||
if config.near:
|
||||
param_dict['near'] = '&near=' + urlparse.quote(config.near)
|
||||
if near_city:
|
||||
param_dict['near'] = '&near=' + urlparse.quote(near_city)
|
||||
|
||||
# Set language for results (lr) if source isn't set, otherwise use the
|
||||
# result language param provided in the results
|
||||
@ -176,26 +116,15 @@ def gen_query(query, args, config) -> str:
|
||||
if 'nfpr' in args:
|
||||
param_dict['nfpr'] = '&nfpr=' + args.get('nfpr')
|
||||
|
||||
# 'chips' is used in image tabs to pass the optional 'filter' to add to the
|
||||
# given search term
|
||||
if 'chips' in args:
|
||||
param_dict['chips'] = '&chips=' + args.get('chips')
|
||||
|
||||
param_dict['gl'] = (
|
||||
'&gl=' + config.country
|
||||
) if config.country else ''
|
||||
param_dict['cr'] = ('&cr=' + config.ctry) if config.ctry else ''
|
||||
param_dict['hl'] = (
|
||||
'&hl=' + config.lang_interface.replace('lang_', '')
|
||||
) if config.lang_interface else ''
|
||||
param_dict['safe'] = '&safe=' + ('active' if config.safe else 'off')
|
||||
|
||||
# Block all sites specified in the user config
|
||||
unquoted_query = urlparse.unquote(query)
|
||||
for blocked_site in config.block.replace(' ', '').split(','):
|
||||
if not blocked_site:
|
||||
continue
|
||||
block = (' -site:' + blocked_site)
|
||||
query += block if block not in unquoted_query else ''
|
||||
for blocked in config.block.split(','):
|
||||
query += (' -site:' + blocked) if blocked else ''
|
||||
|
||||
for val in param_dict.values():
|
||||
if not val:
|
||||
@ -215,66 +144,37 @@ class Request:
|
||||
config: the user's current whoogle configuration
|
||||
"""
|
||||
|
||||
def __init__(self, normal_ua, root_path, config: Config, http_client=None):
|
||||
self.search_url = 'https://www.google.com/search?gbv=1&q='
|
||||
# Google Images rejects the lightweight gbv=1 interface. Use the
|
||||
# modern udm=2 entrypoint specifically for image searches to avoid the
|
||||
# "update your browser" interstitial.
|
||||
self.image_search_url = 'https://www.google.com/search?udm=2&q='
|
||||
# Optionally send heartbeat to Tor to determine availability
|
||||
# Only when Tor is enabled in config to avoid unnecessary socket usage
|
||||
if config.tor:
|
||||
send_tor_signal(Signal.HEARTBEAT)
|
||||
def __init__(self, normal_ua, root_path, config: Config):
|
||||
# Send heartbeat to Tor, used in determining if the user can or cannot
|
||||
# enable Tor for future requests
|
||||
send_tor_signal(Signal.HEARTBEAT)
|
||||
|
||||
self.language = config.lang_search if config.lang_search else ''
|
||||
self.country = config.country if config.country else ''
|
||||
|
||||
# For setting Accept-language Header
|
||||
self.lang_interface = ''
|
||||
if config.accept_language:
|
||||
self.lang_interface = config.lang_interface
|
||||
|
||||
self.mobile = bool(normal_ua) and ('Android' in normal_ua
|
||||
or 'iPhone' in normal_ua)
|
||||
|
||||
# Generate user agent based on config
|
||||
self.modified_user_agent = gen_user_agent(config, self.mobile)
|
||||
self.language = config.lang_search
|
||||
self.mobile = 'Android' in normal_ua or 'iPhone' in normal_ua
|
||||
self.modified_user_agent = gen_user_agent(self.mobile)
|
||||
if not self.mobile:
|
||||
self.modified_user_agent_mobile = gen_user_agent(config, True)
|
||||
self.modified_user_agent_mobile = gen_user_agent(True)
|
||||
|
||||
# Dedicated modern UA to use when Google rejects legacy ones (e.g. Images)
|
||||
self.image_user_agent = (
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/127.0.0.0 Safari/537.36'
|
||||
)
|
||||
|
||||
# Set up proxy configuration
|
||||
proxy_path = os.environ.get('WHOOGLE_PROXY_LOC', '')
|
||||
if proxy_path:
|
||||
proxy_type = os.environ.get('WHOOGLE_PROXY_TYPE', '')
|
||||
proxy_user = os.environ.get('WHOOGLE_PROXY_USER', '')
|
||||
proxy_pass = os.environ.get('WHOOGLE_PROXY_PASS', '')
|
||||
# Set up proxy, if previously configured
|
||||
if os.environ.get('WHOOGLE_PROXY_LOC'):
|
||||
auth_str = ''
|
||||
if proxy_user:
|
||||
auth_str = f'{proxy_user}:{proxy_pass}@'
|
||||
|
||||
proxy_str = f'{proxy_type}://{auth_str}{proxy_path}'
|
||||
if os.environ.get('WHOOGLE_PROXY_USER', ''):
|
||||
auth_str = os.environ.get('WHOOGLE_PROXY_USER', '') + \
|
||||
':' + os.environ.get('WHOOGLE_PROXY_PASS', '')
|
||||
self.proxies = {
|
||||
'https': proxy_str,
|
||||
'http': proxy_str
|
||||
'http': os.environ.get('WHOOGLE_PROXY_TYPE', '') + '://' +
|
||||
auth_str + '@' + os.environ.get('WHOOGLE_PROXY_LOC', ''),
|
||||
}
|
||||
self.proxies['https'] = self.proxies['http'].replace('http',
|
||||
'https')
|
||||
else:
|
||||
self.proxies = {
|
||||
'http': 'socks5://127.0.0.1:9050',
|
||||
'https': 'socks5://127.0.0.1:9050'
|
||||
} if config.tor else {}
|
||||
|
||||
self.tor = config.tor
|
||||
self.tor_valid = False
|
||||
self.root_path = root_path
|
||||
# Initialize HTTP client (shared per proxies)
|
||||
self.http_client = http_client or get_http_client(self.proxies)
|
||||
|
||||
def __getitem__(self, name):
|
||||
return getattr(self, name)
|
||||
@ -289,39 +189,19 @@ class Request:
|
||||
list: The list of matches for possible search suggestions
|
||||
|
||||
"""
|
||||
# Check if autocomplete is disabled via environment variable
|
||||
if os.environ.get('WHOOGLE_AUTOCOMPLETE', '1') == '0':
|
||||
return []
|
||||
|
||||
try:
|
||||
ac_query = dict(q=query)
|
||||
if self.language:
|
||||
ac_query['lr'] = self.language
|
||||
if self.country:
|
||||
ac_query['gl'] = self.country
|
||||
if self.lang_interface:
|
||||
ac_query['hl'] = self.lang_interface
|
||||
ac_query = dict(hl=self.language, q=query)
|
||||
response = self.send(base_url=AUTOCOMPLETE_URL,
|
||||
query=urlparse.urlencode(ac_query)).text
|
||||
|
||||
response = self.send(base_url=AUTOCOMPLETE_URL,
|
||||
query=urlparse.urlencode(ac_query)).text
|
||||
|
||||
if not response:
|
||||
return []
|
||||
|
||||
try:
|
||||
root = ET.fromstring(response)
|
||||
return [_.attrib['data'] for _ in
|
||||
root.findall('.//suggestion/[@data]')]
|
||||
except ET.ParseError:
|
||||
# Malformed XML response
|
||||
return []
|
||||
except Exception as e:
|
||||
# Log the error but don't crash - autocomplete is non-essential
|
||||
print(f"Autocomplete error: {str(e)}")
|
||||
if not response:
|
||||
return []
|
||||
|
||||
def send(self, base_url='', query='', attempt=0,
|
||||
force_mobile=False, user_agent=''):
|
||||
root = ET.fromstring(response)
|
||||
return [_.attrib['data'] for _ in
|
||||
root.findall('.//suggestion/[@data]')]
|
||||
|
||||
def send(self, base_url=SEARCH_URL, query='', attempt=0,
|
||||
force_mobile=False) -> Response:
|
||||
"""Sends an outbound request to a URL. Optionally sends the request
|
||||
using Tor, if enabled by the user.
|
||||
|
||||
@ -330,69 +210,27 @@ class Request:
|
||||
query: The optional query string for the request
|
||||
attempt: The number of attempts made for the request
|
||||
(used for cycling through Tor identities, if enabled)
|
||||
force_mobile: Optional flag to enable a mobile user agent
|
||||
(used for fetching full size images in search results)
|
||||
|
||||
Returns:
|
||||
Response: The Response object returned by the requests call
|
||||
|
||||
"""
|
||||
use_client_user_agent = int(os.environ.get('WHOOGLE_USE_CLIENT_USER_AGENT', '0'))
|
||||
if user_agent and use_client_user_agent == 1:
|
||||
modified_user_agent = user_agent
|
||||
if force_mobile and not self.mobile:
|
||||
modified_user_agent = self.modified_user_agent_mobile
|
||||
else:
|
||||
if force_mobile and not self.mobile:
|
||||
modified_user_agent = self.modified_user_agent_mobile
|
||||
else:
|
||||
modified_user_agent = self.modified_user_agent
|
||||
|
||||
# Some Google endpoints (notably Images) now refuse legacy user agents.
|
||||
# If an image search is detected and the generated UA isn't Chromium-
|
||||
# like, retry with a modern Chrome string to avoid the "update your
|
||||
# browser" interstitial.
|
||||
if (('tbm=isch' in query) or ('udm=2' in query)) and 'Chrome' not in modified_user_agent:
|
||||
modified_user_agent = self.image_user_agent
|
||||
modified_user_agent = self.modified_user_agent
|
||||
|
||||
headers = {
|
||||
'User-Agent': modified_user_agent,
|
||||
'Accept': ('text/html,application/xhtml+xml,application/xml;'
|
||||
'q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8'),
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Cache-Control': 'max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Sec-Fetch-Dest': 'document'
|
||||
'User-Agent': modified_user_agent
|
||||
}
|
||||
# Only attach client hints when using a Chromium-like user agent to
|
||||
# avoid sending conflicting information that can trigger unsupported
|
||||
# browser pages.
|
||||
if 'Chrome' in headers['User-Agent']:
|
||||
headers.update({
|
||||
'Sec-CH-UA': (
|
||||
'"Not/A)Brand";v="8", '
|
||||
'"Chromium";v="127", '
|
||||
'"Google Chrome";v="127"'
|
||||
),
|
||||
'Sec-CH-UA-Mobile': '?0',
|
||||
'Sec-CH-UA-Platform': '"Windows"'
|
||||
})
|
||||
|
||||
|
||||
# Add Accept-Language header tied to the current config if requested
|
||||
if self.lang_interface:
|
||||
headers['Accept-Language'] = (
|
||||
self.lang_interface.replace('lang_', '') + ';q=1.0'
|
||||
# FIXME: Should investigate this further to ensure the consent
|
||||
# view is suppressed correctly
|
||||
now = datetime.now()
|
||||
cookies = {
|
||||
'CONSENT': 'YES+cb.{:d}{:02d}{:02d}-17-p0.de+F+678'.format(
|
||||
now.year, now.month, now.day
|
||||
)
|
||||
|
||||
# Consent cookies keep Google from showing the interstitial consent wall
|
||||
consent_cookies = {
|
||||
'CONSENT': 'PENDING+987',
|
||||
'SOCS': 'CAESHAgBEhIaAB'
|
||||
}
|
||||
|
||||
# Validate Tor conn and request new identity if the last one failed
|
||||
@ -405,39 +243,27 @@ class Request:
|
||||
|
||||
# Make sure that the tor connection is valid, if enabled
|
||||
if self.tor:
|
||||
try:
|
||||
tor_check = self.http_client.get('https://check.torproject.org/',
|
||||
headers=headers,
|
||||
retries=1)
|
||||
self.tor_valid = 'Congratulations' in tor_check.text
|
||||
tor_check = requests.get('https://check.torproject.org/',
|
||||
proxies=self.proxies, headers=headers)
|
||||
self.tor_valid = 'Congratulations' in tor_check.text
|
||||
|
||||
if not self.tor_valid:
|
||||
raise TorError(
|
||||
"Tor connection succeeded, but the connection could "
|
||||
"not be validated by torproject.org",
|
||||
disable=True)
|
||||
except httpx.RequestError:
|
||||
if not self.tor_valid:
|
||||
raise TorError(
|
||||
"Error raised during Tor connection validation",
|
||||
"Tor connection succeeded, but the connection could not "
|
||||
"be validated by torproject.org",
|
||||
disable=True)
|
||||
|
||||
search_base = base_url or self.search_url
|
||||
if not base_url and ('tbm=isch' in query or 'udm=2' in query):
|
||||
search_base = self.image_search_url
|
||||
|
||||
try:
|
||||
response = self.http_client.get(
|
||||
search_base + query,
|
||||
headers=headers,
|
||||
cookies=consent_cookies)
|
||||
except httpx.HTTPError as e:
|
||||
raise
|
||||
response = requests.get(
|
||||
base_url + query,
|
||||
proxies=self.proxies,
|
||||
headers=headers,
|
||||
cookies=cookies)
|
||||
|
||||
# Retry query with new identity if using Tor (max 10 attempts)
|
||||
if 'form id="captcha-form"' in response.text and self.tor:
|
||||
attempt += 1
|
||||
if attempt > 10:
|
||||
raise TorError("Tor query failed -- max attempts exceeded 10")
|
||||
return self.send(search_base, query, attempt)
|
||||
return self.send(base_url, query, attempt)
|
||||
|
||||
return response
|
||||
|
||||
804
app/routes.py
804
app/routes.py
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
|
||||
|
||||
@ -1,452 +0,0 @@
|
||||
"""Google Custom Search Engine (CSE) API Client
|
||||
|
||||
This module provides a client for Google's Custom Search JSON API,
|
||||
allowing users to bring their own API key (BYOK) for search functionality.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import render_template
|
||||
|
||||
|
||||
# Google Custom Search API endpoint
|
||||
CSE_API_URL = 'https://www.googleapis.com/customsearch/v1'
|
||||
|
||||
|
||||
class CSEException(Exception):
|
||||
"""Exception raised for CSE API errors"""
|
||||
def __init__(self, message: str, code: int = 500, is_quota_error: bool = False):
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.is_quota_error = is_quota_error
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSEError:
|
||||
"""Represents an error from the CSE API"""
|
||||
code: int
|
||||
message: str
|
||||
|
||||
@property
|
||||
def is_quota_exceeded(self) -> bool:
|
||||
return self.code == 429 or 'quota' in self.message.lower()
|
||||
|
||||
@property
|
||||
def is_invalid_key(self) -> bool:
|
||||
return self.code == 400 or 'invalid' in self.message.lower()
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSEResult:
|
||||
"""Represents a single search result from CSE API"""
|
||||
title: str
|
||||
link: str
|
||||
snippet: str
|
||||
display_link: str
|
||||
html_title: Optional[str] = None
|
||||
html_snippet: Optional[str] = None
|
||||
# Image-specific fields (populated for image search)
|
||||
image_url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None
|
||||
image_width: Optional[int] = None
|
||||
image_height: Optional[int] = None
|
||||
context_link: Optional[str] = None # Page where image was found
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSEResponse:
|
||||
"""Represents a complete CSE API response"""
|
||||
results: list[CSEResult]
|
||||
total_results: str
|
||||
search_time: float
|
||||
query: str
|
||||
start_index: int
|
||||
is_image_search: bool = False
|
||||
error: Optional[CSEError] = None
|
||||
|
||||
@property
|
||||
def has_error(self) -> bool:
|
||||
return self.error is not None
|
||||
|
||||
@property
|
||||
def has_results(self) -> bool:
|
||||
return len(self.results) > 0
|
||||
|
||||
|
||||
class CSEClient:
|
||||
"""Client for Google Custom Search Engine API
|
||||
|
||||
Usage:
|
||||
client = CSEClient(api_key='your-key', cse_id='your-cse-id')
|
||||
response = client.search('python programming')
|
||||
|
||||
if response.has_error:
|
||||
print(f"Error: {response.error.message}")
|
||||
else:
|
||||
for result in response.results:
|
||||
print(f"{result.title}: {result.link}")
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, cse_id: str, timeout: float = 10.0):
|
||||
"""Initialize CSE client
|
||||
|
||||
Args:
|
||||
api_key: Google API key with Custom Search API enabled
|
||||
cse_id: Custom Search Engine ID (cx parameter)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.cse_id = cse_id
|
||||
self.timeout = timeout
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
start: int = 1,
|
||||
num: int = 10,
|
||||
safe: str = 'off',
|
||||
language: str = '',
|
||||
country: str = '',
|
||||
search_type: str = ''
|
||||
) -> CSEResponse:
|
||||
"""Execute a search query against the CSE API
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
start: Starting result index (1-based, for pagination)
|
||||
num: Number of results to return (max 10)
|
||||
safe: Safe search setting ('off', 'medium', 'high')
|
||||
language: Language restriction (e.g., 'lang_en')
|
||||
country: Country restriction (e.g., 'countryUS')
|
||||
search_type: Type of search ('image' for image search, '' for web)
|
||||
|
||||
Returns:
|
||||
CSEResponse with results or error information
|
||||
"""
|
||||
params = {
|
||||
'key': self.api_key,
|
||||
'cx': self.cse_id,
|
||||
'q': query,
|
||||
'start': start,
|
||||
'num': min(num, 10), # API max is 10
|
||||
'safe': safe,
|
||||
}
|
||||
|
||||
# Add search type for image search
|
||||
if search_type == 'image':
|
||||
params['searchType'] = 'image'
|
||||
|
||||
# Add optional parameters
|
||||
if language:
|
||||
# CSE uses 'lr' for language restrict
|
||||
params['lr'] = language
|
||||
if country:
|
||||
# CSE uses 'cr' for country restrict
|
||||
params['cr'] = country
|
||||
|
||||
try:
|
||||
response = self._client.get(CSE_API_URL, params=params)
|
||||
data = response.json()
|
||||
|
||||
# Check for API errors
|
||||
if 'error' in data:
|
||||
error_info = data['error']
|
||||
return CSEResponse(
|
||||
results=[],
|
||||
total_results='0',
|
||||
search_time=0.0,
|
||||
query=query,
|
||||
start_index=start,
|
||||
error=CSEError(
|
||||
code=error_info.get('code', 500),
|
||||
message=error_info.get('message', 'Unknown error')
|
||||
)
|
||||
)
|
||||
|
||||
# Parse successful response
|
||||
search_info = data.get('searchInformation', {})
|
||||
items = data.get('items', [])
|
||||
is_image = search_type == 'image'
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
# Extract image-specific data if present
|
||||
image_data = item.get('image', {})
|
||||
|
||||
results.append(CSEResult(
|
||||
title=item.get('title', ''),
|
||||
link=item.get('link', ''),
|
||||
snippet=item.get('snippet', ''),
|
||||
display_link=item.get('displayLink', ''),
|
||||
html_title=item.get('htmlTitle'),
|
||||
html_snippet=item.get('htmlSnippet'),
|
||||
# Image fields
|
||||
image_url=item.get('link') if is_image else None,
|
||||
thumbnail_url=image_data.get('thumbnailLink'),
|
||||
image_width=image_data.get('width'),
|
||||
image_height=image_data.get('height'),
|
||||
context_link=image_data.get('contextLink')
|
||||
))
|
||||
|
||||
return CSEResponse(
|
||||
results=results,
|
||||
total_results=search_info.get('totalResults', '0'),
|
||||
search_time=float(search_info.get('searchTime', 0)),
|
||||
query=query,
|
||||
start_index=start,
|
||||
is_image_search=is_image
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return CSEResponse(
|
||||
results=[],
|
||||
total_results='0',
|
||||
search_time=0.0,
|
||||
query=query,
|
||||
start_index=start,
|
||||
error=CSEError(code=408, message='Request timed out')
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return CSEResponse(
|
||||
results=[],
|
||||
total_results='0',
|
||||
search_time=0.0,
|
||||
query=query,
|
||||
start_index=start,
|
||||
error=CSEError(code=500, message=f'Request failed: {str(e)}')
|
||||
)
|
||||
except Exception as e:
|
||||
return CSEResponse(
|
||||
results=[],
|
||||
total_results='0',
|
||||
search_time=0.0,
|
||||
query=query,
|
||||
start_index=start,
|
||||
error=CSEError(code=500, message=f'Unexpected error: {str(e)}')
|
||||
)
|
||||
|
||||
def close(self):
|
||||
"""Close the HTTP client"""
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
def cse_results_to_html(response: CSEResponse, query: str) -> str:
|
||||
"""Convert CSE API response to HTML matching Whoogle's result format
|
||||
|
||||
This generates HTML that mimics the structure expected by Whoogle's
|
||||
existing filter and result processing pipeline.
|
||||
|
||||
Args:
|
||||
response: CSEResponse from the API
|
||||
query: Original search query
|
||||
|
||||
Returns:
|
||||
HTML string formatted like Google search results
|
||||
"""
|
||||
if response.has_error:
|
||||
error = response.error
|
||||
if error.is_quota_exceeded:
|
||||
return _error_html(
|
||||
'API Quota Exceeded',
|
||||
'Your Google Custom Search API quota has been exceeded. '
|
||||
'Free tier allows 100 queries/day. Wait until midnight PT '
|
||||
'or enable billing in Google Cloud Console.'
|
||||
)
|
||||
elif error.is_invalid_key:
|
||||
return _error_html(
|
||||
'Invalid API Key',
|
||||
'Your Google Custom Search API key is invalid. '
|
||||
'Please check your API key and CSE ID in settings.'
|
||||
)
|
||||
else:
|
||||
return _error_html('Search Error', error.message)
|
||||
|
||||
if not response.has_results:
|
||||
return _no_results_html(query)
|
||||
|
||||
# Use different HTML structure for image vs web results
|
||||
if response.is_image_search:
|
||||
return _image_results_html(response, query)
|
||||
|
||||
# Build HTML results matching Whoogle's expected structure
|
||||
results_html = []
|
||||
|
||||
for result in response.results:
|
||||
# Escape HTML in content
|
||||
title = _escape_html(result.title)
|
||||
snippet = _escape_html(result.snippet)
|
||||
link = result.link
|
||||
display_link = _escape_html(result.display_link)
|
||||
|
||||
# Use HTML versions if available (they have bold tags for query terms)
|
||||
if result.html_title:
|
||||
title = result.html_title
|
||||
if result.html_snippet:
|
||||
snippet = result.html_snippet
|
||||
|
||||
# Match the structure used by Google/mock results
|
||||
result_html = f'''
|
||||
<div class="ZINbbc xpd O9g5cc uUPGi">
|
||||
<div class="kCrYT">
|
||||
<a href="{link}">
|
||||
<h3 class="BNeawe vvjwJb AP7Wnd">{title}</h3>
|
||||
<div class="BNeawe UPmit AP7Wnd luh4tb" style="color: var(--whoogle-result-url);">{display_link}</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="kCrYT">
|
||||
<div class="BNeawe s3v9rd AP7Wnd">
|
||||
<span class="VwiC3b">{snippet}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
results_html.append(result_html)
|
||||
|
||||
# Build pagination if needed
|
||||
pagination_html = ''
|
||||
if int(response.total_results) > 10:
|
||||
pagination_html = _pagination_html(response.start_index, response.query)
|
||||
|
||||
# Wrap in expected structure
|
||||
# Add data-cse attribute to prevent collapse_sections from collapsing these results
|
||||
return f'''
|
||||
<html>
|
||||
<body>
|
||||
<div id="main" data-cse="true">
|
||||
<div id="cnt">
|
||||
<div id="rcnt">
|
||||
<div id="center_col">
|
||||
<div id="res">
|
||||
<div id="search">
|
||||
<div id="rso">
|
||||
{''.join(results_html)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pagination_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Escape HTML special characters"""
|
||||
if not text:
|
||||
return ''
|
||||
return (text
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace("'", '''))
|
||||
|
||||
|
||||
def _error_html(title: str, message: str) -> str:
|
||||
"""Generate error HTML"""
|
||||
return f'''
|
||||
<html>
|
||||
<body>
|
||||
<div id="main">
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<h2 style="color: #d93025;">{_escape_html(title)}</h2>
|
||||
<p>{_escape_html(message)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
def _no_results_html(query: str) -> str:
|
||||
"""Generate no results HTML"""
|
||||
return f'''
|
||||
<html>
|
||||
<body>
|
||||
<div id="main">
|
||||
<div style="padding: 20px;">
|
||||
<p>No results found for <b>{_escape_html(query)}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
def _image_results_html(response: CSEResponse, query: str) -> str:
|
||||
"""Generate HTML for image search results using the imageresults template
|
||||
|
||||
Args:
|
||||
response: CSEResponse with image results
|
||||
query: Original search query
|
||||
|
||||
Returns:
|
||||
HTML string formatted for image results display
|
||||
"""
|
||||
# Convert CSE results to the format expected by imageresults.html template
|
||||
results = []
|
||||
for result in response.results:
|
||||
image_url = result.image_url or result.link
|
||||
thumbnail_url = result.thumbnail_url or image_url
|
||||
web_page = result.context_link or result.link
|
||||
domain = urlparse(web_page).netloc if web_page else result.display_link
|
||||
|
||||
results.append({
|
||||
'domain': domain,
|
||||
'img_url': image_url,
|
||||
'web_page': web_page,
|
||||
'img_tbn': thumbnail_url
|
||||
})
|
||||
|
||||
# Build pagination link if needed
|
||||
next_link = None
|
||||
if int(response.total_results) > response.start_index + len(response.results) - 1:
|
||||
next_start = response.start_index + 10
|
||||
next_link = f'search?q={query}&tbm=isch&start={next_start}'
|
||||
|
||||
# Use the same template as regular image results
|
||||
return render_template(
|
||||
'imageresults.html',
|
||||
length=len(results),
|
||||
results=results,
|
||||
view_label="View Image",
|
||||
next_link=next_link
|
||||
)
|
||||
|
||||
|
||||
def _pagination_html(current_start: int, query: str) -> str:
|
||||
"""Generate pagination links"""
|
||||
# CSE API uses 1-based indexing, 10 results per page
|
||||
current_page = (current_start - 1) // 10 + 1
|
||||
|
||||
prev_link = ''
|
||||
next_link = ''
|
||||
|
||||
if current_page > 1:
|
||||
prev_start = (current_page - 2) * 10 + 1
|
||||
prev_link = f'<a href="search?q={query}&start={prev_start}">Previous</a>'
|
||||
|
||||
next_start = current_page * 10 + 1
|
||||
next_link = f'<a href="search?q={query}&start={next_start}">Next</a>'
|
||||
|
||||
return f'''
|
||||
<div id="foot" style="text-align: center; padding: 20px;">
|
||||
{prev_link}
|
||||
<span style="margin: 0 20px;">Page {current_page}</span>
|
||||
{next_link}
|
||||
</div>
|
||||
'''
|
||||
@ -1,219 +0,0 @@
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from cachetools import TTLCache
|
||||
import ssl
|
||||
import os
|
||||
|
||||
# Import h2 exceptions for better error handling
|
||||
try:
|
||||
from h2.exceptions import ProtocolError as H2ProtocolError
|
||||
except ImportError:
|
||||
H2ProtocolError = None
|
||||
|
||||
|
||||
class HttpxClient:
|
||||
"""Thin wrapper around httpx.Client providing simple retries and optional TTL caching.
|
||||
|
||||
The client is intended to be safe for reuse across requests. Per-request
|
||||
overrides for headers/cookies are supported.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
proxies: Optional[Dict[str, str]] = None,
|
||||
timeout_seconds: float = 15.0,
|
||||
cache_ttl_seconds: int = 30,
|
||||
cache_maxsize: int = 256,
|
||||
http2: bool = True) -> None:
|
||||
# Allow disabling HTTP/2 via environment variable
|
||||
# HTTP/2 can sometimes cause protocol errors with certain servers
|
||||
if os.environ.get('WHOOGLE_DISABLE_HTTP2', '').lower() in ('1', 'true', 't', 'yes', 'y'):
|
||||
http2 = False
|
||||
|
||||
client_kwargs = dict(http2=http2,
|
||||
timeout=timeout_seconds,
|
||||
follow_redirects=True)
|
||||
# Prefer future-proof mounts when proxies are provided; fall back to proxies=
|
||||
self._proxies = proxies or {}
|
||||
self._http2 = http2
|
||||
|
||||
# Determine verify behavior and initialize client with fallbacks
|
||||
self._verify = self._determine_verify_setting()
|
||||
try:
|
||||
self._client = self._build_client(client_kwargs, self._verify)
|
||||
except ssl.SSLError:
|
||||
# Fallback to system trust store
|
||||
try:
|
||||
system_ctx = ssl.create_default_context()
|
||||
self._client = self._build_client(client_kwargs, system_ctx)
|
||||
self._verify = system_ctx
|
||||
except ssl.SSLError:
|
||||
insecure_fallback = os.environ.get('WHOOGLE_INSECURE_FALLBACK', '0').lower() in ('1', 'true', 't', 'yes', 'y')
|
||||
if insecure_fallback:
|
||||
self._client = self._build_client(client_kwargs, False)
|
||||
self._verify = False
|
||||
else:
|
||||
raise
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._cache = TTLCache(maxsize=cache_maxsize, ttl=cache_ttl_seconds)
|
||||
self._cache_lock = threading.Lock()
|
||||
|
||||
def _determine_verify_setting(self):
|
||||
"""Determine SSL verification setting from environment.
|
||||
|
||||
Honors:
|
||||
- WHOOGLE_CA_BUNDLE: path to CA bundle file
|
||||
- WHOOGLE_SSL_VERIFY: '0' to disable verification
|
||||
- WHOOGLE_SSL_BACKEND: 'system' to prefer system trust store
|
||||
"""
|
||||
ca_bundle = os.environ.get('WHOOGLE_CA_BUNDLE', '').strip()
|
||||
if ca_bundle:
|
||||
return ca_bundle
|
||||
|
||||
verify_env = os.environ.get('WHOOGLE_SSL_VERIFY', '1').lower()
|
||||
if verify_env in ('0', 'false', 'no', 'n'):
|
||||
return False
|
||||
|
||||
backend = os.environ.get('WHOOGLE_SSL_BACKEND', '').lower()
|
||||
if backend == 'system':
|
||||
return ssl.create_default_context()
|
||||
|
||||
return True
|
||||
|
||||
def _build_client(self, client_kwargs: Dict[str, Any], verify: Any) -> httpx.Client:
|
||||
"""Construct httpx.Client with proxies and provided verify setting."""
|
||||
kwargs = dict(client_kwargs)
|
||||
kwargs['verify'] = verify
|
||||
if self._proxies:
|
||||
proxy_values = list(self._proxies.values())
|
||||
single_proxy = proxy_values[0] if proxy_values and all(v == proxy_values[0] for v in proxy_values) else None
|
||||
if single_proxy:
|
||||
try:
|
||||
return httpx.Client(proxy=single_proxy, **kwargs)
|
||||
except TypeError:
|
||||
try:
|
||||
return httpx.Client(proxies=self._proxies, **kwargs)
|
||||
except TypeError:
|
||||
mounts: Dict[str, httpx.Proxy] = {}
|
||||
for scheme_key, url in self._proxies.items():
|
||||
prefix = f"{scheme_key}://"
|
||||
mounts[prefix] = httpx.Proxy(url)
|
||||
return httpx.Client(mounts=mounts, **kwargs)
|
||||
else:
|
||||
try:
|
||||
return httpx.Client(proxies=self._proxies, **kwargs)
|
||||
except TypeError:
|
||||
mounts: Dict[str, httpx.Proxy] = {}
|
||||
for scheme_key, url in self._proxies.items():
|
||||
prefix = f"{scheme_key}://"
|
||||
mounts[prefix] = httpx.Proxy(url)
|
||||
return httpx.Client(mounts=mounts, **kwargs)
|
||||
else:
|
||||
return httpx.Client(**kwargs)
|
||||
|
||||
@property
|
||||
def proxies(self) -> Dict[str, str]:
|
||||
return self._proxies
|
||||
|
||||
def _cache_key(self, method: str, url: str, headers: Optional[Dict[str, str]]) -> Tuple[str, str, Tuple[Tuple[str, str], ...]]:
|
||||
normalized_headers = tuple(sorted((headers or {}).items()))
|
||||
return (method.upper(), url, normalized_headers)
|
||||
|
||||
def get(self,
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
cookies: Optional[Dict[str, str]] = None,
|
||||
retries: int = 2,
|
||||
backoff_seconds: float = 0.5,
|
||||
use_cache: bool = False) -> httpx.Response:
|
||||
if use_cache:
|
||||
key = self._cache_key('GET', url, headers)
|
||||
with self._cache_lock:
|
||||
cached = self._cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
attempt = 0
|
||||
while attempt <= retries:
|
||||
try:
|
||||
# Check if client is closed and recreate if needed
|
||||
if self._client.is_closed:
|
||||
self._recreate_client()
|
||||
|
||||
response = self._client.get(url, headers=headers, cookies=cookies)
|
||||
if use_cache and response.status_code == 200:
|
||||
with self._cache_lock:
|
||||
self._cache[key] = response
|
||||
return response
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
# Check for specific errors that require client recreation
|
||||
should_recreate = False
|
||||
|
||||
if isinstance(exc, (httpx.HTTPError, RuntimeError)):
|
||||
if "client has been closed" in str(exc).lower():
|
||||
should_recreate = True
|
||||
|
||||
# Handle H2 protocol errors (connection state issues)
|
||||
if H2ProtocolError and isinstance(exc, H2ProtocolError):
|
||||
should_recreate = True
|
||||
|
||||
# Also check if the error message contains h2 protocol error info
|
||||
if "ProtocolError" in str(exc) or "ConnectionState.CLOSED" in str(exc):
|
||||
should_recreate = True
|
||||
|
||||
if should_recreate:
|
||||
self._recreate_client()
|
||||
if attempt < retries:
|
||||
time.sleep(backoff_seconds * (2 ** attempt))
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
# For non-recoverable errors or last attempt, raise
|
||||
if attempt == retries:
|
||||
raise
|
||||
|
||||
# For other errors, still retry with backoff
|
||||
time.sleep(backoff_seconds * (2 ** attempt))
|
||||
attempt += 1
|
||||
|
||||
# Should not reach here
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
raise httpx.HTTPError('Unknown HTTP error')
|
||||
|
||||
def _recreate_client(self) -> None:
|
||||
"""Recreate the HTTP client when it has been closed."""
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass # Client might already be closed
|
||||
|
||||
# Recreate with same configuration
|
||||
client_kwargs = dict(timeout=self._timeout_seconds,
|
||||
follow_redirects=True,
|
||||
http2=self._http2)
|
||||
|
||||
try:
|
||||
self._client = self._build_client(client_kwargs, self._verify)
|
||||
except ssl.SSLError:
|
||||
try:
|
||||
system_ctx = ssl.create_default_context()
|
||||
self._client = self._build_client(client_kwargs, system_ctx)
|
||||
self._verify = system_ctx
|
||||
except ssl.SSLError:
|
||||
insecure_fallback = os.environ.get('WHOOGLE_INSECURE_FALLBACK', '0').lower() in ('1', 'true', 't', 'yes', 'y')
|
||||
if insecure_fallback:
|
||||
self._client = self._build_client(client_kwargs, False)
|
||||
self._verify = False
|
||||
else:
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import os
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from app.services.http_client import HttpxClient
|
||||
|
||||
|
||||
_clients: Dict[tuple, HttpxClient] = {}
|
||||
|
||||
|
||||
def _proxies_key(proxies: Dict[str, str]) -> Tuple[Tuple[str, str], Tuple[str, str]]:
|
||||
if not proxies:
|
||||
return tuple(), tuple()
|
||||
# Separate http/https for stable key
|
||||
items = sorted((proxies or {}).items())
|
||||
return tuple(items), tuple(items)
|
||||
|
||||
|
||||
def get_http_client(proxies: Dict[str, str]) -> HttpxClient:
|
||||
# Determine HTTP/2 enablement from env (default on)
|
||||
http2_env = os.environ.get('WHOOGLE_HTTP2', '1').lower()
|
||||
http2_enabled = http2_env in ('1', 'true', 't', 'yes', 'y')
|
||||
|
||||
key = (_proxies_key(proxies or {}), http2_enabled)
|
||||
client = _clients.get(key)
|
||||
if client is not None:
|
||||
return client
|
||||
client = HttpxClient(proxies=proxies or None, http2=http2_enabled)
|
||||
_clients[key] = client
|
||||
return client
|
||||
|
||||
|
||||
def close_all_clients() -> None:
|
||||
for client in list(_clients.values()):
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
_clients.clear()
|
||||
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
2
app/static/build/.gitignore
vendored
2
app/static/build/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@ -22,25 +22,20 @@ li {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.anon-view {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: var(--whoogle-dark-page-bg) !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
a:visited h3 div, a:visited .qXLe6d {
|
||||
a:visited h3 div {
|
||||
color: var(--whoogle-dark-result-visited) !important;
|
||||
}
|
||||
|
||||
a:link h3 div, a:link .qXLe6d {
|
||||
a:link h3 div {
|
||||
color: var(--whoogle-dark-result-title) !important;
|
||||
}
|
||||
|
||||
a:link div, a:link .fYyStc {
|
||||
a:link div {
|
||||
color: var(--whoogle-dark-result-url) !important;
|
||||
}
|
||||
|
||||
@ -62,34 +57,16 @@ select {
|
||||
background-color: var(--whoogle-dark-page-bg) !important;
|
||||
}
|
||||
|
||||
.ZINbbc, .ezO2md {
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 0 !important;
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
margin-bottom: 10px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.BsXmcf {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.KP7LCb {
|
||||
box-shadow: 0 0 0 0 !important;
|
||||
}
|
||||
|
||||
.BVG0Nb {
|
||||
box-shadow: 0 0 0 0 !important;
|
||||
background-color: var(--whoogle-dark-page-bg) !important;
|
||||
}
|
||||
|
||||
.ZINbbc.luh4tb {
|
||||
background: var(--whoogle-dark-result-bg) !important;
|
||||
margin-bottom: 24px !important;
|
||||
.ZINbbc {
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
}
|
||||
|
||||
.bRsWnc {
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
}
|
||||
|
||||
.BVG0Nb {
|
||||
background-color: var(--whoogle-dark-page-bg) !important;
|
||||
}
|
||||
|
||||
.x54gtf {
|
||||
@ -104,19 +81,9 @@ select {
|
||||
background-color: var(--whoogle-dark-divider) !important;
|
||||
}
|
||||
|
||||
.home-search {
|
||||
border-color: var(--whoogle-dark-element-bg) !important;
|
||||
}
|
||||
|
||||
.sa1toc {
|
||||
background: var(--whoogle-dark-page-bg) !important;
|
||||
}
|
||||
|
||||
#search-bar {
|
||||
border-color: var(--whoogle-dark-element-bg) !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
border-bottom: 2px solid var(--whoogle-dark-element-bg);
|
||||
}
|
||||
|
||||
#search-bar:focus {
|
||||
@ -135,11 +102,11 @@ select {
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
color: var(--whoogle-dark-text);
|
||||
}
|
||||
|
||||
.collapsible:after {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
color: var(--whoogle-dark-text);
|
||||
}
|
||||
|
||||
.active {
|
||||
@ -147,7 +114,7 @@ select {
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.content, .result-config {
|
||||
.content {
|
||||
background-color: var(--whoogle-dark-element-bg) !important;
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
@ -156,14 +123,10 @@ select {
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.link {
|
||||
#gh-link {
|
||||
color: var(--whoogle-dark-contrast-text);
|
||||
}
|
||||
|
||||
.link-color {
|
||||
color: var(--whoogle-dark-result-url) !important;
|
||||
}
|
||||
|
||||
.autocomplete-items {
|
||||
border: 1px solid var(--whoogle-dark-element-bg);
|
||||
}
|
||||
@ -183,40 +146,3 @@ select {
|
||||
background-color: var(--whoogle-dark-element-bg) !important;
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: var(--whoogle-dark-text);
|
||||
}
|
||||
|
||||
path {
|
||||
fill: var(--whoogle-dark-logo);
|
||||
}
|
||||
|
||||
.header-div {
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
}
|
||||
|
||||
#search-reset {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.mobile-search-bar {
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.search-bar-desktop {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.ip-text-div, .update_available, .cb_label, .cb {
|
||||
color: var(--whoogle-dark-secondary-text) !important;
|
||||
}
|
||||
|
||||
.cb:focus {
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.desktop-header, .mobile-header {
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
.result-config {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mobile-logo {
|
||||
font: 22px/36px Futura, Arial, sans-serif;
|
||||
padding-left: 5px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-div {
|
||||
@ -80,171 +71,3 @@ header {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 6px 1px #2375e8;
|
||||
}
|
||||
|
||||
#mobile-header-logo {
|
||||
height: 1.75em;
|
||||
}
|
||||
|
||||
.mobile-input-div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-search-bar {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
padding: 0 0 0 8px;
|
||||
padding-right: 0px;
|
||||
-webkit-box-flex: 1;
|
||||
height: 35px;
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0,0,0,.00);
|
||||
overflow: hidden;
|
||||
border: 0px !important;
|
||||
}
|
||||
|
||||
.autocomplete-mobile{
|
||||
display: -webkit-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desktop-header-logo {
|
||||
height: 1.65em;
|
||||
}
|
||||
|
||||
.header-autocomplete {
|
||||
width: 100%;
|
||||
flex: 1
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1967D2;
|
||||
text-decoration: none;
|
||||
tap-highlight-color: rgba(0, 0, 0, .10);
|
||||
}
|
||||
|
||||
.header-tab-div {
|
||||
border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 2px 3px rgba(32, 33, 36, 0.18);
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-tab-div-2 {
|
||||
border-top: 1px solid #dadce0;
|
||||
height: 39px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-tab-div-3 {
|
||||
height: 51px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.desktop-header {
|
||||
height: 39px;
|
||||
display: box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-tab {
|
||||
box-pack: justify;
|
||||
font-size: 14px;
|
||||
line-height: 37px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.desktop-header a, .desktop-header span {
|
||||
color: #70757a;
|
||||
display: block;
|
||||
flex: none;
|
||||
padding: 0 16px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
span.header-tab-span {
|
||||
border-bottom: 2px solid #4285f4;
|
||||
color: #4285f4;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
height: 39px;
|
||||
display: box;
|
||||
display: flex;
|
||||
overflow-x: scroll;
|
||||
width: 100%;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.mobile-header a, .mobile-header span {
|
||||
color: #70757a;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
/* padding: 8px 12px 8px 12px; */
|
||||
}
|
||||
|
||||
span.mobile-tab-span {
|
||||
border-bottom: 2px solid #202124;
|
||||
color: #202124;
|
||||
height: 26px;
|
||||
/* margin: 0 12px; */
|
||||
/* padding: 0; */
|
||||
}
|
||||
|
||||
.desktop-header input {
|
||||
margin: 2px 4px 2px 8px;
|
||||
}
|
||||
|
||||
a.header-tab-a:visited {
|
||||
color: #70757a;
|
||||
}
|
||||
|
||||
.header-tab-div-end {
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.adv-search {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.adv-search:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#adv-search-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-collapsible {
|
||||
max-height: 0px;
|
||||
overflow: hidden;
|
||||
transition: max-height .25s linear;
|
||||
}
|
||||
|
||||
.search-bar-input {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
padding: 0 0 0 8px;
|
||||
flex: 1;
|
||||
height: 35px;
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#result-country {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 801px) {
|
||||
.header-tab-div {
|
||||
margin-bottom: 10px !important
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,30 +12,3 @@
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
}
|
||||
.ZINbbc.xpd.O9g5cc.uUPGi input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cb {
|
||||
width: 40%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
line-height: 28px;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #5f6368;
|
||||
font-size: 14px !important;
|
||||
height: 36px;
|
||||
padding: 0 0 0 12px;
|
||||
margin: 10px 10px 10px 0;
|
||||
}
|
||||
|
||||
.conversion_box {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.ZINbbc.xpd.O9g5cc.uUPGi input:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
@ -22,11 +22,6 @@ li {
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.anon-view {
|
||||
color: var(--whoogle-text) !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: var(--whoogle-page-bg) !important;
|
||||
color: var(--whoogle-text) !important;
|
||||
@ -38,28 +33,11 @@ select {
|
||||
}
|
||||
|
||||
.ZINbbc {
|
||||
overflow: hidden;
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
margin-bottom: 10px !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 1px 6px rgba(32,33,36,0.28) !important;
|
||||
}
|
||||
|
||||
.BsXmcf {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.BVG0Nb {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
|
||||
.ZINbbc.luh4tb {
|
||||
background: var(--whoogle-result-bg) !important;
|
||||
margin-bottom: 24px !important;
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
|
||||
.bRsWnc {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
|
||||
.x54gtf {
|
||||
@ -75,15 +53,15 @@ select {
|
||||
}
|
||||
|
||||
|
||||
a:visited div, a:visited .qXLe6d {
|
||||
a:visited h3 div {
|
||||
color: var(--whoogle-result-visited) !important;
|
||||
}
|
||||
|
||||
a:link div, a:link .qXLe6d {
|
||||
a:link h3 div {
|
||||
color: var(--whoogle-result-title) !important;
|
||||
}
|
||||
|
||||
a:link div, a:link .fYyStc {
|
||||
a:link div {
|
||||
color: var(--whoogle-result-url) !important;
|
||||
}
|
||||
|
||||
@ -102,7 +80,7 @@ input {
|
||||
}
|
||||
|
||||
.home-search {
|
||||
border-color: var(--whoogle-element-bg) !important;
|
||||
border: 3px solid var(--whoogle-element-bg) !important;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@ -133,7 +111,7 @@ input {
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
.content, .result-config {
|
||||
.content {
|
||||
background-color: var(--whoogle-element-bg) !important;
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
@ -142,14 +120,10 @@ input {
|
||||
color: var(--whoogle-contrast-text);
|
||||
}
|
||||
|
||||
.link {
|
||||
#gh-link {
|
||||
color: var(--whoogle-element-bg);
|
||||
}
|
||||
|
||||
.link-color {
|
||||
color: var(--whoogle-result-url) !important;
|
||||
}
|
||||
|
||||
.autocomplete-items {
|
||||
border: 1px solid var(--whoogle-element-bg);
|
||||
}
|
||||
@ -168,42 +142,3 @@ input {
|
||||
background-color: var(--whoogle-element-bg) !important;
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: var(--whoogle-text);
|
||||
}
|
||||
|
||||
path {
|
||||
fill: var(--whoogle-logo);
|
||||
}
|
||||
|
||||
.header-div {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
|
||||
#search-reset {
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.mobile-search-bar {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.search-bar-desktop {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
color: var(--whoogle-text);
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.ip-text-div, .update_available, .cb_label, .cb {
|
||||
color: var(--whoogle-secondary-text) !important;
|
||||
}
|
||||
|
||||
.cb:focus {
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.desktop-header, .mobile-header {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ a {
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
svg {
|
||||
margin-top: .3em;
|
||||
height: 70%;
|
||||
margin-top: .7em;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,11 +13,6 @@ body {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.home-search {
|
||||
background: transparent !important;
|
||||
border: 3px solid;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
background: transparent !important;
|
||||
width: 80%;
|
||||
@ -61,15 +56,6 @@ body {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.config-options {
|
||||
max-height: 370px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.config-buttons {
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
.config-div {
|
||||
padding: 5px;
|
||||
}
|
||||
@ -144,7 +130,6 @@ footer {
|
||||
|
||||
.whoogle-svg {
|
||||
width: 80%;
|
||||
height: initial;
|
||||
display: block;
|
||||
margin: auto;
|
||||
padding-bottom: 10px;
|
||||
@ -177,14 +162,3 @@ details summary {
|
||||
padding: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 1000px) {
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#search-bar {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,3 @@
|
||||
body {
|
||||
display: block !important;
|
||||
margin: auto !important;
|
||||
}
|
||||
|
||||
.vvjwJb {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.ezO2md {
|
||||
border-radius: 10px;
|
||||
border: 0 !important;
|
||||
box-shadow: 0 3px 5px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@ -37,61 +22,6 @@ body {
|
||||
}
|
||||
|
||||
details summary {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
font-weight: bold;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
details summary span {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#lingva-iframe {
|
||||
width: 100%;
|
||||
height: 650px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.ip-address-div {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ip-text-div {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-favicon {
|
||||
float: left;
|
||||
width: 25px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.has-favicon .sCuL3 {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
#flex_text_audio_icon_chunk {
|
||||
display: none;
|
||||
}
|
||||
|
||||
audio {
|
||||
display: block;
|
||||
margin-right: auto;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
@media (min-width: 801px) {
|
||||
body {
|
||||
min-width: 736px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 801px) {
|
||||
details summary {
|
||||
margin-bottom: 10px !important
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
/* LIGHT THEME COLORS */
|
||||
--whoogle-logo: #685e79;
|
||||
--whoogle-page-bg: #ffffff;
|
||||
--whoogle-element-bg: #4285f4;
|
||||
--whoogle-element-bg: #685e79;
|
||||
--whoogle-text: #000000;
|
||||
--whoogle-contrast-text: #ffffff;
|
||||
--whoogle-secondary-text: #70757a;
|
||||
@ -11,44 +11,18 @@
|
||||
--whoogle-result-title: #1967d2;
|
||||
--whoogle-result-url: #0d652d;
|
||||
--whoogle-result-visited: #4b11a8;
|
||||
--whoogle-divider: #dfe1e5;
|
||||
|
||||
/* DARK THEME COLORS */
|
||||
--whoogle-dark-logo: #685e79;
|
||||
--whoogle-dark-page-bg: #101020;
|
||||
--whoogle-dark-element-bg: #4285f4;
|
||||
--whoogle-dark-text: #ffffff;
|
||||
--whoogle-dark-contrast-text: #ffffff;
|
||||
--whoogle-dark-secondary-text: #bbbbbb;
|
||||
--whoogle-dark-result-bg: #212131;
|
||||
--whoogle-dark-result-title: #64a7f6;
|
||||
--whoogle-dark-result-url: #34a853;
|
||||
--whoogle-dark-result-visited: #bbbbff;
|
||||
}
|
||||
|
||||
#whoogle-w {
|
||||
fill: #4285f4;
|
||||
}
|
||||
|
||||
#whoogle-h {
|
||||
fill: #ea4335;
|
||||
}
|
||||
|
||||
#whoogle-o-1 {
|
||||
fill: #fbbc05;
|
||||
}
|
||||
|
||||
#whoogle-o-2 {
|
||||
fill: #4285f4;
|
||||
}
|
||||
|
||||
#whoogle-g {
|
||||
fill: #34a853;
|
||||
}
|
||||
|
||||
#whoogle-l {
|
||||
fill: #ea4335;
|
||||
}
|
||||
|
||||
#whoogle-e {
|
||||
fill: #fbbc05;
|
||||
--whoogle-dark-logo: #888888;
|
||||
--whoogle-dark-page-bg: #080808;
|
||||
--whoogle-dark-element-bg: #111111;
|
||||
--whoogle-dark-text: #dddddd;
|
||||
--whoogle-dark-contrast-text: #aaaaaa;
|
||||
--whoogle-dark-secondary-text: #8a8b8c;
|
||||
--whoogle-dark-result-bg: #111111;
|
||||
--whoogle-dark-result-title: #dddddd;
|
||||
--whoogle-dark-result-url: #eceff4;
|
||||
--whoogle-dark-result-visited: #959595;
|
||||
--whoogle-dark-divider: #111111;
|
||||
}
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
let searchInput;
|
||||
let currentFocus;
|
||||
let originalSearch;
|
||||
let autocompleteResults;
|
||||
|
||||
const handleUserInput = () => {
|
||||
const handleUserInput = searchBar => {
|
||||
let xhrRequest = new XMLHttpRequest();
|
||||
xhrRequest.open("POST", "autocomplete");
|
||||
xhrRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
||||
@ -14,114 +9,118 @@ const handleUserInput = () => {
|
||||
}
|
||||
|
||||
// Fill autocomplete with fetched results
|
||||
autocompleteResults = JSON.parse(xhrRequest.responseText)[1];
|
||||
updateAutocompleteList();
|
||||
let autocompleteResults = JSON.parse(xhrRequest.responseText);
|
||||
autocomplete(searchBar, autocompleteResults[1]);
|
||||
};
|
||||
|
||||
xhrRequest.send('q=' + searchInput.value);
|
||||
xhrRequest.send('q=' + searchBar.value);
|
||||
};
|
||||
|
||||
const removeActive = suggestion => {
|
||||
// Remove "autocomplete-active" class from previously active suggestion
|
||||
for (let i = 0; i < suggestion.length; i++) {
|
||||
suggestion[i].classList.remove("autocomplete-active");
|
||||
}
|
||||
};
|
||||
const autocomplete = (searchInput, autocompleteResults) => {
|
||||
let currentFocus;
|
||||
let originalSearch;
|
||||
|
||||
const addActive = (suggestion) => {
|
||||
// Handle navigation outside of suggestion list
|
||||
if (!suggestion || !suggestion[currentFocus]) {
|
||||
if (currentFocus >= suggestion.length) {
|
||||
// Move selection back to the beginning
|
||||
currentFocus = 0;
|
||||
} else if (currentFocus < 0) {
|
||||
// Retrieve original search and remove active suggestion selection
|
||||
currentFocus = -1;
|
||||
searchInput.value = originalSearch;
|
||||
removeActive(suggestion);
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
searchInput.addEventListener("input", function () {
|
||||
let autocompleteList, autocompleteItem, i, val = this.value;
|
||||
closeAllLists();
|
||||
|
||||
if (!val || !autocompleteResults) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
removeActive(suggestion);
|
||||
suggestion[currentFocus].classList.add("autocomplete-active");
|
||||
currentFocus = -1;
|
||||
autocompleteList = document.createElement("div");
|
||||
autocompleteList.setAttribute("id", this.id + "-autocomplete-list");
|
||||
autocompleteList.setAttribute("class", "autocomplete-items");
|
||||
this.parentNode.appendChild(autocompleteList);
|
||||
|
||||
// Autofill search bar with suggestion content (minus the "bang name" if using a bang operator)
|
||||
let searchContent = suggestion[currentFocus].textContent;
|
||||
if (searchContent.indexOf('(') > 0) {
|
||||
searchInput.value = searchContent.substring(0, searchContent.indexOf('('));
|
||||
} else {
|
||||
searchInput.value = searchContent;
|
||||
}
|
||||
|
||||
searchInput.focus();
|
||||
};
|
||||
|
||||
const autocompleteInput = (e) => {
|
||||
// Handle navigation between autocomplete suggestions
|
||||
let suggestion = document.getElementById("autocomplete-list");
|
||||
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
|
||||
if (e.keyCode === 40) { // down
|
||||
e.preventDefault();
|
||||
currentFocus++;
|
||||
addActive(suggestion);
|
||||
} else if (e.keyCode === 38) { //up
|
||||
e.preventDefault();
|
||||
currentFocus--;
|
||||
addActive(suggestion);
|
||||
} else if (e.keyCode === 13) { // enter
|
||||
e.preventDefault();
|
||||
if (currentFocus > -1) {
|
||||
if (suggestion) suggestion[currentFocus].click();
|
||||
for (i = 0; i < autocompleteResults.length; i++) {
|
||||
if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {
|
||||
autocompleteItem = document.createElement("div");
|
||||
autocompleteItem.innerHTML = "<strong>" + autocompleteResults[i].substr(0, val.length) + "</strong>";
|
||||
autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);
|
||||
autocompleteItem.innerHTML += "<input type=\"hidden\" value=\"" + autocompleteResults[i] + "\">";
|
||||
autocompleteItem.addEventListener("click", function () {
|
||||
searchInput.value = this.getElementsByTagName("input")[0].value;
|
||||
closeAllLists();
|
||||
document.getElementById("search-form").submit();
|
||||
});
|
||||
autocompleteList.appendChild(autocompleteItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
originalSearch = searchInput.value;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAutocompleteList = () => {
|
||||
let autocompleteItem, i;
|
||||
let val = originalSearch;
|
||||
|
||||
let autocompleteList = document.getElementById("autocomplete-list");
|
||||
autocompleteList.innerHTML = "";
|
||||
|
||||
if (!val || !autocompleteResults) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentFocus = -1;
|
||||
|
||||
for (i = 0; i < autocompleteResults.length; i++) {
|
||||
if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {
|
||||
autocompleteItem = document.createElement("div");
|
||||
autocompleteItem.setAttribute("class", "autocomplete-item");
|
||||
autocompleteItem.innerHTML = "<strong>" + autocompleteResults[i].substr(0, val.length) + "</strong>";
|
||||
autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);
|
||||
autocompleteItem.innerHTML += "<input type=\"hidden\" value=\"" + autocompleteResults[i] + "\">";
|
||||
autocompleteItem.addEventListener("click", function () {
|
||||
searchInput.value = this.getElementsByTagName("input")[0].value;
|
||||
autocompleteList.innerHTML = "";
|
||||
document.getElementById("search-form").submit();
|
||||
});
|
||||
autocompleteList.appendChild(autocompleteItem);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
let autocompleteList = document.createElement("div");
|
||||
autocompleteList.setAttribute("id", "autocomplete-list");
|
||||
autocompleteList.setAttribute("class", "autocomplete-items");
|
||||
|
||||
searchInput = document.getElementById("search-bar");
|
||||
searchInput.parentNode.appendChild(autocompleteList);
|
||||
|
||||
searchInput.addEventListener("keydown", (event) => autocompleteInput(event));
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
autocompleteList.innerHTML = "";
|
||||
});
|
||||
});
|
||||
|
||||
searchInput.addEventListener("keydown", function (e) {
|
||||
let suggestion = document.getElementById(this.id + "-autocomplete-list");
|
||||
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
|
||||
if (e.keyCode === 40) { // down
|
||||
e.preventDefault();
|
||||
currentFocus++;
|
||||
addActive(suggestion);
|
||||
} else if (e.keyCode === 38) { //up
|
||||
e.preventDefault();
|
||||
currentFocus--;
|
||||
addActive(suggestion);
|
||||
} else if (e.keyCode === 13) { // enter
|
||||
e.preventDefault();
|
||||
if (currentFocus > -1) {
|
||||
if (suggestion) suggestion[currentFocus].click();
|
||||
}
|
||||
} else {
|
||||
originalSearch = document.getElementById("search-bar").value;
|
||||
}
|
||||
});
|
||||
|
||||
const addActive = suggestion => {
|
||||
let searchBar = document.getElementById("search-bar");
|
||||
|
||||
// Handle navigation outside of suggestion list
|
||||
if (!suggestion || !suggestion[currentFocus]) {
|
||||
if (currentFocus >= suggestion.length) {
|
||||
// Move selection back to the beginning
|
||||
currentFocus = 0;
|
||||
} else if (currentFocus < 0) {
|
||||
// Retrieve original search and remove active suggestion selection
|
||||
currentFocus = -1;
|
||||
searchBar.value = originalSearch;
|
||||
removeActive(suggestion);
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
removeActive(suggestion);
|
||||
suggestion[currentFocus].classList.add("autocomplete-active");
|
||||
|
||||
// Autofill search bar with suggestion content (minus the "bang name" if using a bang operator)
|
||||
let searchContent = suggestion[currentFocus].textContent;
|
||||
if (searchContent.indexOf('(') > 0) {
|
||||
searchBar.value = searchContent.substring(0, searchContent.indexOf('('));
|
||||
} else {
|
||||
searchBar.value = searchContent;
|
||||
}
|
||||
|
||||
searchBar.focus();
|
||||
};
|
||||
|
||||
const removeActive = suggestion => {
|
||||
for (let i = 0; i < suggestion.length; i++) {
|
||||
suggestion[i].classList.remove("autocomplete-active");
|
||||
}
|
||||
};
|
||||
|
||||
const closeAllLists = el => {
|
||||
let suggestions = document.getElementsByClassName("autocomplete-items");
|
||||
for (let i = 0; i < suggestions.length; i++) {
|
||||
if (el !== suggestions[i] && el !== searchInput) {
|
||||
suggestions[i].parentNode.removeChild(suggestions[i]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Close lists and search when user selects a suggestion
|
||||
document.addEventListener("click", function (e) {
|
||||
closeAllLists(e.target);
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,8 +2,6 @@ const setupSearchLayout = () => {
|
||||
// Setup search field
|
||||
const searchBar = document.getElementById("search-bar");
|
||||
const searchBtn = document.getElementById("search-submit");
|
||||
const arrowKeys = [37, 38, 39, 40];
|
||||
let searchValue = searchBar.value;
|
||||
|
||||
// Automatically focus on search field
|
||||
searchBar.focus();
|
||||
@ -13,9 +11,8 @@ const setupSearchLayout = () => {
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault();
|
||||
searchBtn.click();
|
||||
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
|
||||
searchValue = searchBar.value;
|
||||
handleUserInput();
|
||||
} else {
|
||||
handleUserInput(searchBar);
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -29,25 +26,11 @@ const setupConfigLayout = () => {
|
||||
if (content.style.maxHeight) {
|
||||
content.style.maxHeight = null;
|
||||
} else {
|
||||
content.style.maxHeight = "400px";
|
||||
content.style.maxHeight = content.scrollHeight + "px";
|
||||
}
|
||||
|
||||
content.classList.toggle("open");
|
||||
});
|
||||
|
||||
// Setup user agent dropdown handler
|
||||
const userAgentSelect = document.getElementById("config-user-agent");
|
||||
const customUserAgentDiv = document.querySelector(".config-div-custom-user-agent");
|
||||
|
||||
if (userAgentSelect && customUserAgentDiv) {
|
||||
userAgentSelect.addEventListener("change", function() {
|
||||
if (this.value === "custom") {
|
||||
customUserAgentDiv.style.display = "block";
|
||||
} else {
|
||||
customUserAgentDiv.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = event => {
|
||||
|
||||
@ -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", () => {
|
||||
const advSearchToggle = document.getElementById("adv-search-toggle");
|
||||
const advSearchDiv = document.getElementById("adv-search-div");
|
||||
const searchBar = document.getElementById("search-bar");
|
||||
const countrySelect = document.getElementById("result-country");
|
||||
const timePeriodSelect = document.getElementById("result-time-period");
|
||||
const arrowKeys = [37, 38, 39, 40];
|
||||
let searchValue = searchBar.value;
|
||||
|
||||
countrySelect.onchange = () => {
|
||||
let str = window.location.href;
|
||||
n = str.lastIndexOf("/search");
|
||||
if (n > 0) {
|
||||
str = str.substring(0, n) + `/search?q=${searchBar.value}`;
|
||||
str = tackOnParams(str);
|
||||
window.location.href = str;
|
||||
}
|
||||
}
|
||||
|
||||
timePeriodSelect.onchange = () => {
|
||||
let str = window.location.href;
|
||||
n = str.lastIndexOf("/search");
|
||||
if (n > 0) {
|
||||
str = str.substring(0, n) + `/search?q=${searchBar.value}`;
|
||||
str = tackOnParams(str);
|
||||
window.location.href = str;
|
||||
}
|
||||
}
|
||||
|
||||
function tackOnParams(str) {
|
||||
if (timePeriodSelect.value != "") {
|
||||
str = str + `&tbs=${timePeriodSelect.value}`;
|
||||
}
|
||||
if (countrySelect.value != "") {
|
||||
str = str + `&country=${countrySelect.value}`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const toggleAdvancedSearch = on => {
|
||||
if (on) {
|
||||
advSearchDiv.style.maxHeight = "70px";
|
||||
searchBar.addEventListener("keyup", function (event) {
|
||||
if (event.keyCode !== 13) {
|
||||
handleUserInput(searchBar);
|
||||
} else {
|
||||
advSearchDiv.style.maxHeight = "0px";
|
||||
}
|
||||
localStorage.advSearchToggled = on;
|
||||
}
|
||||
|
||||
try {
|
||||
toggleAdvancedSearch(JSON.parse(localStorage.advSearchToggled));
|
||||
} catch (error) {
|
||||
console.warn("Did not recover advanced search toggle state");
|
||||
}
|
||||
|
||||
advSearchToggle.onclick = () => {
|
||||
toggleAdvancedSearch(advSearchToggle.checked);
|
||||
}
|
||||
|
||||
searchBar.addEventListener("keyup", function(event) {
|
||||
if (event.keyCode === 13) {
|
||||
document.getElementById("search-form").submit();
|
||||
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
|
||||
searchValue = searchBar.value;
|
||||
handleUserInput();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,62 +1,44 @@
|
||||
(function () {
|
||||
let searchBar, results;
|
||||
let shift = false;
|
||||
const keymap = {
|
||||
ArrowUp: goUp,
|
||||
ArrowDown: goDown,
|
||||
ShiftTab: goUp,
|
||||
Tab: goDown,
|
||||
k: goUp,
|
||||
j: goDown,
|
||||
'/': focusSearch,
|
||||
};
|
||||
let activeIdx = -1;
|
||||
let searchBar, results;
|
||||
const keymap = {
|
||||
ArrowUp: goUp,
|
||||
ArrowDown: goDown,
|
||||
k: goUp,
|
||||
j: goDown,
|
||||
'/': focusSearch,
|
||||
};
|
||||
let activeIdx = -1;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
searchBar = document.querySelector('#search-bar');
|
||||
results = document.querySelectorAll('#main>div>div>div>a');
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
searchBar = document.querySelector('#search-bar');
|
||||
results = document.querySelectorAll('#main>div>div>div>a');
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Shift') {
|
||||
shift = true;
|
||||
}
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT') return true;
|
||||
if (typeof keymap[e.key] === 'function') {
|
||||
e.preventDefault();
|
||||
keymap[e.key]();
|
||||
}
|
||||
});
|
||||
|
||||
if (e.target.tagName === 'INPUT') return true;
|
||||
if (typeof keymap[e.key] === 'function') {
|
||||
e.preventDefault();
|
||||
function goUp () {
|
||||
if (activeIdx > 0) focusResult(activeIdx - 1);
|
||||
else focusSearch();
|
||||
}
|
||||
|
||||
keymap[`${shift && e.key == 'Tab' ? 'Shift' : ''}${e.key}`]();
|
||||
}
|
||||
});
|
||||
function goDown () {
|
||||
if (activeIdx < results.length - 1) focusResult(activeIdx + 1);
|
||||
}
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Shift') {
|
||||
shift = false;
|
||||
}
|
||||
});
|
||||
function focusResult (idx) {
|
||||
activeIdx = idx;
|
||||
results[activeIdx].scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
results[activeIdx].focus();
|
||||
}
|
||||
|
||||
function goUp () {
|
||||
if (activeIdx > 0) focusResult(activeIdx - 1);
|
||||
else focusSearch();
|
||||
}
|
||||
|
||||
function goDown () {
|
||||
if (activeIdx < results.length - 1) focusResult(activeIdx + 1);
|
||||
}
|
||||
|
||||
function focusResult (idx) {
|
||||
activeIdx = idx;
|
||||
results[activeIdx].scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
results[activeIdx].focus();
|
||||
}
|
||||
|
||||
function focusSearch () {
|
||||
if (window.usingCalculator) {
|
||||
// if this function exists, it means the calculator widget has been displayed
|
||||
if (usingCalculator()) return;
|
||||
}
|
||||
activeIdx = -1;
|
||||
searchBar.focus();
|
||||
}
|
||||
function focusSearch () {
|
||||
activeIdx = -1;
|
||||
searchBar.focus();
|
||||
}
|
||||
}());
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
const checkForTracking = () => {
|
||||
const mainDiv = document.getElementById("main");
|
||||
const searchBar = document.getElementById("search-bar");
|
||||
// some pages (e.g. images) do not have these
|
||||
if (!mainDiv || !searchBar)
|
||||
return;
|
||||
const query = searchBar.value.replace(/\s+/g, '');
|
||||
const query = document.getElementById("search-bar").value.replace(/\s+/g, '');
|
||||
|
||||
// Note: regex functions for checking for tracking queries were derived
|
||||
// from here -- https://stackoverflow.com/questions/619977
|
||||
@ -16,7 +12,7 @@ const checkForTracking = () => {
|
||||
]
|
||||
},
|
||||
"usps": {
|
||||
"link": `https://tools.usps.com/go/TrackConfirmAction_input?origTrackNum=${query}`,
|
||||
"link": `https://tools.usps.com/go/TrackConfirmAction?tLabels=${query}`,
|
||||
"expr": [
|
||||
/(\b\d{30}\b)|(\b91\d+\b)|(\b\d{20}\b)/,
|
||||
/^E\D{1}\d{9}\D{2}$|^9\d{15,21}$/,
|
||||
@ -63,14 +59,11 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
checkForTracking();
|
||||
|
||||
// Clear input if reset button tapped
|
||||
const searchBar = document.getElementById("search-bar");
|
||||
const search = document.getElementById("search-bar");
|
||||
const resetBtn = document.getElementById("search-reset");
|
||||
// some pages (e.g. images) do not have these
|
||||
if (!searchBar || !resetBtn)
|
||||
return;
|
||||
resetBtn.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
searchBar.value = "";
|
||||
searchBar.focus();
|
||||
search.value = "";
|
||||
search.focus();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,245 +1,248 @@
|
||||
[
|
||||
{"name": "-------", "value": ""},
|
||||
{"name": "Afghanistan", "value": "AF"},
|
||||
{"name": "Albania", "value": "AL"},
|
||||
{"name": "Algeria", "value": "DZ"},
|
||||
{"name": "American Samoa", "value": "AS"},
|
||||
{"name": "Andorra", "value": "AD"},
|
||||
{"name": "Angola", "value": "AO"},
|
||||
{"name": "Anguilla", "value": "AI"},
|
||||
{"name": "Antarctica", "value": "AQ"},
|
||||
{"name": "Antigua and Barbuda", "value": "AG"},
|
||||
{"name": "Argentina", "value": "AR"},
|
||||
{"name": "Armenia", "value": "AM"},
|
||||
{"name": "Aruba", "value": "AW"},
|
||||
{"name": "Australia", "value": "AU"},
|
||||
{"name": "Austria", "value": "AT"},
|
||||
{"name": "Azerbaijan", "value": "AZ"},
|
||||
{"name": "Bahamas", "value": "BS"},
|
||||
{"name": "Bahrain", "value": "BH"},
|
||||
{"name": "Bangladesh", "value": "BD"},
|
||||
{"name": "Barbados", "value": "BB"},
|
||||
{"name": "Belarus", "value": "BY"},
|
||||
{"name": "Belgium", "value": "BE"},
|
||||
{"name": "Belize", "value": "BZ"},
|
||||
{"name": "Benin", "value": "BJ"},
|
||||
{"name": "Bermuda", "value": "BM"},
|
||||
{"name": "Bhutan", "value": "BT"},
|
||||
{"name": "Bolivia", "value": "BO"},
|
||||
{"name": "Bosnia and Herzegovina", "value": "BA"},
|
||||
{"name": "Botswana", "value": "BW"},
|
||||
{"name": "Bouvet Island", "value": "BV"},
|
||||
{"name": "Brazil", "value": "BR"},
|
||||
{"name": "British Indian Ocean Territory", "value": "IO"},
|
||||
{"name": "Brunei Darussalam", "value": "BN"},
|
||||
{"name": "Bulgaria", "value": "BG"},
|
||||
{"name": "Burkina Faso", "value": "BF"},
|
||||
{"name": "Burundi", "value": "BI"},
|
||||
{"name": "Cambodia", "value": "KH"},
|
||||
{"name": "Cameroon", "value": "CM"},
|
||||
{"name": "Canada", "value": "CA"},
|
||||
{"name": "Cape Verde", "value": "CV"},
|
||||
{"name": "Cayman Islands", "value": "KY"},
|
||||
{"name": "Central African Republic", "value": "CF"},
|
||||
{"name": "Chad", "value": "TD"},
|
||||
{"name": "Chile", "value": "CL"},
|
||||
{"name": "China", "value": "CN"},
|
||||
{"name": "Christmas Island", "value": "CX"},
|
||||
{"name": "Cocos (Keeling) Islands", "value": "CC"},
|
||||
{"name": "Colombia", "value": "CO"},
|
||||
{"name": "Comoros", "value": "KM"},
|
||||
{"name": "Congo", "value": "CG"},
|
||||
{"name": "Congo, Democratic Republic of the", "value": "CD"},
|
||||
{"name": "Cook Islands", "value": "CK"},
|
||||
{"name": "Costa Rica", "value": "CR"},
|
||||
{"name": "Cote D'ivoire", "value": "CI"},
|
||||
{"name": "Croatia (Hrvatska)", "value": "HR"},
|
||||
{"name": "Cuba", "value": "CU"},
|
||||
{"name": "Cyprus", "value": "CY"},
|
||||
{"name": "Czech Republic", "value": "CZ"},
|
||||
{"name": "Denmark", "value": "DK"},
|
||||
{"name": "Djibouti", "value": "DJ"},
|
||||
{"name": "Dominica", "value": "DM"},
|
||||
{"name": "Dominican Republic", "value": "DO"},
|
||||
{"name": "East Timor", "value": "TP"},
|
||||
{"name": "Ecuador", "value": "EC"},
|
||||
{"name": "Egypt", "value": "EG"},
|
||||
{"name": "El Salvador", "value": "SV"},
|
||||
{"name": "Equatorial Guinea", "value": "GQ"},
|
||||
{"name": "Eritrea", "value": "ER"},
|
||||
{"name": "Estonia", "value": "EE"},
|
||||
{"name": "Ethiopia", "value": "ET"},
|
||||
{"name": "European Union", "value": "EU"},
|
||||
{"name": "Falkland Islands (Malvinas)", "value": "FK"},
|
||||
{"name": "Faroe Islands", "value": "FO"},
|
||||
{"name": "Fiji", "value": "FJ"},
|
||||
{"name": "Finland", "value": "FI"},
|
||||
{"name": "France", "value": "FR"},
|
||||
{"name": "France, Metropolitan", "value": "FX"},
|
||||
{"name": "French Guiana", "value": "GF"},
|
||||
{"name": "French Polynesia", "value": "PF"},
|
||||
{"name": "French Southern Territories", "value": "TF"},
|
||||
{"name": "Gabon", "value": "GA"},
|
||||
{"name": "Gambia", "value": "GM"},
|
||||
{"name": "Georgia", "value": "GE"},
|
||||
{"name": "Germany", "value": "DE"},
|
||||
{"name": "Ghana", "value": "GH"},
|
||||
{"name": "Gibraltar", "value": "GI"},
|
||||
{"name": "Greece", "value": "GR"},
|
||||
{"name": "Greenland", "value": "GL"},
|
||||
{"name": "Grenada", "value": "GD"},
|
||||
{"name": "Guadeloupe", "value": "GP"},
|
||||
{"name": "Guam", "value": "GU"},
|
||||
{"name": "Guatemala", "value": "GT"},
|
||||
{"name": "Guinea", "value": "GN"},
|
||||
{"name": "Guinea-Bissau", "value": "GW"},
|
||||
{"name": "Guyana", "value": "GY"},
|
||||
{"name": "Haiti", "value": "HT"},
|
||||
{"name": "Heard Island and Mcdonald Islands", "value": "HM"},
|
||||
{"name": "Holy See (Vatican City State)", "value": "VA"},
|
||||
{"name": "Honduras", "value": "HN"},
|
||||
{"name": "Hong Kong", "value": "HK"},
|
||||
{"name": "Hungary", "value": "HU"},
|
||||
{"name": "Iceland", "value": "IS"},
|
||||
{"name": "India", "value": "IN"},
|
||||
{"name": "Indonesia", "value": "ID"},
|
||||
{"name": "Iran, Islamic Republic of", "value": "IR"},
|
||||
{"name": "Iraq", "value": "IQ"},
|
||||
{"name": "Ireland", "value": "IE"},
|
||||
{"name": "Israel", "value": "IL"},
|
||||
{"name": "Italy", "value": "IT"},
|
||||
{"name": "Jamaica", "value": "JM"},
|
||||
{"name": "Japan", "value": "JP"},
|
||||
{"name": "Jordan", "value": "JO"},
|
||||
{"name": "Kazakhstan", "value": "KZ"},
|
||||
{"name": "Kenya", "value": "KE"},
|
||||
{"name": "Kiribati", "value": "KI"},
|
||||
{"name": "Korea, Democratic People's Republic of", "value": "KP"},
|
||||
{"name": "Korea, Republic of", "value": "KR"},
|
||||
{"name": "Kuwait", "value": "KW"},
|
||||
{"name": "Kyrgyzstan", "value": "KG"},
|
||||
{"name": "Lao People's Democratic Republic", "value": "LA"},
|
||||
{"name": "Latvia", "value": "LV"},
|
||||
{"name": "Lebanon", "value": "LB"},
|
||||
{"name": "Lesotho", "value": "LS"},
|
||||
{"name": "Liberia", "value": "LR"},
|
||||
{"name": "Libyan Arab Jamahiriya", "value": "LY"},
|
||||
{"name": "Liechtenstein", "value": "LI"},
|
||||
{"name": "Lithuania", "value": "LT"},
|
||||
{"name": "Luxembourg", "value": "LU"},
|
||||
{"name": "Macao", "value": "MO"},
|
||||
{"name": "Madagascar", "value": "MG"},
|
||||
{"name": "Malawi", "value": "MW"},
|
||||
{"name": "Malaysia", "value": "MY"},
|
||||
{"name": "Maldives", "value": "MV"},
|
||||
{"name": "Mali", "value": "ML"},
|
||||
{"name": "Malta", "value": "MT"},
|
||||
{"name": "Marshall Islands", "value": "MH"},
|
||||
{"name": "Martinique", "value": "MQ"},
|
||||
{"name": "Mauritania", "value": "MR"},
|
||||
{"name": "Mauritius", "value": "MU"},
|
||||
{"name": "Mayotte", "value": "YT"},
|
||||
{"name": "Mexico", "value": "MX"},
|
||||
{"name": "Micronesia, Federated States of", "value": "FM"},
|
||||
{"name": "Moldova, Republic of", "value": "MD"},
|
||||
{"name": "Monaco", "value": "MC"},
|
||||
{"name": "Mongolia", "value": "MN"},
|
||||
{"name": "Montserrat", "value": "MS"},
|
||||
{"name": "Morocco", "value": "MA"},
|
||||
{"name": "Mozambique", "value": "MZ"},
|
||||
{"name": "Myanmar", "value": "MM"},
|
||||
{"name": "Namibia", "value": "NA"},
|
||||
{"name": "Nauru", "value": "NR"},
|
||||
{"name": "Nepal", "value": "NP"},
|
||||
{"name": "Netherlands", "value": "NL"},
|
||||
{"name": "Netherlands Antilles", "value": "AN"},
|
||||
{"name": "New Caledonia", "value": "NC"},
|
||||
{"name": "New Zealand", "value": "NZ"},
|
||||
{"name": "Nicaragua", "value": "NI"},
|
||||
{"name": "Niger", "value": "NE"},
|
||||
{"name": "Nigeria", "value": "NG"},
|
||||
{"name": "Niue", "value": "NU"},
|
||||
{"name": "Norfolk Island", "value": "NF"},
|
||||
{"name": "North Macedonia", "value": "MK"},
|
||||
{"name": "Northern Mariana Islands", "value": "MP"},
|
||||
{"name": "Norway", "value": "NO"},
|
||||
{"name": "Oman", "value": "OM"},
|
||||
{"name": "Pakistan", "value": "PK"},
|
||||
{"name": "Palau", "value": "PW"},
|
||||
{"name": "Palestinian Territory", "value": "PS"},
|
||||
{"name": "Panama", "value": "PA"},
|
||||
{"name": "Papua New Guinea", "value": "PG"},
|
||||
{"name": "Paraguay", "value": "PY"},
|
||||
{"name": "Peru", "value": "PE"},
|
||||
{"name": "Philippines", "value": "PH"},
|
||||
{"name": "Pitcairn", "value": "PN"},
|
||||
{"name": "Poland", "value": "PL"},
|
||||
{"name": "Portugal", "value": "PT"},
|
||||
{"name": "Puerto Rico", "value": "PR"},
|
||||
{"name": "Qatar", "value": "QA"},
|
||||
{"name": "Reunion", "value": "RE"},
|
||||
{"name": "Romania", "value": "RO"},
|
||||
{"name": "Russian Federation", "value": "RU"},
|
||||
{"name": "Rwanda", "value": "RW"},
|
||||
{"name": "Saint Helena", "value": "SH"},
|
||||
{"name": "Saint Kitts and Nevis", "value": "KN"},
|
||||
{"name": "Saint Lucia", "value": "LC"},
|
||||
{"name": "Saint Pierre and Miquelon", "value": "PM"},
|
||||
{"name": "Saint Vincent and the Grenadines", "value": "VC"},
|
||||
{"name": "Samoa", "value": "WS"},
|
||||
{"name": "San Marino", "value": "SM"},
|
||||
{"name": "Sao Tome and Principe", "value": "ST"},
|
||||
{"name": "Saudi Arabia", "value": "SA"},
|
||||
{"name": "Senegal", "value": "SN"},
|
||||
{"name": "Serbia and Montenegro", "value": "CS"},
|
||||
{"name": "Seychelles", "value": "SC"},
|
||||
{"name": "Sierra Leone", "value": "SL"},
|
||||
{"name": "Singapore", "value": "SG"},
|
||||
{"name": "Slovakia", "value": "SK"},
|
||||
{"name": "Slovenia", "value": "SI"},
|
||||
{"name": "Solomon Islands", "value": "SB"},
|
||||
{"name": "Somalia", "value": "SO"},
|
||||
{"name": "South Africa", "value": "ZA"},
|
||||
{"name": "South Georgia and the South Sandwich Islands", "value": "GS"},
|
||||
{"name": "Spain", "value": "ES"},
|
||||
{"name": "Sri Lanka", "value": "LK"},
|
||||
{"name": "Sudan", "value": "SD"},
|
||||
{"name": "Suriname", "value": "SR"},
|
||||
{"name": "Svalbard and Jan Mayen", "value": "SJ"},
|
||||
{"name": "Swaziland", "value": "SZ"},
|
||||
{"name": "Sweden", "value": "SE"},
|
||||
{"name": "Switzerland", "value": "CH"},
|
||||
{"name": "Syrian Arab Republic", "value": "SY"},
|
||||
{"name": "Taiwan", "value": "TW"},
|
||||
{"name": "Tajikistan", "value": "TJ"},
|
||||
{"name": "Tanzania, United Republic of", "value": "TZ"},
|
||||
{"name": "Thailand", "value": "TH"},
|
||||
{"name": "Togo", "value": "TG"},
|
||||
{"name": "Tokelau", "value": "TK"},
|
||||
{"name": "Tonga", "value": "TO"},
|
||||
{"name": "Trinidad and Tobago", "value": "TT"},
|
||||
{"name": "Tunisia", "value": "TN"},
|
||||
{"name": "Turkmenistan", "value": "TM"},
|
||||
{"name": "Turks and Caicos Islands", "value": "TC"},
|
||||
{"name": "Tuvalu", "value": "TV"},
|
||||
{"name": "Türkiye", "value": "TR"},
|
||||
{"name": "Uganda", "value": "UG"},
|
||||
{"name": "Ukraine", "value": "UA"},
|
||||
{"name": "United Arab Emirates", "value": "AE"},
|
||||
{"name": "United Kingdom", "value": "UK"},
|
||||
{"name": "United States", "value": "US"},
|
||||
{"name": "United States Minor Outlying Islands", "value": "UM"},
|
||||
{"name": "Uruguay", "value": "UY"},
|
||||
{"name": "Uzbekistan", "value": "UZ"},
|
||||
{"name": "Vanuatu", "value": "VU"},
|
||||
{"name": "Venezuela", "value": "VE"},
|
||||
{"name": "Vietnam", "value": "VN"},
|
||||
{"name": "Virgin Islands, British", "value": "VG"},
|
||||
{"name": "Virgin Islands, U.S.", "value": "VI"},
|
||||
{"name": "Wallis and Futuna", "value": "WF"},
|
||||
{"name": "Western Sahara", "value": "EH"},
|
||||
{"name": "Yemen", "value": "YE"},
|
||||
{"name": "Yugoslavia", "value": "YU"},
|
||||
{"name": "Zambia", "value": "ZM"},
|
||||
{"name": "Zimbabwe", "value": "ZW"}
|
||||
{"name": "Afghanistan", "value": "countryAF"},
|
||||
{"name": "Albania", "value": "countryAL"},
|
||||
{"name": "Algeria", "value": "countryDZ"},
|
||||
{"name": "American Samoa", "value": "countryAS"},
|
||||
{"name": "Andorra", "value": "countryAD"},
|
||||
{"name": "Angola", "value": "countryAO"},
|
||||
{"name": "Anguilla", "value": "countryAI"},
|
||||
{"name": "Antarctica", "value": "countryAQ"},
|
||||
{"name": "Antigua and Barbuda", "value": "countryAG"},
|
||||
{"name": "Argentina", "value": "countryAR"},
|
||||
{"name": "Armenia", "value": "countryAM"},
|
||||
{"name": "Aruba", "value": "countryAW"},
|
||||
{"name": "Australia", "value": "countryAU"},
|
||||
{"name": "Austria", "value": "countryAT"},
|
||||
{"name": "Azerbaijan", "value": "countryAZ"},
|
||||
{"name": "Bahamas", "value": "countryBS"},
|
||||
{"name": "Bahrain", "value": "countryBH"},
|
||||
{"name": "Bangladesh", "value": "countryBD"},
|
||||
{"name": "Barbados", "value": "countryBB"},
|
||||
{"name": "Belarus", "value": "countryBY"},
|
||||
{"name": "Belgium", "value": "countryBE"},
|
||||
{"name": "Belize", "value": "countryBZ"},
|
||||
{"name": "Benin", "value": "countryBJ"},
|
||||
{"name": "Bermuda", "value": "countryBM"},
|
||||
{"name": "Bhutan", "value": "countryBT"},
|
||||
{"name": "Bolivia", "value": "countryBO"},
|
||||
{"name": "Bosnia and Herzegovina", "value": "countryBA"},
|
||||
{"name": "Botswana", "value": "countryBW"},
|
||||
{"name": "Bouvet Island", "value": "countryBV"},
|
||||
{"name": "Brazil", "value": "countryBR"},
|
||||
{"name": "British Indian Ocean Territory", "value": "countryIO"},
|
||||
{"name": "Brunei Darussalam", "value": "countryBN"},
|
||||
{"name": "Bulgaria", "value": "countryBG"},
|
||||
{"name": "Burkina Faso", "value": "countryBF"},
|
||||
{"name": "Burundi", "value": "countryBI"},
|
||||
{"name": "Cambodia", "value": "countryKH"},
|
||||
{"name": "Cameroon", "value": "countryCM"},
|
||||
{"name": "Canada", "value": "countryCA"},
|
||||
{"name": "Cape Verde", "value": "countryCV"},
|
||||
{"name": "Cayman Islands", "value": "countryKY"},
|
||||
{"name": "Central African Republic", "value": "countryCF"},
|
||||
{"name": "Chad", "value": "countryTD"},
|
||||
{"name": "Chile", "value": "countryCL"},
|
||||
{"name": "China", "value": "countryCN"},
|
||||
{"name": "Christmas Island", "value": "countryCX"},
|
||||
{"name": "Cocos (Keeling) Islands", "value": "countryCC"},
|
||||
{"name": "Colombia", "value": "countryCO"},
|
||||
{"name": "Comoros", "value": "countryKM"},
|
||||
{"name": "Congo", "value": "countryCG"},
|
||||
{"name": "Congo, Democratic Republic of the", "value": "countryCD"},
|
||||
{"name": "Cook Islands", "value": "countryCK"},
|
||||
{"name": "Costa Rica", "value": "countryCR"},
|
||||
{"name": "Cote D\"ivoire", "value": "countryCI"},
|
||||
{"name": "Croatia (Hrvatska)", "value": "countryHR"},
|
||||
{"name": "Cuba", "value": "countryCU"},
|
||||
{"name": "Cyprus", "value": "countryCY"},
|
||||
{"name": "Czech Republic", "value": "countryCZ"},
|
||||
{"name": "Denmark", "value": "countryDK"},
|
||||
{"name": "Djibouti", "value": "countryDJ"},
|
||||
{"name": "Dominica", "value": "countryDM"},
|
||||
{"name": "Dominican Republic", "value": "countryDO"},
|
||||
{"name": "East Timor", "value": "countryTP"},
|
||||
{"name": "Ecuador", "value": "countryEC"},
|
||||
{"name": "Egypt", "value": "countryEG"},
|
||||
{"name": "El Salvador", "value": "countrySV"},
|
||||
{"name": "Equatorial Guinea", "value": "countryGQ"},
|
||||
{"name": "Eritrea", "value": "countryER"},
|
||||
{"name": "Estonia", "value": "countryEE"},
|
||||
{"name": "Ethiopia", "value": "countryET"},
|
||||
{"name": "European Union", "value": "countryEU"},
|
||||
{"name": "Falkland Islands (Malvinas)", "value": "countryFK"},
|
||||
{"name": "Faroe Islands", "value": "countryFO"},
|
||||
{"name": "Fiji", "value": "countryFJ"},
|
||||
{"name": "Finland", "value": "countryFI"},
|
||||
{"name": "France", "value": "countryFR"},
|
||||
{"name": "France, Metropolitan", "value": "countryFX"},
|
||||
{"name": "French Guiana", "value": "countryGF"},
|
||||
{"name": "French Polynesia", "value": "countryPF"},
|
||||
{"name": "French Southern Territories", "value": "countryTF"},
|
||||
{"name": "Gabon", "value": "countryGA"},
|
||||
{"name": "Gambia", "value": "countryGM"},
|
||||
{"name": "Georgia", "value": "countryGE"},
|
||||
{"name": "Germany", "value": "countryDE"},
|
||||
{"name": "Ghana", "value": "countryGH"},
|
||||
{"name": "Gibraltar", "value": "countryGI"},
|
||||
{"name": "Greece", "value": "countryGR"},
|
||||
{"name": "Greenland", "value": "countryGL"},
|
||||
{"name": "Grenada", "value": "countryGD"},
|
||||
{"name": "Guadeloupe", "value": "countryGP"},
|
||||
{"name": "Guam", "value": "countryGU"},
|
||||
{"name": "Guatemala", "value": "countryGT"},
|
||||
{"name": "Guinea", "value": "countryGN"},
|
||||
{"name": "Guinea-Bissau", "value": "countryGW"},
|
||||
{"name": "Guyana", "value": "countryGY"},
|
||||
{"name": "Haiti", "value": "countryHT"},
|
||||
{"name": "Heard Island and Mcdonald Islands", "value": "countryHM"},
|
||||
{"name": "Holy See (Vatican City State)", "value": "countryVA"},
|
||||
{"name": "Honduras", "value": "countryHN"},
|
||||
{"name": "Hong Kong", "value": "countryHK"},
|
||||
{"name": "Hungary", "value": "countryHU"},
|
||||
{"name": "Iceland", "value": "countryIS"},
|
||||
{"name": "India", "value": "countryIN"},
|
||||
{"name": "Indonesia", "value": "countryID"},
|
||||
{"name": "Iran, Islamic Republic of", "value": "countryIR"},
|
||||
{"name": "Iraq", "value": "countryIQ"},
|
||||
{"name": "Ireland", "value": "countryIE"},
|
||||
{"name": "Israel", "value": "countryIL"},
|
||||
{"name": "Italy", "value": "countryIT"},
|
||||
{"name": "Jamaica", "value": "countryJM"},
|
||||
{"name": "Japan", "value": "countryJP"},
|
||||
{"name": "Jordan", "value": "countryJO"},
|
||||
{"name": "Kazakhstan", "value": "countryKZ"},
|
||||
{"name": "Kenya", "value": "countryKE"},
|
||||
{"name": "Kiribati", "value": "countryKI"},
|
||||
{"name": "Korea, Democratic People\"s Republic of",
|
||||
"value": "countryKP"},
|
||||
{"name": "Korea, Republic of", "value": "countryKR"},
|
||||
{"name": "Kuwait", "value": "countryKW"},
|
||||
{"name": "Kyrgyzstan", "value": "countryKG"},
|
||||
{"name": "Lao People\"s Democratic Republic", "value": "countryLA"},
|
||||
{"name": "Latvia", "value": "countryLV"},
|
||||
{"name": "Lebanon", "value": "countryLB"},
|
||||
{"name": "Lesotho", "value": "countryLS"},
|
||||
{"name": "Liberia", "value": "countryLR"},
|
||||
{"name": "Libyan Arab Jamahiriya", "value": "countryLY"},
|
||||
{"name": "Liechtenstein", "value": "countryLI"},
|
||||
{"name": "Lithuania", "value": "countryLT"},
|
||||
{"name": "Luxembourg", "value": "countryLU"},
|
||||
{"name": "Macao", "value": "countryMO"},
|
||||
{"name": "Macedonia, the Former Yugosalv Republic of",
|
||||
"value": "countryMK"},
|
||||
{"name": "Madagascar", "value": "countryMG"},
|
||||
{"name": "Malawi", "value": "countryMW"},
|
||||
{"name": "Malaysia", "value": "countryMY"},
|
||||
{"name": "Maldives", "value": "countryMV"},
|
||||
{"name": "Mali", "value": "countryML"},
|
||||
{"name": "Malta", "value": "countryMT"},
|
||||
{"name": "Marshall Islands", "value": "countryMH"},
|
||||
{"name": "Martinique", "value": "countryMQ"},
|
||||
{"name": "Mauritania", "value": "countryMR"},
|
||||
{"name": "Mauritius", "value": "countryMU"},
|
||||
{"name": "Mayotte", "value": "countryYT"},
|
||||
{"name": "Mexico", "value": "countryMX"},
|
||||
{"name": "Micronesia, Federated States of", "value": "countryFM"},
|
||||
{"name": "Moldova, Republic of", "value": "countryMD"},
|
||||
{"name": "Monaco", "value": "countryMC"},
|
||||
{"name": "Mongolia", "value": "countryMN"},
|
||||
{"name": "Montserrat", "value": "countryMS"},
|
||||
{"name": "Morocco", "value": "countryMA"},
|
||||
{"name": "Mozambique", "value": "countryMZ"},
|
||||
{"name": "Myanmar", "value": "countryMM"},
|
||||
{"name": "Namibia", "value": "countryNA"},
|
||||
{"name": "Nauru", "value": "countryNR"},
|
||||
{"name": "Nepal", "value": "countryNP"},
|
||||
{"name": "Netherlands", "value": "countryNL"},
|
||||
{"name": "Netherlands Antilles", "value": "countryAN"},
|
||||
{"name": "New Caledonia", "value": "countryNC"},
|
||||
{"name": "New Zealand", "value": "countryNZ"},
|
||||
{"name": "Nicaragua", "value": "countryNI"},
|
||||
{"name": "Niger", "value": "countryNE"},
|
||||
{"name": "Nigeria", "value": "countryNG"},
|
||||
{"name": "Niue", "value": "countryNU"},
|
||||
{"name": "Norfolk Island", "value": "countryNF"},
|
||||
{"name": "Northern Mariana Islands", "value": "countryMP"},
|
||||
{"name": "Norway", "value": "countryNO"},
|
||||
{"name": "Oman", "value": "countryOM"},
|
||||
{"name": "Pakistan", "value": "countryPK"},
|
||||
{"name": "Palau", "value": "countryPW"},
|
||||
{"name": "Palestinian Territory", "value": "countryPS"},
|
||||
{"name": "Panama", "value": "countryPA"},
|
||||
{"name": "Papua New Guinea", "value": "countryPG"},
|
||||
{"name": "Paraguay", "value": "countryPY"},
|
||||
{"name": "Peru", "value": "countryPE"},
|
||||
{"name": "Philippines", "value": "countryPH"},
|
||||
{"name": "Pitcairn", "value": "countryPN"},
|
||||
{"name": "Poland", "value": "countryPL"},
|
||||
{"name": "Portugal", "value": "countryPT"},
|
||||
{"name": "Puerto Rico", "value": "countryPR"},
|
||||
{"name": "Qatar", "value": "countryQA"},
|
||||
{"name": "Reunion", "value": "countryRE"},
|
||||
{"name": "Romania", "value": "countryRO"},
|
||||
{"name": "Russian Federation", "value": "countryRU"},
|
||||
{"name": "Rwanda", "value": "countryRW"},
|
||||
{"name": "Saint Helena", "value": "countrySH"},
|
||||
{"name": "Saint Kitts and Nevis", "value": "countryKN"},
|
||||
{"name": "Saint Lucia", "value": "countryLC"},
|
||||
{"name": "Saint Pierre and Miquelon", "value": "countryPM"},
|
||||
{"name": "Saint Vincent and the Grenadines", "value": "countryVC"},
|
||||
{"name": "Samoa", "value": "countryWS"},
|
||||
{"name": "San Marino", "value": "countrySM"},
|
||||
{"name": "Sao Tome and Principe", "value": "countryST"},
|
||||
{"name": "Saudi Arabia", "value": "countrySA"},
|
||||
{"name": "Senegal", "value": "countrySN"},
|
||||
{"name": "Serbia and Montenegro", "value": "countryCS"},
|
||||
{"name": "Seychelles", "value": "countrySC"},
|
||||
{"name": "Sierra Leone", "value": "countrySL"},
|
||||
{"name": "Singapore", "value": "countrySG"},
|
||||
{"name": "Slovakia", "value": "countrySK"},
|
||||
{"name": "Slovenia", "value": "countrySI"},
|
||||
{"name": "Solomon Islands", "value": "countrySB"},
|
||||
{"name": "Somalia", "value": "countrySO"},
|
||||
{"name": "South Africa", "value": "countryZA"},
|
||||
{"name": "South Georgia and the South Sandwich Islands",
|
||||
"value": "countryGS"},
|
||||
{"name": "Spain", "value": "countryES"},
|
||||
{"name": "Sri Lanka", "value": "countryLK"},
|
||||
{"name": "Sudan", "value": "countrySD"},
|
||||
{"name": "Suriname", "value": "countrySR"},
|
||||
{"name": "Svalbard and Jan Mayen", "value": "countrySJ"},
|
||||
{"name": "Swaziland", "value": "countrySZ"},
|
||||
{"name": "Sweden", "value": "countrySE"},
|
||||
{"name": "Switzerland", "value": "countryCH"},
|
||||
{"name": "Syrian Arab Republic", "value": "countrySY"},
|
||||
{"name": "Taiwan, Province of China", "value": "countryTW"},
|
||||
{"name": "Tajikistan", "value": "countryTJ"},
|
||||
{"name": "Tanzania, United Republic of", "value": "countryTZ"},
|
||||
{"name": "Thailand", "value": "countryTH"},
|
||||
{"name": "Togo", "value": "countryTG"},
|
||||
{"name": "Tokelau", "value": "countryTK"},
|
||||
{"name": "Tonga", "value": "countryTO"},
|
||||
{"name": "Trinidad and Tobago", "value": "countryTT"},
|
||||
{"name": "Tunisia", "value": "countryTN"},
|
||||
{"name": "Turkey", "value": "countryTR"},
|
||||
{"name": "Turkmenistan", "value": "countryTM"},
|
||||
{"name": "Turks and Caicos Islands", "value": "countryTC"},
|
||||
{"name": "Tuvalu", "value": "countryTV"},
|
||||
{"name": "Uganda", "value": "countryUG"},
|
||||
{"name": "Ukraine", "value": "countryUA"},
|
||||
{"name": "United Arab Emirates", "value": "countryAE"},
|
||||
{"name": "United Kingdom", "value": "countryUK"},
|
||||
{"name": "United States", "value": "countryUS"},
|
||||
{"name": "United States Minor Outlying Islands", "value": "countryUM"},
|
||||
{"name": "Uruguay", "value": "countryUY"},
|
||||
{"name": "Uzbekistan", "value": "countryUZ"},
|
||||
{"name": "Vanuatu", "value": "countryVU"},
|
||||
{"name": "Venezuela", "value": "countryVE"},
|
||||
{"name": "Vietnam", "value": "countryVN"},
|
||||
{"name": "Virgin Islands, British", "value": "countryVG"},
|
||||
{"name": "Virgin Islands, U.S.", "value": "countryVI"},
|
||||
{"name": "Wallis and Futuna", "value": "countryWF"},
|
||||
{"name": "Western Sahara", "value": "countryEH"},
|
||||
{"name": "Yemen", "value": "countryYE"},
|
||||
{"name": "Yugoslavia", "value": "countryYU"},
|
||||
{"name": "Zambia", "value": "countryZM"},
|
||||
{"name": "Zimbabwe", "value": "countryZW"}
|
||||
]
|
||||
|
||||
@ -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": "Arabic (عربى)", "value": "lang_ar"},
|
||||
{"name": "Armenian (հայերեն)", "value": "lang_hy"},
|
||||
{"name": "Azerbaijani (Azərbaycanca)", "value": "lang_az"},
|
||||
{"name": "Belarusian (Беларуская)", "value": "lang_be"},
|
||||
{"name": "Bulgarian (български)", "value": "lang_bg"},
|
||||
{"name": "Catalan (Català)", "value": "lang_ca"},
|
||||
{"name": "Chinese, Simplified (简体中文)", "value": "lang_zh-CN"},
|
||||
{"name": "Chinese, Traditional (正體中文)", "value": "lang_zh-TW"},
|
||||
{"name": "Chinese, Traditional (繁体中文)", "value": "lang_zh-TW"},
|
||||
{"name": "Croatian (Hrvatski)", "value": "lang_hr"},
|
||||
{"name": "Czech (čeština)", "value": "lang_cs"},
|
||||
{"name": "Danish (Dansk)", "value": "lang_da"},
|
||||
@ -19,7 +18,7 @@
|
||||
{"name": "Filipino (Pilipino)", "value": "lang_tl"},
|
||||
{"name": "Finnish (Suomalainen)", "value": "lang_fi"},
|
||||
{"name": "French (Français)", "value": "lang_fr"},
|
||||
{"name": "German (Deutsch)", "value": "lang_de"},
|
||||
{"name": "German (Deutsche)", "value": "lang_de"},
|
||||
{"name": "Greek (Ελληνικά)", "value": "lang_el"},
|
||||
{"name": "Hebrew (עִברִית)", "value": "lang_iw"},
|
||||
{"name": "Hindi (हिंदी)", "value": "lang_hi"},
|
||||
@ -29,27 +28,22 @@
|
||||
{"name": "Italian (Italiano)", "value": "lang_it"},
|
||||
{"name": "Japanese (日本語)", "value": "lang_ja"},
|
||||
{"name": "Korean (한국어)", "value": "lang_ko"},
|
||||
{"name": "Kurdish (Kurdî)", "value": "lang_ku"},
|
||||
{"name": "Latvian (Latvietis)", "value": "lang_lv"},
|
||||
{"name": "Lithuanian (Lietuvis)", "value": "lang_lt"},
|
||||
{"name": "Norwegian (Norwegian)", "value": "lang_no"},
|
||||
{"name": "Persian (فارسی)", "value": "lang_fa"},
|
||||
{"name": "Polish (Polskie)", "value": "lang_pl"},
|
||||
{"name": "Portuguese (Português)", "value": "lang_pt"},
|
||||
{"name": "Portugese (Português)", "value": "lang_pt"},
|
||||
{"name": "Romanian (Română)", "value": "lang_ro"},
|
||||
{"name": "Russian (русский)", "value": "lang_ru"},
|
||||
{"name": "Serbian (Српски)", "value": "lang_sr"},
|
||||
{"name": "Sinhala (සිංහල)", "value": "lang_si"},
|
||||
{"name": "Slovak (Slovák)", "value": "lang_sk"},
|
||||
{"name": "Slovenian (Slovenščina)", "value": "lang_sl"},
|
||||
{"name": "Spanish (Español)", "value": "lang_es"},
|
||||
{"name": "Swahili (Kiswahili)", "value": "lang_sw"},
|
||||
{"name": "Swedish (Svenska)", "value": "lang_sv"},
|
||||
{"name": "Thai (ไทย)", "value": "lang_th"},
|
||||
{"name": "Turkish (Türkçe)", "value": "lang_tr"},
|
||||
{"name": "Ukrainian (Українська)", "value": "lang_uk"},
|
||||
{"name": "Vietnamese (Tiếng Việt)", "value": "lang_vi"},
|
||||
{"name": "Welsh (Cymraeg)", "value": "lang_cy"},
|
||||
{"name": "Xhosa (isiXhosa)", "value": "lang_xh"},
|
||||
{"name": "Zulu (isiZulu)", "value": "lang_zu"}
|
||||
{"name": "Turkish (Türk)", "value": "lang_tr"},
|
||||
{"name": "Ukranian (Український)", "value": "lang_uk"},
|
||||
{"name": "Vietnamese (Tiếng Việt)", "value": "lang_vi"}
|
||||
]
|
||||
|
||||
@ -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,263 +0,0 @@
|
||||
<!--
|
||||
Calculator widget.
|
||||
This file should contain all required
|
||||
CSS, HTML, and JS for it.
|
||||
-->
|
||||
|
||||
<style>
|
||||
#calc-text {
|
||||
background: var(--whoogle-dark-page-bg);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
color: var(--whoogle-dark-text);
|
||||
}
|
||||
#prev-equation {
|
||||
text-align: right;
|
||||
}
|
||||
.error-border {
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
#calc-btns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-rows: repeat(5, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
#calc-btns button {
|
||||
background: #313141;
|
||||
color: var(--whoogle-dark-text);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#calc-btns button:hover {
|
||||
background: #414151;
|
||||
}
|
||||
#calc-btns .common {
|
||||
background: #51516a;
|
||||
}
|
||||
#calc-btns .common:hover {
|
||||
background: #61617a;
|
||||
}
|
||||
#calc-btn-0 { grid-row: 5; grid-column: 3; }
|
||||
#calc-btn-1 { grid-row: 4; grid-column: 3; }
|
||||
#calc-btn-2 { grid-row: 4; grid-column: 4; }
|
||||
#calc-btn-3 { grid-row: 4; grid-column: 5; }
|
||||
#calc-btn-4 { grid-row: 3; grid-column: 3; }
|
||||
#calc-btn-5 { grid-row: 3; grid-column: 4; }
|
||||
#calc-btn-6 { grid-row: 3; grid-column: 5; }
|
||||
#calc-btn-7 { grid-row: 2; grid-column: 3; }
|
||||
#calc-btn-8 { grid-row: 2; grid-column: 4; }
|
||||
#calc-btn-9 { grid-row: 2; grid-column: 5; }
|
||||
#calc-btn-EQ { grid-row: 5; grid-column: 5; }
|
||||
#calc-btn-PT { grid-row: 5; grid-column: 4; }
|
||||
#calc-btn-BCK { grid-row: 5; grid-column: 6; }
|
||||
#calc-btn-ADD { grid-row: 4; grid-column: 6; }
|
||||
#calc-btn-SUB { grid-row: 3; grid-column: 6; }
|
||||
#calc-btn-MLT { grid-row: 2; grid-column: 6; }
|
||||
#calc-btn-DIV { grid-row: 1; grid-column: 6; }
|
||||
#calc-btn-CLR { grid-row: 1; grid-column: 5; }
|
||||
#calc-btn-PRC{ grid-row: 1; grid-column: 4; }
|
||||
#calc-btn-RP { grid-row: 1; grid-column: 3; }
|
||||
#calc-btn-LP { grid-row: 1; grid-column: 2; }
|
||||
#calc-btn-ABS { grid-row: 1; grid-column: 1; }
|
||||
#calc-btn-SIN { grid-row: 2; grid-column: 2; }
|
||||
#calc-btn-COS { grid-row: 3; grid-column: 2; }
|
||||
#calc-btn-TAN { grid-row: 4; grid-column: 2; }
|
||||
#calc-btn-SQR { grid-row: 5; grid-column: 2; }
|
||||
#calc-btn-EXP { grid-row: 2; grid-column: 1; }
|
||||
#calc-btn-E { grid-row: 3; grid-column: 1; }
|
||||
#calc-btn-PI { grid-row: 4; grid-column: 1; }
|
||||
#calc-btn-LOG { grid-row: 5; grid-column: 1; }
|
||||
</style>
|
||||
<p id="prev-equation"></p>
|
||||
<div id="calculator-widget">
|
||||
<p id="calc-text">0</p>
|
||||
<div id="calc-btns">
|
||||
<button id="calc-btn-0" class="common">0</button>
|
||||
<button id="calc-btn-1" class="common">1</button>
|
||||
<button id="calc-btn-2" class="common">2</button>
|
||||
<button id="calc-btn-3" class="common">3</button>
|
||||
<button id="calc-btn-4" class="common">4</button>
|
||||
<button id="calc-btn-5" class="common">5</button>
|
||||
<button id="calc-btn-6" class="common">6</button>
|
||||
<button id="calc-btn-7" class="common">7</button>
|
||||
<button id="calc-btn-8" class="common">8</button>
|
||||
<button id="calc-btn-9" class="common">9</button>
|
||||
<button id="calc-btn-EQ" class="common">=</button>
|
||||
<button id="calc-btn-PT" class="common">.</button>
|
||||
<button id="calc-btn-BCK">⬅</button>
|
||||
<button id="calc-btn-ADD">+</button>
|
||||
<button id="calc-btn-SUB">-</button>
|
||||
<button id="calc-btn-MLT">x</button>
|
||||
<button id="calc-btn-DIV">/</button>
|
||||
<button id="calc-btn-CLR">C</button>
|
||||
<button id="calc-btn-PRC">%</button>
|
||||
<button id="calc-btn-RP">)</button>
|
||||
<button id="calc-btn-LP">(</button>
|
||||
<button id="calc-btn-ABS">|x|</button>
|
||||
<button id="calc-btn-SIN">sin</button>
|
||||
<button id="calc-btn-COS">cos</button>
|
||||
<button id="calc-btn-TAN">tan</button>
|
||||
<button id="calc-btn-SQR">√</button>
|
||||
<button id="calc-btn-EXP">^</button>
|
||||
<button id="calc-btn-E">ℇ</button>
|
||||
<button id="calc-btn-PI">π</button>
|
||||
<button id="calc-btn-LOG">log</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// JS does not have this by default.
|
||||
// from https://www.freecodecamp.org/news/how-to-factorialize-a-number-in-javascript-9263c89a4b38/
|
||||
function factorial(num) {
|
||||
if (num < 0)
|
||||
return -1;
|
||||
else if (num === 0)
|
||||
return 1;
|
||||
else {
|
||||
return (num * factorial(num - 1));
|
||||
}
|
||||
}
|
||||
// returns true if the user is currently focused on the calculator widget
|
||||
function usingCalculator() {
|
||||
let activeElement = document.activeElement;
|
||||
while (true) {
|
||||
if (!activeElement) return false;
|
||||
if (activeElement.id === "calculator-wrapper") return true;
|
||||
activeElement = activeElement.parentElement;
|
||||
}
|
||||
}
|
||||
const $ = q => document.querySelectorAll(q);
|
||||
// key bindings for commonly used buttons
|
||||
const keybindings = {
|
||||
"0": "0",
|
||||
"1": "1",
|
||||
"2": "2",
|
||||
"3": "3",
|
||||
"4": "4",
|
||||
"5": "5",
|
||||
"6": "6",
|
||||
"7": "7",
|
||||
"8": "8",
|
||||
"9": "9",
|
||||
"Enter": "EQ",
|
||||
".": "PT",
|
||||
"+": "ADD",
|
||||
"-": "SUB",
|
||||
"*": "MLT",
|
||||
"/": "DIV",
|
||||
"%": "PRC",
|
||||
"c": "CLR",
|
||||
"(": "LP",
|
||||
")": "RP",
|
||||
"Backspace": "BCK",
|
||||
}
|
||||
window.addEventListener("keydown", event => {
|
||||
if (!usingCalculator()) return;
|
||||
if (event.key === "Enter" && document.activeElement.id !== "search-bar")
|
||||
event.preventDefault();
|
||||
if (keybindings[event.key])
|
||||
document.getElementById("calc-btn-" + keybindings[event.key]).click();
|
||||
})
|
||||
// calculates the string
|
||||
const calc = () => {
|
||||
var mathtext = document.getElementById("calc-text");
|
||||
var statement = mathtext.innerHTML
|
||||
// remove empty ()
|
||||
.replace("()", "")
|
||||
// special constants
|
||||
.replace("π", "(Math.PI)")
|
||||
.replace("ℇ", "(Math.E)")
|
||||
// turns 3(1+2) into 3*(1+2) (for example)
|
||||
.replace(/(?<=[0-9\)])(?<=[^+\-x*\/%^])\(/, "x(")
|
||||
// same except reversed
|
||||
.replace(/\)(?=[0-9\(])(?=[^+\-x*\/%^])/, ")x")
|
||||
// replace human friendly x with JS *
|
||||
.replace("x", "*")
|
||||
// trig & misc functions
|
||||
.replace("sin", "Math.sin")
|
||||
.replace("cos", "Math.cos")
|
||||
.replace("tan", "Math.tan")
|
||||
.replace("√", "Math.sqrt")
|
||||
.replace("^", "**")
|
||||
.replace("abs", "Math.abs")
|
||||
.replace("log", "Math.log")
|
||||
;
|
||||
// add any missing )s to the end
|
||||
while(true) if (
|
||||
(statement.match(/\(/g) || []).length >
|
||||
(statement.match(/\)/g) || []).length
|
||||
) statement += ")"; else break;
|
||||
// evaluate the expression using a safe evaluator (no eval())
|
||||
console.log("calculating [" + statement + "]");
|
||||
try {
|
||||
// Safe evaluation: create a sandboxed function with only Math object available
|
||||
// This prevents arbitrary code execution while allowing mathematical operations
|
||||
const safeEval = new Function('Math', `'use strict'; return (${statement})`);
|
||||
var result = safeEval(Math);
|
||||
document.getElementById("prev-equation").innerHTML = mathtext.innerHTML + " = ";
|
||||
mathtext.innerHTML = result;
|
||||
mathtext.classList.remove("error-border");
|
||||
} catch (e) {
|
||||
mathtext.classList.add("error-border");
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
const updateCalc = (e) => {
|
||||
// character(s) recieved from button
|
||||
var c = event.target.innerHTML;
|
||||
var mathtext = document.getElementById("calc-text");
|
||||
if (mathtext.innerHTML === "0") mathtext.innerHTML = "";
|
||||
// special cases
|
||||
switch (c) {
|
||||
case "C":
|
||||
// Clear
|
||||
mathtext.innerHTML = "0";
|
||||
break;
|
||||
case "⬅":
|
||||
// Delete
|
||||
mathtext.innerHTML = mathtext.innerHTML.slice(0, -1);
|
||||
if (mathtext.innerHTML.length === 0) {
|
||||
mathtext.innerHTML = "0";
|
||||
}
|
||||
break;
|
||||
case "=":
|
||||
calc()
|
||||
break;
|
||||
case "sin":
|
||||
case "cos":
|
||||
case "tan":
|
||||
case "log":
|
||||
case "√":
|
||||
mathtext.innerHTML += `${c}(`;
|
||||
break;
|
||||
case "|x|":
|
||||
mathtext.innerHTML += "abs("
|
||||
break;
|
||||
case "+":
|
||||
case "-":
|
||||
case "x":
|
||||
case "/":
|
||||
case "%":
|
||||
case "^":
|
||||
if (mathtext.innerHTML.length === 0) mathtext.innerHTML = "0";
|
||||
// prevent typing 2 operators in a row
|
||||
if (mathtext.innerHTML.match(/[+\-x\/%^] $/))
|
||||
mathtext.innerHTML = mathtext.innerHTML.slice(0, -3);
|
||||
mathtext.innerHTML += ` ${c} `;
|
||||
break;
|
||||
default:
|
||||
mathtext.innerHTML += c;
|
||||
}
|
||||
}
|
||||
for (let i of $("#calc-btns button")) {
|
||||
i.addEventListener('click', event => {
|
||||
updateCalc(event);
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -1,58 +1,29 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel="shortcut icon" href="static/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="static/img/favicon.ico" type="image/x-icon">
|
||||
{% if not search_type %}
|
||||
<head>
|
||||
<link rel="shortcut icon" href="static/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="static/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
||||
{% else %}
|
||||
<link rel="search" href="opensearch.xml?tbm={{ search_type }}" type="application/opensearchdescription+xml" title="Whoogle Search ({{ search_name }})">
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
{% if bundle_static() %}
|
||||
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url('logo.css') }}">
|
||||
<link rel="stylesheet" href="{{ cb_url('input.css') }}">
|
||||
<link rel="stylesheet" href="{{ cb_url('search.css') }}">
|
||||
<link rel="stylesheet" href="{{ cb_url('header.css') }}">
|
||||
{% endif %}
|
||||
{% if config.theme %}
|
||||
{% if config.theme == 'system' %}
|
||||
<style>
|
||||
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||
</style>
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if config.style %}
|
||||
<style>
|
||||
{{ config.style }}
|
||||
</style>
|
||||
{% endif %}
|
||||
<title>{{ clean_query(query) }} - Whoogle Search</title>
|
||||
</head>
|
||||
<body>
|
||||
{{ search_header|safe }}
|
||||
{% if is_translation %}
|
||||
<iframe
|
||||
id="lingva-iframe"
|
||||
src="{{ lingva_url }}/auto/{{ translate_to }}/{{ translate_str }}">
|
||||
</iframe>
|
||||
{% endif %}
|
||||
{{ response|safe }}
|
||||
</body>
|
||||
{% include 'footer.html' %}
|
||||
{% if bundle_static() %}
|
||||
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||
{% else %}
|
||||
{% if autocomplete_enabled == '1' %}
|
||||
<script src="{{ cb_url('autocomplete.js') }}"></script>
|
||||
{% endif %}
|
||||
<script src="{{ cb_url('utils.js') }}"></script>
|
||||
<script src="{{ cb_url('keyboard.js') }}"></script>
|
||||
<script src="{{ cb_url('currency.js') }}"></script>
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<link rel="stylesheet" href="static/css/input.css">
|
||||
<link rel="stylesheet" href="static/css/search.css">
|
||||
<link rel="stylesheet" href="static/css/variables.css">
|
||||
<link rel="stylesheet" href="static/css/header.css">
|
||||
<link rel="stylesheet" href="static/css/{{ 'dark' if config.dark else 'light' }}-theme.css"/>
|
||||
<style>{{ config.style }}</style>
|
||||
<title>{{ query }} - Whoogle Search</title>
|
||||
</head>
|
||||
<body>
|
||||
{{ search_header|safe }}
|
||||
{{ response|safe }}
|
||||
</body>
|
||||
<footer>
|
||||
<p style="color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};">
|
||||
Whoogle Search v{{ version_number }} ||
|
||||
<a id="gh-link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
|
||||
</p>
|
||||
</footer>
|
||||
<script src="static/js/autocomplete.js"></script>
|
||||
<script src="static/js/utils.js"></script>
|
||||
<script src="static/js/keyboard.js"></script>
|
||||
</html>
|
||||
|
||||
@ -1,128 +1,6 @@
|
||||
{% if config.theme %}
|
||||
{% if config.theme == 'system' %}
|
||||
<style>
|
||||
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||
</style>
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bundle_static() %}
|
||||
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
|
||||
<link rel="stylesheet" href="{{ cb_url('error.css') }}">
|
||||
{% endif %}
|
||||
<style>{{ config.style }}</style>
|
||||
<div>
|
||||
<h1>Error</h1>
|
||||
<p>
|
||||
{{ error_message }}
|
||||
</p>
|
||||
<hr>
|
||||
{% if query and translation %}
|
||||
<p>
|
||||
<h4><a class="link" href="https://farside.link">{{ translation['continue-search'] }}</a></h4>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/benbusby/whoogle-search">Whoogle</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="{{farside}}/whoogle/search?q={{query}}{{params}}">
|
||||
{{farside}}/whoogle/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/searxng/searxng">SearXNG</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="{{farside}}/searxng/search?q={{query}}">
|
||||
{{farside}}/searxng/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://git.lolcat.ca/lolcat/4get">4get</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="{{farside}}/4get/web?s={{query}}&scraper=google">
|
||||
{{farside}}/4get/web?s={{query}}&scraper=google
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h4>Other options:</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://kagi.com">Kagi</a>
|
||||
<ul>
|
||||
<li>Requires account</li>
|
||||
<li>
|
||||
<a class="link-color" href="https://kagi.com/search?q={{query}}">
|
||||
kagi.com/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://4get.ca">4get</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://4get.ca/web?s={{query}}">
|
||||
4get.ca/web?s={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://duckduckgo.com">DuckDuckGo</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://duckduckgo.com/?q={{query}}">
|
||||
duckduckgo.com/?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://search.brave.com">Brave Search</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://search.brave.com/search?q={{query}}">
|
||||
search.brave.com/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://ecosia.com">Ecosia</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://ecosia.com/search?q={{query}}">
|
||||
ecosia.com/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://google.com">Google</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://google.com/search?q={{query}}">
|
||||
google.com/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
</p>
|
||||
{% endif %}
|
||||
<a class="link" href="home">Return Home</a>
|
||||
</div>
|
||||
<h1>Error</h1>
|
||||
<hr>
|
||||
<p>
|
||||
Error: "{{ error_message|safe }}"
|
||||
</p>
|
||||
<a href="/">Return Home</a>
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
<footer>
|
||||
<p class="footer">
|
||||
Whoogle Search v{{ version_number }} ||
|
||||
<a class="link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
|
||||
{% if has_update %}
|
||||
|| <span class="update_available">Update Available 🟢</span>
|
||||
{% endif %}
|
||||
{% if config.show_user_agent and used_user_agent %}
|
||||
<br><span class="user-agent-display" style="font-size: 0.85em; color: #666;">User Agent: {{ used_user_agent }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</footer>
|
||||
@ -1,95 +1,64 @@
|
||||
{% if mobile %}
|
||||
<header>
|
||||
<div class="header-div">
|
||||
<form class="search-form header"
|
||||
id="search-form"
|
||||
method="{{ 'GET' if config.get_only else 'POST' }}">
|
||||
<a class="logo-link mobile-logo" href="{{ home_url }}">
|
||||
<div id="mobile-header-logo">
|
||||
<div style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;" class="bz1lBb">
|
||||
<form class="search-form Pg70bf" id="search-form" method="POST">
|
||||
<a class="logo-link mobile-logo"
|
||||
href="/"
|
||||
style="display:flex; justify-content:center; align-items:center;">
|
||||
<div style="height: 1.75em;">
|
||||
{{ logo|safe }}
|
||||
</div>
|
||||
</a>
|
||||
<div class="H0PQec mobile-input-div">
|
||||
<div class="autocomplete-mobile esbc autocomplete">
|
||||
{% if config.preferences %}
|
||||
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||
{% endif %}
|
||||
<input
|
||||
id="search-bar"
|
||||
class="mobile-search-bar"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="search-bar-input"
|
||||
name="q"
|
||||
type="text"
|
||||
value="{{ clean_query(query) }}"
|
||||
dir="auto">
|
||||
<input id="search-reset" type="reset" value="x">
|
||||
<div class="H0PQec" style="width: 100%;">
|
||||
<div class="sbc esbc autocomplete">
|
||||
<input
|
||||
id="search-bar"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="noHIxc"
|
||||
name="q"
|
||||
style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;
|
||||
color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};"
|
||||
type="text"
|
||||
value="{{ query }}">
|
||||
<input style="color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }}" id="search-reset" type="reset" value="x">
|
||||
<input name="tbm" value="{{ search_type }}" style="display: none">
|
||||
<input name="country" value="{{ config.country }}" style="display: none;">
|
||||
<input type="submit" style="display: none;">
|
||||
<div class="sc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<div class="header-tab-div">
|
||||
<div class="header-tab-div-2">
|
||||
<div class="header-tab-div-3">
|
||||
<div class="mobile-header header-tab">
|
||||
{% for tab_id, tab_content in tabs.items() %}
|
||||
{% if tab_content['selected'] %}
|
||||
<span class="mobile-tab-span">{{ tab_content['name'] }}</span>
|
||||
{% else %}
|
||||
<a class="header-tab-a" href="{{ tab_content['href'] }}">{{ tab_content['name'] }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<label for="adv-search-toggle" id="adv-search-label" class="adv-search">⚙</label>
|
||||
<input id="adv-search-toggle" type="checkbox">
|
||||
<div class="header-tab-div-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="" id="s">
|
||||
</div>
|
||||
</header>
|
||||
{% else %}
|
||||
<header>
|
||||
<div class="logo-div">
|
||||
<a class="logo-link" href="{{ home_url }}">
|
||||
<div class="desktop-header-logo">
|
||||
<a class="logo-link" href="/">
|
||||
<div style="height: 1.65em;">
|
||||
{{ logo|safe }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="search-div">
|
||||
<form id="search-form"
|
||||
class="search-form"
|
||||
id="sf"
|
||||
method="{{ 'GET' if config.get_only else 'POST' }}">
|
||||
<div class="autocomplete header-autocomplete">
|
||||
<form id="search-form" class="search-form" id="sf" method="POST">
|
||||
<div class="autocomplete" style="width: 100%; flex: 1">
|
||||
<div style="width: 100%; display: flex">
|
||||
{% if config.preferences %}
|
||||
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||
{% endif %}
|
||||
<input
|
||||
id="search-bar"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class="search-bar-desktop search-bar-input"
|
||||
name="q"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value="{{ clean_query(query) }}"
|
||||
dir="auto">
|
||||
<input
|
||||
id="search-bar"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class="search-bar-desktop noHIxc"
|
||||
name="q"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value="{{ query }}"
|
||||
style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;
|
||||
color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};
|
||||
border-bottom: {{ '2px solid var(--whoogle-dark-element-bg)' if config.dark else '0px' }};">
|
||||
<input name="tbm" value="{{ search_type }}" style="display: none">
|
||||
<input name="country" value="{{ config.country }}" style="display: none;">
|
||||
<input name="tbs" value="{{ config.tbs }}" style="display: none;">
|
||||
<input type="submit" style="display: none;">
|
||||
<div class="sc"></div>
|
||||
</div>
|
||||
@ -97,66 +66,6 @@
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<div>
|
||||
<div class="header-tab-div">
|
||||
<div class="header-tab-div-2">
|
||||
<div class="header-tab-div-3">
|
||||
<div class="desktop-header header-tab">
|
||||
{% for tab_id, tab_content in tabs.items() %}
|
||||
{% if tab_content['selected'] %}
|
||||
<span class="header-tab-span">{{ tab_content['name'] }}</span>
|
||||
{% else %}
|
||||
<a class="header-tab-a" href="{{ tab_content['href'] }}">{{ tab_content['name'] }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<label for="adv-search-toggle" id="adv-search-label" class="adv-search">⚙</label>
|
||||
<input id="adv-search-toggle" type="checkbox">
|
||||
<div class="header-tab-div-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="" id="s">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="result-collapsible" id="adv-search-div">
|
||||
<div class="result-config">
|
||||
<label for="config-country">{{ translation['config-country'] }}: </label>
|
||||
<select name="country" id="result-country">
|
||||
{% for country in countries %}
|
||||
<option value="{{ country.value }}"
|
||||
{% if (
|
||||
config.country != '' and config.country in country.value
|
||||
) or (
|
||||
config.country == '' and country.value == '')
|
||||
%}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ country.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<br />
|
||||
<label for="config-time-period">{{ translation['config-time-period'] }}: </label>
|
||||
<select name="tbs" id="result-time-period">
|
||||
{% for time_period in time_periods %}
|
||||
<option value="{{ time_period.value }}"
|
||||
{% if (
|
||||
config.tbs != '' and config.tbs in time_period.value
|
||||
) or (
|
||||
config.tbs == '' and time_period.value == '')
|
||||
%}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ translation[time_period.value] }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if bundle_static() %}
|
||||
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||
{% else %}
|
||||
<script type="text/javascript" src="{{ cb_url('header.js') }}"></script>
|
||||
{% endif %}
|
||||
<script type="text/javascript" src="static/js/header.js"></script>
|
||||
|
||||
@ -1,409 +1,116 @@
|
||||
<div>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta content="application/xhtml+xml; charset=utf-8" http-equiv="Content-Type"/>
|
||||
<meta content="no-cache" name="Cache-Control"/>
|
||||
<title>
|
||||
</title>
|
||||
<style>
|
||||
html {
|
||||
font-family: Roboto, Helvetica Neue, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-size-adjust: 100%;
|
||||
color: #3c4043;
|
||||
word-wrap: break-word;
|
||||
background-color: #fff;
|
||||
}
|
||||
body {
|
||||
padding: 0 12px;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a img {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.FbhRzb {
|
||||
border-left: thin solid #dadce0;
|
||||
border-right: thin solid #dadce0;
|
||||
border-top: thin solid #dadce0;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.n692Zd {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cvifge {
|
||||
height: 40px;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.QvGUP {
|
||||
height: 40px;
|
||||
padding: 0 8px 0 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.O4cRJf {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
padding-right: 16px;
|
||||
}
|
||||
.O1ePr {
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.kgJEQe {
|
||||
height: 36px;
|
||||
width: 98px;
|
||||
vertical-align: top;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.lXLRf {
|
||||
vertical-align: top;
|
||||
}
|
||||
.MhzMZd {
|
||||
border: 0;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.xB0fq {
|
||||
height: 40px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
background-color: #4285f4;
|
||||
color: #fff;
|
||||
padding: 0 16px;
|
||||
margin: 0;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xB0fq:focus {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
.M7pB2 {
|
||||
border: thin solid #dadce0;
|
||||
margin: 0 0 3px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
height: 40px;
|
||||
}
|
||||
.euZec {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
border-spacing: 0;
|
||||
}
|
||||
table.euZec td {
|
||||
padding: 0;
|
||||
width: 25%;
|
||||
}
|
||||
.QIqI7 {
|
||||
display: inline-block;
|
||||
padding-top: 4px;
|
||||
font-weight: bold;
|
||||
color: #4285f4;
|
||||
}
|
||||
.EY24We {
|
||||
border-bottom: 2px solid #4285f4;
|
||||
}
|
||||
.CsQyDc {
|
||||
display: inline-block;
|
||||
color: #70757a;
|
||||
}
|
||||
.TuS8Ad {
|
||||
font-size: 14px;
|
||||
}
|
||||
.HddGcc {
|
||||
padding: 8px;
|
||||
color: #70757a;
|
||||
}
|
||||
.dzp8ae {
|
||||
font-weight: bold;
|
||||
color: #3c4043;
|
||||
}
|
||||
.rEM8G {
|
||||
color: #70757a;
|
||||
}
|
||||
.bookcf {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
.InWNIe {
|
||||
text-align: center;
|
||||
}
|
||||
.uZgmoc {
|
||||
border: thin solid #dadce0;
|
||||
color: #70757a;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
.frGj1b {
|
||||
display: block;
|
||||
padding: 12px 0 12px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.BnJWBc {
|
||||
text-align: center;
|
||||
padding: 6px 0 13px 0;
|
||||
height: 35px;
|
||||
}
|
||||
.e3goi {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
.GpQGbf {
|
||||
margin: auto;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.X6ZCif {
|
||||
color: #202124;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
display: inline-block;
|
||||
padding-top: 2px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.TwVfHd {
|
||||
border-radius: 16px;
|
||||
border: thin solid #dadce0;
|
||||
display: inline-block;
|
||||
padding: 8px 8px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.yekiAe {
|
||||
background-color: #dadce0;
|
||||
}
|
||||
.svla5d {
|
||||
width: 100%;
|
||||
}
|
||||
.ezO2md {
|
||||
border: thin solid #dadce0;
|
||||
padding: 12px 16px 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.TxbwNb {
|
||||
border-spacing: 0;
|
||||
}
|
||||
.K35ahc {
|
||||
width: 100%;
|
||||
}
|
||||
.owohpf {
|
||||
text-align: center;
|
||||
}
|
||||
.RAyV4b {
|
||||
height: 220px;
|
||||
line-height: 220px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
.t0fcAb {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
vertical-align: middle;
|
||||
object-fit: cover;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 220px;
|
||||
display: block;
|
||||
}
|
||||
.Tor4Ec {
|
||||
padding-top: 2px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.fYyStc {
|
||||
word-break: break-word;
|
||||
}
|
||||
.ynsChf {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.Fj3V3b {
|
||||
color: #1967d2;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.FrIlee {
|
||||
color: #202124;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.F9iS2e {
|
||||
color: #70757a;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.WMQ2Le {
|
||||
color: #70757a;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.x3G5ab {
|
||||
color: #202124;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.fuLhoc {
|
||||
color: #1967d2;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.epoveb {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: 400;
|
||||
color: #202124;
|
||||
}
|
||||
.dXDvrc {
|
||||
color: #0d652d;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.dloBPe {
|
||||
font-weight: bold;
|
||||
}
|
||||
.YVIcad {
|
||||
color: #70757a;
|
||||
}
|
||||
.JkVVdd {
|
||||
color: #ea4335;
|
||||
}
|
||||
.oXZRFd {
|
||||
color: #ea4335;
|
||||
}
|
||||
.MQHtg {
|
||||
color: #fbbc04;
|
||||
}
|
||||
.pyMRrb {
|
||||
color: #1e8e3e;
|
||||
}
|
||||
.EtTZid {
|
||||
color: #1e8e3e;
|
||||
}
|
||||
.M3vVJe {
|
||||
color: #1967d2;
|
||||
}
|
||||
.qXLe6d {
|
||||
display: block;
|
||||
}
|
||||
.NHQNef {
|
||||
font-style: italic;
|
||||
}
|
||||
.Cb8Z7c {
|
||||
white-space: pre;
|
||||
}
|
||||
a.ZWRArf {
|
||||
text-decoration: none;
|
||||
}
|
||||
a .CVA68e:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.e3goi {
|
||||
width: 25%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.svla5d {
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.e3goi {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.e3goi {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
a{text-decoration:none;color:inherit}a:hover{text-decoration:underline}a img{border:0}body{font-family:Roboto,Helvetica,Arial,sans-serif;padding:8px;margin:0 auto;max-width:700px;min-width:240px;}.FbhRzb{border-left:thin solid #dadce0;border-right:thin solid #dadce0;border-top:thin solid #dadce0;height:40px;overflow:hidden}.n692Zd{margin-bottom:10px}.cvifge{height:40px;border-spacing:0;width:100%;}.QvGUP{height:40px;padding:0 8px 0 8px;vertical-align:top}.O4cRJf{height:40px;width:100%;padding:0;padding-right:16px}.O1ePr{height:40px;padding:0;vertical-align:top}.kgJEQe{height:36px;width:98px;vertical-align:top;margin-top:4px}.lXLRf{vertical-align:top}.MhzMZd{border:0;vertical-align:middle;font-size:14px;height:40px;padding:0;width:100%;padding-left:16px}.xB0fq{height:40px;border:none;font-size:14px;background-color:#4285f4;color:#fff;padding:0 16px;margin:0;vertical-align:top;cursor:pointer}.xB0fq:focus{border:1px solid #000}.M7pB2{border:thin solid #dadce0;margin:0 0 3px 0;font-size:13px;font-weight:500;height:40px}.euZec{width:100%;height:40px;text-align:center;border-spacing:0}table.euZec td{padding:0;width:25%}.QIqI7{display:inline-block;padding-top:4px;font-weight:bold;color:#4285f4}.EY24We{border-bottom:2px solid #4285f4}.CsQyDc{display:inline-block;color:#70757a}.TuS8Ad{font-size:14px}.HddGcc{padding:8px;color:#70757a}.dzp8ae{font-weight:bold;color:#3c4043}.rEM8G{color:#70757a}.bookcf{table-layout:fixed;width:100%;border-spacing:0}.InWNIe{text-align:center}.uZgmoc{border:thin solid #dadce0;color:#70757a;font-size:14px;text-align:center;table-layout:fixed;width:100%}.frGj1b{display:block;padding:12px 0 12px 0;width:100%}.BnJWBc{text-align:center;padding:6px 0 13px 0;height:35px}.e3goi{vertical-align:top;padding:0;height:180px}.GpQGbf{margin:auto;border-collapse:collapse;border-spacing:0;width:100%}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<style>
|
||||
.X6ZCif{color:#202124;font-size:11px;line-height:16px;display:inline-block;padding-top:2px;overflow:hidden;padding-bottom:4px;width:100%}.TwVfHd{border-radius:16px;border:thin solid #dadce0;display:inline-block;padding:8px 8px;margin-right:8px;margin-bottom:4px}.yekiAe{background-color:#dadce0}.svla5d{width:100%}.ezO2md{border:thin solid #dadce0;padding:12px 16px 12px 16px;margin-bottom:10px;font-family:Roboto,Helvetica,Arial,sans-serif}.lIMUZd{font-family:Roboto,Helvetica,Arial,sans-serif}.TxbwNb{border-spacing:0}.K35ahc{width:100%}.owohpf{text-align:center}.RAyV4b{width:162px;height:140px;line-height:140px;overflow:'hidden';text-align:center;}.t0fcAb{text-align:center;margin:auto;vertical-align:middle;width:100%;height:100%;object-fit: contain}.Tor4Ec{padding-top:2px;padding-bottom:8px;}.fYyStc{word-break:break-word}.ynsChf{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.Fj3V3b{color:#1967D2;font-size:14px;line-height:20px}.FrIlee{color:#202124;font-size:11px;line-height:16px}.F9iS2e{color:#70757a;font-size:11px;line-height:16px}.WMQ2Le{color:#70757a;font-size:12px;line-height:16px}.x3G5ab{color:#202124;font-size:12px;line-height:16px}.fuLhoc{color:#1967D2;font-size:18px;line-height:24px}.epoveb{font-size:32px;line-height:40px;font-weight:400;color:#202124}.dXDvrc{color:#0d652d;font-size:14px;line-height:20px;word-wrap:break-word}.dloBPe{font-weight:bold}.YVIcad{color:#70757a}.JkVVdd{color:#ea4335}.oXZRFd{color:#ea4335}.MQHtg{color:#fbbc04}.pyMRrb{color:#1e8e3e}.EtTZid{color:#1e8e3e}.M3vVJe{color:#1967D2}.qXLe6d{display:block}.NHQNef{font-style:italic}.Cb8Z7c{white-space:pre}a.ZWRArf{text-decoration:none}a .CVA68e:hover{text-decoration:underline}
|
||||
</style>
|
||||
<div class="n692Zd">
|
||||
<div class="BnJWBc">
|
||||
<a class="lXLRf" href="/?safe=off&gbv=1&output=images&ie=UTF-8&tbm=isch&sa=X&ved=0ahUKEwjhh7TZyd_vAhWShf0HHeYzCmsQPAgC">
|
||||
<img alt="Google" class="kgJEQe" src="/images/branding/searchlogo/1x/googlelogo_desk_heirloom_color_150x55dp.gif"/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="FbhRzb">
|
||||
<form action="/search">
|
||||
<input name="safe" type="hidden" value="off"/>
|
||||
<input name="gbv" type="hidden" value="1"/>
|
||||
<input name="ie" type="hidden" value="ISO-8859-1"/>
|
||||
<input name="tbm" type="hidden" value="isch"/>
|
||||
<input name="oq" type="hidden"/>
|
||||
<input name="aqs" type="hidden"/>
|
||||
<table class="cvifge">
|
||||
<tr>
|
||||
<td class="O4cRJf">
|
||||
<!-- search input -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
<div class="M7pB2">
|
||||
<!-- search options -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="X6ZCif"> Not present in mobile
|
||||
</div> -->
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<div class="lIMUZd">
|
||||
<table class="By0U9">
|
||||
<!-- correction suggested -->
|
||||
</table>
|
||||
</div>
|
||||
<div class="lIMUZd">
|
||||
<table class="By0U9">
|
||||
<!-- correction suggested -->
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="GpQGbf">
|
||||
{% for i in range((length // 4) + 1) %}
|
||||
<tr>
|
||||
{% for j in range([length - (i*4), 4]|min) %}
|
||||
<td align="center" class="e3goi">
|
||||
<td align="center" class="e3goi">
|
||||
<div class="svla5d">
|
||||
<div>
|
||||
<div class="lIMUZd">
|
||||
<div>
|
||||
<table class="TxbwNb">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ results[(i*4)+j].web_page }}">
|
||||
<div class="RAyV4b">
|
||||
<img
|
||||
alt=""
|
||||
class="t0fcAb"
|
||||
src="{{ results[(i*4)+j].img_tbn }}"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ results[(i*4)+j].web_page }}">
|
||||
<div class="Tor4Ec">
|
||||
<span class="qXLe6d x3G5ab">
|
||||
<span class="fYyStc">
|
||||
{{ results[(i*4)+j].domain }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="{{ results[(i*4)+j].img_url }}">
|
||||
<div class="Tor4Ec">
|
||||
<span class="qXLe6d F9iS2e">
|
||||
<span class="fYyStc"> {{ view_label }} </span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="lIMUZd">
|
||||
<div>
|
||||
<table class="TxbwNb">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ results[(i*4)+j].webpage }}">
|
||||
<div class="RAyV4b">
|
||||
<img alt="" class="t0fcAb" src="{{ results[(i*4)+j].img_tbn }}"/>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ results[(i*4)+j].webpage }}">
|
||||
<div class="Tor4Ec">
|
||||
<span class="qXLe6d x3G5ab">
|
||||
<span class="fYyStc">
|
||||
{{ results[(i*4)+j].domain }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="{{ results[(i*4)+j].img_url }}">
|
||||
<div class="Tor4Ec">
|
||||
<span class="qXLe6d F9iS2e">
|
||||
<span class="fYyStc">
|
||||
{{ view_label }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<table class="uZgmoc">
|
||||
<!-- next page object -->
|
||||
</table>
|
||||
<br />
|
||||
</div>
|
||||
<br/>
|
||||
<div class="TuS8Ad">
|
||||
<!-- information about user connection -->
|
||||
<div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,323 +1,181 @@
|
||||
<html style="background: #000;">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="static/img/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="static/img/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="static/img/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="static/img/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="static/img/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="static/img/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="static/img/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="static/img/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="static/img/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="static/img/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="static/img/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="static/img/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="static/img/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="static/img/favicon/manifest.json">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png">
|
||||
{% if bundle_static() %}
|
||||
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||
{% else %}
|
||||
{% if autocomplete_enabled == '1' %}
|
||||
<script src="{{ cb_url('autocomplete.js') }}"></script>
|
||||
{% endif %}
|
||||
<script type="text/javascript" src="{{ cb_url('controller.js') }}"></script>
|
||||
{% endif %}
|
||||
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% if bundle_static() %}
|
||||
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url('logo.css') }}">
|
||||
{% endif %}
|
||||
{% if config.theme %}
|
||||
{% if config.theme == 'system' %}
|
||||
<style>
|
||||
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||
</style>
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not bundle_static() %}
|
||||
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
|
||||
{% endif %}
|
||||
<noscript>
|
||||
<style>
|
||||
#main {
|
||||
display: inherit !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-height: 400px;
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
<style>{{ config.style }}</style>
|
||||
<title>Whoogle Search</title>
|
||||
</head>
|
||||
<body id="main">
|
||||
<div class="search-container">
|
||||
<div class="logo-container">
|
||||
{{ logo|safe }}
|
||||
</div>
|
||||
<form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}">
|
||||
<div class="search-fields">
|
||||
<div class="autocomplete">
|
||||
{% if config.preferences %}
|
||||
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||
{% endif %}
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
id="search-bar"
|
||||
class="home-search"
|
||||
autofocus="autofocus"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
dir="auto">
|
||||
</div>
|
||||
<input type="submit" id="search-submit" value="{{ translation['search'] }}">
|
||||
</div>
|
||||
</form>
|
||||
{% if not config_disabled %}
|
||||
<br/>
|
||||
<button id="config-collapsible" class="collapsible">{{ translation['config'] }}</button>
|
||||
<div class="content">
|
||||
<div class="config-fields">
|
||||
<form id="config-form" action="config" method="post">
|
||||
<div class="config-options">
|
||||
<div class="config-div config-div-country">
|
||||
<label for="config-country">{{ translation['config-country'] }}: </label>
|
||||
<select name="country" id="config-country">
|
||||
{% for country in countries %}
|
||||
<option value="{{ country.value }}"
|
||||
{% if (
|
||||
config.country != '' and config.country in country.value
|
||||
) or (
|
||||
config.country == '' and country.value == '')
|
||||
%}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ country.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<label for="config-time-period">{{ translation['config-time-period'] }}</label>
|
||||
<select name="tbs" id="config-time-period">
|
||||
{% for time_period in time_periods %}
|
||||
<option value="{{ time_period.value }}"
|
||||
{% if (
|
||||
config.tbs != '' and config.tbs in time_period.value
|
||||
) or (
|
||||
config.tbs == '' and time_period.value == '')
|
||||
%}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ translation[time_period.value] }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div config-div-lang">
|
||||
<label for="config-lang-interface">{{ translation['config-lang'] }}: </label>
|
||||
<select name="lang_interface" id="config-lang-interface">
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang.value }}"
|
||||
{% if lang.value in config.lang_interface %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div config-div-search-lang">
|
||||
<label for="config-lang-search">{{ translation['config-lang-search'] }}: </label>
|
||||
<select name="lang_search" id="config-lang-search">
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang.value }}"
|
||||
{% if lang.value in config.lang_search %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div config-div-near">
|
||||
<label for="config-near">{{ translation['config-near'] }}: </label>
|
||||
<input type="text" name="near" id="config-near"
|
||||
placeholder="{{ translation['config-near-help'] }}" value="{{ config.near }}">
|
||||
</div>
|
||||
<div class="config-div config-div-block">
|
||||
<label for="config-block">{{ translation['config-block'] }}: </label>
|
||||
<input type="text" name="block" id="config-block"
|
||||
placeholder="{{ translation['config-block-help'] }}" value="{{ config.block }}">
|
||||
</div>
|
||||
<div class="config-div config-div-block">
|
||||
<label for="config-block-title">{{ translation['config-block-title'] }}: </label>
|
||||
<input type="text" name="block_title" id="config-block"
|
||||
placeholder="{{ translation['config-block-title-help'] }}"
|
||||
value="{{ config.block_title }}">
|
||||
</div>
|
||||
<div class="config-div config-div-block">
|
||||
<label for="config-block-url">{{ translation['config-block-url'] }}: </label>
|
||||
<input type="text" name="block_url" id="config-block"
|
||||
placeholder="{{ translation['config-block-url-help'] }}" value="{{ config.block_url }}">
|
||||
</div>
|
||||
<div class="config-div config-div-anon-view">
|
||||
<label for="config-anon-view">{{ translation['config-anon-view'] }}: </label>
|
||||
<input type="checkbox" name="anon_view" id="config-anon-view" {{ 'checked' if config.anon_view else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-nojs">
|
||||
<label for="config-nojs">{{ translation['config-nojs'] }}: </label>
|
||||
<input type="checkbox" name="nojs" id="config-nojs" {{ 'checked' if config.nojs else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-theme">
|
||||
<label for="config-theme">{{ translation['config-theme'] }}: </label>
|
||||
<select name="theme" id="config-theme">
|
||||
{% for theme in themes %}
|
||||
<option value="{{ theme }}"
|
||||
{% if theme in config.theme %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ translation[theme].capitalize() }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- DEPRECATED -->
|
||||
<div class="config-div config-div-safe">
|
||||
<label for="config-safe">{{ translation['config-safe'] }}: </label>
|
||||
<input type="checkbox" name="safe" id="config-safe" {{ 'checked' if config.safe else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-alts">
|
||||
<label class="tooltip" for="config-alts">{{ translation['config-alts'] }}: </label>
|
||||
<input type="checkbox" name="alts" id="config-alts" {{ 'checked' if config.alts else '' }}>
|
||||
<div><span class="info-text"> — {{ translation['config-alts-help'] }}</span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-new-tab">
|
||||
<label for="config-new-tab">{{ translation['config-new-tab'] }}: </label>
|
||||
<input type="checkbox" name="new_tab"
|
||||
id="config-new-tab" {{ 'checked' if config.new_tab else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-view-image">
|
||||
<label for="config-view-image">{{ translation['config-images'] }}: </label>
|
||||
<input type="checkbox" name="view_image"
|
||||
id="config-view-image" {{ 'checked' if config.view_image else '' }}>
|
||||
<div><span class="info-text"> — {{ translation['config-images-help'] }}</span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-tor">
|
||||
<label for="config-tor">{{ translation['config-tor'] }}: {{ '' if tor_available else 'Unavailable' }}</label>
|
||||
<input type="checkbox" name="tor"
|
||||
id="config-tor" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-get-only">
|
||||
<label for="config-get-only">{{ translation['config-get-only'] }}: </label>
|
||||
<input type="checkbox" name="get_only"
|
||||
id="config-get-only" {{ 'checked' if config.get_only else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-user-agent">
|
||||
<label for="config-user-agent">User Agent: </label>
|
||||
<select name="user_agent" id="config-user-agent">
|
||||
<option value="env_conf" {% if config.user_agent == 'env_conf' %}selected{% endif %}>Use ENV Conf</option>
|
||||
<option value="default" {% if config.user_agent == 'default' %}selected{% endif %}>Default</option>
|
||||
<option value="custom" {% if config.user_agent == 'custom' %}selected{% endif %}>Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div config-div-custom-user-agent" {% if config.user_agent != 'custom' %}style="display: none;"{% endif %}>
|
||||
<label for="config-custom-user-agent">Custom User Agent: </label>
|
||||
<input type="text" name="custom_user_agent" id="config-custom-user-agent"
|
||||
value="{{ config.custom_user_agent }}"
|
||||
placeholder="Enter custom user agent string">
|
||||
<div><span class="info-text"> — <a href="https://github.com/benbusby/whoogle-search/wiki/User-Agents">User Agent Wiki</a></span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-accept-language">
|
||||
<label for="config-accept-language">Set Accept-Language: </label>
|
||||
<input type="checkbox" name="accept_language"
|
||||
id="config-accept-language" {{ 'checked' if config.accept_language else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-show-user-agent">
|
||||
<label for="config-show-user-agent">Show User Agent in Footer: </label>
|
||||
<input type="checkbox" name="show_user_agent"
|
||||
id="config-show-user-agent" {{ 'checked' if config.show_user_agent else '' }}>
|
||||
</div>
|
||||
<!-- Google Custom Search Engine (BYOK) Settings -->
|
||||
<div class="config-div config-div-cse-header" style="margin-top: 20px; border-top: 1px solid var(--result-bg); padding-top: 15px;">
|
||||
<strong>Google Custom Search (BYOK)</strong>
|
||||
<div><span class="info-text"> — <a href="https://github.com/benbusby/whoogle-search#google-custom-search-byok">Setup Guide</a></span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-use-cse">
|
||||
<label for="config-use-cse">Use Custom Search API: </label>
|
||||
<input type="checkbox" name="use_cse" id="config-use-cse" {{ 'checked' if config.use_cse else '' }}>
|
||||
<div><span class="info-text"> — Enable to use your own Google API key (100 free queries/day)</span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-cse-api-key">
|
||||
<label for="config-cse-api-key">CSE API Key: </label>
|
||||
<input type="password" name="cse_api_key" id="config-cse-api-key"
|
||||
value="{{ config.cse_api_key }}"
|
||||
placeholder="AIza..."
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="config-div config-div-cse-id">
|
||||
<label for="config-cse-id">CSE ID: </label>
|
||||
<input type="text" name="cse_id" id="config-cse-id"
|
||||
value="{{ config.cse_id }}"
|
||||
placeholder="abc123..."
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="config-div config-div-root-url">
|
||||
<label for="config-url">{{ translation['config-url'] }}: </label>
|
||||
<input type="text" name="url" id="config-url" value="{{ config.url }}">
|
||||
</div>
|
||||
<div class="config-div config-div-custom-css">
|
||||
<a id="css-link"
|
||||
href="https://github.com/benbusby/whoogle-search/wiki/User-Contributed-CSS-Themes">
|
||||
{{ translation['config-css'] }}:
|
||||
</a>
|
||||
<textarea
|
||||
name="style_modified"
|
||||
id="config-style"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
value="">{{ config.style_modified.replace('\t', '') }}</textarea>
|
||||
</div>
|
||||
<div class="config-div config-div-pref-url">
|
||||
<label for="config-pref-encryption">{{ translation['config-pref-encryption'] }}: </label>
|
||||
<input type="checkbox" name="preferences_encrypted"
|
||||
id="config-pref-encryption" {{ 'checked' if config.preferences_encrypted and config.preferences_key else '' }}>
|
||||
<div><span class="info-text"> — {{ translation['config-pref-help'] }}</span></div>
|
||||
<label for="config-pref-url">{{ translation['config-pref-url'] }}: </label>
|
||||
<input type="text" name="pref-url" id="config-pref-url" value="{{ config.url }}?preferences={{ config.preferences }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-div config-buttons">
|
||||
<input type="submit" id="config-load" value="{{ translation['load'] }}">
|
||||
<input type="submit" id="config-submit" value="{{ translation['apply'] }}">
|
||||
<input type="submit" id="config-save" value="{{ translation['save-as'] }}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'footer.html' %}
|
||||
</body>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="static/img/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="static/img/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="static/img/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="static/img/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="static/img/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="static/img/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="static/img/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="static/img/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="static/img/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="static/img/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="static/img/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="static/img/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="static/img/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="static/img/favicon/manifest.json">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png">
|
||||
<script type="text/javascript" src="static/js/autocomplete.js"></script>
|
||||
<script type="text/javascript" src="static/js/controller.js"></script>
|
||||
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="static/css/variables.css">
|
||||
<link rel="stylesheet" href="static/css/main.css">
|
||||
<link rel="stylesheet" href="static/css/{{ 'dark' if config.dark else 'light' }}-theme.css"/>
|
||||
<noscript>
|
||||
<style>
|
||||
#main { display: inherit !important; }
|
||||
.content { max-height: 720px; padding: 18px; border-radius: 10px; }
|
||||
.collapsible { display: none; }
|
||||
</style>
|
||||
</noscript>
|
||||
<style>{{ config.style }}</style>
|
||||
<title>Whoogle Search</title>
|
||||
</head>
|
||||
<body id="main" style="display: none; background-color: {{ '#000' if config.dark else '#fff' }}">
|
||||
<div class="search-container">
|
||||
<div class="logo-container">
|
||||
{{ logo|safe }}
|
||||
</div>
|
||||
<form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}">
|
||||
<div class="search-fields">
|
||||
<div class="autocomplete">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
id="search-bar"
|
||||
class="home-search"
|
||||
autofocus="autofocus"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<input type="submit" id="search-submit" value="{{ translation['search'] }}">
|
||||
</div>
|
||||
</form>
|
||||
{% if not config_disabled %}
|
||||
<br/>
|
||||
<button id="config-collapsible" class="collapsible">{{ translation['config'] }}</button>
|
||||
<div class="content">
|
||||
<div class="config-fields">
|
||||
<form id="config-form" action="config" method="post">
|
||||
<div class="config-div config-div-ctry">
|
||||
<label for="config-ctry">{{ translation['config-country'] }}: </label>
|
||||
<select name="ctry" id="config-ctry">
|
||||
{% for ctry in countries %}
|
||||
<option value="{{ ctry.value }}"
|
||||
{% if ctry.value in config.ctry %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ ctry.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div><span class="info-text"> — {{ translation['config-country-help'] }}</span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-lang">
|
||||
<label for="config-lang-interface">{{ translation['config-lang'] }}: </label>
|
||||
<select name="lang_interface" id="config-lang-interface">
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang.value }}"
|
||||
{% if lang.value in config.lang_interface %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div config-div-search-lang">
|
||||
<label for="config-lang-search">{{ translation['config-lang-search'] }}: </label>
|
||||
<select name="lang_search" id="config-lang-search">
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang.value }}"
|
||||
{% if lang.value in config.lang_search %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div config-div-near">
|
||||
<label for="config-near">{{ translation['config-near'] }}: </label>
|
||||
<input type="text" name="near" id="config-near" placeholder="City Name" value="{{ config.near }}">
|
||||
</div>
|
||||
<div class="config-div config-div-block">
|
||||
<label for="config-block">{{ translation['config-block'] }}: </label>
|
||||
<input type="text" name="block" id="config-block" placeholder="Comma-separated site list" value="{{ config.block }}">
|
||||
</div>
|
||||
<div class="config-div config-div-nojs">
|
||||
<label for="config-nojs">{{ translation['config-nojs'] }}: </label>
|
||||
<input type="checkbox" name="nojs" id="config-nojs" {{ 'checked' if config.nojs else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-dark">
|
||||
<label for="config-dark">{{ translation['config-dark'] }}: </label>
|
||||
<input type="checkbox" name="dark" id="config-dark" {{ 'checked' if config.dark else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-safe">
|
||||
<label for="config-safe">{{ translation['config-safe'] }}: </label>
|
||||
<input type="checkbox" name="safe" id="config-safe" {{ 'checked' if config.safe else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-alts">
|
||||
<label class="tooltip" for="config-alts">{{ translation['config-alts'] }}: </label>
|
||||
<input type="checkbox" name="alts" id="config-alts" {{ 'checked' if config.alts else '' }}>
|
||||
<div><span class="info-text"> — {{ translation['config-alts-help'] }}</span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-new-tab">
|
||||
<label for="config-new-tab">{{ translation['config-new-tab'] }}: </label>
|
||||
<input type="checkbox" name="new_tab" id="config-new-tab" {{ 'checked' if config.new_tab else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-view-image">
|
||||
<label for="config-view-image">{{ translation['config-images'] }}: </label>
|
||||
<input type="checkbox" name="view_image" id="config-view-image" {{ 'checked' if config.view_image else '' }}>
|
||||
<div><span class="info-text"> — {{ translation['config-images-help'] }}</span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-tor">
|
||||
<label for="config-tor">{{ translation['config-tor'] }}: {{ '' if tor_available else 'Unavailable' }}</label>
|
||||
<input type="checkbox" name="tor" id="config-tor" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-get-only">
|
||||
<label for="config-get-only">{{ translation['config-get-only'] }}: </label>
|
||||
<input type="checkbox" name="get_only" id="config-get-only" {{ 'checked' if config.get_only else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-root-url">
|
||||
<label for="config-url">{{ translation['config-url'] }}: </label>
|
||||
<input type="text" name="url" id="config-url" value="{{ config.url }}">
|
||||
</div>
|
||||
<div class="config-div config-div-custom-css">
|
||||
<label for="config-style">{{ translation['config-css'] }}:</label>
|
||||
<textarea
|
||||
name="style"
|
||||
id="config-style"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
value="">
|
||||
{{ config.style }}
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<input type="submit" id="config-load" value="{{ translation['load'] }}">
|
||||
<input type="submit" id="config-submit" value="{{ translation['apply'] }}">
|
||||
<input type="submit" id="config-save" value="{{ translation['save-as'] }}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<footer>
|
||||
<p style="color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};">
|
||||
Whoogle Search v{{ version_number }} ||
|
||||
<a id="gh-link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
<link rel="stylesheet" href="static/css/logo.css">
|
||||
<svg id="Layer_1" class="whoogle-svg" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1028 254">
|
||||
<style>
|
||||
path {
|
||||
fill: {{ 'var(--whoogle-dark-logo)' if dark else 'var(--whoogle-logo)' }};
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<style>
|
||||
</style>
|
||||
@ -16,3 +22,4 @@
|
||||
<path class="cls-1" d="M950.51,539.43c-.31,20.82-10.91,37.89-28,44.71-25.32,10.11-53.89-7-57.87-34.41-1.51-10.43-1.06-20.59,2.68-30.44,7.08-18.66,25.09-29.59,45-27.58,17.76,1.79,33.92,17.68,36.86,36.35C949.79,531.82,950.08,535.64,950.51,539.43Z" transform="translate(-446 -413)"></path>
|
||||
<path class="cls-1" d="M1099.71,539.39c-.39,22.14-11.74,39.51-30.16,45.6-25.8,8.54-53.64-10.27-55.87-37.67-.78-9.54-.55-18.93,3-28,7.25-18.72,24.95-29.59,45-27.62,17.2,1.68,33.14,16.78,36.57,34.84C1099,530.77,1099.23,535.1,1099.71,539.39Z" transform="translate(-446 -413)"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.6 KiB |
File diff suppressed because one or more lines are too long
@ -1,4 +1,4 @@
|
||||
<form id="search-form" action="search" method="post">
|
||||
<form id="search-form" action="{{ url }}/search" method="post">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
@ -8,7 +8,6 @@
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
placeholder="Whoogle Search"
|
||||
autocomplete="off"
|
||||
dir="auto">
|
||||
autocomplete="off">
|
||||
<input type="submit" style="width: 9%" id="search-submit" value="Search">
|
||||
</form>
|
||||
|
||||
@ -1,57 +1,7 @@
|
||||
import json
|
||||
import httpx
|
||||
import urllib.parse as urlparse
|
||||
import os
|
||||
import glob
|
||||
import requests
|
||||
|
||||
bangs_dict = {}
|
||||
DDG_BANGS = 'https://duckduckgo.com/bang.js'
|
||||
|
||||
|
||||
def load_all_bangs(ddg_bangs_file: str, ddg_bangs: dict = {}):
|
||||
"""Loads all the bang files in alphabetical order
|
||||
|
||||
Args:
|
||||
ddg_bangs_file: The str path to the new DDG bangs json file
|
||||
ddg_bangs: The dict of ddg bangs. If this is empty, it will load the
|
||||
bangs from the file
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
global bangs_dict
|
||||
ddg_bangs_file = os.path.normpath(ddg_bangs_file)
|
||||
|
||||
if (bangs_dict and not ddg_bangs) or os.path.getsize(ddg_bangs_file) <= 4:
|
||||
return
|
||||
|
||||
bangs = {}
|
||||
bangs_dir = os.path.dirname(ddg_bangs_file)
|
||||
bang_files = glob.glob(os.path.join(bangs_dir, '*.json'))
|
||||
|
||||
# Normalize the paths
|
||||
bang_files = [os.path.normpath(f) for f in bang_files]
|
||||
|
||||
# Move the ddg bangs file to the beginning
|
||||
bang_files = sorted([f for f in bang_files if f != ddg_bangs_file])
|
||||
|
||||
if ddg_bangs:
|
||||
bangs |= ddg_bangs
|
||||
else:
|
||||
bang_files.insert(0, ddg_bangs_file)
|
||||
|
||||
for i, bang_file in enumerate(bang_files):
|
||||
try:
|
||||
with open(bang_file, 'r', encoding='utf-8') as f:
|
||||
bangs |= json.load(f)
|
||||
except json.decoder.JSONDecodeError:
|
||||
# Ignore decoding error only for the ddg bangs file, since this can
|
||||
# occur if file is still being written
|
||||
if i != 0:
|
||||
raise
|
||||
|
||||
bangs_dict = dict(sorted(bangs.items()))
|
||||
DDG_BANGS = 'https://duckduckgo.com/bang.v255.js'
|
||||
|
||||
|
||||
def gen_bangs_json(bangs_file: str) -> None:
|
||||
@ -64,9 +14,12 @@ def gen_bangs_json(bangs_file: str) -> None:
|
||||
None
|
||||
|
||||
"""
|
||||
# Request full list from DDG
|
||||
r = httpx.get(DDG_BANGS)
|
||||
r.raise_for_status()
|
||||
try:
|
||||
# Request full list from DDG
|
||||
r = requests.get(DDG_BANGS)
|
||||
r.raise_for_status()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
raise SystemExit(err)
|
||||
|
||||
# Convert to json
|
||||
data = json.loads(r.text)
|
||||
@ -81,70 +34,28 @@ def gen_bangs_json(bangs_file: str) -> None:
|
||||
'suggestion': bang_command + ' (' + row['s'] + ')'
|
||||
}
|
||||
|
||||
with open(bangs_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(bangs_data, f)
|
||||
print('* Finished creating ddg bangs json')
|
||||
load_all_bangs(bangs_file, bangs_data)
|
||||
json.dump(bangs_data, open(bangs_file, 'w'))
|
||||
|
||||
|
||||
def suggest_bang(query: str) -> list[str]:
|
||||
"""Suggests bangs for a user's query
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
|
||||
Returns:
|
||||
list[str]: A list of bang suggestions
|
||||
|
||||
"""
|
||||
global bangs_dict
|
||||
return [bangs_dict[_]['suggestion'] for _ in bangs_dict if _.startswith(query)]
|
||||
|
||||
|
||||
def resolve_bang(query: str) -> str:
|
||||
def resolve_bang(query: str, bangs_dict: dict) -> str:
|
||||
"""Transform's a user's query to a bang search, if an operator is found
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
bangs_dict: The dict of available bang operators, with corresponding
|
||||
format string search URLs
|
||||
(i.e. "!w": "https://en.wikipedia.org...?search={}")
|
||||
|
||||
Returns:
|
||||
str: A formatted redirect for a bang search, or an empty str if there
|
||||
wasn't a match or didn't contain a bang operator
|
||||
|
||||
"""
|
||||
global bangs_dict
|
||||
split_query = query.split(' ')
|
||||
for operator in bangs_dict.keys():
|
||||
if operator not in split_query:
|
||||
continue
|
||||
|
||||
#if ! not in query simply return (speed up processing)
|
||||
if '!' not in query:
|
||||
return ''
|
||||
|
||||
split_query = query.strip().split(' ')
|
||||
|
||||
# look for operator in query if one is found, list operator should be of
|
||||
# length 1, operator should not be case-sensitive here to remove it later
|
||||
operator = [
|
||||
word
|
||||
for word in split_query
|
||||
if word.lower() in bangs_dict
|
||||
]
|
||||
if len(operator) == 1:
|
||||
# get operator
|
||||
operator = operator[0]
|
||||
|
||||
# removes operator from query
|
||||
split_query.remove(operator)
|
||||
|
||||
# rebuild the query string
|
||||
bang_query = ' '.join(split_query).strip()
|
||||
|
||||
# Check if operator is a key in bangs and get bang if exists
|
||||
bang = bangs_dict.get(operator.lower(), None)
|
||||
if bang:
|
||||
bang_url = bang['url']
|
||||
|
||||
if bang_query:
|
||||
return bang_url.replace('{}', bang_query, 1)
|
||||
else:
|
||||
parsed_url = urlparse.urlparse(bang_url)
|
||||
return f'{parsed_url.scheme}://{parsed_url.netloc}'
|
||||
return bangs_dict[operator]['url'].format(
|
||||
query.replace(operator, '').strip())
|
||||
return ''
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
from bs4 import BeautifulSoup as bsoup
|
||||
from cryptography.fernet import Fernet
|
||||
from flask import Request
|
||||
|
||||
ddg_favicon_site = 'http://icons.duckduckgo.com/ip2'
|
||||
|
||||
empty_gif = base64.b64decode(
|
||||
'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')
|
||||
|
||||
placeholder_img = base64.b64decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABF0lEQVRIS8XWPw9EMBQA8Eok' \
|
||||
'JBKrMFqMBt//GzAYLTZ/VomExPDu6uLiaPteqVynBn0/75W2Vp7nEIYhe6p1XcespmmAd7Is' \
|
||||
'M+4URcGiKPogvMMvmIS2eN9MOMKbKWgf54SYgI4vKkTuQKJKSJErkKzUSkQHUs0lilAg7GMh' \
|
||||
'ISoIA/hYMiKCKIA2soeowCWEMkfHtUmrXLcyGYYBfN9HF8djiaglWzNZlgVs21YisoAUaEXG' \
|
||||
'cQTP86QIFgi7vyLzPIPjOEIEC7ANQv/4aZrAdd0TUtc1i+MYnSsMWjPp+x6CIPgJVlUVS5KE' \
|
||||
'DKig/+wnVzM4pnzaGeHd+ENlWbI0TbVLJBtw2uMfP63wc9d2kDCWxi5Q27bsBerSJ9afJbeL' \
|
||||
'AAAAAElFTkSuQmCC'
|
||||
)
|
||||
|
||||
|
||||
def fetch_favicon(url: str) -> bytes:
|
||||
"""Fetches a favicon using DuckDuckGo's favicon retriever
|
||||
|
||||
Args:
|
||||
url: The url to fetch the favicon from
|
||||
Returns:
|
||||
bytes - the favicon bytes, or a placeholder image if one
|
||||
was not returned
|
||||
"""
|
||||
response = httpx.get(f'{ddg_favicon_site}/{urlparse(url).netloc}.ico')
|
||||
|
||||
if response.status_code == 200 and len(response.content) > 0:
|
||||
tmp_mem = io.BytesIO()
|
||||
tmp_mem.write(response.content)
|
||||
tmp_mem.seek(0)
|
||||
|
||||
return tmp_mem.read()
|
||||
return placeholder_img
|
||||
|
||||
|
||||
def gen_file_hash(path: str, static_file: str) -> str:
|
||||
with open(os.path.join(path, static_file), 'rb') as f:
|
||||
file_contents = f.read()
|
||||
file_hash = hashlib.md5(file_contents).hexdigest()[:8]
|
||||
filename_split = os.path.splitext(static_file)
|
||||
|
||||
return f'{filename_split[0]}.{file_hash}{filename_split[-1]}'
|
||||
|
||||
|
||||
def read_config_bool(var: str, default: bool=False) -> bool:
|
||||
val = os.getenv(var, '1' if default else '0')
|
||||
# user can specify one of the following values as 'true' inputs (all
|
||||
# variants with upper case letters will also work):
|
||||
# ('true', 't', '1', 'yes', 'y')
|
||||
return val.lower() in ('true', 't', '1', 'yes', 'y')
|
||||
|
||||
|
||||
def get_client_ip(r: Request) -> str:
|
||||
if r.environ.get('HTTP_X_FORWARDED_FOR') is None:
|
||||
return r.environ['REMOTE_ADDR']
|
||||
|
||||
return r.environ['HTTP_X_FORWARDED_FOR']
|
||||
|
||||
|
||||
def get_request_url(url: str) -> str:
|
||||
if os.getenv('HTTPS_ONLY', False):
|
||||
return url.replace('http://', 'https://', 1)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def get_proxy_host_url(r: Request, default: str, root=False) -> str:
|
||||
scheme = r.headers.get('X-Forwarded-Proto', 'https')
|
||||
http_host = r.headers.get('X-Forwarded-Host')
|
||||
|
||||
full_path = r.full_path if not root else ''
|
||||
if full_path.startswith('/'):
|
||||
full_path = f'/{full_path}'
|
||||
|
||||
if http_host:
|
||||
prefix = os.environ.get('WHOOGLE_URL_PREFIX', '')
|
||||
if prefix:
|
||||
prefix = f'/{re.sub("[^0-9a-zA-Z]+", "", prefix)}'
|
||||
return f'{scheme}://{http_host}{prefix}{full_path}'
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def check_for_update(version_url: str, current: str) -> int:
|
||||
# Check for the latest version of Whoogle
|
||||
has_update = ''
|
||||
with contextlib.suppress(httpx.RequestError, AttributeError):
|
||||
update = bsoup(httpx.get(version_url).text, 'html.parser')
|
||||
latest = update.select_one('[class="Link--primary"]').string[1:]
|
||||
current = int(''.join(filter(str.isdigit, current)))
|
||||
latest = int(''.join(filter(str.isdigit, latest)))
|
||||
has_update = '' if current >= latest else latest
|
||||
|
||||
return has_update
|
||||
|
||||
|
||||
def get_abs_url(url, page_url):
|
||||
# Creates a valid absolute URL using a partial or relative URL
|
||||
urls = {
|
||||
"//": f"https:{url}",
|
||||
"/": f"{urlparse(page_url).netloc}{url}",
|
||||
"./": f"{page_url}{url[2:]}"
|
||||
}
|
||||
for start in urls:
|
||||
if url.startswith(start):
|
||||
return urls[start]
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def list_to_dict(lst: list) -> dict:
|
||||
if len(lst) < 2:
|
||||
return {}
|
||||
return {lst[i].replace(' ', ''): lst[i+1].replace(' ', '')
|
||||
for i in range(0, len(lst), 2)}
|
||||
|
||||
|
||||
def encrypt_string(key: bytes, string: str) -> str:
|
||||
cipher_suite = Fernet(key)
|
||||
return cipher_suite.encrypt(string.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_string(key: bytes, string: str) -> str:
|
||||
cipher_suite = Fernet(g.session_key)
|
||||
return cipher_suite.decrypt(string.encode()).decode()
|
||||
@ -1,126 +1,34 @@
|
||||
from app.models.config import Config
|
||||
from app.models.endpoint import Endpoint
|
||||
from app.utils.misc import list_to_dict
|
||||
from bs4 import BeautifulSoup, NavigableString, MarkupResemblesLocatorWarning
|
||||
import warnings
|
||||
import copy
|
||||
from flask import current_app
|
||||
import html
|
||||
from bs4 import BeautifulSoup
|
||||
import os
|
||||
import urllib.parse as urlparse
|
||||
from urllib.parse import parse_qs
|
||||
import re
|
||||
warnings.filterwarnings('ignore', category=MarkupResemblesLocatorWarning)
|
||||
|
||||
|
||||
SKIP_ARGS = ['ref_src', 'utm']
|
||||
SKIP_PREFIX = ['//www.', '//mobile.', '//m.']
|
||||
GOOG_STATIC = 'www.gstatic.com'
|
||||
G_M_LOGO_URL = 'https://www.gstatic.com/m/images/icons/googleg.gif'
|
||||
GOOG_IMG = '/images/branding/searchlogo/1x/googlelogo'
|
||||
LOGO_URL = GOOG_IMG + '_desk'
|
||||
BLANK_B64 = ('data:image/png;base64,'
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkw'
|
||||
'AIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC')
|
||||
|
||||
|
||||
# Ad keywords
|
||||
BLACKLIST = [
|
||||
'ad', 'ads', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告',
|
||||
'Reklama', 'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan',
|
||||
'広告', 'Augl.', 'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन',
|
||||
'Reklam', 'آگهی', 'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés',
|
||||
'Anúncio', 'Quảng cáo', 'โฆษณา', 'sponsored', 'patrocinado', 'gesponsert',
|
||||
'Sponzorováno', '스폰서', 'Gesponsord', 'Sponsorisé'
|
||||
'ad', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告', 'Reklama',
|
||||
'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan', '広告', 'Augl.',
|
||||
'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन', 'Reklam', 'آگهی',
|
||||
'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés', 'Anúncio'
|
||||
]
|
||||
|
||||
SITE_ALTS = {
|
||||
'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'farside.link/nitter'),
|
||||
'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'farside.link/invidious'),
|
||||
'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'farside.link/libreddit'),
|
||||
**dict.fromkeys([
|
||||
'medium.com',
|
||||
'levelup.gitconnected.com'
|
||||
], os.getenv('WHOOGLE_ALT_MD', 'farside.link/scribe')),
|
||||
'imgur.com': os.getenv('WHOOGLE_ALT_IMG', 'farside.link/rimgo'),
|
||||
'wikipedia.org': os.getenv('WHOOGLE_ALT_WIKI', 'farside.link/wikiless'),
|
||||
'imdb.com': os.getenv('WHOOGLE_ALT_IMDB', 'farside.link/libremdb'),
|
||||
'quora.com': os.getenv('WHOOGLE_ALT_QUORA', 'farside.link/quetre'),
|
||||
'stackoverflow.com': os.getenv('WHOOGLE_ALT_SO', 'farside.link/anonymousoverflow')
|
||||
'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'nitter.net'),
|
||||
'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'invidious.snopyta.org'),
|
||||
'instagram.com': os.getenv('WHOOGLE_ALT_IG', 'bibliogram.art/u'),
|
||||
'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'libredd.it')
|
||||
}
|
||||
|
||||
# Include custom site redirects from WHOOGLE_REDIRECTS
|
||||
SITE_ALTS.update(list_to_dict(re.split(',|:', os.getenv('WHOOGLE_REDIRECTS', ''))))
|
||||
|
||||
|
||||
def contains_cjko(s: str) -> bool:
|
||||
"""This function check whether or not a string contains Chinese, Japanese,
|
||||
or Korean characters. It employs regex and uses the u escape sequence to
|
||||
match any character in a set of Unicode ranges.
|
||||
|
||||
Args:
|
||||
s (str): string to be checked
|
||||
|
||||
Returns:
|
||||
bool: True if the input s contains the characters and False otherwise
|
||||
"""
|
||||
unicode_ranges = ('\u4e00-\u9fff' # Chinese characters
|
||||
'\u3040-\u309f' # Japanese hiragana
|
||||
'\u30a0-\u30ff' # Japanese katakana
|
||||
'\u4e00-\u9faf' # Japanese kanji
|
||||
'\uac00-\ud7af' # Korean hangul syllables
|
||||
'\u1100-\u11ff' # Korean hangul jamo
|
||||
)
|
||||
return bool(re.search(fr'[{unicode_ranges}]', s))
|
||||
|
||||
|
||||
def bold_search_terms(response: str, query: str) -> BeautifulSoup:
|
||||
"""Wraps all search terms in bold tags (<b>). If any terms are wrapped
|
||||
in quotes, only that exact phrase will be made bold.
|
||||
|
||||
Args:
|
||||
response: The initial response body for the query
|
||||
query: The original search query
|
||||
|
||||
Returns:
|
||||
BeautifulSoup: modified soup object with bold items
|
||||
"""
|
||||
response = BeautifulSoup(response, 'html.parser')
|
||||
|
||||
def replace_any_case(element: NavigableString, target_word: str) -> None:
|
||||
# Replace all instances of the word, but maintaining the same case in
|
||||
# the replacement
|
||||
if len(element) == len(target_word):
|
||||
return
|
||||
|
||||
# Ensure target word is escaped for regex
|
||||
target_word = re.escape(target_word)
|
||||
|
||||
# Check if the word contains Chinese, Japanese, or Korean characters
|
||||
if contains_cjko(target_word):
|
||||
reg_pattern = fr'((?![{{}}<>-]){target_word}(?![{{}}<>-]))'
|
||||
else:
|
||||
reg_pattern = fr'\b((?![{{}}<>-]){target_word}(?![{{}}<>-]))\b'
|
||||
|
||||
if re.match(r'.*[@_!#$%^&*()<>?/\|}{~:].*', target_word) or (
|
||||
element.parent and element.parent.name == 'style'):
|
||||
return
|
||||
|
||||
element.replace_with(BeautifulSoup(
|
||||
re.sub(reg_pattern,
|
||||
r'<b>\1</b>',
|
||||
element,
|
||||
flags=re.I), 'html.parser')
|
||||
)
|
||||
|
||||
# Split all words out of query, grouping the ones wrapped in quotes
|
||||
for word in re.split(r'\s+(?=[^"]*(?:"[^"]*"[^"]*)*$)', query):
|
||||
word = re.sub(r'[@_!#$%^&*()<>?/\|}{~:]+', '', word)
|
||||
target = response.find_all(
|
||||
string=re.compile(r'' + re.escape(word), re.I))
|
||||
for nav_str in target:
|
||||
replace_any_case(nav_str, word)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def has_ad_content(element: str) -> bool:
|
||||
"""Inspects an HTML element for ad related content
|
||||
@ -132,12 +40,11 @@ def has_ad_content(element: str) -> bool:
|
||||
bool: True/False for the element containing an ad
|
||||
|
||||
"""
|
||||
element_str = ''.join(filter(str.isalpha, element))
|
||||
return (element_str.upper() in (value.upper() for value in BLACKLIST)
|
||||
return (element.upper() in (value.upper() for value in BLACKLIST)
|
||||
or 'ⓘ' in element)
|
||||
|
||||
|
||||
def get_first_link(soup) -> str:
|
||||
def get_first_link(soup: BeautifulSoup) -> str:
|
||||
"""Retrieves the first result link from the query response
|
||||
|
||||
Args:
|
||||
@ -147,83 +54,35 @@ def get_first_link(soup) -> str:
|
||||
str: A str link to the first result
|
||||
|
||||
"""
|
||||
first_link = ''
|
||||
|
||||
# Find the first valid search result link, excluding details elements
|
||||
# Replace hrefs with only the intended destination (no "utm" type tags)
|
||||
for a in soup.find_all('a', href=True):
|
||||
# Skip links that are inside details elements (collapsible sections)
|
||||
if a.find_parent('details'):
|
||||
continue
|
||||
|
||||
# Return the first search result URL
|
||||
if a['href'].startswith('http://') or a['href'].startswith('https://'):
|
||||
first_link = a['href']
|
||||
break
|
||||
|
||||
return first_link
|
||||
if 'url?q=' in a['href']:
|
||||
return filter_link_args(a['href'])
|
||||
return ''
|
||||
|
||||
|
||||
def get_site_alt(link: str, site_alts: dict = SITE_ALTS) -> str:
|
||||
def get_site_alt(link: str) -> str:
|
||||
"""Returns an alternative to a particular site, if one is configured
|
||||
|
||||
Args:
|
||||
link: A string result URL to check against the site_alts map
|
||||
site_alts: A map of site alternatives to replace with. defaults to SITE_ALTS
|
||||
link: A string result URL to check against the SITE_ALTS map
|
||||
|
||||
Returns:
|
||||
str: An updated (or ignored) result link
|
||||
|
||||
"""
|
||||
# Need to replace full hostname with alternative to encapsulate
|
||||
# subdomains as well
|
||||
parsed_link = urlparse.urlparse(link)
|
||||
|
||||
# Extract subdomain separately from the domain+tld. The subdomain
|
||||
# is used for wikiless translations.
|
||||
split_host = parsed_link.netloc.split('.')
|
||||
subdomain = split_host[0] if len(split_host) > 2 else ''
|
||||
hostname = '.'.join(split_host[-2:])
|
||||
|
||||
# The full scheme + hostname is used when comparing against the list of
|
||||
# available alternative services, due to how Medium links are constructed.
|
||||
# (i.e. for medium.com: "https://something.medium.com" should match,
|
||||
# "https://medium.com/..." should match, but "philomedium.com" should not)
|
||||
hostcomp = f'{parsed_link.scheme}://{hostname}'
|
||||
|
||||
for site_key in site_alts.keys():
|
||||
site_alt = f'{parsed_link.scheme}://{site_key}'
|
||||
if not hostname or site_alt not in hostcomp or not site_alts[site_key]:
|
||||
for site_key in SITE_ALTS.keys():
|
||||
if site_key not in link:
|
||||
continue
|
||||
|
||||
# Wikipedia -> Wikiless replacements require the subdomain (if it's
|
||||
# a 2-char language code) to be passed as a URL param to Wikiless
|
||||
# in order to preserve the language setting.
|
||||
params = ''
|
||||
if 'wikipedia' in hostname and len(subdomain) == 2:
|
||||
hostname = f'{subdomain}.{hostname}'
|
||||
params = f'?lang={subdomain}'
|
||||
elif 'medium' in hostname and len(subdomain) > 0:
|
||||
hostname = f'{subdomain}.{hostname}'
|
||||
|
||||
parsed_alt = urlparse.urlparse(site_alts[site_key])
|
||||
link = link.replace(hostname, site_alts[site_key]) + params
|
||||
# If a scheme is specified in the alternative, this results in a
|
||||
# replaced link that looks like "https://http://altservice.tld".
|
||||
# In this case, we can remove the original scheme from the result
|
||||
# and use the one specified for the alt.
|
||||
if parsed_alt.scheme:
|
||||
link = '//'.join(link.split('//')[1:])
|
||||
|
||||
for prefix in SKIP_PREFIX:
|
||||
if parsed_alt.scheme:
|
||||
# If a scheme is specified, remove everything before the
|
||||
# first occurence of it
|
||||
link = f'{parsed_alt.scheme}{link.split(parsed_alt.scheme, 1)[-1]}'
|
||||
else:
|
||||
# Otherwise, replace the first occurrence of the prefix
|
||||
link = link.replace(prefix, '//', 1)
|
||||
link = link.replace(site_key, SITE_ALTS[site_key])
|
||||
break
|
||||
|
||||
for prefix in SKIP_PREFIX:
|
||||
link = link.replace(prefix, '//')
|
||||
|
||||
return link
|
||||
|
||||
|
||||
@ -271,192 +130,8 @@ def append_nojs(result: BeautifulSoup) -> None:
|
||||
|
||||
"""
|
||||
nojs_link = BeautifulSoup(features='html.parser').new_tag('a')
|
||||
nojs_link['href'] = f'{Endpoint.window}?nojs=1&location=' + result['href']
|
||||
nojs_link.string = ' NoJS Link'
|
||||
nojs_link['href'] = '/window?location=' + result['href']
|
||||
nojs_link['style'] = 'display:block;width:100%;'
|
||||
nojs_link.string = 'NoJS Link: ' + nojs_link['href']
|
||||
result.append(BeautifulSoup('<br><hr><br>', 'html.parser'))
|
||||
result.append(nojs_link)
|
||||
|
||||
|
||||
def append_anon_view(result: BeautifulSoup, config: Config) -> None:
|
||||
"""Appends an 'anonymous view' for a search result, where all site
|
||||
contents are viewed through Whoogle as a proxy.
|
||||
|
||||
Args:
|
||||
result: The search result to append an anon view link to
|
||||
nojs: Remove Javascript from Anonymous View
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
av_link = BeautifulSoup(features='html.parser').new_tag('a')
|
||||
nojs = 'nojs=1' if config.nojs else 'nojs=0'
|
||||
location = f'location={result["href"]}'
|
||||
av_link['href'] = f'{Endpoint.window}?{nojs}&{location}'
|
||||
translation = current_app.config['TRANSLATIONS'][
|
||||
config.get_localization_lang()
|
||||
]
|
||||
av_link.string = f'{translation["anon-view"]}'
|
||||
av_link['class'] = 'anon-view'
|
||||
result.append(av_link)
|
||||
|
||||
def check_currency(response: str) -> dict:
|
||||
"""Check whether the results have currency conversion
|
||||
|
||||
Args:
|
||||
response: Search query Result
|
||||
|
||||
Returns:
|
||||
dict: Consists of currency names and values
|
||||
|
||||
"""
|
||||
soup = BeautifulSoup(response, 'html.parser')
|
||||
currency_link = soup.find('a', {'href': 'https://g.co/gfd'})
|
||||
if currency_link:
|
||||
while 'class' not in currency_link.attrs or \
|
||||
'ZINbbc' not in currency_link.attrs['class']:
|
||||
if currency_link.parent:
|
||||
currency_link = currency_link.parent
|
||||
else:
|
||||
return {}
|
||||
currency_link = currency_link.find_all(class_='BNeawe')
|
||||
currency1 = currency_link[0].text
|
||||
currency2 = currency_link[1].text
|
||||
currency1 = currency1.rstrip('=').split(' ', 1)
|
||||
currency2 = currency2.split(' ', 1)
|
||||
|
||||
# Handle differences in currency formatting
|
||||
# i.e. "5.000" vs "5,000"
|
||||
if currency2[0][-3] == ',':
|
||||
currency1[0] = currency1[0].replace('.', '')
|
||||
currency1[0] = currency1[0].replace(',', '.')
|
||||
currency2[0] = currency2[0].replace('.', '')
|
||||
currency2[0] = currency2[0].replace(',', '.')
|
||||
else:
|
||||
currency1[0] = currency1[0].replace(',', '')
|
||||
currency2[0] = currency2[0].replace(',', '')
|
||||
|
||||
currency1_value = float(re.sub(r'[^\d\.]', '', currency1[0]))
|
||||
currency1_label = currency1[1]
|
||||
|
||||
currency2_value = float(re.sub(r'[^\d\.]', '', currency2[0]))
|
||||
currency2_label = currency2[1]
|
||||
|
||||
return {'currencyValue1': currency1_value,
|
||||
'currencyLabel1': currency1_label,
|
||||
'currencyValue2': currency2_value,
|
||||
'currencyLabel2': currency2_label
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def add_currency_card(soup: BeautifulSoup,
|
||||
conversion_details: dict) -> BeautifulSoup:
|
||||
"""Adds the currency conversion boxes
|
||||
to response of the search query
|
||||
|
||||
Args:
|
||||
soup: Parsed search result
|
||||
conversion_details: Dictionary of currency
|
||||
related information
|
||||
|
||||
Returns:
|
||||
BeautifulSoup
|
||||
"""
|
||||
# Element before which the code will be changed
|
||||
# (This is the 'disclaimer' link)
|
||||
element1 = soup.find('a', {'href': 'https://g.co/gfd'})
|
||||
|
||||
while 'class' not in element1.attrs or \
|
||||
'nXE3Ob' not in element1.attrs['class']:
|
||||
element1 = element1.parent
|
||||
|
||||
# Creating the conversion factor
|
||||
conversion_factor = (conversion_details['currencyValue1'] /
|
||||
conversion_details['currencyValue2'])
|
||||
|
||||
# Creating a new div for the input boxes
|
||||
conversion_box = soup.new_tag('div')
|
||||
conversion_box['class'] = 'conversion_box'
|
||||
|
||||
# Currency to be converted from
|
||||
input_box1 = soup.new_tag('input')
|
||||
input_box1['id'] = 'cb1'
|
||||
input_box1['type'] = 'number'
|
||||
input_box1['class'] = 'cb'
|
||||
input_box1['value'] = conversion_details['currencyValue1']
|
||||
input_box1['oninput'] = f'convert(1, 2, {1 / conversion_factor})'
|
||||
|
||||
label_box1 = soup.new_tag('label')
|
||||
label_box1['for'] = 'cb1'
|
||||
label_box1['class'] = 'cb_label'
|
||||
label_box1.append(conversion_details['currencyLabel1'])
|
||||
|
||||
br = soup.new_tag('br')
|
||||
|
||||
# Currency to be converted to
|
||||
input_box2 = soup.new_tag('input')
|
||||
input_box2['id'] = 'cb2'
|
||||
input_box2['type'] = 'number'
|
||||
input_box2['class'] = 'cb'
|
||||
input_box2['value'] = conversion_details['currencyValue2']
|
||||
input_box2['oninput'] = f'convert(2, 1, {conversion_factor})'
|
||||
|
||||
label_box2 = soup.new_tag('label')
|
||||
label_box2['for'] = 'cb2'
|
||||
label_box2['class'] = 'cb_label'
|
||||
label_box2.append(conversion_details['currencyLabel2'])
|
||||
|
||||
conversion_box.append(input_box1)
|
||||
conversion_box.append(label_box1)
|
||||
conversion_box.append(br)
|
||||
conversion_box.append(input_box2)
|
||||
conversion_box.append(label_box2)
|
||||
|
||||
element1.insert_before(conversion_box)
|
||||
return soup
|
||||
|
||||
|
||||
def get_tabs_content(tabs: dict,
|
||||
full_query: str,
|
||||
search_type: str,
|
||||
preferences: str,
|
||||
translation: dict) -> dict:
|
||||
"""Takes the default tabs content and updates it according to the query.
|
||||
|
||||
Args:
|
||||
tabs: The default content for the tabs
|
||||
full_query: The original search query
|
||||
search_type: The current search_type
|
||||
translation: The translation to get the names of the tabs
|
||||
|
||||
Returns:
|
||||
dict: contains the name, the href and if the tab is selected or not
|
||||
"""
|
||||
map_query = full_query
|
||||
if '-site:' in full_query:
|
||||
block_idx = full_query.index('-site:')
|
||||
map_query = map_query[:block_idx]
|
||||
tabs = copy.deepcopy(tabs)
|
||||
for tab_id, tab_content in tabs.items():
|
||||
# update name to desired language
|
||||
if tab_id in translation:
|
||||
tab_content['name'] = translation[tab_id]
|
||||
|
||||
# update href with query
|
||||
query = full_query.replace(f'&tbm={search_type}', '')
|
||||
|
||||
if tab_content['tbm'] is not None:
|
||||
query = f"{query}&tbm={tab_content['tbm']}"
|
||||
|
||||
if preferences:
|
||||
query = f"{query}&preferences={preferences}"
|
||||
|
||||
tab_content['href'] = tab_content['href'].format(
|
||||
query=query,
|
||||
map_query=map_query)
|
||||
|
||||
# update if selected tab (default all tab is selected)
|
||||
if tab_content['tbm'] == search_type:
|
||||
tabs['all']['selected'] = False
|
||||
tab_content['selected'] = True
|
||||
return tabs
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
from app.filter import Filter
|
||||
from app.request import gen_query
|
||||
from app.utils.misc import get_proxy_host_url
|
||||
from app.utils.results import get_first_link
|
||||
from app.services.cse_client import CSEClient, cse_results_to_html
|
||||
|
||||
from bs4 import BeautifulSoup as bsoup
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from flask import g
|
||||
|
||||
from app.filter import Filter, get_first_link
|
||||
from app.request import gen_query
|
||||
|
||||
TOR_BANNER = '<hr><h1 style="text-align: center">You are using Tor</h1><hr>'
|
||||
CAPTCHA = 'div class="g-recaptcha"'
|
||||
|
||||
@ -54,20 +52,17 @@ class Search:
|
||||
Attributes:
|
||||
request: the incoming flask request
|
||||
config: the current user config settings
|
||||
session_key: the flask user fernet key
|
||||
session: the flask user session
|
||||
"""
|
||||
def __init__(self, request, config, session_key, cookies_disabled=False, user_request=None):
|
||||
def __init__(self, request, config, session, cookies_disabled=False):
|
||||
method = request.method
|
||||
self.request = request
|
||||
self.request_params = request.args if method == 'GET' else request.form
|
||||
self.user_agent = request.headers.get('User-Agent')
|
||||
self.feeling_lucky = False
|
||||
self.config = config
|
||||
self.session_key = session_key
|
||||
self.session = session
|
||||
self.query = ''
|
||||
self.widget = ''
|
||||
self.cookies_disabled = cookies_disabled
|
||||
self.user_request = user_request
|
||||
self.search_type = self.request_params.get(
|
||||
'tbm') if 'tbm' in self.request_params else ''
|
||||
|
||||
@ -100,26 +95,13 @@ class Search:
|
||||
else:
|
||||
# Attempt to decrypt if this is an internal link
|
||||
try:
|
||||
q = Fernet(self.session_key).decrypt(q.encode()).decode()
|
||||
q = Fernet(self.session['key']).decrypt(q.encode()).decode()
|
||||
except InvalidToken:
|
||||
pass
|
||||
|
||||
# Strip '!' for "feeling lucky" queries
|
||||
if match := re.search(r"(^|\s)!($|\s)", q):
|
||||
self.feeling_lucky = True
|
||||
start, end = match.span()
|
||||
self.query = " ".join([seg for seg in [q[:start], q[end:]] if seg])
|
||||
else:
|
||||
self.feeling_lucky = False
|
||||
self.query = q
|
||||
|
||||
# Check for possible widgets
|
||||
self.widget = "ip" if re.search("([^a-z0-9]|^)my *[^a-z0-9] *(ip|internet protocol)" +
|
||||
"($|( *[^a-z0-9] *(((addres|address|adres|" +
|
||||
"adress)|a)? *$)))", self.query.lower()) else self.widget
|
||||
self.widget = 'calculator' if re.search(
|
||||
r"\bcalculator\b|\bcalc\b|\bcalclator\b|\bmath\b",
|
||||
self.query.lower()) else self.widget
|
||||
# Strip leading '! ' for "feeling lucky" queries
|
||||
self.feeling_lucky = q.startswith('! ')
|
||||
self.query = q[2:] if self.feeling_lucky else q
|
||||
return self.query
|
||||
|
||||
def generate_response(self) -> str:
|
||||
@ -131,153 +113,51 @@ class Search:
|
||||
|
||||
"""
|
||||
mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent
|
||||
# reconstruct url if X-Forwarded-Host header present
|
||||
root_url = get_proxy_host_url(
|
||||
self.request,
|
||||
self.request.url_root,
|
||||
root=True)
|
||||
|
||||
content_filter = Filter(self.session_key,
|
||||
root_url=root_url,
|
||||
content_filter = Filter(self.session['key'],
|
||||
mobile=mobile,
|
||||
config=self.config,
|
||||
query=self.query,
|
||||
page_url=self.request.url)
|
||||
|
||||
# Check if CSE (Custom Search Engine) should be used
|
||||
use_cse = (
|
||||
self.config.use_cse and
|
||||
self.config.cse_api_key and
|
||||
self.config.cse_id
|
||||
)
|
||||
|
||||
if use_cse:
|
||||
# Use Google Custom Search API
|
||||
return self._generate_cse_response(content_filter, root_url, mobile)
|
||||
|
||||
# Default: Use traditional scraping method
|
||||
return self._generate_scrape_response(content_filter, root_url, mobile)
|
||||
|
||||
def _generate_cse_response(self, content_filter: Filter, root_url: str, mobile: bool) -> str:
|
||||
"""Generate response using Google Custom Search API
|
||||
|
||||
Args:
|
||||
content_filter: Filter instance for processing results
|
||||
root_url: Root URL of the instance
|
||||
mobile: Whether this is a mobile request
|
||||
|
||||
Returns:
|
||||
str: HTML response string
|
||||
"""
|
||||
# Get pagination start index from request params
|
||||
start = int(self.request_params.get('start', 1))
|
||||
|
||||
# Determine safe search setting
|
||||
safe = 'high' if self.config.safe else 'off'
|
||||
|
||||
# Determine search type (web or image)
|
||||
# tbm=isch or udm=2 indicates image search
|
||||
search_type = ''
|
||||
if self.search_type == 'isch' or self.request_params.get('udm') == '2':
|
||||
search_type = 'image'
|
||||
|
||||
# Create CSE client and perform search
|
||||
with CSEClient(
|
||||
api_key=self.config.cse_api_key,
|
||||
cse_id=self.config.cse_id
|
||||
) as client:
|
||||
response = client.search(
|
||||
query=self.query,
|
||||
start=start,
|
||||
safe=safe,
|
||||
language=self.config.lang_search,
|
||||
country=self.config.country,
|
||||
search_type=search_type
|
||||
)
|
||||
|
||||
# Convert CSE response to HTML
|
||||
html_content = cse_results_to_html(response, self.query)
|
||||
|
||||
# Store full query for tabs
|
||||
self.full_query = self.query
|
||||
|
||||
# Parse and filter the HTML
|
||||
html_soup = bsoup(html_content, 'html.parser')
|
||||
|
||||
# Handle feeling lucky
|
||||
if self.feeling_lucky:
|
||||
if response.has_results and response.results:
|
||||
return response.results[0].link
|
||||
self.feeling_lucky = False
|
||||
|
||||
# Apply content filter (encrypts links, applies CSS, etc.)
|
||||
formatted_results = content_filter.clean(html_soup)
|
||||
|
||||
return str(formatted_results)
|
||||
|
||||
def _generate_scrape_response(self, content_filter: Filter, root_url: str, mobile: bool) -> str:
|
||||
"""Generate response using traditional HTML scraping
|
||||
|
||||
Args:
|
||||
content_filter: Filter instance for processing results
|
||||
root_url: Root URL of the instance
|
||||
mobile: Whether this is a mobile request
|
||||
|
||||
Returns:
|
||||
str: HTML response string
|
||||
"""
|
||||
config=self.config)
|
||||
full_query = gen_query(self.query,
|
||||
self.request_params,
|
||||
self.config)
|
||||
self.full_query = full_query
|
||||
self.config,
|
||||
content_filter.near)
|
||||
|
||||
# force mobile search when view image is true and
|
||||
# the request is not already made by a mobile
|
||||
is_image_query = ('tbm=isch' in full_query) or ('udm=2' in full_query)
|
||||
# Always parse image results when hitting the images endpoint (udm=2)
|
||||
# to avoid Google returning only text/AI blocks.
|
||||
view_image = is_image_query
|
||||
view_image = ('tbm=isch' in full_query
|
||||
and self.config.view_image
|
||||
and not g.user_request.mobile)
|
||||
|
||||
client = self.user_request or g.user_request
|
||||
get_body = client.send(query=full_query,
|
||||
force_mobile=self.config.view_image,
|
||||
user_agent=self.user_agent)
|
||||
get_body = g.user_request.send(query=full_query,
|
||||
force_mobile=view_image)
|
||||
|
||||
# Produce cleanable html soup from response
|
||||
get_body_safed = get_body.text.replace("<","andlt;").replace(">","andgt;")
|
||||
html_soup = bsoup(get_body_safed, 'html.parser')
|
||||
|
||||
# Ensure we extract only the content within <html> if it exists
|
||||
# This prevents doctype declarations from appearing in the output
|
||||
if html_soup.html:
|
||||
html_soup = html_soup.html
|
||||
html_soup = bsoup(content_filter.reskin(get_body.text), 'html.parser')
|
||||
|
||||
# Replace current soup if view_image is active
|
||||
if view_image:
|
||||
html_soup = content_filter.view_image(html_soup)
|
||||
|
||||
# Indicate whether or not a Tor connection is active
|
||||
if (self.user_request or g.user_request).tor_valid:
|
||||
html_soup.insert(0, bsoup(TOR_BANNER, 'html.parser'))
|
||||
tor_banner = bsoup('', 'html.parser')
|
||||
if g.user_request.tor_valid:
|
||||
tor_banner = bsoup(TOR_BANNER, 'html.parser')
|
||||
html_soup.insert(0, tor_banner)
|
||||
|
||||
formatted_results = content_filter.clean(html_soup)
|
||||
if self.feeling_lucky:
|
||||
if lucky_link := get_first_link(formatted_results):
|
||||
return lucky_link
|
||||
return get_first_link(html_soup)
|
||||
else:
|
||||
formatted_results = content_filter.clean(html_soup)
|
||||
|
||||
# Fall through to regular search if unable to find link
|
||||
self.feeling_lucky = False
|
||||
# Append user config to all search links, if available
|
||||
param_str = ''.join('&{}={}'.format(k, v)
|
||||
for k, v in
|
||||
self.request_params.to_dict(flat=True).items()
|
||||
if self.config.is_safe_key(k))
|
||||
for link in formatted_results.find_all('a', href=True):
|
||||
if 'search?' not in link['href'] or link['href'].index(
|
||||
'search?') > 1:
|
||||
continue
|
||||
link['href'] += param_str
|
||||
|
||||
# Append user config to all search links, if available
|
||||
param_str = ''.join('&{}={}'.format(k, v)
|
||||
for k, v in
|
||||
self.request_params.to_dict(flat=True).items()
|
||||
if self.config.is_safe_key(k))
|
||||
for link in formatted_results.find_all('a', href=True):
|
||||
link['rel'] = "nofollow noopener noreferrer"
|
||||
if 'search?' not in link['href'] or link['href'].index(
|
||||
'search?') > 1:
|
||||
continue
|
||||
link['href'] += param_str
|
||||
|
||||
return str(formatted_results)
|
||||
return str(formatted_results)
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
from cryptography.fernet import Fernet
|
||||
from flask import current_app as app
|
||||
|
||||
REQUIRED_SESSION_VALUES = ['uuid', 'config', 'key', 'auth']
|
||||
REQUIRED_SESSION_VALUES = ['uuid', 'config', 'key']
|
||||
|
||||
|
||||
def generate_key() -> bytes:
|
||||
def generate_user_key(cookies_disabled=False) -> bytes:
|
||||
"""Generates a key for encrypting searches and element URLs
|
||||
|
||||
Args:
|
||||
@ -16,6 +16,9 @@ def generate_key() -> bytes:
|
||||
str: A unique Fernet key
|
||||
|
||||
"""
|
||||
if cookies_disabled:
|
||||
return app.default_key
|
||||
|
||||
# Generate/regenerate unique key per user
|
||||
return Fernet.generate_key()
|
||||
|
||||
|
||||
@ -1,336 +0,0 @@
|
||||
"""
|
||||
User Agent Generator for Opera-based UA strings.
|
||||
|
||||
This module generates realistic Opera User Agent strings based on patterns
|
||||
found in working UA strings that successfully bypass Google's restrictions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
# Default fallback UA if generation fails
|
||||
DEFAULT_FALLBACK_UA = "Opera/9.80 (iPad; Opera Mini/5.0.17381/503; U; eu) Presto/2.6.35 Version/11.10)"
|
||||
|
||||
# Opera UA Pattern Templates
|
||||
OPERA_PATTERNS = [
|
||||
# Opera Mini (J2ME/MIDP)
|
||||
"Opera/9.80 (J2ME/MIDP; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
|
||||
# Opera Mobile (Android)
|
||||
"Opera/9.80 (Android; Linux; Opera Mobi/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
|
||||
# Opera Mobile (iPhone)
|
||||
"Opera/9.80 (iPhone; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
|
||||
# Opera Mobile (iPad)
|
||||
"Opera/9.80 (iPad; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
]
|
||||
|
||||
# Randomization pools based on working UAs
|
||||
OPERA_MINI_VERSIONS = [
|
||||
"4.0", "4.1.11321", "4.1.12965", "4.1.13573", "4.1.13907", "4.1.14287",
|
||||
"4.1.15082", "4.2.13057", "4.2.13221", "4.2.13265", "4.2.13337",
|
||||
"4.2.13400", "4.2.13918", "4.2.13943", "4.2.14320", "4.2.14409",
|
||||
"4.2.14753", "4.2.14881", "4.2.14885", "4.2.14912", "4.2.15066",
|
||||
"4.2.15410", "4.2.16007", "4.2.16320", "4.2.18887", "4.2.19634",
|
||||
"4.2.21465", "4.2.22228", "4.2.23453", "4.2.24721", "4.3.13337",
|
||||
"4.3.24214", "4.4.26736", "4.4.29476", "4.5.33867", "4.5.40312",
|
||||
"5.0.15650", "5.0.16823", "5.0.17381", "5.0.17443", "5.0.18635",
|
||||
"5.0.18741", "5.0.19683", "5.0.19693", "5.0.20873", "5.0.22349",
|
||||
"5.1.21051", "5.1.21126", "5.1.21214", "5.1.21415", "5.1.21594",
|
||||
"5.1.21595", "5.1.22296", "5.1.22303", "5.1.22396", "5.1.22460",
|
||||
"5.1.22783", "5.1.22784", "6.0.24095", "6.0.24212", "6.0.24455",
|
||||
"6.1.25375", "6.1.25378", "6.1.25759", "6.24093", "6.24096",
|
||||
"6.24209", "6.24288", "6.5.26955", "6.5.29702", "7.0.29952",
|
||||
"7.1.32052", "7.1.32444", "7.1.32694", "7.29530", "7.5.33361",
|
||||
"7.6.35766", "9.80", "36.2.2254"
|
||||
]
|
||||
|
||||
OPERA_MOBI_BUILDS = [
|
||||
"27", "49", "447", "498", "1181", "1209", "3730",
|
||||
"ADR-1011151731", "ADR-1012211514", "ADR-1012221546", "ADR-1012272315",
|
||||
"SYB-1103211396", "SYB-1104061449", "SYB-1107071606",
|
||||
"ADR-1111101157"
|
||||
]
|
||||
|
||||
BUILD_NUMBERS = [
|
||||
"18.678", "18.684", "18.738", "18.794", "19.892", "19.916",
|
||||
"20.2477", "20.2479", "20.2485", "20.2489", "21.529", "22.387",
|
||||
"22.394", "22.401", "22.414", "22.453", "22.478", "23.317",
|
||||
"23.333", "23.334", "23.377", "23.390", "24.741", "24.743",
|
||||
"24.746", "24.783", "24.838", "24.871", "24.899", "25.657",
|
||||
"25.677", "25.729", "25.872", "26.1305", "27.1366", "27.1407",
|
||||
"27.1573", "28.2075", "28.2555", "28.2647", "28.2766", "29.3594",
|
||||
"30.3316", "31.1350", "35.2883", "35.5706", "37.6584", "119.132",
|
||||
"170.51", "170.54", "764", "870", "886", "490", "503"
|
||||
]
|
||||
|
||||
PRESTO_VERSIONS = [
|
||||
"2.2.0", "2.4.15", "2.4.154.15", "2.4.18", "2.5.25", "2.5.28",
|
||||
"2.6.35", "2.7.60", "2.7.81", "2.8.119", "2.8.149", "2.8.191",
|
||||
"2.9.201", "2.12.423"
|
||||
]
|
||||
|
||||
FINAL_VERSIONS = [
|
||||
"10.00", "10.1", "10.5", "10.54", "10.5454", "11.00", "11.10",
|
||||
"12.02", "12.16", "13.00"
|
||||
]
|
||||
|
||||
LANGUAGES = [
|
||||
# English variants
|
||||
"en", "en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-ZA", "en-IN", "en-SG",
|
||||
# Western European
|
||||
"de", "de-DE", "de-AT", "de-CH",
|
||||
"fr", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU",
|
||||
"es", "es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-LA",
|
||||
"it", "it-IT", "it-CH",
|
||||
"pt", "pt-PT", "pt-BR",
|
||||
"nl", "nl-NL", "nl-BE",
|
||||
# Nordic languages
|
||||
"da", "da-DK",
|
||||
"sv", "sv-SE",
|
||||
"no", "no-NO", "nb", "nn",
|
||||
"fi", "fi-FI",
|
||||
"is", "is-IS",
|
||||
# Eastern European
|
||||
"pl", "pl-PL",
|
||||
"cs", "cs-CZ",
|
||||
"sk", "sk-SK",
|
||||
"hu", "hu-HU",
|
||||
"ro", "ro-RO",
|
||||
"bg", "bg-BG",
|
||||
"hr", "hr-HR",
|
||||
"sr", "sr-RS",
|
||||
"sl", "sl-SI",
|
||||
"uk", "uk-UA",
|
||||
"ru", "ru-RU",
|
||||
# Asian languages
|
||||
"zh", "zh-CN", "zh-TW", "zh-HK",
|
||||
"ja", "ja-JP",
|
||||
"ko", "ko-KR",
|
||||
"th", "th-TH",
|
||||
"vi", "vi-VN",
|
||||
"id", "id-ID",
|
||||
"ms", "ms-MY",
|
||||
"fil", "tl",
|
||||
# Middle Eastern
|
||||
"tr", "tr-TR",
|
||||
"ar", "ar-SA", "ar-AE", "ar-EG",
|
||||
"he", "he-IL",
|
||||
"fa", "fa-IR",
|
||||
# Other
|
||||
"hi", "hi-IN",
|
||||
"bn", "bn-IN",
|
||||
"ta", "ta-IN",
|
||||
"te", "te-IN",
|
||||
"mr", "mr-IN",
|
||||
"el", "el-GR",
|
||||
"ca", "ca-ES",
|
||||
"eu", "eu-ES"
|
||||
]
|
||||
|
||||
|
||||
|
||||
def generate_opera_ua() -> str:
|
||||
"""
|
||||
Generate a single random Opera User Agent string.
|
||||
|
||||
Returns:
|
||||
str: A randomly generated Opera UA string
|
||||
"""
|
||||
pattern = random.choice(OPERA_PATTERNS)
|
||||
|
||||
# Determine which parameters to use based on the pattern
|
||||
params = {
|
||||
'lang': random.choice(LANGUAGES)
|
||||
}
|
||||
|
||||
if '{version}' in pattern:
|
||||
params['version'] = random.choice(OPERA_MINI_VERSIONS)
|
||||
|
||||
if '{build}' in pattern:
|
||||
# Use MOBI build for "Opera Mobi", regular build for "Opera Mini"
|
||||
if "Opera Mobi" in pattern:
|
||||
params['build'] = random.choice(OPERA_MOBI_BUILDS)
|
||||
else:
|
||||
params['build'] = random.choice(BUILD_NUMBERS)
|
||||
|
||||
if '{presto}' in pattern:
|
||||
params['presto'] = random.choice(PRESTO_VERSIONS)
|
||||
|
||||
if '{final}' in pattern:
|
||||
params['final'] = random.choice(FINAL_VERSIONS)
|
||||
|
||||
return pattern.format(**params)
|
||||
|
||||
|
||||
def generate_ua_pool(count: int = 10) -> List[str]:
|
||||
"""
|
||||
Generate a pool of unique Opera User Agent strings.
|
||||
|
||||
Args:
|
||||
count: Number of UA strings to generate (default: 10)
|
||||
|
||||
Returns:
|
||||
List[str]: List of unique UA strings
|
||||
"""
|
||||
ua_pool = set()
|
||||
|
||||
# Keep generating until we have enough unique UAs
|
||||
# Add safety limit to prevent infinite loop
|
||||
max_attempts = count * 100
|
||||
attempts = 0
|
||||
|
||||
try:
|
||||
while len(ua_pool) < count and attempts < max_attempts:
|
||||
ua = generate_opera_ua()
|
||||
ua_pool.add(ua)
|
||||
attempts += 1
|
||||
except Exception:
|
||||
# If generation fails entirely, return at least the default fallback
|
||||
if not ua_pool:
|
||||
return [DEFAULT_FALLBACK_UA]
|
||||
|
||||
# If we couldn't generate enough, fill remaining with default
|
||||
result = list(ua_pool)
|
||||
while len(result) < count:
|
||||
result.append(DEFAULT_FALLBACK_UA)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def save_ua_pool(uas: List[str], cache_path: str) -> None:
|
||||
"""
|
||||
Save UA pool to cache file.
|
||||
|
||||
Args:
|
||||
uas: List of UA strings to save
|
||||
cache_path: Path to cache file
|
||||
"""
|
||||
cache_data = {
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'user_agents': uas
|
||||
}
|
||||
|
||||
# Ensure directory exists
|
||||
cache_dir = os.path.dirname(cache_path)
|
||||
if cache_dir and not os.path.exists(cache_dir):
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_data, f, indent=2)
|
||||
|
||||
|
||||
def load_custom_ua_list(file_path: str) -> List[str]:
|
||||
"""
|
||||
Load custom UA list from a text file.
|
||||
|
||||
Args:
|
||||
file_path: Path to text file containing UA strings (one per line)
|
||||
|
||||
Returns:
|
||||
List[str]: List of UA strings, or empty list if file is invalid
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
uas = [line.strip() for line in f if line.strip()]
|
||||
|
||||
# Validate that we have at least one UA
|
||||
if not uas:
|
||||
return []
|
||||
|
||||
return uas
|
||||
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def load_ua_pool(cache_path: str, count: int = 10) -> List[str]:
|
||||
"""
|
||||
Load UA pool from custom list file, cache, or generate new one.
|
||||
|
||||
Priority order:
|
||||
1. Custom UA list file (if WHOOGLE_UA_LIST_FILE is set)
|
||||
2. Cached auto-generated UAs
|
||||
3. Newly generated UAs
|
||||
|
||||
Args:
|
||||
cache_path: Path to cache file
|
||||
count: Number of UAs to generate if cache is invalid (default: 10)
|
||||
|
||||
Returns:
|
||||
List[str]: List of UA strings
|
||||
"""
|
||||
# Check for custom UA list file first (highest priority)
|
||||
custom_ua_file = os.environ.get('WHOOGLE_UA_LIST_FILE', '').strip()
|
||||
if custom_ua_file:
|
||||
custom_uas = load_custom_ua_list(custom_ua_file)
|
||||
if custom_uas:
|
||||
# Custom list loaded successfully
|
||||
return custom_uas
|
||||
else:
|
||||
# Custom file specified but invalid, log warning and fall back
|
||||
print(f"Warning: Custom UA list file '{custom_ua_file}' not found or invalid, falling back to auto-generated UAs")
|
||||
|
||||
# Check if we should use cache
|
||||
use_cache = os.environ.get('WHOOGLE_UA_CACHE_PERSISTENT', '1') == '1'
|
||||
refresh_days = int(os.environ.get('WHOOGLE_UA_CACHE_REFRESH_DAYS', '0'))
|
||||
|
||||
# If cache disabled, always generate new
|
||||
if not use_cache:
|
||||
uas = generate_ua_pool(count)
|
||||
save_ua_pool(uas, cache_path)
|
||||
return uas
|
||||
|
||||
# Try to load from cache
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Check if cache is expired (if refresh_days > 0)
|
||||
if refresh_days > 0:
|
||||
generated_at = datetime.fromisoformat(cache_data['generated_at'])
|
||||
age_days = (datetime.now() - generated_at).days
|
||||
|
||||
if age_days >= refresh_days:
|
||||
# Cache expired, generate new
|
||||
uas = generate_ua_pool(count)
|
||||
save_ua_pool(uas, cache_path)
|
||||
return uas
|
||||
|
||||
# Cache is valid, return it
|
||||
return cache_data['user_agents']
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
# Cache file is corrupted, generate new
|
||||
pass
|
||||
|
||||
# No valid cache, generate new
|
||||
uas = generate_ua_pool(count)
|
||||
save_ua_pool(uas, cache_path)
|
||||
return uas
|
||||
|
||||
|
||||
def get_random_ua(ua_pool: List[str]) -> str:
|
||||
"""
|
||||
Get a random UA from the pool.
|
||||
|
||||
Args:
|
||||
ua_pool: List of UA strings
|
||||
|
||||
Returns:
|
||||
str: Random UA string from the pool
|
||||
"""
|
||||
if not ua_pool:
|
||||
# Fallback to generating one if pool is empty
|
||||
try:
|
||||
return generate_opera_ua()
|
||||
except Exception:
|
||||
# If generation fails, use default fallback
|
||||
return DEFAULT_FALLBACK_UA
|
||||
|
||||
return random.choice(ua_pool)
|
||||
|
||||
@ -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,8 +0,0 @@
|
||||
import os
|
||||
|
||||
optional_dev_tag = ''
|
||||
if os.getenv('DEV_BUILD'):
|
||||
optional_dev_tag = '.dev' + os.getenv('DEV_BUILD')
|
||||
|
||||
__version__ = '1.2.2' + optional_dev_tag
|
||||
|
||||
@ -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.9.4
|
||||
|
||||
icon: https://github.com/benbusby/whoogle-search/raw/main/app/static/img/favicon/favicon-96x96.png
|
||||
|
||||
sources:
|
||||
- https://github.com/benbusby/whoogle-search
|
||||
- https://gitlab.com/benbusby/whoogle-search
|
||||
- https://gogs.benbusby.com/benbusby/whoogle-search
|
||||
|
||||
keywords:
|
||||
- whoogle
|
||||
- degoogle
|
||||
- search
|
||||
- google
|
||||
- search-engine
|
||||
- privacy
|
||||
- tor
|
||||
- python
|
||||
@ -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,44 +0,0 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: autoscaling/v2
|
||||
{{- else -}}
|
||||
apiVersion: autoscaling/v2beta1
|
||||
{{- end }}
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "whoogle.fullname" . }}
|
||||
labels:
|
||||
{{- include "whoogle.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "whoogle.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- else -}}
|
||||
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- else -}}
|
||||
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@ -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,115 +0,0 @@
|
||||
# Default values for whoogle.
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: benbusby/whoogle-search
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: ""
|
||||
pullSecrets: []
|
||||
# - my-image-pull-secret
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
conf: {}
|
||||
# WHOOGLE_URL_PREFIX: "" # The URL prefix to use for the whoogle instance (i.e. "/whoogle")
|
||||
# WHOOGLE_DOTENV: "" # Load environment variables in whoogle.env
|
||||
# WHOOGLE_USER: "" # The username for basic auth. WHOOGLE_PASS must also be set if used.
|
||||
# WHOOGLE_PASS: "" # The password for basic auth. WHOOGLE_USER must also be set if used.
|
||||
# WHOOGLE_PROXY_USER: "" # The username of the proxy server.
|
||||
# WHOOGLE_PROXY_PASS: "" # The password of the proxy server.
|
||||
# WHOOGLE_PROXY_TYPE: "" # The type of the proxy server. Can be "socks5", "socks4", or "http".
|
||||
# WHOOGLE_PROXY_LOC: "" # The location of the proxy server (host or ip).
|
||||
# EXPOSE_PORT: "" # The port where Whoogle will be exposed. (default 5000)
|
||||
# HTTPS_ONLY: "" # Enforce HTTPS. (See https://github.com/benbusby/whoogle-search#https-enforcement)
|
||||
# WHOOGLE_ALT_TW: "" # The twitter.com alternative to use when site alternatives are enabled in the config.
|
||||
# WHOOGLE_ALT_YT: "" # The youtube.com alternative to use when site alternatives are enabled in the config.
|
||||
# WHOOGLE_ALT_RD: "" # The reddit.com alternative to use when site alternatives are enabled in the config.
|
||||
# WHOOGLE_ALT_TL: "" # The Google Translate alternative to use. This is used for all "translate ____" searches.
|
||||
# WHOOGLE_ALT_MD: "" # The medium.com alternative to use when site alternatives are enabled in the config.
|
||||
# WHOOGLE_ALT_IMG: "" # The imgur.com alternative to use when site alternatives are enabled in the config.
|
||||
# WHOOGLE_ALT_WIKI: "" # The wikipedia.com alternative to use when site alternatives are enabled in the config.
|
||||
# WHOOGLE_ALT_IMDB: "" # The imdb.com alternative to use. Set to "" to continue using imdb.com when site alternatives are enabled.
|
||||
# WHOOGLE_ALT_QUORA: "" # The quora.com alternative to use. Set to "" to continue using quora.com when site alternatives are enabled.
|
||||
# WHOOGLE_ALT_SO: "" # The stackoverflow.com alternative to use. Set to "" to continue using stackoverflow.com when site alternatives are enabled.
|
||||
# WHOOGLE_AUTOCOMPLETE: "" # Controls visibility of autocomplete/search suggestions. Default on -- use '0' to disable
|
||||
# WHOOGLE_MINIMAL: "" # Remove everything except basic result cards from all search queries.
|
||||
|
||||
# WHOOGLE_CONFIG_DISABLE: "" # Hide config from UI and disallow changes to config by client
|
||||
# WHOOGLE_CONFIG_COUNTRY: "" # Filter results by hosting country
|
||||
# WHOOGLE_CONFIG_LANGUAGE: "" # Set interface language
|
||||
# WHOOGLE_CONFIG_SEARCH_LANGUAGE: "" # Set search result language
|
||||
# WHOOGLE_CONFIG_BLOCK: "" # Block websites from search results (use comma-separated list)
|
||||
# WHOOGLE_CONFIG_THEME: "" # Set theme mode (light, dark, or system)
|
||||
# WHOOGLE_CONFIG_SAFE: "" # Enable safe searches
|
||||
# WHOOGLE_CONFIG_ALTS: "" # Use social media site alternatives (nitter, invidious, etc)
|
||||
# WHOOGLE_CONFIG_NEAR: "" # Restrict results to only those near a particular city
|
||||
# WHOOGLE_CONFIG_TOR: "" # Use Tor routing (if available)
|
||||
# WHOOGLE_CONFIG_NEW_TAB: "" # Always open results in new tab
|
||||
# WHOOGLE_CONFIG_VIEW_IMAGE: "" # Enable View Image option
|
||||
# WHOOGLE_CONFIG_GET_ONLY: "" # Search using GET requests only
|
||||
# WHOOGLE_CONFIG_URL: "" # The root url of the instance (https://<your url>/)
|
||||
# WHOOGLE_CONFIG_STYLE: "" # The custom CSS to use for styling (should be single line)
|
||||
# WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED: "" # Encrypt preferences token, requires key
|
||||
# WHOOGLE_CONFIG_PREFERENCES_KEY: "" # Key to encrypt preferences in URL (REQUIRED to show url)
|
||||
|
||||
podAnnotations: {}
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 5000
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
hosts:
|
||||
- host: whoogle.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: ImplementationSpecific
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - whoogle.example.com
|
||||
|
||||
resources: {}
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 100
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
@ -1,81 +0,0 @@
|
||||
# can't use mem_limit in a 3.x docker-compose file in non swarm mode
|
||||
# see https://github.com/docker/compose/issues/4513
|
||||
version: "2.4"
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
container_name: "traefik"
|
||||
command:
|
||||
#- "--log.level=DEBUG"
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
|
||||
#- "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
- "--certificatesresolvers.myresolver.acme.email=change@domain.name"
|
||||
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- "443:443"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- "./letsencrypt:/letsencrypt"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
|
||||
whoogle-search:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.whoami.rule=Host(`change.host.name`)"
|
||||
- "traefik.http.routers.whoami.entrypoints=websecure"
|
||||
- "traefik.http.routers.whoami.tls.certresolver=myresolver"
|
||||
- "traefik.http.services.whoogle-search.loadbalancer.server.port=5000"
|
||||
image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search}
|
||||
container_name: whoogle-search
|
||||
restart: unless-stopped
|
||||
pids_limit: 50
|
||||
mem_limit: 256mb
|
||||
memswap_limit: 256mb
|
||||
# user debian-tor from tor package
|
||||
user: whoogle
|
||||
security_opt:
|
||||
- no-new-privileges
|
||||
cap_drop:
|
||||
- ALL
|
||||
tmpfs:
|
||||
- /config/:size=10M,uid=927,gid=927,mode=1700
|
||||
- /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700
|
||||
- /run/tor/:size=1M,uid=927,gid=927,mode=1700
|
||||
environment: # Uncomment to configure environment variables
|
||||
# Basic auth configuration, uncomment to enable
|
||||
#- WHOOGLE_USER=<auth username>
|
||||
#- WHOOGLE_PASS=<auth password>
|
||||
# Proxy configuration, uncomment to enable
|
||||
#- WHOOGLE_PROXY_USER=<proxy username>
|
||||
#- WHOOGLE_PROXY_PASS=<proxy password>
|
||||
#- WHOOGLE_PROXY_TYPE=<proxy type (http|https|socks4|socks5)
|
||||
#- WHOOGLE_PROXY_LOC=<proxy host/ip>
|
||||
# Site alternative configurations, uncomment to enable
|
||||
# Note: If not set, the feature will still be available
|
||||
# with default values.
|
||||
#- WHOOGLE_ALT_TW=farside.link/nitter
|
||||
#- WHOOGLE_ALT_YT=farside.link/invidious
|
||||
#- WHOOGLE_ALT_IG=farside.link/bibliogram/u
|
||||
#- WHOOGLE_ALT_RD=farside.link/libreddit
|
||||
#- WHOOGLE_ALT_MD=farside.link/scribe
|
||||
#- WHOOGLE_ALT_TL=farside.link/lingva
|
||||
#- WHOOGLE_ALT_IMG=farside.link/rimgo
|
||||
#- WHOOGLE_ALT_WIKI=farside.link/wikiless
|
||||
#- WHOOGLE_ALT_IMDB=farside.link/libremdb
|
||||
#- WHOOGLE_ALT_QUORA=farside.link/quetre
|
||||
#- WHOOGLE_ALT_SO=farside.link/anonymousoverflow
|
||||
# - WHOOGLE_CONFIG_DISABLE=1
|
||||
# - WHOOGLE_CONFIG_SEARCH_LANGUAGE=lang_en
|
||||
# - WHOOGLE_CONFIG_GET_ONLY=1
|
||||
# - WHOOGLE_CONFIG_COUNTRY=FR
|
||||
# - WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED=1
|
||||
# - WHOOGLE_CONFIG_PREFERENCES_KEY="NEEDS_TO_BE_MODIFIED"
|
||||
#env_file: # Alternatively, load variables from whoogle.env
|
||||
#- whoogle.env
|
||||
ports:
|
||||
- 8000:5000
|
||||
@ -1,24 +1,26 @@
|
||||
# Modern docker-compose format (v2+) does not require version specification
|
||||
# Memory limits are supported in Compose v2+ without version field
|
||||
# cant use mem_limit in a 3.x docker-compose file in non swarm mode
|
||||
# see https://github.com/docker/compose/issues/4513
|
||||
version: "2.4"
|
||||
|
||||
services:
|
||||
whoogle-search:
|
||||
image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search}
|
||||
image: benbusby/whoogle-search
|
||||
container_name: whoogle-search
|
||||
restart: unless-stopped
|
||||
pids_limit: 50
|
||||
mem_limit: 256mb
|
||||
memswap_limit: 256mb
|
||||
# user debian-tor from tor package
|
||||
user: whoogle
|
||||
user: '102'
|
||||
security_opt:
|
||||
- no-new-privileges
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /config/:size=10M,uid=927,gid=927,mode=1700
|
||||
- /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700
|
||||
- /run/tor/:size=1M,uid=927,gid=927,mode=1700
|
||||
- /config/:size=10M,uid=102,gid=102,mode=1700
|
||||
- /var/lib/tor/:size=10M,uid=102,gid=102,mode=1700
|
||||
- /run/tor/:size=1M,uid=102,gid=102,mode=1700
|
||||
#environment: # Uncomment to configure environment variables
|
||||
# Basic auth configuration, uncomment to enable
|
||||
#- WHOOGLE_USER=<auth username>
|
||||
@ -26,22 +28,15 @@ services:
|
||||
# Proxy configuration, uncomment to enable
|
||||
#- WHOOGLE_PROXY_USER=<proxy username>
|
||||
#- WHOOGLE_PROXY_PASS=<proxy password>
|
||||
#- WHOOGLE_PROXY_TYPE=<proxy type (http|https|socks4|socks5)
|
||||
#- WHOOGLE_PROXY_TYPE=<proxy type (http|socks4|socks5)
|
||||
#- WHOOGLE_PROXY_LOC=<proxy host/ip>
|
||||
# Site alternative configurations, uncomment to enable
|
||||
# Note: If not set, the feature will still be available
|
||||
# with default values.
|
||||
#- WHOOGLE_ALT_TW=farside.link/nitter
|
||||
#- WHOOGLE_ALT_YT=farside.link/invidious
|
||||
#- WHOOGLE_ALT_IG=farside.link/bibliogram/u
|
||||
#- WHOOGLE_ALT_RD=farside.link/libreddit
|
||||
#- WHOOGLE_ALT_MD=farside.link/scribe
|
||||
#- WHOOGLE_ALT_TL=farside.link/lingva
|
||||
#- WHOOGLE_ALT_IMG=farside.link/rimgo
|
||||
#- WHOOGLE_ALT_WIKI=farside.link/wikiless
|
||||
#- WHOOGLE_ALT_IMDB=farside.link/libremdb
|
||||
#- WHOOGLE_ALT_QUORA=farside.link/quetre
|
||||
#- WHOOGLE_ALT_SO=farside.link/anonymousoverflow
|
||||
# with default values.
|
||||
#- WHOOGLE_ALT_TW=nitter.net
|
||||
#- WHOOGLE_ALT_YT=invidious.snopyta.org
|
||||
#- WHOOGLE_ALT_IG=bibliogram.art/u
|
||||
#- WHOOGLE_ALT_RD=libredd.it
|
||||
#env_file: # Alternatively, load variables from whoogle.env
|
||||
#- whoogle.env
|
||||
ports:
|
||||
|
||||
BIN
docs/screenshot_desktop.jpg
Normal file
BIN
docs/screenshot_desktop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 214 KiB |
BIN
docs/screenshot_mobile.jpg
Normal file
BIN
docs/screenshot_mobile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB |
@ -1,363 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test User Agent strings against Google to find which ones return actual search results
|
||||
instead of JavaScript pages or upgrade browser messages.
|
||||
|
||||
Usage:
|
||||
python test_google_user_agents.py <user_agent_file> [--output <output_file>] [--query <search_query>]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from typing import List, Tuple
|
||||
import requests
|
||||
|
||||
# Common search queries to cycle through for more realistic testing
|
||||
DEFAULT_SEARCH_QUERIES = [
|
||||
"python programming",
|
||||
"weather today",
|
||||
"news",
|
||||
"how to cook pasta",
|
||||
"best movies 2025",
|
||||
"restaurants near me",
|
||||
"translate hello",
|
||||
"calculator",
|
||||
"time",
|
||||
"maps",
|
||||
"images",
|
||||
"videos",
|
||||
"shopping",
|
||||
"travel",
|
||||
"sports scores",
|
||||
"stock market",
|
||||
"recipes",
|
||||
"music",
|
||||
"books",
|
||||
"technology",
|
||||
"AI",
|
||||
"AI programming",
|
||||
"Why does google hate users?"
|
||||
]
|
||||
|
||||
# Markers that indicate blocked/JS pages
|
||||
BLOCK_MARKERS = [
|
||||
"unusual traffic",
|
||||
"sorry but your computer",
|
||||
"solve the captcha",
|
||||
"request looks automated",
|
||||
"g-recaptcha",
|
||||
"upgrade your browser",
|
||||
"browser is not supported",
|
||||
"please upgrade",
|
||||
"isn't supported",
|
||||
"isn\"t supported", # With escaped quote
|
||||
"upgrade to a recent version",
|
||||
"update your browser",
|
||||
"your browser isn't supported",
|
||||
]
|
||||
|
||||
# Markers that indicate actual search results
|
||||
SUCCESS_MARKERS = [
|
||||
'<div class="g"', # Google search result container
|
||||
'<div id="search"', # Search results container
|
||||
'<div class="rc"', # Result container
|
||||
'class="yuRUbf"', # Result link container
|
||||
'class="LC20lb"', # Result title
|
||||
'- Google Search</title>', # Page title indicator
|
||||
'id="rso"', # Results container
|
||||
'class="g"', # Result class (without div tag)
|
||||
]
|
||||
|
||||
|
||||
def read_user_agents(file_path: str) -> List[str]:
|
||||
"""Read user agent strings from a file, one per line."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
user_agents = [line.strip() for line in f if line.strip()]
|
||||
return user_agents
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File '{file_path}' not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def test_user_agent(user_agent: str, query: str = "test", timeout: float = 10.0) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test a user agent against Google search.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_working: bool, reason: str)
|
||||
"""
|
||||
url = "https://www.google.com/search"
|
||||
params = {"q": query, "gbv": "1", "num": "10"}
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=timeout)
|
||||
|
||||
# Check HTTP status
|
||||
if response.status_code == 429:
|
||||
# Rate limited - raise this so we can handle it specially
|
||||
raise Exception(f"Rate limited (429)")
|
||||
if response.status_code >= 500:
|
||||
return False, f"Server error ({response.status_code})"
|
||||
if response.status_code == 403:
|
||||
return False, f"Blocked ({response.status_code})"
|
||||
if response.status_code >= 400:
|
||||
return False, f"HTTP {response.status_code}"
|
||||
|
||||
body_lower = response.text.lower()
|
||||
|
||||
# Check for block markers
|
||||
for marker in BLOCK_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
return False, f"Blocked: {marker}"
|
||||
|
||||
# Check for redirect indicators first - these indicate non-working responses
|
||||
has_redirect = ("window.location" in body_lower or "location.href" in body_lower) and "google.com" not in body_lower
|
||||
if has_redirect:
|
||||
return False, "JavaScript redirect detected"
|
||||
|
||||
# Check for noscript redirect (another indicator of JS-only page)
|
||||
if 'noscript' in body_lower and 'http-equiv="refresh"' in body_lower:
|
||||
return False, "NoScript redirect page"
|
||||
|
||||
# Check for success markers (actual search results)
|
||||
# We need at least one strong indicator of search results
|
||||
has_results = any(marker in response.text for marker in SUCCESS_MARKERS)
|
||||
|
||||
if has_results:
|
||||
return True, "OK - Has search results"
|
||||
else:
|
||||
# Check for very short responses (likely error pages)
|
||||
if len(response.text) < 1000:
|
||||
return False, "Response too short (likely error page)"
|
||||
# If we don't have success markers, it's not a working response
|
||||
# Even if it's substantial and doesn't have block markers, it might be a JS-only page
|
||||
return False, "No search results found"
|
||||
|
||||
except requests.Timeout:
|
||||
return False, "Request timeout"
|
||||
except requests.HTTPError as e:
|
||||
if e.response and e.response.status_code == 429:
|
||||
# Rate limited - raise this so we can handle it specially
|
||||
raise Exception(f"Rate limited (429) - {str(e)}")
|
||||
return False, f"HTTP error: {str(e)}"
|
||||
except requests.RequestException as e:
|
||||
# Check if it's a 429 in the response
|
||||
if hasattr(e, 'response') and e.response and e.response.status_code == 429:
|
||||
raise Exception(f"Rate limited (429) - {str(e)}")
|
||||
return False, f"Request error: {str(e)}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test User Agent strings against Google to find working ones.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python test_google_user_agents.py UAs.txt
|
||||
python test_google_user_agents.py UAs.txt --output working_uas.txt
|
||||
python test_google_user_agents.py UAs.txt --query "python programming"
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
"user_agent_file",
|
||||
help="Path to file containing user agent strings (one per line)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
help="Output file to write working user agents (default: stdout)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query", "-q",
|
||||
default=None,
|
||||
help="Search query to use for testing (default: cycles through random queries)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--random-queries", "-r",
|
||||
action="store_true",
|
||||
help="Use random queries from a predefined list (default: True if --query not specified)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", "-t",
|
||||
type=float,
|
||||
default=10.0,
|
||||
help="Request timeout in seconds (default: 10.0)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay", "-d",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Delay between requests in seconds (default: 0.5)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Show detailed results for each user agent"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine query strategy
|
||||
use_random_queries = args.random_queries or (args.query is None)
|
||||
if use_random_queries:
|
||||
search_queries = DEFAULT_SEARCH_QUERIES.copy()
|
||||
random.shuffle(search_queries) # Shuffle for variety
|
||||
current_query_idx = 0
|
||||
query_display = f"cycling through {len(search_queries)} random queries"
|
||||
else:
|
||||
search_queries = [args.query]
|
||||
query_display = f"'{args.query}'"
|
||||
|
||||
# Read user agents
|
||||
user_agents = read_user_agents(args.user_agent_file)
|
||||
if not user_agents:
|
||||
print("No user agents found in file.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Testing {len(user_agents)} user agents against Google...", file=sys.stderr)
|
||||
print(f"Query: {query_display}", file=sys.stderr)
|
||||
if args.output:
|
||||
print(f"Output file: {args.output} (appending results incrementally)", file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
# Load existing working user agents from output file to avoid duplicates
|
||||
existing_working = set()
|
||||
if args.output:
|
||||
try:
|
||||
with open(args.output, 'r', encoding='utf-8') as f:
|
||||
existing_working = {line.strip() for line in f if line.strip()}
|
||||
if existing_working:
|
||||
print(f"Found {len(existing_working)} existing user agents in output file", file=sys.stderr)
|
||||
except FileNotFoundError:
|
||||
# File doesn't exist yet, that's fine
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read existing output file: {e}", file=sys.stderr)
|
||||
|
||||
# Open output file for incremental writing if specified (append mode)
|
||||
output_file = None
|
||||
if args.output:
|
||||
try:
|
||||
output_file = open(args.output, 'a', encoding='utf-8')
|
||||
except Exception as e:
|
||||
print(f"Error opening output file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
working_agents = []
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
last_successful_idx = 0
|
||||
|
||||
try:
|
||||
for idx, ua in enumerate(user_agents, 1):
|
||||
# Skip testing if this UA is already in the working file
|
||||
if args.output and ua in existing_working:
|
||||
skipped_count += 1
|
||||
if args.verbose:
|
||||
print(f"[{idx}/{len(user_agents)}] ⊘ SKIPPED - Already in working file", file=sys.stderr)
|
||||
last_successful_idx = idx
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get the next query (cycle through if using random queries)
|
||||
if use_random_queries:
|
||||
query = search_queries[current_query_idx % len(search_queries)]
|
||||
current_query_idx += 1
|
||||
else:
|
||||
query = args.query
|
||||
|
||||
is_working, reason = test_user_agent(ua, query, args.timeout)
|
||||
|
||||
if is_working:
|
||||
working_agents.append(ua)
|
||||
status = "✓"
|
||||
# Write immediately to output file if specified (skip if duplicate)
|
||||
if output_file:
|
||||
if ua not in existing_working:
|
||||
output_file.write(ua + '\n')
|
||||
output_file.flush() # Ensure it's written to disk
|
||||
existing_working.add(ua) # Track it to avoid duplicates
|
||||
else:
|
||||
if args.verbose:
|
||||
print(f"[{idx}/{len(user_agents)}] {status} WORKING (duplicate, skipped) - {reason}", file=sys.stderr)
|
||||
# Also print to stdout if no output file
|
||||
if not args.output:
|
||||
print(ua)
|
||||
|
||||
if args.verbose:
|
||||
print(f"[{idx}/{len(user_agents)}] {status} WORKING - {reason}", file=sys.stderr)
|
||||
else:
|
||||
failed_count += 1
|
||||
status = "✗"
|
||||
if args.verbose:
|
||||
print(f"[{idx}/{len(user_agents)}] {status} FAILED - {reason}", file=sys.stderr)
|
||||
|
||||
last_successful_idx = idx
|
||||
|
||||
# Progress indicator for non-verbose mode
|
||||
if not args.verbose and idx % 10 == 0:
|
||||
print(f"Progress: {idx}/{len(user_agents)} tested ({len(working_agents)} working, {failed_count} failed)", file=sys.stderr)
|
||||
|
||||
# Delay between requests to avoid rate limiting
|
||||
if idx < len(user_agents):
|
||||
time.sleep(args.delay)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(file=sys.stderr)
|
||||
print(f"\nInterrupted by user at index {idx}/{len(user_agents)}", file=sys.stderr)
|
||||
print(f"Last successful test: {last_successful_idx}/{len(user_agents)}", file=sys.stderr)
|
||||
break
|
||||
except Exception as e:
|
||||
# Handle unexpected errors (like network issues or rate limits)
|
||||
error_msg = str(e)
|
||||
if "429" in error_msg or "Rate limited" in error_msg:
|
||||
print(file=sys.stderr)
|
||||
print(f"\n⚠️ RATE LIMIT DETECTED at index {idx}/{len(user_agents)}", file=sys.stderr)
|
||||
print(f"Last successful test: {last_successful_idx}/{len(user_agents)}", file=sys.stderr)
|
||||
print(f"Working user agents found so far: {len(working_agents)}", file=sys.stderr)
|
||||
if args.output:
|
||||
print(f"Results saved to: {args.output}", file=sys.stderr)
|
||||
print(f"\nTo resume later, you can skip the first {last_successful_idx} user agents.", file=sys.stderr)
|
||||
raise # Re-raise to exit the loop
|
||||
else:
|
||||
print(f"[{idx}/{len(user_agents)}] ERROR - {error_msg}", file=sys.stderr)
|
||||
failed_count += 1
|
||||
last_successful_idx = idx
|
||||
if idx < len(user_agents):
|
||||
time.sleep(args.delay)
|
||||
continue
|
||||
|
||||
finally:
|
||||
# Close output file if opened
|
||||
if output_file:
|
||||
output_file.close()
|
||||
|
||||
# Summary
|
||||
print(file=sys.stderr)
|
||||
tested_count = last_successful_idx - skipped_count
|
||||
print(f"Summary: {len(working_agents)} working, {failed_count} failed, {skipped_count} skipped out of {last_successful_idx} processed (of {len(user_agents)} total)", file=sys.stderr)
|
||||
if last_successful_idx < len(user_agents):
|
||||
print(f"Note: Processing stopped at index {last_successful_idx}. {len(user_agents) - last_successful_idx} user agents not processed.", file=sys.stderr)
|
||||
if args.output:
|
||||
print(f"Results saved to: {args.output}", file=sys.stderr)
|
||||
|
||||
return 0 if working_agents else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@ -1,198 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone Opera User Agent String Generator
|
||||
|
||||
This tool generates Opera-based User Agent strings that can be used with Whoogle.
|
||||
It can be run independently to generate and display UA strings on demand.
|
||||
|
||||
Usage:
|
||||
python misc/generate_uas.py [count]
|
||||
|
||||
Arguments:
|
||||
count: Number of UA strings to generate (default: 10)
|
||||
|
||||
Examples:
|
||||
python misc/generate_uas.py # Generate 10 UAs
|
||||
python misc/generate_uas.py 20 # Generate 20 UAs
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Default fallback UA if generation fails
|
||||
DEFAULT_FALLBACK_UA = "Opera/9.30 (Nintendo Wii; U; ; 3642; en)"
|
||||
|
||||
# Try to import from the app module if available
|
||||
try:
|
||||
# Add parent directory to path to allow imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from app.utils.ua_generator import generate_ua_pool
|
||||
USE_APP_MODULE = True
|
||||
except ImportError:
|
||||
USE_APP_MODULE = False
|
||||
# Self-contained version if app module is not available
|
||||
import random
|
||||
|
||||
# Opera UA Pattern Templates
|
||||
OPERA_PATTERNS = [
|
||||
"Opera/9.80 (J2ME/MIDP; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
"Opera/9.80 (Android; Linux; Opera Mobi/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
"Opera/9.80 (iPhone; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
"Opera/9.80 (iPad; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
]
|
||||
|
||||
OPERA_MINI_VERSIONS = [
|
||||
"4.0", "4.1.11321", "4.2.13337", "4.2.14912", "4.2.15410", "4.3.24214",
|
||||
"5.0.18741", "5.1.22296", "5.1.22783", "6.0.24095", "6.24093", "7.1.32444",
|
||||
"7.6.35766", "36.2.2254"
|
||||
]
|
||||
|
||||
OPERA_MOBI_BUILDS = [
|
||||
"27", "49", "447", "1209", "3730", "ADR-1012221546", "SYB-1107071606"
|
||||
]
|
||||
|
||||
BUILD_NUMBERS = [
|
||||
"22.387", "22.478", "23.334", "23.377", "24.746", "24.783", "25.657",
|
||||
"27.1407", "28.2647", "35.5706", "119.132", "870", "886"
|
||||
]
|
||||
|
||||
PRESTO_VERSIONS = [
|
||||
"2.4.15", "2.4.18", "2.5.25", "2.8.119", "2.12.423"
|
||||
]
|
||||
|
||||
FINAL_VERSIONS = [
|
||||
"10.00", "10.1", "10.54", "11.10", "12.16", "13.00"
|
||||
]
|
||||
|
||||
LANGUAGES = [
|
||||
# English variants
|
||||
"en", "en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-ZA", "en-IN", "en-SG",
|
||||
# Western European
|
||||
"de", "de-DE", "de-AT", "de-CH",
|
||||
"fr", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU",
|
||||
"es", "es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-LA",
|
||||
"it", "it-IT", "it-CH",
|
||||
"pt", "pt-PT", "pt-BR",
|
||||
"nl", "nl-NL", "nl-BE",
|
||||
# Nordic languages
|
||||
"da", "da-DK",
|
||||
"sv", "sv-SE",
|
||||
"no", "no-NO", "nb", "nn",
|
||||
"fi", "fi-FI",
|
||||
"is", "is-IS",
|
||||
# Eastern European
|
||||
"pl", "pl-PL",
|
||||
"cs", "cs-CZ",
|
||||
"sk", "sk-SK",
|
||||
"hu", "hu-HU",
|
||||
"ro", "ro-RO",
|
||||
"bg", "bg-BG",
|
||||
"hr", "hr-HR",
|
||||
"sr", "sr-RS",
|
||||
"sl", "sl-SI",
|
||||
"uk", "uk-UA",
|
||||
"ru", "ru-RU",
|
||||
# Asian languages
|
||||
"zh", "zh-CN", "zh-TW", "zh-HK",
|
||||
"ja", "ja-JP",
|
||||
"ko", "ko-KR",
|
||||
"th", "th-TH",
|
||||
"vi", "vi-VN",
|
||||
"id", "id-ID",
|
||||
"ms", "ms-MY",
|
||||
"fil", "tl",
|
||||
# Middle Eastern
|
||||
"tr", "tr-TR",
|
||||
"ar", "ar-SA", "ar-AE", "ar-EG",
|
||||
"he", "he-IL",
|
||||
"fa", "fa-IR",
|
||||
# Other
|
||||
"hi", "hi-IN",
|
||||
"bn", "bn-IN",
|
||||
"ta", "ta-IN",
|
||||
"te", "te-IN",
|
||||
"mr", "mr-IN",
|
||||
"el", "el-GR",
|
||||
"ca", "ca-ES",
|
||||
"eu", "eu-ES"
|
||||
]
|
||||
|
||||
def generate_opera_ua():
|
||||
"""Generate a single random Opera User Agent string."""
|
||||
pattern = random.choice(OPERA_PATTERNS)
|
||||
params = {'lang': random.choice(LANGUAGES)}
|
||||
|
||||
if '{version}' in pattern:
|
||||
params['version'] = random.choice(OPERA_MINI_VERSIONS)
|
||||
if '{build}' in pattern:
|
||||
if "Opera Mobi" in pattern:
|
||||
params['build'] = random.choice(OPERA_MOBI_BUILDS)
|
||||
else:
|
||||
params['build'] = random.choice(BUILD_NUMBERS)
|
||||
if '{presto}' in pattern:
|
||||
params['presto'] = random.choice(PRESTO_VERSIONS)
|
||||
if '{final}' in pattern:
|
||||
params['final'] = random.choice(FINAL_VERSIONS)
|
||||
|
||||
return pattern.format(**params)
|
||||
|
||||
def generate_ua_pool(count=10):
|
||||
"""Generate a pool of unique Opera User Agent strings."""
|
||||
ua_pool = set()
|
||||
max_attempts = count * 100
|
||||
attempts = 0
|
||||
|
||||
try:
|
||||
while len(ua_pool) < count and attempts < max_attempts:
|
||||
ua = generate_opera_ua()
|
||||
ua_pool.add(ua)
|
||||
attempts += 1
|
||||
except Exception:
|
||||
# If generation fails entirely, return at least the default fallback
|
||||
if not ua_pool:
|
||||
return [DEFAULT_FALLBACK_UA]
|
||||
|
||||
# If we couldn't generate enough, fill remaining with default
|
||||
result = list(ua_pool)
|
||||
while len(result) < count:
|
||||
result.append(DEFAULT_FALLBACK_UA)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to generate and display UA strings."""
|
||||
# Parse command line argument
|
||||
count = 10 # Default
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
count = int(sys.argv[1])
|
||||
if count < 1:
|
||||
print("Error: Count must be a positive integer", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid count '{sys.argv[1]}'. Must be an integer.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Show which mode we're using (to stderr so it doesn't interfere with output)
|
||||
if USE_APP_MODULE:
|
||||
print(f"# Using app.utils.ua_generator module", file=sys.stderr)
|
||||
else:
|
||||
print(f"# Using standalone generator (app module not available)", file=sys.stderr)
|
||||
|
||||
print(f"# Generating {count} Opera User Agent strings...\n", file=sys.stderr)
|
||||
|
||||
# Generate UAs
|
||||
uas = generate_ua_pool(count)
|
||||
|
||||
# Display them (one per line, no numbering)
|
||||
for ua in uas:
|
||||
print(ua)
|
||||
|
||||
# Summary to stderr so it doesn't interfere with piping
|
||||
print(f"\n# Generated {len(uas)} unique User Agent strings", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
https://search.garudalinux.org
|
||||
https://search.sethforprivacy.com
|
||||
https://whoogle.privacydev.net
|
||||
https://wg.vern.cc
|
||||
https://whoogle.lunar.icu
|
||||
https://whoogle.4040940.xyz
|
||||
@ -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
|
||||
|
||||
FF_STRING="FascistFirewall 1"
|
||||
|
||||
if [ "$WHOOGLE_TOR_SERVICE" == "0" ]; then
|
||||
echo "Skipping Tor startup..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$WHOOGLE_TOR_FF" == "1" ]; then
|
||||
if (grep -q "$FF_STRING" /etc/tor/torrc); then
|
||||
echo "FascistFirewall feature already enabled."
|
||||
else
|
||||
echo "$FF_STRING" >> /etc/tor/torrc
|
||||
|
||||
if [ "$?" -eq 0 ]; then
|
||||
echo "FascistFirewall added to /etc/tor/torrc"
|
||||
else
|
||||
echo "ERROR: Unable to modify /etc/tor/torrc with $FF_STRING."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$(whoami)" != "root" ]; then
|
||||
tor -f /etc/tor/torrc
|
||||
else
|
||||
if (grep alpine /etc/os-release >/dev/null); then
|
||||
rc-service tor start
|
||||
else
|
||||
service tor start
|
||||
fi
|
||||
service tor start
|
||||
fi
|
||||
|
||||
@ -6,7 +6,3 @@ CookieAuthFileGroupReadable 1
|
||||
ExtORPortCookieAuthFileGroupReadable 1
|
||||
CacheDirectoryGroupReadable 1
|
||||
CookieAuthFile /var/lib/tor/control_auth_cookie
|
||||
Log debug-notice file /dev/null
|
||||
# UseBridges 1
|
||||
# ClientTransportPlugin obfs4 exec /usr/bin/obfs4proxy
|
||||
# Bridge obfs4 ip and so on
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
import json
|
||||
import pathlib
|
||||
import httpx
|
||||
|
||||
lingva = 'https://lingva.ml/api/v1/en'
|
||||
|
||||
|
||||
def format_lang(lang: str) -> str:
|
||||
# Chinese (traditional and simplified) require
|
||||
# a different format for lingva translations
|
||||
if 'zh-' in lang:
|
||||
if lang == 'zh-TW':
|
||||
return 'zh_HANT'
|
||||
return 'zh'
|
||||
|
||||
# Strip lang prefix to leave only the actual
|
||||
# language code (i.e. 'en', 'fr', etc)
|
||||
return lang.replace('lang_', '')
|
||||
|
||||
|
||||
def translate(v: str, lang: str) -> str:
|
||||
# Strip lang prefix to leave only the actual
|
||||
#language code (i.e. "es", "fr", etc)
|
||||
lang = format_lang(lang)
|
||||
|
||||
lingva_req = f'{lingva}/{lang}/{v}'
|
||||
|
||||
response = httpx.get(lingva_req).json()
|
||||
|
||||
if 'translation' in response:
|
||||
return response['translation']
|
||||
return ''
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
file_path = pathlib.Path(__file__).parent.resolve()
|
||||
tl_path = 'app/static/settings/translations.json'
|
||||
|
||||
with open(f'{file_path}/../{tl_path}', 'r+', encoding='utf-8') as tl_file:
|
||||
tl_data = json.load(tl_file)
|
||||
|
||||
# If there are any english translations that don't
|
||||
# exist for other languages, extract them and translate
|
||||
# them now
|
||||
en_tl = tl_data['lang_en']
|
||||
for k, v in en_tl.items():
|
||||
for lang in tl_data:
|
||||
if lang == 'lang_en' or k in tl_data[lang]:
|
||||
continue
|
||||
|
||||
translation = ''
|
||||
if len(k) == 0:
|
||||
# Special case for placeholder text that gets used
|
||||
# for translations without any key present
|
||||
translation = v
|
||||
else:
|
||||
# Translate the string using lingva
|
||||
translation = translate(v, lang)
|
||||
|
||||
if len(translation) == 0:
|
||||
print(f'! Unable to translate {lang}[{k}]')
|
||||
continue
|
||||
print(f'{lang}[{k}] = {translation}')
|
||||
tl_data[lang][k] = translation
|
||||
|
||||
# Write out updated translations json
|
||||
print(json.dumps(tl_data, indent=4, ensure_ascii=False))
|
||||
@ -1,16 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
lint.select = [
|
||||
"E", "F", "W", # pycodestyle/pyflakes
|
||||
"I", # isort
|
||||
]
|
||||
lint.ignore = []
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py312']
|
||||
@ -1,34 +1,34 @@
|
||||
attrs==25.3.0
|
||||
beautifulsoup4==4.13.5
|
||||
brotli==1.2.0
|
||||
certifi==2025.8.3
|
||||
cffi==2.0.0
|
||||
click==8.3.0
|
||||
cryptography==46.0.1
|
||||
cssutils==2.11.1
|
||||
defusedxml==0.7.1
|
||||
Flask==3.1.2
|
||||
idna==3.10
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.2
|
||||
more-itertools==10.8.0
|
||||
packaging==25.0
|
||||
pluggy==1.6.0
|
||||
pycodestyle==2.14.0
|
||||
pycparser==2.22
|
||||
pyOpenSSL==25.3.0
|
||||
pyparsing==3.2.5
|
||||
pytest==8.3.3
|
||||
python-dateutil==2.9.0.post0
|
||||
httpx[http2,socks]==0.28.1
|
||||
cachetools==6.2.0
|
||||
soupsieve==2.8
|
||||
stem==1.8.2
|
||||
httpcore>=1.0.9
|
||||
h11>=0.16.0
|
||||
validators==0.35.0
|
||||
waitress==3.0.2
|
||||
wcwidth==0.2.14
|
||||
Werkzeug==3.1.4
|
||||
python-dotenv==1.1.1
|
||||
attrs==19.3.0
|
||||
beautifulsoup4==4.8.2
|
||||
bs4==0.0.1
|
||||
cachelib==0.1
|
||||
certifi==2020.4.5.1
|
||||
cffi==1.13.2
|
||||
chardet==3.0.4
|
||||
Click==7.0
|
||||
cryptography==3.3.2
|
||||
Flask==1.1.1
|
||||
Flask-Session==0.3.2
|
||||
idna==2.9
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.11.3
|
||||
MarkupSafe==1.1.1
|
||||
more-itertools==8.3.0
|
||||
packaging==20.4
|
||||
pluggy==0.13.1
|
||||
py==1.10.0
|
||||
pycodestyle==2.6.0
|
||||
pycparser==2.19
|
||||
pyOpenSSL==19.1.0
|
||||
pyparsing==2.4.7
|
||||
PySocks==1.7.1
|
||||
pytest==5.4.1
|
||||
python-dateutil==2.8.1
|
||||
requests==2.23.0
|
||||
soupsieve==1.9.5
|
||||
stem==1.8.0
|
||||
urllib3==1.25.9
|
||||
waitress==1.4.3
|
||||
wcwidth==0.1.9
|
||||
Werkzeug==0.16.0
|
||||
python-dotenv==0.16.0
|
||||
|
||||
25
run
25
run
@ -1,37 +1,26 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
# Usage:
|
||||
# ./run # Runs the full web app
|
||||
# ./run test # Runs the testing suite
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= command cd -- "$(dirname -- "$0")" && pwd -P)"
|
||||
SCRIPT_DIR="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
# Set directory to serve static content from
|
||||
SUBDIR="${1:-app}"
|
||||
export APP_ROOT="$SCRIPT_DIR/$SUBDIR"
|
||||
export STATIC_FOLDER="$APP_ROOT/static"
|
||||
|
||||
# Clear out build directory
|
||||
rm -f "$SCRIPT_DIR"/app/static/build/*.js
|
||||
rm -f "$SCRIPT_DIR"/app/static/build/*.css
|
||||
|
||||
# Check for regular vs test run
|
||||
if [ "$SUBDIR" = "test" ]; then
|
||||
if [[ "$SUBDIR" == "test" ]]; then
|
||||
# Set up static files for testing
|
||||
rm -rf "$STATIC_FOLDER"
|
||||
ln -s "$SCRIPT_DIR/app/static" "$STATIC_FOLDER"
|
||||
pytest -sv
|
||||
else
|
||||
mkdir -p "$STATIC_FOLDER"
|
||||
|
||||
if [ ! -z "$UNIX_SOCKET" ]; then
|
||||
python3 -um app \
|
||||
--unix-socket "$UNIX_SOCKET"
|
||||
else
|
||||
echo "Running on http://${ADDRESS:-0.0.0.0}:${PORT:-"${EXPOSE_PORT:-5000}"}"
|
||||
python3 -um app \
|
||||
--host "${ADDRESS:-0.0.0.0}" \
|
||||
--port "${PORT:-"${EXPOSE_PORT:-5000}"}"
|
||||
fi
|
||||
python3 -um app \
|
||||
--host "${ADDRESS:-0.0.0.0}" \
|
||||
--port "${PORT:-"${EXPOSE_PORT:-5000}"}"
|
||||
fi
|
||||
|
||||
45
setup.cfg
45
setup.cfg
@ -1,45 +0,0 @@
|
||||
[metadata]
|
||||
name = whoogle-search
|
||||
version = attr: app.version.__version__
|
||||
url = https://github.com/benbusby/whoogle-search
|
||||
description = Self-hosted, ad-free, privacy-respecting metasearch engine
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
keywords = search, metasearch, flask, adblock, degoogle, privacy
|
||||
author = Ben Busby
|
||||
author_email = contact@benbusby.com
|
||||
license = MIT
|
||||
classifiers =
|
||||
Programming Language :: Python :: 3
|
||||
License :: OSI Approved :: MIT License
|
||||
Operating System :: OS Independent
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
include_package_data = True
|
||||
install_requires=
|
||||
beautifulsoup4
|
||||
brotli
|
||||
cssutils
|
||||
cryptography
|
||||
defusedxml
|
||||
Flask
|
||||
python-dotenv
|
||||
httpx[http2,socks]
|
||||
stem
|
||||
validators
|
||||
waitress
|
||||
|
||||
[options.extras_require]
|
||||
test =
|
||||
pytest
|
||||
python-dateutil
|
||||
dev = pycodestyle
|
||||
|
||||
[options.packages.find]
|
||||
exclude =
|
||||
test*
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
whoogle-search = app.routes:run_app
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user