├── test ├── __init__.py ├── test_autocomplete.py ├── conftest.py ├── test_misc.py ├── test_routes.py └── test_results.py ├── app ├── models │ ├── __init__.py │ ├── endpoint.py │ └── g_classes.py ├── utils │ ├── __init__.py │ ├── session.py │ ├── widgets.py │ ├── bangs.py │ ├── misc.py │ └── search.py ├── static │ ├── build │ │ └── .gitignore │ ├── settings │ │ ├── themes.json │ │ ├── time_periods.json │ │ ├── header_tabs.json │ │ └── languages.json │ ├── img │ │ ├── logo.png │ │ ├── favicon.ico │ │ ├── favicon │ │ │ ├── favicon.ico │ │ │ ├── apple-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon-96x96.png │ │ │ ├── ms-icon-70x70.png │ │ │ ├── apple-icon-57x57.png │ │ │ ├── apple-icon-60x60.png │ │ │ ├── apple-icon-72x72.png │ │ │ ├── apple-icon-76x76.png │ │ │ ├── ms-icon-144x144.png │ │ │ ├── ms-icon-150x150.png │ │ │ ├── ms-icon-310x310.png │ │ │ ├── android-icon-36x36.png │ │ │ ├── android-icon-48x48.png │ │ │ ├── android-icon-72x72.png │ │ │ ├── android-icon-96x96.png │ │ │ ├── apple-icon-114x114.png │ │ │ ├── apple-icon-120x120.png │ │ │ ├── apple-icon-144x144.png │ │ │ ├── apple-icon-152x152.png │ │ │ ├── apple-icon-180x180.png │ │ │ ├── android-icon-144x144.png │ │ │ ├── android-icon-192x192.png │ │ │ ├── apple-icon-precomposed.png │ │ │ ├── browserconfig.xml │ │ │ └── manifest.json │ │ └── whoogle.svg │ ├── css │ │ ├── error.css │ │ ├── logo.css │ │ ├── input.css │ │ ├── variables.css │ │ ├── search.css │ │ ├── main.css │ │ ├── light-theme.css │ │ ├── header.css │ │ └── dark-theme.css │ ├── bangs │ │ └── 00-whoogle.json │ ├── js │ │ ├── currency.js │ │ ├── keyboard.js │ │ ├── header.js │ │ ├── utils.js │ │ ├── controller.js │ │ └── autocomplete.js │ └── widgets │ │ └── calculator.html ├── __main__.py ├── version.py ├── templates │ ├── footer.html │ ├── search.html │ ├── display.html │ ├── error.html │ ├── opensearch.xml │ ├── logo.html │ ├── header.html │ └── imageresults.html └── __init__.py ├── letsencrypt └── acme.json ├── .dockerignore ├── .replit ├── misc ├── tor │ ├── control.conf │ ├── torrc │ └── start-tor.sh ├── replit.py ├── instances.txt ├── heroku-regen.sh └── update-translations.py ├── heroku.yml ├── docs ├── banner.png ├── screenshot_mobile.png └── screenshot_desktop.png ├── pyproject.toml ├── MANIFEST.in ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ ├── feature_request.md │ ├── bug_report.md │ └── new-theme.md ├── workflows │ ├── tests.yml │ ├── scan.yml │ ├── docker_tests.yml │ ├── docker_main.yml │ ├── pypi.yml │ └── buildx.yml └── FUNDING.yml ├── .gitignore ├── charts └── whoogle │ ├── templates │ ├── serviceaccount.yaml │ ├── service.yaml │ ├── tests │ │ └── test-connection.yaml │ ├── hpa.yaml │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── ingress.yaml │ └── deployment.yaml │ ├── .helmignore │ ├── Chart.yaml │ └── values.yaml ├── requirements.txt ├── run ├── LICENSE ├── setup.cfg ├── docker-compose.yml ├── Dockerfile ├── docker-compose-traefik.yaml ├── whoogle.template.env └── app.json /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /letsencrypt/acme.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | venv/ 3 | test/ 4 | -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | entrypoint = "misc/replit.py" 2 | -------------------------------------------------------------------------------- /app/static/build/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /misc/tor/control.conf: -------------------------------------------------------------------------------- 1 | # Place password here. Keep this safe. -------------------------------------------------------------------------------- /app/__main__.py: -------------------------------------------------------------------------------- 1 | from .routes import run_app 2 | 3 | run_app() 4 | -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: Dockerfile 4 | 5 | -------------------------------------------------------------------------------- /app/static/settings/themes.json: -------------------------------------------------------------------------------- 1 | [ 2 | "light", 3 | "dark", 4 | "system" 5 | ] 6 | -------------------------------------------------------------------------------- /docs/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/docs/banner.png -------------------------------------------------------------------------------- /app/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/logo.png -------------------------------------------------------------------------------- /app/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/screenshot_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/docs/screenshot_mobile.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /docs/screenshot_desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/docs/screenshot_desktop.png -------------------------------------------------------------------------------- /app/static/img/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/favicon.ico -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon.png -------------------------------------------------------------------------------- /app/static/img/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /app/static/img/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /app/static/img/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /app/static/img/favicon/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/ms-icon-70x70.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-57x57.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-60x60.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-72x72.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-76x76.png -------------------------------------------------------------------------------- /app/static/img/favicon/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/ms-icon-144x144.png -------------------------------------------------------------------------------- /app/static/img/favicon/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/ms-icon-150x150.png -------------------------------------------------------------------------------- /app/static/img/favicon/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/ms-icon-310x310.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft app/static 2 | graft app/templates 3 | graft app/misc 4 | include requirements.txt 5 | recursive-include test 6 | global-exclude *.pyc 7 | -------------------------------------------------------------------------------- /app/static/img/favicon/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/android-icon-36x36.png -------------------------------------------------------------------------------- /app/static/img/favicon/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/android-icon-48x48.png -------------------------------------------------------------------------------- /app/static/img/favicon/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/android-icon-72x72.png -------------------------------------------------------------------------------- /app/static/img/favicon/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/android-icon-96x96.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-114x114.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-120x120.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-144x144.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-152x152.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-180x180.png -------------------------------------------------------------------------------- /app/static/img/favicon/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/android-icon-144x144.png -------------------------------------------------------------------------------- /app/static/img/favicon/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/android-icon-192x192.png -------------------------------------------------------------------------------- /app/static/img/favicon/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowerskitchen/whoogle-search/HEAD/app/static/img/favicon/apple-icon-precomposed.png -------------------------------------------------------------------------------- /app/static/css/error.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 1.3rem; 3 | } 4 | 5 | @media (max-width: 1000px) { 6 | html { 7 | font-size: 3rem; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/version.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | optional_dev_tag = '' 4 | if os.getenv('DEV_BUILD'): 5 | optional_dev_tag = '.dev' + os.getenv('DEV_BUILD') 6 | 7 | __version__ = '0.9.3' + optional_dev_tag 8 | -------------------------------------------------------------------------------- /misc/replit.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | # A plague upon Replit and all who have built it 4 | replit_cmd = "killall -q python3 > /dev/null 2>&1; pip install -r requirements.txt && ./run" 5 | subprocess.run(replit_cmd, shell=True) 6 | -------------------------------------------------------------------------------- /app/static/css/logo.css: -------------------------------------------------------------------------------- 1 | .cls-1 { 2 | fill: transparent; 3 | } 4 | 5 | svg { 6 | height: inherit; 7 | } 8 | 9 | a { 10 | height: inherit; 11 | } 12 | 13 | @media (max-width: 1000px) { 14 | svg { 15 | margin-top: .3em; 16 | height: 70%; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/static/img/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a (simple) question about Whoogle 4 | title: "[QUESTION] " 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | Type out your question here. Please make sure that this is a topic that isn't already covered in the README. 11 | -------------------------------------------------------------------------------- /app/static/settings/time_periods.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"name": "Any time", "value": ""}, 3 | {"name": "Past hour", "value": "qdr:h"}, 4 | {"name": "Past 24 hours", "value": "qdr:d"}, 5 | {"name": "Past week", "value": "qdr:w"}, 6 | {"name": "Past month", "value": "qdr:m"}, 7 | {"name": "Past year", "value": "qdr:y"} 8 | ] 9 | -------------------------------------------------------------------------------- /app/static/bangs/00-whoogle.json: -------------------------------------------------------------------------------- 1 | { 2 | "!i": { 3 | "url": "search?q={}&tbm=isch", 4 | "suggestion": "!i (Whoogle Images)" 5 | }, 6 | "!v": { 7 | "url": "search?q={}&tbm=vid", 8 | "suggestion": "!v (Whoogle Videos)" 9 | }, 10 | "!n": { 11 | "url": "search?q={}&tbm=nws", 12 | "suggestion": "!n (Whoogle News)" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/templates/footer.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .venv/ 3 | .idea/ 4 | __pycache__/ 5 | *.pyc 6 | *.pem 7 | *.conf 8 | *.key 9 | config.json 10 | test/static 11 | flask_session/ 12 | app/static/config 13 | app/static/custom_config 14 | app/static/bangs/* 15 | !app/static/bangs/00-whoogle.json 16 | 17 | # pip stuff 18 | /build/ 19 | dist/ 20 | *.egg-info/ 21 | 22 | # env 23 | whoogle.env 24 | 25 | # vim 26 | *~ 27 | *.swp 28 | -------------------------------------------------------------------------------- /charts/whoogle/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "whoogle.serviceAccountName" . }} 6 | labels: 7 | {{- include "whoogle.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /misc/tor/torrc: -------------------------------------------------------------------------------- 1 | DataDirectory /var/lib/tor 2 | ControlPort 9051 3 | CookieAuthentication 1 4 | DataDirectoryGroupReadable 1 5 | CookieAuthFileGroupReadable 1 6 | ExtORPortCookieAuthFileGroupReadable 1 7 | CacheDirectoryGroupReadable 1 8 | CookieAuthFile /var/lib/tor/control_auth_cookie 9 | Log debug-notice file /dev/null 10 | # UseBridges 1 11 | # ClientTransportPlugin obfs4 exec /usr/bin/obfs4proxy 12 | # Bridge obfs4 ip and so on 13 | -------------------------------------------------------------------------------- /app/static/js/currency.js: -------------------------------------------------------------------------------- 1 | const convert = (n1, n2, conversionFactor) => { 2 | // id's for currency input boxes 3 | let id1 = "cb" + n1; 4 | let id2 = "cb" + n2; 5 | // getting the value of the input box that just got filled 6 | let inputBox = document.getElementById(id1).value; 7 | // updating the other input box after conversion 8 | document.getElementById(id2).value = ((inputBox * conversionFactor).toFixed(2)); 9 | } 10 | -------------------------------------------------------------------------------- /charts/whoogle/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "whoogle.fullname" . }} 5 | labels: 6 | {{- include "whoogle.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "whoogle.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /charts/whoogle/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Python 11 | uses: actions/setup-python@v5 12 | with: 13 | python-version: '3.x' 14 | - name: Install dependencies 15 | run: pip install --upgrade pip && pip install -r requirements.txt 16 | - name: Run tests 17 | run: ./run test 18 | -------------------------------------------------------------------------------- /charts/whoogle/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "whoogle.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "whoogle.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "whoogle.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /app/templates/search.html: -------------------------------------------------------------------------------- 1 |
2 | 13 | 14 |
15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: benbusby 3 | ko_fi: benbusby 4 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 5 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 6 | liberapay: # Replace with a single Liberapay username 7 | issuehunt: # Replace with a single IssueHunt username 8 | otechie: # Replace with a single Otechie username 9 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 10 | -------------------------------------------------------------------------------- /.github/workflows/scan.yml: -------------------------------------------------------------------------------- 1 | name: scan 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | scan: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Build the container image 13 | run: | 14 | docker build --tag whoogle-search:test . 15 | - name: Initiate grype scan 16 | run: | 17 | curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b . 18 | chmod +x ./grype 19 | ./grype whoogle-search:test --only-fixed 20 | -------------------------------------------------------------------------------- /test/test_autocomplete.py: -------------------------------------------------------------------------------- 1 | from app.models.endpoint import Endpoint 2 | 3 | 4 | def test_autocomplete_get(client): 5 | rv = client.get(f'/{Endpoint.autocomplete}?q=green+eggs+and') 6 | assert rv._status_code == 200 7 | assert len(rv.data) >= 1 8 | assert b'green eggs and ham' in rv.data 9 | 10 | 11 | def test_autocomplete_post(client): 12 | rv = client.post(f'/{Endpoint.autocomplete}', 13 | data=dict(q='the+cat+in+the')) 14 | assert rv._status_code == 200 15 | assert len(rv.data) >= 1 16 | assert b'the cat in the hat' in rv.data 17 | -------------------------------------------------------------------------------- /app/models/endpoint.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Endpoint(Enum): 5 | autocomplete = 'autocomplete' 6 | home = 'home' 7 | healthz = 'healthz' 8 | config = 'config' 9 | opensearch = 'opensearch.xml' 10 | search = 'search' 11 | search_html = 'search.html' 12 | url = 'url' 13 | imgres = 'imgres' 14 | element = 'element' 15 | window = 'window' 16 | 17 | def __str__(self): 18 | return self.value 19 | 20 | def in_path(self, path: str) -> bool: 21 | return path.startswith(self.value) or \ 22 | path.startswith(f'/{self.value}') 23 | -------------------------------------------------------------------------------- /charts/whoogle/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: whoogle 3 | description: A self hosted search engine on Kubernetes 4 | type: application 5 | version: 0.1.0 6 | appVersion: 0.9.3 7 | 8 | icon: https://github.com/benbusby/whoogle-search/raw/main/app/static/img/favicon/favicon-96x96.png 9 | 10 | sources: 11 | - https://github.com/benbusby/whoogle-search 12 | - https://gitlab.com/benbusby/whoogle-search 13 | - https://gogs.benbusby.com/benbusby/whoogle-search 14 | 15 | keywords: 16 | - whoogle 17 | - degoogle 18 | - search 19 | - google 20 | - search-engine 21 | - privacy 22 | - tor 23 | - python 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a feature that would improve Whoogle 4 | title: "[FEATURE] " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 15 | 16 | **Describe the feature you'd like to see added** 17 | A short description of the feature, and what it would accomplish. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /app/static/settings/header_tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": { 3 | "tbm": null, 4 | "href": "search?q={query}", 5 | "name": "All", 6 | "selected": true 7 | }, 8 | "images": { 9 | "tbm": "isch", 10 | "href": "search?q={query}", 11 | "name": "Images", 12 | "selected": false 13 | }, 14 | "maps": { 15 | "tbm": null, 16 | "href": "https://maps.google.com/maps?q={map_query}", 17 | "name": "Maps", 18 | "selected": false 19 | }, 20 | "videos": { 21 | "tbm": "vid", 22 | "href": "search?q={query}", 23 | "name": "Videos", 24 | "selected": false 25 | }, 26 | "news": { 27 | "tbm": "nws", 28 | "href": "search?q={query}", 29 | "name": "News", 30 | "selected": false 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /misc/instances.txt: -------------------------------------------------------------------------------- 1 | https://search.albony.xyz 2 | https://search.garudalinux.org 3 | https://search.dr460nf1r3.org 4 | https://search.nezumi.party 5 | https://s.tokhmi.xyz 6 | https://search.sethforprivacy.com 7 | https://whoogle.dcs0.hu 8 | https://whoogle.lunar.icu 9 | https://gowogle.voring.me 10 | https://whoogle.privacydev.net 11 | https://whoogle.hostux.net 12 | https://wg.vern.cc 13 | https://whoogle.hxvy0.gq 14 | https://whoogle.ungovernable.men 15 | https://whoogle2.ungovernable.men 16 | https://whoogle3.ungovernable.men 17 | https://wgl.frail.duckdns.org 18 | https://whoogle.no-logs.com 19 | https://whoogle.ftw.lol 20 | https://whoogle-search--replitcomreside.repl.co 21 | https://search.notrustverify.ch 22 | https://whoogle.datura.network 23 | https://whoogle.yepserver.xyz 24 | https://search.snine.nl 25 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | from app import app 2 | from app.utils.session import generate_key 3 | import pytest 4 | import random 5 | 6 | demo_config = { 7 | 'near': random.choice(['Seattle', 'New York', 'San Francisco']), 8 | 'dark': str(random.getrandbits(1)), 9 | 'nojs': str(random.getrandbits(1)), 10 | 'lang_interface': random.choice(app.config['LANGUAGES'])['value'], 11 | 'lang_search': random.choice(app.config['LANGUAGES'])['value'], 12 | 'country': random.choice(app.config['COUNTRIES'])['value'] 13 | } 14 | 15 | 16 | @pytest.fixture 17 | def client(): 18 | with app.test_client() as client: 19 | with client.session_transaction() as session: 20 | session['uuid'] = 'test' 21 | session['key'] = app.enc_key 22 | session['config'] = {} 23 | session['auth'] = False 24 | yield client 25 | -------------------------------------------------------------------------------- /misc/tor/start-tor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | FF_STRING="FascistFirewall 1" 4 | 5 | if [ "$WHOOGLE_TOR_SERVICE" == "0" ]; then 6 | echo "Skipping Tor startup..." 7 | exit 0 8 | fi 9 | 10 | if [ "$WHOOGLE_TOR_FF" == "1" ]; then 11 | if (grep -q "$FF_STRING" /etc/tor/torrc); then 12 | echo "FascistFirewall feature already enabled." 13 | else 14 | echo "$FF_STRING" >> /etc/tor/torrc 15 | 16 | if [ "$?" -eq 0 ]; then 17 | echo "FascistFirewall added to /etc/tor/torrc" 18 | else 19 | echo "ERROR: Unable to modify /etc/tor/torrc with $FF_STRING." 20 | exit 1 21 | fi 22 | fi 23 | fi 24 | 25 | if [ "$(whoami)" != "root" ]; then 26 | tor -f /etc/tor/torrc 27 | else 28 | if (grep alpine /etc/os-release >/dev/null); then 29 | rc-service tor start 30 | else 31 | service tor start 32 | fi 33 | fi 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==22.2.0 2 | beautifulsoup4==4.11.2 3 | brotli==1.0.9 4 | cachelib==0.10.2 5 | certifi==2024.7.4 6 | cffi==1.17.1 7 | chardet==5.1.0 8 | click==8.1.3 9 | cryptography==3.3.2; platform_machine == 'armv7l' 10 | cryptography==43.0.1; platform_machine != 'armv7l' 11 | cssutils==2.7.0 12 | defusedxml==0.7.1 13 | Flask==2.3.2 14 | idna==3.7 15 | itsdangerous==2.1.2 16 | Jinja2==3.1.5 17 | MarkupSafe==2.1.2 18 | more-itertools==9.0.0 19 | packaging==23.0 20 | pluggy==1.0.0 21 | pycodestyle==2.10.0 22 | pycparser==2.22 23 | pyOpenSSL==19.1.0; platform_machine == 'armv7l' 24 | pyOpenSSL==24.2.1; platform_machine != 'armv7l' 25 | pyparsing==3.0.9 26 | PySocks==1.7.1 27 | pytest==7.2.1 28 | python-dateutil==2.8.2 29 | requests==2.32.2 30 | soupsieve==2.4 31 | stem==1.8.1 32 | urllib3==1.26.19 33 | validators==0.22.0 34 | waitress==3.0.1 35 | wcwidth==0.2.6 36 | Werkzeug==3.0.6 37 | python-dotenv==0.21.1 38 | -------------------------------------------------------------------------------- /.github/workflows/docker_tests.yml: -------------------------------------------------------------------------------- 1 | name: docker_tests 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: main 8 | 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout code 14 | uses: actions/checkout@v2 15 | - name: build and test (docker) 16 | run: | 17 | docker build --tag whoogle-search:test . 18 | docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test 19 | sleep 15 20 | docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1 21 | - name: build and test (docker compose) 22 | run: | 23 | docker rm -f whoogle-search-nocompose 24 | WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach 25 | sleep 15 26 | docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1 27 | -------------------------------------------------------------------------------- /app/static/css/input.css: -------------------------------------------------------------------------------- 1 | #search-bar { 2 | background: transparent !important; 3 | padding-right: 50px; 4 | } 5 | 6 | #search-reset { 7 | all: unset; 8 | margin-left: -50px; 9 | text-align: center; 10 | background-color: transparent !important; 11 | cursor: pointer; 12 | height: 40px; 13 | width: 50px; 14 | } 15 | .ZINbbc.xpd.O9g5cc.uUPGi input::-webkit-outer-spin-button, 16 | input::-webkit-inner-spin-button { 17 | -webkit-appearance: none; 18 | margin: 0; 19 | } 20 | 21 | .cb { 22 | width: 40%; 23 | overflow: hidden; 24 | text-align: left; 25 | line-height: 28px; 26 | background: transparent; 27 | border-radius: 6px; 28 | border: 1px solid #5f6368; 29 | font-size: 14px !important; 30 | height: 36px; 31 | padding: 0 0 0 12px; 32 | margin: 10px 10px 10px 0; 33 | } 34 | 35 | .conversion_box { 36 | margin-top: 15px; 37 | } 38 | 39 | .ZINbbc.xpd.O9g5cc.uUPGi input:focus-visible { 40 | outline: 0; 41 | } 42 | -------------------------------------------------------------------------------- /misc/heroku-regen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Assumes this is being executed from a session that has already logged 3 | # into Heroku with "heroku login -i" beforehand. 4 | # 5 | # You can set this up to run every night when you aren't using the 6 | # instance with a cronjob. For example: 7 | # 0 3 * * * /home/pi/whoogle-search/config/heroku-regen.sh 8 | 9 | HEROKU_CLI_SITE="https://devcenter.heroku.com/articles/heroku-cli" 10 | 11 | if ! [[ -x "$(command -v heroku)" ]]; then 12 | echo "Must have heroku cli installed: $HEROKU_CLI_SITE" 13 | exit 1 14 | fi 15 | 16 | cd "$(builtin cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/../" 17 | 18 | if [[ $# -ne 1 ]]; then 19 | echo -e "Must provide the name of the Whoogle instance to regenerate" 20 | exit 1 21 | fi 22 | 23 | APP_NAME="$1" 24 | 25 | heroku apps:destroy "$APP_NAME" --confirm "$APP_NAME" 26 | heroku apps:create "$APP_NAME" 27 | heroku container:login 28 | heroku container:push web 29 | heroku container:release web 30 | -------------------------------------------------------------------------------- /app/static/img/favicon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Whoogle Search", 3 | "short_name": "Whoogle", 4 | "display": "fullscreen", 5 | "scope": "/", 6 | "icons": [ 7 | { 8 | "src": "android-icon-36x36.png", 9 | "sizes": "36x36", 10 | "type": "image\/png", 11 | "density": "0.75" 12 | }, 13 | { 14 | "src": "android-icon-48x48.png", 15 | "sizes": "48x48", 16 | "type": "image\/png", 17 | "density": "1.0" 18 | }, 19 | { 20 | "src": "android-icon-72x72.png", 21 | "sizes": "72x72", 22 | "type": "image\/png", 23 | "density": "1.5" 24 | }, 25 | { 26 | "src": "android-icon-96x96.png", 27 | "sizes": "96x96", 28 | "type": "image\/png", 29 | "density": "2.0" 30 | }, 31 | { 32 | "src": "android-icon-144x144.png", 33 | "sizes": "144x144", 34 | "type": "image\/png", 35 | "density": "3.0" 36 | }, 37 | { 38 | "src": "android-icon-192x192.png", 39 | "sizes": "192x192", 40 | "type": "image\/png", 41 | "density": "4.0" 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/docker_main.yml: -------------------------------------------------------------------------------- 1 | name: docker_main 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["tests"] 6 | branches: [main] 7 | types: 8 | - completed 9 | 10 | # TODO: Needs refactoring to use reusable workflows and share w/ docker_tests 11 | jobs: 12 | on-success: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: checkout code 16 | uses: actions/checkout@v2 17 | - name: build and test (docker) 18 | run: | 19 | docker build --tag whoogle-search:test . 20 | docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test 21 | sleep 15 22 | docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1 23 | - name: build and test (docker-compose) 24 | run: | 25 | docker rm -f whoogle-search-nocompose 26 | WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach 27 | sleep 15 28 | docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1 29 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Usage: 3 | # ./run # Runs the full web app 4 | # ./run test # Runs the testing suite 5 | 6 | set -e 7 | 8 | SCRIPT_DIR="$(CDPATH= command cd -- "$(dirname -- "$0")" && pwd -P)" 9 | 10 | # Set directory to serve static content from 11 | SUBDIR="${1:-app}" 12 | export APP_ROOT="$SCRIPT_DIR/$SUBDIR" 13 | export STATIC_FOLDER="$APP_ROOT/static" 14 | 15 | # Clear out build directory 16 | rm -f "$SCRIPT_DIR"/app/static/build/*.js 17 | rm -f "$SCRIPT_DIR"/app/static/build/*.css 18 | 19 | # Check for regular vs test run 20 | if [ "$SUBDIR" = "test" ]; then 21 | # Set up static files for testing 22 | rm -rf "$STATIC_FOLDER" 23 | ln -s "$SCRIPT_DIR/app/static" "$STATIC_FOLDER" 24 | pytest -sv 25 | else 26 | mkdir -p "$STATIC_FOLDER" 27 | 28 | if [ ! -z "$UNIX_SOCKET" ]; then 29 | python3 -um app \ 30 | --unix-socket "$UNIX_SOCKET" 31 | else 32 | echo "Running on http://${ADDRESS:-0.0.0.0}:${PORT:-"${EXPOSE_PORT:-5000}"}" 33 | python3 -um app \ 34 | --host "${ADDRESS:-0.0.0.0}" \ 35 | --port "${PORT:-"${EXPOSE_PORT:-5000}"}" 36 | fi 37 | fi 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ben Busby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = whoogle-search 3 | version = attr: app.version.__version__ 4 | url = https://github.com/benbusby/whoogle-search 5 | description = Self-hosted, ad-free, privacy-respecting metasearch engine 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | keywords = search, metasearch, flask, adblock, degoogle, privacy 9 | author = Ben Busby 10 | author_email = contact@benbusby.com 11 | license = MIT 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | Operating System :: OS Independent 16 | 17 | [options] 18 | packages = find: 19 | include_package_data = True 20 | install_requires= 21 | beautifulsoup4 22 | brotli 23 | cssutils 24 | cryptography 25 | defusedxml 26 | Flask 27 | python-dotenv 28 | requests 29 | stem 30 | validators 31 | waitress 32 | 33 | [options.extras_require] 34 | test = 35 | pytest 36 | python-dateutil 37 | dev = pycodestyle 38 | 39 | [options.packages.find] 40 | exclude = 41 | test* 42 | 43 | [options.entry_points] 44 | console_scripts = 45 | whoogle-search = app.routes:run_app 46 | -------------------------------------------------------------------------------- /app/utils/session.py: -------------------------------------------------------------------------------- 1 | from cryptography.fernet import Fernet 2 | from flask import current_app as app 3 | 4 | REQUIRED_SESSION_VALUES = ['uuid', 'config', 'key', 'auth'] 5 | 6 | 7 | def generate_key() -> bytes: 8 | """Generates a key for encrypting searches and element URLs 9 | 10 | Args: 11 | cookies_disabled: Flag for whether or not cookies are disabled by the 12 | user. If so, the user can only use the default key 13 | generated on app init for queries. 14 | 15 | Returns: 16 | str: A unique Fernet key 17 | 18 | """ 19 | # Generate/regenerate unique key per user 20 | return Fernet.generate_key() 21 | 22 | 23 | def valid_user_session(session: dict) -> bool: 24 | """Validates the current user session 25 | 26 | Args: 27 | session: The current Flask user session 28 | 29 | Returns: 30 | bool: True/False indicating that all required session values are 31 | available 32 | 33 | """ 34 | # Generate secret key for user if unavailable 35 | for value in REQUIRED_SESSION_VALUES: 36 | if value not in session: 37 | return False 38 | 39 | return True 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help fix an issue with Whoogle 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Deployment Method** 21 | - [ ] Heroku (one-click deploy) 22 | - [ ] Docker 23 | - [ ] `run` executable 24 | - [ ] pip/pipx 25 | - [ ] Other: [describe setup] 26 | 27 | **Version of Whoogle Search** 28 | - [ ] Latest build from [source] (i.e. GitHub, Docker Hub, pip, etc) 29 | - [ ] Version [version number] 30 | - [ ] Not sure 31 | 32 | 33 | **Desktop (please complete the following information):** 34 | - OS: [e.g. iOS] 35 | - Browser [e.g. chrome, safari] 36 | - Version [e.g. 22] 37 | 38 | **Smartphone (please complete the following information):** 39 | - Device: [e.g. iPhone6] 40 | - OS: [e.g. iOS8.1] 41 | - Browser [e.g. stock browser, safari] 42 | - Version [e.g. 22] 43 | 44 | **Additional context** 45 | Add any other context about the problem here. 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-theme.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New theme 3 | about: Create a new theme for Whoogle 4 | title: "[THEME] " 5 | labels: theme 6 | assignees: benbusby 7 | 8 | --- 9 | 10 | Use the following template to design your theme, replacing the blank spaces with the colors of your choice. 11 | 12 | ```css 13 | :root { 14 | /* LIGHT THEME COLORS */ 15 | --whoogle-logo: #______; 16 | --whoogle-page-bg: #______; 17 | --whoogle-element-bg: #______; 18 | --whoogle-text: #______; 19 | --whoogle-contrast-text: #______; 20 | --whoogle-secondary-text: #______; 21 | --whoogle-result-bg: #______; 22 | --whoogle-result-title: #______; 23 | --whoogle-result-url: #______; 24 | --whoogle-result-visited: #______; 25 | 26 | /* DARK THEME COLORS */ 27 | --whoogle-dark-logo: #______; 28 | --whoogle-dark-page-bg: #______; 29 | --whoogle-dark-element-bg: #______; 30 | --whoogle-dark-text: #______; 31 | --whoogle-dark-contrast-text: #______; 32 | --whoogle-dark-secondary-text: #______; 33 | --whoogle-dark-result-bg: #______; 34 | --whoogle-dark-result-title: #______; 35 | --whoogle-dark-result-url: #______; 36 | --whoogle-dark-result-visited: #______; 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /app/static/css/variables.css: -------------------------------------------------------------------------------- 1 | /* Colors */ 2 | :root { 3 | /* LIGHT THEME COLORS */ 4 | --whoogle-logo: #685e79; 5 | --whoogle-page-bg: #ffffff; 6 | --whoogle-element-bg: #4285f4; 7 | --whoogle-text: #000000; 8 | --whoogle-contrast-text: #ffffff; 9 | --whoogle-secondary-text: #70757a; 10 | --whoogle-result-bg: #ffffff; 11 | --whoogle-result-title: #1967d2; 12 | --whoogle-result-url: #0d652d; 13 | --whoogle-result-visited: #4b11a8; 14 | 15 | /* DARK THEME COLORS */ 16 | --whoogle-dark-logo: #685e79; 17 | --whoogle-dark-page-bg: #101020; 18 | --whoogle-dark-element-bg: #4285f4; 19 | --whoogle-dark-text: #ffffff; 20 | --whoogle-dark-contrast-text: #ffffff; 21 | --whoogle-dark-secondary-text: #bbbbbb; 22 | --whoogle-dark-result-bg: #212131; 23 | --whoogle-dark-result-title: #64a7f6; 24 | --whoogle-dark-result-url: #34a853; 25 | --whoogle-dark-result-visited: #bbbbff; 26 | } 27 | 28 | #whoogle-w { 29 | fill: #4285f4; 30 | } 31 | 32 | #whoogle-h { 33 | fill: #ea4335; 34 | } 35 | 36 | #whoogle-o-1 { 37 | fill: #fbbc05; 38 | } 39 | 40 | #whoogle-o-2 { 41 | fill: #4285f4; 42 | } 43 | 44 | #whoogle-g { 45 | fill: #34a853; 46 | } 47 | 48 | #whoogle-l { 49 | fill: #ea4335; 50 | } 51 | 52 | #whoogle-e { 53 | fill: #fbbc05; 54 | } 55 | -------------------------------------------------------------------------------- /app/models/g_classes.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | 4 | class GClasses: 5 | """A class for tracking obfuscated class names used in Google results that 6 | are directly referenced in Whoogle's filtering code. 7 | 8 | Note: Using these should be a last resort. It is always preferred to filter 9 | results using structural cues instead of referencing class names, as these 10 | are liable to change at any moment. 11 | """ 12 | main_tbm_tab = 'KP7LCb' 13 | images_tbm_tab = 'n692Zd' 14 | footer = 'TuS8Ad' 15 | result_class_a = 'ZINbbc' 16 | result_class_b = 'luh4td' 17 | scroller_class = 'idg8be' 18 | line_tag = 'BsXmcf' 19 | 20 | result_classes = { 21 | result_class_a: ['Gx5Zad'], 22 | result_class_b: ['fP1Qef'] 23 | } 24 | 25 | @classmethod 26 | def replace_css_classes(cls, soup: BeautifulSoup) -> BeautifulSoup: 27 | """Replace updated Google classes with the original class names that 28 | Whoogle relies on for styling. 29 | 30 | Args: 31 | soup: The result page as a BeautifulSoup object 32 | 33 | Returns: 34 | BeautifulSoup: The new BeautifulSoup 35 | """ 36 | result_divs = soup.find_all('div', { 37 | 'class': [_ for c in cls.result_classes.values() for _ in c] 38 | }) 39 | 40 | for div in result_divs: 41 | new_class = ' '.join(div['class']) 42 | for key, val in cls.result_classes.items(): 43 | new_class = ' '.join(new_class.replace(_, key) for _ in val) 44 | div['class'] = new_class.split(' ') 45 | return soup 46 | 47 | def __str__(self): 48 | return self.value 49 | -------------------------------------------------------------------------------- /charts/whoogle/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion -}} 3 | apiVersion: autoscaling/v2 4 | {{- else -}} 5 | apiVersion: autoscaling/v2beta1 6 | {{- end }} 7 | kind: HorizontalPodAutoscaler 8 | metadata: 9 | name: {{ include "whoogle.fullname" . }} 10 | labels: 11 | {{- include "whoogle.labels" . | nindent 4 }} 12 | spec: 13 | scaleTargetRef: 14 | apiVersion: apps/v1 15 | kind: Deployment 16 | name: {{ include "whoogle.fullname" . }} 17 | minReplicas: {{ .Values.autoscaling.minReplicas }} 18 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 19 | metrics: 20 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | - type: Resource 22 | resource: 23 | name: cpu 24 | {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }} 25 | target: 26 | type: Utilization 27 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 28 | {{- else -}} 29 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 30 | {{- end }} 31 | {{- end }} 32 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 33 | - type: Resource 34 | resource: 35 | name: memory 36 | {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }} 37 | target: 38 | type: Utilization 39 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 40 | {{- else -}} 41 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 42 | {{- end }} 43 | {{- end }} 44 | {{- end }} 45 | -------------------------------------------------------------------------------- /charts/whoogle/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "whoogle.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "whoogle.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "whoogle.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | 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}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /app/static/css/search.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: block !important; 3 | margin: auto !important; 4 | } 5 | 6 | .vvjwJb { 7 | font-size: 16px !important; 8 | } 9 | 10 | .ezO2md { 11 | border-radius: 10px; 12 | border: 0 !important; 13 | box-shadow: 0 3px 5px rgb(0 0 0 / 0.2); 14 | } 15 | 16 | .autocomplete { 17 | position: relative; 18 | display: inline-block; 19 | width: 100%; 20 | } 21 | 22 | .autocomplete-items { 23 | position: absolute; 24 | border-bottom: none; 25 | border-top: none; 26 | z-index: 99; 27 | 28 | /*position the autocomplete items to be the same width as the container:*/ 29 | top: 100%; 30 | left: 0; 31 | right: 0; 32 | } 33 | 34 | .autocomplete-items div { 35 | padding: 10px; 36 | cursor: pointer; 37 | } 38 | 39 | details summary { 40 | margin-bottom: 20px; 41 | font-weight: bold; 42 | padding-left: 10px; 43 | } 44 | 45 | details summary span { 46 | font-weight: normal; 47 | } 48 | 49 | #lingva-iframe { 50 | width: 100%; 51 | height: 650px; 52 | border: 0; 53 | } 54 | 55 | .ip-address-div { 56 | padding-bottom: 0 !important; 57 | } 58 | 59 | .ip-text-div { 60 | padding-top: 0 !important; 61 | } 62 | 63 | .footer { 64 | text-align: center; 65 | } 66 | 67 | .site-favicon { 68 | float: left; 69 | width: 25px; 70 | padding-right: 5px; 71 | } 72 | 73 | .has-favicon .sCuL3 { 74 | padding-left: 30px; 75 | } 76 | 77 | #flex_text_audio_icon_chunk { 78 | display: none; 79 | } 80 | 81 | audio { 82 | display: block; 83 | margin-right: auto; 84 | padding-bottom: 5px; 85 | } 86 | 87 | @media (min-width: 801px) { 88 | body { 89 | min-width: 736px !important; 90 | } 91 | } 92 | 93 | @media (max-width: 801px) { 94 | details summary { 95 | margin-bottom: 10px !important 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/static/js/keyboard.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | let searchBar, results; 3 | let shift = false; 4 | const keymap = { 5 | ArrowUp: goUp, 6 | ArrowDown: goDown, 7 | ShiftTab: goUp, 8 | Tab: goDown, 9 | k: goUp, 10 | j: goDown, 11 | '/': focusSearch, 12 | }; 13 | let activeIdx = -1; 14 | 15 | document.addEventListener('DOMContentLoaded', () => { 16 | searchBar = document.querySelector('#search-bar'); 17 | results = document.querySelectorAll('#main>div>div>div>a'); 18 | }); 19 | 20 | document.addEventListener('keydown', (e) => { 21 | if (e.key === 'Shift') { 22 | shift = true; 23 | } 24 | 25 | if (e.target.tagName === 'INPUT') return true; 26 | if (typeof keymap[e.key] === 'function') { 27 | e.preventDefault(); 28 | 29 | keymap[`${shift && e.key == 'Tab' ? 'Shift' : ''}${e.key}`](); 30 | } 31 | }); 32 | 33 | document.addEventListener('keyup', (e) => { 34 | if (e.key === 'Shift') { 35 | shift = false; 36 | } 37 | }); 38 | 39 | function goUp () { 40 | if (activeIdx > 0) focusResult(activeIdx - 1); 41 | else focusSearch(); 42 | } 43 | 44 | function goDown () { 45 | if (activeIdx < results.length - 1) focusResult(activeIdx + 1); 46 | } 47 | 48 | function focusResult (idx) { 49 | activeIdx = idx; 50 | results[activeIdx].scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); 51 | results[activeIdx].focus(); 52 | } 53 | 54 | function focusSearch () { 55 | if (window.usingCalculator) { 56 | // if this function exists, it means the calculator widget has been displayed 57 | if (usingCalculator()) return; 58 | } 59 | activeIdx = -1; 60 | searchBar.focus(); 61 | } 62 | }()); 63 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: pypi 2 | 3 | on: 4 | push: 5 | branches: main 6 | tags: v* 7 | 8 | jobs: 9 | publish-test: 10 | name: Build and publish to TestPyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 3.9 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.9 18 | - name: Install pypa/build 19 | run: >- 20 | python -m 21 | pip install 22 | build 23 | setuptools 24 | --user 25 | - name: Set dev timestamp 26 | run: echo "DEV_BUILD=$(date +%s)" >> $GITHUB_ENV 27 | - name: Build binary wheel and source tarball 28 | run: >- 29 | python -m 30 | build 31 | --sdist 32 | --wheel 33 | --outdir dist/ 34 | . 35 | - name: Publish distribution to TestPyPI 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 39 | repository_url: https://test.pypi.org/legacy/ 40 | publish: 41 | name: Build and publish to PyPI 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Set up Python 3.9 46 | uses: actions/setup-python@v5 47 | with: 48 | python-version: 3.9 49 | - name: Install pypa/build 50 | run: >- 51 | python -m 52 | pip install 53 | build 54 | --user 55 | - name: Build binary wheel and source tarball 56 | run: >- 57 | python -m 58 | build 59 | --sdist 60 | --wheel 61 | --outdir dist/ 62 | . 63 | - name: Publish distribution to PyPI 64 | if: startsWith(github.ref, 'refs/tags') 65 | uses: pypa/gh-action-pypi-publish@master 66 | with: 67 | password: ${{ secrets.PYPI_API_TOKEN }} 68 | -------------------------------------------------------------------------------- /charts/whoogle/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "whoogle.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "whoogle.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "whoogle.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "whoogle.labels" -}} 37 | helm.sh/chart: {{ include "whoogle.chart" . }} 38 | {{ include "whoogle.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "whoogle.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "whoogle.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "whoogle.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "whoogle.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # can't use mem_limit in a 3.x docker-compose file in non swarm mode 2 | # see https://github.com/docker/compose/issues/4513 3 | version: "2.4" 4 | 5 | services: 6 | whoogle-search: 7 | image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search} 8 | container_name: whoogle-search 9 | restart: unless-stopped 10 | pids_limit: 50 11 | mem_limit: 256mb 12 | memswap_limit: 256mb 13 | # user debian-tor from tor package 14 | user: whoogle 15 | security_opt: 16 | - no-new-privileges 17 | cap_drop: 18 | - ALL 19 | tmpfs: 20 | - /config/:size=10M,uid=927,gid=927,mode=1700 21 | - /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700 22 | - /run/tor/:size=1M,uid=927,gid=927,mode=1700 23 | #environment: # Uncomment to configure environment variables 24 | # Basic auth configuration, uncomment to enable 25 | #- WHOOGLE_USER= 26 | #- WHOOGLE_PASS= 27 | # Proxy configuration, uncomment to enable 28 | #- WHOOGLE_PROXY_USER= 29 | #- WHOOGLE_PROXY_PASS= 30 | #- WHOOGLE_PROXY_TYPE= 32 | # Site alternative configurations, uncomment to enable 33 | # Note: If not set, the feature will still be available 34 | # with default values. 35 | #- WHOOGLE_ALT_TW=farside.link/nitter 36 | #- WHOOGLE_ALT_YT=farside.link/invidious 37 | #- WHOOGLE_ALT_IG=farside.link/bibliogram/u 38 | #- WHOOGLE_ALT_RD=farside.link/libreddit 39 | #- WHOOGLE_ALT_MD=farside.link/scribe 40 | #- WHOOGLE_ALT_TL=farside.link/lingva 41 | #- WHOOGLE_ALT_IMG=farside.link/rimgo 42 | #- WHOOGLE_ALT_WIKI=farside.link/wikiless 43 | #- WHOOGLE_ALT_IMDB=farside.link/libremdb 44 | #- WHOOGLE_ALT_QUORA=farside.link/quetre 45 | #- WHOOGLE_ALT_SO=farside.link/anonymousoverflow 46 | #env_file: # Alternatively, load variables from whoogle.env 47 | #- whoogle.env 48 | ports: 49 | - 5000:5000 50 | -------------------------------------------------------------------------------- /app/templates/display.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% if not search_type %} 6 | 7 | {% else %} 8 | 9 | {% endif %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% if config.theme %} 17 | {% if config.theme == 'system' %} 18 | 22 | {% else %} 23 | 24 | {% endif %} 25 | {% else %} 26 | 27 | {% endif %} 28 | 29 | {{ clean_query(query) }} - Whoogle Search 30 | 31 | 32 | {{ search_header|safe }} 33 | {% if is_translation %} 34 | 38 | {% endif %} 39 | {{ response|safe }} 40 | 41 | {% include 'footer.html' %} 42 | {% if autocomplete_enabled == '1' %} 43 | 44 | {% endif %} 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /.github/workflows/buildx.yml: -------------------------------------------------------------------------------- 1 | name: buildx 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["docker_main"] 6 | branches: [main] 7 | types: 8 | - completed 9 | push: 10 | tags: 11 | - '*' 12 | 13 | jobs: 14 | on-success: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Wait for tests to succeed 18 | if: ${{ github.event.workflow_run.conclusion != 'success' && startsWith(github.ref, 'refs/tags') != true }} 19 | run: exit 1 20 | - name: checkout code 21 | uses: actions/checkout@v2 22 | - name: install buildx 23 | id: buildx 24 | uses: crazy-max/ghaction-docker-buildx@v1 25 | with: 26 | version: latest 27 | - name: Login to Docker Hub 28 | uses: docker/login-action@v1 29 | with: 30 | username: ${{ secrets.DOCKER_USERNAME }} 31 | password: ${{ secrets.DOCKER_PASSWORD }} 32 | - name: Login to ghcr.io 33 | uses: docker/login-action@v1 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | - name: build and push the image 39 | if: startsWith(github.ref, 'refs/heads/main') && github.actor == 'benbusby' 40 | run: | 41 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 42 | docker buildx ls 43 | docker buildx build --push \ 44 | --tag benbusby/whoogle-search:latest \ 45 | --platform linux/amd64,linux/arm64 . 46 | docker buildx build --push \ 47 | --tag ghcr.io/benbusby/whoogle-search:latest \ 48 | --platform linux/amd64,linux/arm64 . 49 | - name: build and push tag 50 | if: startsWith(github.ref, 'refs/tags') 51 | run: | 52 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 53 | docker buildx ls 54 | docker buildx build --push \ 55 | --tag benbusby/whoogle-search:${GITHUB_REF#refs/*/v}\ 56 | --platform linux/amd64,linux/arm/v7,linux/arm64 . 57 | docker buildx build --push \ 58 | --tag ghcr.io/benbusby/whoogle-search:${GITHUB_REF#refs/*/v}\ 59 | --platform linux/amd64,linux/arm/v7,linux/arm64 . 60 | -------------------------------------------------------------------------------- /charts/whoogle/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "whoogle.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "whoogle.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /misc/update-translations.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | import requests 4 | 5 | lingva = 'https://lingva.ml/api/v1/en' 6 | 7 | 8 | def format_lang(lang: str) -> str: 9 | # Chinese (traditional and simplified) require 10 | # a different format for lingva translations 11 | if 'zh-' in lang: 12 | if lang == 'zh-TW': 13 | return 'zh_HANT' 14 | return 'zh' 15 | 16 | # Strip lang prefix to leave only the actual 17 | # language code (i.e. 'en', 'fr', etc) 18 | return lang.replace('lang_', '') 19 | 20 | 21 | def translate(v: str, lang: str) -> str: 22 | # Strip lang prefix to leave only the actual 23 | #language code (i.e. "es", "fr", etc) 24 | lang = format_lang(lang) 25 | 26 | lingva_req = f'{lingva}/{lang}/{v}' 27 | 28 | response = requests.get(lingva_req).json() 29 | 30 | if 'translation' in response: 31 | return response['translation'] 32 | return '' 33 | 34 | 35 | if __name__ == '__main__': 36 | file_path = pathlib.Path(__file__).parent.resolve() 37 | tl_path = 'app/static/settings/translations.json' 38 | 39 | with open(f'{file_path}/../{tl_path}', 'r+', encoding='utf-8') as tl_file: 40 | tl_data = json.load(tl_file) 41 | 42 | # If there are any english translations that don't 43 | # exist for other languages, extract them and translate 44 | # them now 45 | en_tl = tl_data['lang_en'] 46 | for k, v in en_tl.items(): 47 | for lang in tl_data: 48 | if lang == 'lang_en' or k in tl_data[lang]: 49 | continue 50 | 51 | translation = '' 52 | if len(k) == 0: 53 | # Special case for placeholder text that gets used 54 | # for translations without any key present 55 | translation = v 56 | else: 57 | # Translate the string using lingva 58 | translation = translate(v, lang) 59 | 60 | if len(translation) == 0: 61 | print(f'! Unable to translate {lang}[{k}]') 62 | continue 63 | print(f'{lang}[{k}] = {translation}') 64 | tl_data[lang][k] = translation 65 | 66 | # Write out updated translations json 67 | print(json.dumps(tl_data, indent=4, ensure_ascii=False)) 68 | -------------------------------------------------------------------------------- /app/static/js/header.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | const advSearchToggle = document.getElementById("adv-search-toggle"); 3 | const advSearchDiv = document.getElementById("adv-search-div"); 4 | const searchBar = document.getElementById("search-bar"); 5 | const countrySelect = document.getElementById("result-country"); 6 | const timePeriodSelect = document.getElementById("result-time-period"); 7 | const arrowKeys = [37, 38, 39, 40]; 8 | let searchValue = searchBar.value; 9 | 10 | countrySelect.onchange = () => { 11 | let str = window.location.href; 12 | n = str.lastIndexOf("/search"); 13 | if (n > 0) { 14 | str = str.substring(0, n) + `/search?q=${searchBar.value}`; 15 | str = tackOnParams(str); 16 | window.location.href = str; 17 | } 18 | } 19 | 20 | timePeriodSelect.onchange = () => { 21 | let str = window.location.href; 22 | n = str.lastIndexOf("/search"); 23 | if (n > 0) { 24 | str = str.substring(0, n) + `/search?q=${searchBar.value}`; 25 | str = tackOnParams(str); 26 | window.location.href = str; 27 | } 28 | } 29 | 30 | function tackOnParams(str) { 31 | if (timePeriodSelect.value != "") { 32 | str = str + `&tbs=${timePeriodSelect.value}`; 33 | } 34 | if (countrySelect.value != "") { 35 | str = str + `&country=${countrySelect.value}`; 36 | } 37 | return str; 38 | } 39 | 40 | const toggleAdvancedSearch = on => { 41 | if (on) { 42 | advSearchDiv.style.maxHeight = "70px"; 43 | } else { 44 | advSearchDiv.style.maxHeight = "0px"; 45 | } 46 | localStorage.advSearchToggled = on; 47 | } 48 | 49 | try { 50 | toggleAdvancedSearch(JSON.parse(localStorage.advSearchToggled)); 51 | } catch (error) { 52 | console.warn("Did not recover advanced search toggle state"); 53 | } 54 | 55 | advSearchToggle.onclick = () => { 56 | toggleAdvancedSearch(advSearchToggle.checked); 57 | } 58 | 59 | searchBar.addEventListener("keyup", function(event) { 60 | if (event.keyCode === 13) { 61 | document.getElementById("search-form").submit(); 62 | } else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) { 63 | searchValue = searchBar.value; 64 | handleUserInput(); 65 | } 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /app/utils/widgets.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from bs4 import BeautifulSoup 3 | 4 | 5 | # root 6 | BASE_DIR = Path(__file__).parent.parent.parent 7 | 8 | def add_ip_card(html_soup: BeautifulSoup, ip: str) -> BeautifulSoup: 9 | """Adds the client's IP address to the search results 10 | if query contains keywords 11 | 12 | Args: 13 | html_soup: The parsed search result containing the keywords 14 | ip: ip address of the client 15 | 16 | Returns: 17 | BeautifulSoup 18 | 19 | """ 20 | main_div = html_soup.select_one('#main') 21 | if main_div: 22 | # HTML IP card tag 23 | ip_tag = html_soup.new_tag('div') 24 | ip_tag['class'] = 'ZINbbc xpd O9g5cc uUPGi' 25 | 26 | # For IP Address html tag 27 | ip_address = html_soup.new_tag('div') 28 | ip_address['class'] = 'kCrYT ip-address-div' 29 | ip_address.string = ip 30 | 31 | # Text below the IP address 32 | ip_text = html_soup.new_tag('div') 33 | ip_text.string = 'Your public IP address' 34 | ip_text['class'] = 'kCrYT ip-text-div' 35 | 36 | # Adding all the above html tags to the IP card 37 | ip_tag.append(ip_address) 38 | ip_tag.append(ip_text) 39 | 40 | # Insert the element at the top of the result list 41 | main_div.insert_before(ip_tag) 42 | return html_soup 43 | 44 | def add_calculator_card(html_soup: BeautifulSoup) -> BeautifulSoup: 45 | """Adds the a calculator widget to the search results 46 | if query contains keywords 47 | 48 | Args: 49 | html_soup: The parsed search result containing the keywords 50 | 51 | Returns: 52 | BeautifulSoup 53 | """ 54 | main_div = html_soup.select_one('#main') 55 | if main_div: 56 | # absolute path 57 | widget_file = open(BASE_DIR / 'app/static/widgets/calculator.html', encoding="utf8") 58 | widget_tag = html_soup.new_tag('div') 59 | widget_tag['class'] = 'ZINbbc xpd O9g5cc uUPGi' 60 | widget_tag['id'] = 'calculator-wrapper' 61 | calculator_text = html_soup.new_tag('div') 62 | calculator_text['class'] = 'kCrYT ip-address-div' 63 | calculator_text.string = 'Calculator' 64 | calculator_widget = html_soup.new_tag('div') 65 | calculator_widget.append(BeautifulSoup(widget_file, 'html.parser')) 66 | calculator_widget['class'] = 'kCrYT ip-text-div' 67 | widget_tag.append(calculator_text) 68 | widget_tag.append(calculator_widget) 69 | main_div.insert_before(widget_tag) 70 | widget_file.close() 71 | return html_soup 72 | -------------------------------------------------------------------------------- /test/test_misc.py: -------------------------------------------------------------------------------- 1 | from cryptography.fernet import Fernet 2 | 3 | from app import app 4 | from app.models.endpoint import Endpoint 5 | from app.utils.session import generate_key, valid_user_session 6 | 7 | JAPAN_PREFS = 'uG7IBICwK7FgMJNpUawp2tKDb1Omuv_euy-cJHVZ' \ 8 | + 'BSydthgwxRFIHxiVA8qUGavKaDXyiM5uNuPIjKbEAW-zB_vzNXWVaafFhW7k2' \ 9 | + 'fO2_mS5e5eK41XXWwiViTz2VVmGWje0UgQwwVPe1A7aH0s10FgARsd2xl5nlg' \ 10 | + 'RLHT2krPUw-iLQ5uHZSnYXFuF4caYemWcj4vqB2ocHkt-aqn04jgnnlWWME_K' \ 11 | + '9ySWdWmPyS66HtLt1tCwc_-xGZklvbHw==' 12 | 13 | 14 | def test_generate_user_keys(): 15 | key = generate_key() 16 | assert Fernet(key) 17 | assert generate_key() != key 18 | 19 | 20 | def test_valid_session(client): 21 | assert not valid_user_session({'key': '', 'config': {}}) 22 | with client.session_transaction() as session: 23 | assert valid_user_session(session) 24 | 25 | 26 | def test_valid_translation_keys(client): 27 | valid_lang_keys = [_['value'] for _ in app.config['LANGUAGES']] 28 | en_keys = app.config['TRANSLATIONS']['lang_en'].keys() 29 | for translation_key in app.config['TRANSLATIONS']: 30 | # Ensure the translation is using a valid language value 31 | assert translation_key in valid_lang_keys 32 | 33 | # Ensure all translations match the same size/content of the original 34 | # English translation 35 | assert app.config['TRANSLATIONS'][translation_key].keys() == en_keys 36 | 37 | 38 | def test_query_decryption(client): 39 | # FIXME: Handle decryption errors in search.py and rewrite test 40 | # This previously was used to test swapping decryption keys between 41 | # queries. While this worked in theory and usually didn't cause problems, 42 | # they were tied to session IDs and those are really unreliable (meaning 43 | # that occasionally page navigation would break). 44 | rv = client.get('/') 45 | cookie = rv.headers['Set-Cookie'] 46 | 47 | rv = client.get(f'/{Endpoint.search}?q=test+1', headers={'Cookie': cookie}) 48 | assert rv._status_code == 200 49 | 50 | with client.session_transaction() as session: 51 | assert valid_user_session(session) 52 | 53 | rv = client.get(f'/{Endpoint.search}?q=test+2', headers={'Cookie': cookie}) 54 | assert rv._status_code == 200 55 | 56 | with client.session_transaction() as session: 57 | assert valid_user_session(session) 58 | 59 | 60 | def test_prefs_url(client): 61 | base_url = f'/{Endpoint.search}?q=wikipedia' 62 | rv = client.get(base_url) 63 | assert rv._status_code == 200 64 | assert b'wikipedia.org' in rv.data 65 | assert b'ja.wikipedia.org' not in rv.data 66 | 67 | rv = client.get(f'{base_url}&preferences={JAPAN_PREFS}') 68 | assert rv._status_code == 200 69 | assert b'ja.wikipedia.org' in rv.data 70 | 71 | -------------------------------------------------------------------------------- /app/static/js/utils.js: -------------------------------------------------------------------------------- 1 | const checkForTracking = () => { 2 | const mainDiv = document.getElementById("main"); 3 | const searchBar = document.getElementById("search-bar"); 4 | // some pages (e.g. images) do not have these 5 | if (!mainDiv || !searchBar) 6 | return; 7 | const query = searchBar.value.replace(/\s+/g, ''); 8 | 9 | // Note: regex functions for checking for tracking queries were derived 10 | // from here -- https://stackoverflow.com/questions/619977 11 | const matchTracking = { 12 | "ups": { 13 | "link": `https://www.ups.com/track?tracknum=${query}`, 14 | "expr": [ 15 | /\b(1Z ?[0-9A-Z]{3} ?[0-9A-Z]{3} ?[0-9A-Z]{2} ?[0-9A-Z]{4} ?[0-9A-Z]{3} ?[0-9A-Z]|[\dT]\d\d\d ?\d\d\d\d ?\d\d\d)\b/ 16 | ] 17 | }, 18 | "usps": { 19 | "link": `https://tools.usps.com/go/TrackConfirmAction_input?origTrackNum=${query}`, 20 | "expr": [ 21 | /(\b\d{30}\b)|(\b91\d+\b)|(\b\d{20}\b)/, 22 | /^E\D{1}\d{9}\D{2}$|^9\d{15,21}$/, 23 | /^91[0-9]+$/, 24 | /^[A-Za-z]{2}[0-9]+US$/ 25 | ] 26 | }, 27 | "fedex": { 28 | "link": `https://www.fedex.com/apps/fedextrack/?tracknumbers=${query}`, 29 | "expr": [ 30 | /(\b96\d{20}\b)|(\b\d{15}\b)|(\b\d{12}\b)/, 31 | /\b((98\d\d\d\d\d?\d\d\d\d|98\d\d) ?\d\d\d\d ?\d\d\d\d( ?\d\d\d)?)\b/, 32 | /^[0-9]{15}$/ 33 | ] 34 | } 35 | }; 36 | 37 | // Creates a link to a UPS/USPS/FedEx tracking page 38 | const createTrackingLink = href => { 39 | let link = document.createElement("a"); 40 | link.className = "tracking-link"; 41 | link.innerHTML = "View Tracking Info"; 42 | link.href = href; 43 | mainDiv.prepend(link); 44 | }; 45 | 46 | // Compares the query against a set of regex patterns 47 | // for tracking numbers 48 | const compareQuery = provider => { 49 | provider.expr.some(regex => { 50 | if (query.match(regex)) { 51 | createTrackingLink(provider.link); 52 | return true; 53 | } 54 | }); 55 | }; 56 | 57 | for (const key of Object.keys(matchTracking)) { 58 | compareQuery(matchTracking[key]); 59 | } 60 | }; 61 | 62 | document.addEventListener("DOMContentLoaded", function() { 63 | checkForTracking(); 64 | 65 | // Clear input if reset button tapped 66 | const searchBar = document.getElementById("search-bar"); 67 | const resetBtn = document.getElementById("search-reset"); 68 | // some pages (e.g. images) do not have these 69 | if (!searchBar || !resetBtn) 70 | return; 71 | resetBtn.addEventListener("click", event => { 72 | event.preventDefault(); 73 | searchBar.value = ""; 74 | searchBar.focus(); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /app/static/settings/languages.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"name": "-------", "value": ""}, 3 | {"name": "English", "value": "lang_en"}, 4 | {"name": "Afrikaans (Afrikaans)", "value": "lang_af"}, 5 | {"name": "Arabic (عربى)", "value": "lang_ar"}, 6 | {"name": "Armenian (հայերեն)", "value": "lang_hy"}, 7 | {"name": "Azerbaijani (Azərbaycanca)", "value": "lang_az"}, 8 | {"name": "Belarusian (Беларуская)", "value": "lang_be"}, 9 | {"name": "Bulgarian (български)", "value": "lang_bg"}, 10 | {"name": "Catalan (Català)", "value": "lang_ca"}, 11 | {"name": "Chinese, Simplified (简体中文)", "value": "lang_zh-CN"}, 12 | {"name": "Chinese, Traditional (正體中文)", "value": "lang_zh-TW"}, 13 | {"name": "Croatian (Hrvatski)", "value": "lang_hr"}, 14 | {"name": "Czech (čeština)", "value": "lang_cs"}, 15 | {"name": "Danish (Dansk)", "value": "lang_da"}, 16 | {"name": "Dutch (Nederlands)", "value": "lang_nl"}, 17 | {"name": "Esperanto (Esperanto)", "value": "lang_eo"}, 18 | {"name": "Estonian (Eestlane)", "value": "lang_et"}, 19 | {"name": "Filipino (Pilipino)", "value": "lang_tl"}, 20 | {"name": "Finnish (Suomalainen)", "value": "lang_fi"}, 21 | {"name": "French (Français)", "value": "lang_fr"}, 22 | {"name": "German (Deutsch)", "value": "lang_de"}, 23 | {"name": "Greek (Ελληνικά)", "value": "lang_el"}, 24 | {"name": "Hebrew (עִברִית)", "value": "lang_iw"}, 25 | {"name": "Hindi (हिंदी)", "value": "lang_hi"}, 26 | {"name": "Hungarian (Magyar)", "value": "lang_hu"}, 27 | {"name": "Icelandic (Íslenska)", "value": "lang_is"}, 28 | {"name": "Indonesian (Indonesian)", "value": "lang_id"}, 29 | {"name": "Italian (Italiano)", "value": "lang_it"}, 30 | {"name": "Japanese (日本語)", "value": "lang_ja"}, 31 | {"name": "Korean (한국어)", "value": "lang_ko"}, 32 | {"name": "Kurdish (Kurdî)", "value": "lang_ku"}, 33 | {"name": "Latvian (Latvietis)", "value": "lang_lv"}, 34 | {"name": "Lithuanian (Lietuvis)", "value": "lang_lt"}, 35 | {"name": "Norwegian (Norwegian)", "value": "lang_no"}, 36 | {"name": "Persian (فارسی)", "value": "lang_fa"}, 37 | {"name": "Polish (Polskie)", "value": "lang_pl"}, 38 | {"name": "Portuguese (Português)", "value": "lang_pt"}, 39 | {"name": "Romanian (Română)", "value": "lang_ro"}, 40 | {"name": "Russian (русский)", "value": "lang_ru"}, 41 | {"name": "Serbian (Српски)", "value": "lang_sr"}, 42 | {"name": "Sinhala (සිංහල)", "value": "lang_si"}, 43 | {"name": "Slovak (Slovák)", "value": "lang_sk"}, 44 | {"name": "Slovenian (Slovenščina)", "value": "lang_sl"}, 45 | {"name": "Spanish (Español)", "value": "lang_es"}, 46 | {"name": "Swahili (Kiswahili)", "value": "lang_sw"}, 47 | {"name": "Swedish (Svenska)", "value": "lang_sv"}, 48 | {"name": "Thai (ไทย)", "value": "lang_th"}, 49 | {"name": "Turkish (Türk)", "value": "lang_tr"}, 50 | {"name": "Ukrainian (Українська)", "value": "lang_uk"}, 51 | {"name": "Vietnamese (Tiếng Việt)", "value": "lang_vi"}, 52 | {"name": "Welsh (Cymraeg)", "value": "lang_cy"}, 53 | {"name": "Xhosa (isiXhosa)", "value": "lang_xh"}, 54 | {"name": "Zulu (isiZulu)", "value": "lang_zu"} 55 | ] 56 | -------------------------------------------------------------------------------- /app/static/js/controller.js: -------------------------------------------------------------------------------- 1 | const setupSearchLayout = () => { 2 | // Setup search field 3 | const searchBar = document.getElementById("search-bar"); 4 | const searchBtn = document.getElementById("search-submit"); 5 | const arrowKeys = [37, 38, 39, 40]; 6 | let searchValue = searchBar.value; 7 | 8 | // Automatically focus on search field 9 | searchBar.focus(); 10 | searchBar.select(); 11 | 12 | searchBar.addEventListener("keyup", function(event) { 13 | if (event.keyCode === 13) { 14 | event.preventDefault(); 15 | searchBtn.click(); 16 | } else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) { 17 | searchValue = searchBar.value; 18 | handleUserInput(); 19 | } 20 | }); 21 | }; 22 | 23 | const setupConfigLayout = () => { 24 | // Setup whoogle config 25 | const collapsible = document.getElementById("config-collapsible"); 26 | collapsible.addEventListener("click", function() { 27 | this.classList.toggle("active"); 28 | let content = this.nextElementSibling; 29 | if (content.style.maxHeight) { 30 | content.style.maxHeight = null; 31 | } else { 32 | content.style.maxHeight = "400px"; 33 | } 34 | 35 | content.classList.toggle("open"); 36 | }); 37 | }; 38 | 39 | const loadConfig = event => { 40 | event.preventDefault(); 41 | let config = prompt("Enter name of config:"); 42 | if (!config) { 43 | alert("Must specify a name for the config to load"); 44 | return; 45 | } 46 | 47 | let xhrPUT = new XMLHttpRequest(); 48 | xhrPUT.open("PUT", "config?name=" + config + ".conf"); 49 | xhrPUT.onload = function() { 50 | if (xhrPUT.readyState === 4 && xhrPUT.status !== 200) { 51 | alert("Error loading Whoogle config"); 52 | return; 53 | } 54 | 55 | location.reload(true); 56 | }; 57 | 58 | xhrPUT.send(); 59 | }; 60 | 61 | const saveConfig = event => { 62 | event.preventDefault(); 63 | let config = prompt("Enter name for this config:"); 64 | if (!config) { 65 | alert("Must specify a name for the config to save"); 66 | return; 67 | } 68 | 69 | let configForm = document.getElementById("config-form"); 70 | configForm.action = 'config?name=' + config + ".conf"; 71 | configForm.submit(); 72 | }; 73 | 74 | document.addEventListener("DOMContentLoaded", function() { 75 | setTimeout(function() { 76 | document.getElementById("main").style.display = "block"; 77 | }, 100); 78 | 79 | setupSearchLayout(); 80 | setupConfigLayout(); 81 | 82 | document.getElementById("config-load").addEventListener("click", loadConfig); 83 | document.getElementById("config-save").addEventListener("click", saveConfig); 84 | 85 | // Focusing on the search input field requires a delay for elements to finish 86 | // loading (seemingly only on FF) 87 | setTimeout(function() { document.getElementById("search-bar").focus(); }, 250); 88 | }); 89 | -------------------------------------------------------------------------------- /charts/whoogle/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "whoogle.fullname" . }} 5 | labels: 6 | {{- include "whoogle.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "whoogle.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "whoogle.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.image.pullSecrets }} 24 | imagePullSecrets: 25 | {{- range .}} 26 | - name: {{ . }} 27 | {{- end }} 28 | {{- end }} 29 | serviceAccountName: {{ include "whoogle.serviceAccountName" . }} 30 | securityContext: 31 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 32 | containers: 33 | - name: whoogle 34 | securityContext: 35 | {{- toYaml .Values.securityContext | nindent 12 }} 36 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 37 | imagePullPolicy: {{ .Values.image.pullPolicy }} 38 | {{- with .Values.conf }} 39 | env: 40 | {{- range $k,$v := . }} 41 | {{- if $v }} 42 | - name: {{ $k }} 43 | value: {{ tpl (toString $v) $ | quote }} 44 | {{- end }} 45 | {{- end }} 46 | {{- end }} 47 | ports: 48 | - name: http 49 | containerPort: {{ default 5000 .Values.conf.EXPOSE_PORT }} 50 | protocol: TCP 51 | livenessProbe: 52 | httpGet: 53 | path: / 54 | port: http 55 | {{- if and .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS }} 56 | httpHeaders: 57 | - name: Authorization 58 | value: Basic {{ b64enc (printf "%s:%s" .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS) }} 59 | {{- end }} 60 | readinessProbe: 61 | httpGet: 62 | path: / 63 | port: http 64 | {{- if and .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS }} 65 | httpHeaders: 66 | - name: Authorization 67 | value: Basic {{ b64enc (printf "%s:%s" .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS) }} 68 | {{- end }} 69 | resources: 70 | {{- toYaml .Values.resources | nindent 12 }} 71 | {{- with .Values.nodeSelector }} 72 | nodeSelector: 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- with .Values.affinity }} 76 | affinity: 77 | {{- toYaml . | nindent 8 }} 78 | {{- end }} 79 | {{- with .Values.tolerations }} 80 | tolerations: 81 | {{- toYaml . | nindent 8 }} 82 | {{- end }} 83 | -------------------------------------------------------------------------------- /test/test_routes.py: -------------------------------------------------------------------------------- 1 | from app import app 2 | from app.models.endpoint import Endpoint 3 | 4 | import json 5 | 6 | from test.conftest import demo_config 7 | 8 | 9 | def test_main(client): 10 | rv = client.get('/') 11 | assert rv._status_code == 200 12 | 13 | 14 | def test_search(client): 15 | rv = client.get(f'/{Endpoint.search}?q=test') 16 | assert rv._status_code == 200 17 | 18 | 19 | def test_feeling_lucky(client): 20 | # Bang at beginning of query 21 | rv = client.get(f'/{Endpoint.search}?q=!%20wikipedia') 22 | assert rv._status_code == 303 23 | assert rv.headers.get('Location').startswith('https://www.wikipedia.org') 24 | 25 | # Move bang to end of query 26 | rv = client.get(f'/{Endpoint.search}?q=github%20!') 27 | assert rv._status_code == 303 28 | assert rv.headers.get('Location').startswith('https://github.com') 29 | 30 | 31 | def test_ddg_bang(client): 32 | # Bang at beginning of query 33 | rv = client.get(f'/{Endpoint.search}?q=!gh%20whoogle') 34 | assert rv._status_code == 302 35 | assert rv.headers.get('Location').startswith('https://github.com') 36 | 37 | # Move bang to end of query 38 | rv = client.get(f'/{Endpoint.search}?q=github%20!w') 39 | assert rv._status_code == 302 40 | assert rv.headers.get('Location').startswith('https://en.wikipedia.org') 41 | 42 | # Move bang to middle of query 43 | rv = client.get(f'/{Endpoint.search}?q=big%20!r%20chungus') 44 | assert rv._status_code == 302 45 | assert rv.headers.get('Location').startswith('https://www.reddit.com') 46 | 47 | # Ensure bang is case insensitive 48 | rv = client.get(f'/{Endpoint.search}?q=!GH%20whoogle') 49 | assert rv._status_code == 302 50 | assert rv.headers.get('Location').startswith('https://github.com') 51 | 52 | # Ensure bang without a query still redirects to the result 53 | rv = client.get(f'/{Endpoint.search}?q=!gh') 54 | assert rv._status_code == 302 55 | assert rv.headers.get('Location').startswith('https://github.com') 56 | 57 | 58 | def test_custom_bang(client): 59 | # Bang at beginning of query 60 | rv = client.get(f'/{Endpoint.search}?q=!i%20whoogle') 61 | assert rv._status_code == 302 62 | assert rv.headers.get('Location').startswith('search?q=') 63 | 64 | 65 | def test_config(client): 66 | rv = client.post(f'/{Endpoint.config}', data=demo_config) 67 | assert rv._status_code == 302 68 | 69 | rv = client.get(f'/{Endpoint.config}') 70 | assert rv._status_code == 200 71 | 72 | config = json.loads(rv.data) 73 | for key in demo_config.keys(): 74 | assert config[key] == demo_config[key] 75 | 76 | # Test disabling changing config from client 77 | app.config['CONFIG_DISABLE'] = 1 78 | dark_mod = not demo_config['dark'] 79 | demo_config['dark'] = dark_mod 80 | rv = client.post(f'/{Endpoint.config}', data=demo_config) 81 | assert rv._status_code == 403 82 | 83 | rv = client.get(f'/{Endpoint.config}') 84 | config = json.loads(rv.data) 85 | assert config['dark'] != dark_mod 86 | 87 | 88 | def test_opensearch(client): 89 | rv = client.get(f'/{Endpoint.opensearch}') 90 | assert rv._status_code == 200 91 | assert 'Whoogle' in str(rv.data) 92 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.6-alpine3.20 AS builder 2 | 3 | RUN apk --no-cache add \ 4 | build-base \ 5 | libxml2-dev \ 6 | libxslt-dev \ 7 | openssl-dev \ 8 | libffi-dev 9 | 10 | COPY requirements.txt . 11 | 12 | RUN pip install --upgrade pip 13 | RUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt 14 | 15 | FROM python:3.12.6-alpine3.20 16 | 17 | RUN apk add --no-cache tor curl openrc libstdc++ 18 | # git go //for obfs4proxy 19 | # libcurl4-openssl-dev 20 | 21 | RUN apk --no-cache upgrade 22 | 23 | # uncomment to build obfs4proxy 24 | # RUN git clone https://gitlab.com/yawning/obfs4.git 25 | # WORKDIR /obfs4 26 | # RUN go build -o obfs4proxy/obfs4proxy ./obfs4proxy 27 | # RUN cp ./obfs4proxy/obfs4proxy /usr/bin/obfs4proxy 28 | 29 | ARG DOCKER_USER=whoogle 30 | ARG DOCKER_USERID=927 31 | ARG config_dir=/config 32 | RUN mkdir -p $config_dir 33 | RUN chmod a+w $config_dir 34 | VOLUME $config_dir 35 | 36 | ARG url_prefix='' 37 | ARG username='' 38 | ARG password='' 39 | ARG proxyuser='' 40 | ARG proxypass='' 41 | ARG proxytype='' 42 | ARG proxyloc='' 43 | ARG whoogle_dotenv='' 44 | ARG use_https='' 45 | ARG whoogle_port=5000 46 | ARG twitter_alt='farside.link/nitter' 47 | ARG youtube_alt='farside.link/invidious' 48 | ARG reddit_alt='farside.link/libreddit' 49 | ARG medium_alt='farside.link/scribe' 50 | ARG translate_alt='farside.link/lingva' 51 | ARG imgur_alt='farside.link/rimgo' 52 | ARG wikipedia_alt='farside.link/wikiless' 53 | ARG imdb_alt='farside.link/libremdb' 54 | ARG quora_alt='farside.link/quetre' 55 | ARG so_alt='farside.link/anonymousoverflow' 56 | 57 | ENV CONFIG_VOLUME=$config_dir \ 58 | WHOOGLE_URL_PREFIX=$url_prefix \ 59 | WHOOGLE_USER=$username \ 60 | WHOOGLE_PASS=$password \ 61 | WHOOGLE_PROXY_USER=$proxyuser \ 62 | WHOOGLE_PROXY_PASS=$proxypass \ 63 | WHOOGLE_PROXY_TYPE=$proxytype \ 64 | WHOOGLE_PROXY_LOC=$proxyloc \ 65 | WHOOGLE_DOTENV=$whoogle_dotenv \ 66 | HTTPS_ONLY=$use_https \ 67 | EXPOSE_PORT=$whoogle_port \ 68 | WHOOGLE_ALT_TW=$twitter_alt \ 69 | WHOOGLE_ALT_YT=$youtube_alt \ 70 | WHOOGLE_ALT_RD=$reddit_alt \ 71 | WHOOGLE_ALT_MD=$medium_alt \ 72 | WHOOGLE_ALT_TL=$translate_alt \ 73 | WHOOGLE_ALT_IMG=$imgur_alt \ 74 | WHOOGLE_ALT_WIKI=$wikipedia_alt \ 75 | WHOOGLE_ALT_IMDB=$imdb_alt \ 76 | WHOOGLE_ALT_QUORA=$quora_alt \ 77 | WHOOGLE_ALT_SO=$so_alt 78 | 79 | WORKDIR /whoogle 80 | 81 | COPY --from=builder /install /usr/local 82 | COPY misc/tor/torrc /etc/tor/torrc 83 | COPY misc/tor/start-tor.sh misc/tor/start-tor.sh 84 | COPY app/ app/ 85 | COPY run whoogle.env* ./ 86 | 87 | # Create user/group to run as 88 | RUN adduser -D -g $DOCKER_USERID -u $DOCKER_USERID $DOCKER_USER 89 | 90 | # Fix ownership / permissions 91 | RUN chown -R ${DOCKER_USER}:${DOCKER_USER} /whoogle /var/lib/tor 92 | 93 | # Allow writing symlinks to build dir 94 | RUN chown $DOCKER_USERID:$DOCKER_USERID app/static/build 95 | 96 | USER $DOCKER_USER:$DOCKER_USER 97 | 98 | EXPOSE $EXPOSE_PORT 99 | 100 | HEALTHCHECK --interval=30s --timeout=5s \ 101 | CMD curl -f http://localhost:${EXPOSE_PORT}/healthz || exit 1 102 | 103 | CMD misc/tor/start-tor.sh & ./run 104 | -------------------------------------------------------------------------------- /docker-compose-traefik.yaml: -------------------------------------------------------------------------------- 1 | # can't use mem_limit in a 3.x docker-compose file in non swarm mode 2 | # see https://github.com/docker/compose/issues/4513 3 | version: "2.4" 4 | 5 | services: 6 | traefik: 7 | image: "traefik:v2.7" 8 | container_name: "traefik" 9 | command: 10 | #- "--log.level=DEBUG" 11 | - "--api.insecure=true" 12 | - "--providers.docker=true" 13 | - "--providers.docker.exposedbydefault=false" 14 | - "--entrypoints.websecure.address=:443" 15 | - "--certificatesresolvers.myresolver.acme.tlschallenge=true" 16 | #- "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" 17 | - "--certificatesresolvers.myresolver.acme.email=change@domain.name" 18 | - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" 19 | ports: 20 | - "443:443" 21 | - "8080:8080" 22 | volumes: 23 | - "./letsencrypt:/letsencrypt" 24 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 25 | 26 | whoogle-search: 27 | labels: 28 | - "traefik.enable=true" 29 | - "traefik.http.routers.whoami.rule=Host(`change.host.name`)" 30 | - "traefik.http.routers.whoami.entrypoints=websecure" 31 | - "traefik.http.routers.whoami.tls.certresolver=myresolver" 32 | - "traefik.http.services.whoogle-search.loadbalancer.server.port=5000" 33 | image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search} 34 | container_name: whoogle-search 35 | restart: unless-stopped 36 | pids_limit: 50 37 | mem_limit: 256mb 38 | memswap_limit: 256mb 39 | # user debian-tor from tor package 40 | user: whoogle 41 | security_opt: 42 | - no-new-privileges 43 | cap_drop: 44 | - ALL 45 | tmpfs: 46 | - /config/:size=10M,uid=927,gid=927,mode=1700 47 | - /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700 48 | - /run/tor/:size=1M,uid=927,gid=927,mode=1700 49 | environment: # Uncomment to configure environment variables 50 | # Basic auth configuration, uncomment to enable 51 | #- WHOOGLE_USER= 52 | #- WHOOGLE_PASS= 53 | # Proxy configuration, uncomment to enable 54 | #- WHOOGLE_PROXY_USER= 55 | #- WHOOGLE_PROXY_PASS= 56 | #- WHOOGLE_PROXY_TYPE= 58 | # Site alternative configurations, uncomment to enable 59 | # Note: If not set, the feature will still be available 60 | # with default values. 61 | #- WHOOGLE_ALT_TW=farside.link/nitter 62 | #- WHOOGLE_ALT_YT=farside.link/invidious 63 | #- WHOOGLE_ALT_IG=farside.link/bibliogram/u 64 | #- WHOOGLE_ALT_RD=farside.link/libreddit 65 | #- WHOOGLE_ALT_MD=farside.link/scribe 66 | #- WHOOGLE_ALT_TL=farside.link/lingva 67 | #- WHOOGLE_ALT_IMG=farside.link/rimgo 68 | #- WHOOGLE_ALT_WIKI=farside.link/wikiless 69 | #- WHOOGLE_ALT_IMDB=farside.link/libremdb 70 | #- WHOOGLE_ALT_QUORA=farside.link/quetre 71 | #- WHOOGLE_ALT_SO=farside.link/anonymousoverflow 72 | # - WHOOGLE_CONFIG_DISABLE=1 73 | # - WHOOGLE_CONFIG_SEARCH_LANGUAGE=lang_en 74 | # - WHOOGLE_CONFIG_GET_ONLY=1 75 | # - WHOOGLE_CONFIG_COUNTRY=FR 76 | # - WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED=1 77 | # - WHOOGLE_CONFIG_PREFERENCES_KEY="NEEDS_TO_BE_MODIFIED" 78 | #env_file: # Alternatively, load variables from whoogle.env 79 | #- whoogle.env 80 | ports: 81 | - 8000:5000 82 | -------------------------------------------------------------------------------- /whoogle.template.env: -------------------------------------------------------------------------------- 1 | # ---------------------------------- 2 | # Rename to "whoogle.env" before use 3 | # ---------------------------------- 4 | # You can set Whoogle environment variables here, but must 5 | # modify your deployment to enable these values: 6 | # - Local: Set WHOOGLE_DOTENV=1 7 | # - docker-compose: Uncomment the env_file option 8 | # - docker: Add "--env-file ./whoogle.env" to your build command 9 | 10 | #WHOOGLE_ALT_TW=farside.link/nitter 11 | #WHOOGLE_ALT_YT=farside.link/invidious 12 | #WHOOGLE_ALT_IG=farside.link/bibliogram/u 13 | #WHOOGLE_ALT_RD=farside.link/libreddit 14 | #WHOOGLE_ALT_MD=farside.link/scribe 15 | #WHOOGLE_ALT_TL=farside.link/lingva 16 | #WHOOGLE_ALT_IMG=farside.link/rimgo 17 | #WHOOGLE_ALT_WIKI=farside.link/wikiless 18 | #WHOOGLE_ALT_IMDB=farside.link/libremdb 19 | #WHOOGLE_ALT_QUORA=farside.link/quetre 20 | #WHOOGLE_ALT_SO=farside.link/anonymousoverflow 21 | #WHOOGLE_USER="" 22 | #WHOOGLE_PASS="" 23 | #WHOOGLE_PROXY_USER="" 24 | #WHOOGLE_PROXY_PASS="" 25 | #WHOOGLE_PROXY_TYPE="" 26 | #WHOOGLE_PROXY_LOC="" 27 | #WHOOGLE_CSP=1 28 | #HTTPS_ONLY=1 29 | 30 | # The URL prefix to use for the whoogle instance (i.e. "/whoogle") 31 | #WHOOGLE_URL_PREFIX="" 32 | 33 | # Restrict results to only those near a particular city 34 | #WHOOGLE_CONFIG_NEAR=denver 35 | 36 | # See app/static/settings/countries.json for values 37 | #WHOOGLE_CONFIG_COUNTRY=US 38 | 39 | # See app/static/settings/languages.json for values 40 | #WHOOGLE_CONFIG_LANGUAGE=lang_en 41 | 42 | # See app/static/settings/languages.json for values 43 | #WHOOGLE_CONFIG_SEARCH_LANGUAGE=lang_en 44 | 45 | # Disable changing of config from client 46 | #WHOOGLE_CONFIG_DISABLE=1 47 | 48 | # Block websites from search results (comma-separated list) 49 | #WHOOGLE_CONFIG_BLOCK=pinterest.com,whitehouse.gov 50 | 51 | # Theme (light, dark, or system) 52 | #WHOOGLE_CONFIG_THEME=system 53 | 54 | # Safe search mode 55 | #WHOOGLE_CONFIG_SAFE=1 56 | 57 | # Use social media site alternatives (nitter, bibliogram, etc) 58 | #WHOOGLE_CONFIG_ALTS=1 59 | 60 | # Use Tor if available 61 | #WHOOGLE_CONFIG_TOR=1 62 | 63 | # Open results in new tab 64 | #WHOOGLE_CONFIG_NEW_TAB=1 65 | 66 | # Enable View Image option 67 | #WHOOGLE_CONFIG_VIEW_IMAGE=1 68 | 69 | # Search using GET requests only (exposes query in logs) 70 | #WHOOGLE_CONFIG_GET_ONLY=1 71 | 72 | # Remove everything except basic result cards from all search queries 73 | #WHOOGLE_MINIMAL=0 74 | 75 | # Set the number of results per page 76 | #WHOOGLE_RESULTS_PER_PAGE=10 77 | 78 | # Controls visibility of autocomplete/search suggestions 79 | #WHOOGLE_AUTOCOMPLETE=1 80 | 81 | # The port where Whoogle will be exposed 82 | #EXPOSE_PORT=5000 83 | 84 | # Set instance URL 85 | #WHOOGLE_CONFIG_URL=https:/// 86 | 87 | # Set custom CSS styling/theming 88 | #WHOOGLE_CONFIG_STYLE=":root { /* LIGHT THEME COLORS */ --whoogle-background: #d8dee9; --whoogle-accent: #2e3440; --whoogle-text: #3B4252; --whoogle-contrast-text: #eceff4; --whoogle-secondary-text: #70757a; --whoogle-result-bg: #fff; --whoogle-result-title: #4c566a; --whoogle-result-url: #81a1c1; --whoogle-result-visited: #a3be8c; /* DARK THEME COLORS */ --whoogle-dark-background: #222; --whoogle-dark-accent: #685e79; --whoogle-dark-text: #fff; --whoogle-dark-contrast-text: #000; --whoogle-dark-secondary-text: #bbb; --whoogle-dark-result-bg: #000; --whoogle-dark-result-title: #1967d2; --whoogle-dark-result-url: #4b11a8; --whoogle-dark-result-visited: #bbbbff; }" 89 | 90 | # Enable preferences encryption (requires key) 91 | #WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED=1 92 | 93 | # Set Key to encode config in url 94 | #WHOOGLE_CONFIG_PREFERENCES_KEY="NEEDS_TO_BE_MODIFIED" -------------------------------------------------------------------------------- /app/static/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Avenir, Helvetica, Arial, sans-serif; 3 | } 4 | 5 | .logo { 6 | width: 80%; 7 | display: block; 8 | margin: auto; 9 | padding-bottom: 10px; 10 | } 11 | 12 | .logo-container { 13 | max-height: 500px; 14 | } 15 | 16 | .home-search { 17 | background: transparent !important; 18 | border: 3px solid; 19 | } 20 | 21 | .search-container { 22 | background: transparent !important; 23 | width: 80%; 24 | position: absolute; 25 | top: 50%; 26 | left: 50%; 27 | transform: translate(-50%, -50%); 28 | max-width: 600px; 29 | z-index: 15; 30 | } 31 | 32 | .search-items { 33 | width: 100%; 34 | position: relative; 35 | display: flex; 36 | } 37 | 38 | #search-bar { 39 | background: transparent !important; 40 | width: 100%; 41 | padding: 5px; 42 | height: 40px; 43 | outline: none; 44 | font-size: 24px; 45 | border-radius: 10px 10px 0 0; 46 | max-width: 600px; 47 | background: rgba(0, 0, 0, 0); 48 | } 49 | 50 | #search-submit { 51 | width: 100%; 52 | height: 40px; 53 | text-align: center; 54 | cursor: pointer; 55 | font-size: 20px; 56 | align-content: center; 57 | align-items: center; 58 | margin: auto; 59 | border-radius: 0 0 10px 10px; 60 | max-width: 600px; 61 | -webkit-appearance: none; 62 | } 63 | 64 | .config-options { 65 | max-height: 370px; 66 | overflow-y: scroll; 67 | } 68 | 69 | .config-buttons { 70 | max-height: 30px; 71 | } 72 | 73 | .config-div { 74 | padding: 5px; 75 | } 76 | 77 | button::-moz-focus-inner { 78 | border: 0; 79 | } 80 | 81 | .collapsible { 82 | outline: 0; 83 | background-color: rgba(0, 0, 0, 0); 84 | cursor: pointer; 85 | padding: 18px; 86 | width: 100%; 87 | border: none; 88 | text-align: left; 89 | outline: none; 90 | font-size: 15px; 91 | border-radius: 10px 10px 0 0; 92 | } 93 | 94 | .collapsible:after { 95 | content: '\002B'; 96 | font-weight: bold; 97 | float: right; 98 | margin-left: 5px; 99 | } 100 | 101 | .active:after { 102 | content: "\2212"; 103 | } 104 | 105 | .content { 106 | padding: 0 18px; 107 | max-height: 0; 108 | overflow: hidden; 109 | transition: max-height 0.2s ease-out; 110 | border-radius: 0 0 10px 10px; 111 | } 112 | 113 | .open { 114 | padding-bottom: 20px; 115 | } 116 | 117 | .hidden { 118 | display: none; 119 | } 120 | 121 | footer { 122 | position: fixed; 123 | bottom: 0%; 124 | text-align: center; 125 | width: 100%; 126 | z-index: 10; 127 | } 128 | 129 | .info-text { 130 | font-style: italic; 131 | font-size: 12px; 132 | } 133 | 134 | #config-style { 135 | resize: none; 136 | overflow-y: scroll; 137 | width: 100%; 138 | height: 100px; 139 | } 140 | 141 | .whoogle-logo { 142 | display: none; 143 | } 144 | 145 | .whoogle-svg { 146 | width: 80%; 147 | height: initial; 148 | display: block; 149 | margin: auto; 150 | padding-bottom: 10px; 151 | } 152 | 153 | .autocomplete { 154 | position: relative; 155 | display: inline-block; 156 | width: 100%; 157 | } 158 | 159 | .autocomplete-items { 160 | position: absolute; 161 | border-bottom: none; 162 | border-top: none; 163 | z-index: 99; 164 | 165 | /*position the autocomplete items to be the same width as the container:*/ 166 | top: 100%; 167 | left: 0; 168 | right: 0; 169 | } 170 | 171 | .autocomplete-items div { 172 | padding: 10px; 173 | cursor: pointer; 174 | } 175 | 176 | details summary { 177 | padding: 10px; 178 | font-weight: bold; 179 | } 180 | 181 | /* Mobile styles */ 182 | @media (max-width: 1000px) { 183 | select { 184 | width: 100%; 185 | } 186 | 187 | #search-bar { 188 | font-size: 20px; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /app/templates/error.html: -------------------------------------------------------------------------------- 1 | {% if config.theme %} 2 | {% if config.theme == 'system' %} 3 | 7 | {% else %} 8 | 9 | {% endif %} 10 | {% else %} 11 | 12 | {% endif %} 13 | 14 | 15 | 16 |
17 |

Error

18 |

19 | {{ error_message }} 20 |

21 |
22 | {% if query and translation %} 23 |

24 |

{{ translation['continue-search'] }}

25 | 47 |
48 |

Other options:

49 | 102 |
103 |

104 | {% endif %} 105 | Return Home 106 |
107 | -------------------------------------------------------------------------------- /app/utils/bangs.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | import urllib.parse as urlparse 4 | import os 5 | import glob 6 | 7 | bangs_dict = {} 8 | DDG_BANGS = 'https://duckduckgo.com/bang.js' 9 | 10 | 11 | def load_all_bangs(ddg_bangs_file: str, ddg_bangs: dict = {}): 12 | """Loads all the bang files in alphabetical order 13 | 14 | Args: 15 | ddg_bangs_file: The str path to the new DDG bangs json file 16 | ddg_bangs: The dict of ddg bangs. If this is empty, it will load the 17 | bangs from the file 18 | 19 | Returns: 20 | None 21 | 22 | """ 23 | global bangs_dict 24 | ddg_bangs_file = os.path.normpath(ddg_bangs_file) 25 | 26 | if (bangs_dict and not ddg_bangs) or os.path.getsize(ddg_bangs_file) <= 4: 27 | return 28 | 29 | bangs = {} 30 | bangs_dir = os.path.dirname(ddg_bangs_file) 31 | bang_files = glob.glob(os.path.join(bangs_dir, '*.json')) 32 | 33 | # Normalize the paths 34 | bang_files = [os.path.normpath(f) for f in bang_files] 35 | 36 | # Move the ddg bangs file to the beginning 37 | bang_files = sorted([f for f in bang_files if f != ddg_bangs_file]) 38 | 39 | if ddg_bangs: 40 | bangs |= ddg_bangs 41 | else: 42 | bang_files.insert(0, ddg_bangs_file) 43 | 44 | for i, bang_file in enumerate(bang_files): 45 | try: 46 | bangs |= json.load(open(bang_file)) 47 | except json.decoder.JSONDecodeError: 48 | # Ignore decoding error only for the ddg bangs file, since this can 49 | # occur if file is still being written 50 | if i != 0: 51 | raise 52 | 53 | bangs_dict = dict(sorted(bangs.items())) 54 | 55 | 56 | def gen_bangs_json(bangs_file: str) -> None: 57 | """Generates a json file from the DDG bangs list 58 | 59 | Args: 60 | bangs_file: The str path to the new DDG bangs json file 61 | 62 | Returns: 63 | None 64 | 65 | """ 66 | try: 67 | # Request full list from DDG 68 | r = requests.get(DDG_BANGS) 69 | r.raise_for_status() 70 | except requests.exceptions.HTTPError as err: 71 | raise SystemExit(err) 72 | 73 | # Convert to json 74 | data = json.loads(r.text) 75 | 76 | # Set up a json object (with better formatting) for all available bangs 77 | bangs_data = {} 78 | 79 | for row in data: 80 | bang_command = '!' + row['t'] 81 | bangs_data[bang_command] = { 82 | 'url': row['u'].replace('{{{s}}}', '{}'), 83 | 'suggestion': bang_command + ' (' + row['s'] + ')' 84 | } 85 | 86 | json.dump(bangs_data, open(bangs_file, 'w')) 87 | print('* Finished creating ddg bangs json') 88 | load_all_bangs(bangs_file, bangs_data) 89 | 90 | 91 | def suggest_bang(query: str) -> list[str]: 92 | """Suggests bangs for a user's query 93 | 94 | Args: 95 | query: The search query 96 | 97 | Returns: 98 | list[str]: A list of bang suggestions 99 | 100 | """ 101 | global bangs_dict 102 | return [bangs_dict[_]['suggestion'] for _ in bangs_dict if _.startswith(query)] 103 | 104 | 105 | def resolve_bang(query: str) -> str: 106 | """Transform's a user's query to a bang search, if an operator is found 107 | 108 | Args: 109 | query: The search query 110 | 111 | Returns: 112 | str: A formatted redirect for a bang search, or an empty str if there 113 | wasn't a match or didn't contain a bang operator 114 | 115 | """ 116 | global bangs_dict 117 | 118 | #if ! not in query simply return (speed up processing) 119 | if '!' not in query: 120 | return '' 121 | 122 | split_query = query.strip().split(' ') 123 | 124 | # look for operator in query if one is found, list operator should be of 125 | # length 1, operator should not be case-sensitive here to remove it later 126 | operator = [ 127 | word 128 | for word in split_query 129 | if word.lower() in bangs_dict 130 | ] 131 | if len(operator) == 1: 132 | # get operator 133 | operator = operator[0] 134 | 135 | # removes operator from query 136 | split_query.remove(operator) 137 | 138 | # rebuild the query string 139 | bang_query = ' '.join(split_query).strip() 140 | 141 | # Check if operator is a key in bangs and get bang if exists 142 | bang = bangs_dict.get(operator.lower(), None) 143 | if bang: 144 | bang_url = bang['url'] 145 | 146 | if bang_query: 147 | return bang_url.replace('{}', bang_query, 1) 148 | else: 149 | parsed_url = urlparse.urlparse(bang_url) 150 | return f'{parsed_url.scheme}://{parsed_url.netloc}' 151 | return '' 152 | -------------------------------------------------------------------------------- /app/utils/misc.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import contextlib 4 | import io 5 | import os 6 | import re 7 | 8 | from requests import exceptions, get 9 | from urllib.parse import urlparse 10 | from bs4 import BeautifulSoup as bsoup 11 | from cryptography.fernet import Fernet 12 | from flask import Request 13 | 14 | ddg_favicon_site = 'http://icons.duckduckgo.com/ip2' 15 | 16 | empty_gif = base64.b64decode( 17 | 'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==') 18 | 19 | placeholder_img = base64.b64decode( 20 | 'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABF0lEQVRIS8XWPw9EMBQA8Eok' \ 21 | 'JBKrMFqMBt//GzAYLTZ/VomExPDu6uLiaPteqVynBn0/75W2Vp7nEIYhe6p1XcespmmAd7Is' \ 22 | 'M+4URcGiKPogvMMvmIS2eN9MOMKbKWgf54SYgI4vKkTuQKJKSJErkKzUSkQHUs0lilAg7GMh' \ 23 | 'ISoIA/hYMiKCKIA2soeowCWEMkfHtUmrXLcyGYYBfN9HF8djiaglWzNZlgVs21YisoAUaEXG' \ 24 | 'cQTP86QIFgi7vyLzPIPjOEIEC7ANQv/4aZrAdd0TUtc1i+MYnSsMWjPp+x6CIPgJVlUVS5KE' \ 25 | 'DKig/+wnVzM4pnzaGeHd+ENlWbI0TbVLJBtw2uMfP63wc9d2kDCWxi5Q27bsBerSJ9afJbeL' \ 26 | 'AAAAAElFTkSuQmCC' 27 | ) 28 | 29 | 30 | def fetch_favicon(url: str) -> bytes: 31 | """Fetches a favicon using DuckDuckGo's favicon retriever 32 | 33 | Args: 34 | url: The url to fetch the favicon from 35 | Returns: 36 | bytes - the favicon bytes, or a placeholder image if one 37 | was not returned 38 | """ 39 | response = get(f'{ddg_favicon_site}/{urlparse(url).netloc}.ico') 40 | 41 | if response.status_code == 200 and len(response.content) > 0: 42 | tmp_mem = io.BytesIO() 43 | tmp_mem.write(response.content) 44 | tmp_mem.seek(0) 45 | 46 | return tmp_mem.read() 47 | return placeholder_img 48 | 49 | 50 | def gen_file_hash(path: str, static_file: str) -> str: 51 | file_contents = open(os.path.join(path, static_file), 'rb').read() 52 | file_hash = hashlib.md5(file_contents).hexdigest()[:8] 53 | filename_split = os.path.splitext(static_file) 54 | 55 | return f'{filename_split[0]}.{file_hash}{filename_split[-1]}' 56 | 57 | 58 | def read_config_bool(var: str, default: bool=False) -> bool: 59 | val = os.getenv(var, '1' if default else '0') 60 | # user can specify one of the following values as 'true' inputs (all 61 | # variants with upper case letters will also work): 62 | # ('true', 't', '1', 'yes', 'y') 63 | return val.lower() in ('true', 't', '1', 'yes', 'y') 64 | 65 | 66 | def get_client_ip(r: Request) -> str: 67 | if r.environ.get('HTTP_X_FORWARDED_FOR') is None: 68 | return r.environ['REMOTE_ADDR'] 69 | 70 | return r.environ['HTTP_X_FORWARDED_FOR'] 71 | 72 | 73 | def get_request_url(url: str) -> str: 74 | if os.getenv('HTTPS_ONLY', False): 75 | return url.replace('http://', 'https://', 1) 76 | 77 | return url 78 | 79 | 80 | def get_proxy_host_url(r: Request, default: str, root=False) -> str: 81 | scheme = r.headers.get('X-Forwarded-Proto', 'https') 82 | http_host = r.headers.get('X-Forwarded-Host') 83 | 84 | full_path = r.full_path if not root else '' 85 | if full_path.startswith('/'): 86 | full_path = f'/{full_path}' 87 | 88 | if http_host: 89 | prefix = os.environ.get('WHOOGLE_URL_PREFIX', '') 90 | if prefix: 91 | prefix = f'/{re.sub("[^0-9a-zA-Z]+", "", prefix)}' 92 | return f'{scheme}://{http_host}{prefix}{full_path}' 93 | 94 | return default 95 | 96 | 97 | def check_for_update(version_url: str, current: str) -> int: 98 | # Check for the latest version of Whoogle 99 | has_update = '' 100 | with contextlib.suppress(exceptions.ConnectionError, AttributeError): 101 | update = bsoup(get(version_url).text, 'html.parser') 102 | latest = update.select_one('[class="Link--primary"]').string[1:] 103 | current = int(''.join(filter(str.isdigit, current))) 104 | latest = int(''.join(filter(str.isdigit, latest))) 105 | has_update = '' if current >= latest else latest 106 | 107 | return has_update 108 | 109 | 110 | def get_abs_url(url, page_url): 111 | # Creates a valid absolute URL using a partial or relative URL 112 | urls = { 113 | "//": f"https:{url}", 114 | "/": f"{urlparse(page_url).netloc}{url}", 115 | "./": f"{page_url}{url[2:]}" 116 | } 117 | for start in urls: 118 | if url.startswith(start): 119 | return urls[start] 120 | 121 | return url 122 | 123 | 124 | def list_to_dict(lst: list) -> dict: 125 | if len(lst) < 2: 126 | return {} 127 | return {lst[i].replace(' ', ''): lst[i+1].replace(' ', '') 128 | for i in range(0, len(lst), 2)} 129 | 130 | 131 | def encrypt_string(key: bytes, string: str) -> str: 132 | cipher_suite = Fernet(key) 133 | return cipher_suite.encrypt(string.encode()).decode() 134 | 135 | 136 | def decrypt_string(key: bytes, string: str) -> str: 137 | cipher_suite = Fernet(g.session_key) 138 | return cipher_suite.decrypt(string.encode()).decode() 139 | -------------------------------------------------------------------------------- /app/static/js/autocomplete.js: -------------------------------------------------------------------------------- 1 | let searchInput; 2 | let currentFocus; 3 | let originalSearch; 4 | let autocompleteResults; 5 | 6 | const handleUserInput = () => { 7 | let xhrRequest = new XMLHttpRequest(); 8 | xhrRequest.open("POST", "autocomplete"); 9 | xhrRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); 10 | xhrRequest.onload = function () { 11 | if (xhrRequest.readyState === 4 && xhrRequest.status !== 200) { 12 | // Do nothing if failed to fetch autocomplete results 13 | return; 14 | } 15 | 16 | // Fill autocomplete with fetched results 17 | autocompleteResults = JSON.parse(xhrRequest.responseText)[1]; 18 | updateAutocompleteList(); 19 | }; 20 | 21 | xhrRequest.send('q=' + searchInput.value); 22 | }; 23 | 24 | const removeActive = suggestion => { 25 | // Remove "autocomplete-active" class from previously active suggestion 26 | for (let i = 0; i < suggestion.length; i++) { 27 | suggestion[i].classList.remove("autocomplete-active"); 28 | } 29 | }; 30 | 31 | const addActive = (suggestion) => { 32 | // Handle navigation outside of suggestion list 33 | if (!suggestion || !suggestion[currentFocus]) { 34 | if (currentFocus >= suggestion.length) { 35 | // Move selection back to the beginning 36 | currentFocus = 0; 37 | } else if (currentFocus < 0) { 38 | // Retrieve original search and remove active suggestion selection 39 | currentFocus = -1; 40 | searchInput.value = originalSearch; 41 | removeActive(suggestion); 42 | return; 43 | } else { 44 | return; 45 | } 46 | } 47 | 48 | removeActive(suggestion); 49 | suggestion[currentFocus].classList.add("autocomplete-active"); 50 | 51 | // Autofill search bar with suggestion content (minus the "bang name" if using a bang operator) 52 | let searchContent = suggestion[currentFocus].textContent; 53 | if (searchContent.indexOf('(') > 0) { 54 | searchInput.value = searchContent.substring(0, searchContent.indexOf('(')); 55 | } else { 56 | searchInput.value = searchContent; 57 | } 58 | 59 | searchInput.focus(); 60 | }; 61 | 62 | const autocompleteInput = (e) => { 63 | // Handle navigation between autocomplete suggestions 64 | let suggestion = document.getElementById("autocomplete-list"); 65 | if (suggestion) suggestion = suggestion.getElementsByTagName("div"); 66 | if (e.keyCode === 40) { // down 67 | e.preventDefault(); 68 | currentFocus++; 69 | addActive(suggestion); 70 | } else if (e.keyCode === 38) { //up 71 | e.preventDefault(); 72 | currentFocus--; 73 | addActive(suggestion); 74 | } else if (e.keyCode === 13) { // enter 75 | e.preventDefault(); 76 | if (currentFocus > -1) { 77 | if (suggestion) suggestion[currentFocus].click(); 78 | } 79 | } else { 80 | originalSearch = searchInput.value; 81 | } 82 | }; 83 | 84 | const updateAutocompleteList = () => { 85 | let autocompleteItem, i; 86 | let val = originalSearch; 87 | 88 | let autocompleteList = document.getElementById("autocomplete-list"); 89 | autocompleteList.innerHTML = ""; 90 | 91 | if (!val || !autocompleteResults) { 92 | return false; 93 | } 94 | 95 | currentFocus = -1; 96 | 97 | for (i = 0; i < autocompleteResults.length; i++) { 98 | if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) { 99 | autocompleteItem = document.createElement("div"); 100 | autocompleteItem.setAttribute("class", "autocomplete-item"); 101 | autocompleteItem.innerHTML = "" + autocompleteResults[i].substr(0, val.length) + ""; 102 | autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length); 103 | autocompleteItem.innerHTML += ""; 104 | autocompleteItem.addEventListener("click", function () { 105 | searchInput.value = this.getElementsByTagName("input")[0].value; 106 | autocompleteList.innerHTML = ""; 107 | document.getElementById("search-form").submit(); 108 | }); 109 | autocompleteList.appendChild(autocompleteItem); 110 | } 111 | } 112 | }; 113 | 114 | document.addEventListener("DOMContentLoaded", function() { 115 | let autocompleteList = document.createElement("div"); 116 | autocompleteList.setAttribute("id", "autocomplete-list"); 117 | autocompleteList.setAttribute("class", "autocomplete-items"); 118 | 119 | searchInput = document.getElementById("search-bar"); 120 | searchInput.parentNode.appendChild(autocompleteList); 121 | 122 | searchInput.addEventListener("keydown", (event) => autocompleteInput(event)); 123 | 124 | document.addEventListener("click", function (e) { 125 | autocompleteList.innerHTML = ""; 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /app/static/css/light-theme.css: -------------------------------------------------------------------------------- 1 | html { 2 | background: var(--whoogle-page-bg) !important; 3 | } 4 | 5 | body { 6 | background: var(--whoogle-page-bg) !important; 7 | } 8 | 9 | div { 10 | color: var(--whoogle-text) !important; 11 | } 12 | 13 | label { 14 | color: var(--whoogle-contrast-text) !important; 15 | } 16 | 17 | li a { 18 | color: var(--whoogle-result-url) !important; 19 | } 20 | 21 | li { 22 | color: var(--whoogle-text) !important; 23 | } 24 | 25 | .anon-view { 26 | color: var(--whoogle-text) !important; 27 | text-decoration: underline; 28 | } 29 | 30 | textarea { 31 | background: var(--whoogle-page-bg) !important; 32 | color: var(--whoogle-text) !important; 33 | } 34 | 35 | select { 36 | background: var(--whoogle-page-bg) !important; 37 | color: var(--whoogle-text) !important; 38 | } 39 | 40 | .ZINbbc { 41 | overflow: hidden; 42 | background-color: var(--whoogle-result-bg) !important; 43 | margin-bottom: 10px !important; 44 | border-radius: 8px !important; 45 | box-shadow: 0 1px 6px rgba(32,33,36,0.28) !important; 46 | } 47 | 48 | .BsXmcf { 49 | background-color: unset !important; 50 | } 51 | 52 | .BVG0Nb { 53 | background-color: var(--whoogle-result-bg) !important; 54 | } 55 | 56 | .ZINbbc.luh4tb { 57 | background: var(--whoogle-result-bg) !important; 58 | margin-bottom: 24px !important; 59 | } 60 | 61 | .bRsWnc { 62 | background-color: var(--whoogle-result-bg) !important; 63 | } 64 | 65 | .x54gtf { 66 | background-color: var(--whoogle-divider) !important; 67 | } 68 | 69 | .Q0HXG { 70 | background-color: var(--whoogle-divider) !important; 71 | } 72 | 73 | .LKSyXe { 74 | background-color: var(--whoogle-divider) !important; 75 | } 76 | 77 | 78 | a:visited div, a:visited .qXLe6d { 79 | color: var(--whoogle-result-visited) !important; 80 | } 81 | 82 | a:link div, a:link .qXLe6d { 83 | color: var(--whoogle-result-title) !important; 84 | } 85 | 86 | a:link div, a:link .fYyStc { 87 | color: var(--whoogle-result-url) !important; 88 | } 89 | 90 | div span { 91 | color: var(--whoogle-secondary-text) !important; 92 | } 93 | 94 | input { 95 | background-color: var(--whoogle-page-bg) !important; 96 | color: var(--whoogle-text) !important; 97 | } 98 | 99 | #search-bar { 100 | color: var(--whoogle-text) !important; 101 | background-color: var(--whoogle-page-bg); 102 | } 103 | 104 | .home-search { 105 | border-color: var(--whoogle-element-bg) !important; 106 | } 107 | 108 | .search-container { 109 | background-color: var(--whoogle-page-bg) !important; 110 | } 111 | 112 | #search-submit { 113 | border: 1px solid var(--whoogle-element-bg) !important; 114 | background: var(--whoogle-element-bg) !important; 115 | color: var(--whoogle-contrast-text) !important; 116 | } 117 | 118 | .info-text { 119 | color: var(--whoogle-contrast-text) !important; 120 | opacity: 75%; 121 | } 122 | 123 | .collapsible { 124 | color: var(--whoogle-text) !important; 125 | } 126 | 127 | .collapsible:after { 128 | color: var(--whoogle-text); 129 | } 130 | 131 | .active { 132 | background-color: var(--whoogle-element-bg) !important; 133 | color: var(--whoogle-contrast-text) !important; 134 | } 135 | 136 | .content, .result-config { 137 | background-color: var(--whoogle-element-bg) !important; 138 | color: var(--whoogle-contrast-text) !important; 139 | } 140 | 141 | .active:after { 142 | color: var(--whoogle-contrast-text); 143 | } 144 | 145 | .link { 146 | color: var(--whoogle-element-bg); 147 | } 148 | 149 | .link-color { 150 | color: var(--whoogle-result-url) !important; 151 | } 152 | 153 | .autocomplete-items { 154 | border: 1px solid var(--whoogle-element-bg); 155 | } 156 | 157 | .autocomplete-items div { 158 | background-color: var(--whoogle-page-bg); 159 | border-bottom: 1px solid var(--whoogle-element-bg); 160 | } 161 | 162 | .autocomplete-items div:hover { 163 | background-color: var(--whoogle-element-bg); 164 | color: var(--whoogle-contrast-text) !important; 165 | } 166 | 167 | .autocomplete-active { 168 | background-color: var(--whoogle-element-bg) !important; 169 | color: var(--whoogle-contrast-text) !important; 170 | } 171 | 172 | .footer { 173 | color: var(--whoogle-text); 174 | } 175 | 176 | path { 177 | fill: var(--whoogle-logo); 178 | } 179 | 180 | .header-div { 181 | background-color: var(--whoogle-result-bg) !important; 182 | } 183 | 184 | #search-reset { 185 | color: var(--whoogle-text) !important; 186 | } 187 | 188 | .mobile-search-bar { 189 | background-color: var(--whoogle-result-bg) !important; 190 | color: var(--whoogle-text) !important; 191 | } 192 | 193 | .search-bar-desktop { 194 | background-color: var(--whoogle-result-bg) !important; 195 | color: var(--whoogle-text); 196 | border-bottom: 0px; 197 | } 198 | 199 | .ip-text-div, .update_available, .cb_label, .cb { 200 | color: var(--whoogle-secondary-text) !important; 201 | } 202 | 203 | .cb:focus { 204 | color: var(--whoogle-text) !important; 205 | } 206 | 207 | .desktop-header, .mobile-header { 208 | background-color: var(--whoogle-result-bg) !important; 209 | } 210 | -------------------------------------------------------------------------------- /app/static/css/header.css: -------------------------------------------------------------------------------- 1 | header { 2 | font-family: Roboto,HelveticaNeue,Arial,sans-serif; 3 | font-size: 14px; 4 | line-height: 20px; 5 | color: #3C4043; 6 | word-wrap: break-word; 7 | } 8 | 9 | .logo-link, .logo-letter { 10 | text-decoration: none !important; 11 | letter-spacing: -1px; 12 | text-align: center; 13 | border-radius: 2px 0 0 0; 14 | } 15 | 16 | .result-config { 17 | margin-bottom: 10px; 18 | padding: 10px; 19 | border-radius: 8px; 20 | } 21 | 22 | .mobile-logo { 23 | font: 22px/36px Futura, Arial, sans-serif; 24 | padding-left: 5px; 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | } 29 | 30 | .logo-div { 31 | letter-spacing: -1px; 32 | text-align: center; 33 | font: 22pt Futura, Arial, sans-serif; 34 | padding: 10px 0 5px 0; 35 | height: 37px; 36 | font-smoothing: antialiased; 37 | } 38 | 39 | .search-bar-desktop { 40 | border-radius: 8px 8px 0 0; 41 | height: 40px !important; 42 | } 43 | 44 | .search-div { 45 | border-radius: 8px 8px 0 0; 46 | box-shadow: 0 1px 6px rgba(32, 33, 36, 0.18); 47 | margin-top: 10px; 48 | } 49 | 50 | .search-form { 51 | height: 39px; 52 | display: flex; 53 | width: 100%; 54 | margin: 0px; 55 | } 56 | 57 | .search-input { 58 | background: none; 59 | margin: 2px 4px 2px 8px; 60 | display: block; 61 | font-size: 16px; 62 | padding: 0 0 0 8px; 63 | flex: 1; 64 | height: 35px; 65 | outline: none; 66 | border: none; 67 | width: 100%; 68 | -webkit-tap-highlight-color: rgba(0,0,0,0); 69 | overflow: hidden; 70 | } 71 | 72 | .tracking-link { 73 | font-size: large; 74 | text-align: center; 75 | margin: 15px; 76 | display: block; 77 | } 78 | 79 | #main>div:focus-within { 80 | border-radius: 8px; 81 | box-shadow: 0 0 6px 1px #2375e8; 82 | } 83 | 84 | #mobile-header-logo { 85 | height: 1.75em; 86 | } 87 | 88 | .mobile-input-div { 89 | width: 100%; 90 | } 91 | 92 | .mobile-search-bar { 93 | display: block; 94 | font-size: 16px; 95 | padding: 0 0 0 8px; 96 | padding-right: 0px; 97 | -webkit-box-flex: 1; 98 | height: 35px; 99 | outline: none; 100 | border: none; 101 | width: 100%; 102 | -webkit-tap-highlight-color: rgba(0,0,0,.00); 103 | overflow: hidden; 104 | border: 0px !important; 105 | } 106 | 107 | .autocomplete-mobile{ 108 | display: -webkit-box; 109 | width: 100%; 110 | } 111 | 112 | .desktop-header-logo { 113 | height: 1.65em; 114 | } 115 | 116 | .header-autocomplete { 117 | width: 100%; 118 | flex: 1 119 | } 120 | 121 | a { 122 | color: #1967D2; 123 | text-decoration: none; 124 | tap-highlight-color: rgba(0, 0, 0, .10); 125 | } 126 | 127 | .header-tab-div { 128 | border-radius: 0 0 8px 8px; 129 | box-shadow: 0 2px 3px rgba(32, 33, 36, 0.18); 130 | overflow: hidden; 131 | margin-bottom: 10px; 132 | } 133 | 134 | .header-tab-div-2 { 135 | border-top: 1px solid #dadce0; 136 | height: 39px; 137 | overflow: hidden; 138 | } 139 | 140 | .header-tab-div-3 { 141 | height: 51px; 142 | overflow-x: auto; 143 | overflow-y: hidden; 144 | } 145 | 146 | .desktop-header { 147 | height: 39px; 148 | display: box; 149 | display: flex; 150 | width: 100%; 151 | } 152 | 153 | .header-tab { 154 | box-pack: justify; 155 | font-size: 14px; 156 | line-height: 37px; 157 | justify-content: space-between; 158 | } 159 | 160 | .desktop-header a, .desktop-header span { 161 | color: #70757a; 162 | display: block; 163 | flex: none; 164 | padding: 0 16px; 165 | text-align: center; 166 | text-transform: uppercase; 167 | } 168 | 169 | span.header-tab-span { 170 | border-bottom: 2px solid #4285f4; 171 | color: #4285f4; 172 | font-weight: bold; 173 | } 174 | 175 | .mobile-header { 176 | height: 39px; 177 | display: box; 178 | display: flex; 179 | overflow-x: scroll; 180 | width: 100%; 181 | padding-left: 12px; 182 | } 183 | 184 | .mobile-header a, .mobile-header span { 185 | color: #70757a; 186 | text-decoration: none; 187 | display: inline-block; 188 | /* padding: 8px 12px 8px 12px; */ 189 | } 190 | 191 | span.mobile-tab-span { 192 | border-bottom: 2px solid #202124; 193 | color: #202124; 194 | height: 26px; 195 | /* margin: 0 12px; */ 196 | /* padding: 0; */ 197 | } 198 | 199 | .desktop-header input { 200 | margin: 2px 4px 2px 8px; 201 | } 202 | 203 | a.header-tab-a:visited { 204 | color: #70757a; 205 | } 206 | 207 | .header-tab-div-end { 208 | border-left: 1px solid rgba(0, 0, 0, 0.12); 209 | } 210 | 211 | .adv-search { 212 | font-size: 30px; 213 | } 214 | 215 | .adv-search:hover { 216 | cursor: pointer; 217 | } 218 | 219 | #adv-search-toggle { 220 | display: none; 221 | } 222 | 223 | .result-collapsible { 224 | max-height: 0px; 225 | overflow: hidden; 226 | transition: max-height .25s linear; 227 | } 228 | 229 | .search-bar-input { 230 | display: block; 231 | font-size: 16px; 232 | padding: 0 0 0 8px; 233 | flex: 1; 234 | height: 35px; 235 | outline: none; 236 | border: none; 237 | width: 100%; 238 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 239 | overflow: hidden; 240 | } 241 | 242 | #result-country { 243 | max-width: 200px; 244 | } 245 | 246 | @media (max-width: 801px) { 247 | .header-tab-div { 248 | margin-bottom: 10px !important 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /charts/whoogle/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for whoogle. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | nameOverride: "" 6 | fullnameOverride: "" 7 | 8 | replicaCount: 1 9 | image: 10 | repository: benbusby/whoogle-search 11 | pullPolicy: IfNotPresent 12 | # Overrides the image tag whose default is the chart appVersion. 13 | tag: "" 14 | pullSecrets: [] 15 | # - my-image-pull-secret 16 | 17 | serviceAccount: 18 | # Specifies whether a service account should be created 19 | create: true 20 | # Annotations to add to the service account 21 | annotations: {} 22 | # The name of the service account to use. 23 | # If not set and create is true, a name is generated using the fullname template 24 | name: "" 25 | 26 | conf: {} 27 | # WHOOGLE_URL_PREFIX: "" # The URL prefix to use for the whoogle instance (i.e. "/whoogle") 28 | # WHOOGLE_DOTENV: "" # Load environment variables in whoogle.env 29 | # WHOOGLE_USER: "" # The username for basic auth. WHOOGLE_PASS must also be set if used. 30 | # WHOOGLE_PASS: "" # The password for basic auth. WHOOGLE_USER must also be set if used. 31 | # WHOOGLE_PROXY_USER: "" # The username of the proxy server. 32 | # WHOOGLE_PROXY_PASS: "" # The password of the proxy server. 33 | # WHOOGLE_PROXY_TYPE: "" # The type of the proxy server. Can be "socks5", "socks4", or "http". 34 | # WHOOGLE_PROXY_LOC: "" # The location of the proxy server (host or ip). 35 | # EXPOSE_PORT: "" # The port where Whoogle will be exposed. (default 5000) 36 | # HTTPS_ONLY: "" # Enforce HTTPS. (See https://github.com/benbusby/whoogle-search#https-enforcement) 37 | # WHOOGLE_ALT_TW: "" # The twitter.com alternative to use when site alternatives are enabled in the config. 38 | # WHOOGLE_ALT_YT: "" # The youtube.com alternative to use when site alternatives are enabled in the config. 39 | # WHOOGLE_ALT_RD: "" # The reddit.com alternative to use when site alternatives are enabled in the config. 40 | # WHOOGLE_ALT_TL: "" # The Google Translate alternative to use. This is used for all "translate ____" searches. 41 | # WHOOGLE_ALT_MD: "" # The medium.com alternative to use when site alternatives are enabled in the config. 42 | # WHOOGLE_ALT_IMG: "" # The imgur.com alternative to use when site alternatives are enabled in the config. 43 | # WHOOGLE_ALT_WIKI: "" # The wikipedia.com alternative to use when site alternatives are enabled in the config. 44 | # WHOOGLE_ALT_IMDB: "" # The imdb.com alternative to use. Set to "" to continue using imdb.com when site alternatives are enabled. 45 | # WHOOGLE_ALT_QUORA: "" # The quora.com alternative to use. Set to "" to continue using quora.com when site alternatives are enabled. 46 | # WHOOGLE_ALT_SO: "" # The stackoverflow.com alternative to use. Set to "" to continue using stackoverflow.com when site alternatives are enabled. 47 | # WHOOGLE_AUTOCOMPLETE: "" # Controls visibility of autocomplete/search suggestions. Default on -- use '0' to disable 48 | # WHOOGLE_MINIMAL: "" # Remove everything except basic result cards from all search queries. 49 | 50 | # WHOOGLE_CONFIG_DISABLE: "" # Hide config from UI and disallow changes to config by client 51 | # WHOOGLE_CONFIG_COUNTRY: "" # Filter results by hosting country 52 | # WHOOGLE_CONFIG_LANGUAGE: "" # Set interface language 53 | # WHOOGLE_CONFIG_SEARCH_LANGUAGE: "" # Set search result language 54 | # WHOOGLE_CONFIG_BLOCK: "" # Block websites from search results (use comma-separated list) 55 | # WHOOGLE_CONFIG_THEME: "" # Set theme mode (light, dark, or system) 56 | # WHOOGLE_CONFIG_SAFE: "" # Enable safe searches 57 | # WHOOGLE_CONFIG_ALTS: "" # Use social media site alternatives (nitter, invidious, etc) 58 | # WHOOGLE_CONFIG_NEAR: "" # Restrict results to only those near a particular city 59 | # WHOOGLE_CONFIG_TOR: "" # Use Tor routing (if available) 60 | # WHOOGLE_CONFIG_NEW_TAB: "" # Always open results in new tab 61 | # WHOOGLE_CONFIG_VIEW_IMAGE: "" # Enable View Image option 62 | # WHOOGLE_CONFIG_GET_ONLY: "" # Search using GET requests only 63 | # WHOOGLE_CONFIG_URL: "" # The root url of the instance (https:///) 64 | # WHOOGLE_CONFIG_STYLE: "" # The custom CSS to use for styling (should be single line) 65 | # WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED: "" # Encrypt preferences token, requires key 66 | # WHOOGLE_CONFIG_PREFERENCES_KEY: "" # Key to encrypt preferences in URL (REQUIRED to show url) 67 | 68 | podAnnotations: {} 69 | podSecurityContext: {} 70 | # fsGroup: 2000 71 | securityContext: 72 | runAsUser: 0 73 | # capabilities: 74 | # drop: 75 | # - ALL 76 | # readOnlyRootFilesystem: true 77 | 78 | service: 79 | type: ClusterIP 80 | port: 5000 81 | 82 | ingress: 83 | enabled: false 84 | className: "" 85 | annotations: {} 86 | # kubernetes.io/ingress.class: nginx 87 | # kubernetes.io/tls-acme: "true" 88 | hosts: 89 | - host: whoogle.example.com 90 | paths: 91 | - path: / 92 | pathType: ImplementationSpecific 93 | tls: [] 94 | # - secretName: chart-example-tls 95 | # hosts: 96 | # - whoogle.example.com 97 | 98 | resources: {} 99 | # requests: 100 | # cpu: 100m 101 | # memory: 128Mi 102 | # limits: 103 | # cpu: 100m 104 | # memory: 128Mi 105 | 106 | autoscaling: 107 | enabled: false 108 | minReplicas: 1 109 | maxReplicas: 100 110 | targetCPUUtilizationPercentage: 80 111 | # targetMemoryUtilizationPercentage: 80 112 | 113 | nodeSelector: {} 114 | tolerations: [] 115 | affinity: {} 116 | -------------------------------------------------------------------------------- /app/static/css/dark-theme.css: -------------------------------------------------------------------------------- 1 | html { 2 | background: var(--whoogle-dark-page-bg) !important; 3 | } 4 | 5 | body { 6 | background: var(--whoogle-dark-page-bg) !important; 7 | } 8 | 9 | div { 10 | color: var(--whoogle-dark-text) !important; 11 | } 12 | 13 | label { 14 | color: var(--whoogle-dark-contrast-text) !important; 15 | } 16 | 17 | li a { 18 | color: var(--whoogle-dark-result-url) !important; 19 | } 20 | 21 | li { 22 | color: var(--whoogle-dark-text) !important; 23 | } 24 | 25 | .anon-view { 26 | color: var(--whoogle-dark-text) !important; 27 | text-decoration: underline; 28 | } 29 | 30 | textarea { 31 | background: var(--whoogle-dark-page-bg) !important; 32 | color: var(--whoogle-dark-text) !important; 33 | } 34 | 35 | a:visited h3 div, a:visited .qXLe6d { 36 | color: var(--whoogle-dark-result-visited) !important; 37 | } 38 | 39 | a:link h3 div, a:link .qXLe6d { 40 | color: var(--whoogle-dark-result-title) !important; 41 | } 42 | 43 | a:link div, a:link .fYyStc { 44 | color: var(--whoogle-dark-result-url) !important; 45 | } 46 | 47 | div span { 48 | color: var(--whoogle-dark-secondary-text) !important; 49 | } 50 | 51 | input { 52 | background-color: var(--whoogle-dark-page-bg) !important; 53 | color: var(--whoogle-dark-text) !important; 54 | } 55 | 56 | select { 57 | background: var(--whoogle-dark-page-bg) !important; 58 | color: var(--whoogle-dark-text) !important; 59 | } 60 | 61 | .search-container { 62 | background-color: var(--whoogle-dark-page-bg) !important; 63 | } 64 | 65 | .ZINbbc, .ezO2md { 66 | overflow: hidden; 67 | box-shadow: 0 0 0 0 !important; 68 | background-color: var(--whoogle-dark-result-bg) !important; 69 | margin-bottom: 10px !important; 70 | border-radius: 8px !important; 71 | } 72 | 73 | .BsXmcf { 74 | background-color: unset !important; 75 | } 76 | 77 | .KP7LCb { 78 | box-shadow: 0 0 0 0 !important; 79 | } 80 | 81 | .BVG0Nb { 82 | box-shadow: 0 0 0 0 !important; 83 | background-color: var(--whoogle-dark-page-bg) !important; 84 | } 85 | 86 | .ZINbbc.luh4tb { 87 | background: var(--whoogle-dark-result-bg) !important; 88 | margin-bottom: 24px !important; 89 | } 90 | 91 | .bRsWnc { 92 | background-color: var(--whoogle-dark-result-bg) !important; 93 | } 94 | 95 | .x54gtf { 96 | background-color: var(--whoogle-dark-divider) !important; 97 | } 98 | 99 | .Q0HXG { 100 | background-color: var(--whoogle-dark-divider) !important; 101 | } 102 | 103 | .LKSyXe { 104 | background-color: var(--whoogle-dark-divider) !important; 105 | } 106 | 107 | .home-search { 108 | border-color: var(--whoogle-dark-element-bg) !important; 109 | } 110 | 111 | .sa1toc { 112 | background: var(--whoogle-dark-page-bg) !important; 113 | } 114 | 115 | #search-bar { 116 | border-color: var(--whoogle-dark-element-bg) !important; 117 | color: var(--whoogle-dark-text) !important; 118 | background-color: var(--whoogle-dark-result-bg) !important; 119 | border-bottom: 2px solid var(--whoogle-dark-element-bg); 120 | } 121 | 122 | #search-bar:focus { 123 | color: var(--whoogle-dark-text) !important; 124 | } 125 | 126 | #search-submit { 127 | border: 1px solid var(--whoogle-dark-element-bg) !important; 128 | background: var(--whoogle-dark-element-bg) !important; 129 | color: var(--whoogle-dark-contrast-text) !important; 130 | } 131 | 132 | .info-text { 133 | color: var(--whoogle-dark-contrast-text) !important; 134 | opacity: 75%; 135 | } 136 | 137 | .collapsible { 138 | color: var(--whoogle-dark-text) !important; 139 | } 140 | 141 | .collapsible:after { 142 | color: var(--whoogle-dark-text) !important; 143 | } 144 | 145 | .active { 146 | background-color: var(--whoogle-dark-element-bg) !important; 147 | color: var(--whoogle-dark-contrast-text) !important; 148 | } 149 | 150 | .content, .result-config { 151 | background-color: var(--whoogle-dark-element-bg) !important; 152 | color: var(--whoogle-contrast-text) !important; 153 | } 154 | 155 | .active:after { 156 | color: var(--whoogle-dark-contrast-text) !important; 157 | } 158 | 159 | .link { 160 | color: var(--whoogle-dark-contrast-text); 161 | } 162 | 163 | .link-color { 164 | color: var(--whoogle-dark-result-url) !important; 165 | } 166 | 167 | .autocomplete-items { 168 | border: 1px solid var(--whoogle-dark-element-bg); 169 | } 170 | 171 | .autocomplete-items div { 172 | color: var(--whoogle-dark-text); 173 | background-color: var(--whoogle-dark-page-bg); 174 | border-bottom: 1px solid var(--whoogle-dark-element-bg); 175 | } 176 | 177 | .autocomplete-items div:hover { 178 | background-color: var(--whoogle-dark-element-bg); 179 | color: var(--whoogle-dark-contrast-text) !important; 180 | } 181 | 182 | .autocomplete-active { 183 | background-color: var(--whoogle-dark-element-bg) !important; 184 | color: var(--whoogle-dark-contrast-text) !important; 185 | } 186 | 187 | .footer { 188 | color: var(--whoogle-dark-text); 189 | } 190 | 191 | path { 192 | fill: var(--whoogle-dark-logo); 193 | } 194 | 195 | .header-div { 196 | background-color: var(--whoogle-dark-result-bg) !important; 197 | } 198 | 199 | #search-reset { 200 | color: var(--whoogle-dark-text) !important; 201 | } 202 | 203 | .mobile-search-bar { 204 | background-color: var(--whoogle-dark-result-bg) !important; 205 | color: var(--whoogle-dark-text) !important; 206 | } 207 | 208 | .search-bar-desktop { 209 | color: var(--whoogle-dark-text) !important; 210 | } 211 | 212 | .ip-text-div, .update_available, .cb_label, .cb { 213 | color: var(--whoogle-dark-secondary-text) !important; 214 | } 215 | 216 | .cb:focus { 217 | color: var(--whoogle-dark-contrast-text) !important; 218 | } 219 | 220 | .desktop-header, .mobile-header { 221 | background-color: var(--whoogle-dark-result-bg) !important; 222 | } 223 | -------------------------------------------------------------------------------- /test/test_results.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | from app.filter import Filter 3 | from app.models.config import Config 4 | from app.models.endpoint import Endpoint 5 | from app.utils import results 6 | from app.utils.session import generate_key 7 | from datetime import datetime 8 | from dateutil.parser import ParserError, parse 9 | from urllib.parse import urlparse 10 | 11 | from test.conftest import demo_config 12 | 13 | 14 | def get_search_results(data): 15 | secret_key = generate_key() 16 | soup = Filter(user_key=secret_key, config=Config(**demo_config)).clean( 17 | BeautifulSoup(data, 'html.parser')) 18 | 19 | main_divs = soup.find('div', {'id': 'main'}) 20 | assert len(main_divs) > 1 21 | 22 | result_divs = [] 23 | for div in main_divs: 24 | # Result divs should only have 1 inner div 25 | if (len(list(div.children)) != 1 26 | or not div.findChild() 27 | or 'div' not in div.findChild().name): 28 | continue 29 | 30 | result_divs.append(div) 31 | 32 | return result_divs 33 | 34 | 35 | def test_get_results(client): 36 | # FIXME: Temporary fix while #1211 is investigated 37 | return 38 | 39 | rv = client.get(f'/{Endpoint.search}?q=test') 40 | assert rv._status_code == 200 41 | 42 | # Depending on the search, there can be more 43 | # than 10 result divs 44 | results = get_search_results(rv.data) 45 | assert len(results) >= 10 46 | assert len(results) <= 15 47 | 48 | 49 | def test_post_results(client): 50 | rv = client.post(f'/{Endpoint.search}', data=dict(q='test')) 51 | assert rv._status_code == 302 52 | 53 | 54 | def test_translate_search(client): 55 | rv = client.get(f'/{Endpoint.search}?q=translate hola') 56 | assert rv._status_code == 200 57 | 58 | # Pretty weak test, but better than nothing 59 | str_data = str(rv.data) 60 | assert 'iframe' in str_data 61 | assert '/auto/en/ hola' in str_data 62 | 63 | 64 | def test_block_results(client): 65 | rv = client.get(f'/{Endpoint.search}?q=pinterest') 66 | assert rv._status_code == 200 67 | 68 | has_pinterest = False 69 | for link in BeautifulSoup(rv.data, 'html.parser').find_all('a', href=True): 70 | if 'pinterest.com' in urlparse(link['href']).netloc: 71 | has_pinterest = True 72 | break 73 | 74 | assert has_pinterest 75 | 76 | demo_config['block'] = 'pinterest.com' 77 | rv = client.post(f'/{Endpoint.config}', data=demo_config) 78 | assert rv._status_code == 302 79 | 80 | rv = client.get(f'/{Endpoint.search}?q=pinterest') 81 | assert rv._status_code == 200 82 | 83 | for link in BeautifulSoup(rv.data, 'html.parser').find_all('a', href=True): 84 | result_site = urlparse(link['href']).netloc 85 | if not result_site: 86 | continue 87 | assert result_site not in 'pinterest.com' 88 | 89 | 90 | def test_view_my_ip(client): 91 | # FIXME: Temporary fix while #1211 is investigated 92 | return 93 | 94 | rv = client.get(f'/{Endpoint.search}?q=my ip address') 95 | assert rv._status_code == 200 96 | 97 | # Pretty weak test, but better than nothing 98 | str_data = str(rv.data) 99 | assert 'Your public IP address' in str_data 100 | assert '127.0.0.1' in str_data 101 | 102 | 103 | def test_recent_results(client): 104 | # FIXME: Temporary fix while #1211 is investigated 105 | return 106 | 107 | times = { 108 | 'tbs=qdr:y': 365, 109 | 'tbs=qdr:m': 31, 110 | 'tbs=qdr:w': 7 111 | } 112 | 113 | for time, num_days in times.items(): 114 | rv = client.get(f'/{Endpoint.search}?q=test&' + time) 115 | result_divs = get_search_results(rv.data) 116 | 117 | current_date = datetime.now() 118 | for div in [_ for _ in result_divs if _.find('span')]: 119 | date_span = div.find('span').decode_contents() 120 | if not date_span or len(date_span) > 15 or len(date_span) < 7: 121 | continue 122 | 123 | try: 124 | date = parse(date_span) 125 | # Date can have a little bit of wiggle room 126 | assert (current_date - date).days <= (num_days + 5) 127 | except ParserError: 128 | pass 129 | 130 | 131 | def test_leading_slash_search(client): 132 | # Ensure searches with a leading slash are interpreted 133 | # correctly as queries and not endpoints 134 | q = '/test' 135 | rv = client.get(f'/{Endpoint.search}?q={q}') 136 | assert rv._status_code == 200 137 | 138 | soup = Filter( 139 | user_key=generate_key(), 140 | config=Config(**demo_config), 141 | query=q 142 | ).clean(BeautifulSoup(rv.data, 'html.parser')) 143 | 144 | for link in soup.find_all('a', href=True): 145 | if 'start=' not in link['href']: 146 | continue 147 | 148 | assert link['href'].startswith(f'{Endpoint.search}') 149 | 150 | 151 | def test_site_alt_prefix_skip(): 152 | # Ensure prefixes are skipped correctly for site alts 153 | 154 | # default silte_alts (farside.link) 155 | assert results.get_site_alt(link = 'https://www.reddit.com') == 'https://farside.link/libreddit' 156 | assert results.get_site_alt(link = 'https://www.twitter.com') == 'https://farside.link/nitter' 157 | assert results.get_site_alt(link = 'https://www.youtube.com') == 'https://farside.link/invidious' 158 | 159 | test_site_alts = { 160 | 'reddit.com': 'reddit.endswithmobile.domain', 161 | 'twitter.com': 'https://twitter.endswithm.domain', 162 | 'youtube.com': 'http://yt.endswithwww.domain', 163 | } 164 | # Domains with part of SKIP_PREFIX in them 165 | assert results.get_site_alt(link = 'https://www.reddit.com', site_alts = test_site_alts) == 'https://reddit.endswithmobile.domain' 166 | assert results.get_site_alt(link = 'https://www.twitter.com', site_alts = test_site_alts) == 'https://twitter.endswithm.domain' 167 | assert results.get_site_alt(link = 'https://www.youtube.com', site_alts = test_site_alts) == 'http://yt.endswithwww.domain' 168 | -------------------------------------------------------------------------------- /app/templates/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | {% if not search_type %} 5 | Whoogle 6 | {% else %} 7 | Whoogle {{ search_name }} 8 | {% endif %} 9 | Whoogle: A self-hosted, ad-free, privacy-respecting metasearch engine 10 | UTF-8 11 | 12 | data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAYAAAA6/NlyAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAASAAAAEgARslrPgAADS9JREFUaN7Nm2lsXNd1x3/nzQxnOMNdJEeUuYjUZu2Lh7IrWZZsy46btN7ioEgRSHZapEybLgHStPDSAqntNg7yIahdyAjgJg4aB66tJDBsy4ocW5QrWzYtUQslUdx3cV9nONt7tx/ekNSQQ84MOTLzB0hg3nbv/55zzzn33HOFm4Sqx58GEcEwHIALpXIQcaNUFmCJPKYQ8aJULyJDgBcRH4ZhHHn1+ZvSL0kZwX/9KTQ3g6bZgFKU2ghsBnYDxUAOkAnYAW2aMAQBLzAK9ALngLPANUQa8Psncbk48soP/jAIV33rBzA8DE5nHkp9CXggQnI14FjkZ8NAT4T4B8BvsNnaMQx15L//bXkIVz3+NFgsQihUDDwEPALcDriWOoizEAIuAb9B5KiIXFFK6Ud+/twXR7jq0JMAuRGSfwXsBGwpJjobBtACvAr8Ap+vhcxMkpV4UoSrDj8FIhpK7UKpfwK+DDhvMtHZ0IEziPwQOAYEk5F2QoSr/u4FGBkBkUyUOgz8A7DmCyY6G4PATxF5EcPowmLhyM+ejfuSJf53wbOhEqAA+Hfge0DhMpMFU7P2AFsQqUWpPs/2fdScP7U0wlWHngSRMuA54DCmW/lDgQBrgW3ANUpLOzwVO6iprV7whfhklXoR+Eq856eglPlPoeZvWASRlIUBAHWIfBvDOLWQes/bYsQSFwAvYEo2bu+UoUAg3elgRX4uK1cVkJuXjcNhR0QIhcJMjHvp7emn9/ogE+NewuEwmqbF+3Si+Bj4C5S6Mh/pmCSqDj8FkIlSLwB/CVgXJKoUIsLKogI2bV3Htp0bcRflk5HhxGqLftUwDPyTAUaGx7h2tYXaz+toberE7w+kSuLHEPlrTBfGbAs+p4Wqw0+B1SqEQn+DKd30eGRdGU7uPOBh710eCtwrkuq4zzvJlUuNHH+nmraWrlSRfgX4W8A3OyafY7Q82/eBUh7gR4A7Htni0iK+9udf4c4DlWRlZyTdYVuajVXFbtbdWk4oFKanqw9dN5ZKfC3Qgq5f8Ow6EGW5owjfEEH9B7BvoS8ahqJibQnf+ObDbNi0Bosl9jwMh3UCgSChYAgFaJoWk0xGposNGyswDIPWli4MXbEEznZgPZr2MXD9RsLTn6x64hkIBsFi+SbwIguoslKK4pIivn74QdasL5tzPxAI0t7aRXNDO91dfYyOjKHrBi5XOgWFeZRVFLN2XRnZuVlzyPt8kxz91TE+PvU5hjG/lU8Q/4WmfRcIThmwGYui62CxlADfikc2MyuDh752/xyyhmHQ0tjB74+fpv5KMxPjXpSa22m7PQ13UQF793vYvWc7TudMc05nOg9+9T7Gx7ycP3t5qar9VQzjdeBk1aEnOfLq86ZKVz3+NBgGiBwCnmBmvRoTB+69nbvu3o1oM53RdZ1TH3zG//7P2zQ1tBEKhqZ97ew/wzAYGR6n/koTA33DlK5ehdM1Q9ruSCNvRTaXLzbgn1yS9c4ADDTtXcCoOX8qQkwp0LQ8zNXPvC5IKcXKogLuuHMX2g1zVinFJx+d462jJxgaHJ13nt4ITRP0sM5nn5zn6K+OMToyHnV/dUUxO27btBTpTuEgSm2e+mGpeuIZU53hYaAKSJvvTRHhnvv3sLNycxShK5caefO1dxkbGUfTkpOGiHC9ux/RhPW3lk8HIZqmkZ7uSIWUs4FOLSPj1G2b7kDDMKbSMg9gqkBMKKVwudJZv7E8qvHJST8fnviYwYHhKBVPBqaGnOXa1Zao6yWrV7FmfVlMO5Ak/tjweotgZq6WApXxOlVYVMDKouiFUktjBw31rYgsPjwUEUZHxqk5cwE9rE9fT0uzUV5RkorQcwewA6XQUIpIwq083lv5Bbk4XdFpqsZrbUz6/EvxmdOkG6+1MTIyFnXdXVSA3Z4GSxOyC6V2EQ6jqXAYzOziggk3ESErOyNqtM3IqDcVKgfA+NgEY6MTUddy87KwO+wLrrwSxG5sNpcmNls6ZpYxLtLTo8dED+tzOrhYiJhWO+APRLfpdGCzJZSniIe1QL6GmWUsTuSNOZIUUr2mZfbcUIYiRQqUCxRrKJUb+RGHLUz6/FGXrFYruXlZKemNUgpbmhWnM1qLvN5JQqFwKprIQKlSDZFCFnBHM3wVw0Oj6PqMFbVaLZSUrUqJlJWCvBU5ZOdED+DgwDD+SX8q2nAgcosW2etJKE812D/MxLgv6tqadWVkZrlSYLgU6zaUk5UVPfa9PQMEg6ElDyhgQ6lcDXOJGNfRaaLR1ztId2dv1PXS1avYvG39ktyGUopCdz6Vf7QtKniZ9PlpbmhLmRcALIl7dDGjqrqL19B1Y2bY0mzcfd8eVpW4MQwj4c/dSNZms7L/3tspK4+2nU2N7TQ3daTUMGqYskl4CGs+uUhrc0fUtbLyW3j0zx6gwL0iKdJKKaxWC/vu3s3eA54oYsFAiE9P1zIx7kslYaUhMoG5ZRkXIsLw0CgnT3xCIBD9yuZt6/nGE4+wZp0Z+6oFFu9KKQzDICPTyZ88cpA/ffTgHB9/6UI9F89dTSVZHRizolQ/5v5sQhCBC+eusvHTi9xx523TblNEuHXzGvLyc/jow884//llhoZGCU4NjALEXAVlZWVQVlHMXffsZuOWtVitc1ek3Z29kUxmqvjiB7qtiAyi1GjihAW/P8BbR9/H7rCzq3JL1P1C9woefux+9t29m7bmTrq7+vBO+DAMA4fDTl5+Dqsriim6pRCHY37nsHe/h9bmTi7W1ie95JwHPkS6rJjS7U3mTRFhcGCEX7/+HjablS3bN0SpnmbRKCjMo6Awb/qaUiQlrdy8bB567D4mxr20NnUueul5A0aADkvl7oMhdH0TcbKUsUh7J3w01reaBN0rSEuzLfB88j3MzslkZVEBTQ1tTEws2XjVIvKKxbNlD5ih5aMk4I9nk56c9FN/uZn21i5cGU5y8rKxWBIL9pVS9PYMMDoyTlZ27GAvLz+HrOwM6i83E4zkyRaJ13E43rF4tu8DM4/1IGY6JCmICEop+q4PcrWukbbmTgKBoLmMFDN3NZXj0nWdgD/IyPAYrc0dnHz/DO/+9gPqLjRQvqaYrOzMmG0UuvPRRGht6iQcDi+GdBB4iXC4zhrRtQaU+hwoWezwaZowPublXE0dF8/Xk+Eypb0iPwdXhhNN0wj4zT2lwYFhxsa85lIwYr3feO1dvn7oQdxF+XO+bbVauOdLe/H7Axx/+9RiApxriJwFsHh27gfDCCNSBNxLgpvk80l7SuL+CLmerl7amrtoae6ks6OHgf4hfD4/RmQ7ZepvoG8I74SX9RsrYtoCi0WjuGQlA31DdHf1JivlX6JpbwCGpaa2Gs+Ou8AsE7oHKFos4VjkRQTRBE2Lzk3Hev56zwCGbrB2/Wos1rnjnmZPo2z1LfT1DtJ3fSBR0oPAsyjVhIgpTc+uA6jR0VGx293AAVJYsJYMDMOgq+M6TqeD0vLimP7X6UqnpGwVLU0djAyPJUL6OCL/iUjoyM+fMwnX1FZTufsgiAwC9wN58b5yMyAihMM67W095OZlsap4ZUx3lpnlIic3m4b61nhr5QngWUyXRM35UzPz1bNzP2K19mMYOcB+lknKIkLAH6S9rZvi0pXkF8Qe+0L3CjIznTQ3thPwB+cj/Q4iP0YkMLUxPk24prYaz9a9AO2YSb1FW+xUkPZ6J+nrHaSsojimjxYRClfm09PdT1fH9ViEe4F/AS7Z3W7OnD4GzA40LBYwjJZI0dfgchEG0801N7bz5mvvMDgwPOd+wB+k+vdnqL/chMxVxhDwEpr2O0T4yY/+fobijU/V1Fbj2bkfoBkzCNnDMqk2mFIc6B/G651kw8YKbJF6EZ/Pz9u/fp/33q7G552MJd33gGdQajxuyUPN+VN4tu8zEKkHtmDmc5cVvT39WKwWVlcUMzo8zltv/o6PPqwhHIoZddUh8o+I1E8ZqgUJA1TuOgC6PoZILbAVKGMZoesG7S1d+LyTfHSyhnM1ddOVQ7PQBnxn5I03Tjq2bk28bAmm6z0A9gIvY27HLDvmIQowgMj3gZ+hlJqvon7e1dGRV58HTYOXn/s/RL6NWfS17JiHbDsi30fkF4iohY4PLBg319RW4+nSQal24DRmBe0altGQxUAd8B3gKEoZ8UqJ4y4Uamqrqdx1AKXrA6Jpp4EsTEO23EWmIcyw8Xtit1ejlEqkbjq5AnFzXrswkwX/DKSkCGMR6AVeAo4A/RCZggkgadW84azDDsw6zMf44uqnJ4D3gZfRtBMoFUr27MPiD3mYxwHsGMYdwCHgIGY4ejPm9xBQDfwSkeNK10cdbjc/+fF3k/7Q0o/xPP40mBtVm1Hqy5jnIHaw9NMtQaABOAH8FpFPEfFiGAmr700hPE185gCIG7OA5DbMQpm1mEnCDMyyCuusdnXMJLkPM5XaDnwGnEXkLCLti1Hdm044irwpdUEpJ5APFKNUKSK3RDbgZ47iwRjQjUgX0AFcR2QcMBI5tJEs/h/GMBxGKn9DKwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNC0xMlQyMDoyMDo0OSswMDowME0is3UAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDQtMTJUMjA6MjA6NDkrMDA6MDA8fwvJAAAARnRFWHRzb2Z0d2FyZQBJbWFnZU1hZ2ljayA2LjcuOC05IDIwMTQtMDUtMTIgUTE2IGh0dHA6Ly93d3cuaW1hZ2VtYWdpY2sub3Jn3IbtAAAAABh0RVh0VGh1bWI6OkRvY3VtZW50OjpQYWdlcwAxp/+7LwAAABh0RVh0VGh1bWI6OkltYWdlOjpoZWlnaHQAMTkyDwByhQAAABd0RVh0VGh1bWI6OkltYWdlOjpXaWR0aAAxOTLTrCEIAAAAGXRFWHRUaHVtYjo6TWltZXR5cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADE1ODY3MjI4NDlV2OpiAAAAD3RFWHRUaHVtYjo6U2l6ZQAwQkKUoj7sAAAAVnRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vbW50bG9nL2Zhdmljb25zLzIwMjAtMDQtMTIvNTdhMDYyNGFhNzAyYzk3ZWU1YTE5MjgwYWEwNTkwZDMuaWNvLnBuZ1EXWHMAAAAASUVORK5CYII= 13 | 14 | 15 | 16 | {% if search_type %} 17 | 18 | {% endif %} 19 | 20 | 21 | 22 | 23 | {{ main_url }}/search 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/static/img/whoogle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/templates/logo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/utils/search.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import Any 4 | from app.filter import Filter 5 | from app.request import gen_query 6 | from app.utils.misc import get_proxy_host_url 7 | from app.utils.results import get_first_link 8 | from bs4 import BeautifulSoup as bsoup 9 | from cryptography.fernet import Fernet, InvalidToken 10 | from flask import g 11 | 12 | TOR_BANNER = '

You are using Tor


' 13 | CAPTCHA = 'div class="g-recaptcha"' 14 | 15 | 16 | def needs_https(url: str) -> bool: 17 | """Checks if the current instance needs to be upgraded to HTTPS 18 | 19 | Note that all Heroku instances are available by default over HTTPS, but 20 | do not automatically set up a redirect when visited over HTTP. 21 | 22 | Args: 23 | url: The instance url 24 | 25 | Returns: 26 | bool: True/False representing the need to upgrade 27 | 28 | """ 29 | https_only = bool(os.getenv('HTTPS_ONLY', 0)) 30 | is_heroku = url.endswith('.herokuapp.com') 31 | is_http = url.startswith('http://') 32 | 33 | return (is_heroku and is_http) or (https_only and is_http) 34 | 35 | 36 | def has_captcha(results: str) -> bool: 37 | """Checks to see if the search results are blocked by a captcha 38 | 39 | Args: 40 | results: The search page html as a string 41 | 42 | Returns: 43 | bool: True/False indicating if a captcha element was found 44 | 45 | """ 46 | return CAPTCHA in results 47 | 48 | 49 | class Search: 50 | """Search query preprocessor - used before submitting the query or 51 | redirecting to another site 52 | 53 | Attributes: 54 | request: the incoming flask request 55 | config: the current user config settings 56 | session_key: the flask user fernet key 57 | """ 58 | def __init__(self, request, config, session_key, cookies_disabled=False): 59 | method = request.method 60 | self.request = request 61 | self.request_params = request.args if method == 'GET' else request.form 62 | self.user_agent = request.headers.get('User-Agent') 63 | self.feeling_lucky = False 64 | self.config = config 65 | self.session_key = session_key 66 | self.query = '' 67 | self.widget = '' 68 | self.cookies_disabled = cookies_disabled 69 | self.search_type = self.request_params.get( 70 | 'tbm') if 'tbm' in self.request_params else '' 71 | 72 | def __getitem__(self, name) -> Any: 73 | return getattr(self, name) 74 | 75 | def __setitem__(self, name, value) -> None: 76 | return setattr(self, name, value) 77 | 78 | def __delitem__(self, name) -> None: 79 | return delattr(self, name) 80 | 81 | def __contains__(self, name) -> bool: 82 | return hasattr(self, name) 83 | 84 | def new_search_query(self) -> str: 85 | """Parses a plaintext query into a valid string for submission 86 | 87 | Also decrypts the query string, if encrypted (in the case of 88 | paginated results). 89 | 90 | Returns: 91 | str: A valid query string 92 | 93 | """ 94 | q = self.request_params.get('q') 95 | 96 | if q is None or len(q) == 0: 97 | return '' 98 | else: 99 | # Attempt to decrypt if this is an internal link 100 | try: 101 | q = Fernet(self.session_key).decrypt(q.encode()).decode() 102 | except InvalidToken: 103 | pass 104 | 105 | # Strip '!' for "feeling lucky" queries 106 | if match := re.search("(^|\s)!($|\s)", q): 107 | self.feeling_lucky = True 108 | start, end = match.span() 109 | self.query = " ".join([seg for seg in [q[:start], q[end:]] if seg]) 110 | else: 111 | self.feeling_lucky = False 112 | self.query = q 113 | 114 | # Check for possible widgets 115 | self.widget = "ip" if re.search("([^a-z0-9]|^)my *[^a-z0-9] *(ip|internet protocol)" + 116 | "($|( *[^a-z0-9] *(((addres|address|adres|" + 117 | "adress)|a)? *$)))", self.query.lower()) else self.widget 118 | self.widget = 'calculator' if re.search( 119 | r"\bcalculator\b|\bcalc\b|\bcalclator\b|\bmath\b", 120 | self.query.lower()) else self.widget 121 | return self.query 122 | 123 | def generate_response(self) -> str: 124 | """Generates a response for the user's query 125 | 126 | Returns: 127 | str: A string response to the search query, in the form of a URL 128 | or string representation of HTML content. 129 | 130 | """ 131 | mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent 132 | # reconstruct url if X-Forwarded-Host header present 133 | root_url = get_proxy_host_url( 134 | self.request, 135 | self.request.url_root, 136 | root=True) 137 | 138 | content_filter = Filter(self.session_key, 139 | root_url=root_url, 140 | mobile=mobile, 141 | config=self.config, 142 | query=self.query) 143 | full_query = gen_query(self.query, 144 | self.request_params, 145 | self.config) 146 | self.full_query = full_query 147 | 148 | # force mobile search when view image is true and 149 | # the request is not already made by a mobile 150 | # FIXME: Broken since the user agent changes as of 16 Jan 2025 151 | # view_image = ('tbm=isch' in full_query 152 | # and self.config.view_image 153 | # and not g.user_request.mobile) 154 | 155 | get_body = g.user_request.send(query=full_query, 156 | force_mobile=self.config.view_image, 157 | user_agent=self.user_agent) 158 | 159 | # Produce cleanable html soup from response 160 | get_body_safed = get_body.text.replace("<","andlt;").replace(">","andgt;") 161 | html_soup = bsoup(get_body_safed, 'html.parser') 162 | 163 | # Replace current soup if view_image is active 164 | # FIXME: Broken since the user agent changes as of 16 Jan 2025 165 | # if view_image: 166 | # html_soup = content_filter.view_image(html_soup) 167 | 168 | # Indicate whether or not a Tor connection is active 169 | if g.user_request.tor_valid: 170 | html_soup.insert(0, bsoup(TOR_BANNER, 'html.parser')) 171 | 172 | formatted_results = content_filter.clean(html_soup) 173 | if self.feeling_lucky: 174 | if lucky_link := get_first_link(formatted_results): 175 | return lucky_link 176 | 177 | # Fall through to regular search if unable to find link 178 | self.feeling_lucky = False 179 | 180 | # Append user config to all search links, if available 181 | param_str = ''.join('&{}={}'.format(k, v) 182 | for k, v in 183 | self.request_params.to_dict(flat=True).items() 184 | if self.config.is_safe_key(k)) 185 | for link in formatted_results.find_all('a', href=True): 186 | link['rel'] = "nofollow noopener noreferrer" 187 | if 'search?' not in link['href'] or link['href'].index( 188 | 'search?') > 1: 189 | continue 190 | link['href'] += param_str 191 | 192 | return str(formatted_results) 193 | 194 | -------------------------------------------------------------------------------- /app/templates/header.html: -------------------------------------------------------------------------------- 1 | {% if mobile %} 2 |
3 |
4 |
7 | 12 |
13 |
14 | {% if config.preferences %} 15 | 16 | {% endif %} 17 | 29 | 30 | 31 | 32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {% for tab_id, tab_content in tabs.items() %} 44 | {% if tab_content['selected'] %} 45 | {{ tab_content['name'] }} 46 | {% else %} 47 | {{ tab_content['name'] }} 48 | {% endif %} 49 | {% endfor %} 50 | 51 | 52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | {% else %} 61 |
62 | 69 |
70 |
74 |
75 |
76 | {% if config.preferences %} 77 | 78 | {% endif %} 79 | 90 | 91 | 92 | 93 | 94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | {% for tab_id, tab_content in tabs.items() %} 106 | {% if tab_content['selected'] %} 107 | {{ tab_content['name'] }} 108 | {% else %} 109 | {{ tab_content['name'] }} 110 | {% endif %} 111 | {% endfor %} 112 | 113 | 114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | {% endif %} 122 |
123 |
124 | 125 | 139 |
140 | 141 | 155 |
156 |
157 | 158 | 159 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from app.filter import clean_query 2 | from app.request import send_tor_signal 3 | from app.utils.session import generate_key 4 | from app.utils.bangs import gen_bangs_json, load_all_bangs 5 | from app.utils.misc import gen_file_hash, read_config_bool 6 | from base64 import b64encode 7 | from bs4 import MarkupResemblesLocatorWarning 8 | from datetime import datetime, timedelta 9 | from dotenv import load_dotenv 10 | from flask import Flask 11 | import json 12 | import logging.config 13 | import os 14 | from stem import Signal 15 | import threading 16 | import warnings 17 | 18 | from werkzeug.middleware.proxy_fix import ProxyFix 19 | 20 | from app.utils.misc import read_config_bool 21 | from app.version import __version__ 22 | 23 | app = Flask(__name__, static_folder=os.path.dirname( 24 | os.path.abspath(__file__)) + '/static') 25 | 26 | app.wsgi_app = ProxyFix(app.wsgi_app) 27 | 28 | # look for WHOOGLE_ENV, else look in parent directory 29 | dot_env_path = os.getenv( 30 | "WHOOGLE_DOTENV_PATH", 31 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "../whoogle.env")) 32 | 33 | # Load .env file if enabled 34 | if os.path.exists(dot_env_path): 35 | load_dotenv(dot_env_path) 36 | 37 | app.enc_key = generate_key() 38 | 39 | if read_config_bool('HTTPS_ONLY'): 40 | app.config['SESSION_COOKIE_NAME'] = '__Secure-session' 41 | app.config['SESSION_COOKIE_SECURE'] = True 42 | 43 | app.config['VERSION_NUMBER'] = __version__ 44 | app.config['APP_ROOT'] = os.getenv( 45 | 'APP_ROOT', 46 | os.path.dirname(os.path.abspath(__file__))) 47 | app.config['STATIC_FOLDER'] = os.getenv( 48 | 'STATIC_FOLDER', 49 | os.path.join(app.config['APP_ROOT'], 'static')) 50 | app.config['BUILD_FOLDER'] = os.path.join( 51 | app.config['STATIC_FOLDER'], 'build') 52 | app.config['CACHE_BUSTING_MAP'] = {} 53 | app.config['LANGUAGES'] = json.load(open( 54 | os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json'), 55 | encoding='utf-8')) 56 | app.config['COUNTRIES'] = json.load(open( 57 | os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json'), 58 | encoding='utf-8')) 59 | app.config['TIME_PERIODS'] = json.load(open( 60 | os.path.join(app.config['STATIC_FOLDER'], 'settings/time_periods.json'), 61 | encoding='utf-8')) 62 | app.config['TRANSLATIONS'] = json.load(open( 63 | os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json'), 64 | encoding='utf-8')) 65 | app.config['THEMES'] = json.load(open( 66 | os.path.join(app.config['STATIC_FOLDER'], 'settings/themes.json'), 67 | encoding='utf-8')) 68 | app.config['HEADER_TABS'] = json.load(open( 69 | os.path.join(app.config['STATIC_FOLDER'], 'settings/header_tabs.json'), 70 | encoding='utf-8')) 71 | app.config['CONFIG_PATH'] = os.getenv( 72 | 'CONFIG_VOLUME', 73 | os.path.join(app.config['STATIC_FOLDER'], 'config')) 74 | app.config['DEFAULT_CONFIG'] = os.path.join( 75 | app.config['CONFIG_PATH'], 76 | 'config.json') 77 | app.config['CONFIG_DISABLE'] = read_config_bool('WHOOGLE_CONFIG_DISABLE') 78 | app.config['SESSION_FILE_DIR'] = os.path.join( 79 | app.config['CONFIG_PATH'], 80 | 'session') 81 | app.config['MAX_SESSION_SIZE'] = 4000 # Sessions won't exceed 4KB 82 | app.config['BANG_PATH'] = os.getenv( 83 | 'CONFIG_VOLUME', 84 | os.path.join(app.config['STATIC_FOLDER'], 'bangs')) 85 | app.config['BANG_FILE'] = os.path.join( 86 | app.config['BANG_PATH'], 87 | 'bangs.json') 88 | 89 | # Ensure all necessary directories exist 90 | if not os.path.exists(app.config['CONFIG_PATH']): 91 | os.makedirs(app.config['CONFIG_PATH']) 92 | 93 | if not os.path.exists(app.config['SESSION_FILE_DIR']): 94 | os.makedirs(app.config['SESSION_FILE_DIR']) 95 | 96 | if not os.path.exists(app.config['BANG_PATH']): 97 | os.makedirs(app.config['BANG_PATH']) 98 | 99 | if not os.path.exists(app.config['BUILD_FOLDER']): 100 | os.makedirs(app.config['BUILD_FOLDER']) 101 | 102 | # Session values 103 | app_key_path = os.path.join(app.config['CONFIG_PATH'], 'whoogle.key') 104 | if os.path.exists(app_key_path): 105 | try: 106 | app.config['SECRET_KEY'] = open(app_key_path, 'r').read() 107 | except PermissionError: 108 | app.config['SECRET_KEY'] = str(b64encode(os.urandom(32))) 109 | else: 110 | app.config['SECRET_KEY'] = str(b64encode(os.urandom(32))) 111 | with open(app_key_path, 'w') as key_file: 112 | key_file.write(app.config['SECRET_KEY']) 113 | key_file.close() 114 | app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365) 115 | 116 | # NOTE: SESSION_COOKIE_SAMESITE must be set to 'lax' to allow the user's 117 | # previous session to persist when accessing the instance from an external 118 | # link. Setting this value to 'strict' causes Whoogle to revalidate a new 119 | # session, and fail, resulting in cookies being disabled. 120 | app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' 121 | 122 | # Config fields that are used to check for updates 123 | app.config['RELEASES_URL'] = 'https://github.com/' \ 124 | 'benbusby/whoogle-search/releases' 125 | app.config['LAST_UPDATE_CHECK'] = datetime.now() - timedelta(hours=24) 126 | app.config['HAS_UPDATE'] = '' 127 | 128 | # The alternative to Google Translate is treated a bit differently than other 129 | # social media site alternatives, in that it is used for any translation 130 | # related searches. 131 | translate_url = os.getenv('WHOOGLE_ALT_TL', 'https://farside.link/lingva') 132 | if not translate_url.startswith('http'): 133 | translate_url = 'https://' + translate_url 134 | app.config['TRANSLATE_URL'] = translate_url 135 | 136 | app.config['CSP'] = 'default-src \'none\';' \ 137 | 'frame-src ' + translate_url + ';' \ 138 | 'manifest-src \'self\';' \ 139 | 'img-src \'self\' data:;' \ 140 | 'style-src \'self\' \'unsafe-inline\';' \ 141 | 'script-src \'self\';' \ 142 | 'media-src \'self\';' \ 143 | 'connect-src \'self\';' 144 | 145 | # Generate DDG bang filter 146 | generating_bangs = False 147 | if not os.path.exists(app.config['BANG_FILE']): 148 | generating_bangs = True 149 | json.dump({}, open(app.config['BANG_FILE'], 'w')) 150 | bangs_thread = threading.Thread( 151 | target=gen_bangs_json, 152 | args=(app.config['BANG_FILE'],)) 153 | bangs_thread.start() 154 | 155 | # Build new mapping of static files for cache busting 156 | cache_busting_dirs = ['css', 'js'] 157 | for cb_dir in cache_busting_dirs: 158 | full_cb_dir = os.path.join(app.config['STATIC_FOLDER'], cb_dir) 159 | for cb_file in os.listdir(full_cb_dir): 160 | # Create hash from current file state 161 | full_cb_path = os.path.join(full_cb_dir, cb_file) 162 | cb_file_link = gen_file_hash(full_cb_dir, cb_file) 163 | build_path = os.path.join(app.config['BUILD_FOLDER'], cb_file_link) 164 | 165 | try: 166 | os.symlink(full_cb_path, build_path) 167 | except FileExistsError: 168 | # Symlink hasn't changed, ignore 169 | pass 170 | 171 | # Create mapping for relative path urls 172 | map_path = build_path.replace(app.config['APP_ROOT'], '') 173 | if map_path.startswith('/'): 174 | map_path = map_path[1:] 175 | app.config['CACHE_BUSTING_MAP'][cb_file] = map_path 176 | 177 | # Templating functions 178 | app.jinja_env.globals.update(clean_query=clean_query) 179 | app.jinja_env.globals.update( 180 | cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f.lower()]) 181 | 182 | # Attempt to acquire tor identity, to determine if Tor config is available 183 | send_tor_signal(Signal.HEARTBEAT) 184 | 185 | # Suppress spurious warnings from BeautifulSoup 186 | warnings.simplefilter('ignore', MarkupResemblesLocatorWarning) 187 | 188 | from app import routes # noqa 189 | 190 | # The gen_bangs_json function takes care of loading bangs, so skip it here if 191 | # it's already being loaded 192 | if not generating_bangs: 193 | load_all_bangs(app.config['BANG_FILE']) 194 | 195 | # Disable logging from imported modules 196 | logging.config.dictConfig({ 197 | 'version': 1, 198 | 'disable_existing_loggers': True, 199 | }) 200 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Whoogle Search", 3 | "description": "A lightweight, privacy-oriented, containerized Google search proxy for desktop/mobile that removes Javascript, AMP links, tracking, and ads/sponsored content", 4 | "repository": "https://github.com/benbusby/whoogle-search", 5 | "logo": "https://raw.githubusercontent.com/benbusby/whoogle-search/master/app/static/img/favicon/ms-icon-150x150.png", 6 | "keywords": [ 7 | "search", 8 | "metasearch", 9 | "flask", 10 | "docker", 11 | "heroku", 12 | "adblock", 13 | "degoogle", 14 | "privacy" 15 | ], 16 | "stack": "container", 17 | "env": { 18 | "WHOOGLE_URL_PREFIX": { 19 | "description": "The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")", 20 | "value": "", 21 | "required": false 22 | }, 23 | "WHOOGLE_USER": { 24 | "description": "The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.", 25 | "value": "", 26 | "required": false 27 | }, 28 | "WHOOGLE_PASS": { 29 | "description": "The password for basic auth. WHOOGLE_USER must also be set if used. Leave empty to disable.", 30 | "value": "", 31 | "required": false 32 | }, 33 | "WHOOGLE_PROXY_USER": { 34 | "description": "The username of the proxy server. Leave empty to disable.", 35 | "value": "", 36 | "required": false 37 | }, 38 | "WHOOGLE_PROXY_PASS": { 39 | "description": "The password of the proxy server. Leave empty to disable.", 40 | "value": "", 41 | "required": false 42 | }, 43 | "WHOOGLE_PROXY_TYPE": { 44 | "description": "The type of the proxy server. For example \"socks5\". Leave empty to disable.", 45 | "value": "", 46 | "required": false 47 | }, 48 | "WHOOGLE_PROXY_LOC": { 49 | "description": "The location of the proxy server (host or ip). Leave empty to disable.", 50 | "value": "", 51 | "required": false 52 | }, 53 | "WHOOGLE_ALT_TW": { 54 | "description": "The site to use as a replacement for twitter.com when site alternatives are enabled in the config.", 55 | "value": "farside.link/nitter", 56 | "required": false 57 | }, 58 | "WHOOGLE_ALT_YT": { 59 | "description": "The site to use as a replacement for youtube.com when site alternatives are enabled in the config.", 60 | "value": "farside.link/invidious", 61 | "required": false 62 | }, 63 | "WHOOGLE_ALT_RD": { 64 | "description": "The site to use as a replacement for reddit.com when site alternatives are enabled in the config.", 65 | "value": "farside.link/libreddit", 66 | "required": false 67 | }, 68 | "WHOOGLE_ALT_MD": { 69 | "description": "The site to use as a replacement for medium.com when site alternatives are enabled in the config.", 70 | "value": "farside.link/scribe", 71 | "required": false 72 | }, 73 | "WHOOGLE_ALT_TL": { 74 | "description": "The Google Translate alternative to use for all searches following the 'translate ___' structure.", 75 | "value": "farside.link/lingva", 76 | "required": false 77 | }, 78 | "WHOOGLE_ALT_IMG": { 79 | "description": "The site to use as a replacement for imgur.com when site alternatives are enabled in the config.", 80 | "value": "farside.link/rimgo", 81 | "required": false 82 | }, 83 | "WHOOGLE_ALT_WIKI": { 84 | "description": "The site to use as a replacement for wikipedia.com when site alternatives are enabled in the config.", 85 | "value": "farside.link/wikiless", 86 | "required": false 87 | }, 88 | "WHOOGLE_ALT_IMDB": { 89 | "description": "The site to use as a replacement for imdb.com when site alternatives are enabled in the config.", 90 | "value": "farside.link/libremdb", 91 | "required": false 92 | }, 93 | "WHOOGLE_ALT_QUORA": { 94 | "description": "The site to use as a replacement for quora.com when site alternatives are enabled in the config.", 95 | "value": "farside.link/quetre", 96 | "required": false 97 | }, 98 | "WHOOGLE_ALT_SO": { 99 | "description": "The site to use as a replacement for stackoverflow.com when site alternatives are enabled in the config.", 100 | "value": "farside.link/anonymousoverflow", 101 | "required": false 102 | }, 103 | "WHOOGLE_MINIMAL": { 104 | "description": "Remove everything except basic result cards from all search queries (set to 1 or leave blank)", 105 | "value": "", 106 | "required": false 107 | }, 108 | "WHOOGLE_CONFIG_COUNTRY": { 109 | "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)", 110 | "value": "", 111 | "required": false 112 | }, 113 | "WHOOGLE_CONFIG_TIME_PERIOD" : { 114 | "description": "[CONFIG] The time period to use for restricting search results", 115 | "value": "", 116 | "required": false 117 | }, 118 | "WHOOGLE_CONFIG_LANGUAGE": { 119 | "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)", 120 | "value": "", 121 | "required": false 122 | }, 123 | "WHOOGLE_CONFIG_SEARCH_LANGUAGE": { 124 | "description": "[CONFIG] The language to use for search results (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/languages.json)", 125 | "value": "", 126 | "required": false 127 | }, 128 | "WHOOGLE_CONFIG_DISABLE": { 129 | "description": "[CONFIG] Disable ability for client to change config (set to 1 or leave blank)", 130 | "value": "", 131 | "required": false 132 | }, 133 | "WHOOGLE_CONFIG_BLOCK": { 134 | "description": "[CONFIG] Block websites from search results (comma-separated list)", 135 | "value": "", 136 | "required": false 137 | }, 138 | "WHOOGLE_CONFIG_THEME": { 139 | "description": "[CONFIG] Set theme to 'dark', 'light', or 'system'", 140 | "value": "system", 141 | "required": false 142 | }, 143 | "WHOOGLE_CONFIG_SAFE": { 144 | "description": "[CONFIG] Use safe mode for searches (set to 1 or leave blank)", 145 | "value": "", 146 | "required": false 147 | }, 148 | "WHOOGLE_CONFIG_ALTS": { 149 | "description": "[CONFIG] Use social media alternatives (set to 1 or leave blank)", 150 | "value": "", 151 | "required": false 152 | }, 153 | "WHOOGLE_CONFIG_NEAR": { 154 | "description": "[CONFIG] Restrict results to only those near a particular city", 155 | "value": "", 156 | "required": false 157 | }, 158 | "WHOOGLE_CONFIG_TOR": { 159 | "description": "[CONFIG] Use Tor, if available (set to 1 or leave blank)", 160 | "value": "", 161 | "required": false 162 | }, 163 | "WHOOGLE_CONFIG_NEW_TAB": { 164 | "description": "[CONFIG] Always open results in new tab (set to 1 or leave blank)", 165 | "value": "", 166 | "required": false 167 | }, 168 | "WHOOGLE_CONFIG_VIEW_IMAGE": { 169 | "description": "[CONFIG] Enable View Image option (set to 1 or leave blank)", 170 | "value": "", 171 | "required": false 172 | }, 173 | "WHOOGLE_CONFIG_GET_ONLY": { 174 | "description": "[CONFIG] Search using GET requests only (set to 1 or leave blank)", 175 | "value": "", 176 | "required": false 177 | }, 178 | "WHOOGLE_CONFIG_STYLE": { 179 | "description": "[CONFIG] Custom CSS styling (paste in CSS or leave blank)", 180 | "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; }", 181 | "required": false 182 | }, 183 | "WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED": { 184 | "description": "[CONFIG] Encrypt preferences token, requires WHOOGLE_CONFIG_PREFERENCES_KEY to be set", 185 | "value": "", 186 | "required": false 187 | }, 188 | "WHOOGLE_CONFIG_PREFERENCES_KEY": { 189 | "description": "[CONFIG] Key to encrypt preferences", 190 | "value": "NEEDS_TO_BE_MODIFIED", 191 | "required": false 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /app/static/widgets/calculator.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 78 |

79 |
80 |

0

81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 |
113 |
114 | 261 | -------------------------------------------------------------------------------- /app/templates/imageresults.html: -------------------------------------------------------------------------------- 1 |
2 | 322 |
323 |
324 |
325 |
326 | 327 | 328 |
329 |
330 |
331 |
332 | 333 | {% for i in range((length // 4) + 1) %} 334 | 335 | {% for j in range([length - (i*4), 4]|min) %} 336 | 381 | {% endfor %} 382 | 383 | {% endfor %} 384 |
337 |
338 |
339 | 378 |
379 |
380 |
385 |
386 | 387 | 388 |
389 |
390 |
391 | --------------------------------------------------------------------------------