mirror of
https://github.com/benbusby/whoogle-search.git
synced 2025-10-23 23:10:12 -04:00
Compare commits
604 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4bfb1e2d | ||
|
|
b16ac3b736 | ||
|
|
97952c69da | ||
|
|
852f51ae04 | ||
|
|
beb822dce1 | ||
|
|
7df25b7620 | ||
|
|
7c5ee45f77 | ||
|
|
c2d2f0a0c4 | ||
|
|
c46ec6f937 | ||
|
|
65c0c99dad | ||
|
|
20111a8f88 | ||
|
|
bb3347f7ff | ||
|
|
e0a4a5f2cb | ||
|
|
457725ee5a | ||
|
|
442060b2ef | ||
|
|
ca214cb563 | ||
|
|
33cdaf390d | ||
|
|
9dd33de91a | ||
|
|
0fe29daaf1 | ||
|
|
579d983db8 | ||
|
|
be83605c77 | ||
|
|
ffdeeb5f44 | ||
|
|
99c7c7b00d | ||
|
|
7f80eb1e51 | ||
|
|
418d9df89c | ||
|
|
3733d87546 | ||
|
|
6782413560 | ||
|
|
1c1dcfc270 | ||
|
|
ba757b64e8 | ||
|
|
3476367ee1 | ||
|
|
7ce8c0b216 | ||
|
|
e24f2d751c | ||
|
|
ec9e7877b6 | ||
|
|
c70497d532 | ||
|
|
69d1ddae0c | ||
|
|
339eb61cea | ||
|
|
e4cabe3e5b | ||
|
|
f25611cbcb | ||
|
|
97502de606 | ||
|
|
1339c49dc5 | ||
|
|
dda91ad155 | ||
|
|
d6c8d73147 | ||
|
|
123a00669c | ||
|
|
5218d97f02 | ||
|
|
fb19bded0d | ||
|
|
5701d0d55f | ||
|
|
50625f222f | ||
|
|
99ca67c90e | ||
|
|
389c0a4d8d | ||
|
|
041c1fbb35 | ||
|
|
0130237913 | ||
|
|
d6bfa30b35 | ||
|
|
a15190e976 | ||
|
|
88e2dda151 | ||
|
|
6635716743 | ||
|
|
6f1e1e6847 | ||
|
|
36d8268643 | ||
|
|
e70bbabd63 | ||
|
|
4fad68adf2 | ||
|
|
89d07abb6c | ||
|
|
a8205c3043 | ||
|
|
1b9f7990b6 | ||
|
|
c54f4a79a6 | ||
|
|
223f00c3c0 | ||
|
|
466cb63d0e | ||
|
|
c056322037 | ||
|
|
5eb609d0b2 | ||
|
|
a016a1bcf4 | ||
|
|
6924f5ce0d | ||
|
|
6abe5511f4 | ||
|
|
9d7ab1e2f8 | ||
|
|
441ce72527 | ||
|
|
58d54c6384 | ||
|
|
add9313a99 | ||
|
|
436233f718 | ||
|
|
58b7512707 | ||
|
|
cc7907fb84 | ||
|
|
ee5a536861 | ||
|
|
5b8ac0c52e | ||
|
|
97394f2b02 | ||
|
|
91112f1b7b | ||
|
|
81420e035a | ||
|
|
a509169110 | ||
|
|
79b6e9a157 | ||
|
|
5cf5ad992a | ||
|
|
95f872ccde | ||
|
|
06c2310e3a | ||
|
|
9bfdd88a5e | ||
|
|
37ff61dfac | ||
|
|
80e41e6b44 | ||
|
|
f18bf07ac3 | ||
|
|
fd20135af0 | ||
|
|
7a1ebfe975 | ||
|
|
efbbd6b9d6 | ||
|
|
4d1d3f4984 | ||
|
|
c389c26220 | ||
|
|
ef54f00212 | ||
|
|
af60509a8d | ||
|
|
2395bb7a6a | ||
|
|
c216c033ef | ||
|
|
aaf90b52bb | ||
|
|
7313edff46 | ||
|
|
cdbe550737 | ||
|
|
70dc750c7a | ||
|
|
b5ae07613b | ||
|
|
166b28040a | ||
|
|
57398a9b3b | ||
|
|
b97f3dd4c0 | ||
|
|
9f68c843d6 | ||
|
|
2a0b9a47b2 | ||
|
|
1644a4a04e | ||
|
|
6a10efbd9e | ||
|
|
9cc1004fb8 | ||
|
|
cdf0b50284 | ||
|
|
2950aa869b | ||
|
|
7bda165ca3 | ||
|
|
81b7fd1876 | ||
|
|
a7e937f7c6 | ||
|
|
c2873190c9 | ||
|
|
f7513bab69 | ||
|
|
330ae964f3 | ||
|
|
67b6110087 | ||
|
|
4292ec7f63 | ||
|
|
3ef191b5d8 | ||
|
|
c36396e9cb | ||
|
|
0d013c788f | ||
|
|
ec05f8d52f | ||
|
|
e2070045bf | ||
|
|
b093d39ed1 | ||
|
|
e9c4ed6399 | ||
|
|
8b99a87020 | ||
|
|
3a2e0b262e | ||
|
|
8830615abc | ||
|
|
693ca3a9a8 | ||
|
|
a35b1dabbc | ||
|
|
a623210244 | ||
|
|
abbe29d9d3 | ||
|
|
19be033ba2 | ||
|
|
92e8ede24e | ||
|
|
4a0089686e | ||
|
|
a40b98341b | ||
|
|
18fc14dc5b | ||
|
|
8a3c9ea397 | ||
|
|
ee25d3a23d | ||
|
|
c705bc7391 | ||
|
|
2ac9e37696 | ||
|
|
6f9d11a6ed | ||
|
|
63a2ea56ed | ||
|
|
4962659acb | ||
|
|
8a2a6f3265 | ||
|
|
1b4e4e144e | ||
|
|
29992985bc | ||
|
|
179fc5e020 | ||
|
|
421a8ac054 | ||
|
|
0003da4bb9 | ||
|
|
2b8d100cfb | ||
|
|
f0a1223f40 | ||
|
|
958f74e1f5 | ||
|
|
08732fac9e | ||
|
|
f65529f328 | ||
|
|
f213a2a64a | ||
|
|
2cb4b9e3ca | ||
|
|
17dd1f1df1 | ||
|
|
4b7ab3b283 | ||
|
|
349b87ec18 | ||
|
|
1eb8e04ed1 | ||
|
|
74a5e24901 | ||
|
|
bf6c2505b1 | ||
|
|
9d9022ed99 | ||
|
|
25e7134bef | ||
|
|
5ae9160d38 | ||
|
|
076948dd0e | ||
|
|
b39ba0533a | ||
|
|
229c9388cf | ||
|
|
f970b62f12 | ||
|
|
31feb7228f | ||
|
|
b1e468ff01 | ||
|
|
8c426ab180 | ||
|
|
f7c4381ba6 | ||
|
|
baa8bd0eb4 | ||
|
|
1759c119a8 | ||
|
|
74f7975e62 | ||
|
|
0c65eb9616 | ||
|
|
3f827bbf19 | ||
|
|
fb8a2ea325 | ||
|
|
6b56dab4c1 | ||
|
|
7ca69e752d | ||
|
|
da53db2a81 | ||
|
|
c4c32a4bcc | ||
|
|
991fe6d910 | ||
|
|
fab65d720d | ||
|
|
df760ffbae | ||
|
|
09db4ff730 | ||
|
|
16794df68d | ||
|
|
94b208dd3f | ||
|
|
12ce174b9a | ||
|
|
e318594d9b | ||
|
|
026714bb84 | ||
|
|
e5a5aad997 | ||
|
|
ccf9f06f2f | ||
|
|
6955ec6161 | ||
|
|
fdc63b862e | ||
|
|
aa54491ae0 | ||
|
|
cec10e81d3 | ||
|
|
2827a4ef47 | ||
|
|
a760476d1b | ||
|
|
4f77f3680d | ||
|
|
253ea62f8f | ||
|
|
c24caceb03 | ||
|
|
4f85076a2b | ||
|
|
76c78d8584 | ||
|
|
3dda8b25ef | ||
|
|
08aa1ab8f1 | ||
|
|
7041b43db9 | ||
|
|
424e6dd341 | ||
|
|
c9c197bb5f | ||
|
|
7a852aa876 | ||
|
|
8fbbdf2cec | ||
|
|
3dc6d14377 | ||
|
|
cd7fce2822 | ||
|
|
fd85f1573a | ||
|
|
0310f0f542 | ||
|
|
1226b8db9c | ||
|
|
cde05ea55d | ||
|
|
3bd785b9b7 | ||
|
|
33742ce247 | ||
|
|
08b16f5a0c | ||
|
|
d099b46336 | ||
|
|
09a90ec46a | ||
|
|
6bd48e40a7 | ||
|
|
2d23e0e952 | ||
|
|
1a66b195d4 | ||
|
|
a7fe1fd0df | ||
|
|
abbf037115 | ||
|
|
06fd29f663 | ||
|
|
7494a14bc2 | ||
|
|
6696f2b12b | ||
|
|
77884d05f2 | ||
|
|
3e39e0e041 | ||
|
|
2a37619028 | ||
|
|
75682de892 | ||
|
|
e99db8db26 | ||
|
|
6ca51ecdcb | ||
|
|
a3fa999b0d | ||
|
|
70df88b825 | ||
|
|
4d7254e74d | ||
|
|
4b2b0bf3c9 | ||
|
|
3943b2bc2c | ||
|
|
219fc58401 | ||
|
|
74503d542e | ||
|
|
11275a7796 | ||
|
|
c42640e21c | ||
|
|
1aad47f2af | ||
|
|
6bb9c8448b | ||
|
|
8f59b7c340 | ||
|
|
32ad39d0e1 | ||
|
|
77f617e984 | ||
|
|
81a802e3fc | ||
|
|
a6a97aa9c7 | ||
|
|
cab1105169 | ||
|
|
2eee0b87d5 | ||
|
|
aa198ed562 | ||
|
|
3f363b0175 | ||
|
|
8e867a5ace | ||
|
|
73dd5b80b5 | ||
|
|
839683b4e1 | ||
|
|
78614877f2 | ||
|
|
bf92944b95 | ||
|
|
fde2c4db1e | ||
|
|
96b9cce70c | ||
|
|
75a57ede07 | ||
|
|
a1adf60b30 | ||
|
|
5db72a9552 | ||
|
|
2a8519be30 | ||
|
|
03eeb3fad1 | ||
|
|
f688b88bd8 | ||
|
|
7164d066c3 | ||
|
|
4473f8ee1d | ||
|
|
6a24a785ee | ||
|
|
ee2d3726af | ||
|
|
c1d9373d55 | ||
|
|
a51fbb1b0e | ||
|
|
cada4efe1d | ||
|
|
0d2d5fff5d | ||
|
|
90e160094d | ||
|
|
877785c3ca | ||
|
|
d05ec08abf | ||
|
|
ddb8931e68 | ||
|
|
194b2eae74 | ||
|
|
966644baa0 | ||
|
|
ddc73a53fe | ||
|
|
cb5557cc2e | ||
|
|
c9ee9dcc8b | ||
|
|
dc03022e27 | ||
|
|
b03fe74f10 | ||
|
|
2600ad5a05 | ||
|
|
d512745767 | ||
|
|
d51be4f529 | ||
|
|
35ac5ac82f | ||
|
|
65796fd1a5 | ||
|
|
a9e1f0d1bc | ||
|
|
f9ff781df3 | ||
|
|
47df4da4b5 | ||
|
|
f22e5ac171 | ||
|
|
ef98d85dc5 | ||
|
|
57d9ae9351 | ||
|
|
ce477ef997 | ||
|
|
fb6627a9cc | ||
|
|
9bcd9931f7 | ||
|
|
fb600d6fc8 | ||
|
|
f5d599e7d2 | ||
|
|
5d521be5d9 | ||
|
|
0f6226ce51 | ||
|
|
194ddc33f3 | ||
|
|
b809c88fa5 | ||
|
|
7486697d41 | ||
|
|
afc93b8a21 | ||
|
|
b4d9f1f5e5 | ||
|
|
ad112e236e | ||
|
|
8a0b872337 | ||
|
|
2490089645 | ||
|
|
62d7491936 | ||
|
|
abc30d7da3 | ||
|
|
d62ceb8423 | ||
|
|
b2c524bc3e | ||
|
|
a9b675cd24 | ||
|
|
5c8be4428b | ||
|
|
7688c1a233 | ||
|
|
6d362ca5c7 | ||
|
|
94b4eb08a2 | ||
|
|
cded1e0272 | ||
|
|
ca80bb0caa | ||
|
|
9317d9217f | ||
|
|
7d01620316 | ||
|
|
739a5092cc | ||
|
|
2fcfeacd44 | ||
|
|
0e5630f33a | ||
|
|
470e2932ad | ||
|
|
797372ecaa | ||
|
|
788730cdc2 | ||
|
|
0d6901aaa2 | ||
|
|
5ecd4fe931 | ||
|
|
e575fad324 | ||
|
|
4c91667b6f | ||
|
|
3ec1f46fe8 | ||
|
|
73ab9f29a5 | ||
|
|
f5c47234de | ||
|
|
605338e998 | ||
|
|
9c4351a174 | ||
|
|
0048c2f9aa | ||
|
|
a58f70ca7e | ||
|
|
2a0ad8796c | ||
|
|
f7e3650728 | ||
|
|
69f845a047 | ||
|
|
809520ec70 | ||
|
|
b28fa86e33 | ||
|
|
5069838e69 | ||
|
|
c3634a5135 | ||
|
|
e72d8437f7 | ||
|
|
9984158ec1 | ||
|
|
0e711beca7 | ||
|
|
23402e27e1 | ||
|
|
d33e8241dc | ||
|
|
b2c048af92 | ||
|
|
7c5094d37b | ||
|
|
c6c9965335 | ||
|
|
4eafe0a5b0 | ||
|
|
070c327642 | ||
|
|
558a627a73 | ||
|
|
502067addc | ||
|
|
11099f7b1d | ||
|
|
4aa94a5d75 | ||
|
|
500942cb99 | ||
|
|
b393e68d1d | ||
|
|
63301efb28 | ||
|
|
e3394e29dd | ||
|
|
9ba73331aa | ||
|
|
33f56bb0cb | ||
|
|
fef280a0c9 | ||
|
|
df6aa59fbf | ||
|
|
3918c60d87 | ||
|
|
1af4566991 | ||
|
|
4dd2c581ac | ||
|
|
9cbd7bd9d3 | ||
|
|
2e3c647591 | ||
|
|
863cbb2b8d | ||
|
|
72e5a227c8 | ||
|
|
6d178342ee | ||
|
|
0b70962e0c | ||
|
|
ecb4277e69 | ||
|
|
09a0039a38 | ||
|
|
fc50359752 | ||
|
|
257e3f33ef | ||
|
|
4dd01cdfda | ||
|
|
74cb48086c | ||
|
|
ded787547a | ||
|
|
31f4c00aee | ||
|
|
f4b65be876 | ||
|
|
362b6a75c8 | ||
|
|
8c92b381a2 | ||
|
|
95be59eaab | ||
|
|
a2d5a23c43 | ||
|
|
d02a7d90b9 | ||
|
|
6d9df65d02 | ||
|
|
b745460a87 | ||
|
|
fd802aac06 | ||
|
|
dec6d80dda | ||
|
|
f6c0843183 | ||
|
|
c637eb28dd | ||
|
|
119437a07c | ||
|
|
84b5987ac5 | ||
|
|
3d8da1db58 | ||
|
|
634d179568 | ||
|
|
7bea6349a0 | ||
|
|
10a15e06e1 | ||
|
|
1867e7ad01 | ||
|
|
e16038bf28 | ||
|
|
b75ff0782d | ||
|
|
3e20788857 | ||
|
|
f73e4b9239 | ||
|
|
27051363ff | ||
|
|
15391379be | ||
|
|
9c96f0fd57 | ||
|
|
30d4337783 | ||
|
|
73f631b1f9 | ||
|
|
3c06519130 | ||
|
|
1d3e7c0255 | ||
|
|
a8afd49f84 | ||
|
|
79a4a17311 | ||
|
|
baffb5fc81 | ||
|
|
5a27d748d1 | ||
|
|
6f5f3d8ca7 | ||
|
|
0c5578937e | ||
|
|
de28e06d8f | ||
|
|
a768c1b5aa | ||
|
|
7f91de7399 | ||
|
|
e06ff85579 | ||
|
|
1f18e505ab | ||
|
|
257b23e89e | ||
|
|
e93507f148 | ||
|
|
3f40a6c485 | ||
|
|
24cc07c20a | ||
|
|
b742b6fc0d | ||
|
|
c91103a45b | ||
|
|
9ad1d60a47 | ||
|
|
3784d897d9 | ||
|
|
b73c14c7cc | ||
|
|
c766554eea | ||
|
|
ddf951de35 | ||
|
|
829903fb9c | ||
|
|
d1c9b7f803 | ||
|
|
7fe066b4ea | ||
|
|
c2ced23073 | ||
|
|
0a78c524fa | ||
|
|
26b560da1d | ||
|
|
cad1e2ab4d | ||
|
|
5189cdb072 | ||
|
|
f04c7c5557 | ||
|
|
190b684469 | ||
|
|
b96e3a0acb | ||
|
|
d8dcdc7455 | ||
|
|
1abd040428 | ||
|
|
591ed4a6d6 | ||
|
|
f154b5f2e2 | ||
|
|
6decab5a51 | ||
|
|
6763c2e99d | ||
|
|
d16ef6d011 | ||
|
|
2c9cf3ecc6 | ||
|
|
90441b2668 | ||
|
|
543f2b2a01 | ||
|
|
5a05bfb6de | ||
|
|
5118ddb8b8 | ||
|
|
999248d71b | ||
|
|
19e89de5d9 | ||
|
|
91002ec6be | ||
|
|
8f70236403 | ||
|
|
05c492bf82 | ||
|
|
782d4e160e | ||
|
|
771bf34ce9 | ||
|
|
aff7b6c72f | ||
|
|
284a8102c8 | ||
|
|
f7b8b30e9d | ||
|
|
d4e5984ccd | ||
|
|
1b7d3edd30 | ||
|
|
6a229eba5f | ||
|
|
e6bca2d35f | ||
|
|
ca782875c2 | ||
|
|
843632a22c | ||
|
|
79fb7531be | ||
|
|
ee6a27e541 | ||
|
|
87fbb04c59 | ||
|
|
ff885e4fde | ||
|
|
18688705be | ||
|
|
c1d8a0c625 | ||
|
|
a76d39ec86 | ||
|
|
9097c3ae23 | ||
|
|
20976f2ab9 | ||
|
|
c6716e6d46 | ||
|
|
60b36c3b19 | ||
|
|
334aabacb7 | ||
|
|
c89353cfec | ||
|
|
f18400b1f1 | ||
|
|
2dd86fcf97 | ||
|
|
002e2103ad | ||
|
|
b189ea3963 | ||
|
|
57a7bf6e95 | ||
|
|
1729324fbd | ||
|
|
10b60d9373 | ||
|
|
f12b0e62c5 | ||
|
|
27d978f232 | ||
|
|
46da74fe8a | ||
|
|
c3fd84b942 | ||
|
|
817b51eb48 | ||
|
|
5289b4ceb3 | ||
|
|
ed963933d9 | ||
|
|
b4e2add146 | ||
|
|
598b58a43d | ||
|
|
f093fd26c1 | ||
|
|
d1e0a06ebd | ||
|
|
c298b8447c | ||
|
|
5d86326ae6 | ||
|
|
118c9da813 | ||
|
|
4ad4ed5ff7 | ||
|
|
9f84a8ad83 | ||
|
|
ad2b2554c1 | ||
|
|
9320d8e230 | ||
|
|
648a540126 | ||
|
|
981c7d28f8 | ||
|
|
71070ee921 | ||
|
|
be3714f074 | ||
|
|
388f51cfb7 | ||
|
|
f34490d4f1 | ||
|
|
b44762d157 | ||
|
|
4f5ed37c0a | ||
|
|
14a5e63ad9 | ||
|
|
8d24f8abdd | ||
|
|
1a3790c7b1 | ||
|
|
8e91564600 | ||
|
|
a69ec74cfd | ||
|
|
694642ccb3 | ||
|
|
38c38a772f | ||
|
|
958faed1b6 | ||
|
|
13202cc6b1 | ||
|
|
68fdd55482 | ||
|
|
c41e0fc239 | ||
|
|
afd01820bb | ||
|
|
d894bd347d | ||
|
|
b21b4f4f57 | ||
|
|
bcb1d8ecc9 | ||
|
|
82ccace647 | ||
|
|
a6b4252210 | ||
|
|
83d19d7644 | ||
|
|
904091f440 | ||
|
|
44b0fe519c | ||
|
|
e7a604d428 | ||
|
|
a64a86efb6 | ||
|
|
9d024cffce | ||
|
|
614dceeb70 | ||
|
|
3892355199 | ||
|
|
e1a9ec03f0 | ||
|
|
fbbe658320 | ||
|
|
59479228bf | ||
|
|
7103d9eccb | ||
|
|
cbe32a081e | ||
|
|
e1e6e84649 | ||
|
|
43faaee77f | ||
|
|
cf55765933 | ||
|
|
4649d96dda | ||
|
|
7c221b7f7f | ||
|
|
c2a76bd73a | ||
|
|
75e7410981 | ||
|
|
448efb8f2a | ||
|
|
fcfa3783e3 | ||
|
|
d5eebe9fe5 | ||
|
|
3f1096b05d | ||
|
|
373a466cb5 | ||
|
|
65d948368c | ||
|
|
2bd17bef47 | ||
|
|
45f7522e65 | ||
|
|
1fdf226802 | ||
|
|
27b6d05b6a | ||
|
|
4466bbc8f4 | ||
|
|
05995649f3 | ||
|
|
c8da53d4b0 | ||
|
|
a7bf9728e3 | ||
|
|
35aa02167c | ||
|
|
3b7b9d2738 | ||
|
|
d6d7110e22 | ||
|
|
8ae7b5947e | ||
|
|
f56e913521 | ||
|
|
5b963b441c | ||
|
|
01fe0c02a5 | ||
|
|
7136197e5d | ||
|
|
2eb33007f7 | ||
|
|
8fa0e7f093 | ||
|
|
d2fac809ca | ||
|
|
e40a071b6b | ||
|
|
baa7a87efb | ||
|
|
b7e48a9597 | ||
|
|
18af72c182 | ||
|
|
cb80c181a6 | ||
|
|
1030118d0b | ||
|
|
13abb0ae7f | ||
|
|
ed32fb927c |
@ -1,2 +1,3 @@
|
|||||||
.git/
|
.git/
|
||||||
venv/
|
venv/
|
||||||
|
test/
|
||||||
|
|||||||
38
.github/ISSUE_TEMPLATE/new-theme.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/new-theme.md
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: New theme
|
||||||
|
about: Create a new theme for Whoogle
|
||||||
|
title: "[THEME] <your theme name>"
|
||||||
|
labels: theme
|
||||||
|
assignees: benbusby
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Use the following template to design your theme, replacing the blank spaces with the colors of your choice.
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* LIGHT THEME COLORS */
|
||||||
|
--whoogle-logo: #______;
|
||||||
|
--whoogle-page-bg: #______;
|
||||||
|
--whoogle-element-bg: #______;
|
||||||
|
--whoogle-text: #______;
|
||||||
|
--whoogle-contrast-text: #______;
|
||||||
|
--whoogle-secondary-text: #______;
|
||||||
|
--whoogle-result-bg: #______;
|
||||||
|
--whoogle-result-title: #______;
|
||||||
|
--whoogle-result-url: #______;
|
||||||
|
--whoogle-result-visited: #______;
|
||||||
|
|
||||||
|
/* DARK THEME COLORS */
|
||||||
|
--whoogle-dark-logo: #______;
|
||||||
|
--whoogle-dark-page-bg: #______;
|
||||||
|
--whoogle-dark-element-bg: #______;
|
||||||
|
--whoogle-dark-text: #______;
|
||||||
|
--whoogle-dark-contrast-text: #______;
|
||||||
|
--whoogle-dark-secondary-text: #______;
|
||||||
|
--whoogle-dark-result-bg: #______;
|
||||||
|
--whoogle-dark-result-title: #______;
|
||||||
|
--whoogle-dark-result-url: #______;
|
||||||
|
--whoogle-dark-result-visited: #______;
|
||||||
|
}
|
||||||
|
```
|
||||||
77
.github/workflows/buildx.yml
vendored
77
.github/workflows/buildx.yml
vendored
@ -1,13 +1,25 @@
|
|||||||
name: buildx
|
name: buildx
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["docker_main"]
|
||||||
|
branches: [main]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
push:
|
push:
|
||||||
branches: develop
|
tags:
|
||||||
|
- '*'
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
on-success:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Wait for tests to succeed
|
||||||
|
if: ${{ github.event.workflow_run.conclusion != 'success' && startsWith(github.ref, 'refs/tags') != true }}
|
||||||
|
run: exit 1
|
||||||
- name: checkout code
|
- name: checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: install buildx
|
- name: install buildx
|
||||||
@ -15,14 +27,65 @@ jobs:
|
|||||||
uses: crazy-max/ghaction-docker-buildx@v1
|
uses: crazy-max/ghaction-docker-buildx@v1
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
- name: log in to docker hub
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Login to ghcr.io
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Disabled: only build on release events now
|
||||||
|
# - name: build and push the image
|
||||||
|
# if: startsWith(github.ref, 'refs/heads/main') && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||||
|
# run: |
|
||||||
|
# docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||||
|
# docker buildx ls
|
||||||
|
# docker buildx build --push \
|
||||||
|
# --tag benbusby/whoogle-search:latest \
|
||||||
|
# --platform linux/amd64,linux/arm64 .
|
||||||
|
# docker buildx build --push \
|
||||||
|
# --tag ghcr.io/benbusby/whoogle-search:latest \
|
||||||
|
# --platform linux/amd64,linux/arm64 .
|
||||||
|
- name: build and push release (version + latest)
|
||||||
|
if: github.event_name == 'release' && github.event.release.prerelease == false && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.DOCKER_PASSWORD }}" | \
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
VERSION="${TAG#v}"
|
||||||
- name: build and push the image
|
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||||
|
docker buildx ls
|
||||||
|
docker buildx build --push \
|
||||||
|
--tag benbusby/whoogle-search:${VERSION} \
|
||||||
|
--tag benbusby/whoogle-search:latest \
|
||||||
|
--platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||||
|
docker buildx build --push \
|
||||||
|
--tag ghcr.io/benbusby/whoogle-search:${VERSION} \
|
||||||
|
--tag ghcr.io/benbusby/whoogle-search:latest \
|
||||||
|
--platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||||
|
- name: build and push pre-release (version only)
|
||||||
|
if: github.event_name == 'release' && github.event.release.prerelease == true && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||||
|
docker buildx ls
|
||||||
|
docker buildx build --push \
|
||||||
|
--tag benbusby/whoogle-search:${VERSION} \
|
||||||
|
--platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||||
|
docker buildx build --push \
|
||||||
|
--tag ghcr.io/benbusby/whoogle-search:${VERSION} \
|
||||||
|
--platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||||
|
- name: build and push tag
|
||||||
|
if: startsWith(github.ref, 'refs/tags')
|
||||||
run: |
|
run: |
|
||||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||||
docker buildx ls
|
docker buildx ls
|
||||||
docker buildx build --push \
|
docker buildx build --push \
|
||||||
--tag benbusby/whoogle-search:buildx-experimental \
|
--tag benbusby/whoogle-search:${GITHUB_REF#refs/*/v}\
|
||||||
|
--platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||||
|
docker buildx build --push \
|
||||||
|
--tag ghcr.io/benbusby/whoogle-search:${GITHUB_REF#refs/*/v}\
|
||||||
--platform linux/amd64,linux/arm/v7,linux/arm64 .
|
--platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||||
|
|||||||
28
.github/workflows/docker_main.yml
vendored
Normal file
28
.github/workflows/docker_main.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
name: docker_main
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["tests"]
|
||||||
|
branches: [main]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
# TODO: Needs refactoring to use reusable workflows and share w/ docker_tests
|
||||||
|
jobs:
|
||||||
|
on-success:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: build and test (docker)
|
||||||
|
run: |
|
||||||
|
docker build --tag whoogle-search:test .
|
||||||
|
docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test
|
||||||
|
sleep 15
|
||||||
|
docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1
|
||||||
|
- name: build and test (docker-compose)
|
||||||
|
run: |
|
||||||
|
docker rm -f whoogle-search-nocompose
|
||||||
|
WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach
|
||||||
|
sleep 15
|
||||||
|
docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1
|
||||||
26
.github/workflows/docker_tests.yml
vendored
Normal file
26
.github/workflows/docker_tests.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
name: docker_tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: main
|
||||||
|
pull_request:
|
||||||
|
branches: main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: build and test (docker)
|
||||||
|
run: |
|
||||||
|
docker build --tag whoogle-search:test .
|
||||||
|
docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test
|
||||||
|
sleep 15
|
||||||
|
docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1
|
||||||
|
- name: build and test (docker compose)
|
||||||
|
run: |
|
||||||
|
docker rm -f whoogle-search-nocompose
|
||||||
|
WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach
|
||||||
|
sleep 15
|
||||||
|
docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1
|
||||||
22
.github/workflows/pep8.yml
vendored
22
.github/workflows/pep8.yml
vendored
@ -1,22 +0,0 @@
|
|||||||
name: pep8
|
|
||||||
|
|
||||||
on:
|
|
||||||
push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: '3.x'
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install pycodestyle
|
|
||||||
- name: Run pycodestyle
|
|
||||||
run: |
|
|
||||||
pycodestyle --show-source --show-pep8 app/*
|
|
||||||
pycodestyle --show-source --show-pep8 test/*
|
|
||||||
83
.github/workflows/pypi.yml
vendored
Normal file
83
.github/workflows/pypi.yml
vendored
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
name: pypi
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: main
|
||||||
|
tags: v*
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-test:
|
||||||
|
name: Build and publish to TestPyPI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.9
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.9
|
||||||
|
- name: Install pypa/build
|
||||||
|
run: >-
|
||||||
|
python -m
|
||||||
|
pip install
|
||||||
|
build
|
||||||
|
setuptools
|
||||||
|
--user
|
||||||
|
- name: Set dev timestamp
|
||||||
|
run: echo "DEV_BUILD=$(date +%s)" >> $GITHUB_ENV
|
||||||
|
- name: Build binary wheel and source tarball
|
||||||
|
run: >-
|
||||||
|
python -m
|
||||||
|
build
|
||||||
|
--sdist
|
||||||
|
--wheel
|
||||||
|
--outdir dist/
|
||||||
|
.
|
||||||
|
- name: Publish distribution to TestPyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@master
|
||||||
|
with:
|
||||||
|
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||||
|
repository_url: https://test.pypi.org/legacy/
|
||||||
|
publish:
|
||||||
|
# Gate real PyPI publishing to stable SemVer tags only
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
name: Build and publish to PyPI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check if stable release
|
||||||
|
id: check_tag
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
if echo "$TAG" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "is_stable=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Tag '$TAG' is a stable release. Will publish to PyPI."
|
||||||
|
else
|
||||||
|
echo "is_stable=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Tag '$TAG' is not a stable release (contains pre-release suffix). Skipping PyPI publish."
|
||||||
|
fi
|
||||||
|
- name: Set up Python 3.9
|
||||||
|
if: steps.check_tag.outputs.is_stable == 'true'
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.9
|
||||||
|
- name: Install pypa/build
|
||||||
|
if: steps.check_tag.outputs.is_stable == 'true'
|
||||||
|
run: >-
|
||||||
|
python -m
|
||||||
|
pip install
|
||||||
|
build
|
||||||
|
--user
|
||||||
|
- name: Build binary wheel and source tarball
|
||||||
|
if: steps.check_tag.outputs.is_stable == 'true'
|
||||||
|
run: >-
|
||||||
|
python -m
|
||||||
|
build
|
||||||
|
--sdist
|
||||||
|
--wheel
|
||||||
|
--outdir dist/
|
||||||
|
.
|
||||||
|
- name: Publish distribution to PyPI
|
||||||
|
if: steps.check_tag.outputs.is_stable == 'true'
|
||||||
|
uses: pypa/gh-action-pypi-publish@master
|
||||||
|
with:
|
||||||
|
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
19
.github/workflows/scan.yml
vendored
Normal file
19
.github/workflows/scan.yml
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
name: scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Build the container image
|
||||||
|
run: |
|
||||||
|
docker build --tag whoogle-search:test .
|
||||||
|
- name: Initiate grype scan
|
||||||
|
run: |
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b .
|
||||||
|
chmod +x ./grype
|
||||||
|
./grype whoogle-search:test --only-fixed
|
||||||
33
.github/workflows/stale.yml
vendored
Normal file
33
.github/workflows/stale.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
|
||||||
|
#
|
||||||
|
# You can adjust the behavior by modifying this file.
|
||||||
|
# For more information, see:
|
||||||
|
# https://github.com/actions/stale
|
||||||
|
name: Mark stale issues and pull requests
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '35 10 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v10
|
||||||
|
with:
|
||||||
|
days-before-stale: 90
|
||||||
|
days-before-close: 7
|
||||||
|
stale-issue-message: 'This issue has been automatically marked as stale due to inactivity. If it is still valid please comment within 7 days or it will be auto-closed.'
|
||||||
|
close-issue-message: 'Closing this issue due to prolonged inactivity.'
|
||||||
|
# Disabled PR Closing for now, but pre-staged the settings
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
operations-per-run: 100
|
||||||
|
stale-pr-message: "This PR appears to be stale. If it is still valid please comment within 14 days or it will be auto-closed."
|
||||||
|
close-pr-message: "This PR was closed as stale."
|
||||||
|
exempt-issue-labels: 'keep-open,enhancement,critical,dependencies,documentation'
|
||||||
17
.github/workflows/tests.yml
vendored
Normal file
17
.github/workflows/tests.yml
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install --upgrade pip && pip install -r requirements.txt
|
||||||
|
- name: Run tests
|
||||||
|
run: ./run test
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,17 +1,27 @@
|
|||||||
venv/
|
venv/
|
||||||
|
.venv/
|
||||||
.idea/
|
.idea/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pem
|
*.pem
|
||||||
*.conf
|
*.conf
|
||||||
|
*.key
|
||||||
config.json
|
config.json
|
||||||
test/static
|
test/static
|
||||||
flask_session/
|
flask_session/
|
||||||
app/static/config
|
app/static/config
|
||||||
app/static/custom_config
|
app/static/custom_config
|
||||||
app/static/bangs
|
app/static/bangs/*
|
||||||
|
!app/static/bangs/00-whoogle.json
|
||||||
|
|
||||||
# pip stuff
|
# pip stuff
|
||||||
build/
|
/build/
|
||||||
dist/
|
dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
|
# env
|
||||||
|
whoogle.env
|
||||||
|
|
||||||
|
# vim
|
||||||
|
*~
|
||||||
|
*.swp
|
||||||
|
|||||||
13
.pre-commit-config.yaml
Normal file
13
.pre-commit-config.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.6.9
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 24.8.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
args: [--quiet]
|
||||||
|
|
||||||
15
.travis.yml
15
.travis.yml
@ -1,15 +0,0 @@
|
|||||||
language: python
|
|
||||||
python: 3.6
|
|
||||||
before_install:
|
|
||||||
- sudo apt-get -y install libgnutls28-dev
|
|
||||||
install:
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
script:
|
|
||||||
- "./run test"
|
|
||||||
deploy:
|
|
||||||
provider: pypi
|
|
||||||
user: __token__
|
|
||||||
password:
|
|
||||||
secure: WNEH2Gg84MZF/AZEberFDGPPWb4cYyHAeD/XV8En94QRSI9Aznz6qiDKOvV4eVgjMAIEW5uB3TL1LHf6KU+Hrg6SmhF7JquqP1gsBOCDNFPTljO+k2Hc53uDdSnhi/HLgY7cnFNX4lc2nNrbyxZxMHuSA2oNz/tosyNGBEeyU+JA5va7uX0albGsLiNjimO4aeau83fsI0Hn2eN6ag68pewUMXNxzpyTeO2bRcCd5d5iILs07jMVwFoC2j7W11oNqrVuSWAs8CPe4+kwvNvXWxljUGiBGppNZ7RAsKNLwi6U6kGGUTWjQm09rY/2JBpJ2WEGmIWGIrno75iiFRbjnRp3mnXPvtVTyWhh+hQIUd7bJOVKM34i9eHotYTrkMJObgW1gnRzvI9VYldtgL/iP/Isn2Pv2EeMX8V+C9/8pxv0jkQkZMnFhE6gGlzpz37zTl04B2J7xyV5znM35Lx2Pn3zxdcmdCvD3yT8I4MuBbKqq2/v4emYCfPfOmfwnS0BEVSqr9lbx4xfUZV76tcvLcj4n86DJbx77pA2Ch8FRprpOOBcf0WuqTbZp8c3mb8prFp2EupUknXu7+C2VQ6sqrnzNuDeTGm/nyjjRQ81rlvlD4tqkwsEGEDDO44FF2eUTc5D2MvoHs4cnz095FWjy63gn5IxUjhMi31b5tGRz2Q=
|
|
||||||
on:
|
|
||||||
tags: true
|
|
||||||
102
Dockerfile
102
Dockerfile
@ -1,60 +1,80 @@
|
|||||||
FROM python:3.8-slim as builder
|
FROM python:3.12.6-alpine3.20 AS builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apk --no-cache add \
|
||||||
build-essential \
|
build-base \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
libxslt-dev \
|
libxslt-dev \
|
||||||
libssl-dev \
|
openssl-dev \
|
||||||
libffi-dev
|
libffi-dev
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
RUN pip install --upgrade pip
|
||||||
RUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt
|
RUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
FROM python:3.8-slim
|
FROM python:3.12.6-alpine3.20
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apk add --no-cache tor curl openrc libstdc++
|
||||||
libcurl4-openssl-dev \
|
# git go //for obfs4proxy
|
||||||
tor \
|
# libcurl4-openssl-dev
|
||||||
wget \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
|
RUN apk --no-cache upgrade
|
||||||
|
|
||||||
|
# uncomment to build obfs4proxy
|
||||||
|
# RUN git clone https://gitlab.com/yawning/obfs4.git
|
||||||
|
# WORKDIR /obfs4
|
||||||
|
# RUN go build -o obfs4proxy/obfs4proxy ./obfs4proxy
|
||||||
|
# RUN cp ./obfs4proxy/obfs4proxy /usr/bin/obfs4proxy
|
||||||
|
|
||||||
|
ARG DOCKER_USER=whoogle
|
||||||
|
ARG DOCKER_USERID=927
|
||||||
ARG config_dir=/config
|
ARG config_dir=/config
|
||||||
RUN mkdir -p $config_dir
|
RUN mkdir -p $config_dir
|
||||||
|
RUN chmod a+w $config_dir
|
||||||
VOLUME $config_dir
|
VOLUME $config_dir
|
||||||
ENV CONFIG_VOLUME=$config_dir
|
|
||||||
|
|
||||||
|
ARG url_prefix=''
|
||||||
ARG username=''
|
ARG username=''
|
||||||
ENV WHOOGLE_USER=$username
|
|
||||||
ARG password=''
|
ARG password=''
|
||||||
ENV WHOOGLE_PASS=$password
|
|
||||||
|
|
||||||
ARG proxyuser=''
|
ARG proxyuser=''
|
||||||
ENV WHOOGLE_PROXY_USER=$proxyuser
|
|
||||||
ARG proxypass=''
|
ARG proxypass=''
|
||||||
ENV WHOOGLE_PROXY_PASS=$proxypass
|
|
||||||
ARG proxytype=''
|
ARG proxytype=''
|
||||||
ENV WHOOGLE_PROXY_TYPE=$proxytype
|
|
||||||
ARG proxyloc=''
|
ARG proxyloc=''
|
||||||
ENV WHOOGLE_PROXY_LOC=$proxyloc
|
|
||||||
|
|
||||||
ARG whoogle_dotenv=''
|
ARG whoogle_dotenv=''
|
||||||
ENV WHOOGLE_DOTENV=$whoogle_dotenv
|
|
||||||
|
|
||||||
ARG use_https=''
|
ARG use_https=''
|
||||||
ENV HTTPS_ONLY=$use_https
|
|
||||||
|
|
||||||
ARG whoogle_port=5000
|
ARG whoogle_port=5000
|
||||||
ENV EXPOSE_PORT=$whoogle_port
|
ARG twitter_alt='farside.link/nitter'
|
||||||
|
ARG youtube_alt='farside.link/invidious'
|
||||||
|
ARG reddit_alt='farside.link/libreddit'
|
||||||
|
ARG medium_alt='farside.link/scribe'
|
||||||
|
ARG translate_alt='farside.link/lingva'
|
||||||
|
ARG imgur_alt='farside.link/rimgo'
|
||||||
|
ARG wikipedia_alt='farside.link/wikiless'
|
||||||
|
ARG imdb_alt='farside.link/libremdb'
|
||||||
|
ARG quora_alt='farside.link/quetre'
|
||||||
|
ARG so_alt='farside.link/anonymousoverflow'
|
||||||
|
|
||||||
ARG twitter_alt='nitter.net'
|
ENV CONFIG_VOLUME=$config_dir \
|
||||||
ENV WHOOGLE_ALT_TW=$twitter_alt
|
WHOOGLE_URL_PREFIX=$url_prefix \
|
||||||
ARG youtube_alt='invidious.snopyta.org'
|
WHOOGLE_USER=$username \
|
||||||
ENV WHOOGLE_ALT_YT=$youtube_alt
|
WHOOGLE_PASS=$password \
|
||||||
ARG instagram_alt='bibliogram.art/u'
|
WHOOGLE_PROXY_USER=$proxyuser \
|
||||||
ENV WHOOGLE_ALT_IG=$instagram_alt
|
WHOOGLE_PROXY_PASS=$proxypass \
|
||||||
ARG reddit_alt='libredd.it'
|
WHOOGLE_PROXY_TYPE=$proxytype \
|
||||||
ENV WHOOGLE_ALT_RD=$reddit_alt
|
WHOOGLE_PROXY_LOC=$proxyloc \
|
||||||
|
WHOOGLE_DOTENV=$whoogle_dotenv \
|
||||||
|
HTTPS_ONLY=$use_https \
|
||||||
|
EXPOSE_PORT=$whoogle_port \
|
||||||
|
WHOOGLE_ALT_TW=$twitter_alt \
|
||||||
|
WHOOGLE_ALT_YT=$youtube_alt \
|
||||||
|
WHOOGLE_ALT_RD=$reddit_alt \
|
||||||
|
WHOOGLE_ALT_MD=$medium_alt \
|
||||||
|
WHOOGLE_ALT_TL=$translate_alt \
|
||||||
|
WHOOGLE_ALT_IMG=$imgur_alt \
|
||||||
|
WHOOGLE_ALT_WIKI=$wikipedia_alt \
|
||||||
|
WHOOGLE_ALT_IMDB=$imdb_alt \
|
||||||
|
WHOOGLE_ALT_QUORA=$quora_alt \
|
||||||
|
WHOOGLE_ALT_SO=$so_alt
|
||||||
|
|
||||||
WORKDIR /whoogle
|
WORKDIR /whoogle
|
||||||
|
|
||||||
@ -62,12 +82,22 @@ COPY --from=builder /install /usr/local
|
|||||||
COPY misc/tor/torrc /etc/tor/torrc
|
COPY misc/tor/torrc /etc/tor/torrc
|
||||||
COPY misc/tor/start-tor.sh misc/tor/start-tor.sh
|
COPY misc/tor/start-tor.sh misc/tor/start-tor.sh
|
||||||
COPY app/ app/
|
COPY app/ app/
|
||||||
COPY run .
|
COPY run whoogle.env* ./
|
||||||
COPY whoogle.env .
|
|
||||||
|
# Create user/group to run as
|
||||||
|
RUN adduser -D -g $DOCKER_USERID -u $DOCKER_USERID $DOCKER_USER
|
||||||
|
|
||||||
|
# Fix ownership / permissions
|
||||||
|
RUN chown -R ${DOCKER_USER}:${DOCKER_USER} /whoogle /var/lib/tor
|
||||||
|
|
||||||
|
# Allow writing symlinks to build dir
|
||||||
|
RUN chown $DOCKER_USERID:$DOCKER_USERID app/static/build
|
||||||
|
|
||||||
|
USER $DOCKER_USER:$DOCKER_USER
|
||||||
|
|
||||||
EXPOSE $EXPOSE_PORT
|
EXPOSE $EXPOSE_PORT
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s \
|
HEALTHCHECK --interval=30s --timeout=5s \
|
||||||
CMD wget --no-verbose --tries=1 http://localhost:${EXPOSE_PORT}/ || exit 1
|
CMD curl -f http://localhost:${EXPOSE_PORT}/healthz || exit 1
|
||||||
|
|
||||||
CMD misc/tor/start-tor.sh & ./run
|
CMD ["/bin/sh", "-c", "misc/tor/start-tor.sh & ./run"]
|
||||||
|
|||||||
137
LETA_INTEGRATION.md
Normal file
137
LETA_INTEGRATION.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Mullvad Leta Backend Integration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Whoogle Search now supports using Mullvad Leta (https://leta.mullvad.net) as an alternative search backend. This provides an additional privacy-focused search option that routes queries through Mullvad's infrastructure.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Backend Selection**: Users can choose between Google (default) and Mullvad Leta as the search backend
|
||||||
|
- **Privacy-Focused**: Leta is designed for privacy and doesn't track searches
|
||||||
|
- **Seamless Integration**: Results from Leta are automatically converted to Whoogle's display format
|
||||||
|
- **Automatic Tab Filtering**: Image, video, news, and map tabs are automatically hidden when using Leta (as these are not supported)
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
When using the Mullvad Leta backend, the following search types are **NOT supported**:
|
||||||
|
- Image search (`tbm=isch`)
|
||||||
|
- Video search (`tbm=vid`)
|
||||||
|
- News search (`tbm=nws`)
|
||||||
|
- Map search
|
||||||
|
|
||||||
|
Attempting to use these search types with Leta enabled will show an error message and redirect to the home page.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Via Web Interface
|
||||||
|
|
||||||
|
1. Click the "Config" button on the Whoogle home page
|
||||||
|
2. Scroll down to find the "Use Mullvad Leta Backend" checkbox
|
||||||
|
3. **Leta is enabled by default** - uncheck the box to use Google instead
|
||||||
|
4. Click "Apply" to save your settings
|
||||||
|
|
||||||
|
### Via Environment Variable
|
||||||
|
|
||||||
|
Leta is **enabled by default**. To disable it and use Google instead:
|
||||||
|
```bash
|
||||||
|
WHOOGLE_CONFIG_USE_LETA=0
|
||||||
|
```
|
||||||
|
|
||||||
|
To explicitly enable it (though it's already default):
|
||||||
|
```bash
|
||||||
|
WHOOGLE_CONFIG_USE_LETA=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. **app/models/config.py**
|
||||||
|
- Added `use_leta` configuration option
|
||||||
|
- Added to `safe_keys` list for URL parameter passing
|
||||||
|
|
||||||
|
2. **app/request.py**
|
||||||
|
- Modified `Request.__init__()` to use Leta URL when configured
|
||||||
|
- Added `gen_query_leta()` function to format queries for Leta's API
|
||||||
|
- Leta uses different query parameters than Google:
|
||||||
|
- `engine=google` (or `brave`)
|
||||||
|
- `country=XX` (lowercase country code)
|
||||||
|
- `language=XX` (language code without `lang_` prefix)
|
||||||
|
- `lastUpdated=d|w|m|y` (time period filter)
|
||||||
|
- `page=N` (pagination, 1-indexed)
|
||||||
|
|
||||||
|
3. **app/filter.py**
|
||||||
|
- Added `convert_leta_to_whoogle()` method to parse Leta's HTML structure
|
||||||
|
- Modified `clean()` method to detect and convert Leta results
|
||||||
|
- Leta results use `<article>` tags with specific classes that are converted to Whoogle's format
|
||||||
|
|
||||||
|
4. **app/routes.py**
|
||||||
|
- Added validation to prevent unsupported search types when using Leta
|
||||||
|
- Shows user-friendly error message when attempting image/video/news/map searches with Leta
|
||||||
|
|
||||||
|
5. **app/utils/results.py**
|
||||||
|
- Modified `get_tabs_content()` to accept `use_leta` parameter
|
||||||
|
- Filters out non-web search tabs when Leta is enabled
|
||||||
|
|
||||||
|
6. **app/templates/index.html**
|
||||||
|
- Added checkbox in settings panel for enabling/disabling Leta backend
|
||||||
|
- Includes helpful tooltip explaining Leta's limitations
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Query Parameter Mapping
|
||||||
|
|
||||||
|
| Google Parameter | Leta Parameter | Notes |
|
||||||
|
|-----------------|----------------|-------|
|
||||||
|
| `q=<query>` | `q=<query>` | Same format |
|
||||||
|
| `gl=<country>` | `country=<code>` | Lowercase country code |
|
||||||
|
| `lr=<lang>` | `language=<code>` | Without `lang_` prefix |
|
||||||
|
| `tbs=qdr:d` | `lastUpdated=d` | Time filters mapped |
|
||||||
|
| `start=10` | `page=2` | Converted to 1-indexed pages |
|
||||||
|
| `tbm=isch/vid/nws` | N/A | Not supported |
|
||||||
|
|
||||||
|
### Leta HTML Structure
|
||||||
|
|
||||||
|
Leta returns results in this structure:
|
||||||
|
```html
|
||||||
|
<article class="svelte-fmlk7p">
|
||||||
|
<a href="<result-url>">
|
||||||
|
<h3>Result Title</h3>
|
||||||
|
</a>
|
||||||
|
<cite>display-url.com</cite>
|
||||||
|
<p class="result__body">Result snippet/description</p>
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is converted to Whoogle's expected format for consistent display.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To test the Leta integration:
|
||||||
|
|
||||||
|
1. Enable Leta in settings
|
||||||
|
2. Perform a regular web search - should see results from Leta
|
||||||
|
3. Try to access an image/video/news tab - should see error message
|
||||||
|
4. Check pagination works correctly
|
||||||
|
5. Verify country and language filters work
|
||||||
|
6. Test time period filters (past day/week/month/year)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `WHOOGLE_CONFIG_USE_LETA`: Set to `0` to disable Leta and use Google instead (default: `1` - Leta enabled)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for future versions:
|
||||||
|
- Add Brave as an alternative engine option (Leta supports both Google and Brave)
|
||||||
|
- Implement image search support if Leta adds this capability
|
||||||
|
- Add per-query backend selection (bang-style syntax)
|
||||||
|
- Cache Leta results for improved performance
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Leta's search results are cached on their end, so you may see "cached X days ago" messages
|
||||||
|
- Leta requires no API key or authentication
|
||||||
|
- Leta respects Tor configuration if enabled in Whoogle
|
||||||
|
- User agent settings apply to Leta requests as well
|
||||||
|
|
||||||
@ -2,4 +2,5 @@ graft app/static
|
|||||||
graft app/templates
|
graft app/templates
|
||||||
graft app/misc
|
graft app/misc
|
||||||
include requirements.txt
|
include requirements.txt
|
||||||
|
recursive-include test
|
||||||
global-exclude *.pyc
|
global-exclude *.pyc
|
||||||
|
|||||||
573
README.md
573
README.md
@ -1,30 +1,68 @@
|
|||||||
|
>[!WARNING]
|
||||||
|
>
|
||||||
|
>**Mullvad Leta Backend Now Available!**
|
||||||
|
>
|
||||||
|
>As of 16 January, 2025, Google seemingly no longer supports performing search queries without JavaScript enabled. We have made multiple workarounds, but as of 2 October 2025, Google has killed off all remaining methods we had to retrieve results from them originally. While we work to rebuild and hopefully find new ways to continue on, we have released a stopgap which uses [Mullvad Leta](https://leta.mullvad.net) (an alternative privacy-focused search backend) as the default (but disable-able) backend leveraging their Google results.
|
||||||
|
>
|
||||||
|
>**Leta is now enabled by default**. It provides anonymous search results through Mullvad's infrastructure without requiring JavaScript. While Leta doesn't support image, video, news, or map searches, it provides privacy-focused web search results.
|
||||||
|
>
|
||||||
|
>To switch back to Google (if it becomes available again), you can disable Leta in the config settings or set `WHOOGLE_CONFIG_USE_LETA=0` in your environment variables. See [LETA_INTEGRATION.md](LETA_INTEGRATION.md) for more details.
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
[](https://github.com/benbusby/shoogle/releases)
|
[](https://github.com/benbusby/shoogle/releases)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://travis-ci.com/benbusby/whoogle-search)
|
[](https://github.com/benbusby/whoogle-search/actions/workflows/tests.yml)
|
||||||
[](https://github.com/benbusby/whoogle-search/actions?query=workflow%3Apep8)
|
[](https://github.com/benbusby/whoogle-search/actions/workflows/buildx.yml)
|
||||||
[](https://codebeat.co/projects/github-com-benbusby-shoogle-master)
|
|
||||||
[](https://hub.docker.com/r/benbusby/whoogle-search)
|
[](https://hub.docker.com/r/benbusby/whoogle-search)
|
||||||
|
|
||||||
Get Google search results, but without any ads, javascript, AMP links, cookies, or IP address tracking. Easily deployable in one click as a Docker app, and customizable with a single config file. Quick and simple to implement as a primary search engine replacement on both desktop and mobile.
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://sr.ht/~benbusby/whoogle-search">SourceHut</a></td>
|
||||||
|
<td><a href="https://github.com/benbusby/whoogle-search">GitHub</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Get Google search results, but without any ads, JavaScript, AMP links, cookies, or IP address tracking. Easily deployable in one click as a Docker app, and customizable with a single config file. Quick and simple to implement as a primary search engine replacement on both desktop and mobile.
|
||||||
|
|
||||||
Contents
|
Contents
|
||||||
1. [Features](#features)
|
1. [Features](#features)
|
||||||
2. [Dependencies](#dependencies)
|
3. [Install/Deploy Options](#install)
|
||||||
3. [Install/Deploy](#install)
|
1. [Heroku Quick Deploy](#heroku-quick-deploy)
|
||||||
4. [Environment Variables](#environment-variables)
|
1. [Render.com](#render)
|
||||||
|
1. [Repl.it](#replit)
|
||||||
|
1. [Fly.io](#flyio)
|
||||||
|
1. [Koyeb](#koyeb)
|
||||||
|
1. [pipx](#pipx)
|
||||||
|
1. [pip](#pip)
|
||||||
|
1. [Manual](#manual)
|
||||||
|
1. [Docker](#manual-docker)
|
||||||
|
1. [Arch/AUR](#arch-linux--arch-based-distributions)
|
||||||
|
1. [Helm/Kubernetes](#helm-chart-for-kubernetes)
|
||||||
|
4. [Environment Variables and Configuration](#environment-variables)
|
||||||
5. [Usage](#usage)
|
5. [Usage](#usage)
|
||||||
6. [Extra Steps](#extra-steps)
|
6. [Extra Steps](#extra-steps)
|
||||||
7. [FAQ](#faq)
|
1. [Set Primary Search Engine](#set-whoogle-as-your-primary-search-engine)
|
||||||
8. [Public Instances](#public-instances)
|
2. [Custom Redirecting](#custom-redirecting)
|
||||||
9. [Screenshots](#screenshots)
|
2. [Custom Bangs](#custom-bangs)
|
||||||
|
3. [Prevent Downtime (Heroku Only)](#prevent-downtime-heroku-only)
|
||||||
|
4. [Manual HTTPS Enforcement](#https-enforcement)
|
||||||
|
5. [Using with Firefox Containers](#using-with-firefox-containers)
|
||||||
|
6. [Reverse Proxying](#reverse-proxying)
|
||||||
|
1. [Nginx](#nginx)
|
||||||
|
7. [Contributing](#contributing)
|
||||||
|
8. [FAQ](#faq)
|
||||||
|
9. [Public Instances](#public-instances)
|
||||||
|
10. [Screenshots](#screenshots)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
- **Mullvad Leta backend support** - Privacy-focused alternative to Google (enabled by default)
|
||||||
- No ads or sponsored content
|
- No ads or sponsored content
|
||||||
- No javascript
|
- No JavaScript\*
|
||||||
- No cookies
|
- No cookies\*\*
|
||||||
- No tracking/linking of your personal IP address\*
|
- No tracking/linking of your personal IP address\*\*\*
|
||||||
- No AMP links
|
- No AMP links
|
||||||
- No URL tracking tags (i.e. utm=%s)
|
- No URL tracking tags (i.e. utm=%s)
|
||||||
- No referrer header
|
- No referrer header
|
||||||
@ -32,40 +70,50 @@ Contents
|
|||||||
- Autocomplete/search suggestions
|
- Autocomplete/search suggestions
|
||||||
- POST request search and suggestion queries (when possible)
|
- POST request search and suggestion queries (when possible)
|
||||||
- View images at full res without site redirect (currently mobile only)
|
- View images at full res without site redirect (currently mobile only)
|
||||||
- Dark mode
|
- Light/Dark/System theme modes (with support for [custom CSS theming](https://github.com/benbusby/whoogle-search/wiki/User-Contributed-CSS-Themes))
|
||||||
- Randomly generated User Agent
|
- Randomly generated User Agent
|
||||||
- Easy to install/deploy
|
- Easy to install/deploy
|
||||||
- DDG-style bang (i.e. `!<tag> <query>`) searches
|
- DDG-style bang (i.e. `!<tag> <query>`) searches
|
||||||
|
- User-defined [custom bangs](#custom-bangs)
|
||||||
- Optional location-based searching (i.e. results near \<city\>)
|
- Optional location-based searching (i.e. results near \<city\>)
|
||||||
- Optional NoJS mode to disable all Javascript in results
|
- Optional NoJS mode to view search results in a separate window with JavaScript blocked
|
||||||
|
- JSON output for results via content negotiation (see "JSON results (API)")
|
||||||
|
|
||||||
<sup>*If deployed to a remote server, or configured to send requests through a VPN, Tor, proxy, etc.</sup>
|
<sup>*No third party JavaScript. Whoogle can be used with JavaScript disabled, but if enabled, uses JavaScript for things like presenting search suggestions.</sup>
|
||||||
|
|
||||||
## Dependencies
|
<sup>**No third party cookies. Whoogle uses server side cookies (sessions) to store non-sensitive configuration settings such as theme, language, etc. Just like with JavaScript, cookies can be disabled and not affect Whoogle's search functionality.</sup>
|
||||||
If using Heroku Quick Deploy, **you can skip this section**.
|
|
||||||
|
|
||||||
- Docker ([Windows](https://docs.docker.com/docker-for-windows/install/), [macOS](https://docs.docker.com/docker-for-mac/install/), [Ubuntu](https://docs.docker.com/engine/install/ubuntu/), [other Linux distros](https://docs.docker.com/engine/install/binaries/))
|
<sup>***If deployed to a remote server, or configured to send requests through a VPN, Tor, proxy, etc.</sup>
|
||||||
- Only needed if you intend on deploying the app as a Docker image
|
|
||||||
- [Python3](https://www.python.org/downloads/)
|
|
||||||
- `libcurl4-openssl-dev` and `libssl-dev`
|
|
||||||
- macOS: `brew install openssl curl-openssl`
|
|
||||||
- Ubuntu: `sudo apt-get install -y libcurl4-openssl-dev libssl-dev`
|
|
||||||
- Arch: `pacman -S curl openssl`
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
There are a few different ways to begin using the app, depending on your preferences:
|
There are a few different ways to begin using the app, depending on your preferences:
|
||||||
|
|
||||||
### A) [Heroku Quick Deploy](https://heroku.com/about)
|
___
|
||||||
[](https://heroku.com/deploy?template=https://github.com/benbusby/whoogle-search/tree/heroku-app-beta)
|
|
||||||
|
|
||||||
*Note: Requires a (free) Heroku account*
|
### [Heroku Quick Deploy](https://heroku.com/about)
|
||||||
|
[](https://heroku.com/deploy?template=https://github.com/benbusby/whoogle-search/tree/main)
|
||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
- Free deployment of app
|
- Easy Deployment of App
|
||||||
- Free HTTPS url (https://\<your app name\>.herokuapp.com)
|
- A HTTPS url (https://\<your app name\>.herokuapp.com)
|
||||||
- Downtime after periods of inactivity \([solution](https://github.com/benbusby/whoogle-search#prevent-downtime-heroku-only)\)
|
|
||||||
|
|
||||||
### B) [Repl.it](https://repl.it)
|
Notes:
|
||||||
|
- Requires a **PAID** Heroku Account.
|
||||||
|
- Sometimes has issues with auto-redirecting to `https`. Make sure to navigate to the `https` version of your app before adding as a default search engine.
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### [Render](https://render.com)
|
||||||
|
|
||||||
|
Create an account on [render.com](https://render.com) and import the Whoogle repo with the following settings:
|
||||||
|
|
||||||
|
- Runtime: `Python 3`
|
||||||
|
- Build Command: `pip install -r requirements.txt`
|
||||||
|
- Run Command: `./run`
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### [Repl.it](https://repl.it)
|
||||||
[](https://repl.it/github/benbusby/whoogle-search)
|
[](https://repl.it/github/benbusby/whoogle-search)
|
||||||
|
|
||||||
*Note: Requires a (free) Replit account*
|
*Note: Requires a (free) Replit account*
|
||||||
@ -74,37 +122,102 @@ Provides:
|
|||||||
- Free deployment of app
|
- Free deployment of app
|
||||||
- Free HTTPS url (https://\<app name\>.\<username\>\.repl\.co)
|
- Free HTTPS url (https://\<app name\>.\<username\>\.repl\.co)
|
||||||
- Supports custom domains
|
- Supports custom domains
|
||||||
- Downtime after periods of inactivity \([solution 1](https://repl.it/talk/ask/use-this-pingmat1replco-just-enter/28821/101298), [solution 2](https://repl.it/talk/learn/How-to-use-and-setup-UptimeRobot/9003)\)
|
- Downtime after periods of inactivity ([solution](https://repl.it/talk/learn/How-to-use-and-setup-UptimeRobot/9003)\)
|
||||||
|
|
||||||
### C) [pipx](https://github.com/pipxproject/pipx#install-pipx)
|
___
|
||||||
|
|
||||||
|
### [Fly.io](https://fly.io)
|
||||||
|
|
||||||
|
You will need a [Fly.io](https://fly.io) account to deploy Whoogle.
|
||||||
|
|
||||||
|
#### Install the CLI: https://fly.io/docs/hands-on/installing/
|
||||||
|
|
||||||
|
#### Deploy the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flyctl auth login
|
||||||
|
flyctl launch --image benbusby/whoogle-search:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The first deploy won't succeed because the default `internal_port` is wrong.
|
||||||
|
To fix this, open the generated `fly.toml` file, set `services.internal_port` to `5000` and run `flyctl launch` again.
|
||||||
|
|
||||||
|
Your app is now available at `https://<app-name>.fly.dev`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Requires a [**PAID**](https://fly.io/docs/about/pricing/#free-allowances) Fly.io Account.
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### [Koyeb](https://www.koyeb.com)
|
||||||
|
|
||||||
|
Use one of the following guides to install Whoogle on Koyeb:
|
||||||
|
|
||||||
|
1. Using GitHub: https://www.koyeb.com/docs/quickstart/deploy-with-git
|
||||||
|
2. Using Docker: https://www.koyeb.com/docs/quickstart/deploy-a-docker-application
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### [RepoCloud](https://repocloud.io)
|
||||||
|
[](https://repocloud.io/details/?app_id=309)
|
||||||
|
|
||||||
|
1. Sign up for a free [RepoCloud account](https://repocloud.io) and receive free credits to get started.
|
||||||
|
2. Click "Deploy" to launch the app and access it instantly via your RepoCloud URL.
|
||||||
|
___
|
||||||
|
|
||||||
|
### [pipx](https://github.com/pipxproject/pipx#install-pipx)
|
||||||
Persistent install:
|
Persistent install:
|
||||||
|
|
||||||
`pipx install git+https://github.com/benbusby/whoogle-search.git`
|
`pipx install https://github.com/benbusby/whoogle-search/archive/refs/heads/main.zip`
|
||||||
|
|
||||||
Sandboxed temporary instance:
|
Sandboxed temporary instance:
|
||||||
|
|
||||||
`pipx run --spec git+https://github.com/benbusby/whoogle-search.git whoogle-search`
|
`pipx run --spec git+https://github.com/benbusby/whoogle-search.git whoogle-search`
|
||||||
|
|
||||||
### D) pip
|
___
|
||||||
|
|
||||||
|
### pip
|
||||||
`pip install whoogle-search`
|
`pip install whoogle-search`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ whoogle-search --help
|
$ whoogle-search --help
|
||||||
usage: whoogle-search [-h] [--port <port number>] [--host <ip address>] [--debug]
|
usage: whoogle-search [-h] [--port <port number>] [--host <ip address>] [--debug] [--https-only] [--userpass <username:password>]
|
||||||
[--https-only]
|
[--proxyauth <username:password>] [--proxytype <socks4|socks5|http>] [--proxyloc <location:port>]
|
||||||
|
|
||||||
Whoogle Search console runner
|
Whoogle Search console runner
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help Show this help message and exit
|
||||||
--port <port number> Specifies a port to run on (default 5000)
|
--port <port number> Specifies a port to run on (default 5000)
|
||||||
--host <ip address> Specifies the host address to use (default 127.0.0.1)
|
--host <ip address> Specifies the host address to use (default 127.0.0.1)
|
||||||
--debug Activates debug mode for the server (default False)
|
--debug Activates debug mode for the server (default False)
|
||||||
--https-only Enforces HTTPS redirects for all requests (default False)
|
--https-only Enforces HTTPS redirects for all requests
|
||||||
|
--userpass <username:password>
|
||||||
|
Sets a username/password basic auth combo (default None)
|
||||||
|
--proxyauth <username:password>
|
||||||
|
Sets a username/password for a HTTP/SOCKS proxy (default None)
|
||||||
|
--proxytype <socks4|socks5|http>
|
||||||
|
Sets a proxy type for all connections (default None)
|
||||||
|
--proxyloc <location:port>
|
||||||
|
Sets a proxy location for all connections (default None)
|
||||||
```
|
```
|
||||||
See the [available environment variables](#environment-variables) for additional configuration.
|
See the [available environment variables](#environment-variables) for additional configuration.
|
||||||
|
|
||||||
### E) Manual
|
___
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
*Note: `Content-Security-Policy` headers can be sent by Whoogle if you set `WHOOGLE_CSP`.*
|
||||||
|
|
||||||
|
#### Dependencies
|
||||||
|
- [Python3](https://www.python.org/downloads/)
|
||||||
|
- `libcurl4-openssl-dev` and `libssl-dev`
|
||||||
|
- macOS: `brew install openssl curl-openssl`
|
||||||
|
- Ubuntu: `sudo apt-get install -y libcurl4-openssl-dev libssl-dev`
|
||||||
|
- Arch: `pacman -S curl openssl`
|
||||||
|
|
||||||
|
#### Install
|
||||||
|
|
||||||
Clone the repo and run the following commands to start the app in a local-only environment:
|
Clone the repo and run the following commands to start the app in a local-only environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -118,9 +231,9 @@ pip install -r requirements.txt
|
|||||||
See the [available environment variables](#environment-variables) for additional configuration.
|
See the [available environment variables](#environment-variables) for additional configuration.
|
||||||
|
|
||||||
#### systemd Configuration
|
#### systemd Configuration
|
||||||
After building the virtual environment, you can add the following to `/lib/systemd/system/whoogle.service` to set up a Whoogle Search systemd service:
|
After building the virtual environment, you can add something like the following to `/lib/systemd/system/whoogle.service` to set up a Whoogle Search systemd service:
|
||||||
|
|
||||||
```
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Whoogle
|
Description=Whoogle
|
||||||
|
|
||||||
@ -131,21 +244,36 @@ Description=Whoogle
|
|||||||
# Proxy configuration, uncomment to enable
|
# Proxy configuration, uncomment to enable
|
||||||
#Environment=WHOOGLE_PROXY_USER=<proxy username>
|
#Environment=WHOOGLE_PROXY_USER=<proxy username>
|
||||||
#Environment=WHOOGLE_PROXY_PASS=<proxy password>
|
#Environment=WHOOGLE_PROXY_PASS=<proxy password>
|
||||||
#Environment=WHOOGLE_PROXY_TYPE=<proxy type (http|proxy4|proxy5)
|
#Environment=WHOOGLE_PROXY_TYPE=<proxy type (http|https|proxy4|proxy5)
|
||||||
#Environment=WHOOGLE_PROXY_LOC=<proxy host/ip>
|
#Environment=WHOOGLE_PROXY_LOC=<proxy host/ip>
|
||||||
# Site alternative configurations, uncomment to enable
|
# Site alternative configurations, uncomment to enable
|
||||||
# Note: If not set, the feature will still be available
|
# Note: If not set, the feature will still be available
|
||||||
# with default values.
|
# with default values.
|
||||||
#Environment=WHOOGLE_ALT_TW=nitter.net
|
#Environment=WHOOGLE_ALT_TW=farside.link/nitter
|
||||||
#Environment=WHOOGLE_ALT_YT=invidious.snopyta.org
|
#Environment=WHOOGLE_ALT_YT=farside.link/invidious
|
||||||
#Environment=WHOOGLE_ALT_IG=bibliogram.art/u
|
#Environment=WHOOGLE_ALT_RD=farside.link/libreddit
|
||||||
#Environment=WHOOGLE_ALT_RD=libredd.it
|
#Environment=WHOOGLE_ALT_MD=farside.link/scribe
|
||||||
|
#Environment=WHOOGLE_ALT_TL=farside.link/lingva
|
||||||
|
#Environment=WHOOGLE_ALT_IMG=farside.link/rimgo
|
||||||
|
#Environment=WHOOGLE_ALT_WIKI=farside.link/wikiless
|
||||||
|
#Environment=WHOOGLE_ALT_IMDB=farside.link/libremdb
|
||||||
|
#Environment=WHOOGLE_ALT_QUORA=farside.link/quetre
|
||||||
|
#Environment=WHOOGLE_ALT_SO=farside.link/anonymousoverflow
|
||||||
# Load values from dotenv only
|
# Load values from dotenv only
|
||||||
#Environment=WHOOGLE_DOTENV=1
|
#Environment=WHOOGLE_DOTENV=1
|
||||||
|
# specify dotenv location if not in default location
|
||||||
|
#Environment=WHOOGLE_DOTENV_PATH=<path/to>/whoogle.env
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=<username>
|
||||||
WorkingDirectory=<whoogle_directory>
|
# If installed as a package, add:
|
||||||
ExecStart=<whoogle_directory>/venv/bin/python3 -um app --host 0.0.0.0 --port 5000
|
ExecStart=<python_install_dir>/python3 <whoogle_install_dir>/whoogle-search --host 127.0.0.1 --port 5000
|
||||||
|
# For example:
|
||||||
|
# ExecStart=/usr/bin/python3 /home/my_username/.local/bin/whoogle-search --host 127.0.0.1 --port 5000
|
||||||
|
# Otherwise if running the app from source, add:
|
||||||
|
ExecStart=<whoogle_repo_dir>/run
|
||||||
|
# For example:
|
||||||
|
# ExecStart=/var/www/whoogle-search/run
|
||||||
|
WorkingDirectory=<whoogle_repo_dir>
|
||||||
ExecReload=/bin/kill -HUP $MAINPID
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
@ -161,7 +289,54 @@ sudo systemctl enable whoogle
|
|||||||
sudo systemctl start whoogle
|
sudo systemctl start whoogle
|
||||||
```
|
```
|
||||||
|
|
||||||
### F) Manual (Docker)
|
#### Tor Configuration *optional*
|
||||||
|
If routing your request through Tor you will need to make the following adjustments.
|
||||||
|
Due to the nature of interacting with Google through Tor we will need to be able to send signals to Tor and therefore authenticate with it.
|
||||||
|
|
||||||
|
There are two authentication methods, password and cookie. You will need to make changes to your torrc:
|
||||||
|
* Cookie
|
||||||
|
1. Uncomment or add the following lines in your torrc:
|
||||||
|
- `ControlPort 9051`
|
||||||
|
- `CookieAuthentication 1`
|
||||||
|
- `DataDirectoryGroupReadable 1`
|
||||||
|
- `CookieAuthFileGroupReadable 1`
|
||||||
|
|
||||||
|
2. Make the tor auth cookie readable:
|
||||||
|
- This is assuming that you are using a dedicated user to run whoogle. If you are using a different user replace `whoogle` with that user.
|
||||||
|
|
||||||
|
1. `chmod tor:whoogle /var/lib/tor`
|
||||||
|
2. `chmod tor:whoogle /var/lib/tor/control_auth_cookie`
|
||||||
|
|
||||||
|
3. Restart the tor service:
|
||||||
|
- `systemctl restart tor`
|
||||||
|
|
||||||
|
4. Set the Tor environment variable to 1, `WHOOGLE_CONFIG_TOR`. Refer to the [Environment Variables](#environment-variables) section for more details.
|
||||||
|
- This may be added in the systemd unit file or env file `WHOOGLE_CONFIG_TOR=1`
|
||||||
|
|
||||||
|
* Password
|
||||||
|
1. Run this command:
|
||||||
|
- `tor --hash-password {Your Password Here}`; put your password in place of `{Your Password Here}`.
|
||||||
|
- Keep the output of this command, you will be placing it in your torrc.
|
||||||
|
- Keep the password input of this command, you will be using it later.
|
||||||
|
|
||||||
|
2. Uncomment or add the following lines in your torrc:
|
||||||
|
- `ControlPort 9051`
|
||||||
|
- `HashedControlPassword {Place output here}`; put the output of the previous command in place of `{Place output here}`.
|
||||||
|
|
||||||
|
3. Now take the password from the first step and place it in the control.conf file within the whoogle working directory, ie. [misc/tor/control.conf](misc/tor/control.conf)
|
||||||
|
- If you want to place your password file in a different location set this location with the `WHOOGLE_TOR_CONF` environment variable. Refer to the [Environment Variables](#environment-variables) section for more details.
|
||||||
|
|
||||||
|
4. Heavily restrict access to control.conf to only be readable by the user running whoogle:
|
||||||
|
- `chmod 400 control.conf`
|
||||||
|
|
||||||
|
5. Finally set the Tor environment variable and use password variable to 1, `WHOOGLE_CONFIG_TOR` and `WHOOGLE_TOR_USE_PASS`. Refer to the [Environment Variables](#environment-variables) section for more details.
|
||||||
|
- These may be added to the systemd unit file or env file:
|
||||||
|
- `WHOOGLE_CONFIG_TOR=1`
|
||||||
|
- `WHOOGLE_TOR_USE_PASS=1`
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### Manual (Docker)
|
||||||
1. Ensure the Docker daemon is running, and is accessible by your user account
|
1. Ensure the Docker daemon is running, and is accessible by your user account
|
||||||
- To add user permissions, you can execute `sudo usermod -aG docker yourusername`
|
- To add user permissions, you can execute `sudo usermod -aG docker yourusername`
|
||||||
- Running `docker ps` should return something besides an error. If you encounter an error saying the daemon isn't running, try `sudo systemctl start docker` (Linux) or ensure the docker tool is running (Windows/macOS).
|
- Running `docker ps` should return something besides an error. If you encounter an error saying the daemon isn't running, try `sudo systemctl start docker` (Linux) or ensure the docker tool is running (Windows/macOS).
|
||||||
@ -169,8 +344,6 @@ sudo systemctl start whoogle
|
|||||||
|
|
||||||
#### Docker CLI
|
#### Docker CLI
|
||||||
|
|
||||||
***Note:** For ARM machines, use the `buildx-experimental` Docker tag.*
|
|
||||||
|
|
||||||
Through Docker Hub:
|
Through Docker Hub:
|
||||||
```bash
|
```bash
|
||||||
docker pull benbusby/whoogle-search
|
docker pull benbusby/whoogle-search
|
||||||
@ -221,12 +394,25 @@ heroku container:release web
|
|||||||
heroku open
|
heroku open
|
||||||
```
|
```
|
||||||
|
|
||||||
This series of commands can take a while, but once you run it once, you shouldn't have to run it again. The final command, `heroku open` will launch a tab in your web browser, where you can test out Whoogle and even [set it as your primary search engine](https://github.com/benbusby/whoogle#set-whoogle-as-your-primary-search-engine).
|
This series of commands can take a while, but once you run it once, you shouldn't have to run it again. The final command, `heroku open` will launch a tab in your web browser, where you can test out Whoogle and even [set it as your primary search engine](https://github.com/benbusby/whoogle-search#set-whoogle-as-your-primary-search-engine).
|
||||||
You may also edit environment variables from your app’s Settings tab in the Heroku Dashboard.
|
You may also edit environment variables from your app’s Settings tab in the Heroku Dashboard.
|
||||||
|
|
||||||
#### Arch Linux & Arch-based Distributions
|
___
|
||||||
|
|
||||||
|
### Arch Linux & Arch-based Distributions
|
||||||
There is an [AUR package available](https://aur.archlinux.org/packages/whoogle-git/), as well as a pre-built and daily updated package available at [Chaotic-AUR](https://chaotic.cx).
|
There is an [AUR package available](https://aur.archlinux.org/packages/whoogle-git/), as well as a pre-built and daily updated package available at [Chaotic-AUR](https://chaotic.cx).
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### Helm chart for Kubernetes
|
||||||
|
To use the Kubernetes Helm Chart:
|
||||||
|
1. Ensure you have [Helm](https://helm.sh/docs/intro/install/) `>=3.0.0` installed
|
||||||
|
2. Clone this repository
|
||||||
|
3. Update [charts/whoogle/values.yaml](./charts/whoogle/values.yaml) as desired
|
||||||
|
4. Run `helm upgrade --install whoogle ./charts/whoogle`
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
#### Using your own server, or alternative container deployment
|
#### Using your own server, or alternative container deployment
|
||||||
There are other methods for deploying docker containers that are well outlined in [this article](https://rollout.io/blog/the-shortlist-of-docker-hosting/), but there are too many to describe set up for each here. Generally it should be about the same amount of effort as the Heroku deployment.
|
There are other methods for deploying docker containers that are well outlined in [this article](https://rollout.io/blog/the-shortlist-of-docker-hosting/), but there are too many to describe set up for each here. Generally it should be about the same amount of effort as the Heroku deployment.
|
||||||
|
|
||||||
@ -237,52 +423,119 @@ Depending on your preferences, you can also deploy the app yourself on your own
|
|||||||
- A bit more experience or willingness to work through issues
|
- A bit more experience or willingness to work through issues
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
There are a few optional environment variables available for customizing a Whoogle instance. These can be set manually, or copied into `whoogle.env` and enabled by setting `WHOOGLE_DOTENV=1`.
|
There are a few optional environment variables available for customizing a Whoogle instance. These can be set manually, or copied into `whoogle.env` and enabled for your preferred deployment method:
|
||||||
|
|
||||||
|
- Local runs: Set `WHOOGLE_DOTENV=1` before running
|
||||||
|
- With `docker-compose`: Uncomment the `env_file` option
|
||||||
|
- With `docker build/run`: Add `--env-file ./whoogle.env` to your command
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| ------------------ | ----------------------------------------------------------------------------------------- |
|
| -------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| WHOOGLE_URL_PREFIX | The URL prefix to use for the whoogle instance (i.e. "/whoogle") |
|
||||||
| WHOOGLE_DOTENV | Load environment variables in `whoogle.env` |
|
| WHOOGLE_DOTENV | Load environment variables in `whoogle.env` |
|
||||||
|
| WHOOGLE_DOTENV_PATH | The path to `whoogle.env` if not in default location |
|
||||||
| WHOOGLE_USER | The username for basic auth. WHOOGLE_PASS must also be set if used. |
|
| WHOOGLE_USER | The username for basic auth. WHOOGLE_PASS must also be set if used. |
|
||||||
| WHOOGLE_PASS | The password for basic auth. WHOOGLE_USER must also be set if used. |
|
| WHOOGLE_PASS | The password for basic auth. WHOOGLE_USER must also be set if used. |
|
||||||
| WHOOGLE_PROXY_USER | The username of the proxy server. |
|
| WHOOGLE_PROXY_USER | The username of the proxy server. |
|
||||||
| WHOOGLE_PROXY_PASS | The password of the proxy server. |
|
| WHOOGLE_PROXY_PASS | The password of the proxy server. |
|
||||||
| WHOOGLE_PROXY_TYPE | The type of the proxy server. Can be "socks5", "socks4", or "http". |
|
| WHOOGLE_PROXY_TYPE | The type of the proxy server. Can be "socks5", "socks4", or "http". |
|
||||||
| WHOOGLE_PROXY_LOC | The location of the proxy server (host or ip). |
|
| WHOOGLE_PROXY_LOC | The location of the proxy server (host or ip). |
|
||||||
|
| WHOOGLE_USER_AGENT | The desktop user agent to use. Defaults to a randomly generated one. |
|
||||||
|
| WHOOGLE_USER_AGENT_MOBILE | The mobile user agent to use. Defaults to a randomly generated one. |
|
||||||
|
| WHOOGLE_USE_CLIENT_USER_AGENT | Enable to use your own user agent for all requests. Defaults to false. |
|
||||||
|
| WHOOGLE_REDIRECTS | Specify sites that should be redirected elsewhere. See [custom redirecting](#custom-redirecting). |
|
||||||
| EXPOSE_PORT | The port where Whoogle will be exposed. |
|
| EXPOSE_PORT | The port where Whoogle will be exposed. |
|
||||||
| HTTPS_ONLY | Enforce HTTPS. (See [here](https://github.com/benbusby/whoogle-search#https-enforcement)) |
|
| HTTPS_ONLY | Enforce HTTPS. (See [here](https://github.com/benbusby/whoogle-search#https-enforcement)) |
|
||||||
| WHOOGLE_ALT_TW | The twitter.com alternative to use when site alternatives are enabled in the config. |
|
| WHOOGLE_ALT_TW | The twitter.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
| WHOOGLE_ALT_YT | The youtube.com alternative to use when site alternatives are enabled in the config. |
|
| WHOOGLE_ALT_YT | The youtube.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
| WHOOGLE_ALT_IG | The instagram.com alternative to use when site alternatives are enabled in the config. |
|
| WHOOGLE_ALT_RD | The reddit.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
| WHOOGLE_ALT_RD | The reddit.com alternative to use when site alternatives are enabled in the config. |
|
| WHOOGLE_ALT_TL | The Google Translate alternative to use. This is used for all "translate ____" searches. Set to "" to disable. |
|
||||||
|
| WHOOGLE_ALT_MD | The medium.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
|
| WHOOGLE_ALT_IMG | The imgur.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
|
| WHOOGLE_ALT_WIKI | The wikipedia.org alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
|
| WHOOGLE_ALT_IMDB | The imdb.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
|
| WHOOGLE_ALT_QUORA | The quora.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
|
| WHOOGLE_ALT_SO | The stackoverflow.com alternative to use when site alternatives are enabled in the config. Set to "" to disable. |
|
||||||
|
| WHOOGLE_AUTOCOMPLETE | Controls visibility of autocomplete/search suggestions. Default on -- use '0' to disable. |
|
||||||
|
| WHOOGLE_MINIMAL | Remove everything except basic result cards from all search queries. |
|
||||||
|
| WHOOGLE_CSP | Sets a default set of 'Content-Security-Policy' headers |
|
||||||
|
| WHOOGLE_RESULTS_PER_PAGE | Set the number of results per page |
|
||||||
|
| WHOOGLE_TOR_SERVICE | Enable/disable the Tor service on startup. Default on -- use '0' to disable. |
|
||||||
|
| WHOOGLE_TOR_USE_PASS | Use password authentication for tor control port. |
|
||||||
|
| WHOOGLE_TOR_CONF | The absolute path to the config file containing the password for the tor control port. Default: ./misc/tor/control.conf WHOOGLE_TOR_PASS must be 1 for this to work.|
|
||||||
|
| WHOOGLE_SHOW_FAVICONS | Show/hide favicons next to search result URLs. Default on. |
|
||||||
|
| WHOOGLE_UPDATE_CHECK | Enable/disable the automatic daily check for new versions of Whoogle. Default on. |
|
||||||
|
| WHOOGLE_FALLBACK_ENGINE_URL | Set a fallback Search Engine URL when there is internal server error or instance is rate-limited. Search query is appended to the end of the URL (eg. https://duckduckgo.com/?k1=-1&q=). |
|
||||||
|
| WHOOGLE_BUNDLE_STATIC | When set to 1, serve a single bundled CSS and JS file generated at startup to reduce requests. Default off. |
|
||||||
|
| WHOOGLE_HTTP2 | Enable HTTP/2 for upstream requests (via httpx). Default on — set to 0 to force HTTP/1.1. |
|
||||||
|
|
||||||
### Config Environment Variables
|
### Config Environment Variables
|
||||||
These environment variables allow setting default config values, but can be overwritten manually by using the home page config menu. These allow a shortcut for destroying/rebuilding an instance to the same config state every time.
|
These environment variables allow setting default config values, but can be overwritten manually by using the home page config menu. These allow a shortcut for destroying/rebuilding an instance to the same config state every time.
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| ----------------------- | --------------------------------------------------------------- |
|
| ------------------------------------ | --------------------------------------------------------------- |
|
||||||
|
| WHOOGLE_CONFIG_DISABLE | Hide config from UI and disallow changes to config by client |
|
||||||
| WHOOGLE_CONFIG_COUNTRY | Filter results by hosting country |
|
| WHOOGLE_CONFIG_COUNTRY | Filter results by hosting country |
|
||||||
| WHOOGLE_CONFIG_LANGUAGE | Set interface and search result language |
|
| WHOOGLE_CONFIG_LANGUAGE | Set interface language |
|
||||||
| WHOOGLE_CONFIG_DARK | Enable dark theme |
|
| WHOOGLE_CONFIG_SEARCH_LANGUAGE | Set search result language |
|
||||||
|
| WHOOGLE_CONFIG_BLOCK | Block websites from search results (use comma-separated list) |
|
||||||
|
| WHOOGLE_CONFIG_BLOCK_TITLE | Block search result with a REGEX filter on title |
|
||||||
|
| WHOOGLE_CONFIG_BLOCK_URL | Block search result with a REGEX filter on URL |
|
||||||
|
| WHOOGLE_CONFIG_THEME | Set theme mode (light, dark, or system) |
|
||||||
| WHOOGLE_CONFIG_SAFE | Enable safe searches |
|
| WHOOGLE_CONFIG_SAFE | Enable safe searches |
|
||||||
| WHOOGLE_CONFIG_ALTS | Use social media site alternatives (nitter, invidious, etc) |
|
| WHOOGLE_CONFIG_ALTS | Use social media site alternatives (nitter, invidious, etc) |
|
||||||
|
| WHOOGLE_CONFIG_NEAR | Restrict results to only those near a particular city |
|
||||||
| WHOOGLE_CONFIG_TOR | Use Tor routing (if available) |
|
| WHOOGLE_CONFIG_TOR | Use Tor routing (if available) |
|
||||||
| WHOOGLE_CONFIG_NEW_TAB | Always open results in new tab |
|
| WHOOGLE_CONFIG_NEW_TAB | Always open results in new tab |
|
||||||
|
| WHOOGLE_CONFIG_VIEW_IMAGE | Enable View Image option |
|
||||||
| WHOOGLE_CONFIG_GET_ONLY | Search using GET requests only |
|
| WHOOGLE_CONFIG_GET_ONLY | Search using GET requests only |
|
||||||
| WHOOGLE_CONFIG_URL | The root url of the instance (`https://<your url>/`) |
|
| WHOOGLE_CONFIG_URL | The root url of the instance (`https://<your url>/`) |
|
||||||
| WHOOGLE_CONFIG_STYLE | The custom CSS to use for styling (must be single line) |
|
| WHOOGLE_CONFIG_STYLE | The custom CSS to use for styling (should be single line) |
|
||||||
|
| WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED | Encrypt preferences token, requires preferences key |
|
||||||
|
| WHOOGLE_CONFIG_PREFERENCES_KEY | Key to encrypt preferences in URL (REQUIRED to show url) |
|
||||||
|
| WHOOGLE_CONFIG_ANON_VIEW | Include the "anonymous view" option for each search result |
|
||||||
|
| WHOOGLE_CONFIG_USE_LETA | Use Mullvad Leta as search backend (default: enabled). Set to 0 to use Google instead |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
Same as most search engines, with the exception of filtering by time range.
|
Same as most search engines, with the exception of filtering by time range.
|
||||||
|
|
||||||
To filter by a range of time, append ":past <time>" to the end of your search, where <time> can be `hour`, `day`, `month`, or `year`. Example: `coronavirus updates :past hour`
|
To filter by a range of time, append ":past <time>" to the end of your search, where <time> can be `hour`, `day`, `month`, or `year`. Example: `coronavirus updates :past hour`
|
||||||
|
|
||||||
|
### JSON results (API)
|
||||||
|
Whoogle can return filtered results as JSON using the same sanitization rules as the HTML view.
|
||||||
|
|
||||||
|
- Send `Accept: application/json` or append `format=json` to the search URL.
|
||||||
|
- Example: `/search?q=whoogle` with `Accept: application/json`, or `/search?q=whoogle&format=json`.
|
||||||
|
- Response shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"query": "whoogle",
|
||||||
|
"search_type": "",
|
||||||
|
"results": [
|
||||||
|
{"href": "https://example.com/page", "text": "Example Page"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Special cases:
|
||||||
|
- Feeling Lucky returns HTTP 303 with body `{ "redirect": "<url>" }`.
|
||||||
|
- Temporary blocks (captcha) return HTTP 503 with `{ "blocked": true, "error_message": "...", "query": "..." }`.
|
||||||
|
|
||||||
## Extra Steps
|
## Extra Steps
|
||||||
|
|
||||||
### Set Whoogle as your primary search engine
|
### Set Whoogle as your primary search engine
|
||||||
*Note: If you're using a reverse proxy to run Whoogle Search, make sure the "Root URL" config option on the home page is set to your URL before going through these steps.*
|
*Note: If you're using a reverse proxy to run Whoogle Search, make sure the "Root URL" config option on the home page is set to your URL before going through these steps.*
|
||||||
|
|
||||||
Update browser settings:
|
Browser settings:
|
||||||
- Firefox (Desktop)
|
- Firefox (Desktop)
|
||||||
- Navigate to your app's url, and click the 3 dot menu in the address bar. At the bottom, there should be an option to "Add Search Engine". Once you've clicked this, open your Firefox Preferences menu, click "Search" in the left menu, and use the available dropdown to select "Whoogle" from the list.
|
- Version 89+
|
||||||
|
- Navigate to your app's url, right click the address bar, and select "Add Search Engine".
|
||||||
|
- Previous versions
|
||||||
|
- Navigate to your app's url, and click the 3 dot menu in the address bar. At the bottom, there should be an option to "Add Search Engine".
|
||||||
|
- Once you've added the new search engine, open your Firefox Preferences menu, click "Search" in the left menu, and use the available dropdown to select "Whoogle" from the list.
|
||||||
|
- **Note**: If your Whoogle instance uses Firefox Containers, you'll need to [go through the steps here](#using-with-firefox-containers) to get it working properly.
|
||||||
- Firefox (iOS)
|
- Firefox (iOS)
|
||||||
- In the mobile app Settings page, tap "Search" within the "General" section. There should be an option titled "Add Search Engine" to select. It should prompt you to enter a title and search query url - use the following elements to fill out the form:
|
- In the mobile app Settings page, tap "Search" within the "General" section. There should be an option titled "Add Search Engine" to select. It should prompt you to enter a title and search query url - use the following elements to fill out the form:
|
||||||
- Title: "Whoogle"
|
- Title: "Whoogle"
|
||||||
@ -305,21 +558,50 @@ Update browser settings:
|
|||||||
- Search string to use: `https://\<your whoogle url\>/search?q=%s`
|
- Search string to use: `https://\<your whoogle url\>/search?q=%s`
|
||||||
- [Alfred](https://www.alfredapp.com/) (Mac OS X)
|
- [Alfred](https://www.alfredapp.com/) (Mac OS X)
|
||||||
1. Go to `Alfred Preferences` > `Features` > `Web Search` and click `Add Custom Search`. Then configure these settings
|
1. Go to `Alfred Preferences` > `Features` > `Web Search` and click `Add Custom Search`. Then configure these settings
|
||||||
- Search URL: `https://\<your whoogle url\>/search?q={query}
|
- Search URL: `https://\<your whoogle url\>/search?q={query}`
|
||||||
- Title: `Whoogle for '{query}'` (or whatever you want)
|
- Title: `Whoogle for '{query}'` (or whatever you want)
|
||||||
- Keyword: `whoogle`
|
- Keyword: `whoogle`
|
||||||
|
|
||||||
2. Go to `Default Results` and click the `Setup fallback results` button. Click `+` and add Whoogle, then drag it to the top.
|
2. Go to `Default Results` and click the `Setup fallback results` button. Click `+` and add Whoogle, then drag it to the top.
|
||||||
- Others (TODO)
|
- Chrome/Chromium-based Browsers
|
||||||
|
- Automatic
|
||||||
|
- Visit the home page of your Whoogle Search instance -- this will automatically add the search engine if the [requirements](https://www.chromium.org/tab-to-search/) are met (GET request, no OnSubmit script, no path). If not, you can add it manually.
|
||||||
|
- Manual
|
||||||
|
- Under search engines > manage search engines > add, manually enter your Whoogle instance details with a `<whoogle url>/search?q=%s` formatted search URL.
|
||||||
|
|
||||||
### Customizing and Configuration
|
### Custom Redirecting
|
||||||
Whoogle currently allows a few minor configuration settings, accessible from the home page:
|
You can set custom site redirects using the `WHOOGLE_REDIRECTS` environment
|
||||||
- "Near"
|
variable. A lot of sites, such as Twitter, Reddit, etc, have built-in redirects
|
||||||
- Set to a city name to narrow your results to a general geographic region. This can be useful if you rely on being able to search for things like "pizza places" and see results in your city, rather than results from wherever the server is located.
|
to [Farside links](https://sr.ht/~benbusby/farside), but you may want to define
|
||||||
- Dark Mode
|
your own.
|
||||||
- Sets background to pure black
|
|
||||||
- NoJS Mode (Experimental)
|
To do this, you can use the following syntax:
|
||||||
- Adds a separate link for each search result that will open the webpage without any javascript content served. Can be useful if you're seeking a no-javascript experience on mobile, but otherwise could just be accomplished with a browser plugin.
|
|
||||||
|
```
|
||||||
|
WHOOGLE_REDIRECTS="<parent_domain>:<new_domain>"
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, if you want to redirect from "badsite.com" to "goodsite.com":
|
||||||
|
|
||||||
|
```
|
||||||
|
WHOOGLE_REDIRECTS="badsite.com:goodsite.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
This can be used for multiple sites as well, with comma separation:
|
||||||
|
|
||||||
|
```
|
||||||
|
WHOOGLE_REDIRECTS="badA.com:goodA.com,badB.com:goodB.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: Do not include "http(s)://" when defining your redirect.
|
||||||
|
|
||||||
|
### Custom Bangs
|
||||||
|
You can create your own custom bangs. By default, bangs are stored in
|
||||||
|
`app/static/bangs`. See [`00-whoogle.json`](https://github.com/benbusby/whoogle-search/blob/main/app/static/bangs/00-whoogle.json)
|
||||||
|
for an example. These are parsed in alphabetical order with later files
|
||||||
|
overriding bangs set in earlier files, with the exception that DDG bangs
|
||||||
|
(downloaded to `app/static/bangs/bangs.json`) are always parsed first. Thus,
|
||||||
|
any custom bangs will always override the DDG ones.
|
||||||
|
|
||||||
### Prevent Downtime (Heroku only)
|
### Prevent Downtime (Heroku only)
|
||||||
Part of the deal with Heroku's free tier is that you're allocated 550 hours/month (meaning it can't stay active 24/7), and the app is temporarily shut down after 30 minutes of inactivity. Once it becomes inactive, any Whoogle searches will still work, but it'll take an extra 10-15 seconds for the app to come back online before displaying the result, which can be frustrating if you're in a hurry.
|
Part of the deal with Heroku's free tier is that you're allocated 550 hours/month (meaning it can't stay active 24/7), and the app is temporarily shut down after 30 minutes of inactivity. Once it becomes inactive, any Whoogle searches will still work, but it'll take an extra 10-15 seconds for the app to come back online before displaying the result, which can be frustrating if you're in a hurry.
|
||||||
@ -341,9 +623,114 @@ Note: You should have your own domain name and [an https certificate](https://le
|
|||||||
- Pip/Pipx: Add the `--https-only` flag to the end of the `whoogle-search` command
|
- Pip/Pipx: Add the `--https-only` flag to the end of the `whoogle-search` command
|
||||||
- Default `run` script: Modify the script locally to include the `--https-only` flag at the end of the python run command
|
- Default `run` script: Modify the script locally to include the `--https-only` flag at the end of the python run command
|
||||||
|
|
||||||
Available config values are `near`, `nojs`, `dark` and `url`.
|
### Using with Firefox Containers
|
||||||
|
Unfortunately, Firefox Containers do not currently pass through `POST` requests (the default) to the engine, and Firefox caches the opensearch template on initial page load. To get around this, you can take the following steps to get it working as expected:
|
||||||
|
|
||||||
|
1. Remove any existing Whoogle search engines from Firefox settings
|
||||||
|
2. Enable `GET Requests Only` in Whoogle config
|
||||||
|
3. Clear Firefox cache
|
||||||
|
4. Restart Firefox
|
||||||
|
5. Navigate to Whoogle instance and [re-add the engine](#set-whoogle-as-your-primary-search-engine)
|
||||||
|
|
||||||
|
### Reverse Proxying
|
||||||
|
|
||||||
|
#### Nginx
|
||||||
|
|
||||||
|
Here is a sample Nginx config for Whoogle:
|
||||||
|
|
||||||
|
```
|
||||||
|
server {
|
||||||
|
server_name your_domain_name.com;
|
||||||
|
access_log /dev/null;
|
||||||
|
error_log /dev/null;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-NginX-Proxy true;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_pass http://localhost:5000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then add SSL support using LetsEncrypt by following a guide such as [this one](https://www.nginx.com/blog/using-free-ssltls-certificates-from-lets-encrypt-with-nginx/).
|
||||||
|
|
||||||
|
### Static asset bundling (optional)
|
||||||
|
Whoogle can optionally serve a single bundled CSS and JS to reduce the number of HTTP requests.
|
||||||
|
|
||||||
|
- Enable by setting `WHOOGLE_BUNDLE_STATIC=1` and restarting the app.
|
||||||
|
- On startup, Whoogle concatenates local CSS/JS into hashed files under `app/static/build/` and templates will prefer those bundles.
|
||||||
|
- When disabled (default), templates load individual CSS/JS files for easier development.
|
||||||
|
- Note: Theme CSS (`*-theme.css`) are still loaded separately to honor user theme selection.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Under the hood, Whoogle is a basic Flask app with the following structure:
|
||||||
|
|
||||||
|
- `app/`
|
||||||
|
- `routes.py`: Primary app entrypoint, contains all API routes
|
||||||
|
- `request.py`: Handles all outbound requests, including proxied/Tor connectivity
|
||||||
|
- `filter.py`: Functions and utilities used for filtering out content from upstream Google search results
|
||||||
|
- `utils/`
|
||||||
|
- `bangs.py`: All logic related to handling DDG-style "bang" queries
|
||||||
|
- `results.py`: Utility functions for interpreting/modifying individual search results
|
||||||
|
- `search.py`: Creates and handles new search queries
|
||||||
|
- `session.py`: Miscellaneous methods related to user sessions
|
||||||
|
- `templates/`
|
||||||
|
- `index.html`: The home page template
|
||||||
|
- `display.html`: The search results template
|
||||||
|
- `header.html`: A general "top of the page" query header for desktop and mobile
|
||||||
|
- `search.html`: An iframe-able search page
|
||||||
|
- `logo.html`: A template consisting mostly of the Whoogle logo as an SVG (separated to help keep `index.html` a bit cleaner)
|
||||||
|
- `opensearch.xml`: A template used for supporting [OpenSearch](https://developer.mozilla.org/en-US/docs/Web/OpenSearch).
|
||||||
|
- `imageresults.html`: An "experimental" template used for supporting the "Full Size" image feature on desktop.
|
||||||
|
- `static/<css|js>`
|
||||||
|
- CSS/JavaScript files, should be self-explanatory
|
||||||
|
- `static/settings`
|
||||||
|
- Key-value JSON files for establishing valid configuration values
|
||||||
|
|
||||||
|
|
||||||
|
If you're new to the project, the easiest way to get started would be to try fixing [an open bug report](https://github.com/benbusby/whoogle-search/issues?q=is%3Aissue+is%3Aopen+label%3Abug). If there aren't any open, or if the open ones are too stale, try taking on a [feature request](https://github.com/benbusby/whoogle-search/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement). Generally speaking, if you can write something that has any potential of breaking down in the future, you should write a test for it.
|
||||||
|
|
||||||
|
The project follows the [PEP 8 Style Guide](https://www.python.org/dev/peps/pep-0008/), but is liable to change. Static typing should always be used when possible. Function documentation is greatly appreciated, and typically follows the below format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def contains(x: list, y: int) -> bool:
|
||||||
|
"""Check a list (x) for the presence of an element (y)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: The list to inspect
|
||||||
|
y: The int to look for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the list contains the item, otherwise False
|
||||||
|
"""
|
||||||
|
|
||||||
|
return y in x
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Translating
|
||||||
|
|
||||||
|
Whoogle currently supports translations using [`translations.json`](https://github.com/benbusby/whoogle-search/blob/main/app/static/settings/translations.json). Language values in this file need to match the "value" of the according language in [`languages.json`](https://github.com/benbusby/whoogle-search/blob/main/app/static/settings/languages.json) (i.e. "lang_en" for English, "lang_es" for Spanish, etc). After you add a new set of translations to `translations.json`, open a PR with your changes and they will be merged in as soon as possible.
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
**What is Mullvad Leta and why is it the default?**
|
||||||
|
|
||||||
|
Mullvad Leta is a privacy-focused search service provided by [Mullvad VPN](https://mullvad.net/en/leta). As of January 2025, Google disabled JavaScript-free search results, which breaks Whoogle's core functionality. Leta provides an excellent alternative that:
|
||||||
|
|
||||||
|
- Doesn't require JavaScript
|
||||||
|
- Provides privacy-focused search results through Mullvad's infrastructure
|
||||||
|
- Uses Google's search index (so results are similar to what you'd expect)
|
||||||
|
- Doesn't track or log your searches
|
||||||
|
|
||||||
|
**Limitations:** Leta only supports regular web search - no images, videos, news, or maps. If you need these features and Google's JavaScript-free search becomes available again, you can disable Leta in settings or set `WHOOGLE_CONFIG_USE_LETA=0`.
|
||||||
|
|
||||||
|
For more details, see [LETA_INTEGRATION.md](LETA_INTEGRATION.md).
|
||||||
|
|
||||||
**What's the difference between this and [Searx](https://github.com/asciimoo/searx)?**
|
**What's the difference between this and [Searx](https://github.com/asciimoo/searx)?**
|
||||||
|
|
||||||
Whoogle is intended to only ever be deployed to private instances by individuals of any background, with as little effort as possible. Prior knowledge of/experience with the command line or deploying applications is not necessary to deploy Whoogle, which isn't the case with Searx. As a result, Whoogle is missing some features of Searx in order to be as easy to deploy as possible.
|
Whoogle is intended to only ever be deployed to private instances by individuals of any background, with as little effort as possible. Prior knowledge of/experience with the command line or deploying applications is not necessary to deploy Whoogle, which isn't the case with Searx. As a result, Whoogle is missing some features of Searx in order to be as easy to deploy as possible.
|
||||||
@ -354,21 +741,29 @@ I'm a huge fan of Searx though and encourage anyone to use that instead if they
|
|||||||
|
|
||||||
**Why does the image results page look different?**
|
**Why does the image results page look different?**
|
||||||
|
|
||||||
A lot of the app currently piggybacks on Google's existing support for fetching results pages with Javascript disabled. To their credit, they've done an excellent job with styling pages, but it seems that the image results page - particularly on mobile - is a little rough. Moving forward, with enough interest, I'd like to transition to fetching the results and parsing them into a unique Whoogle-fied interface that I can style myself.
|
A lot of the app currently piggybacks on Google's existing support for fetching results pages with JavaScript disabled. To their credit, they've done an excellent job with styling pages, but it seems that the image results page - particularly on mobile - is a little rough. Moving forward, with enough interest, I'd like to transition to fetching the results and parsing them into a unique Whoogle-fied interface that I can style myself.
|
||||||
|
|
||||||
## Public Instances
|
## Public Instances
|
||||||
|
|
||||||
*Note: Use public instances at your own discretion. Maintainers of Whoogle do not personally validate the integrity of these instances, and popular public instances are more likely to be rate-limited or blocked.*
|
*Note: Use public instances at your own discretion. The maintainers of Whoogle do not personally validate the integrity of any other instances. Popular public instances are more likely to be rate-limited or blocked.*
|
||||||
|
|
||||||
|
| Website | Country | Language | Cloudflare |
|
||||||
|
|-|-|-|-|
|
||||||
|
| [https://search.garudalinux.org](https://search.garudalinux.org) | 🇫🇮 FI | Multi-choice | ✅ |
|
||||||
|
| [https://whoogle.privacydev.net](https://whoogle.privacydev.net) | 🇫🇷 FR | English | |
|
||||||
|
| [https://whoogle.lunar.icu](https://whoogle.lunar.icu) | 🇩🇪 DE | Multi-choice | ✅ |
|
||||||
|
|
||||||
|
* A checkmark in the "Cloudflare" category here refers to the use of the reverse proxy, [Cloudflare](https://cloudflare.com). The checkmark will not be listed for a site which uses Cloudflare DNS but rather the proxying service which grants Cloudflare the ability to monitor traffic to the website.
|
||||||
|
|
||||||
|
#### Onion Instances
|
||||||
|
|
||||||
|
| Website | Country | Language |
|
||||||
|
|-|-|-|
|
||||||
|
NONE of the existing Onion accessible sites appear to be live anymore
|
||||||
|
|
||||||
- [https://whoogle.sdf.org](https://whoogle.sdf.org)
|
|
||||||
- [https://whoogle.himiko.cloud](https://whoogle.himiko.cloud)
|
|
||||||
- [https://whoogle.kavin.rocks](https://whoogle.kavin.rocks) or [http://whoogledq5f5wly5p4i2ohnvjwlihnlg4oajjum2oeddfwqdwupbuhqd.onion](http://whoogledq5f5wly5p4i2ohnvjwlihnlg4oajjum2oeddfwqdwupbuhqd.onion)
|
|
||||||
- [https://search.garudalinux.org](https://search.garudalinux.org)
|
|
||||||
- [https://whooglesearch.net/](https://whooglesearch.net/)
|
|
||||||
- [https://search.whoogle.tech/](https://search.whoogle.tech/)
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
#### Desktop
|
#### Desktop
|
||||||

|

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

|

|
||||||
|
|||||||
104
app.json
104
app.json
@ -15,6 +15,11 @@
|
|||||||
],
|
],
|
||||||
"stack": "container",
|
"stack": "container",
|
||||||
"env": {
|
"env": {
|
||||||
|
"WHOOGLE_URL_PREFIX": {
|
||||||
|
"description": "The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
"WHOOGLE_USER": {
|
"WHOOGLE_USER": {
|
||||||
"description": "The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.",
|
"description": "The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.",
|
||||||
"value": "",
|
"value": "",
|
||||||
@ -47,22 +52,57 @@
|
|||||||
},
|
},
|
||||||
"WHOOGLE_ALT_TW": {
|
"WHOOGLE_ALT_TW": {
|
||||||
"description": "The site to use as a replacement for twitter.com when site alternatives are enabled in the config.",
|
"description": "The site to use as a replacement for twitter.com when site alternatives are enabled in the config.",
|
||||||
"value": "nitter.net",
|
"value": "farside.link/nitter",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
"WHOOGLE_ALT_YT": {
|
"WHOOGLE_ALT_YT": {
|
||||||
"description": "The site to use as a replacement for youtube.com when site alternatives are enabled in the config.",
|
"description": "The site to use as a replacement for youtube.com when site alternatives are enabled in the config.",
|
||||||
"value": "invidious.snopyta.org",
|
"value": "farside.link/invidious",
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
"WHOOGLE_ALT_IG": {
|
|
||||||
"description": "The site to use as a replacement for instagram.com when site alternatives are enabled in the config.",
|
|
||||||
"value": "bibliogram.art/u",
|
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
"WHOOGLE_ALT_RD": {
|
"WHOOGLE_ALT_RD": {
|
||||||
"description": "The site to use as a replacement for reddit.com when site alternatives are enabled in the config.",
|
"description": "The site to use as a replacement for reddit.com when site alternatives are enabled in the config.",
|
||||||
"value": "libredd.it",
|
"value": "farside.link/libreddit",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_ALT_MD": {
|
||||||
|
"description": "The site to use as a replacement for medium.com when site alternatives are enabled in the config.",
|
||||||
|
"value": "farside.link/scribe",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_ALT_TL": {
|
||||||
|
"description": "The Google Translate alternative to use for all searches following the 'translate ___' structure.",
|
||||||
|
"value": "farside.link/lingva",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_ALT_IMG": {
|
||||||
|
"description": "The site to use as a replacement for imgur.com when site alternatives are enabled in the config.",
|
||||||
|
"value": "farside.link/rimgo",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_ALT_WIKI": {
|
||||||
|
"description": "The site to use as a replacement for wikipedia.com when site alternatives are enabled in the config.",
|
||||||
|
"value": "farside.link/wikiless",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_ALT_IMDB": {
|
||||||
|
"description": "The site to use as a replacement for imdb.com when site alternatives are enabled in the config.",
|
||||||
|
"value": "farside.link/libremdb",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_ALT_QUORA": {
|
||||||
|
"description": "The site to use as a replacement for quora.com when site alternatives are enabled in the config.",
|
||||||
|
"value": "farside.link/quetre",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_ALT_SO": {
|
||||||
|
"description": "The site to use as a replacement for stackoverflow.com when site alternatives are enabled in the config.",
|
||||||
|
"value": "farside.link/anonymousoverflow",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_MINIMAL": {
|
||||||
|
"description": "Remove everything except basic result cards from all search queries (set to 1 or leave blank)",
|
||||||
|
"value": "",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
"WHOOGLE_CONFIG_COUNTRY": {
|
"WHOOGLE_CONFIG_COUNTRY": {
|
||||||
@ -70,16 +110,36 @@
|
|||||||
"value": "",
|
"value": "",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
"WHOOGLE_CONFIG_LANGUAGE": {
|
"WHOOGLE_CONFIG_TIME_PERIOD" : {
|
||||||
"description": "[CONFIG] The language to use for search results and interface (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/languages.json)",
|
"description": "[CONFIG] The time period to use for restricting search results",
|
||||||
"value": "",
|
"value": "",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
"WHOOGLE_CONFIG_DARK": {
|
"WHOOGLE_CONFIG_LANGUAGE": {
|
||||||
"description": "[CONFIG] Enable dark mode (set to 1 or leave blank)",
|
"description": "[CONFIG] The language to use for the interface (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/languages.json)",
|
||||||
"value": "",
|
"value": "",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
|
"WHOOGLE_CONFIG_SEARCH_LANGUAGE": {
|
||||||
|
"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)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_CONFIG_DISABLE": {
|
||||||
|
"description": "[CONFIG] Disable ability for client to change config (set to 1 or leave blank)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_CONFIG_BLOCK": {
|
||||||
|
"description": "[CONFIG] Block websites from search results (comma-separated list)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_CONFIG_THEME": {
|
||||||
|
"description": "[CONFIG] Set theme to 'dark', 'light', or 'system'",
|
||||||
|
"value": "system",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
"WHOOGLE_CONFIG_SAFE": {
|
"WHOOGLE_CONFIG_SAFE": {
|
||||||
"description": "[CONFIG] Use safe mode for searches (set to 1 or leave blank)",
|
"description": "[CONFIG] Use safe mode for searches (set to 1 or leave blank)",
|
||||||
"value": "",
|
"value": "",
|
||||||
@ -90,6 +150,11 @@
|
|||||||
"value": "",
|
"value": "",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
|
"WHOOGLE_CONFIG_NEAR": {
|
||||||
|
"description": "[CONFIG] Restrict results to only those near a particular city",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
"WHOOGLE_CONFIG_TOR": {
|
"WHOOGLE_CONFIG_TOR": {
|
||||||
"description": "[CONFIG] Use Tor, if available (set to 1 or leave blank)",
|
"description": "[CONFIG] Use Tor, if available (set to 1 or leave blank)",
|
||||||
"value": "",
|
"value": "",
|
||||||
@ -99,6 +164,11 @@
|
|||||||
"description": "[CONFIG] Always open results in new tab (set to 1 or leave blank)",
|
"description": "[CONFIG] Always open results in new tab (set to 1 or leave blank)",
|
||||||
"value": "",
|
"value": "",
|
||||||
"required": false
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_CONFIG_VIEW_IMAGE": {
|
||||||
|
"description": "[CONFIG] Enable View Image option (set to 1 or leave blank)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
},
|
},
|
||||||
"WHOOGLE_CONFIG_GET_ONLY": {
|
"WHOOGLE_CONFIG_GET_ONLY": {
|
||||||
"description": "[CONFIG] Search using GET requests only (set to 1 or leave blank)",
|
"description": "[CONFIG] Search using GET requests only (set to 1 or leave blank)",
|
||||||
@ -107,8 +177,18 @@
|
|||||||
},
|
},
|
||||||
"WHOOGLE_CONFIG_STYLE": {
|
"WHOOGLE_CONFIG_STYLE": {
|
||||||
"description": "[CONFIG] Custom CSS styling (paste in CSS or leave blank)",
|
"description": "[CONFIG] Custom CSS styling (paste in CSS or leave blank)",
|
||||||
|
"value": ":root { /* LIGHT THEME COLORS */ --whoogle-background: #d8dee9; --whoogle-accent: #2e3440; --whoogle-text: #3B4252; --whoogle-contrast-text: #eceff4; --whoogle-secondary-text: #70757a; --whoogle-result-bg: #fff; --whoogle-result-title: #4c566a; --whoogle-result-url: #81a1c1; --whoogle-result-visited: #a3be8c; /* DARK THEME COLORS */ --whoogle-dark-background: #222; --whoogle-dark-accent: #685e79; --whoogle-dark-text: #fff; --whoogle-dark-contrast-text: #000; --whoogle-dark-secondary-text: #bbb; --whoogle-dark-result-bg: #000; --whoogle-dark-result-title: #1967d2; --whoogle-dark-result-url: #4b11a8; --whoogle-dark-result-visited: #bbbbff; }",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED": {
|
||||||
|
"description": "[CONFIG] Encrypt preferences token, requires WHOOGLE_CONFIG_PREFERENCES_KEY to be set",
|
||||||
"value": "",
|
"value": "",
|
||||||
"required": false
|
"required": false
|
||||||
|
},
|
||||||
|
"WHOOGLE_CONFIG_PREFERENCES_KEY": {
|
||||||
|
"description": "[CONFIG] Key to encrypt preferences",
|
||||||
|
"value": "NEEDS_TO_BE_MODIFIED",
|
||||||
|
"required": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
240
app/__init__.py
240
app/__init__.py
@ -1,76 +1,256 @@
|
|||||||
|
from app.filter import clean_query
|
||||||
from app.request import send_tor_signal
|
from app.request import send_tor_signal
|
||||||
from app.utils.session import generate_user_key
|
from app.utils.session import generate_key
|
||||||
from app.utils.bangs import gen_bangs_json
|
from app.utils.bangs import gen_bangs_json, load_all_bangs
|
||||||
|
from app.utils.misc import gen_file_hash, read_config_bool
|
||||||
|
from base64 import b64encode
|
||||||
|
from bs4 import MarkupResemblesLocatorWarning
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_session import Session
|
|
||||||
import json
|
import json
|
||||||
|
import logging.config
|
||||||
import os
|
import os
|
||||||
from stem import Signal
|
from stem import Signal
|
||||||
from dotenv import load_dotenv
|
import threading
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
|
from app.utils.misc import read_config_bool
|
||||||
|
from app.services.http_client import HttpxClient
|
||||||
|
from app.services.provider import close_all_clients
|
||||||
|
from app.version import __version__
|
||||||
|
|
||||||
app = Flask(__name__, static_folder=os.path.dirname(
|
app = Flask(__name__, static_folder=os.path.dirname(
|
||||||
os.path.abspath(__file__)) + '/static')
|
os.path.abspath(__file__)) + '/static')
|
||||||
|
|
||||||
# Load .env file if enabled
|
app.wsgi_app = ProxyFix(app.wsgi_app)
|
||||||
if os.getenv("WHOOGLE_DOTENV", ''):
|
|
||||||
dotenv_path = '../whoogle.env'
|
|
||||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
||||||
dotenv_path))
|
|
||||||
|
|
||||||
app.default_key = generate_user_key()
|
# look for WHOOGLE_ENV, else look in parent directory
|
||||||
app.no_cookie_ips = []
|
dot_env_path = os.getenv(
|
||||||
app.config['SECRET_KEY'] = os.urandom(32)
|
"WHOOGLE_DOTENV_PATH",
|
||||||
app.config['SESSION_TYPE'] = 'filesystem'
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../whoogle.env"))
|
||||||
app.config['VERSION_NUMBER'] = '0.4.1'
|
|
||||||
|
# Load .env file if enabled
|
||||||
|
if os.path.exists(dot_env_path):
|
||||||
|
load_dotenv(dot_env_path)
|
||||||
|
|
||||||
|
app.enc_key = generate_key()
|
||||||
|
|
||||||
|
if read_config_bool('HTTPS_ONLY'):
|
||||||
|
app.config['SESSION_COOKIE_NAME'] = '__Secure-session'
|
||||||
|
app.config['SESSION_COOKIE_SECURE'] = True
|
||||||
|
|
||||||
|
app.config['VERSION_NUMBER'] = __version__
|
||||||
app.config['APP_ROOT'] = os.getenv(
|
app.config['APP_ROOT'] = os.getenv(
|
||||||
'APP_ROOT',
|
'APP_ROOT',
|
||||||
os.path.dirname(os.path.abspath(__file__)))
|
os.path.dirname(os.path.abspath(__file__)))
|
||||||
app.config['STATIC_FOLDER'] = os.getenv(
|
app.config['STATIC_FOLDER'] = os.getenv(
|
||||||
'STATIC_FOLDER',
|
'STATIC_FOLDER',
|
||||||
os.path.join(app.config['APP_ROOT'], 'static'))
|
os.path.join(app.config['APP_ROOT'], 'static'))
|
||||||
app.config['LANGUAGES'] = json.load(open(
|
app.config['BUILD_FOLDER'] = os.path.join(
|
||||||
os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json')))
|
app.config['STATIC_FOLDER'], 'build')
|
||||||
app.config['COUNTRIES'] = json.load(open(
|
app.config['CACHE_BUSTING_MAP'] = {}
|
||||||
os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json')))
|
app.config['BUNDLE_STATIC'] = read_config_bool('WHOOGLE_BUNDLE_STATIC')
|
||||||
|
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json'), 'r', encoding='utf-8') as f:
|
||||||
|
app.config['LANGUAGES'] = json.load(f)
|
||||||
|
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json'), 'r', encoding='utf-8') as f:
|
||||||
|
app.config['COUNTRIES'] = json.load(f)
|
||||||
|
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/time_periods.json'), 'r', encoding='utf-8') as f:
|
||||||
|
app.config['TIME_PERIODS'] = json.load(f)
|
||||||
|
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json'), 'r', encoding='utf-8') as f:
|
||||||
|
app.config['TRANSLATIONS'] = json.load(f)
|
||||||
|
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/themes.json'), 'r', encoding='utf-8') as f:
|
||||||
|
app.config['THEMES'] = json.load(f)
|
||||||
|
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/header_tabs.json'), 'r', encoding='utf-8') as f:
|
||||||
|
app.config['HEADER_TABS'] = json.load(f)
|
||||||
app.config['CONFIG_PATH'] = os.getenv(
|
app.config['CONFIG_PATH'] = os.getenv(
|
||||||
'CONFIG_VOLUME',
|
'CONFIG_VOLUME',
|
||||||
os.path.join(app.config['STATIC_FOLDER'], 'config'))
|
os.path.join(app.config['STATIC_FOLDER'], 'config'))
|
||||||
app.config['DEFAULT_CONFIG'] = os.path.join(
|
app.config['DEFAULT_CONFIG'] = os.path.join(
|
||||||
app.config['CONFIG_PATH'],
|
app.config['CONFIG_PATH'],
|
||||||
'config.json')
|
'config.json')
|
||||||
|
app.config['CONFIG_DISABLE'] = read_config_bool('WHOOGLE_CONFIG_DISABLE')
|
||||||
app.config['SESSION_FILE_DIR'] = os.path.join(
|
app.config['SESSION_FILE_DIR'] = os.path.join(
|
||||||
app.config['CONFIG_PATH'],
|
app.config['CONFIG_PATH'],
|
||||||
'session')
|
'session')
|
||||||
|
app.config['MAX_SESSION_SIZE'] = 4000 # Sessions won't exceed 4KB
|
||||||
app.config['BANG_PATH'] = os.getenv(
|
app.config['BANG_PATH'] = os.getenv(
|
||||||
'CONFIG_VOLUME',
|
'CONFIG_VOLUME',
|
||||||
os.path.join(app.config['STATIC_FOLDER'], 'bangs'))
|
os.path.join(app.config['STATIC_FOLDER'], 'bangs'))
|
||||||
app.config['BANG_FILE'] = os.path.join(
|
app.config['BANG_FILE'] = os.path.join(
|
||||||
app.config['BANG_PATH'],
|
app.config['BANG_PATH'],
|
||||||
'bangs.json')
|
'bangs.json')
|
||||||
app.config['CSP'] = 'default-src \'none\';' \
|
|
||||||
'manifest-src \'self\';' \
|
|
||||||
'img-src \'self\';' \
|
|
||||||
'style-src \'self\' \'unsafe-inline\';' \
|
|
||||||
'script-src \'self\';' \
|
|
||||||
'media-src \'self\';' \
|
|
||||||
'connect-src \'self\';' \
|
|
||||||
'form-action \'self\';'
|
|
||||||
|
|
||||||
|
# Global services registry (simple DI)
|
||||||
|
app.services = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.teardown_appcontext
|
||||||
|
def _teardown_clients(exception):
|
||||||
|
try:
|
||||||
|
close_all_clients()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure all necessary directories exist
|
||||||
if not os.path.exists(app.config['CONFIG_PATH']):
|
if not os.path.exists(app.config['CONFIG_PATH']):
|
||||||
os.makedirs(app.config['CONFIG_PATH'])
|
os.makedirs(app.config['CONFIG_PATH'])
|
||||||
|
|
||||||
if not os.path.exists(app.config['SESSION_FILE_DIR']):
|
if not os.path.exists(app.config['SESSION_FILE_DIR']):
|
||||||
os.makedirs(app.config['SESSION_FILE_DIR'])
|
os.makedirs(app.config['SESSION_FILE_DIR'])
|
||||||
|
|
||||||
# Generate DDG bang filter, and create path if it doesn't exist yet
|
|
||||||
if not os.path.exists(app.config['BANG_PATH']):
|
if not os.path.exists(app.config['BANG_PATH']):
|
||||||
os.makedirs(app.config['BANG_PATH'])
|
os.makedirs(app.config['BANG_PATH'])
|
||||||
if not os.path.exists(app.config['BANG_FILE']):
|
|
||||||
gen_bangs_json(app.config['BANG_FILE'])
|
|
||||||
|
|
||||||
Session(app)
|
if not os.path.exists(app.config['BUILD_FOLDER']):
|
||||||
|
os.makedirs(app.config['BUILD_FOLDER'])
|
||||||
|
|
||||||
|
# Session values
|
||||||
|
app_key_path = os.path.join(app.config['CONFIG_PATH'], 'whoogle.key')
|
||||||
|
if os.path.exists(app_key_path):
|
||||||
|
try:
|
||||||
|
with open(app_key_path, 'r', encoding='utf-8') as f:
|
||||||
|
app.config['SECRET_KEY'] = f.read()
|
||||||
|
except PermissionError:
|
||||||
|
app.config['SECRET_KEY'] = str(b64encode(os.urandom(32)))
|
||||||
|
else:
|
||||||
|
app.config['SECRET_KEY'] = str(b64encode(os.urandom(32)))
|
||||||
|
with open(app_key_path, 'w', encoding='utf-8') as key_file:
|
||||||
|
key_file.write(app.config['SECRET_KEY'])
|
||||||
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365)
|
||||||
|
|
||||||
|
# NOTE: SESSION_COOKIE_SAMESITE must be set to 'lax' to allow the user's
|
||||||
|
# previous session to persist when accessing the instance from an external
|
||||||
|
# link. Setting this value to 'strict' causes Whoogle to revalidate a new
|
||||||
|
# session, and fail, resulting in cookies being disabled.
|
||||||
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||||
|
|
||||||
|
# Config fields that are used to check for updates
|
||||||
|
app.config['RELEASES_URL'] = 'https://github.com/' \
|
||||||
|
'benbusby/whoogle-search/releases'
|
||||||
|
app.config['LAST_UPDATE_CHECK'] = datetime.now() - timedelta(hours=24)
|
||||||
|
app.config['HAS_UPDATE'] = ''
|
||||||
|
|
||||||
|
# The alternative to Google Translate is treated a bit differently than other
|
||||||
|
# social media site alternatives, in that it is used for any translation
|
||||||
|
# related searches.
|
||||||
|
translate_url = os.getenv('WHOOGLE_ALT_TL', 'https://farside.link/lingva')
|
||||||
|
if not translate_url.startswith('http'):
|
||||||
|
translate_url = 'https://' + translate_url
|
||||||
|
app.config['TRANSLATE_URL'] = translate_url
|
||||||
|
|
||||||
|
app.config['CSP'] = 'default-src \'none\';' \
|
||||||
|
'frame-src ' + translate_url + ';' \
|
||||||
|
'manifest-src \'self\';' \
|
||||||
|
'img-src \'self\' data:;' \
|
||||||
|
'style-src \'self\' \'unsafe-inline\';' \
|
||||||
|
'script-src \'self\';' \
|
||||||
|
'media-src \'self\';' \
|
||||||
|
'connect-src \'self\';'
|
||||||
|
|
||||||
|
# Generate DDG bang filter
|
||||||
|
generating_bangs = False
|
||||||
|
if not os.path.exists(app.config['BANG_FILE']):
|
||||||
|
generating_bangs = True
|
||||||
|
with open(app.config['BANG_FILE'], 'w', encoding='utf-8') as f:
|
||||||
|
json.dump({}, f)
|
||||||
|
bangs_thread = threading.Thread(
|
||||||
|
target=gen_bangs_json,
|
||||||
|
args=(app.config['BANG_FILE'],))
|
||||||
|
bangs_thread.start()
|
||||||
|
|
||||||
|
# Build new mapping of static files for cache busting
|
||||||
|
cache_busting_dirs = ['css', 'js']
|
||||||
|
for cb_dir in cache_busting_dirs:
|
||||||
|
full_cb_dir = os.path.join(app.config['STATIC_FOLDER'], cb_dir)
|
||||||
|
for cb_file in os.listdir(full_cb_dir):
|
||||||
|
# Create hash from current file state
|
||||||
|
full_cb_path = os.path.join(full_cb_dir, cb_file)
|
||||||
|
cb_file_link = gen_file_hash(full_cb_dir, cb_file)
|
||||||
|
build_path = os.path.join(app.config['BUILD_FOLDER'], cb_file_link)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.symlink(full_cb_path, build_path)
|
||||||
|
except FileExistsError:
|
||||||
|
# Symlink hasn't changed, ignore
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create mapping for relative path urls
|
||||||
|
map_path = build_path.replace(app.config['APP_ROOT'], '')
|
||||||
|
if map_path.startswith('/'):
|
||||||
|
map_path = map_path[1:]
|
||||||
|
app.config['CACHE_BUSTING_MAP'][cb_file] = map_path
|
||||||
|
|
||||||
|
# Optionally create simple bundled assets (opt-in via WHOOGLE_BUNDLE_STATIC=1)
|
||||||
|
if app.config['BUNDLE_STATIC']:
|
||||||
|
# CSS bundle: include all css except theme files (end with -theme.css)
|
||||||
|
css_dir = os.path.join(app.config['STATIC_FOLDER'], 'css')
|
||||||
|
css_parts = []
|
||||||
|
for name in sorted(os.listdir(css_dir)):
|
||||||
|
if not name.endswith('.css'):
|
||||||
|
continue
|
||||||
|
if name.endswith('-theme.css'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(os.path.join(css_dir, name), 'r', encoding='utf-8') as f:
|
||||||
|
css_parts.append(f.read())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
css_bundle = '\n'.join(css_parts)
|
||||||
|
if css_bundle:
|
||||||
|
css_tmp = os.path.join(app.config['BUILD_FOLDER'], 'app.css')
|
||||||
|
with open(css_tmp, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(css_bundle)
|
||||||
|
css_hashed = gen_file_hash(app.config['BUILD_FOLDER'], 'app.css')
|
||||||
|
os.replace(css_tmp, os.path.join(app.config['BUILD_FOLDER'], css_hashed))
|
||||||
|
map_path = os.path.join('app/static/build', css_hashed)
|
||||||
|
app.config['CACHE_BUSTING_MAP']['bundle.css'] = map_path
|
||||||
|
|
||||||
|
# JS bundle: include all js files
|
||||||
|
js_dir = os.path.join(app.config['STATIC_FOLDER'], 'js')
|
||||||
|
js_parts = []
|
||||||
|
for name in sorted(os.listdir(js_dir)):
|
||||||
|
if not name.endswith('.js'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(os.path.join(js_dir, name), 'r', encoding='utf-8') as f:
|
||||||
|
js_parts.append(f.read())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
js_bundle = '\n;'.join(js_parts)
|
||||||
|
if js_bundle:
|
||||||
|
js_tmp = os.path.join(app.config['BUILD_FOLDER'], 'app.js')
|
||||||
|
with open(js_tmp, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(js_bundle)
|
||||||
|
js_hashed = gen_file_hash(app.config['BUILD_FOLDER'], 'app.js')
|
||||||
|
os.replace(js_tmp, os.path.join(app.config['BUILD_FOLDER'], js_hashed))
|
||||||
|
map_path = os.path.join('app/static/build', js_hashed)
|
||||||
|
app.config['CACHE_BUSTING_MAP']['bundle.js'] = map_path
|
||||||
|
|
||||||
|
# Templating functions
|
||||||
|
app.jinja_env.globals.update(clean_query=clean_query)
|
||||||
|
app.jinja_env.globals.update(
|
||||||
|
cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f.lower()])
|
||||||
|
app.jinja_env.globals.update(
|
||||||
|
bundle_static=lambda: app.config.get('BUNDLE_STATIC', False))
|
||||||
|
|
||||||
# Attempt to acquire tor identity, to determine if Tor config is available
|
# Attempt to acquire tor identity, to determine if Tor config is available
|
||||||
send_tor_signal(Signal.HEARTBEAT)
|
send_tor_signal(Signal.HEARTBEAT)
|
||||||
|
|
||||||
|
# Suppress spurious warnings from BeautifulSoup
|
||||||
|
warnings.simplefilter('ignore', MarkupResemblesLocatorWarning)
|
||||||
|
|
||||||
from app import routes # noqa
|
from app import routes # noqa
|
||||||
|
|
||||||
|
# The gen_bangs_json function takes care of loading bangs, so skip it here if
|
||||||
|
# it's already being loaded
|
||||||
|
if not generating_bangs:
|
||||||
|
load_all_bangs(app.config['BANG_FILE'])
|
||||||
|
|
||||||
|
# Disable logging from imported modules
|
||||||
|
logging.config.dictConfig({
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': True,
|
||||||
|
})
|
||||||
|
|||||||
855
app/filter.py
855
app/filter.py
@ -1,28 +1,139 @@
|
|||||||
from app.request import VALID_PARAMS
|
import cssutils
|
||||||
from app.utils.results import *
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from bs4.element import ResultSet, Tag
|
from bs4.element import ResultSet, Tag
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
import re
|
import html
|
||||||
import urllib.parse as urlparse
|
import urllib.parse as urlparse
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.models.g_classes import GClasses
|
||||||
|
from app.request import VALID_PARAMS, MAPS_URL
|
||||||
|
from app.utils.misc import get_abs_url, read_config_bool
|
||||||
|
from app.utils.results import (
|
||||||
|
BLANK_B64, GOOG_IMG, GOOG_STATIC, G_M_LOGO_URL, LOGO_URL, SITE_ALTS,
|
||||||
|
has_ad_content, filter_link_args, append_anon_view, get_site_alt,
|
||||||
|
)
|
||||||
|
from app.models.endpoint import Endpoint
|
||||||
|
from app.models.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
MAPS_ARGS = ['q', 'daddr']
|
||||||
|
|
||||||
|
minimal_mode_sections = ['Top stories', 'Images', 'People also ask']
|
||||||
|
unsupported_g_pages = [
|
||||||
|
'support.google.com',
|
||||||
|
'accounts.google.com',
|
||||||
|
'policies.google.com',
|
||||||
|
'google.com/preferences',
|
||||||
|
'google.com/intl',
|
||||||
|
'advanced_search',
|
||||||
|
'tbm=shop',
|
||||||
|
'ageverification.google.co.kr'
|
||||||
|
]
|
||||||
|
|
||||||
|
unsupported_g_divs = ['google.com/preferences?hl=', 'ageverification.google.co.kr']
|
||||||
|
|
||||||
|
|
||||||
|
def extract_q(q_str: str, href: str) -> str:
|
||||||
|
"""Extracts the 'q' element from a result link. This is typically
|
||||||
|
either the link to a result's website, or a string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
q_str: The result link to parse
|
||||||
|
href: The full url to check for standalone 'q' elements first,
|
||||||
|
rather than parsing the whole query string and then checking.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The 'q' element of the link, or an empty string
|
||||||
|
"""
|
||||||
|
return parse_qs(q_str, keep_blank_values=True)['q'][0] if ('&q=' in href or '?q=' in href) else ''
|
||||||
|
|
||||||
|
|
||||||
|
def build_map_url(href: str) -> str:
|
||||||
|
"""Tries to extract known args that explain the location in the url. If a
|
||||||
|
location is found, returns the default url with it. Otherwise, returns the
|
||||||
|
url unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
href: The full url to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The parsed url, or the url unchanged.
|
||||||
|
"""
|
||||||
|
# parse the url
|
||||||
|
parsed_url = parse_qs(href)
|
||||||
|
# iterate through the known parameters and try build the url
|
||||||
|
for param in MAPS_ARGS:
|
||||||
|
if param in parsed_url:
|
||||||
|
return MAPS_URL + "?q=" + parsed_url[param][0]
|
||||||
|
|
||||||
|
# query could not be extracted returning unchanged url
|
||||||
|
return href
|
||||||
|
|
||||||
|
|
||||||
|
def clean_query(query: str) -> str:
|
||||||
|
"""Strips the blocked site list from the query, if one is being
|
||||||
|
used.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The query string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The query string without any "-site:..." filters
|
||||||
|
"""
|
||||||
|
return query[:query.find('-site:')] if '-site:' in query else query
|
||||||
|
|
||||||
|
|
||||||
|
def clean_css(css: str, page_url: str) -> str:
|
||||||
|
"""Removes all remote URLs from a CSS string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
css: The CSS string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The filtered CSS, with URLs proxied through Whoogle
|
||||||
|
"""
|
||||||
|
sheet = cssutils.parseString(css)
|
||||||
|
urls = cssutils.getUrls(sheet)
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
abs_url = get_abs_url(url, page_url)
|
||||||
|
if abs_url.startswith('data:'):
|
||||||
|
continue
|
||||||
|
css = css.replace(
|
||||||
|
url,
|
||||||
|
f'{Endpoint.element}?type=image/png&url={abs_url}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return css
|
||||||
|
|
||||||
|
|
||||||
class Filter:
|
class Filter:
|
||||||
def __init__(self, user_key: str, mobile=False, config=None) -> None:
|
# Limit used for determining if a result is a "regular" result or a list
|
||||||
if config is None:
|
# type result (such as "people also asked", "related searches", etc)
|
||||||
config = {}
|
RESULT_CHILD_LIMIT = 7
|
||||||
|
|
||||||
self.near = config['near'] if 'near' in config else ''
|
def __init__(
|
||||||
self.dark = config['dark'] if 'dark' in config else False
|
self,
|
||||||
self.nojs = config['nojs'] if 'nojs' in config else False
|
user_key: str,
|
||||||
self.new_tab = config['new_tab'] if 'new_tab' in config else False
|
config: Config,
|
||||||
self.alt_redirect = config['alts'] if 'alts' in config else False
|
root_url='',
|
||||||
|
page_url='',
|
||||||
|
query='',
|
||||||
|
mobile=False) -> None:
|
||||||
|
self.soup = None
|
||||||
|
self.config = config
|
||||||
self.mobile = mobile
|
self.mobile = mobile
|
||||||
self.user_key = user_key
|
self.user_key = user_key
|
||||||
|
self.page_url = page_url
|
||||||
|
self.query = query
|
||||||
self.main_divs = ResultSet('')
|
self.main_divs = ResultSet('')
|
||||||
self._elements = 0
|
self._elements = 0
|
||||||
|
self._av = set()
|
||||||
|
|
||||||
|
self.root_url = root_url[:-1] if root_url.endswith('/') else root_url
|
||||||
|
|
||||||
def __getitem__(self, name):
|
def __getitem__(self, name):
|
||||||
return getattr(self, name)
|
return getattr(self, name)
|
||||||
@ -31,15 +142,126 @@ class Filter:
|
|||||||
def elements(self):
|
def elements(self):
|
||||||
return self._elements
|
return self._elements
|
||||||
|
|
||||||
def reskin(self, page: str) -> str:
|
def convert_leta_to_whoogle(self, soup) -> BeautifulSoup:
|
||||||
# Aesthetic only re-skinning
|
"""Converts Leta search results HTML to Whoogle-compatible format
|
||||||
if self.dark:
|
|
||||||
page = page.replace(
|
|
||||||
'fff', '000').replace(
|
|
||||||
'202124', 'ddd').replace(
|
|
||||||
'1967D2', '3b85ea')
|
|
||||||
|
|
||||||
return page
|
Args:
|
||||||
|
soup: BeautifulSoup object containing Leta results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BeautifulSoup: Converted HTML in Whoogle format
|
||||||
|
"""
|
||||||
|
# Find all Leta result articles
|
||||||
|
articles = soup.find_all('article', class_='svelte-fmlk7p')
|
||||||
|
|
||||||
|
if not articles:
|
||||||
|
# No results found, return empty results page
|
||||||
|
return soup
|
||||||
|
|
||||||
|
# Create a new container for results with proper Whoogle CSS class
|
||||||
|
main_div = BeautifulSoup(features='html.parser').new_tag('div', attrs={'id': 'main'})
|
||||||
|
|
||||||
|
for article in articles:
|
||||||
|
# Extract data from Leta article
|
||||||
|
link_tag = article.find('a', href=True)
|
||||||
|
if not link_tag:
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = link_tag.get('href', '')
|
||||||
|
title_tag = article.find('h3')
|
||||||
|
title = title_tag.get_text(strip=True) if title_tag else ''
|
||||||
|
|
||||||
|
snippet_tag = article.find('p', class_='result__body')
|
||||||
|
snippet = snippet_tag.get_text(strip=True) if snippet_tag else ''
|
||||||
|
|
||||||
|
cite_tag = article.find('cite')
|
||||||
|
display_url = cite_tag.get_text(strip=True) if cite_tag else url
|
||||||
|
|
||||||
|
# Create Whoogle-style result div with proper CSS class
|
||||||
|
result_div = BeautifulSoup(features='html.parser').new_tag(
|
||||||
|
'div', attrs={'class': [GClasses.result_class_a]}
|
||||||
|
)
|
||||||
|
result_outer = BeautifulSoup(features='html.parser').new_tag('div')
|
||||||
|
|
||||||
|
# Create a div for the title link
|
||||||
|
title_div = BeautifulSoup(features='html.parser').new_tag('div')
|
||||||
|
result_link = BeautifulSoup(features='html.parser').new_tag('a', href=url)
|
||||||
|
result_title = BeautifulSoup(features='html.parser').new_tag('h3')
|
||||||
|
result_title.string = title
|
||||||
|
result_link.append(result_title)
|
||||||
|
title_div.append(result_link)
|
||||||
|
|
||||||
|
# Create a div for the URL display with cite
|
||||||
|
url_div = BeautifulSoup(features='html.parser').new_tag('div')
|
||||||
|
result_cite = BeautifulSoup(features='html.parser').new_tag('cite')
|
||||||
|
result_cite.string = display_url
|
||||||
|
url_div.append(result_cite)
|
||||||
|
|
||||||
|
# Create a div for snippet
|
||||||
|
result_snippet = BeautifulSoup(features='html.parser').new_tag('div')
|
||||||
|
snippet_span = BeautifulSoup(features='html.parser').new_tag('span')
|
||||||
|
snippet_span.string = snippet
|
||||||
|
result_snippet.append(snippet_span)
|
||||||
|
|
||||||
|
# Assemble the result with proper structure
|
||||||
|
result_outer.append(title_div)
|
||||||
|
result_outer.append(url_div)
|
||||||
|
result_outer.append(result_snippet)
|
||||||
|
result_div.append(result_outer)
|
||||||
|
main_div.append(result_div)
|
||||||
|
|
||||||
|
# Find and preserve pagination elements from Leta
|
||||||
|
navigation = soup.find('div', class_='navigation')
|
||||||
|
if navigation:
|
||||||
|
# Convert Leta's "Next" button to Whoogle-style pagination
|
||||||
|
next_button = navigation.find('button', attrs={'data-cy': 'next-button'})
|
||||||
|
if next_button:
|
||||||
|
next_form = next_button.find_parent('form')
|
||||||
|
if next_form:
|
||||||
|
# Extract the page number from hidden input
|
||||||
|
page_input = next_form.find('input', attrs={'name': 'page'})
|
||||||
|
if page_input:
|
||||||
|
next_page = page_input.get('value', '2')
|
||||||
|
# Create footer for pagination
|
||||||
|
footer = BeautifulSoup(features='html.parser').new_tag('footer')
|
||||||
|
nav_table = BeautifulSoup(features='html.parser').new_tag('table')
|
||||||
|
nav_tr = BeautifulSoup(features='html.parser').new_tag('tr')
|
||||||
|
nav_td = BeautifulSoup(features='html.parser').new_tag('td')
|
||||||
|
|
||||||
|
# Calculate start value for Whoogle pagination
|
||||||
|
start_val = (int(next_page) - 1) * 10
|
||||||
|
next_link = BeautifulSoup(features='html.parser').new_tag('a', href=f'search?q={self.query}&start={start_val}')
|
||||||
|
next_link.string = 'Next »'
|
||||||
|
|
||||||
|
nav_td.append(next_link)
|
||||||
|
nav_tr.append(nav_td)
|
||||||
|
nav_table.append(nav_tr)
|
||||||
|
footer.append(nav_table)
|
||||||
|
main_div.append(footer)
|
||||||
|
|
||||||
|
# Clear the original soup body and add our converted results
|
||||||
|
if soup.body:
|
||||||
|
soup.body.clear()
|
||||||
|
# Add inline style to body for proper width constraints
|
||||||
|
if not soup.body.get('style'):
|
||||||
|
soup.body['style'] = 'padding: 0 20px; margin: 0 auto; max-width: 1000px;'
|
||||||
|
soup.body.append(main_div)
|
||||||
|
else:
|
||||||
|
# If no body, create one with proper styling
|
||||||
|
new_body = BeautifulSoup(features='html.parser').new_tag(
|
||||||
|
'body',
|
||||||
|
attrs={'style': 'padding: 0 20px; margin: 0 auto; max-width: 1000px;'}
|
||||||
|
)
|
||||||
|
new_body.append(main_div)
|
||||||
|
if soup.html:
|
||||||
|
soup.html.append(new_body)
|
||||||
|
else:
|
||||||
|
# Create minimal HTML structure
|
||||||
|
html_tag = BeautifulSoup(features='html.parser').new_tag('html')
|
||||||
|
html_tag.append(new_body)
|
||||||
|
soup.append(html_tag)
|
||||||
|
|
||||||
|
return soup
|
||||||
|
|
||||||
def encrypt_path(self, path, is_element=False) -> str:
|
def encrypt_path(self, path, is_element=False) -> str:
|
||||||
# Encrypts path to avoid plaintext results in logs
|
# Encrypts path to avoid plaintext results in logs
|
||||||
@ -53,40 +275,157 @@ class Filter:
|
|||||||
return Fernet(self.user_key).encrypt(path.encode()).decode()
|
return Fernet(self.user_key).encrypt(path.encode()).decode()
|
||||||
|
|
||||||
def clean(self, soup) -> BeautifulSoup:
|
def clean(self, soup) -> BeautifulSoup:
|
||||||
self.main_divs = soup.find('div', {'id': 'main'})
|
self.soup = soup
|
||||||
self.remove_ads()
|
|
||||||
self.fix_question_section()
|
|
||||||
self.update_styling(soup)
|
|
||||||
|
|
||||||
for img in [_ for _ in soup.find_all('img') if 'src' in _.attrs]:
|
# Check if this is a Leta result page and convert it
|
||||||
|
if self.config.use_leta and self.soup.find('article', class_='svelte-fmlk7p'):
|
||||||
|
self.soup = self.convert_leta_to_whoogle(self.soup)
|
||||||
|
|
||||||
|
self.main_divs = self.soup.find('div', {'id': 'main'})
|
||||||
|
self.remove_ads()
|
||||||
|
self.remove_block_titles()
|
||||||
|
self.remove_block_url()
|
||||||
|
self.collapse_sections()
|
||||||
|
self.update_css()
|
||||||
|
self.update_styling()
|
||||||
|
self.remove_block_tabs()
|
||||||
|
|
||||||
|
# self.main_divs is only populated for the main page of search results
|
||||||
|
# (i.e. not images/news/etc).
|
||||||
|
if self.main_divs:
|
||||||
|
for div in self.main_divs:
|
||||||
|
self.sanitize_div(div)
|
||||||
|
|
||||||
|
for img in [_ for _ in self.soup.find_all('img') if 'src' in _.attrs]:
|
||||||
self.update_element_src(img, 'image/png')
|
self.update_element_src(img, 'image/png')
|
||||||
|
|
||||||
for audio in [_ for _ in soup.find_all('audio') if 'src' in _.attrs]:
|
for audio in [_ for _ in self.soup.find_all('audio') if 'src' in _.attrs]:
|
||||||
self.update_element_src(audio, 'audio/mpeg')
|
self.update_element_src(audio, 'audio/mpeg')
|
||||||
|
audio['controls'] = ''
|
||||||
|
|
||||||
for link in soup.find_all('a', href=True):
|
for link in self.soup.find_all('a', href=True):
|
||||||
self.update_link(link)
|
self.update_link(link)
|
||||||
|
self.add_favicon(link)
|
||||||
|
|
||||||
input_form = soup.find('form')
|
if self.config.alts:
|
||||||
|
self.site_alt_swap()
|
||||||
|
|
||||||
|
input_form = self.soup.find('form')
|
||||||
if input_form is not None:
|
if input_form is not None:
|
||||||
input_form['method'] = 'POST'
|
input_form['method'] = 'GET' if self.config.get_only else 'POST'
|
||||||
|
# Use a relative URI for submissions
|
||||||
|
input_form['action'] = 'search'
|
||||||
|
|
||||||
# Ensure no extra scripts passed through
|
# Ensure no extra scripts passed through
|
||||||
for script in soup('script'):
|
for script in self.soup('script'):
|
||||||
script.decompose()
|
script.decompose()
|
||||||
|
|
||||||
# Update default footer and header
|
# Update default footer and header
|
||||||
footer = soup.find('footer')
|
footer = self.soup.find('footer')
|
||||||
if footer:
|
if footer:
|
||||||
# Remove divs that have multiple links beyond just page navigation
|
# Remove divs that have multiple links beyond just page navigation
|
||||||
[_.decompose() for _ in footer.find_all('div', recursive=False)
|
[_.decompose() for _ in footer.find_all('div', recursive=False)
|
||||||
if len(_.find_all('a', href=True)) > 3]
|
if len(_.find_all('a', href=True)) > 3]
|
||||||
|
for link in footer.find_all('a', href=True):
|
||||||
|
link['href'] = f'{link["href"]}&preferences={self.config.preferences}'
|
||||||
|
|
||||||
header = soup.find('header')
|
header = self.soup.find('header')
|
||||||
if header:
|
if header:
|
||||||
header.decompose()
|
header.decompose()
|
||||||
|
self.remove_site_blocks(self.soup)
|
||||||
|
return self.soup
|
||||||
|
|
||||||
return soup
|
def sanitize_div(self, div) -> None:
|
||||||
|
"""Removes escaped script and iframe tags from results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None (The soup object is modified directly)
|
||||||
|
"""
|
||||||
|
if not div:
|
||||||
|
return
|
||||||
|
|
||||||
|
for d in div.find_all('div', recursive=True):
|
||||||
|
d_text = d.find(string=True, recursive=False)
|
||||||
|
|
||||||
|
# Ensure we're working with tags that contain text content
|
||||||
|
if not d_text or not d.string:
|
||||||
|
continue
|
||||||
|
|
||||||
|
d.string = html.unescape(d_text)
|
||||||
|
div_soup = BeautifulSoup(d.string, 'html.parser')
|
||||||
|
|
||||||
|
# Remove all valid script or iframe tags in the div
|
||||||
|
for script in div_soup.find_all('script'):
|
||||||
|
script.decompose()
|
||||||
|
|
||||||
|
for iframe in div_soup.find_all('iframe'):
|
||||||
|
iframe.decompose()
|
||||||
|
|
||||||
|
d.string = str(div_soup)
|
||||||
|
|
||||||
|
def add_favicon(self, link) -> None:
|
||||||
|
"""Adds icons for each returned result, using the result site's favicon
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None (The soup object is modified directly)
|
||||||
|
"""
|
||||||
|
# Skip empty, parentless, or internal links
|
||||||
|
show_favicons = read_config_bool('WHOOGLE_SHOW_FAVICONS', True)
|
||||||
|
is_valid_link = link and link.parent and link['href'].startswith('http')
|
||||||
|
if not show_favicons or not is_valid_link:
|
||||||
|
return
|
||||||
|
|
||||||
|
parent = link.parent
|
||||||
|
is_result_div = False
|
||||||
|
|
||||||
|
# Check each parent to make sure that the div doesn't already have a
|
||||||
|
# favicon attached, and that the div is a result div
|
||||||
|
while parent:
|
||||||
|
p_cls = parent.attrs.get('class') or []
|
||||||
|
if 'has-favicon' in p_cls or GClasses.scroller_class in p_cls:
|
||||||
|
return
|
||||||
|
elif GClasses.result_class_a not in p_cls:
|
||||||
|
parent = parent.parent
|
||||||
|
else:
|
||||||
|
is_result_div = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_result_div:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Construct the html for inserting the icon into the parent div
|
||||||
|
parsed = urlparse.urlparse(link['href'])
|
||||||
|
favicon = self.encrypt_path(
|
||||||
|
f'{parsed.scheme}://{parsed.netloc}/favicon.ico',
|
||||||
|
is_element=True)
|
||||||
|
src = f'{self.root_url}/{Endpoint.element}?url={favicon}' + \
|
||||||
|
'&type=image/x-icon'
|
||||||
|
html = f'<img class="site-favicon" src="{src}">'
|
||||||
|
|
||||||
|
favicon = BeautifulSoup(html, 'html.parser')
|
||||||
|
link.parent.insert(0, favicon)
|
||||||
|
|
||||||
|
# Update all parents to indicate that a favicon has been attached
|
||||||
|
parent = link.parent
|
||||||
|
while parent:
|
||||||
|
p_cls = parent.get('class') or []
|
||||||
|
p_cls.append('has-favicon')
|
||||||
|
parent['class'] = p_cls
|
||||||
|
parent = parent.parent
|
||||||
|
|
||||||
|
if GClasses.result_class_a in p_cls:
|
||||||
|
break
|
||||||
|
|
||||||
|
def remove_site_blocks(self, soup) -> None:
|
||||||
|
if not self.config.block or not soup.body:
|
||||||
|
return
|
||||||
|
search_string = ' '.join(['-site:' +
|
||||||
|
_ for _ in self.config.block.split(',')])
|
||||||
|
selected = soup.body.find_all(string=re.compile(search_string))
|
||||||
|
|
||||||
|
for result in selected:
|
||||||
|
result.string.replace_with(result.string.replace(
|
||||||
|
search_string, ''))
|
||||||
|
|
||||||
def remove_ads(self) -> None:
|
def remove_ads(self) -> None:
|
||||||
"""Removes ads found in the list of search result divs
|
"""Removes ads found in the list of search result divs
|
||||||
@ -102,43 +441,124 @@ class Filter:
|
|||||||
if has_ad_content(_.text)]
|
if has_ad_content(_.text)]
|
||||||
_ = div.decompose() if len(div_ads) else None
|
_ = div.decompose() if len(div_ads) else None
|
||||||
|
|
||||||
def fix_question_section(self) -> None:
|
def remove_block_titles(self) -> None:
|
||||||
"""Collapses the "People Also Asked" section into a "details" element
|
if not self.main_divs or not self.config.block_title:
|
||||||
|
return
|
||||||
|
block_title = re.compile(self.config.block_title)
|
||||||
|
for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]:
|
||||||
|
block_divs = [_ for _ in div.find_all('h3', recursive=True)
|
||||||
|
if block_title.search(_.text) is not None]
|
||||||
|
_ = div.decompose() if len(block_divs) else None
|
||||||
|
|
||||||
|
def remove_block_url(self) -> None:
|
||||||
|
if not self.main_divs or not self.config.block_url:
|
||||||
|
return
|
||||||
|
block_url = re.compile(self.config.block_url)
|
||||||
|
for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]:
|
||||||
|
block_divs = [_ for _ in div.find_all('a', recursive=True)
|
||||||
|
if block_url.search(_.attrs['href']) is not None]
|
||||||
|
_ = div.decompose() if len(block_divs) else None
|
||||||
|
|
||||||
|
def remove_block_tabs(self) -> None:
|
||||||
|
if self.main_divs:
|
||||||
|
for div in self.main_divs.find_all(
|
||||||
|
'div',
|
||||||
|
attrs={'class': f'{GClasses.main_tbm_tab}'}
|
||||||
|
):
|
||||||
|
_ = div.decompose()
|
||||||
|
else:
|
||||||
|
# when in images tab
|
||||||
|
for div in self.soup.find_all(
|
||||||
|
'div',
|
||||||
|
attrs={'class': f'{GClasses.images_tbm_tab}'}
|
||||||
|
):
|
||||||
|
_ = div.decompose()
|
||||||
|
|
||||||
|
def collapse_sections(self) -> None:
|
||||||
|
"""Collapses long result sections ("people also asked", "related
|
||||||
|
searches", etc) into "details" elements
|
||||||
|
|
||||||
These sections are typically the only sections in the results page that
|
These sections are typically the only sections in the results page that
|
||||||
are structured as <div><h2>Title</h2><div>...</div></div>, so they are
|
have more than ~5 child divs within a primary result div.
|
||||||
extracted by checking all result divs for h2 children.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None (The soup object is modified directly)
|
None (The soup object is modified directly)
|
||||||
"""
|
"""
|
||||||
|
minimal_mode = read_config_bool('WHOOGLE_MINIMAL')
|
||||||
|
|
||||||
|
def pull_child_divs(result_div: BeautifulSoup):
|
||||||
|
try:
|
||||||
|
top_level_divs = result_div.find_all('div', recursive=False)
|
||||||
|
if not top_level_divs:
|
||||||
|
return []
|
||||||
|
return top_level_divs[0].find_all('div', recursive=False)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
if not self.main_divs:
|
if not self.main_divs:
|
||||||
return
|
return
|
||||||
|
|
||||||
question_divs = [_ for _ in self.main_divs.find_all(
|
# Loop through results and check for the number of child divs in each
|
||||||
'div', recursive=False
|
for result in self.main_divs.find_all():
|
||||||
) if len(_.find_all('h2')) > 0]
|
result_children = pull_child_divs(result)
|
||||||
|
if minimal_mode:
|
||||||
|
if any(f">{x}</span" in str(s) for s in result_children
|
||||||
|
for x in minimal_mode_sections):
|
||||||
|
result.decompose()
|
||||||
|
continue
|
||||||
|
for s in result_children:
|
||||||
|
if ('Twitter ›' in str(s)):
|
||||||
|
result.decompose()
|
||||||
|
continue
|
||||||
|
if len(result_children) < self.RESULT_CHILD_LIMIT:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if len(result_children) < self.RESULT_CHILD_LIMIT:
|
||||||
|
continue
|
||||||
|
|
||||||
if len(question_divs) == 0:
|
# Find and decompose the first element with an inner HTML text val.
|
||||||
return
|
# This typically extracts the title of the section (i.e. "Related
|
||||||
|
# Searches", "People also ask", etc)
|
||||||
|
# If there are more than one child tags with text
|
||||||
|
# parenthesize the rest except the first
|
||||||
|
label = 'Collapsed Results'
|
||||||
|
subtitle = None
|
||||||
|
for elem in result_children:
|
||||||
|
if elem.text:
|
||||||
|
content = list(elem.strings)
|
||||||
|
label = content[0]
|
||||||
|
if len(content) > 1:
|
||||||
|
subtitle = '<span> (' + \
|
||||||
|
''.join(content[1:]) + ')</span>'
|
||||||
|
elem.decompose()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Create the new details element to wrap around the result's
|
||||||
|
# first parent
|
||||||
|
parent = None
|
||||||
|
idx = 0
|
||||||
|
while not parent and idx < len(result_children):
|
||||||
|
parent = result_children[idx].parent
|
||||||
|
idx += 1
|
||||||
|
|
||||||
# Wrap section in details element to allow collapse/expand
|
|
||||||
details = BeautifulSoup(features='html.parser').new_tag('details')
|
details = BeautifulSoup(features='html.parser').new_tag('details')
|
||||||
summary = BeautifulSoup(features='html.parser').new_tag('summary')
|
summary = BeautifulSoup(features='html.parser').new_tag('summary')
|
||||||
summary.string = question_divs[0].find('h2').text
|
summary.string = label
|
||||||
question_divs[0].find('h2').decompose()
|
|
||||||
|
if subtitle:
|
||||||
|
soup = BeautifulSoup(subtitle, 'html.parser')
|
||||||
|
summary.append(soup)
|
||||||
|
|
||||||
details.append(summary)
|
details.append(summary)
|
||||||
question_divs[0].wrap(details)
|
|
||||||
|
|
||||||
for question_div in question_divs:
|
if parent and not minimal_mode:
|
||||||
questions = [_ for _ in question_div.find_all(
|
parent.wrap(details)
|
||||||
'div', recursive=True
|
elif parent and minimal_mode:
|
||||||
) if _.text.endswith('?')]
|
# Remove parent element from document if "minimal mode" is
|
||||||
|
# enabled
|
||||||
|
parent.decompose()
|
||||||
|
|
||||||
for question in questions:
|
def update_element_src(self, element: Tag, mime: str, attr='src') -> None:
|
||||||
question['style'] = 'padding: 10px; font-style: italic;'
|
|
||||||
|
|
||||||
def update_element_src(self, element: Tag, mime: str) -> None:
|
|
||||||
"""Encrypts the original src of an element and rewrites the element src
|
"""Encrypts the original src of an element and rewrites the element src
|
||||||
to use the "/element?src=" pass-through.
|
to use the "/element?src=" pass-through.
|
||||||
|
|
||||||
@ -146,35 +566,66 @@ class Filter:
|
|||||||
None (The soup element is modified directly)
|
None (The soup element is modified directly)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
src = element['src']
|
src = element[attr].split(' ')[0]
|
||||||
|
|
||||||
if src.startswith('//'):
|
if src.startswith('//'):
|
||||||
src = 'https:' + src
|
src = 'https:' + src
|
||||||
|
elif src.startswith('data:'):
|
||||||
|
return
|
||||||
|
|
||||||
if src.startswith(LOGO_URL):
|
if src.startswith(LOGO_URL):
|
||||||
# Re-brand with Whoogle logo
|
# Re-brand with Whoogle logo
|
||||||
element.replace_with(BeautifulSoup(render_template('logo.html')))
|
element.replace_with(BeautifulSoup(
|
||||||
|
render_template('logo.html'),
|
||||||
|
features='html.parser'))
|
||||||
|
return
|
||||||
|
elif src.startswith(G_M_LOGO_URL):
|
||||||
|
# Re-brand with single-letter Whoogle logo
|
||||||
|
element['src'] = 'static/img/favicon/apple-icon.png'
|
||||||
|
element.parent['href'] = 'home'
|
||||||
return
|
return
|
||||||
elif src.startswith(GOOG_IMG) or GOOG_STATIC in src:
|
elif src.startswith(GOOG_IMG) or GOOG_STATIC in src:
|
||||||
element['src'] = BLANK_B64
|
element['src'] = BLANK_B64
|
||||||
return
|
return
|
||||||
|
|
||||||
element['src'] = 'element?url=' + self.encrypt_path(
|
element[attr] = f'{self.root_url}/{Endpoint.element}?url=' + (
|
||||||
|
self.encrypt_path(
|
||||||
src,
|
src,
|
||||||
is_element=True) + '&type=' + urlparse.quote(mime)
|
is_element=True
|
||||||
|
) + '&type=' + urlparse.quote(mime)
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_css(self) -> None:
|
||||||
|
"""Updates URLs used in inline styles to be proxied by Whoogle
|
||||||
|
using the /element endpoint.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None (The soup element is modified directly)
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Filter all <style> tags
|
||||||
|
for style in self.soup.find_all('style'):
|
||||||
|
style.string = clean_css(style.string, self.page_url)
|
||||||
|
|
||||||
|
# TODO: Convert remote stylesheets to style tags and proxy all
|
||||||
|
# remote requests
|
||||||
|
# for link in soup.find_all('link', attrs={'rel': 'stylesheet'}):
|
||||||
|
# print(link)
|
||||||
|
|
||||||
|
def update_styling(self) -> None:
|
||||||
|
# Update CSS classes for result divs
|
||||||
|
soup = GClasses.replace_css_classes(self.soup)
|
||||||
|
|
||||||
def update_styling(self, soup) -> None:
|
|
||||||
""""""
|
|
||||||
# Remove unnecessary button(s)
|
# Remove unnecessary button(s)
|
||||||
for button in soup.find_all('button'):
|
for button in self.soup.find_all('button'):
|
||||||
button.decompose()
|
button.decompose()
|
||||||
|
|
||||||
# Remove svg logos
|
# Remove svg logos
|
||||||
for svg in soup.find_all('svg'):
|
for svg in self.soup.find_all('svg'):
|
||||||
svg.decompose()
|
svg.decompose()
|
||||||
|
|
||||||
# Update logo
|
# Update logo
|
||||||
logo = soup.find('a', {'class': 'l'})
|
logo = self.soup.find('a', {'class': 'l'})
|
||||||
if logo and self.mobile:
|
if logo and self.mobile:
|
||||||
logo['style'] = ('display:flex; justify-content:center; '
|
logo['style'] = ('display:flex; justify-content:center; '
|
||||||
'align-items:center; color:#685e79; '
|
'align-items:center; color:#685e79; '
|
||||||
@ -182,11 +633,32 @@ class Filter:
|
|||||||
|
|
||||||
# Fix search bar length on mobile
|
# Fix search bar length on mobile
|
||||||
try:
|
try:
|
||||||
search_bar = soup.find('header').find('form').find('div')
|
search_bar = self.soup.find('header').find('form').find('div')
|
||||||
search_bar['style'] = 'width: 100%;'
|
search_bar['style'] = 'width: 100%;'
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Fix body max width on images tab
|
||||||
|
style = self.soup.find('style')
|
||||||
|
div = self.soup.find('div', attrs={
|
||||||
|
'class': f'{GClasses.images_tbm_tab}'})
|
||||||
|
if style and div and not self.mobile:
|
||||||
|
css = style.string
|
||||||
|
css_html_tag = (
|
||||||
|
'html{'
|
||||||
|
'font-family: Roboto, Helvetica Neue, Arial, sans-serif;'
|
||||||
|
'font-size: 14px;'
|
||||||
|
'line-height: 20px;'
|
||||||
|
'text-size-adjust: 100%;'
|
||||||
|
'word-wrap: break-word;'
|
||||||
|
'}'
|
||||||
|
)
|
||||||
|
css = f"{css_html_tag}{css}"
|
||||||
|
css = re.sub('body{(.*?)}',
|
||||||
|
'body{padding:0 8px;margin:0 auto;max-width:736px;}',
|
||||||
|
css)
|
||||||
|
style.string = css
|
||||||
|
|
||||||
def update_link(self, link: Tag) -> None:
|
def update_link(self, link: Tag) -> None:
|
||||||
"""Update internal link paths with encrypted path, otherwise remove
|
"""Update internal link paths with encrypted path, otherwise remove
|
||||||
unnecessary redirects and/or marketing params from the url
|
unnecessary redirects and/or marketing params from the url
|
||||||
@ -198,31 +670,64 @@ class Filter:
|
|||||||
None (the tag is updated directly)
|
None (the tag is updated directly)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Replace href with only the intended destination (no "utm" type tags)
|
parsed_link = urlparse.urlparse(link['href'])
|
||||||
href = link['href'].replace('https://www.google.com', '')
|
if '/url?q=' in link['href']:
|
||||||
if 'advanced_search' in href or 'tbm=shop' in href:
|
link_netloc = extract_q(parsed_link.query, link['href'])
|
||||||
|
else:
|
||||||
|
link_netloc = parsed_link.netloc
|
||||||
|
|
||||||
|
# Remove any elements that direct to unsupported Google pages
|
||||||
|
if any(url in link_netloc for url in unsupported_g_pages):
|
||||||
# FIXME: The "Shopping" tab requires further filtering (see #136)
|
# FIXME: The "Shopping" tab requires further filtering (see #136)
|
||||||
# Temporarily removing all links to that tab for now.
|
# Temporarily removing all links to that tab for now.
|
||||||
|
|
||||||
|
# Replaces the /url google unsupported link to the direct url
|
||||||
|
link['href'] = link_netloc
|
||||||
|
parent = link.parent
|
||||||
|
|
||||||
|
if any(divlink in link_netloc for divlink in unsupported_g_divs):
|
||||||
|
# Handle case where a search is performed in a different
|
||||||
|
# language than what is configured. This usually returns a
|
||||||
|
# div with the same classes as normal search results, but with
|
||||||
|
# a link to configure language preferences through Google.
|
||||||
|
# Since we want all language config done through Whoogle, we
|
||||||
|
# can safely decompose this element.
|
||||||
|
while parent:
|
||||||
|
p_cls = parent.attrs.get('class') or []
|
||||||
|
if f'{GClasses.result_class_a}' in p_cls:
|
||||||
|
parent.decompose()
|
||||||
|
break
|
||||||
|
parent = parent.parent
|
||||||
|
else:
|
||||||
|
# Remove cases where google links appear in the footer
|
||||||
|
while parent:
|
||||||
|
p_cls = parent.attrs.get('class') or []
|
||||||
|
if parent.name == 'footer' or f'{GClasses.footer}' in p_cls:
|
||||||
link.decompose()
|
link.decompose()
|
||||||
|
parent = parent.parent
|
||||||
|
|
||||||
|
if link.decomposed:
|
||||||
return
|
return
|
||||||
elif self.new_tab:
|
|
||||||
link['target'] = '_blank'
|
|
||||||
|
|
||||||
|
# Replace href with only the intended destination (no "utm" type tags)
|
||||||
|
href = link['href'].replace('https://www.google.com', '')
|
||||||
result_link = urlparse.urlparse(href)
|
result_link = urlparse.urlparse(href)
|
||||||
query_link = parse_qs(
|
q = extract_q(result_link.query, href)
|
||||||
result_link.query
|
|
||||||
)['q'][0] if '?q=' in href else ''
|
|
||||||
|
|
||||||
if query_link.startswith('/'):
|
if q.startswith('/') and q not in self.query and 'spell=1' not in href:
|
||||||
# Internal google links (i.e. mail, maps, etc) should still
|
# Internal google links (i.e. mail, maps, etc) should still
|
||||||
# be forwarded to Google
|
# be forwarded to Google
|
||||||
link['href'] = 'https://google.com' + query_link
|
link['href'] = 'https://google.com' + q
|
||||||
|
elif q.startswith('https://accounts.google.com'):
|
||||||
|
# Remove Sign-in link
|
||||||
|
link.decompose()
|
||||||
|
return
|
||||||
elif '/search?q=' in href:
|
elif '/search?q=' in href:
|
||||||
# "li:1" implies the query should be interpreted verbatim,
|
# "li:1" implies the query should be interpreted verbatim,
|
||||||
# which is accomplished by wrapping the query in double quotes
|
# which is accomplished by wrapping the query in double quotes
|
||||||
if 'li:1' in href:
|
if 'li:1' in href:
|
||||||
query_link = '"' + query_link + '"'
|
q = '"' + q + '"'
|
||||||
new_search = 'search?q=' + self.encrypt_path(query_link)
|
new_search = 'search?q=' + self.encrypt_path(q)
|
||||||
|
|
||||||
query_params = parse_qs(urlparse.urlparse(href).query)
|
query_params = parse_qs(urlparse.urlparse(href).query)
|
||||||
for param in VALID_PARAMS:
|
for param in VALID_PARAMS:
|
||||||
@ -233,23 +738,193 @@ class Filter:
|
|||||||
link['href'] = new_search
|
link['href'] = new_search
|
||||||
elif 'url?q=' in href:
|
elif 'url?q=' in href:
|
||||||
# Strip unneeded arguments
|
# Strip unneeded arguments
|
||||||
link['href'] = filter_link_args(query_link)
|
link['href'] = filter_link_args(q)
|
||||||
|
|
||||||
# Add no-js option
|
# Add alternate viewing options for results,
|
||||||
if self.nojs:
|
# if the result doesn't already have an AV link
|
||||||
append_nojs(link)
|
netloc = urlparse.urlparse(link['href']).netloc
|
||||||
|
if self.config.anon_view and netloc not in self._av:
|
||||||
|
self._av.add(netloc)
|
||||||
|
append_anon_view(link, self.config)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if href.startswith(MAPS_URL):
|
||||||
|
# Maps links don't work if a site filter is applied
|
||||||
|
link['href'] = build_map_url(link['href'])
|
||||||
|
elif (href.startswith('/?') or href.startswith('/search?') or
|
||||||
|
href.startswith('/imgres?')):
|
||||||
|
# make sure that tags can be clicked as relative URLs
|
||||||
|
link['href'] = href[1:]
|
||||||
|
elif href.startswith('/intl/'):
|
||||||
|
# do nothing, keep original URL for ToS
|
||||||
|
pass
|
||||||
|
elif href.startswith('/preferences'):
|
||||||
|
# there is no config specific URL, remove this
|
||||||
|
link.decompose()
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
link['href'] = href
|
link['href'] = href
|
||||||
|
|
||||||
# Replace link location if "alts" config is enabled
|
if self.config.new_tab and (
|
||||||
if self.alt_redirect:
|
link["href"].startswith("http")
|
||||||
# Search and replace all link descriptions
|
or link["href"].startswith("imgres?")
|
||||||
# with alternative location
|
):
|
||||||
link['href'] = get_site_alt(link['href'])
|
link["target"] = "_blank"
|
||||||
link_desc = link.find_all(
|
|
||||||
text=re.compile('|'.join(SITE_ALTS.keys())))
|
|
||||||
if len(link_desc) == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Replace link destination
|
def site_alt_swap(self) -> None:
|
||||||
link_desc[0].replace_with(get_site_alt(link_desc[0]))
|
"""Replaces link locations and page elements if "alts" config
|
||||||
|
is enabled
|
||||||
|
"""
|
||||||
|
# Precompute regex for sites (escape dots) and common prefixes
|
||||||
|
site_keys = list(SITE_ALTS.keys())
|
||||||
|
if not site_keys:
|
||||||
|
return
|
||||||
|
sites_pattern = re.compile('|'.join([re.escape(k) for k in site_keys]))
|
||||||
|
prefix_pattern = re.compile(r'^(?:https?:\/\/)?(?:(?:www|mobile|m)\.)?')
|
||||||
|
|
||||||
|
# 1) Replace bare domain divs (single token) once, avoiding duplicates
|
||||||
|
for div in self.soup.find_all('div', string=sites_pattern):
|
||||||
|
if not div or not div.string:
|
||||||
|
continue
|
||||||
|
if len(div.string.split(' ')) != 1:
|
||||||
|
continue
|
||||||
|
match = sites_pattern.search(div.string)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
site = match.group(0)
|
||||||
|
alt = SITE_ALTS.get(site, '')
|
||||||
|
if not alt:
|
||||||
|
continue
|
||||||
|
# Skip if already contains the alt to avoid old.old.* repetition
|
||||||
|
if alt in div.string:
|
||||||
|
continue
|
||||||
|
div.string = div.string.replace(site, alt)
|
||||||
|
|
||||||
|
# 2) Update link hrefs and descriptions in a single pass
|
||||||
|
for link in self.soup.find_all('a', href=True):
|
||||||
|
link['href'] = get_site_alt(link['href'])
|
||||||
|
|
||||||
|
# Find a description text node matching a known site
|
||||||
|
desc_nodes = link.find_all(string=sites_pattern)
|
||||||
|
if not desc_nodes:
|
||||||
|
continue
|
||||||
|
desc_node = desc_nodes[0]
|
||||||
|
link_str = str(desc_node)
|
||||||
|
|
||||||
|
# Determine which site key is present in the description
|
||||||
|
site_match = sites_pattern.search(link_str)
|
||||||
|
if not site_match:
|
||||||
|
continue
|
||||||
|
site = site_match.group(0)
|
||||||
|
alt = SITE_ALTS.get(site, '')
|
||||||
|
if not alt:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Avoid duplication if alt already present
|
||||||
|
if alt in link_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Medium-specific handling remains to avoid matching substrings
|
||||||
|
if 'medium.com' in link_str:
|
||||||
|
if link_str.startswith('medium.com') or '.medium.com' in link_str:
|
||||||
|
replaced = SITE_ALTS['medium.com'] + link_str[
|
||||||
|
link_str.find('medium.com') + len('medium.com'):
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
replaced = link_str
|
||||||
|
else:
|
||||||
|
# If the description looks like a URL with scheme, replace only the host
|
||||||
|
if '://' in link_str:
|
||||||
|
scheme, rest = link_str.split('://', 1)
|
||||||
|
host, sep, path = rest.partition('/')
|
||||||
|
# Drop common prefixes from host when swapping to a fully-qualified alt
|
||||||
|
alt_parsed = urlparse.urlparse(alt)
|
||||||
|
alt_host = alt_parsed.netloc if alt_parsed.netloc else alt.replace('https://', '').replace('http://', '')
|
||||||
|
# If alt includes a scheme, prefer its host; otherwise use alt as host
|
||||||
|
if alt_parsed.scheme:
|
||||||
|
new_host = alt_host
|
||||||
|
else:
|
||||||
|
# When alt has no scheme, still replace entire host
|
||||||
|
new_host = alt
|
||||||
|
# Prevent replacing if host already equals target
|
||||||
|
if host == new_host:
|
||||||
|
replaced = link_str
|
||||||
|
else:
|
||||||
|
replaced = f"{scheme}://{new_host}{sep}{path}"
|
||||||
|
else:
|
||||||
|
# No scheme in the text; include optional prefixes in replacement
|
||||||
|
# Replace any leading www./m./mobile. + site with alt host (no scheme)
|
||||||
|
alt_parsed = urlparse.urlparse(alt)
|
||||||
|
alt_host = alt_parsed.netloc if alt_parsed.netloc else alt.replace('https://', '').replace('http://', '')
|
||||||
|
# Build a pattern that includes optional prefixes for the specific site
|
||||||
|
site_with_prefix = re.compile(rf'(?:(?:www|mobile|m)\.)?{re.escape(site)}')
|
||||||
|
replaced = site_with_prefix.sub(alt_host, link_str, count=1)
|
||||||
|
|
||||||
|
new_desc = BeautifulSoup(features='html.parser').new_tag('div')
|
||||||
|
new_desc.string = replaced
|
||||||
|
desc_node.replace_with(new_desc)
|
||||||
|
|
||||||
|
def view_image(self, soup) -> BeautifulSoup:
|
||||||
|
"""Replaces the soup with a new one that handles mobile results and
|
||||||
|
adds the link of the image full res to the results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: A BeautifulSoup object containing the image mobile results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BeautifulSoup: The new BeautifulSoup object
|
||||||
|
"""
|
||||||
|
|
||||||
|
# get some tags that are unchanged between mobile and pc versions
|
||||||
|
cor_suggested = soup.find_all('table', attrs={'class': "By0U9"})
|
||||||
|
next_pages = soup.find('table', attrs={'class': "uZgmoc"})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
# find results div
|
||||||
|
results_div = soup.find('div', attrs={'class': "nQvrDb"})
|
||||||
|
# find all the results (if any)
|
||||||
|
results_all = []
|
||||||
|
if results_div:
|
||||||
|
results_all = results_div.find_all('div', attrs={'class': "lIMUZd"})
|
||||||
|
|
||||||
|
for item in results_all:
|
||||||
|
urls = item.find('a')['href'].split('&imgrefurl=')
|
||||||
|
|
||||||
|
# Skip urls that are not two-element lists
|
||||||
|
if len(urls) != 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
img_url = urlparse.unquote(urls[0].replace(
|
||||||
|
f'/{Endpoint.imgres}?imgurl=', ''))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to strip out only the necessary part of the web page link
|
||||||
|
web_page = urlparse.unquote(urls[1].split('&')[0])
|
||||||
|
except IndexError:
|
||||||
|
web_page = urlparse.unquote(urls[1])
|
||||||
|
|
||||||
|
img_tbn = urlparse.unquote(item.find('a').find('img')['src'])
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'domain': urlparse.urlparse(web_page).netloc,
|
||||||
|
'img_url': img_url,
|
||||||
|
'web_page': web_page,
|
||||||
|
'img_tbn': img_tbn
|
||||||
|
})
|
||||||
|
|
||||||
|
soup = BeautifulSoup(render_template('imageresults.html',
|
||||||
|
length=len(results),
|
||||||
|
results=results,
|
||||||
|
view_label="View Image"),
|
||||||
|
features='html.parser')
|
||||||
|
|
||||||
|
# replace correction suggested by google object if exists
|
||||||
|
if len(cor_suggested):
|
||||||
|
soup.find_all(
|
||||||
|
'table',
|
||||||
|
attrs={'class': "By0U9"}
|
||||||
|
)[0].replaceWith(cor_suggested[0])
|
||||||
|
# replace next page object at the bottom of the page
|
||||||
|
soup.find_all('table',
|
||||||
|
attrs={'class': "uZgmoc"})[0].replaceWith(next_pages)
|
||||||
|
return soup
|
||||||
|
|||||||
@ -1,35 +1,111 @@
|
|||||||
|
from inspect import Attribute
|
||||||
|
from typing import Optional
|
||||||
|
from app.utils.misc import read_config_bool
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
import os
|
import os
|
||||||
|
from base64 import urlsafe_b64encode, urlsafe_b64decode
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
import hashlib
|
||||||
|
import brotli
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
import cssutils
|
||||||
|
from cssutils.css.cssstylesheet import CSSStyleSheet
|
||||||
|
from cssutils.css.cssstylerule import CSSStyleRule
|
||||||
|
|
||||||
|
# removes warnings from cssutils
|
||||||
|
cssutils.log.setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rule_for_selector(stylesheet: CSSStyleSheet,
|
||||||
|
selector: str) -> Optional[CSSStyleRule]:
|
||||||
|
"""Search for a rule that matches a given selector in a stylesheet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stylesheet (CSSStyleSheet) -- the stylesheet to search
|
||||||
|
selector (str) -- the selector to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[CSSStyleRule] -- the rule that matches the selector or None
|
||||||
|
"""
|
||||||
|
for rule in stylesheet.cssRules:
|
||||||
|
if hasattr(rule, "selectorText") and selector == rule.selectorText:
|
||||||
|
return rule
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
app_config = current_app.config
|
# User agent configuration - default to env_conf if environment variables exist, otherwise default
|
||||||
self.url = os.getenv('WHOOGLE_CONFIG_URL', '')
|
env_user_agent = os.getenv('WHOOGLE_USER_AGENT', '')
|
||||||
self.lang_search = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '')
|
env_mobile_agent = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')
|
||||||
self.lang_interface = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '')
|
default_ua_option = 'env_conf' if (env_user_agent or env_mobile_agent) else 'default'
|
||||||
self.style = open(os.path.join(app_config['STATIC_FOLDER'],
|
|
||||||
'css/variables.css')).read()
|
self.user_agent = kwargs.get('user_agent', default_ua_option)
|
||||||
self.ctry = os.getenv('WHOOGLE_CONFIG_COUNTRY', '')
|
self.custom_user_agent = kwargs.get('custom_user_agent', '')
|
||||||
self.safe = bool(os.getenv('WHOOGLE_CONFIG_SAFE', False))
|
self.use_custom_user_agent = kwargs.get('use_custom_user_agent', False)
|
||||||
self.dark = bool(os.getenv('WHOOGLE_CONFIG_DARK', False))
|
|
||||||
self.alts = bool(os.getenv('WHOOGLE_CONFIG_ALTS', False))
|
# Add user agent related keys to safe_keys
|
||||||
self.nojs = bool(os.getenv('WHOOGLE_CONFIG_NOJS', False))
|
|
||||||
self.tor = bool(os.getenv('WHOOGLE_CONFIG_TOR', False))
|
|
||||||
self.near = os.getenv('WHOOGLE_CONFIG_NEAR', '')
|
|
||||||
self.new_tab = bool(os.getenv('WHOOGLE_CONFIG_NEW_TAB', False))
|
|
||||||
self.get_only = bool(os.getenv('WHOOGLE_CONFIG_GET_ONLY', False))
|
|
||||||
self.safe_keys = [
|
self.safe_keys = [
|
||||||
'lang_search',
|
'lang_search',
|
||||||
'lang_interface',
|
'lang_interface',
|
||||||
'ctry',
|
'country',
|
||||||
'dark'
|
'theme',
|
||||||
|
'alts',
|
||||||
|
'new_tab',
|
||||||
|
'view_image',
|
||||||
|
'block',
|
||||||
|
'safe',
|
||||||
|
'nojs',
|
||||||
|
'anon_view',
|
||||||
|
'preferences_encrypted',
|
||||||
|
'tbs',
|
||||||
|
'user_agent',
|
||||||
|
'custom_user_agent',
|
||||||
|
'use_custom_user_agent',
|
||||||
|
'use_leta'
|
||||||
]
|
]
|
||||||
|
|
||||||
for key, value in kwargs.items():
|
app_config = current_app.config
|
||||||
if not value:
|
self.url = os.getenv('WHOOGLE_CONFIG_URL', '')
|
||||||
continue
|
self.lang_search = os.getenv('WHOOGLE_CONFIG_SEARCH_LANGUAGE', '')
|
||||||
setattr(self, key, value)
|
self.lang_interface = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '')
|
||||||
|
self.style_modified = os.getenv(
|
||||||
|
'WHOOGLE_CONFIG_STYLE', '')
|
||||||
|
self.block = os.getenv('WHOOGLE_CONFIG_BLOCK', '')
|
||||||
|
self.block_title = os.getenv('WHOOGLE_CONFIG_BLOCK_TITLE', '')
|
||||||
|
self.block_url = os.getenv('WHOOGLE_CONFIG_BLOCK_URL', '')
|
||||||
|
self.country = os.getenv('WHOOGLE_CONFIG_COUNTRY', '')
|
||||||
|
self.tbs = os.getenv('WHOOGLE_CONFIG_TIME_PERIOD', '')
|
||||||
|
self.theme = os.getenv('WHOOGLE_CONFIG_THEME', 'system')
|
||||||
|
self.safe = read_config_bool('WHOOGLE_CONFIG_SAFE')
|
||||||
|
self.dark = read_config_bool('WHOOGLE_CONFIG_DARK') # deprecated
|
||||||
|
self.alts = read_config_bool('WHOOGLE_CONFIG_ALTS')
|
||||||
|
self.nojs = read_config_bool('WHOOGLE_CONFIG_NOJS')
|
||||||
|
self.tor = read_config_bool('WHOOGLE_CONFIG_TOR')
|
||||||
|
self.near = os.getenv('WHOOGLE_CONFIG_NEAR', '')
|
||||||
|
self.new_tab = read_config_bool('WHOOGLE_CONFIG_NEW_TAB')
|
||||||
|
self.view_image = read_config_bool('WHOOGLE_CONFIG_VIEW_IMAGE')
|
||||||
|
self.get_only = read_config_bool('WHOOGLE_CONFIG_GET_ONLY')
|
||||||
|
self.anon_view = read_config_bool('WHOOGLE_CONFIG_ANON_VIEW')
|
||||||
|
self.preferences_encrypted = read_config_bool('WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED')
|
||||||
|
self.preferences_key = os.getenv('WHOOGLE_CONFIG_PREFERENCES_KEY', '')
|
||||||
|
self.use_leta = read_config_bool('WHOOGLE_CONFIG_USE_LETA', default=True)
|
||||||
|
|
||||||
|
self.accept_language = False
|
||||||
|
|
||||||
|
# Skip setting custom config if there isn't one
|
||||||
|
if kwargs:
|
||||||
|
mutable_attrs = self.get_mutable_attrs()
|
||||||
|
for attr in mutable_attrs:
|
||||||
|
if attr in kwargs.keys():
|
||||||
|
setattr(self, attr, kwargs[attr])
|
||||||
|
elif attr not in kwargs.keys() and mutable_attrs[attr] == bool:
|
||||||
|
# Only set to False if the attribute wasn't already set to True
|
||||||
|
# by environment defaults (e.g., use_leta defaults to True)
|
||||||
|
if not getattr(self, attr, False):
|
||||||
|
setattr(self, attr, False)
|
||||||
|
|
||||||
def __getitem__(self, name):
|
def __getitem__(self, name):
|
||||||
return getattr(self, name)
|
return getattr(self, name)
|
||||||
@ -43,6 +119,55 @@ class Config:
|
|||||||
def __contains__(self, name):
|
def __contains__(self, name):
|
||||||
return hasattr(self, name)
|
return hasattr(self, name)
|
||||||
|
|
||||||
|
def get_mutable_attrs(self):
|
||||||
|
return {name: type(attr) for name, attr in self.__dict__.items()
|
||||||
|
if not name.startswith("__")
|
||||||
|
and (type(attr) is bool or type(attr) is str)}
|
||||||
|
|
||||||
|
def get_attrs(self):
|
||||||
|
return {name: attr for name, attr in self.__dict__.items()
|
||||||
|
if not name.startswith("__")
|
||||||
|
and (type(attr) is bool or type(attr) is str)}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def style(self) -> str:
|
||||||
|
"""Returns the default style updated with specified modifications.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str -- the new style
|
||||||
|
"""
|
||||||
|
vars_path = os.path.join(current_app.config['STATIC_FOLDER'], 'css/variables.css')
|
||||||
|
with open(vars_path, 'r', encoding='utf-8') as f:
|
||||||
|
style_sheet = cssutils.parseString(f.read())
|
||||||
|
|
||||||
|
modified_sheet = cssutils.parseString(self.style_modified)
|
||||||
|
for rule in modified_sheet:
|
||||||
|
rule_default = get_rule_for_selector(style_sheet,
|
||||||
|
rule.selectorText)
|
||||||
|
# if modified rule is in default stylesheet, update it
|
||||||
|
if rule_default is not None:
|
||||||
|
# TODO: update this in a smarter way to handle :root better
|
||||||
|
# for now if we change a varialbe in :root all other default
|
||||||
|
# variables need to be also present
|
||||||
|
rule_default.style = rule.style
|
||||||
|
# else add the new rule to the default stylesheet
|
||||||
|
else:
|
||||||
|
style_sheet.add(rule)
|
||||||
|
return str(style_sheet.cssText, 'utf-8')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preferences(self) -> str:
|
||||||
|
# if encryption key is not set will uncheck preferences encryption
|
||||||
|
if self.preferences_encrypted:
|
||||||
|
self.preferences_encrypted = bool(self.preferences_key)
|
||||||
|
|
||||||
|
# add a tag for visibility if preferences token startswith 'e' it means
|
||||||
|
# the token is encrypted, 'u' means the token is unencrypted and can be
|
||||||
|
# used by other whoogle instances
|
||||||
|
encrypted_flag = "e" if self.preferences_encrypted else 'u'
|
||||||
|
preferences_digest = self._encode_preferences()
|
||||||
|
return f"{encrypted_flag}{preferences_digest}"
|
||||||
|
|
||||||
def is_safe_key(self, key) -> bool:
|
def is_safe_key(self, key) -> bool:
|
||||||
"""Establishes a group of config options that are safe to set
|
"""Establishes a group of config options that are safe to set
|
||||||
in the url.
|
in the url.
|
||||||
@ -57,6 +182,19 @@ class Config:
|
|||||||
|
|
||||||
return key in self.safe_keys
|
return key in self.safe_keys
|
||||||
|
|
||||||
|
def get_localization_lang(self):
|
||||||
|
"""Returns the correct language to use for localization, but falls
|
||||||
|
back to english if not set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str -- the localization language string
|
||||||
|
"""
|
||||||
|
if (self.lang_interface and
|
||||||
|
self.lang_interface in current_app.config['TRANSLATIONS']):
|
||||||
|
return self.lang_interface
|
||||||
|
|
||||||
|
return 'lang_en'
|
||||||
|
|
||||||
def from_params(self, params) -> 'Config':
|
def from_params(self, params) -> 'Config':
|
||||||
"""Modify user config with search parameters. This is primarily
|
"""Modify user config with search parameters. This is primarily
|
||||||
used for specifying configuration on a search-by-search basis on
|
used for specifying configuration on a search-by-search basis on
|
||||||
@ -68,8 +206,79 @@ class Config:
|
|||||||
Returns:
|
Returns:
|
||||||
Config -- a modified config object
|
Config -- a modified config object
|
||||||
"""
|
"""
|
||||||
|
if 'preferences' in params:
|
||||||
|
params_new = self._decode_preferences(params['preferences'])
|
||||||
|
# if preferences leads to an empty dictionary it means preferences
|
||||||
|
# parameter was not decrypted successfully
|
||||||
|
if len(params_new):
|
||||||
|
params = params_new
|
||||||
|
|
||||||
for param_key in params.keys():
|
for param_key in params.keys():
|
||||||
if not self.is_safe_key(param_key):
|
if not self.is_safe_key(param_key):
|
||||||
continue
|
continue
|
||||||
self[param_key] = params.get(param_key)
|
param_val = params.get(param_key)
|
||||||
|
|
||||||
|
if param_val == 'off':
|
||||||
|
param_val = False
|
||||||
|
elif isinstance(param_val, str):
|
||||||
|
if param_val.isdigit():
|
||||||
|
param_val = int(param_val)
|
||||||
|
|
||||||
|
self[param_key] = param_val
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def to_params(self, keys: list = []) -> str:
|
||||||
|
"""Generates a set of safe params for using in Whoogle URLs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keys (list) -- optional list of keys of URL parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str -- a set of URL parameters
|
||||||
|
"""
|
||||||
|
if not len(keys):
|
||||||
|
keys = self.safe_keys
|
||||||
|
|
||||||
|
param_str = ''
|
||||||
|
for safe_key in keys:
|
||||||
|
if not self[safe_key]:
|
||||||
|
continue
|
||||||
|
param_str = param_str + f'&{safe_key}={self[safe_key]}'
|
||||||
|
|
||||||
|
return param_str
|
||||||
|
|
||||||
|
def _get_fernet_key(self, password: str) -> bytes:
|
||||||
|
hash_object = hashlib.md5(password.encode())
|
||||||
|
key = urlsafe_b64encode(hash_object.hexdigest().encode())
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _encode_preferences(self) -> str:
|
||||||
|
preferences_json = json.dumps(self.get_attrs()).encode()
|
||||||
|
compressed_preferences = brotli.compress(preferences_json)
|
||||||
|
|
||||||
|
if self.preferences_encrypted and self.preferences_key:
|
||||||
|
key = self._get_fernet_key(self.preferences_key)
|
||||||
|
encrypted_preferences = Fernet(key).encrypt(compressed_preferences)
|
||||||
|
compressed_preferences = brotli.compress(encrypted_preferences)
|
||||||
|
|
||||||
|
return urlsafe_b64encode(compressed_preferences).decode()
|
||||||
|
|
||||||
|
def _decode_preferences(self, preferences: str) -> dict:
|
||||||
|
mode = preferences[0]
|
||||||
|
preferences = preferences[1:]
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded_data = brotli.decompress(urlsafe_b64decode(preferences.encode() + b'=='))
|
||||||
|
|
||||||
|
if mode == 'e' and self.preferences_key:
|
||||||
|
# preferences are encrypted
|
||||||
|
key = self._get_fernet_key(self.preferences_key)
|
||||||
|
decrypted_data = Fernet(key).decrypt(decoded_data)
|
||||||
|
decoded_data = brotli.decompress(decrypted_data)
|
||||||
|
|
||||||
|
config = json.loads(decoded_data)
|
||||||
|
except Exception:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|||||||
22
app/models/endpoint.py
Normal file
22
app/models/endpoint.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Endpoint(Enum):
|
||||||
|
autocomplete = 'autocomplete'
|
||||||
|
home = 'home'
|
||||||
|
healthz = 'healthz'
|
||||||
|
config = 'config'
|
||||||
|
opensearch = 'opensearch.xml'
|
||||||
|
search = 'search'
|
||||||
|
search_html = 'search.html'
|
||||||
|
url = 'url'
|
||||||
|
imgres = 'imgres'
|
||||||
|
element = 'element'
|
||||||
|
window = 'window'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def in_path(self, path: str) -> bool:
|
||||||
|
return path.startswith(self.value) or \
|
||||||
|
path.startswith(f'/{self.value}')
|
||||||
48
app/models/g_classes.py
Normal file
48
app/models/g_classes.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
class GClasses:
|
||||||
|
"""A class for tracking obfuscated class names used in Google results that
|
||||||
|
are directly referenced in Whoogle's filtering code.
|
||||||
|
|
||||||
|
Note: Using these should be a last resort. It is always preferred to filter
|
||||||
|
results using structural cues instead of referencing class names, as these
|
||||||
|
are liable to change at any moment.
|
||||||
|
"""
|
||||||
|
main_tbm_tab = 'KP7LCb'
|
||||||
|
images_tbm_tab = 'n692Zd'
|
||||||
|
footer = 'TuS8Ad'
|
||||||
|
result_class_a = 'ZINbbc'
|
||||||
|
result_class_b = 'luh4td'
|
||||||
|
scroller_class = 'idg8be'
|
||||||
|
line_tag = 'BsXmcf'
|
||||||
|
|
||||||
|
result_classes = {
|
||||||
|
result_class_a: ['Gx5Zad'],
|
||||||
|
result_class_b: ['fP1Qef']
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def replace_css_classes(cls, soup: BeautifulSoup) -> BeautifulSoup:
|
||||||
|
"""Replace updated Google classes with the original class names that
|
||||||
|
Whoogle relies on for styling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: The result page as a BeautifulSoup object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BeautifulSoup: The new BeautifulSoup
|
||||||
|
"""
|
||||||
|
result_divs = soup.find_all('div', {
|
||||||
|
'class': [_ for c in cls.result_classes.values() for _ in c]
|
||||||
|
})
|
||||||
|
|
||||||
|
for div in result_divs:
|
||||||
|
new_class = ' '.join(div['class'])
|
||||||
|
for key, val in cls.result_classes.items():
|
||||||
|
new_class = ' '.join(new_class.replace(_, key) for _ in val)
|
||||||
|
div['class'] = new_class.split(' ')
|
||||||
|
return soup
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.value
|
||||||
298
app/request.py
298
app/request.py
@ -1,14 +1,18 @@
|
|||||||
from app.models.config import Config
|
from app.models.config import Config
|
||||||
import xml.etree.ElementTree as ET
|
from app.utils.misc import read_config_bool
|
||||||
|
from app.services.provider import get_http_client
|
||||||
|
from datetime import datetime
|
||||||
|
from defusedxml import ElementTree as ET
|
||||||
import random
|
import random
|
||||||
import requests
|
import httpx
|
||||||
from requests import Response, ConnectionError
|
|
||||||
import urllib.parse as urlparse
|
import urllib.parse as urlparse
|
||||||
import os
|
import os
|
||||||
from stem import Signal, SocketError
|
from stem import Signal, SocketError
|
||||||
|
from stem.connection import AuthenticationFailure
|
||||||
from stem.control import Controller
|
from stem.control import Controller
|
||||||
|
from stem.connection import authenticate_cookie, authenticate_password
|
||||||
|
|
||||||
SEARCH_URL = 'https://www.google.com/search?gbv=1&q='
|
MAPS_URL = 'https://maps.google.com/maps'
|
||||||
AUTOCOMPLETE_URL = ('https://suggestqueries.google.com/'
|
AUTOCOMPLETE_URL = ('https://suggestqueries.google.com/'
|
||||||
'complete/search?client=toolbar&')
|
'complete/search?client=toolbar&')
|
||||||
|
|
||||||
@ -36,19 +40,64 @@ class TorError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def send_tor_signal(signal: Signal) -> bool:
|
def send_tor_signal(signal: Signal) -> bool:
|
||||||
|
use_pass = read_config_bool('WHOOGLE_TOR_USE_PASS')
|
||||||
|
|
||||||
|
confloc = './misc/tor/control.conf'
|
||||||
|
# Check that the custom location of conf is real.
|
||||||
|
temp = os.getenv('WHOOGLE_TOR_CONF', '')
|
||||||
|
if os.path.isfile(temp):
|
||||||
|
confloc = temp
|
||||||
|
|
||||||
|
# Attempt to authenticate and send signal.
|
||||||
try:
|
try:
|
||||||
with Controller.from_port(port=9051) as c:
|
with Controller.from_port(port=9051) as c:
|
||||||
c.authenticate()
|
if use_pass:
|
||||||
|
with open(confloc, "r") as conf:
|
||||||
|
# Scan for the last line of the file.
|
||||||
|
for line in conf:
|
||||||
|
pass
|
||||||
|
secret = line.strip('\n')
|
||||||
|
authenticate_password(c, password=secret)
|
||||||
|
else:
|
||||||
|
cookie_path = '/var/lib/tor/control_auth_cookie'
|
||||||
|
authenticate_cookie(c, cookie_path=cookie_path)
|
||||||
c.signal(signal)
|
c.signal(signal)
|
||||||
os.environ['TOR_AVAILABLE'] = '1'
|
os.environ['TOR_AVAILABLE'] = '1'
|
||||||
return True
|
return True
|
||||||
except (SocketError, ConnectionRefusedError, ConnectionError):
|
except (SocketError, AuthenticationFailure,
|
||||||
|
ConnectionRefusedError, ConnectionError):
|
||||||
|
# TODO: Handle Tor authentication (password and cookie)
|
||||||
os.environ['TOR_AVAILABLE'] = '0'
|
os.environ['TOR_AVAILABLE'] = '0'
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def gen_user_agent(is_mobile) -> str:
|
def gen_user_agent(config, is_mobile) -> str:
|
||||||
|
# Define the default PlayStation Portable user agent (replaces Lynx)
|
||||||
|
DEFAULT_UA = 'Mozilla/4.0 (PSP (PlayStation Portable); 2.00)'
|
||||||
|
|
||||||
|
# If using custom user agent, return the custom string
|
||||||
|
if config.user_agent == 'custom' and config.custom_user_agent:
|
||||||
|
return config.custom_user_agent
|
||||||
|
|
||||||
|
# If using environment configuration
|
||||||
|
if config.user_agent == 'env_conf':
|
||||||
|
if is_mobile:
|
||||||
|
env_ua = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')
|
||||||
|
if env_ua:
|
||||||
|
return env_ua
|
||||||
|
else:
|
||||||
|
env_ua = os.getenv('WHOOGLE_USER_AGENT', '')
|
||||||
|
if env_ua:
|
||||||
|
return env_ua
|
||||||
|
# If env vars are not set, fall back to default
|
||||||
|
return DEFAULT_UA
|
||||||
|
|
||||||
|
# If using default user agent
|
||||||
|
if config.user_agent == 'default':
|
||||||
|
return DEFAULT_UA
|
||||||
|
|
||||||
|
# If no custom user agent is set, generate a random one (for backwards compatibility)
|
||||||
firefox = random.choice(['Choir', 'Squier', 'Higher', 'Wire']) + 'fox'
|
firefox = random.choice(['Choir', 'Squier', 'Higher', 'Wire']) + 'fox'
|
||||||
linux = random.choice(['Win', 'Sin', 'Gin', 'Fin', 'Kin']) + 'ux'
|
linux = random.choice(['Win', 'Sin', 'Gin', 'Fin', 'Kin']) + 'ux'
|
||||||
|
|
||||||
@ -58,7 +107,75 @@ def gen_user_agent(is_mobile) -> str:
|
|||||||
return DESKTOP_UA.format("Mozilla", linux, firefox)
|
return DESKTOP_UA.format("Mozilla", linux, firefox)
|
||||||
|
|
||||||
|
|
||||||
def gen_query(query, args, config, near_city=None) -> str:
|
def gen_query_leta(query, args, config) -> str:
|
||||||
|
"""Builds a query string for Mullvad Leta backend
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query string
|
||||||
|
args: Request arguments
|
||||||
|
config: User configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A formatted query string for Leta
|
||||||
|
"""
|
||||||
|
# Ensure search query is parsable
|
||||||
|
query = urlparse.quote(query)
|
||||||
|
|
||||||
|
# Build query starting with 'q='
|
||||||
|
query_str = 'q=' + query
|
||||||
|
|
||||||
|
# Always use Google as the engine (Leta supports 'google' or 'brave')
|
||||||
|
query_str += '&engine=google'
|
||||||
|
|
||||||
|
# Add country if configured
|
||||||
|
if config.country:
|
||||||
|
query_str += '&country=' + config.country.lower()
|
||||||
|
|
||||||
|
# Add language if configured
|
||||||
|
# Convert from Google's lang format (lang_en) to Leta's format (en)
|
||||||
|
if config.lang_search:
|
||||||
|
lang_code = config.lang_search.replace('lang_', '')
|
||||||
|
query_str += '&language=' + lang_code
|
||||||
|
|
||||||
|
# Handle time period filtering with :past syntax or tbs parameter
|
||||||
|
if ':past' in query:
|
||||||
|
time_range = str.strip(query.split(':past', 1)[-1]).lower()
|
||||||
|
if time_range.startswith('day'):
|
||||||
|
query_str += '&lastUpdated=d'
|
||||||
|
elif time_range.startswith('week'):
|
||||||
|
query_str += '&lastUpdated=w'
|
||||||
|
elif time_range.startswith('month'):
|
||||||
|
query_str += '&lastUpdated=m'
|
||||||
|
elif time_range.startswith('year'):
|
||||||
|
query_str += '&lastUpdated=y'
|
||||||
|
elif 'tbs' in args or 'tbs' in config:
|
||||||
|
result_tbs = args.get('tbs') if 'tbs' in args else config.tbs
|
||||||
|
# Convert Google's tbs format to Leta's lastUpdated format
|
||||||
|
if result_tbs and 'qdr:d' in result_tbs:
|
||||||
|
query_str += '&lastUpdated=d'
|
||||||
|
elif result_tbs and 'qdr:w' in result_tbs:
|
||||||
|
query_str += '&lastUpdated=w'
|
||||||
|
elif result_tbs and 'qdr:m' in result_tbs:
|
||||||
|
query_str += '&lastUpdated=m'
|
||||||
|
elif result_tbs and 'qdr:y' in result_tbs:
|
||||||
|
query_str += '&lastUpdated=y'
|
||||||
|
|
||||||
|
# Add pagination if present
|
||||||
|
if 'start' in args:
|
||||||
|
start = int(args.get('start', '0'))
|
||||||
|
# Leta uses 1-indexed pages, Google uses result offset
|
||||||
|
page = (start // 10) + 1
|
||||||
|
if page > 1:
|
||||||
|
query_str += '&page=' + str(page)
|
||||||
|
|
||||||
|
return query_str
|
||||||
|
|
||||||
|
|
||||||
|
def gen_query(query, args, config) -> str:
|
||||||
|
# If using Leta backend, build query differently
|
||||||
|
if config.use_leta:
|
||||||
|
return gen_query_leta(query, args, config)
|
||||||
|
|
||||||
param_dict = {key: '' for key in VALID_PARAMS}
|
param_dict = {key: '' for key in VALID_PARAMS}
|
||||||
|
|
||||||
# Use :past(hour/day/week/month/year) if available
|
# Use :past(hour/day/week/month/year) if available
|
||||||
@ -67,8 +184,8 @@ def gen_query(query, args, config, near_city=None) -> str:
|
|||||||
if ':past' in query and 'tbs' not in args:
|
if ':past' in query and 'tbs' not in args:
|
||||||
time_range = str.strip(query.split(':past', 1)[-1])
|
time_range = str.strip(query.split(':past', 1)[-1])
|
||||||
param_dict['tbs'] = '&tbs=' + ('qdr:' + str.lower(time_range[0]))
|
param_dict['tbs'] = '&tbs=' + ('qdr:' + str.lower(time_range[0]))
|
||||||
elif 'tbs' in args:
|
elif 'tbs' in args or 'tbs' in config:
|
||||||
result_tbs = args.get('tbs')
|
result_tbs = args.get('tbs') if 'tbs' in args else config['tbs']
|
||||||
param_dict['tbs'] = '&tbs=' + result_tbs
|
param_dict['tbs'] = '&tbs=' + result_tbs
|
||||||
|
|
||||||
# Occasionally the 'tbs' param provided by google also contains a
|
# Occasionally the 'tbs' param provided by google also contains a
|
||||||
@ -95,8 +212,8 @@ def gen_query(query, args, config, near_city=None) -> str:
|
|||||||
param_dict['start'] = '&start=' + args.get('start')
|
param_dict['start'] = '&start=' + args.get('start')
|
||||||
|
|
||||||
# Search for results near a particular city, if available
|
# Search for results near a particular city, if available
|
||||||
if near_city:
|
if config.near:
|
||||||
param_dict['near'] = '&near=' + urlparse.quote(near_city)
|
param_dict['near'] = '&near=' + urlparse.quote(config.near)
|
||||||
|
|
||||||
# Set language for results (lr) if source isn't set, otherwise use the
|
# Set language for results (lr) if source isn't set, otherwise use the
|
||||||
# result language param provided in the results
|
# result language param provided in the results
|
||||||
@ -114,12 +231,27 @@ def gen_query(query, args, config, near_city=None) -> str:
|
|||||||
if 'nfpr' in args:
|
if 'nfpr' in args:
|
||||||
param_dict['nfpr'] = '&nfpr=' + args.get('nfpr')
|
param_dict['nfpr'] = '&nfpr=' + args.get('nfpr')
|
||||||
|
|
||||||
param_dict['cr'] = ('&cr=' + config.ctry) if config.ctry else ''
|
# 'chips' is used in image tabs to pass the optional 'filter' to add to the
|
||||||
|
# given search term
|
||||||
|
if 'chips' in args:
|
||||||
|
param_dict['chips'] = '&chips=' + args.get('chips')
|
||||||
|
|
||||||
|
param_dict['gl'] = (
|
||||||
|
'&gl=' + config.country
|
||||||
|
) if config.country else ''
|
||||||
param_dict['hl'] = (
|
param_dict['hl'] = (
|
||||||
'&hl=' + config.lang_interface.replace('lang_', '')
|
'&hl=' + config.lang_interface.replace('lang_', '')
|
||||||
) if config.lang_interface else ''
|
) if config.lang_interface else ''
|
||||||
param_dict['safe'] = '&safe=' + ('active' if config.safe else 'off')
|
param_dict['safe'] = '&safe=' + ('active' if config.safe else 'off')
|
||||||
|
|
||||||
|
# Block all sites specified in the user config
|
||||||
|
unquoted_query = urlparse.unquote(query)
|
||||||
|
for blocked_site in config.block.replace(' ', '').split(','):
|
||||||
|
if not blocked_site:
|
||||||
|
continue
|
||||||
|
block = (' -site:' + blocked_site)
|
||||||
|
query += block if block not in unquoted_query else ''
|
||||||
|
|
||||||
for val in param_dict.values():
|
for val in param_dict.values():
|
||||||
if not val:
|
if not val:
|
||||||
continue
|
continue
|
||||||
@ -138,35 +270,63 @@ class Request:
|
|||||||
config: the user's current whoogle configuration
|
config: the user's current whoogle configuration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, normal_ua, root_path, config: Config):
|
def __init__(self, normal_ua, root_path, config: Config, http_client=None):
|
||||||
# Send heartbeat to Tor, used in determining if the user can or cannot
|
# Use Leta backend if configured, otherwise use Google
|
||||||
# enable Tor for future requests
|
if config.use_leta:
|
||||||
|
self.search_url = 'https://leta.mullvad.net/search?'
|
||||||
|
self.use_leta = True
|
||||||
|
else:
|
||||||
|
self.search_url = 'https://www.google.com/search?gbv=1&num=' + str(
|
||||||
|
os.getenv('WHOOGLE_RESULTS_PER_PAGE', 10)) + '&'
|
||||||
|
self.use_leta = False
|
||||||
|
|
||||||
|
# Optionally send heartbeat to Tor to determine availability
|
||||||
|
# Only when Tor is enabled in config to avoid unnecessary socket usage
|
||||||
|
if config.tor:
|
||||||
send_tor_signal(Signal.HEARTBEAT)
|
send_tor_signal(Signal.HEARTBEAT)
|
||||||
|
|
||||||
self.language = config.lang_search
|
self.language = config.lang_search if config.lang_search else ''
|
||||||
self.mobile = 'Android' in normal_ua or 'iPhone' in normal_ua
|
self.country = config.country if config.country else ''
|
||||||
self.modified_user_agent = gen_user_agent(self.mobile)
|
|
||||||
|
|
||||||
# Set up proxy, if previously configured
|
# For setting Accept-language Header
|
||||||
if os.environ.get('WHOOGLE_PROXY_LOC'):
|
self.lang_interface = ''
|
||||||
|
if config.accept_language:
|
||||||
|
self.lang_interface = config.lang_interface
|
||||||
|
|
||||||
|
self.mobile = bool(normal_ua) and ('Android' in normal_ua
|
||||||
|
or 'iPhone' in normal_ua)
|
||||||
|
|
||||||
|
# Generate user agent based on config
|
||||||
|
self.modified_user_agent = gen_user_agent(config, self.mobile)
|
||||||
|
if not self.mobile:
|
||||||
|
self.modified_user_agent_mobile = gen_user_agent(config, True)
|
||||||
|
|
||||||
|
# Set up proxy configuration
|
||||||
|
proxy_path = os.environ.get('WHOOGLE_PROXY_LOC', '')
|
||||||
|
if proxy_path:
|
||||||
|
proxy_type = os.environ.get('WHOOGLE_PROXY_TYPE', '')
|
||||||
|
proxy_user = os.environ.get('WHOOGLE_PROXY_USER', '')
|
||||||
|
proxy_pass = os.environ.get('WHOOGLE_PROXY_PASS', '')
|
||||||
auth_str = ''
|
auth_str = ''
|
||||||
if os.environ.get('WHOOGLE_PROXY_USER', ''):
|
if proxy_user:
|
||||||
auth_str = os.environ.get('WHOOGLE_PROXY_USER', '') + \
|
auth_str = f'{proxy_user}:{proxy_pass}@'
|
||||||
':' + os.environ.get('WHOOGLE_PROXY_PASS', '')
|
|
||||||
|
proxy_str = f'{proxy_type}://{auth_str}{proxy_path}'
|
||||||
self.proxies = {
|
self.proxies = {
|
||||||
'http': os.environ.get('WHOOGLE_PROXY_TYPE', '') + '://' +
|
'https': proxy_str,
|
||||||
auth_str + '@' + os.environ.get('WHOOGLE_PROXY_LOC', ''),
|
'http': proxy_str
|
||||||
}
|
}
|
||||||
self.proxies['https'] = self.proxies['http'].replace('http',
|
|
||||||
'https')
|
|
||||||
else:
|
else:
|
||||||
self.proxies = {
|
self.proxies = {
|
||||||
'http': 'socks5://127.0.0.1:9050',
|
'http': 'socks5://127.0.0.1:9050',
|
||||||
'https': 'socks5://127.0.0.1:9050'
|
'https': 'socks5://127.0.0.1:9050'
|
||||||
} if config.tor else {}
|
} if config.tor else {}
|
||||||
|
|
||||||
self.tor = config.tor
|
self.tor = config.tor
|
||||||
self.tor_valid = False
|
self.tor_valid = False
|
||||||
self.root_path = root_path
|
self.root_path = root_path
|
||||||
|
# Initialize HTTP client (shared per proxies)
|
||||||
|
self.http_client = http_client or get_http_client(self.proxies)
|
||||||
|
|
||||||
def __getitem__(self, name):
|
def __getitem__(self, name):
|
||||||
return getattr(self, name)
|
return getattr(self, name)
|
||||||
@ -181,18 +341,39 @@ class Request:
|
|||||||
list: The list of matches for possible search suggestions
|
list: The list of matches for possible search suggestions
|
||||||
|
|
||||||
"""
|
"""
|
||||||
ac_query = dict(hl=self.language, q=query)
|
# Check if autocomplete is disabled via environment variable
|
||||||
|
if os.environ.get('WHOOGLE_AUTOCOMPLETE', '1') == '0':
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
ac_query = dict(q=query)
|
||||||
|
if self.language:
|
||||||
|
ac_query['lr'] = self.language
|
||||||
|
if self.country:
|
||||||
|
ac_query['gl'] = self.country
|
||||||
|
if self.lang_interface:
|
||||||
|
ac_query['hl'] = self.lang_interface
|
||||||
|
|
||||||
response = self.send(base_url=AUTOCOMPLETE_URL,
|
response = self.send(base_url=AUTOCOMPLETE_URL,
|
||||||
query=urlparse.urlencode(ac_query)).text
|
query=urlparse.urlencode(ac_query)).text
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
root = ET.fromstring(response)
|
root = ET.fromstring(response)
|
||||||
return [_.attrib['data'] for _ in
|
return [_.attrib['data'] for _ in
|
||||||
root.findall('.//suggestion/[@data]')]
|
root.findall('.//suggestion/[@data]')]
|
||||||
|
except ET.ParseError:
|
||||||
|
# Malformed XML response
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error but don't crash - autocomplete is non-essential
|
||||||
|
print(f"Autocomplete error: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
def send(self, base_url=SEARCH_URL, query='', attempt=0) -> Response:
|
def send(self, base_url='', query='', attempt=0,
|
||||||
|
force_mobile=False, user_agent=''):
|
||||||
"""Sends an outbound request to a URL. Optionally sends the request
|
"""Sends an outbound request to a URL. Optionally sends the request
|
||||||
using Tor, if enabled by the user.
|
using Tor, if enabled by the user.
|
||||||
|
|
||||||
@ -201,20 +382,40 @@ class Request:
|
|||||||
query: The optional query string for the request
|
query: The optional query string for the request
|
||||||
attempt: The number of attempts made for the request
|
attempt: The number of attempts made for the request
|
||||||
(used for cycling through Tor identities, if enabled)
|
(used for cycling through Tor identities, if enabled)
|
||||||
|
force_mobile: Optional flag to enable a mobile user agent
|
||||||
|
(used for fetching full size images in search results)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: The Response object returned by the requests call
|
Response: The Response object returned by the requests call
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
use_client_user_agent = int(os.environ.get('WHOOGLE_USE_CLIENT_USER_AGENT', '0'))
|
||||||
|
if user_agent and use_client_user_agent == 1:
|
||||||
|
modified_user_agent = user_agent
|
||||||
|
else:
|
||||||
|
if force_mobile and not self.mobile:
|
||||||
|
modified_user_agent = self.modified_user_agent_mobile
|
||||||
|
else:
|
||||||
|
modified_user_agent = self.modified_user_agent
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': self.modified_user_agent
|
'User-Agent': modified_user_agent
|
||||||
}
|
}
|
||||||
|
|
||||||
# FIXME: Should investigate this further to ensure the consent
|
# Adding the Accept-Language to the Header if possible
|
||||||
|
if self.lang_interface:
|
||||||
|
headers.update({'Accept-Language':
|
||||||
|
self.lang_interface.replace('lang_', '')
|
||||||
|
+ ';q=1.0'})
|
||||||
|
|
||||||
# view is suppressed correctly
|
# view is suppressed correctly
|
||||||
cookies = {
|
now = datetime.now()
|
||||||
'CONSENT': 'PENDING+999'
|
consent_cookie = 'CONSENT=PENDING+987; SOCS=CAESHAgBEhIaAB'
|
||||||
}
|
# Prefer header-based cookies to avoid httpx per-request cookies deprecation
|
||||||
|
if 'Cookie' in headers:
|
||||||
|
headers['Cookie'] += '; ' + consent_cookie
|
||||||
|
else:
|
||||||
|
headers['Cookie'] = consent_cookie
|
||||||
|
|
||||||
# Validate Tor conn and request new identity if the last one failed
|
# Validate Tor conn and request new identity if the last one failed
|
||||||
if self.tor and not send_tor_signal(
|
if self.tor and not send_tor_signal(
|
||||||
@ -226,27 +427,34 @@ class Request:
|
|||||||
|
|
||||||
# Make sure that the tor connection is valid, if enabled
|
# Make sure that the tor connection is valid, if enabled
|
||||||
if self.tor:
|
if self.tor:
|
||||||
tor_check = requests.get('https://check.torproject.org/',
|
try:
|
||||||
proxies=self.proxies, headers=headers)
|
tor_check = self.http_client.get('https://check.torproject.org/',
|
||||||
|
headers=headers,
|
||||||
|
retries=1)
|
||||||
self.tor_valid = 'Congratulations' in tor_check.text
|
self.tor_valid = 'Congratulations' in tor_check.text
|
||||||
|
|
||||||
if not self.tor_valid:
|
if not self.tor_valid:
|
||||||
raise TorError(
|
raise TorError(
|
||||||
"Tor connection succeeded, but the connection could not "
|
"Tor connection succeeded, but the connection could "
|
||||||
"be validated by torproject.org",
|
"not be validated by torproject.org",
|
||||||
|
disable=True)
|
||||||
|
except httpx.RequestError:
|
||||||
|
raise TorError(
|
||||||
|
"Error raised during Tor connection validation",
|
||||||
disable=True)
|
disable=True)
|
||||||
|
|
||||||
response = requests.get(
|
try:
|
||||||
base_url + query,
|
response = self.http_client.get(
|
||||||
proxies=self.proxies,
|
(base_url or self.search_url) + query,
|
||||||
headers=headers,
|
headers=headers)
|
||||||
cookies=cookies)
|
except httpx.HTTPError as e:
|
||||||
|
raise
|
||||||
|
|
||||||
# Retry query with new identity if using Tor (max 10 attempts)
|
# Retry query with new identity if using Tor (max 10 attempts)
|
||||||
if 'form id="captcha-form"' in response.text and self.tor:
|
if 'form id="captcha-form"' in response.text and self.tor:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
if attempt > 10:
|
if attempt > 10:
|
||||||
raise TorError("Tor query failed -- max attempts exceeded 10")
|
raise TorError("Tor query failed -- max attempts exceeded 10")
|
||||||
return self.send(base_url, query, attempt)
|
return self.send((base_url or self.search_url), query, attempt)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
732
app/routes.py
732
app/routes.py
@ -4,29 +4,60 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
|
import re
|
||||||
import urllib.parse as urlparse
|
import urllib.parse as urlparse
|
||||||
import uuid
|
import uuid
|
||||||
|
import validators
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import waitress
|
import waitress
|
||||||
from flask import jsonify, make_response, request, redirect, render_template, \
|
|
||||||
send_file, session, url_for
|
|
||||||
from requests import exceptions
|
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from app.models.config import Config
|
from app.models.config import Config
|
||||||
|
from app.models.endpoint import Endpoint
|
||||||
from app.request import Request, TorError
|
from app.request import Request, TorError
|
||||||
from app.utils.bangs import resolve_bang
|
from app.utils.bangs import suggest_bang, resolve_bang
|
||||||
|
from app.utils.misc import empty_gif, placeholder_img, get_proxy_host_url, \
|
||||||
|
fetch_favicon
|
||||||
|
from app.filter import Filter
|
||||||
|
from app.utils.misc import read_config_bool, get_client_ip, get_request_url, \
|
||||||
|
check_for_update, encrypt_string
|
||||||
|
from app.utils.widgets import *
|
||||||
|
from app.utils.results import bold_search_terms,\
|
||||||
|
add_currency_card, check_currency, get_tabs_content
|
||||||
|
from app.utils.search import Search, needs_https, has_captcha
|
||||||
from app.utils.session import valid_user_session
|
from app.utils.session import valid_user_session
|
||||||
from app.utils.search import *
|
from bs4 import BeautifulSoup as bsoup
|
||||||
|
from flask import jsonify, make_response, request, redirect, render_template, \
|
||||||
|
send_file, session, url_for, g
|
||||||
|
import httpx
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from werkzeug.datastructures import MultiDict
|
||||||
|
|
||||||
# Load DDG bang json files only on init
|
ac_var = 'WHOOGLE_AUTOCOMPLETE'
|
||||||
bang_json = json.load(open(app.config['BANG_FILE']))
|
autocomplete_enabled = os.getenv(ac_var, '1')
|
||||||
|
|
||||||
|
|
||||||
|
def get_search_name(tbm):
|
||||||
|
for tab in app.config['HEADER_TABS'].values():
|
||||||
|
if tab['tbm'] == tbm:
|
||||||
|
return tab['name']
|
||||||
|
|
||||||
|
|
||||||
def auth_required(f):
|
def auth_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
# do not ask password if cookies already present
|
||||||
|
if (
|
||||||
|
valid_user_session(session)
|
||||||
|
and 'cookies_disabled' not in request.args
|
||||||
|
and session['auth']
|
||||||
|
):
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
auth = request.authorization
|
auth = request.authorization
|
||||||
|
|
||||||
# Skip if username/password not set
|
# Skip if username/password not set
|
||||||
@ -36,6 +67,7 @@ def auth_required(f):
|
|||||||
auth
|
auth
|
||||||
and whoogle_user == auth.username
|
and whoogle_user == auth.username
|
||||||
and whoogle_pass == auth.password):
|
and whoogle_pass == auth.password):
|
||||||
|
session['auth'] = True
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
else:
|
else:
|
||||||
return make_response('Not logged in', 401, {
|
return make_response('Not logged in', 401, {
|
||||||
@ -44,63 +76,112 @@ def auth_required(f):
|
|||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def session_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not valid_user_session(session):
|
||||||
|
session.pop('_permanent', None)
|
||||||
|
|
||||||
|
# Note: This sets all requests to use the encryption key determined per
|
||||||
|
# instance on app init. This can be updated in the future to use a key
|
||||||
|
# that is unique for their session (session['key']) but this should use
|
||||||
|
# a config setting to enable the session based key. Otherwise there can
|
||||||
|
# be problems with searches performed by users with cookies blocked if
|
||||||
|
# a session based key is always used.
|
||||||
|
g.session_key = app.enc_key
|
||||||
|
|
||||||
|
# Clear out old sessions
|
||||||
|
invalid_sessions = []
|
||||||
|
for user_session in os.listdir(app.config['SESSION_FILE_DIR']):
|
||||||
|
file_path = os.path.join(
|
||||||
|
app.config['SESSION_FILE_DIR'],
|
||||||
|
user_session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ignore files that are larger than the max session file size
|
||||||
|
if os.path.getsize(file_path) > app.config['MAX_SESSION_SIZE']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as session_file:
|
||||||
|
_ = pickle.load(session_file)
|
||||||
|
data = pickle.load(session_file)
|
||||||
|
if isinstance(data, dict) and 'valid' in data:
|
||||||
|
continue
|
||||||
|
invalid_sessions.append(file_path)
|
||||||
|
except Exception:
|
||||||
|
# Broad exception handling here due to how instances installed
|
||||||
|
# with pip seem to have issues storing unrelated files in the
|
||||||
|
# same directory as sessions
|
||||||
|
pass
|
||||||
|
|
||||||
|
for invalid_session in invalid_sessions:
|
||||||
|
try:
|
||||||
|
os.remove(invalid_session)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Don't throw error if the invalid session has been removed
|
||||||
|
pass
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def before_request_func():
|
def before_request_func():
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
# Check for latest version if needed
|
||||||
|
now = datetime.now()
|
||||||
|
needs_update_check = now - timedelta(hours=24) > app.config['LAST_UPDATE_CHECK']
|
||||||
|
if read_config_bool('WHOOGLE_UPDATE_CHECK', True) and needs_update_check:
|
||||||
|
app.config['LAST_UPDATE_CHECK'] = now
|
||||||
|
app.config['HAS_UPDATE'] = check_for_update(
|
||||||
|
app.config['RELEASES_URL'],
|
||||||
|
app.config['VERSION_NUMBER'])
|
||||||
|
|
||||||
g.request_params = (
|
g.request_params = (
|
||||||
request.args if request.method == 'GET' else request.form
|
request.args if request.method == 'GET' else request.form
|
||||||
)
|
)
|
||||||
g.cookies_disabled = False
|
|
||||||
|
default_config = json.load(open(app.config['DEFAULT_CONFIG'])) \
|
||||||
|
if os.path.exists(app.config['DEFAULT_CONFIG']) else {}
|
||||||
|
|
||||||
# Generate session values for user if unavailable
|
# Generate session values for user if unavailable
|
||||||
if not valid_user_session(session):
|
if not valid_user_session(session):
|
||||||
session['config'] = json.load(open(app.config['DEFAULT_CONFIG'])) \
|
session['config'] = default_config
|
||||||
if os.path.exists(app.config['DEFAULT_CONFIG']) else {}
|
|
||||||
session['uuid'] = str(uuid.uuid4())
|
session['uuid'] = str(uuid.uuid4())
|
||||||
session['key'] = generate_user_key(True)
|
session['key'] = app.enc_key
|
||||||
|
session['auth'] = False
|
||||||
# Flag cookies as possibly disabled in order to prevent against
|
|
||||||
# unnecessary session directory expansion
|
|
||||||
g.cookies_disabled = True
|
|
||||||
|
|
||||||
# Handle https upgrade
|
|
||||||
if needs_https(request.url):
|
|
||||||
return redirect(
|
|
||||||
request.url.replace('http://', 'https://', 1),
|
|
||||||
code=308)
|
|
||||||
|
|
||||||
|
# Establish config values per user session
|
||||||
g.user_config = Config(**session['config'])
|
g.user_config = Config(**session['config'])
|
||||||
|
|
||||||
|
# Update user config if specified in search args
|
||||||
|
g.user_config = g.user_config.from_params(g.request_params)
|
||||||
|
|
||||||
if not g.user_config.url:
|
if not g.user_config.url:
|
||||||
g.user_config.url = request.url_root.replace(
|
g.user_config.url = get_request_url(request.url_root)
|
||||||
'http://',
|
|
||||||
'https://') if os.getenv('HTTPS_ONLY', False) else request.url_root
|
|
||||||
|
|
||||||
g.user_request = Request(
|
g.user_request = Request(
|
||||||
request.headers.get('User-Agent'),
|
request.headers.get('User-Agent'),
|
||||||
request.url_root,
|
get_request_url(request.url_root),
|
||||||
config=g.user_config)
|
config=g.user_config
|
||||||
|
)
|
||||||
|
|
||||||
g.app_location = g.user_config.url
|
g.app_location = g.user_config.url
|
||||||
|
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def after_request_func(resp):
|
def after_request_func(resp):
|
||||||
# Check if address consistently has cookies blocked,
|
resp.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
# in which case start removing session files after creation.
|
resp.headers['X-Frame-Options'] = 'DENY'
|
||||||
#
|
resp.headers['Cache-Control'] = 'max-age=86400'
|
||||||
# Note: This is primarily done to prevent overpopulation of session
|
|
||||||
# directories, since browsers that block cookies will still trigger
|
|
||||||
# Flask's session creation routine with every request.
|
|
||||||
if g.cookies_disabled and request.remote_addr not in app.no_cookie_ips:
|
|
||||||
app.no_cookie_ips.append(request.remote_addr)
|
|
||||||
elif g.cookies_disabled and request.remote_addr in app.no_cookie_ips:
|
|
||||||
session_list = list(session.keys())
|
|
||||||
for key in session_list:
|
|
||||||
session.pop(key)
|
|
||||||
|
|
||||||
|
if os.getenv('WHOOGLE_CSP', False):
|
||||||
resp.headers['Content-Security-Policy'] = app.config['CSP']
|
resp.headers['Content-Security-Policy'] = app.config['CSP']
|
||||||
if os.environ.get('HTTPS_ONLY', False):
|
if os.environ.get('HTTPS_ONLY', False):
|
||||||
resp.headers['Content-Security-Policy'] += 'upgrade-insecure-requests'
|
resp.headers['Content-Security-Policy'] += \
|
||||||
|
'upgrade-insecure-requests'
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@ -111,12 +192,15 @@ def unknown_page(e):
|
|||||||
return redirect(g.app_location)
|
return redirect(g.app_location)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route(f'/{Endpoint.healthz}', methods=['GET'])
|
||||||
|
def healthz():
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
@app.route('/', methods=['GET'])
|
@app.route('/', methods=['GET'])
|
||||||
|
@app.route(f'/{Endpoint.home}', methods=['GET'])
|
||||||
@auth_required
|
@auth_required
|
||||||
def index():
|
def index():
|
||||||
# Reset keys
|
|
||||||
session['key'] = generate_user_key(g.cookies_disabled)
|
|
||||||
|
|
||||||
# Redirect if an error was raised
|
# Redirect if an error was raised
|
||||||
if 'error_message' in session and session['error_message']:
|
if 'error_message' in session and session['error_message']:
|
||||||
error_message = session['error_message']
|
error_message = session['error_message']
|
||||||
@ -124,17 +208,27 @@ def index():
|
|||||||
return render_template('error.html', error_message=error_message)
|
return render_template('error.html', error_message=error_message)
|
||||||
|
|
||||||
return render_template('index.html',
|
return render_template('index.html',
|
||||||
|
has_update=app.config['HAS_UPDATE'],
|
||||||
languages=app.config['LANGUAGES'],
|
languages=app.config['LANGUAGES'],
|
||||||
countries=app.config['COUNTRIES'],
|
countries=app.config['COUNTRIES'],
|
||||||
|
time_periods=app.config['TIME_PERIODS'],
|
||||||
|
themes=app.config['THEMES'],
|
||||||
|
autocomplete_enabled=autocomplete_enabled,
|
||||||
|
translation=app.config['TRANSLATIONS'][
|
||||||
|
g.user_config.get_localization_lang()
|
||||||
|
],
|
||||||
logo=render_template(
|
logo=render_template(
|
||||||
'logo.html',
|
'logo.html',
|
||||||
config=g.user_config),
|
dark=g.user_config.dark),
|
||||||
|
config_disabled=(
|
||||||
|
app.config['CONFIG_DISABLE'] or
|
||||||
|
not valid_user_session(session)),
|
||||||
config=g.user_config,
|
config=g.user_config,
|
||||||
tor_available=int(os.environ.get('TOR_AVAILABLE')),
|
tor_available=int(os.environ.get('TOR_AVAILABLE')),
|
||||||
version_number=app.config['VERSION_NUMBER'])
|
version_number=app.config['VERSION_NUMBER'])
|
||||||
|
|
||||||
|
|
||||||
@app.route('/opensearch.xml', methods=['GET'])
|
@app.route(f'/{Endpoint.opensearch}', methods=['GET'])
|
||||||
def opensearch():
|
def opensearch():
|
||||||
opensearch_url = g.app_location
|
opensearch_url = g.app_location
|
||||||
if opensearch_url.endswith('/'):
|
if opensearch_url.endswith('/'):
|
||||||
@ -150,12 +244,25 @@ def opensearch():
|
|||||||
return render_template(
|
return render_template(
|
||||||
'opensearch.xml',
|
'opensearch.xml',
|
||||||
main_url=opensearch_url,
|
main_url=opensearch_url,
|
||||||
request_type='' if get_only else 'method="post"'
|
request_type='' if get_only else 'method="post"',
|
||||||
), 200, {'Content-Disposition': 'attachment; filename="opensearch.xml"'}
|
search_type=request.args.get('tbm'),
|
||||||
|
search_name=get_search_name(request.args.get('tbm'))
|
||||||
|
), 200, {'Content-Type': 'application/xml'}
|
||||||
|
|
||||||
|
|
||||||
@app.route('/autocomplete', methods=['GET', 'POST'])
|
@app.route(f'/{Endpoint.search_html}', methods=['GET'])
|
||||||
|
def search_html():
|
||||||
|
search_url = g.app_location
|
||||||
|
if search_url.endswith('/'):
|
||||||
|
search_url = search_url[:-1]
|
||||||
|
return render_template('search.html', url=search_url)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route(f'/{Endpoint.autocomplete}', methods=['GET', 'POST'])
|
||||||
def autocomplete():
|
def autocomplete():
|
||||||
|
if os.getenv(ac_var) and not read_config_bool(ac_var):
|
||||||
|
return jsonify({})
|
||||||
|
|
||||||
q = g.request_params.get('q')
|
q = g.request_params.get('q')
|
||||||
if not q:
|
if not q:
|
||||||
# FF will occasionally (incorrectly) send the q field without a
|
# FF will occasionally (incorrectly) send the q field without a
|
||||||
@ -164,8 +271,7 @@ def autocomplete():
|
|||||||
|
|
||||||
# Search bangs if the query begins with "!", but not "! " (feeling lucky)
|
# Search bangs if the query begins with "!", but not "! " (feeling lucky)
|
||||||
if q.startswith('!') and len(q) > 1 and not q.startswith('! '):
|
if q.startswith('!') and len(q) > 1 and not q.startswith('! '):
|
||||||
return jsonify([q, [bang_json[_]['suggestion'] for _ in bang_json if
|
return jsonify([q, suggest_bang(q)])
|
||||||
_.startswith(q)]])
|
|
||||||
|
|
||||||
if not q and not request.data:
|
if not q and not request.data:
|
||||||
return jsonify({'?': []})
|
return jsonify({'?': []})
|
||||||
@ -177,29 +283,74 @@ def autocomplete():
|
|||||||
#
|
#
|
||||||
# Note: If Tor is enabled, this returns nothing, as the request is
|
# Note: If Tor is enabled, this returns nothing, as the request is
|
||||||
# almost always rejected
|
# almost always rejected
|
||||||
|
# Also check if autocomplete is disabled globally
|
||||||
|
autocomplete_enabled = os.environ.get('WHOOGLE_AUTOCOMPLETE', '1') != '0'
|
||||||
return jsonify([
|
return jsonify([
|
||||||
q,
|
q,
|
||||||
g.user_request.autocomplete(q) if not g.user_config.tor else []
|
g.user_request.autocomplete(q) if (not g.user_config.tor and autocomplete_enabled) else []
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def clean_text_spacing(text: str) -> str:
|
||||||
|
"""Clean up text spacing issues from HTML extraction.
|
||||||
|
|
||||||
@app.route('/search', methods=['GET', 'POST'])
|
Args:
|
||||||
|
text: Text extracted from HTML that may have spacing issues
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cleaned text with proper spacing
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Normalize multiple spaces to single space
|
||||||
|
text = re.sub(r'\s+', ' ', text)
|
||||||
|
|
||||||
|
# Fix domain names: remove space before period followed by domain extension
|
||||||
|
# Examples: "weather .com" -> "weather.com", "example .org" -> "example.org"
|
||||||
|
text = re.sub(r'\s+\.([a-zA-Z]{2,})\b', r'.\1', text)
|
||||||
|
|
||||||
|
# Fix www/http/https patterns
|
||||||
|
# Examples: "www .example" -> "www.example"
|
||||||
|
text = re.sub(r'\b(www|http|https)\s+\.', r'\1.', text)
|
||||||
|
|
||||||
|
# Fix spaces before common punctuation
|
||||||
|
text = re.sub(r'\s+([,;:])', r'\1', text)
|
||||||
|
|
||||||
|
# Strip leading/trailing whitespace
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route(f'/{Endpoint.search}', methods=['GET', 'POST'])
|
||||||
|
@session_required
|
||||||
@auth_required
|
@auth_required
|
||||||
def search():
|
def search():
|
||||||
# Update user config if specified in search args
|
if request.method == 'POST':
|
||||||
g.user_config = g.user_config.from_params(g.request_params)
|
# Redirect as a GET request with an encrypted query
|
||||||
|
post_data = MultiDict(request.form)
|
||||||
|
post_data['q'] = encrypt_string(g.session_key, post_data['q'])
|
||||||
|
get_req_str = urlparse.urlencode(post_data)
|
||||||
|
return redirect(url_for('.search') + '?' + get_req_str)
|
||||||
|
|
||||||
search_util = Search(request, g.user_config, session,
|
search_util = Search(request, g.user_config, g.session_key, user_request=g.user_request)
|
||||||
cookies_disabled=g.cookies_disabled)
|
|
||||||
query = search_util.new_search_query()
|
query = search_util.new_search_query()
|
||||||
|
|
||||||
bang = resolve_bang(query=query, bangs_dict=bang_json)
|
bang = resolve_bang(query)
|
||||||
if bang != '':
|
if bang:
|
||||||
return redirect(bang)
|
return redirect(bang)
|
||||||
|
|
||||||
# Redirect to home if invalid/blank search
|
# Redirect to home if invalid/blank search
|
||||||
if not query:
|
if not query:
|
||||||
return redirect('/')
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
# Check if using Leta with unsupported search type
|
||||||
|
tbm_value = request.args.get('tbm', '').strip()
|
||||||
|
if g.user_config.use_leta and tbm_value:
|
||||||
|
session['error_message'] = (
|
||||||
|
"Image, video, news, and map searches are not supported when using "
|
||||||
|
"Mullvad Leta as the search backend. Please disable Leta in settings "
|
||||||
|
"or perform a regular web search."
|
||||||
|
)
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
# Generate response and number of external elements from the page
|
# Generate response and number of external elements from the page
|
||||||
try:
|
try:
|
||||||
@ -211,125 +362,468 @@ def search():
|
|||||||
'tor']
|
'tor']
|
||||||
return redirect(url_for('.index'))
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
wants_json = (
|
||||||
|
request.args.get('format') == 'json' or
|
||||||
|
'application/json' in request.headers.get('Accept', '') or
|
||||||
|
'application/*+json' in request.headers.get('Accept', '')
|
||||||
|
)
|
||||||
|
|
||||||
if search_util.feeling_lucky:
|
if search_util.feeling_lucky:
|
||||||
|
if wants_json:
|
||||||
|
return jsonify({'redirect': response}), 303
|
||||||
return redirect(response, code=303)
|
return redirect(response, code=303)
|
||||||
|
|
||||||
|
# If the user is attempting to translate a string, determine the correct
|
||||||
|
# string for formatting the lingva.ml url
|
||||||
|
localization_lang = g.user_config.get_localization_lang()
|
||||||
|
translation = app.config['TRANSLATIONS'][localization_lang]
|
||||||
|
translate_to = localization_lang.replace('lang_', '')
|
||||||
|
|
||||||
|
# removing st-card to only use whoogle time selector
|
||||||
|
soup = bsoup(response, "html.parser");
|
||||||
|
for x in soup.find_all(attrs={"id": "st-card"}):
|
||||||
|
x.replace_with("")
|
||||||
|
|
||||||
|
response = str(soup)
|
||||||
|
|
||||||
# Return 503 if temporarily blocked by captcha
|
# Return 503 if temporarily blocked by captcha
|
||||||
resp_code = 503 if has_captcha(str(response)) else 200
|
if has_captcha(str(response)):
|
||||||
|
app.logger.error('503 (CAPTCHA)')
|
||||||
|
fallback_engine = os.environ.get('WHOOGLE_FALLBACK_ENGINE_URL', '')
|
||||||
|
if (fallback_engine):
|
||||||
|
if wants_json:
|
||||||
|
return jsonify({'redirect': fallback_engine + query}), 302
|
||||||
|
return redirect(fallback_engine + query)
|
||||||
|
|
||||||
|
if wants_json:
|
||||||
|
return jsonify({
|
||||||
|
'blocked': True,
|
||||||
|
'error_message': translation['ratelimit'],
|
||||||
|
'query': urlparse.unquote(query)
|
||||||
|
}), 503
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
'error.html',
|
||||||
|
blocked=True,
|
||||||
|
error_message=translation['ratelimit'],
|
||||||
|
translation=translation,
|
||||||
|
farside='https://farside.link',
|
||||||
|
config=g.user_config,
|
||||||
|
query=urlparse.unquote(query),
|
||||||
|
params=g.user_config.to_params(keys=['preferences'])), 503
|
||||||
|
|
||||||
|
response = bold_search_terms(response, query)
|
||||||
|
|
||||||
|
# check for widgets and add if requested
|
||||||
|
if search_util.widget != '':
|
||||||
|
html_soup = bsoup(str(response), 'html.parser')
|
||||||
|
if search_util.widget == 'ip':
|
||||||
|
response = add_ip_card(html_soup, get_client_ip(request))
|
||||||
|
elif search_util.widget == 'calculator' and not 'nojs' in request.args:
|
||||||
|
response = add_calculator_card(html_soup)
|
||||||
|
|
||||||
|
# Update tabs content (fallback to the raw query if full_query isn't set)
|
||||||
|
full_query_val = getattr(search_util, 'full_query', query)
|
||||||
|
tabs = get_tabs_content(app.config['HEADER_TABS'],
|
||||||
|
full_query_val,
|
||||||
|
search_util.search_type,
|
||||||
|
g.user_config.preferences,
|
||||||
|
translation,
|
||||||
|
g.user_config.use_leta)
|
||||||
|
|
||||||
|
# Feature to display currency_card
|
||||||
|
# Since this is determined by more than just the
|
||||||
|
# query is it not defined as a standard widget
|
||||||
|
conversion = check_currency(str(response))
|
||||||
|
if conversion:
|
||||||
|
html_soup = bsoup(str(response), 'html.parser')
|
||||||
|
response = add_currency_card(html_soup, conversion)
|
||||||
|
|
||||||
|
preferences = g.user_config.preferences
|
||||||
|
home_url = f"home?preferences={preferences}" if preferences else "home"
|
||||||
|
cleanresponse = str(response).replace("andlt;","<").replace("andgt;",">")
|
||||||
|
|
||||||
|
if wants_json:
|
||||||
|
# Build a parsable JSON from the filtered soup
|
||||||
|
json_soup = bsoup(str(response), 'html.parser')
|
||||||
|
results = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
# Find all result containers (using known result classes)
|
||||||
|
result_divs = json_soup.find_all('div', class_=['ZINbbc', 'ezO2md'])
|
||||||
|
|
||||||
|
if result_divs:
|
||||||
|
# Process structured Google results with container divs
|
||||||
|
for div in result_divs:
|
||||||
|
# Find the first valid link in this result container
|
||||||
|
link = None
|
||||||
|
for a in div.find_all('a', href=True):
|
||||||
|
if a['href'].startswith('http'):
|
||||||
|
link = a
|
||||||
|
break
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
continue
|
||||||
|
|
||||||
|
href = link['href']
|
||||||
|
if href in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get all text from the result container, not just the link
|
||||||
|
text = clean_text_spacing(div.get_text(separator=' ', strip=True))
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract title and content separately
|
||||||
|
# Title is typically in an h3 tag, CVA68e span, or the main link text
|
||||||
|
title = ''
|
||||||
|
# First try h3 tag
|
||||||
|
h3_tag = div.find('h3')
|
||||||
|
if h3_tag:
|
||||||
|
title = clean_text_spacing(h3_tag.get_text(separator=' ', strip=True))
|
||||||
|
else:
|
||||||
|
# Try CVA68e class (common title class in Google results)
|
||||||
|
title_span = div.find('span', class_='CVA68e')
|
||||||
|
if title_span:
|
||||||
|
title = clean_text_spacing(title_span.get_text(separator=' ', strip=True))
|
||||||
|
elif link:
|
||||||
|
# Fallback to link text, but exclude URL breadcrumb
|
||||||
|
title = clean_text_spacing(link.get_text(separator=' ', strip=True))
|
||||||
|
|
||||||
|
# Content is the description/snippet text
|
||||||
|
# Look for description/snippet elements
|
||||||
|
content = ''
|
||||||
|
# Common classes for snippets/descriptions in Google results
|
||||||
|
snippet_selectors = [
|
||||||
|
{'class_': 'VwiC3b'}, # Standard snippet
|
||||||
|
{'class_': 'FrIlee'}, # Alternative snippet class (common in current Google)
|
||||||
|
{'class_': 's'}, # Another snippet class
|
||||||
|
{'class_': 'st'}, # Legacy snippet class
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in snippet_selectors:
|
||||||
|
snippet_elem = div.find('span', selector) or div.find('div', selector)
|
||||||
|
if snippet_elem:
|
||||||
|
# Get text but exclude any nested links (like "Related searches")
|
||||||
|
content = clean_text_spacing(snippet_elem.get_text(separator=' ', strip=True))
|
||||||
|
# Only use if it's substantial content (not just the URL breadcrumb)
|
||||||
|
if content and not content.startswith('www.') and '›' not in content:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
content = ''
|
||||||
|
|
||||||
|
# If no specific content found, use text minus title as fallback
|
||||||
|
if not content and title:
|
||||||
|
# Try to extract content by removing title from full text
|
||||||
|
if text.startswith(title):
|
||||||
|
content = text[len(title):].strip()
|
||||||
|
else:
|
||||||
|
content = text
|
||||||
|
elif not content:
|
||||||
|
content = text
|
||||||
|
|
||||||
|
seen.add(href)
|
||||||
|
results.append({
|
||||||
|
'href': href,
|
||||||
|
'text': text,
|
||||||
|
'title': title,
|
||||||
|
'content': content
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Fallback: extract links directly if no result containers found
|
||||||
|
for a in json_soup.find_all('a', href=True):
|
||||||
|
href = a['href']
|
||||||
|
if not href.startswith('http'):
|
||||||
|
continue
|
||||||
|
if href in seen:
|
||||||
|
continue
|
||||||
|
text = clean_text_spacing(a.get_text(separator=' ', strip=True))
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
seen.add(href)
|
||||||
|
# In fallback mode, the link text serves as both title and text
|
||||||
|
results.append({
|
||||||
|
'href': href,
|
||||||
|
'text': text,
|
||||||
|
'title': text,
|
||||||
|
'content': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'query': urlparse.unquote(query),
|
||||||
|
'search_type': search_util.search_type,
|
||||||
|
'results': results
|
||||||
|
})
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'display.html',
|
'display.html',
|
||||||
|
has_update=app.config['HAS_UPDATE'],
|
||||||
query=urlparse.unquote(query),
|
query=urlparse.unquote(query),
|
||||||
search_type=search_util.search_type,
|
search_type=search_util.search_type,
|
||||||
|
search_name=get_search_name(search_util.search_type),
|
||||||
config=g.user_config,
|
config=g.user_config,
|
||||||
response=response,
|
autocomplete_enabled=autocomplete_enabled,
|
||||||
|
lingva_url=app.config['TRANSLATE_URL'],
|
||||||
|
translation=translation,
|
||||||
|
translate_to=translate_to,
|
||||||
|
translate_str=query.replace(
|
||||||
|
'translate', ''
|
||||||
|
).replace(
|
||||||
|
translation['translate'], ''
|
||||||
|
),
|
||||||
|
is_translation=any(
|
||||||
|
_ in query.lower() for _ in [translation['translate'], 'translate']
|
||||||
|
) and not search_util.search_type, # Standard search queries only
|
||||||
|
response=cleanresponse,
|
||||||
version_number=app.config['VERSION_NUMBER'],
|
version_number=app.config['VERSION_NUMBER'],
|
||||||
search_header=(render_template(
|
search_header=render_template(
|
||||||
'header.html',
|
'header.html',
|
||||||
|
home_url=home_url,
|
||||||
config=g.user_config,
|
config=g.user_config,
|
||||||
logo=render_template('logo.html'),
|
translation=translation,
|
||||||
|
languages=app.config['LANGUAGES'],
|
||||||
|
countries=app.config['COUNTRIES'],
|
||||||
|
time_periods=app.config['TIME_PERIODS'],
|
||||||
|
logo=render_template('logo.html', dark=g.user_config.dark),
|
||||||
query=urlparse.unquote(query),
|
query=urlparse.unquote(query),
|
||||||
search_type=search_util.search_type,
|
search_type=search_util.search_type,
|
||||||
mobile=g.user_request.mobile)
|
mobile=g.user_request.mobile,
|
||||||
if 'isch' not in search_util.search_type else '')), resp_code
|
tabs=tabs)).replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/config', methods=['GET', 'POST', 'PUT'])
|
@app.route(f'/{Endpoint.config}', methods=['GET', 'POST', 'PUT'])
|
||||||
|
@session_required
|
||||||
@auth_required
|
@auth_required
|
||||||
def config():
|
def config():
|
||||||
|
config_disabled = (
|
||||||
|
app.config['CONFIG_DISABLE'] or
|
||||||
|
not valid_user_session(session))
|
||||||
|
|
||||||
|
name = ''
|
||||||
|
if 'name' in request.args:
|
||||||
|
name = os.path.normpath(request.args.get('name'))
|
||||||
|
if not re.match(r'^[A-Za-z0-9_.+-]+$', name):
|
||||||
|
return make_response('Invalid config name', 400)
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return json.dumps(g.user_config.__dict__)
|
return json.dumps(g.user_config.__dict__)
|
||||||
elif request.method == 'PUT':
|
elif request.method == 'PUT' and not config_disabled:
|
||||||
if 'name' in request.args:
|
if name:
|
||||||
config_pkl = os.path.join(
|
config_pkl = os.path.join(app.config['CONFIG_PATH'], name)
|
||||||
app.config['CONFIG_PATH'],
|
|
||||||
request.args.get('name'))
|
|
||||||
session['config'] = (pickle.load(open(config_pkl, 'rb'))
|
session['config'] = (pickle.load(open(config_pkl, 'rb'))
|
||||||
if os.path.exists(config_pkl)
|
if os.path.exists(config_pkl)
|
||||||
else session['config'])
|
else session['config'])
|
||||||
return json.dumps(session['config'])
|
return json.dumps(session['config'])
|
||||||
else:
|
else:
|
||||||
return json.dumps({})
|
return json.dumps({})
|
||||||
else:
|
elif not config_disabled:
|
||||||
config_data = request.form.to_dict()
|
config_data = request.form.to_dict()
|
||||||
if 'url' not in config_data or not config_data['url']:
|
if 'url' not in config_data or not config_data['url']:
|
||||||
config_data['url'] = g.user_config.url
|
config_data['url'] = g.user_config.url
|
||||||
|
|
||||||
|
# Handle user agent configuration
|
||||||
|
if 'user_agent' in config_data:
|
||||||
|
if config_data['user_agent'] == 'custom':
|
||||||
|
config_data['use_custom_user_agent'] = True
|
||||||
|
# Keep both the selection and the custom string
|
||||||
|
if 'custom_user_agent' in config_data:
|
||||||
|
config_data['custom_user_agent'] = config_data['custom_user_agent']
|
||||||
|
print(f"Setting custom user agent to: {config_data['custom_user_agent']}") # Debug log
|
||||||
|
else:
|
||||||
|
config_data['use_custom_user_agent'] = False
|
||||||
|
# Only clear custom_user_agent if not using custom option
|
||||||
|
if config_data['user_agent'] != 'custom':
|
||||||
|
config_data['custom_user_agent'] = ''
|
||||||
|
|
||||||
# Save config by name to allow a user to easily load later
|
# Save config by name to allow a user to easily load later
|
||||||
if 'name' in request.args:
|
if name:
|
||||||
pickle.dump(
|
pickle.dump(
|
||||||
config_data,
|
config_data,
|
||||||
open(os.path.join(
|
open(os.path.join(
|
||||||
app.config['CONFIG_PATH'],
|
app.config['CONFIG_PATH'],
|
||||||
request.args.get('name')), 'wb'))
|
name), 'wb'))
|
||||||
|
|
||||||
# Overwrite default config if user has cookies disabled
|
|
||||||
if g.cookies_disabled:
|
|
||||||
open(app.config['DEFAULT_CONFIG'], 'w').write(
|
|
||||||
json.dumps(config_data, indent=4))
|
|
||||||
|
|
||||||
session['config'] = config_data
|
session['config'] = config_data
|
||||||
return redirect(config_data['url'])
|
return redirect(config_data['url'])
|
||||||
|
|
||||||
|
|
||||||
@app.route('/url', methods=['GET'])
|
|
||||||
@auth_required
|
|
||||||
def url():
|
|
||||||
if 'url' in request.args:
|
|
||||||
return redirect(request.args.get('url'))
|
|
||||||
|
|
||||||
q = request.args.get('q')
|
|
||||||
if len(q) > 0 and 'http' in q:
|
|
||||||
return redirect(q)
|
|
||||||
else:
|
else:
|
||||||
return render_template(
|
return redirect(url_for('.index'), code=403)
|
||||||
'error.html',
|
|
||||||
error_message='Unable to resolve query: ' + q)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/imgres')
|
@app.route(f'/{Endpoint.imgres}')
|
||||||
|
@session_required
|
||||||
@auth_required
|
@auth_required
|
||||||
def imgres():
|
def imgres():
|
||||||
return redirect(request.args.get('imgurl'))
|
return redirect(request.args.get('imgurl'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/element')
|
@app.route(f'/{Endpoint.element}')
|
||||||
|
@session_required
|
||||||
@auth_required
|
@auth_required
|
||||||
def element():
|
def element():
|
||||||
cipher_suite = Fernet(session['key'])
|
element_url = src_url = request.args.get('url')
|
||||||
src_url = cipher_suite.decrypt(request.args.get('url').encode()).decode()
|
if element_url.startswith('gAAAAA'):
|
||||||
|
try:
|
||||||
|
cipher_suite = Fernet(g.session_key)
|
||||||
|
src_url = cipher_suite.decrypt(element_url.encode()).decode()
|
||||||
|
except (InvalidSignature, InvalidToken) as e:
|
||||||
|
return render_template(
|
||||||
|
'error.html',
|
||||||
|
error_message=str(e)), 401
|
||||||
|
|
||||||
src_type = request.args.get('type')
|
src_type = request.args.get('type')
|
||||||
|
|
||||||
|
# Ensure requested element is from a valid domain
|
||||||
|
domain = urlparse.urlparse(src_url).netloc
|
||||||
|
if not validators.domain(domain):
|
||||||
|
return send_file(io.BytesIO(empty_gif), mimetype='image/gif')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_data = g.user_request.send(base_url=src_url).content
|
response = g.user_request.send(base_url=src_url)
|
||||||
|
|
||||||
|
# Display an empty gif if the requested element couldn't be retrieved
|
||||||
|
if response.status_code != 200 or len(response.content) == 0:
|
||||||
|
if 'favicon' in src_url:
|
||||||
|
favicon = fetch_favicon(src_url)
|
||||||
|
return send_file(io.BytesIO(favicon), mimetype='image/png')
|
||||||
|
else:
|
||||||
|
return send_file(io.BytesIO(empty_gif), mimetype='image/gif')
|
||||||
|
|
||||||
|
file_data = response.content
|
||||||
tmp_mem = io.BytesIO()
|
tmp_mem = io.BytesIO()
|
||||||
tmp_mem.write(file_data)
|
tmp_mem.write(file_data)
|
||||||
tmp_mem.seek(0)
|
tmp_mem.seek(0)
|
||||||
|
|
||||||
return send_file(tmp_mem, mimetype=src_type)
|
return send_file(tmp_mem, mimetype=src_type)
|
||||||
except exceptions.RequestException:
|
except httpx.HTTPError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
empty_gif = base64.b64decode(
|
|
||||||
'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')
|
|
||||||
return send_file(io.BytesIO(empty_gif), mimetype='image/gif')
|
return send_file(io.BytesIO(empty_gif), mimetype='image/gif')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/window')
|
@app.route(f'/{Endpoint.window}')
|
||||||
|
@session_required
|
||||||
@auth_required
|
@auth_required
|
||||||
def window():
|
def window():
|
||||||
get_body = g.user_request.send(base_url=request.args.get('location')).text
|
target_url = request.args.get('location')
|
||||||
get_body = get_body.replace('src="/',
|
if target_url.startswith('gAAAAA'):
|
||||||
'src="' + request.args.get('location') + '"')
|
cipher_suite = Fernet(g.session_key)
|
||||||
get_body = get_body.replace('href="/',
|
target_url = cipher_suite.decrypt(target_url.encode()).decode()
|
||||||
'href="' + request.args.get('location') + '"')
|
|
||||||
|
content_filter = Filter(
|
||||||
|
g.session_key,
|
||||||
|
root_url=request.url_root,
|
||||||
|
config=g.user_config)
|
||||||
|
target = urlparse.urlparse(target_url)
|
||||||
|
|
||||||
|
# Ensure requested URL has a valid domain
|
||||||
|
if not validators.domain(target.netloc):
|
||||||
|
return render_template(
|
||||||
|
'error.html',
|
||||||
|
error_message='Invalid location'), 400
|
||||||
|
|
||||||
|
host_url = f'{target.scheme}://{target.netloc}'
|
||||||
|
|
||||||
|
get_body = g.user_request.send(base_url=target_url).text
|
||||||
|
|
||||||
results = bsoup(get_body, 'html.parser')
|
results = bsoup(get_body, 'html.parser')
|
||||||
|
src_attrs = ['src', 'href', 'srcset', 'data-srcset', 'data-src']
|
||||||
|
|
||||||
for script in results('script'):
|
# Parse HTML response and replace relative links w/ absolute
|
||||||
|
for element in results.find_all():
|
||||||
|
for attr in src_attrs:
|
||||||
|
if not element.has_attr(attr) or not element[attr].startswith('/'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
element[attr] = host_url + element[attr]
|
||||||
|
|
||||||
|
# Replace or remove javascript sources
|
||||||
|
for script in results.find_all('script', {'src': True}):
|
||||||
|
if 'nojs' in request.args:
|
||||||
script.decompose()
|
script.decompose()
|
||||||
|
else:
|
||||||
|
content_filter.update_element_src(script, 'application/javascript')
|
||||||
|
|
||||||
return render_template('display.html', response=results)
|
# Replace all possible image attributes
|
||||||
|
img_sources = ['src', 'data-src', 'data-srcset', 'srcset']
|
||||||
|
for img in results.find_all('img'):
|
||||||
|
_ = [
|
||||||
|
content_filter.update_element_src(img, 'image/png', attr=_)
|
||||||
|
for _ in img_sources if img.has_attr(_)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Replace all stylesheet sources
|
||||||
|
for link in results.find_all('link', {'href': True}):
|
||||||
|
content_filter.update_element_src(link, 'text/css', attr='href')
|
||||||
|
|
||||||
|
# Use anonymous view for all links on page
|
||||||
|
for a in results.find_all('a', {'href': True}):
|
||||||
|
a['href'] = f'{Endpoint.window}?location=' + a['href'] + (
|
||||||
|
'&nojs=1' if 'nojs' in request.args else '')
|
||||||
|
|
||||||
|
# Remove all iframes -- these are commonly used inside of <noscript> tags
|
||||||
|
# to enforce loading Google Analytics
|
||||||
|
for iframe in results.find_all('iframe'):
|
||||||
|
iframe.decompose()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'display.html',
|
||||||
|
response=results,
|
||||||
|
translation=app.config['TRANSLATIONS'][
|
||||||
|
g.user_config.get_localization_lang()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/robots.txt')
|
||||||
|
def robots():
|
||||||
|
response = make_response(
|
||||||
|
'''User-Agent: *
|
||||||
|
Disallow: /''', 200)
|
||||||
|
response.mimetype = 'text/plain'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/favicon.ico')
|
||||||
|
def favicon():
|
||||||
|
return app.send_static_file('img/favicon.ico')
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def page_not_found(e):
|
||||||
|
return render_template('error.html', error_message=str(e)), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def internal_error(e):
|
||||||
|
query = ''
|
||||||
|
if request.method == 'POST':
|
||||||
|
query = request.form.get('q')
|
||||||
|
else:
|
||||||
|
query = request.args.get('q')
|
||||||
|
|
||||||
|
# Attempt to parse the query
|
||||||
|
try:
|
||||||
|
search_util = Search(request, g.user_config, g.session_key)
|
||||||
|
query = search_util.new_search_query()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(traceback.format_exc(), file=sys.stderr)
|
||||||
|
|
||||||
|
fallback_engine = os.environ.get('WHOOGLE_FALLBACK_ENGINE_URL', '')
|
||||||
|
if (fallback_engine):
|
||||||
|
return redirect(fallback_engine + (query or ''))
|
||||||
|
|
||||||
|
localization_lang = g.user_config.get_localization_lang()
|
||||||
|
translation = app.config['TRANSLATIONS'][localization_lang]
|
||||||
|
return render_template(
|
||||||
|
'error.html',
|
||||||
|
error_message='Internal server error (500)',
|
||||||
|
translation=translation,
|
||||||
|
farside='https://farside.link',
|
||||||
|
config=g.user_config,
|
||||||
|
query=urlparse.unquote(query or ''),
|
||||||
|
params=g.user_config.to_params(keys=['preferences'])), 500
|
||||||
|
|
||||||
|
|
||||||
def run_app() -> None:
|
def run_app() -> None:
|
||||||
@ -345,6 +839,16 @@ def run_app() -> None:
|
|||||||
default='127.0.0.1',
|
default='127.0.0.1',
|
||||||
metavar='<ip address>',
|
metavar='<ip address>',
|
||||||
help='Specifies the host address to use (default 127.0.0.1)')
|
help='Specifies the host address to use (default 127.0.0.1)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--unix-socket',
|
||||||
|
default='',
|
||||||
|
metavar='</path/to/unix.sock>',
|
||||||
|
help='Listen for app on unix socket instead of host:port')
|
||||||
|
parser.add_argument(
|
||||||
|
'--unix-socket-perms',
|
||||||
|
default='600',
|
||||||
|
metavar='<octal permissions>',
|
||||||
|
help='Octal permissions to use for the Unix domain socket (default 600)')
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--debug',
|
'--debug',
|
||||||
default=False,
|
default=False,
|
||||||
@ -390,9 +894,15 @@ def run_app() -> None:
|
|||||||
os.environ['WHOOGLE_PROXY_TYPE'] = args.proxytype
|
os.environ['WHOOGLE_PROXY_TYPE'] = args.proxytype
|
||||||
os.environ['WHOOGLE_PROXY_LOC'] = args.proxyloc
|
os.environ['WHOOGLE_PROXY_LOC'] = args.proxyloc
|
||||||
|
|
||||||
os.environ['HTTPS_ONLY'] = '1' if args.https_only else ''
|
if args.https_only:
|
||||||
|
os.environ['HTTPS_ONLY'] = '1'
|
||||||
|
|
||||||
if args.debug:
|
if args.debug:
|
||||||
app.run(host=args.host, port=args.port, debug=args.debug)
|
app.run(host=args.host, port=args.port, debug=args.debug)
|
||||||
|
elif args.unix_socket:
|
||||||
|
waitress.serve(app, unix_socket=args.unix_socket, unix_socket_perms=args.unix_socket_perms)
|
||||||
else:
|
else:
|
||||||
waitress.serve(app, listen="{}:{}".format(args.host, args.port))
|
waitress.serve(
|
||||||
|
app,
|
||||||
|
listen="{}:{}".format(args.host, args.port),
|
||||||
|
url_prefix=os.environ.get('WHOOGLE_URL_PREFIX', ''))
|
||||||
|
|||||||
2
app/services/__init__.py
Normal file
2
app/services/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
|
||||||
219
app/services/http_client.py
Normal file
219
app/services/http_client.py
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from cachetools import TTLCache
|
||||||
|
import ssl
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Import h2 exceptions for better error handling
|
||||||
|
try:
|
||||||
|
from h2.exceptions import ProtocolError as H2ProtocolError
|
||||||
|
except ImportError:
|
||||||
|
H2ProtocolError = None
|
||||||
|
|
||||||
|
|
||||||
|
class HttpxClient:
|
||||||
|
"""Thin wrapper around httpx.Client providing simple retries and optional TTL caching.
|
||||||
|
|
||||||
|
The client is intended to be safe for reuse across requests. Per-request
|
||||||
|
overrides for headers/cookies are supported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
proxies: Optional[Dict[str, str]] = None,
|
||||||
|
timeout_seconds: float = 15.0,
|
||||||
|
cache_ttl_seconds: int = 30,
|
||||||
|
cache_maxsize: int = 256,
|
||||||
|
http2: bool = True) -> None:
|
||||||
|
# Allow disabling HTTP/2 via environment variable
|
||||||
|
# HTTP/2 can sometimes cause protocol errors with certain servers
|
||||||
|
if os.environ.get('WHOOGLE_DISABLE_HTTP2', '').lower() in ('1', 'true', 't', 'yes', 'y'):
|
||||||
|
http2 = False
|
||||||
|
|
||||||
|
client_kwargs = dict(http2=http2,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
follow_redirects=True)
|
||||||
|
# Prefer future-proof mounts when proxies are provided; fall back to proxies=
|
||||||
|
self._proxies = proxies or {}
|
||||||
|
self._http2 = http2
|
||||||
|
|
||||||
|
# Determine verify behavior and initialize client with fallbacks
|
||||||
|
self._verify = self._determine_verify_setting()
|
||||||
|
try:
|
||||||
|
self._client = self._build_client(client_kwargs, self._verify)
|
||||||
|
except ssl.SSLError:
|
||||||
|
# Fallback to system trust store
|
||||||
|
try:
|
||||||
|
system_ctx = ssl.create_default_context()
|
||||||
|
self._client = self._build_client(client_kwargs, system_ctx)
|
||||||
|
self._verify = system_ctx
|
||||||
|
except ssl.SSLError:
|
||||||
|
insecure_fallback = os.environ.get('WHOOGLE_INSECURE_FALLBACK', '0').lower() in ('1', 'true', 't', 'yes', 'y')
|
||||||
|
if insecure_fallback:
|
||||||
|
self._client = self._build_client(client_kwargs, False)
|
||||||
|
self._verify = False
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
self._cache = TTLCache(maxsize=cache_maxsize, ttl=cache_ttl_seconds)
|
||||||
|
self._cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
def _determine_verify_setting(self):
|
||||||
|
"""Determine SSL verification setting from environment.
|
||||||
|
|
||||||
|
Honors:
|
||||||
|
- WHOOGLE_CA_BUNDLE: path to CA bundle file
|
||||||
|
- WHOOGLE_SSL_VERIFY: '0' to disable verification
|
||||||
|
- WHOOGLE_SSL_BACKEND: 'system' to prefer system trust store
|
||||||
|
"""
|
||||||
|
ca_bundle = os.environ.get('WHOOGLE_CA_BUNDLE', '').strip()
|
||||||
|
if ca_bundle:
|
||||||
|
return ca_bundle
|
||||||
|
|
||||||
|
verify_env = os.environ.get('WHOOGLE_SSL_VERIFY', '1').lower()
|
||||||
|
if verify_env in ('0', 'false', 'no', 'n'):
|
||||||
|
return False
|
||||||
|
|
||||||
|
backend = os.environ.get('WHOOGLE_SSL_BACKEND', '').lower()
|
||||||
|
if backend == 'system':
|
||||||
|
return ssl.create_default_context()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _build_client(self, client_kwargs: Dict[str, Any], verify: Any) -> httpx.Client:
|
||||||
|
"""Construct httpx.Client with proxies and provided verify setting."""
|
||||||
|
kwargs = dict(client_kwargs)
|
||||||
|
kwargs['verify'] = verify
|
||||||
|
if self._proxies:
|
||||||
|
proxy_values = list(self._proxies.values())
|
||||||
|
single_proxy = proxy_values[0] if proxy_values and all(v == proxy_values[0] for v in proxy_values) else None
|
||||||
|
if single_proxy:
|
||||||
|
try:
|
||||||
|
return httpx.Client(proxy=single_proxy, **kwargs)
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
return httpx.Client(proxies=self._proxies, **kwargs)
|
||||||
|
except TypeError:
|
||||||
|
mounts: Dict[str, httpx.Proxy] = {}
|
||||||
|
for scheme_key, url in self._proxies.items():
|
||||||
|
prefix = f"{scheme_key}://"
|
||||||
|
mounts[prefix] = httpx.Proxy(url)
|
||||||
|
return httpx.Client(mounts=mounts, **kwargs)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return httpx.Client(proxies=self._proxies, **kwargs)
|
||||||
|
except TypeError:
|
||||||
|
mounts: Dict[str, httpx.Proxy] = {}
|
||||||
|
for scheme_key, url in self._proxies.items():
|
||||||
|
prefix = f"{scheme_key}://"
|
||||||
|
mounts[prefix] = httpx.Proxy(url)
|
||||||
|
return httpx.Client(mounts=mounts, **kwargs)
|
||||||
|
else:
|
||||||
|
return httpx.Client(**kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def proxies(self) -> Dict[str, str]:
|
||||||
|
return self._proxies
|
||||||
|
|
||||||
|
def _cache_key(self, method: str, url: str, headers: Optional[Dict[str, str]]) -> Tuple[str, str, Tuple[Tuple[str, str], ...]]:
|
||||||
|
normalized_headers = tuple(sorted((headers or {}).items()))
|
||||||
|
return (method.upper(), url, normalized_headers)
|
||||||
|
|
||||||
|
def get(self,
|
||||||
|
url: str,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
cookies: Optional[Dict[str, str]] = None,
|
||||||
|
retries: int = 2,
|
||||||
|
backoff_seconds: float = 0.5,
|
||||||
|
use_cache: bool = False) -> httpx.Response:
|
||||||
|
if use_cache:
|
||||||
|
key = self._cache_key('GET', url, headers)
|
||||||
|
with self._cache_lock:
|
||||||
|
cached = self._cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
attempt = 0
|
||||||
|
while attempt <= retries:
|
||||||
|
try:
|
||||||
|
# Check if client is closed and recreate if needed
|
||||||
|
if self._client.is_closed:
|
||||||
|
self._recreate_client()
|
||||||
|
|
||||||
|
response = self._client.get(url, headers=headers, cookies=cookies)
|
||||||
|
if use_cache and response.status_code == 200:
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache[key] = response
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
# Check for specific errors that require client recreation
|
||||||
|
should_recreate = False
|
||||||
|
|
||||||
|
if isinstance(exc, (httpx.HTTPError, RuntimeError)):
|
||||||
|
if "client has been closed" in str(exc).lower():
|
||||||
|
should_recreate = True
|
||||||
|
|
||||||
|
# Handle H2 protocol errors (connection state issues)
|
||||||
|
if H2ProtocolError and isinstance(exc, H2ProtocolError):
|
||||||
|
should_recreate = True
|
||||||
|
|
||||||
|
# Also check if the error message contains h2 protocol error info
|
||||||
|
if "ProtocolError" in str(exc) or "ConnectionState.CLOSED" in str(exc):
|
||||||
|
should_recreate = True
|
||||||
|
|
||||||
|
if should_recreate:
|
||||||
|
self._recreate_client()
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(backoff_seconds * (2 ** attempt))
|
||||||
|
attempt += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For non-recoverable errors or last attempt, raise
|
||||||
|
if attempt == retries:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# For other errors, still retry with backoff
|
||||||
|
time.sleep(backoff_seconds * (2 ** attempt))
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
|
# Should not reach here
|
||||||
|
if last_exc:
|
||||||
|
raise last_exc
|
||||||
|
raise httpx.HTTPError('Unknown HTTP error')
|
||||||
|
|
||||||
|
def _recreate_client(self) -> None:
|
||||||
|
"""Recreate the HTTP client when it has been closed."""
|
||||||
|
try:
|
||||||
|
self._client.close()
|
||||||
|
except Exception:
|
||||||
|
pass # Client might already be closed
|
||||||
|
|
||||||
|
# Recreate with same configuration
|
||||||
|
client_kwargs = dict(timeout=self._timeout_seconds,
|
||||||
|
follow_redirects=True,
|
||||||
|
http2=self._http2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client = self._build_client(client_kwargs, self._verify)
|
||||||
|
except ssl.SSLError:
|
||||||
|
try:
|
||||||
|
system_ctx = ssl.create_default_context()
|
||||||
|
self._client = self._build_client(client_kwargs, system_ctx)
|
||||||
|
self._verify = system_ctx
|
||||||
|
except ssl.SSLError:
|
||||||
|
insecure_fallback = os.environ.get('WHOOGLE_INSECURE_FALLBACK', '0').lower() in ('1', 'true', 't', 'yes', 'y')
|
||||||
|
if insecure_fallback:
|
||||||
|
self._client = self._build_client(client_kwargs, False)
|
||||||
|
self._verify = False
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
|
||||||
40
app/services/provider.py
Normal file
40
app/services/provider.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import os
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
from app.services.http_client import HttpxClient
|
||||||
|
|
||||||
|
|
||||||
|
_clients: Dict[tuple, HttpxClient] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _proxies_key(proxies: Dict[str, str]) -> Tuple[Tuple[str, str], Tuple[str, str]]:
|
||||||
|
if not proxies:
|
||||||
|
return tuple(), tuple()
|
||||||
|
# Separate http/https for stable key
|
||||||
|
items = sorted((proxies or {}).items())
|
||||||
|
return tuple(items), tuple(items)
|
||||||
|
|
||||||
|
|
||||||
|
def get_http_client(proxies: Dict[str, str]) -> HttpxClient:
|
||||||
|
# Determine HTTP/2 enablement from env (default on)
|
||||||
|
http2_env = os.environ.get('WHOOGLE_HTTP2', '1').lower()
|
||||||
|
http2_enabled = http2_env in ('1', 'true', 't', 'yes', 'y')
|
||||||
|
|
||||||
|
key = (_proxies_key(proxies or {}), http2_enabled)
|
||||||
|
client = _clients.get(key)
|
||||||
|
if client is not None:
|
||||||
|
return client
|
||||||
|
client = HttpxClient(proxies=proxies or None, http2=http2_enabled)
|
||||||
|
_clients[key] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def close_all_clients() -> None:
|
||||||
|
for client in list(_clients.values()):
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_clients.clear()
|
||||||
|
|
||||||
|
|
||||||
14
app/static/bangs/00-whoogle.json
Normal file
14
app/static/bangs/00-whoogle.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"!i": {
|
||||||
|
"url": "search?q={}&tbm=isch",
|
||||||
|
"suggestion": "!i (Whoogle Images)"
|
||||||
|
},
|
||||||
|
"!v": {
|
||||||
|
"url": "search?q={}&tbm=vid",
|
||||||
|
"suggestion": "!v (Whoogle Videos)"
|
||||||
|
},
|
||||||
|
"!n": {
|
||||||
|
"url": "search?q={}&tbm=nws",
|
||||||
|
"suggestion": "!n (Whoogle News)"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
app/static/build/.gitignore
vendored
Normal file
2
app/static/build/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@ -15,27 +15,32 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
li a {
|
li a {
|
||||||
color: #4b8eaa !important;
|
color: var(--whoogle-dark-result-url) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
color: var(--whoogle-dark-text) !important;
|
color: var(--whoogle-dark-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anon-view {
|
||||||
|
color: var(--whoogle-dark-text) !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
background: var(--whoogle-dark-page-bg) !important;
|
background: var(--whoogle-dark-page-bg) !important;
|
||||||
color: var(--whoogle-dark-text) !important;
|
color: var(--whoogle-dark-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:visited h3 div {
|
a:visited h3 div, a:visited .qXLe6d {
|
||||||
color: var(--whoogle-dark-result-visited) !important;
|
color: var(--whoogle-dark-result-visited) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:link h3 div {
|
a:link h3 div, a:link .qXLe6d {
|
||||||
color: var(--whoogle-dark-result-title) !important;
|
color: var(--whoogle-dark-result-title) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:link div {
|
a:link div, a:link .fYyStc {
|
||||||
color: var(--whoogle-dark-result-url) !important;
|
color: var(--whoogle-dark-result-url) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,17 +62,61 @@ select {
|
|||||||
background-color: var(--whoogle-dark-page-bg) !important;
|
background-color: var(--whoogle-dark-page-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ZINbbc {
|
.ZINbbc, .ezO2md {
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 0 0 !important;
|
||||||
background-color: var(--whoogle-dark-result-bg) !important;
|
background-color: var(--whoogle-dark-result-bg) !important;
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BsXmcf {
|
||||||
|
background-color: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.KP7LCb {
|
||||||
|
box-shadow: 0 0 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BVG0Nb {
|
||||||
|
box-shadow: 0 0 0 0 !important;
|
||||||
|
background-color: var(--whoogle-dark-page-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ZINbbc.luh4tb {
|
||||||
|
background: var(--whoogle-dark-result-bg) !important;
|
||||||
|
margin-bottom: 24px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bRsWnc {
|
.bRsWnc {
|
||||||
background-color: var(--whoogle-dark-result-bg) !important;
|
background-color: var(--whoogle-dark-result-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.x54gtf {
|
||||||
|
background-color: var(--whoogle-dark-divider) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Q0HXG {
|
||||||
|
background-color: var(--whoogle-dark-divider) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.LKSyXe {
|
||||||
|
background-color: var(--whoogle-dark-divider) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search {
|
||||||
|
border-color: var(--whoogle-dark-element-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa1toc {
|
||||||
|
background: var(--whoogle-dark-page-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
#search-bar {
|
#search-bar {
|
||||||
border: 2px solid var(--whoogle-dark-element-bg) !important;
|
border-color: var(--whoogle-dark-element-bg) !important;
|
||||||
color: var(--whoogle-dark-text) !important;
|
color: var(--whoogle-dark-text) !important;
|
||||||
|
background-color: var(--whoogle-dark-result-bg) !important;
|
||||||
|
border-bottom: 2px solid var(--whoogle-dark-element-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#search-bar:focus {
|
#search-bar:focus {
|
||||||
@ -86,11 +135,11 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.collapsible {
|
.collapsible {
|
||||||
color: var(--whoogle-dark-element-bg) !important;
|
color: var(--whoogle-dark-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsible:after {
|
.collapsible:after {
|
||||||
color: var(--whoogle-dark-element-bg) !important;
|
color: var(--whoogle-dark-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
@ -98,34 +147,76 @@ select {
|
|||||||
color: var(--whoogle-dark-contrast-text) !important;
|
color: var(--whoogle-dark-contrast-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content, .result-config {
|
||||||
background-color: var(--whoogle-dark-element-bg) !important;
|
background-color: var(--whoogle-dark-element-bg) !important;
|
||||||
color: var(--whoogle-contrast-text) !important;
|
color: var(--whoogle-contrast-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active:after {
|
.active:after {
|
||||||
|
color: var(--whoogle-dark-contrast-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
color: var(--whoogle-dark-contrast-text);
|
color: var(--whoogle-dark-contrast-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
#gh-link {
|
.link-color {
|
||||||
color: var(--whoogle-dark-element-bg);
|
color: var(--whoogle-dark-result-url) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-items {
|
.autocomplete-items {
|
||||||
border: 1px solid #685e79;
|
border: 1px solid var(--whoogle-dark-element-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-items div {
|
.autocomplete-items div {
|
||||||
color: #fff;
|
color: var(--whoogle-dark-text);
|
||||||
background-color: #222;
|
background-color: var(--whoogle-dark-page-bg);
|
||||||
border-bottom: 1px solid #242424;
|
border-bottom: 1px solid var(--whoogle-dark-element-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-items div:hover {
|
.autocomplete-items div:hover {
|
||||||
background-color: #404040;
|
background-color: var(--whoogle-dark-element-bg);
|
||||||
|
color: var(--whoogle-dark-contrast-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-active {
|
.autocomplete-active {
|
||||||
background-color: var(--whoogle-dark-element-bg) !important;
|
background-color: var(--whoogle-dark-element-bg) !important;
|
||||||
color: var(--whoogle-dark-contrast-text) !important;
|
color: var(--whoogle-dark-contrast-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
color: var(--whoogle-dark-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: var(--whoogle-dark-logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-div {
|
||||||
|
background-color: var(--whoogle-dark-result-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-reset {
|
||||||
|
color: var(--whoogle-dark-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-bar {
|
||||||
|
background-color: var(--whoogle-dark-result-bg) !important;
|
||||||
|
color: var(--whoogle-dark-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar-desktop {
|
||||||
|
color: var(--whoogle-dark-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-text-div, .update_available, .cb_label, .cb {
|
||||||
|
color: var(--whoogle-dark-secondary-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb:focus {
|
||||||
|
color: var(--whoogle-dark-contrast-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-header, .mobile-header {
|
||||||
|
background-color: var(--whoogle-dark-result-bg) !important;
|
||||||
|
}
|
||||||
|
|||||||
9
app/static/css/error.css
Normal file
9
app/static/css/error.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
html {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
html {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,9 +13,18 @@ header {
|
|||||||
border-radius: 2px 0 0 0;
|
border-radius: 2px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-config {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-logo {
|
.mobile-logo {
|
||||||
font: 22px/36px Futura, Arial, sans-serif;
|
font: 22px/36px Futura, Arial, sans-serif;
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-div {
|
.logo-div {
|
||||||
@ -27,6 +36,11 @@ header {
|
|||||||
font-smoothing: antialiased;
|
font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-bar-desktop {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
height: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.search-div {
|
.search-div {
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
box-shadow: 0 1px 6px rgba(32, 33, 36, 0.18);
|
box-shadow: 0 1px 6px rgba(32, 33, 36, 0.18);
|
||||||
@ -37,6 +51,7 @@ header {
|
|||||||
height: 39px;
|
height: 39px;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
@ -61,8 +76,175 @@ header {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#main>div:focus-within {
|
#main>div:focus-within {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 0 6px 1px #2375e8;
|
box-shadow: 0 0 6px 1px #2375e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#mobile-header-logo {
|
||||||
|
height: 1.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-input-div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-bar {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 0 0 8px;
|
||||||
|
padding-right: 0px;
|
||||||
|
-webkit-box-flex: 1;
|
||||||
|
height: 35px;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0,0,0,.00);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-mobile{
|
||||||
|
display: -webkit-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-header-logo {
|
||||||
|
height: 1.65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-autocomplete {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #1967D2;
|
||||||
|
text-decoration: none;
|
||||||
|
tap-highlight-color: rgba(0, 0, 0, .10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tab-div {
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
box-shadow: 0 2px 3px rgba(32, 33, 36, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tab-div-2 {
|
||||||
|
border-top: 1px solid #dadce0;
|
||||||
|
height: 39px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tab-div-3 {
|
||||||
|
height: 51px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-header {
|
||||||
|
height: 39px;
|
||||||
|
display: box;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tab {
|
||||||
|
box-pack: justify;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 37px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-header a, .desktop-header span {
|
||||||
|
color: #70757a;
|
||||||
|
display: block;
|
||||||
|
flex: none;
|
||||||
|
padding: 0 16px;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.header-tab-span {
|
||||||
|
border-bottom: 2px solid #4285f4;
|
||||||
|
color: #4285f4;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
height: 39px;
|
||||||
|
display: box;
|
||||||
|
display: flex;
|
||||||
|
overflow-x: scroll;
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header a, .mobile-header span {
|
||||||
|
color: #70757a;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
/* padding: 8px 12px 8px 12px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
span.mobile-tab-span {
|
||||||
|
border-bottom: 2px solid #202124;
|
||||||
|
color: #202124;
|
||||||
|
height: 26px;
|
||||||
|
/* margin: 0 12px; */
|
||||||
|
/* padding: 0; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-header input {
|
||||||
|
margin: 2px 4px 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.header-tab-a:visited {
|
||||||
|
color: #70757a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tab-div-end {
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-search {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-search:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#adv-search-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-collapsible {
|
||||||
|
max-height: 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height .25s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar-input {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 0 0 8px;
|
||||||
|
flex: 1;
|
||||||
|
height: 35px;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#result-country {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 801px) {
|
||||||
|
.header-tab-div {
|
||||||
|
margin-bottom: 10px !important
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
41
app/static/css/input.css
Normal file
41
app/static/css/input.css
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#search-bar {
|
||||||
|
background: transparent !important;
|
||||||
|
padding-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-reset {
|
||||||
|
all: unset;
|
||||||
|
margin-left: -50px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: transparent !important;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 40px;
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
.ZINbbc.xpd.O9g5cc.uUPGi input::-webkit-outer-spin-button,
|
||||||
|
input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb {
|
||||||
|
width: 40%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #5f6368;
|
||||||
|
font-size: 14px !important;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 0 0 12px;
|
||||||
|
margin: 10px 10px 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion_box {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ZINbbc.xpd.O9g5cc.uUPGi input:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
@ -15,13 +15,18 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
li a {
|
li a {
|
||||||
color: #4b8eaa !important;
|
color: var(--whoogle-result-url) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
color: var(--whoogle-text) !important;
|
color: var(--whoogle-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anon-view {
|
||||||
|
color: var(--whoogle-text) !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
background: var(--whoogle-page-bg) !important;
|
background: var(--whoogle-page-bg) !important;
|
||||||
color: var(--whoogle-text) !important;
|
color: var(--whoogle-text) !important;
|
||||||
@ -33,22 +38,52 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ZINbbc {
|
.ZINbbc {
|
||||||
|
overflow: hidden;
|
||||||
background-color: var(--whoogle-result-bg) !important;
|
background-color: var(--whoogle-result-bg) !important;
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: 0 1px 6px rgba(32,33,36,0.28) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BsXmcf {
|
||||||
|
background-color: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BVG0Nb {
|
||||||
|
background-color: var(--whoogle-result-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ZINbbc.luh4tb {
|
||||||
|
background: var(--whoogle-result-bg) !important;
|
||||||
|
margin-bottom: 24px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bRsWnc {
|
.bRsWnc {
|
||||||
background-color: var(--whoogle-result-bg) !important;
|
background-color: var(--whoogle-result-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:visited h3 div {
|
.x54gtf {
|
||||||
|
background-color: var(--whoogle-divider) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Q0HXG {
|
||||||
|
background-color: var(--whoogle-divider) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.LKSyXe {
|
||||||
|
background-color: var(--whoogle-divider) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
a:visited div, a:visited .qXLe6d {
|
||||||
color: var(--whoogle-result-visited) !important;
|
color: var(--whoogle-result-visited) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:link h3 div {
|
a:link div, a:link .qXLe6d {
|
||||||
color: var(--whoogle-result-title) !important;
|
color: var(--whoogle-result-title) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:link div {
|
a:link div, a:link .fYyStc {
|
||||||
color: var(--whoogle-result-url) !important;
|
color: var(--whoogle-result-url) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,7 +102,7 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-search {
|
.home-search {
|
||||||
border: 3px solid var(--whoogle-element-bg) !important;
|
border-color: var(--whoogle-element-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
@ -86,11 +121,11 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.collapsible {
|
.collapsible {
|
||||||
color: var(--whoogle-element-bg) !important;
|
color: var(--whoogle-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsible:after {
|
.collapsible:after {
|
||||||
color: var(--whoogle-element-bg) !important;
|
color: var(--whoogle-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
@ -98,7 +133,7 @@ input {
|
|||||||
color: var(--whoogle-contrast-text) !important;
|
color: var(--whoogle-contrast-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content, .result-config {
|
||||||
background-color: var(--whoogle-element-bg) !important;
|
background-color: var(--whoogle-element-bg) !important;
|
||||||
color: var(--whoogle-contrast-text) !important;
|
color: var(--whoogle-contrast-text) !important;
|
||||||
}
|
}
|
||||||
@ -107,24 +142,68 @@ input {
|
|||||||
color: var(--whoogle-contrast-text);
|
color: var(--whoogle-contrast-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
#gh-link {
|
.link {
|
||||||
color: var(--whoogle-element-bg);
|
color: var(--whoogle-element-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-color {
|
||||||
|
color: var(--whoogle-result-url) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.autocomplete-items {
|
.autocomplete-items {
|
||||||
border: 1px solid #d4d4d4;
|
border: 1px solid var(--whoogle-element-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-items div {
|
.autocomplete-items div {
|
||||||
background-color: #fff;
|
background-color: var(--whoogle-page-bg);
|
||||||
border-bottom: 1px solid #d4d4d4;
|
border-bottom: 1px solid var(--whoogle-element-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-items div:hover {
|
.autocomplete-items div:hover {
|
||||||
background-color: #e9e9e9;
|
background-color: var(--whoogle-element-bg);
|
||||||
|
color: var(--whoogle-contrast-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-active {
|
.autocomplete-active {
|
||||||
background-color: var(--whoogle-element-bg) !important;
|
background-color: var(--whoogle-element-bg) !important;
|
||||||
color: var(--whoogle-contrast-text) !important;
|
color: var(--whoogle-contrast-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
color: var(--whoogle-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: var(--whoogle-logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-div {
|
||||||
|
background-color: var(--whoogle-result-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-reset {
|
||||||
|
color: var(--whoogle-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-bar {
|
||||||
|
background-color: var(--whoogle-result-bg) !important;
|
||||||
|
color: var(--whoogle-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar-desktop {
|
||||||
|
background-color: var(--whoogle-result-bg) !important;
|
||||||
|
color: var(--whoogle-text);
|
||||||
|
border-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-text-div, .update_available, .cb_label, .cb {
|
||||||
|
color: var(--whoogle-secondary-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb:focus {
|
||||||
|
color: var(--whoogle-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-header, .mobile-header {
|
||||||
|
background-color: var(--whoogle-result-bg) !important;
|
||||||
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ a {
|
|||||||
|
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
svg {
|
svg {
|
||||||
margin-top: .7em;
|
margin-top: .3em;
|
||||||
|
height: 70%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,11 @@ body {
|
|||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-search {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 3px solid;
|
||||||
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
width: 80%;
|
width: 80%;
|
||||||
@ -56,6 +61,15 @@ body {
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-options {
|
||||||
|
max-height: 370px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-buttons {
|
||||||
|
max-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.config-div {
|
.config-div {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
@ -130,6 +144,7 @@ footer {
|
|||||||
|
|
||||||
.whoogle-svg {
|
.whoogle-svg {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
|
height: initial;
|
||||||
display: block;
|
display: block;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
@ -162,3 +177,14 @@ details summary {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile styles */
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-bar {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,18 @@
|
|||||||
|
body {
|
||||||
|
display: block !important;
|
||||||
|
margin: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vvjwJb {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ezO2md {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: 0 3px 5px rgb(0 0 0 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.autocomplete {
|
.autocomplete {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@ -22,6 +37,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
details summary {
|
details summary {
|
||||||
padding: 10px;
|
margin-bottom: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary span {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
#lingva-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 650px;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-address-div {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-text-div {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-favicon {
|
||||||
|
float: left;
|
||||||
|
width: 25px;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-favicon .sCuL3 {
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flex_text_audio_icon_chunk {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio {
|
||||||
|
display: block;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 801px) {
|
||||||
|
body {
|
||||||
|
min-width: 736px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 801px) {
|
||||||
|
details summary {
|
||||||
|
margin-bottom: 10px !important
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
/* LIGHT THEME COLORS */
|
/* LIGHT THEME COLORS */
|
||||||
--whoogle-logo: #685e79;
|
--whoogle-logo: #685e79;
|
||||||
--whoogle-page-bg: #ffffff;
|
--whoogle-page-bg: #ffffff;
|
||||||
--whoogle-element-bg: #685e79;
|
--whoogle-element-bg: #4285f4;
|
||||||
--whoogle-text: #000000;
|
--whoogle-text: #000000;
|
||||||
--whoogle-contrast-text: #ffffff;
|
--whoogle-contrast-text: #ffffff;
|
||||||
--whoogle-secondary-text: #70757a;
|
--whoogle-secondary-text: #70757a;
|
||||||
@ -14,13 +14,41 @@
|
|||||||
|
|
||||||
/* DARK THEME COLORS */
|
/* DARK THEME COLORS */
|
||||||
--whoogle-dark-logo: #685e79;
|
--whoogle-dark-logo: #685e79;
|
||||||
--whoogle-dark-page-bg: #222222;
|
--whoogle-dark-page-bg: #101020;
|
||||||
--whoogle-dark-element-bg: #685e79;
|
--whoogle-dark-element-bg: #4285f4;
|
||||||
--whoogle-dark-text: #ffffff;
|
--whoogle-dark-text: #ffffff;
|
||||||
--whoogle-dark-contrast-text: #000000;
|
--whoogle-dark-contrast-text: #ffffff;
|
||||||
--whoogle-dark-secondary-text: #bbbbbb;
|
--whoogle-dark-secondary-text: #bbbbbb;
|
||||||
--whoogle-dark-result-bg: #000000;
|
--whoogle-dark-result-bg: #212131;
|
||||||
--whoogle-dark-result-title: #1967d2;
|
--whoogle-dark-result-title: #64a7f6;
|
||||||
--whoogle-dark-result-url: #4b11a8;
|
--whoogle-dark-result-url: #34a853;
|
||||||
--whoogle-dark-result-visited: #bbbbff;
|
--whoogle-dark-result-visited: #bbbbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#whoogle-w {
|
||||||
|
fill: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoogle-h {
|
||||||
|
fill: #ea4335;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoogle-o-1 {
|
||||||
|
fill: #fbbc05;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoogle-o-2 {
|
||||||
|
fill: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoogle-g {
|
||||||
|
fill: #34a853;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoogle-l {
|
||||||
|
fill: #ea4335;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoogle-e {
|
||||||
|
fill: #fbbc05;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
const handleUserInput = searchBar => {
|
let searchInput;
|
||||||
|
let currentFocus;
|
||||||
|
let originalSearch;
|
||||||
|
let autocompleteResults;
|
||||||
|
|
||||||
|
const handleUserInput = () => {
|
||||||
let xhrRequest = new XMLHttpRequest();
|
let xhrRequest = new XMLHttpRequest();
|
||||||
xhrRequest.open("POST", "autocomplete");
|
xhrRequest.open("POST", "autocomplete");
|
||||||
xhrRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
xhrRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
||||||
@ -9,49 +14,54 @@ const handleUserInput = searchBar => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fill autocomplete with fetched results
|
// Fill autocomplete with fetched results
|
||||||
let autocompleteResults = JSON.parse(xhrRequest.responseText);
|
autocompleteResults = JSON.parse(xhrRequest.responseText)[1];
|
||||||
autocomplete(searchBar, autocompleteResults[1]);
|
updateAutocompleteList();
|
||||||
};
|
};
|
||||||
|
|
||||||
xhrRequest.send('q=' + searchBar.value);
|
xhrRequest.send('q=' + searchInput.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const autocomplete = (searchInput, autocompleteResults) => {
|
const removeActive = suggestion => {
|
||||||
let currentFocus;
|
// Remove "autocomplete-active" class from previously active suggestion
|
||||||
let originalSearch;
|
for (let i = 0; i < suggestion.length; i++) {
|
||||||
|
suggestion[i].classList.remove("autocomplete-active");
|
||||||
searchInput.addEventListener("input", function () {
|
|
||||||
let autocompleteList, autocompleteItem, i, val = this.value;
|
|
||||||
closeAllLists();
|
|
||||||
|
|
||||||
if (!val || !autocompleteResults) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addActive = (suggestion) => {
|
||||||
|
// Handle navigation outside of suggestion list
|
||||||
|
if (!suggestion || !suggestion[currentFocus]) {
|
||||||
|
if (currentFocus >= suggestion.length) {
|
||||||
|
// Move selection back to the beginning
|
||||||
|
currentFocus = 0;
|
||||||
|
} else if (currentFocus < 0) {
|
||||||
|
// Retrieve original search and remove active suggestion selection
|
||||||
currentFocus = -1;
|
currentFocus = -1;
|
||||||
autocompleteList = document.createElement("div");
|
searchInput.value = originalSearch;
|
||||||
autocompleteList.setAttribute("id", this.id + "-autocomplete-list");
|
removeActive(suggestion);
|
||||||
autocompleteList.setAttribute("class", "autocomplete-items");
|
return;
|
||||||
this.parentNode.appendChild(autocompleteList);
|
} else {
|
||||||
|
return;
|
||||||
for (i = 0; i < autocompleteResults.length; i++) {
|
|
||||||
if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {
|
|
||||||
autocompleteItem = document.createElement("div");
|
|
||||||
autocompleteItem.innerHTML = "<strong>" + autocompleteResults[i].substr(0, val.length) + "</strong>";
|
|
||||||
autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);
|
|
||||||
autocompleteItem.innerHTML += "<input type=\"hidden\" value=\"" + autocompleteResults[i] + "\">";
|
|
||||||
autocompleteItem.addEventListener("click", function () {
|
|
||||||
searchInput.value = this.getElementsByTagName("input")[0].value;
|
|
||||||
closeAllLists();
|
|
||||||
document.getElementById("search-form").submit();
|
|
||||||
});
|
|
||||||
autocompleteList.appendChild(autocompleteItem);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
searchInput.addEventListener("keydown", function (e) {
|
removeActive(suggestion);
|
||||||
let suggestion = document.getElementById(this.id + "-autocomplete-list");
|
suggestion[currentFocus].classList.add("autocomplete-active");
|
||||||
|
|
||||||
|
// Autofill search bar with suggestion content (minus the "bang name" if using a bang operator)
|
||||||
|
let searchContent = suggestion[currentFocus].textContent;
|
||||||
|
if (searchContent.indexOf('(') > 0) {
|
||||||
|
searchInput.value = searchContent.substring(0, searchContent.indexOf('('));
|
||||||
|
} else {
|
||||||
|
searchInput.value = searchContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const autocompleteInput = (e) => {
|
||||||
|
// Handle navigation between autocomplete suggestions
|
||||||
|
let suggestion = document.getElementById("autocomplete-list");
|
||||||
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
|
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
|
||||||
if (e.keyCode === 40) { // down
|
if (e.keyCode === 40) { // down
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -67,60 +77,51 @@ const autocomplete = (searchInput, autocompleteResults) => {
|
|||||||
if (suggestion) suggestion[currentFocus].click();
|
if (suggestion) suggestion[currentFocus].click();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
originalSearch = document.getElementById("search-bar").value;
|
originalSearch = searchInput.value;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
const addActive = suggestion => {
|
const updateAutocompleteList = () => {
|
||||||
let searchBar = document.getElementById("search-bar");
|
let autocompleteItem, i;
|
||||||
|
let val = originalSearch;
|
||||||
|
|
||||||
|
let autocompleteList = document.getElementById("autocomplete-list");
|
||||||
|
autocompleteList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!val || !autocompleteResults) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle navigation outside of suggestion list
|
|
||||||
if (!suggestion || !suggestion[currentFocus]) {
|
|
||||||
if (currentFocus >= suggestion.length) {
|
|
||||||
// Move selection back to the beginning
|
|
||||||
currentFocus = 0;
|
|
||||||
} else if (currentFocus < 0) {
|
|
||||||
// Retrieve original search and remove active suggestion selection
|
|
||||||
currentFocus = -1;
|
currentFocus = -1;
|
||||||
searchBar.value = originalSearch;
|
|
||||||
removeActive(suggestion);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeActive(suggestion);
|
for (i = 0; i < autocompleteResults.length; i++) {
|
||||||
suggestion[currentFocus].classList.add("autocomplete-active");
|
if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {
|
||||||
|
autocompleteItem = document.createElement("div");
|
||||||
// Autofill search bar with suggestion content (minus the "bang name" if using a bang operator)
|
autocompleteItem.setAttribute("class", "autocomplete-item");
|
||||||
let searchContent = suggestion[currentFocus].textContent;
|
autocompleteItem.innerHTML = "<strong>" + autocompleteResults[i].substr(0, val.length) + "</strong>";
|
||||||
if (searchContent.indexOf('(') > 0) {
|
autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);
|
||||||
searchBar.value = searchContent.substring(0, searchContent.indexOf('('));
|
autocompleteItem.innerHTML += "<input type=\"hidden\" value=\"" + autocompleteResults[i] + "\">";
|
||||||
} else {
|
autocompleteItem.addEventListener("click", function () {
|
||||||
searchBar.value = searchContent;
|
searchInput.value = this.getElementsByTagName("input")[0].value;
|
||||||
}
|
autocompleteList.innerHTML = "";
|
||||||
|
document.getElementById("search-form").submit();
|
||||||
searchBar.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeActive = suggestion => {
|
|
||||||
for (let i = 0; i < suggestion.length; i++) {
|
|
||||||
suggestion[i].classList.remove("autocomplete-active");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeAllLists = el => {
|
|
||||||
let suggestions = document.getElementsByClassName("autocomplete-items");
|
|
||||||
for (let i = 0; i < suggestions.length; i++) {
|
|
||||||
if (el !== suggestions[i] && el !== searchInput) {
|
|
||||||
suggestions[i].parentNode.removeChild(suggestions[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close lists and search when user selects a suggestion
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
closeAllLists(e.target);
|
|
||||||
});
|
});
|
||||||
|
autocompleteList.appendChild(autocompleteItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
let autocompleteList = document.createElement("div");
|
||||||
|
autocompleteList.setAttribute("id", "autocomplete-list");
|
||||||
|
autocompleteList.setAttribute("class", "autocomplete-items");
|
||||||
|
|
||||||
|
searchInput = document.getElementById("search-bar");
|
||||||
|
searchInput.parentNode.appendChild(autocompleteList);
|
||||||
|
|
||||||
|
searchInput.addEventListener("keydown", (event) => autocompleteInput(event));
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
autocompleteList.innerHTML = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -2,6 +2,8 @@ const setupSearchLayout = () => {
|
|||||||
// Setup search field
|
// Setup search field
|
||||||
const searchBar = document.getElementById("search-bar");
|
const searchBar = document.getElementById("search-bar");
|
||||||
const searchBtn = document.getElementById("search-submit");
|
const searchBtn = document.getElementById("search-submit");
|
||||||
|
const arrowKeys = [37, 38, 39, 40];
|
||||||
|
let searchValue = searchBar.value;
|
||||||
|
|
||||||
// Automatically focus on search field
|
// Automatically focus on search field
|
||||||
searchBar.focus();
|
searchBar.focus();
|
||||||
@ -11,8 +13,9 @@ const setupSearchLayout = () => {
|
|||||||
if (event.keyCode === 13) {
|
if (event.keyCode === 13) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
searchBtn.click();
|
searchBtn.click();
|
||||||
} else {
|
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
|
||||||
handleUserInput(searchBar);
|
searchValue = searchBar.value;
|
||||||
|
handleUserInput();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -26,11 +29,25 @@ const setupConfigLayout = () => {
|
|||||||
if (content.style.maxHeight) {
|
if (content.style.maxHeight) {
|
||||||
content.style.maxHeight = null;
|
content.style.maxHeight = null;
|
||||||
} else {
|
} else {
|
||||||
content.style.maxHeight = content.scrollHeight + "px";
|
content.style.maxHeight = "400px";
|
||||||
}
|
}
|
||||||
|
|
||||||
content.classList.toggle("open");
|
content.classList.toggle("open");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setup user agent dropdown handler
|
||||||
|
const userAgentSelect = document.getElementById("config-user-agent");
|
||||||
|
const customUserAgentDiv = document.querySelector(".config-div-custom-user-agent");
|
||||||
|
|
||||||
|
if (userAgentSelect && customUserAgentDiv) {
|
||||||
|
userAgentSelect.addEventListener("change", function() {
|
||||||
|
if (this.value === "custom") {
|
||||||
|
customUserAgentDiv.style.display = "block";
|
||||||
|
} else {
|
||||||
|
customUserAgentDiv.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadConfig = event => {
|
const loadConfig = event => {
|
||||||
|
|||||||
9
app/static/js/currency.js
Normal file
9
app/static/js/currency.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const convert = (n1, n2, conversionFactor) => {
|
||||||
|
// id's for currency input boxes
|
||||||
|
let id1 = "cb" + n1;
|
||||||
|
let id2 = "cb" + n2;
|
||||||
|
// getting the value of the input box that just got filled
|
||||||
|
let inputBox = document.getElementById(id1).value;
|
||||||
|
// updating the other input box after conversion
|
||||||
|
document.getElementById(id2).value = ((inputBox * conversionFactor).toFixed(2));
|
||||||
|
}
|
||||||
@ -1,11 +1,67 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const advSearchToggle = document.getElementById("adv-search-toggle");
|
||||||
|
const advSearchDiv = document.getElementById("adv-search-div");
|
||||||
const searchBar = document.getElementById("search-bar");
|
const searchBar = document.getElementById("search-bar");
|
||||||
|
const countrySelect = document.getElementById("result-country");
|
||||||
|
const timePeriodSelect = document.getElementById("result-time-period");
|
||||||
|
const arrowKeys = [37, 38, 39, 40];
|
||||||
|
let searchValue = searchBar.value;
|
||||||
|
|
||||||
|
countrySelect.onchange = () => {
|
||||||
|
let str = window.location.href;
|
||||||
|
n = str.lastIndexOf("/search");
|
||||||
|
if (n > 0) {
|
||||||
|
str = str.substring(0, n) + `/search?q=${searchBar.value}`;
|
||||||
|
str = tackOnParams(str);
|
||||||
|
window.location.href = str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timePeriodSelect.onchange = () => {
|
||||||
|
let str = window.location.href;
|
||||||
|
n = str.lastIndexOf("/search");
|
||||||
|
if (n > 0) {
|
||||||
|
str = str.substring(0, n) + `/search?q=${searchBar.value}`;
|
||||||
|
str = tackOnParams(str);
|
||||||
|
window.location.href = str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tackOnParams(str) {
|
||||||
|
if (timePeriodSelect.value != "") {
|
||||||
|
str = str + `&tbs=${timePeriodSelect.value}`;
|
||||||
|
}
|
||||||
|
if (countrySelect.value != "") {
|
||||||
|
str = str + `&country=${countrySelect.value}`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAdvancedSearch = on => {
|
||||||
|
if (on) {
|
||||||
|
advSearchDiv.style.maxHeight = "70px";
|
||||||
|
} else {
|
||||||
|
advSearchDiv.style.maxHeight = "0px";
|
||||||
|
}
|
||||||
|
localStorage.advSearchToggled = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
toggleAdvancedSearch(JSON.parse(localStorage.advSearchToggled));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Did not recover advanced search toggle state");
|
||||||
|
}
|
||||||
|
|
||||||
|
advSearchToggle.onclick = () => {
|
||||||
|
toggleAdvancedSearch(advSearchToggle.checked);
|
||||||
|
}
|
||||||
|
|
||||||
searchBar.addEventListener("keyup", function(event) {
|
searchBar.addEventListener("keyup", function(event) {
|
||||||
if (event.keyCode !== 13) {
|
if (event.keyCode === 13) {
|
||||||
handleUserInput(searchBar);
|
|
||||||
} else {
|
|
||||||
document.getElementById("search-form").submit();
|
document.getElementById("search-form").submit();
|
||||||
|
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
|
||||||
|
searchValue = searchBar.value;
|
||||||
|
handleUserInput();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
(function () {
|
(function () {
|
||||||
let searchBar, results;
|
let searchBar, results;
|
||||||
|
let shift = false;
|
||||||
const keymap = {
|
const keymap = {
|
||||||
ArrowUp: goUp,
|
ArrowUp: goUp,
|
||||||
ArrowDown: goDown,
|
ArrowDown: goDown,
|
||||||
|
ShiftTab: goUp,
|
||||||
|
Tab: goDown,
|
||||||
k: goUp,
|
k: goUp,
|
||||||
j: goDown,
|
j: goDown,
|
||||||
'/': focusSearch,
|
'/': focusSearch,
|
||||||
@ -15,10 +18,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
shift = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.target.tagName === 'INPUT') return true;
|
if (e.target.tagName === 'INPUT') return true;
|
||||||
if (typeof keymap[e.key] === 'function') {
|
if (typeof keymap[e.key] === 'function') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
keymap[e.key]();
|
|
||||||
|
keymap[`${shift && e.key == 'Tab' ? 'Shift' : ''}${e.key}`]();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keyup', (e) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
shift = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,6 +52,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focusSearch () {
|
function focusSearch () {
|
||||||
|
if (window.usingCalculator) {
|
||||||
|
// if this function exists, it means the calculator widget has been displayed
|
||||||
|
if (usingCalculator()) return;
|
||||||
|
}
|
||||||
activeIdx = -1;
|
activeIdx = -1;
|
||||||
searchBar.focus();
|
searchBar.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
const checkForTracking = () => {
|
const checkForTracking = () => {
|
||||||
const mainDiv = document.getElementById("main");
|
const mainDiv = document.getElementById("main");
|
||||||
const query = document.getElementById("search-bar").value.replace(/\s+/g, '');
|
const searchBar = document.getElementById("search-bar");
|
||||||
|
// some pages (e.g. images) do not have these
|
||||||
|
if (!mainDiv || !searchBar)
|
||||||
|
return;
|
||||||
|
const query = searchBar.value.replace(/\s+/g, '');
|
||||||
|
|
||||||
// Note: regex functions for checking for tracking queries were derived
|
// Note: regex functions for checking for tracking queries were derived
|
||||||
// from here -- https://stackoverflow.com/questions/619977
|
// from here -- https://stackoverflow.com/questions/619977
|
||||||
@ -12,7 +16,7 @@ const checkForTracking = () => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"usps": {
|
"usps": {
|
||||||
"link": `https://tools.usps.com/go/TrackConfirmAction?tLabels=${query}`,
|
"link": `https://tools.usps.com/go/TrackConfirmAction_input?origTrackNum=${query}`,
|
||||||
"expr": [
|
"expr": [
|
||||||
/(\b\d{30}\b)|(\b91\d+\b)|(\b\d{20}\b)/,
|
/(\b\d{30}\b)|(\b91\d+\b)|(\b\d{20}\b)/,
|
||||||
/^E\D{1}\d{9}\D{2}$|^9\d{15,21}$/,
|
/^E\D{1}\d{9}\D{2}$|^9\d{15,21}$/,
|
||||||
@ -57,4 +61,16 @@ const checkForTracking = () => {
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
checkForTracking();
|
checkForTracking();
|
||||||
|
|
||||||
|
// Clear input if reset button tapped
|
||||||
|
const searchBar = document.getElementById("search-bar");
|
||||||
|
const resetBtn = document.getElementById("search-reset");
|
||||||
|
// some pages (e.g. images) do not have these
|
||||||
|
if (!searchBar || !resetBtn)
|
||||||
|
return;
|
||||||
|
resetBtn.addEventListener("click", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
searchBar.value = "";
|
||||||
|
searchBar.focus();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,248 +1,245 @@
|
|||||||
[
|
[
|
||||||
{"name": "Default (none)", "value": ""},
|
{"name": "-------", "value": ""},
|
||||||
{"name": "Afghanistan", "value": "countryAF"},
|
{"name": "Afghanistan", "value": "AF"},
|
||||||
{"name": "Albania", "value": "countryAL"},
|
{"name": "Albania", "value": "AL"},
|
||||||
{"name": "Algeria", "value": "countryDZ"},
|
{"name": "Algeria", "value": "DZ"},
|
||||||
{"name": "American Samoa", "value": "countryAS"},
|
{"name": "American Samoa", "value": "AS"},
|
||||||
{"name": "Andorra", "value": "countryAD"},
|
{"name": "Andorra", "value": "AD"},
|
||||||
{"name": "Angola", "value": "countryAO"},
|
{"name": "Angola", "value": "AO"},
|
||||||
{"name": "Anguilla", "value": "countryAI"},
|
{"name": "Anguilla", "value": "AI"},
|
||||||
{"name": "Antarctica", "value": "countryAQ"},
|
{"name": "Antarctica", "value": "AQ"},
|
||||||
{"name": "Antigua and Barbuda", "value": "countryAG"},
|
{"name": "Antigua and Barbuda", "value": "AG"},
|
||||||
{"name": "Argentina", "value": "countryAR"},
|
{"name": "Argentina", "value": "AR"},
|
||||||
{"name": "Armenia", "value": "countryAM"},
|
{"name": "Armenia", "value": "AM"},
|
||||||
{"name": "Aruba", "value": "countryAW"},
|
{"name": "Aruba", "value": "AW"},
|
||||||
{"name": "Australia", "value": "countryAU"},
|
{"name": "Australia", "value": "AU"},
|
||||||
{"name": "Austria", "value": "countryAT"},
|
{"name": "Austria", "value": "AT"},
|
||||||
{"name": "Azerbaijan", "value": "countryAZ"},
|
{"name": "Azerbaijan", "value": "AZ"},
|
||||||
{"name": "Bahamas", "value": "countryBS"},
|
{"name": "Bahamas", "value": "BS"},
|
||||||
{"name": "Bahrain", "value": "countryBH"},
|
{"name": "Bahrain", "value": "BH"},
|
||||||
{"name": "Bangladesh", "value": "countryBD"},
|
{"name": "Bangladesh", "value": "BD"},
|
||||||
{"name": "Barbados", "value": "countryBB"},
|
{"name": "Barbados", "value": "BB"},
|
||||||
{"name": "Belarus", "value": "countryBY"},
|
{"name": "Belarus", "value": "BY"},
|
||||||
{"name": "Belgium", "value": "countryBE"},
|
{"name": "Belgium", "value": "BE"},
|
||||||
{"name": "Belize", "value": "countryBZ"},
|
{"name": "Belize", "value": "BZ"},
|
||||||
{"name": "Benin", "value": "countryBJ"},
|
{"name": "Benin", "value": "BJ"},
|
||||||
{"name": "Bermuda", "value": "countryBM"},
|
{"name": "Bermuda", "value": "BM"},
|
||||||
{"name": "Bhutan", "value": "countryBT"},
|
{"name": "Bhutan", "value": "BT"},
|
||||||
{"name": "Bolivia", "value": "countryBO"},
|
{"name": "Bolivia", "value": "BO"},
|
||||||
{"name": "Bosnia and Herzegovina", "value": "countryBA"},
|
{"name": "Bosnia and Herzegovina", "value": "BA"},
|
||||||
{"name": "Botswana", "value": "countryBW"},
|
{"name": "Botswana", "value": "BW"},
|
||||||
{"name": "Bouvet Island", "value": "countryBV"},
|
{"name": "Bouvet Island", "value": "BV"},
|
||||||
{"name": "Brazil", "value": "countryBR"},
|
{"name": "Brazil", "value": "BR"},
|
||||||
{"name": "British Indian Ocean Territory", "value": "countryIO"},
|
{"name": "British Indian Ocean Territory", "value": "IO"},
|
||||||
{"name": "Brunei Darussalam", "value": "countryBN"},
|
{"name": "Brunei Darussalam", "value": "BN"},
|
||||||
{"name": "Bulgaria", "value": "countryBG"},
|
{"name": "Bulgaria", "value": "BG"},
|
||||||
{"name": "Burkina Faso", "value": "countryBF"},
|
{"name": "Burkina Faso", "value": "BF"},
|
||||||
{"name": "Burundi", "value": "countryBI"},
|
{"name": "Burundi", "value": "BI"},
|
||||||
{"name": "Cambodia", "value": "countryKH"},
|
{"name": "Cambodia", "value": "KH"},
|
||||||
{"name": "Cameroon", "value": "countryCM"},
|
{"name": "Cameroon", "value": "CM"},
|
||||||
{"name": "Canada", "value": "countryCA"},
|
{"name": "Canada", "value": "CA"},
|
||||||
{"name": "Cape Verde", "value": "countryCV"},
|
{"name": "Cape Verde", "value": "CV"},
|
||||||
{"name": "Cayman Islands", "value": "countryKY"},
|
{"name": "Cayman Islands", "value": "KY"},
|
||||||
{"name": "Central African Republic", "value": "countryCF"},
|
{"name": "Central African Republic", "value": "CF"},
|
||||||
{"name": "Chad", "value": "countryTD"},
|
{"name": "Chad", "value": "TD"},
|
||||||
{"name": "Chile", "value": "countryCL"},
|
{"name": "Chile", "value": "CL"},
|
||||||
{"name": "China", "value": "countryCN"},
|
{"name": "China", "value": "CN"},
|
||||||
{"name": "Christmas Island", "value": "countryCX"},
|
{"name": "Christmas Island", "value": "CX"},
|
||||||
{"name": "Cocos (Keeling) Islands", "value": "countryCC"},
|
{"name": "Cocos (Keeling) Islands", "value": "CC"},
|
||||||
{"name": "Colombia", "value": "countryCO"},
|
{"name": "Colombia", "value": "CO"},
|
||||||
{"name": "Comoros", "value": "countryKM"},
|
{"name": "Comoros", "value": "KM"},
|
||||||
{"name": "Congo", "value": "countryCG"},
|
{"name": "Congo", "value": "CG"},
|
||||||
{"name": "Congo, Democratic Republic of the", "value": "countryCD"},
|
{"name": "Congo, Democratic Republic of the", "value": "CD"},
|
||||||
{"name": "Cook Islands", "value": "countryCK"},
|
{"name": "Cook Islands", "value": "CK"},
|
||||||
{"name": "Costa Rica", "value": "countryCR"},
|
{"name": "Costa Rica", "value": "CR"},
|
||||||
{"name": "Cote D\"ivoire", "value": "countryCI"},
|
{"name": "Cote D'ivoire", "value": "CI"},
|
||||||
{"name": "Croatia (Hrvatska)", "value": "countryHR"},
|
{"name": "Croatia (Hrvatska)", "value": "HR"},
|
||||||
{"name": "Cuba", "value": "countryCU"},
|
{"name": "Cuba", "value": "CU"},
|
||||||
{"name": "Cyprus", "value": "countryCY"},
|
{"name": "Cyprus", "value": "CY"},
|
||||||
{"name": "Czech Republic", "value": "countryCZ"},
|
{"name": "Czech Republic", "value": "CZ"},
|
||||||
{"name": "Denmark", "value": "countryDK"},
|
{"name": "Denmark", "value": "DK"},
|
||||||
{"name": "Djibouti", "value": "countryDJ"},
|
{"name": "Djibouti", "value": "DJ"},
|
||||||
{"name": "Dominica", "value": "countryDM"},
|
{"name": "Dominica", "value": "DM"},
|
||||||
{"name": "Dominican Republic", "value": "countryDO"},
|
{"name": "Dominican Republic", "value": "DO"},
|
||||||
{"name": "East Timor", "value": "countryTP"},
|
{"name": "East Timor", "value": "TP"},
|
||||||
{"name": "Ecuador", "value": "countryEC"},
|
{"name": "Ecuador", "value": "EC"},
|
||||||
{"name": "Egypt", "value": "countryEG"},
|
{"name": "Egypt", "value": "EG"},
|
||||||
{"name": "El Salvador", "value": "countrySV"},
|
{"name": "El Salvador", "value": "SV"},
|
||||||
{"name": "Equatorial Guinea", "value": "countryGQ"},
|
{"name": "Equatorial Guinea", "value": "GQ"},
|
||||||
{"name": "Eritrea", "value": "countryER"},
|
{"name": "Eritrea", "value": "ER"},
|
||||||
{"name": "Estonia", "value": "countryEE"},
|
{"name": "Estonia", "value": "EE"},
|
||||||
{"name": "Ethiopia", "value": "countryET"},
|
{"name": "Ethiopia", "value": "ET"},
|
||||||
{"name": "European Union", "value": "countryEU"},
|
{"name": "European Union", "value": "EU"},
|
||||||
{"name": "Falkland Islands (Malvinas)", "value": "countryFK"},
|
{"name": "Falkland Islands (Malvinas)", "value": "FK"},
|
||||||
{"name": "Faroe Islands", "value": "countryFO"},
|
{"name": "Faroe Islands", "value": "FO"},
|
||||||
{"name": "Fiji", "value": "countryFJ"},
|
{"name": "Fiji", "value": "FJ"},
|
||||||
{"name": "Finland", "value": "countryFI"},
|
{"name": "Finland", "value": "FI"},
|
||||||
{"name": "France", "value": "countryFR"},
|
{"name": "France", "value": "FR"},
|
||||||
{"name": "France, Metropolitan", "value": "countryFX"},
|
{"name": "France, Metropolitan", "value": "FX"},
|
||||||
{"name": "French Guiana", "value": "countryGF"},
|
{"name": "French Guiana", "value": "GF"},
|
||||||
{"name": "French Polynesia", "value": "countryPF"},
|
{"name": "French Polynesia", "value": "PF"},
|
||||||
{"name": "French Southern Territories", "value": "countryTF"},
|
{"name": "French Southern Territories", "value": "TF"},
|
||||||
{"name": "Gabon", "value": "countryGA"},
|
{"name": "Gabon", "value": "GA"},
|
||||||
{"name": "Gambia", "value": "countryGM"},
|
{"name": "Gambia", "value": "GM"},
|
||||||
{"name": "Georgia", "value": "countryGE"},
|
{"name": "Georgia", "value": "GE"},
|
||||||
{"name": "Germany", "value": "countryDE"},
|
{"name": "Germany", "value": "DE"},
|
||||||
{"name": "Ghana", "value": "countryGH"},
|
{"name": "Ghana", "value": "GH"},
|
||||||
{"name": "Gibraltar", "value": "countryGI"},
|
{"name": "Gibraltar", "value": "GI"},
|
||||||
{"name": "Greece", "value": "countryGR"},
|
{"name": "Greece", "value": "GR"},
|
||||||
{"name": "Greenland", "value": "countryGL"},
|
{"name": "Greenland", "value": "GL"},
|
||||||
{"name": "Grenada", "value": "countryGD"},
|
{"name": "Grenada", "value": "GD"},
|
||||||
{"name": "Guadeloupe", "value": "countryGP"},
|
{"name": "Guadeloupe", "value": "GP"},
|
||||||
{"name": "Guam", "value": "countryGU"},
|
{"name": "Guam", "value": "GU"},
|
||||||
{"name": "Guatemala", "value": "countryGT"},
|
{"name": "Guatemala", "value": "GT"},
|
||||||
{"name": "Guinea", "value": "countryGN"},
|
{"name": "Guinea", "value": "GN"},
|
||||||
{"name": "Guinea-Bissau", "value": "countryGW"},
|
{"name": "Guinea-Bissau", "value": "GW"},
|
||||||
{"name": "Guyana", "value": "countryGY"},
|
{"name": "Guyana", "value": "GY"},
|
||||||
{"name": "Haiti", "value": "countryHT"},
|
{"name": "Haiti", "value": "HT"},
|
||||||
{"name": "Heard Island and Mcdonald Islands", "value": "countryHM"},
|
{"name": "Heard Island and Mcdonald Islands", "value": "HM"},
|
||||||
{"name": "Holy See (Vatican City State)", "value": "countryVA"},
|
{"name": "Holy See (Vatican City State)", "value": "VA"},
|
||||||
{"name": "Honduras", "value": "countryHN"},
|
{"name": "Honduras", "value": "HN"},
|
||||||
{"name": "Hong Kong", "value": "countryHK"},
|
{"name": "Hong Kong", "value": "HK"},
|
||||||
{"name": "Hungary", "value": "countryHU"},
|
{"name": "Hungary", "value": "HU"},
|
||||||
{"name": "Iceland", "value": "countryIS"},
|
{"name": "Iceland", "value": "IS"},
|
||||||
{"name": "India", "value": "countryIN"},
|
{"name": "India", "value": "IN"},
|
||||||
{"name": "Indonesia", "value": "countryID"},
|
{"name": "Indonesia", "value": "ID"},
|
||||||
{"name": "Iran, Islamic Republic of", "value": "countryIR"},
|
{"name": "Iran, Islamic Republic of", "value": "IR"},
|
||||||
{"name": "Iraq", "value": "countryIQ"},
|
{"name": "Iraq", "value": "IQ"},
|
||||||
{"name": "Ireland", "value": "countryIE"},
|
{"name": "Ireland", "value": "IE"},
|
||||||
{"name": "Israel", "value": "countryIL"},
|
{"name": "Israel", "value": "IL"},
|
||||||
{"name": "Italy", "value": "countryIT"},
|
{"name": "Italy", "value": "IT"},
|
||||||
{"name": "Jamaica", "value": "countryJM"},
|
{"name": "Jamaica", "value": "JM"},
|
||||||
{"name": "Japan", "value": "countryJP"},
|
{"name": "Japan", "value": "JP"},
|
||||||
{"name": "Jordan", "value": "countryJO"},
|
{"name": "Jordan", "value": "JO"},
|
||||||
{"name": "Kazakhstan", "value": "countryKZ"},
|
{"name": "Kazakhstan", "value": "KZ"},
|
||||||
{"name": "Kenya", "value": "countryKE"},
|
{"name": "Kenya", "value": "KE"},
|
||||||
{"name": "Kiribati", "value": "countryKI"},
|
{"name": "Kiribati", "value": "KI"},
|
||||||
{"name": "Korea, Democratic People\"s Republic of",
|
{"name": "Korea, Democratic People's Republic of", "value": "KP"},
|
||||||
"value": "countryKP"},
|
{"name": "Korea, Republic of", "value": "KR"},
|
||||||
{"name": "Korea, Republic of", "value": "countryKR"},
|
{"name": "Kuwait", "value": "KW"},
|
||||||
{"name": "Kuwait", "value": "countryKW"},
|
{"name": "Kyrgyzstan", "value": "KG"},
|
||||||
{"name": "Kyrgyzstan", "value": "countryKG"},
|
{"name": "Lao People's Democratic Republic", "value": "LA"},
|
||||||
{"name": "Lao People\"s Democratic Republic", "value": "countryLA"},
|
{"name": "Latvia", "value": "LV"},
|
||||||
{"name": "Latvia", "value": "countryLV"},
|
{"name": "Lebanon", "value": "LB"},
|
||||||
{"name": "Lebanon", "value": "countryLB"},
|
{"name": "Lesotho", "value": "LS"},
|
||||||
{"name": "Lesotho", "value": "countryLS"},
|
{"name": "Liberia", "value": "LR"},
|
||||||
{"name": "Liberia", "value": "countryLR"},
|
{"name": "Libyan Arab Jamahiriya", "value": "LY"},
|
||||||
{"name": "Libyan Arab Jamahiriya", "value": "countryLY"},
|
{"name": "Liechtenstein", "value": "LI"},
|
||||||
{"name": "Liechtenstein", "value": "countryLI"},
|
{"name": "Lithuania", "value": "LT"},
|
||||||
{"name": "Lithuania", "value": "countryLT"},
|
{"name": "Luxembourg", "value": "LU"},
|
||||||
{"name": "Luxembourg", "value": "countryLU"},
|
{"name": "Macao", "value": "MO"},
|
||||||
{"name": "Macao", "value": "countryMO"},
|
{"name": "Madagascar", "value": "MG"},
|
||||||
{"name": "Macedonia, the Former Yugosalv Republic of",
|
{"name": "Malawi", "value": "MW"},
|
||||||
"value": "countryMK"},
|
{"name": "Malaysia", "value": "MY"},
|
||||||
{"name": "Madagascar", "value": "countryMG"},
|
{"name": "Maldives", "value": "MV"},
|
||||||
{"name": "Malawi", "value": "countryMW"},
|
{"name": "Mali", "value": "ML"},
|
||||||
{"name": "Malaysia", "value": "countryMY"},
|
{"name": "Malta", "value": "MT"},
|
||||||
{"name": "Maldives", "value": "countryMV"},
|
{"name": "Marshall Islands", "value": "MH"},
|
||||||
{"name": "Mali", "value": "countryML"},
|
{"name": "Martinique", "value": "MQ"},
|
||||||
{"name": "Malta", "value": "countryMT"},
|
{"name": "Mauritania", "value": "MR"},
|
||||||
{"name": "Marshall Islands", "value": "countryMH"},
|
{"name": "Mauritius", "value": "MU"},
|
||||||
{"name": "Martinique", "value": "countryMQ"},
|
{"name": "Mayotte", "value": "YT"},
|
||||||
{"name": "Mauritania", "value": "countryMR"},
|
{"name": "Mexico", "value": "MX"},
|
||||||
{"name": "Mauritius", "value": "countryMU"},
|
{"name": "Micronesia, Federated States of", "value": "FM"},
|
||||||
{"name": "Mayotte", "value": "countryYT"},
|
{"name": "Moldova, Republic of", "value": "MD"},
|
||||||
{"name": "Mexico", "value": "countryMX"},
|
{"name": "Monaco", "value": "MC"},
|
||||||
{"name": "Micronesia, Federated States of", "value": "countryFM"},
|
{"name": "Mongolia", "value": "MN"},
|
||||||
{"name": "Moldova, Republic of", "value": "countryMD"},
|
{"name": "Montserrat", "value": "MS"},
|
||||||
{"name": "Monaco", "value": "countryMC"},
|
{"name": "Morocco", "value": "MA"},
|
||||||
{"name": "Mongolia", "value": "countryMN"},
|
{"name": "Mozambique", "value": "MZ"},
|
||||||
{"name": "Montserrat", "value": "countryMS"},
|
{"name": "Myanmar", "value": "MM"},
|
||||||
{"name": "Morocco", "value": "countryMA"},
|
{"name": "Namibia", "value": "NA"},
|
||||||
{"name": "Mozambique", "value": "countryMZ"},
|
{"name": "Nauru", "value": "NR"},
|
||||||
{"name": "Myanmar", "value": "countryMM"},
|
{"name": "Nepal", "value": "NP"},
|
||||||
{"name": "Namibia", "value": "countryNA"},
|
{"name": "Netherlands", "value": "NL"},
|
||||||
{"name": "Nauru", "value": "countryNR"},
|
{"name": "Netherlands Antilles", "value": "AN"},
|
||||||
{"name": "Nepal", "value": "countryNP"},
|
{"name": "New Caledonia", "value": "NC"},
|
||||||
{"name": "Netherlands", "value": "countryNL"},
|
{"name": "New Zealand", "value": "NZ"},
|
||||||
{"name": "Netherlands Antilles", "value": "countryAN"},
|
{"name": "Nicaragua", "value": "NI"},
|
||||||
{"name": "New Caledonia", "value": "countryNC"},
|
{"name": "Niger", "value": "NE"},
|
||||||
{"name": "New Zealand", "value": "countryNZ"},
|
{"name": "Nigeria", "value": "NG"},
|
||||||
{"name": "Nicaragua", "value": "countryNI"},
|
{"name": "Niue", "value": "NU"},
|
||||||
{"name": "Niger", "value": "countryNE"},
|
{"name": "Norfolk Island", "value": "NF"},
|
||||||
{"name": "Nigeria", "value": "countryNG"},
|
{"name": "North Macedonia", "value": "MK"},
|
||||||
{"name": "Niue", "value": "countryNU"},
|
{"name": "Northern Mariana Islands", "value": "MP"},
|
||||||
{"name": "Norfolk Island", "value": "countryNF"},
|
{"name": "Norway", "value": "NO"},
|
||||||
{"name": "Northern Mariana Islands", "value": "countryMP"},
|
{"name": "Oman", "value": "OM"},
|
||||||
{"name": "Norway", "value": "countryNO"},
|
{"name": "Pakistan", "value": "PK"},
|
||||||
{"name": "Oman", "value": "countryOM"},
|
{"name": "Palau", "value": "PW"},
|
||||||
{"name": "Pakistan", "value": "countryPK"},
|
{"name": "Palestinian Territory", "value": "PS"},
|
||||||
{"name": "Palau", "value": "countryPW"},
|
{"name": "Panama", "value": "PA"},
|
||||||
{"name": "Palestinian Territory", "value": "countryPS"},
|
{"name": "Papua New Guinea", "value": "PG"},
|
||||||
{"name": "Panama", "value": "countryPA"},
|
{"name": "Paraguay", "value": "PY"},
|
||||||
{"name": "Papua New Guinea", "value": "countryPG"},
|
{"name": "Peru", "value": "PE"},
|
||||||
{"name": "Paraguay", "value": "countryPY"},
|
{"name": "Philippines", "value": "PH"},
|
||||||
{"name": "Peru", "value": "countryPE"},
|
{"name": "Pitcairn", "value": "PN"},
|
||||||
{"name": "Philippines", "value": "countryPH"},
|
{"name": "Poland", "value": "PL"},
|
||||||
{"name": "Pitcairn", "value": "countryPN"},
|
{"name": "Portugal", "value": "PT"},
|
||||||
{"name": "Poland", "value": "countryPL"},
|
{"name": "Puerto Rico", "value": "PR"},
|
||||||
{"name": "Portugal", "value": "countryPT"},
|
{"name": "Qatar", "value": "QA"},
|
||||||
{"name": "Puerto Rico", "value": "countryPR"},
|
{"name": "Reunion", "value": "RE"},
|
||||||
{"name": "Qatar", "value": "countryQA"},
|
{"name": "Romania", "value": "RO"},
|
||||||
{"name": "Reunion", "value": "countryRE"},
|
{"name": "Russian Federation", "value": "RU"},
|
||||||
{"name": "Romania", "value": "countryRO"},
|
{"name": "Rwanda", "value": "RW"},
|
||||||
{"name": "Russian Federation", "value": "countryRU"},
|
{"name": "Saint Helena", "value": "SH"},
|
||||||
{"name": "Rwanda", "value": "countryRW"},
|
{"name": "Saint Kitts and Nevis", "value": "KN"},
|
||||||
{"name": "Saint Helena", "value": "countrySH"},
|
{"name": "Saint Lucia", "value": "LC"},
|
||||||
{"name": "Saint Kitts and Nevis", "value": "countryKN"},
|
{"name": "Saint Pierre and Miquelon", "value": "PM"},
|
||||||
{"name": "Saint Lucia", "value": "countryLC"},
|
{"name": "Saint Vincent and the Grenadines", "value": "VC"},
|
||||||
{"name": "Saint Pierre and Miquelon", "value": "countryPM"},
|
{"name": "Samoa", "value": "WS"},
|
||||||
{"name": "Saint Vincent and the Grenadines", "value": "countryVC"},
|
{"name": "San Marino", "value": "SM"},
|
||||||
{"name": "Samoa", "value": "countryWS"},
|
{"name": "Sao Tome and Principe", "value": "ST"},
|
||||||
{"name": "San Marino", "value": "countrySM"},
|
{"name": "Saudi Arabia", "value": "SA"},
|
||||||
{"name": "Sao Tome and Principe", "value": "countryST"},
|
{"name": "Senegal", "value": "SN"},
|
||||||
{"name": "Saudi Arabia", "value": "countrySA"},
|
{"name": "Serbia and Montenegro", "value": "CS"},
|
||||||
{"name": "Senegal", "value": "countrySN"},
|
{"name": "Seychelles", "value": "SC"},
|
||||||
{"name": "Serbia and Montenegro", "value": "countryCS"},
|
{"name": "Sierra Leone", "value": "SL"},
|
||||||
{"name": "Seychelles", "value": "countrySC"},
|
{"name": "Singapore", "value": "SG"},
|
||||||
{"name": "Sierra Leone", "value": "countrySL"},
|
{"name": "Slovakia", "value": "SK"},
|
||||||
{"name": "Singapore", "value": "countrySG"},
|
{"name": "Slovenia", "value": "SI"},
|
||||||
{"name": "Slovakia", "value": "countrySK"},
|
{"name": "Solomon Islands", "value": "SB"},
|
||||||
{"name": "Slovenia", "value": "countrySI"},
|
{"name": "Somalia", "value": "SO"},
|
||||||
{"name": "Solomon Islands", "value": "countrySB"},
|
{"name": "South Africa", "value": "ZA"},
|
||||||
{"name": "Somalia", "value": "countrySO"},
|
{"name": "South Georgia and the South Sandwich Islands", "value": "GS"},
|
||||||
{"name": "South Africa", "value": "countryZA"},
|
{"name": "Spain", "value": "ES"},
|
||||||
{"name": "South Georgia and the South Sandwich Islands",
|
{"name": "Sri Lanka", "value": "LK"},
|
||||||
"value": "countryGS"},
|
{"name": "Sudan", "value": "SD"},
|
||||||
{"name": "Spain", "value": "countryES"},
|
{"name": "Suriname", "value": "SR"},
|
||||||
{"name": "Sri Lanka", "value": "countryLK"},
|
{"name": "Svalbard and Jan Mayen", "value": "SJ"},
|
||||||
{"name": "Sudan", "value": "countrySD"},
|
{"name": "Swaziland", "value": "SZ"},
|
||||||
{"name": "Suriname", "value": "countrySR"},
|
{"name": "Sweden", "value": "SE"},
|
||||||
{"name": "Svalbard and Jan Mayen", "value": "countrySJ"},
|
{"name": "Switzerland", "value": "CH"},
|
||||||
{"name": "Swaziland", "value": "countrySZ"},
|
{"name": "Syrian Arab Republic", "value": "SY"},
|
||||||
{"name": "Sweden", "value": "countrySE"},
|
{"name": "Taiwan", "value": "TW"},
|
||||||
{"name": "Switzerland", "value": "countryCH"},
|
{"name": "Tajikistan", "value": "TJ"},
|
||||||
{"name": "Syrian Arab Republic", "value": "countrySY"},
|
{"name": "Tanzania, United Republic of", "value": "TZ"},
|
||||||
{"name": "Taiwan, Province of China", "value": "countryTW"},
|
{"name": "Thailand", "value": "TH"},
|
||||||
{"name": "Tajikistan", "value": "countryTJ"},
|
{"name": "Togo", "value": "TG"},
|
||||||
{"name": "Tanzania, United Republic of", "value": "countryTZ"},
|
{"name": "Tokelau", "value": "TK"},
|
||||||
{"name": "Thailand", "value": "countryTH"},
|
{"name": "Tonga", "value": "TO"},
|
||||||
{"name": "Togo", "value": "countryTG"},
|
{"name": "Trinidad and Tobago", "value": "TT"},
|
||||||
{"name": "Tokelau", "value": "countryTK"},
|
{"name": "Tunisia", "value": "TN"},
|
||||||
{"name": "Tonga", "value": "countryTO"},
|
{"name": "Turkmenistan", "value": "TM"},
|
||||||
{"name": "Trinidad and Tobago", "value": "countryTT"},
|
{"name": "Turks and Caicos Islands", "value": "TC"},
|
||||||
{"name": "Tunisia", "value": "countryTN"},
|
{"name": "Tuvalu", "value": "TV"},
|
||||||
{"name": "Turkey", "value": "countryTR"},
|
{"name": "Türkiye", "value": "TR"},
|
||||||
{"name": "Turkmenistan", "value": "countryTM"},
|
{"name": "Uganda", "value": "UG"},
|
||||||
{"name": "Turks and Caicos Islands", "value": "countryTC"},
|
{"name": "Ukraine", "value": "UA"},
|
||||||
{"name": "Tuvalu", "value": "countryTV"},
|
{"name": "United Arab Emirates", "value": "AE"},
|
||||||
{"name": "Uganda", "value": "countryUG"},
|
{"name": "United Kingdom", "value": "UK"},
|
||||||
{"name": "Ukraine", "value": "countryUA"},
|
{"name": "United States", "value": "US"},
|
||||||
{"name": "United Arab Emirates", "value": "countryAE"},
|
{"name": "United States Minor Outlying Islands", "value": "UM"},
|
||||||
{"name": "United Kingdom", "value": "countryUK"},
|
{"name": "Uruguay", "value": "UY"},
|
||||||
{"name": "United States", "value": "countryUS"},
|
{"name": "Uzbekistan", "value": "UZ"},
|
||||||
{"name": "United States Minor Outlying Islands", "value": "countryUM"},
|
{"name": "Vanuatu", "value": "VU"},
|
||||||
{"name": "Uruguay", "value": "countryUY"},
|
{"name": "Venezuela", "value": "VE"},
|
||||||
{"name": "Uzbekistan", "value": "countryUZ"},
|
{"name": "Vietnam", "value": "VN"},
|
||||||
{"name": "Vanuatu", "value": "countryVU"},
|
{"name": "Virgin Islands, British", "value": "VG"},
|
||||||
{"name": "Venezuela", "value": "countryVE"},
|
{"name": "Virgin Islands, U.S.", "value": "VI"},
|
||||||
{"name": "Vietnam", "value": "countryVN"},
|
{"name": "Wallis and Futuna", "value": "WF"},
|
||||||
{"name": "Virgin Islands, British", "value": "countryVG"},
|
{"name": "Western Sahara", "value": "EH"},
|
||||||
{"name": "Virgin Islands, U.S.", "value": "countryVI"},
|
{"name": "Yemen", "value": "YE"},
|
||||||
{"name": "Wallis and Futuna", "value": "countryWF"},
|
{"name": "Yugoslavia", "value": "YU"},
|
||||||
{"name": "Western Sahara", "value": "countryEH"},
|
{"name": "Zambia", "value": "ZM"},
|
||||||
{"name": "Yemen", "value": "countryYE"},
|
{"name": "Zimbabwe", "value": "ZW"}
|
||||||
{"name": "Yugoslavia", "value": "countryYU"},
|
|
||||||
{"name": "Zambia", "value": "countryZM"},
|
|
||||||
{"name": "Zimbabwe", "value": "countryZW"}
|
|
||||||
]
|
]
|
||||||
|
|||||||
32
app/static/settings/header_tabs.json
Normal file
32
app/static/settings/header_tabs.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"all": {
|
||||||
|
"tbm": null,
|
||||||
|
"href": "search?q={query}",
|
||||||
|
"name": "All",
|
||||||
|
"selected": true
|
||||||
|
},
|
||||||
|
"images": {
|
||||||
|
"tbm": "isch",
|
||||||
|
"href": "search?q={query}",
|
||||||
|
"name": "Images",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"maps": {
|
||||||
|
"tbm": null,
|
||||||
|
"href": "https://maps.google.com/maps?q={map_query}",
|
||||||
|
"name": "Maps",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"videos": {
|
||||||
|
"tbm": "vid",
|
||||||
|
"href": "search?q={query}",
|
||||||
|
"name": "Videos",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"tbm": "nws",
|
||||||
|
"href": "search?q={query}",
|
||||||
|
"name": "News",
|
||||||
|
"selected": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,49 +1,55 @@
|
|||||||
[
|
[
|
||||||
{"name": "Default (none specified)", "value": ""},
|
{"name": "-------", "value": ""},
|
||||||
{"name": "English", "value": "lang_en"},
|
{"name": "English", "value": "lang_en"},
|
||||||
{"name": "Afrikaans", "value": "lang_af"},
|
{"name": "Afrikaans (Afrikaans)", "value": "lang_af"},
|
||||||
{"name": "Arabic", "value": "lang_ar"},
|
{"name": "Arabic (عربى)", "value": "lang_ar"},
|
||||||
{"name": "Armenian", "value": "lang_hy"},
|
{"name": "Armenian (հայերեն)", "value": "lang_hy"},
|
||||||
{"name": "Belarusian", "value": "lang_be"},
|
{"name": "Azerbaijani (Azərbaycanca)", "value": "lang_az"},
|
||||||
{"name": "Bulgarian", "value": "lang_bg"},
|
{"name": "Belarusian (Беларуская)", "value": "lang_be"},
|
||||||
{"name": "Catalan", "value": "lang_ca"},
|
{"name": "Bulgarian (български)", "value": "lang_bg"},
|
||||||
{"name": "Chinese (Simplified)", "value": "lang_zh-CN"},
|
{"name": "Catalan (Català)", "value": "lang_ca"},
|
||||||
{"name": "Chinese (Traditional)", "value": "lang_zh-TW"},
|
{"name": "Chinese, Simplified (简体中文)", "value": "lang_zh-CN"},
|
||||||
{"name": "Croatian", "value": "lang_hr"},
|
{"name": "Chinese, Traditional (正體中文)", "value": "lang_zh-TW"},
|
||||||
{"name": "Czech", "value": "lang_cs"},
|
{"name": "Croatian (Hrvatski)", "value": "lang_hr"},
|
||||||
{"name": "Danish", "value": "lang_da"},
|
{"name": "Czech (čeština)", "value": "lang_cs"},
|
||||||
{"name": "Dutch", "value": "lang_nl"},
|
{"name": "Danish (Dansk)", "value": "lang_da"},
|
||||||
{"name": "Esperanto", "value": "lang_eo"},
|
{"name": "Dutch (Nederlands)", "value": "lang_nl"},
|
||||||
{"name": "Estonian", "value": "lang_et"},
|
{"name": "Esperanto (Esperanto)", "value": "lang_eo"},
|
||||||
{"name": "Filipino", "value": "lang_tl"},
|
{"name": "Estonian (Eestlane)", "value": "lang_et"},
|
||||||
{"name": "Finnish", "value": "lang_fi"},
|
{"name": "Filipino (Pilipino)", "value": "lang_tl"},
|
||||||
{"name": "French", "value": "lang_fr"},
|
{"name": "Finnish (Suomalainen)", "value": "lang_fi"},
|
||||||
{"name": "German", "value": "lang_de"},
|
{"name": "French (Français)", "value": "lang_fr"},
|
||||||
{"name": "Greek", "value": "lang_el"},
|
{"name": "German (Deutsch)", "value": "lang_de"},
|
||||||
{"name": "Hebrew", "value": "lang_iw"},
|
{"name": "Greek (Ελληνικά)", "value": "lang_el"},
|
||||||
{"name": "Hindi", "value": "lang_hi"},
|
{"name": "Hebrew (עִברִית)", "value": "lang_iw"},
|
||||||
{"name": "Hungarian", "value": "lang_hu"},
|
{"name": "Hindi (हिंदी)", "value": "lang_hi"},
|
||||||
{"name": "Icelandic", "value": "lang_is"},
|
{"name": "Hungarian (Magyar)", "value": "lang_hu"},
|
||||||
{"name": "Indonesian", "value": "lang_id"},
|
{"name": "Icelandic (Íslenska)", "value": "lang_is"},
|
||||||
{"name": "Italian", "value": "lang_it"},
|
{"name": "Indonesian (Indonesian)", "value": "lang_id"},
|
||||||
{"name": "Japanese", "value": "lang_ja"},
|
{"name": "Italian (Italiano)", "value": "lang_it"},
|
||||||
{"name": "Korean", "value": "lang_ko"},
|
{"name": "Japanese (日本語)", "value": "lang_ja"},
|
||||||
{"name": "Latvian", "value": "lang_lv"},
|
{"name": "Korean (한국어)", "value": "lang_ko"},
|
||||||
{"name": "Lithuanian", "value": "lang_lt"},
|
{"name": "Kurdish (Kurdî)", "value": "lang_ku"},
|
||||||
{"name": "Norwegian", "value": "lang_no"},
|
{"name": "Latvian (Latvietis)", "value": "lang_lv"},
|
||||||
{"name": "Persian", "value": "lang_fa"},
|
{"name": "Lithuanian (Lietuvis)", "value": "lang_lt"},
|
||||||
{"name": "Polish", "value": "lang_pl"},
|
{"name": "Norwegian (Norwegian)", "value": "lang_no"},
|
||||||
{"name": "Portuguese", "value": "lang_pt"},
|
{"name": "Persian (فارسی)", "value": "lang_fa"},
|
||||||
{"name": "Romanian", "value": "lang_ro"},
|
{"name": "Polish (Polskie)", "value": "lang_pl"},
|
||||||
{"name": "Russian", "value": "lang_ru"},
|
{"name": "Portuguese (Português)", "value": "lang_pt"},
|
||||||
{"name": "Serbian", "value": "lang_sr"},
|
{"name": "Romanian (Română)", "value": "lang_ro"},
|
||||||
{"name": "Slovak", "value": "lang_sk"},
|
{"name": "Russian (русский)", "value": "lang_ru"},
|
||||||
{"name": "Slovenian", "value": "lang_sl"},
|
{"name": "Serbian (Српски)", "value": "lang_sr"},
|
||||||
{"name": "Spanish", "value": "lang_es"},
|
{"name": "Sinhala (සිංහල)", "value": "lang_si"},
|
||||||
{"name": "Swahili", "value": "lang_sw"},
|
{"name": "Slovak (Slovák)", "value": "lang_sk"},
|
||||||
{"name": "Swedish", "value": "lang_sv"},
|
{"name": "Slovenian (Slovenščina)", "value": "lang_sl"},
|
||||||
{"name": "Thai", "value": "lang_th"},
|
{"name": "Spanish (Español)", "value": "lang_es"},
|
||||||
{"name": "Turkish", "value": "lang_tr"},
|
{"name": "Swahili (Kiswahili)", "value": "lang_sw"},
|
||||||
{"name": "Ukrainian", "value": "lang_uk"},
|
{"name": "Swedish (Svenska)", "value": "lang_sv"},
|
||||||
{"name": "Vietnamese", "value": "lang_vi"}
|
{"name": "Thai (ไทย)", "value": "lang_th"},
|
||||||
|
{"name": "Turkish (Türkçe)", "value": "lang_tr"},
|
||||||
|
{"name": "Ukrainian (Українська)", "value": "lang_uk"},
|
||||||
|
{"name": "Vietnamese (Tiếng Việt)", "value": "lang_vi"},
|
||||||
|
{"name": "Welsh (Cymraeg)", "value": "lang_cy"},
|
||||||
|
{"name": "Xhosa (isiXhosa)", "value": "lang_xh"},
|
||||||
|
{"name": "Zulu (isiZulu)", "value": "lang_zu"}
|
||||||
]
|
]
|
||||||
|
|||||||
5
app/static/settings/themes.json
Normal file
5
app/static/settings/themes.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
"light",
|
||||||
|
"dark",
|
||||||
|
"system"
|
||||||
|
]
|
||||||
8
app/static/settings/time_periods.json
Normal file
8
app/static/settings/time_periods.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
{"name": "Any time", "value": ""},
|
||||||
|
{"name": "Past hour", "value": "qdr:h"},
|
||||||
|
{"name": "Past 24 hours", "value": "qdr:d"},
|
||||||
|
{"name": "Past week", "value": "qdr:w"},
|
||||||
|
{"name": "Past month", "value": "qdr:m"},
|
||||||
|
{"name": "Past year", "value": "qdr:y"}
|
||||||
|
]
|
||||||
1346
app/static/settings/translations.json
Normal file
1346
app/static/settings/translations.json
Normal file
File diff suppressed because it is too large
Load Diff
260
app/static/widgets/calculator.html
Normal file
260
app/static/widgets/calculator.html
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
<!--
|
||||||
|
Calculator widget.
|
||||||
|
This file should contain all required
|
||||||
|
CSS, HTML, and JS for it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#calc-text {
|
||||||
|
background: var(--whoogle-dark-page-bg);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: right;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--whoogle-dark-text);
|
||||||
|
}
|
||||||
|
#prev-equation {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.error-border {
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
#calc-btns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
grid-template-rows: repeat(5, 1fr);
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
#calc-btns button {
|
||||||
|
background: #313141;
|
||||||
|
color: var(--whoogle-dark-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#calc-btns button:hover {
|
||||||
|
background: #414151;
|
||||||
|
}
|
||||||
|
#calc-btns .common {
|
||||||
|
background: #51516a;
|
||||||
|
}
|
||||||
|
#calc-btns .common:hover {
|
||||||
|
background: #61617a;
|
||||||
|
}
|
||||||
|
#calc-btn-0 { grid-row: 5; grid-column: 3; }
|
||||||
|
#calc-btn-1 { grid-row: 4; grid-column: 3; }
|
||||||
|
#calc-btn-2 { grid-row: 4; grid-column: 4; }
|
||||||
|
#calc-btn-3 { grid-row: 4; grid-column: 5; }
|
||||||
|
#calc-btn-4 { grid-row: 3; grid-column: 3; }
|
||||||
|
#calc-btn-5 { grid-row: 3; grid-column: 4; }
|
||||||
|
#calc-btn-6 { grid-row: 3; grid-column: 5; }
|
||||||
|
#calc-btn-7 { grid-row: 2; grid-column: 3; }
|
||||||
|
#calc-btn-8 { grid-row: 2; grid-column: 4; }
|
||||||
|
#calc-btn-9 { grid-row: 2; grid-column: 5; }
|
||||||
|
#calc-btn-EQ { grid-row: 5; grid-column: 5; }
|
||||||
|
#calc-btn-PT { grid-row: 5; grid-column: 4; }
|
||||||
|
#calc-btn-BCK { grid-row: 5; grid-column: 6; }
|
||||||
|
#calc-btn-ADD { grid-row: 4; grid-column: 6; }
|
||||||
|
#calc-btn-SUB { grid-row: 3; grid-column: 6; }
|
||||||
|
#calc-btn-MLT { grid-row: 2; grid-column: 6; }
|
||||||
|
#calc-btn-DIV { grid-row: 1; grid-column: 6; }
|
||||||
|
#calc-btn-CLR { grid-row: 1; grid-column: 5; }
|
||||||
|
#calc-btn-PRC{ grid-row: 1; grid-column: 4; }
|
||||||
|
#calc-btn-RP { grid-row: 1; grid-column: 3; }
|
||||||
|
#calc-btn-LP { grid-row: 1; grid-column: 2; }
|
||||||
|
#calc-btn-ABS { grid-row: 1; grid-column: 1; }
|
||||||
|
#calc-btn-SIN { grid-row: 2; grid-column: 2; }
|
||||||
|
#calc-btn-COS { grid-row: 3; grid-column: 2; }
|
||||||
|
#calc-btn-TAN { grid-row: 4; grid-column: 2; }
|
||||||
|
#calc-btn-SQR { grid-row: 5; grid-column: 2; }
|
||||||
|
#calc-btn-EXP { grid-row: 2; grid-column: 1; }
|
||||||
|
#calc-btn-E { grid-row: 3; grid-column: 1; }
|
||||||
|
#calc-btn-PI { grid-row: 4; grid-column: 1; }
|
||||||
|
#calc-btn-LOG { grid-row: 5; grid-column: 1; }
|
||||||
|
</style>
|
||||||
|
<p id="prev-equation"></p>
|
||||||
|
<div id="calculator-widget">
|
||||||
|
<p id="calc-text">0</p>
|
||||||
|
<div id="calc-btns">
|
||||||
|
<button id="calc-btn-0" class="common">0</button>
|
||||||
|
<button id="calc-btn-1" class="common">1</button>
|
||||||
|
<button id="calc-btn-2" class="common">2</button>
|
||||||
|
<button id="calc-btn-3" class="common">3</button>
|
||||||
|
<button id="calc-btn-4" class="common">4</button>
|
||||||
|
<button id="calc-btn-5" class="common">5</button>
|
||||||
|
<button id="calc-btn-6" class="common">6</button>
|
||||||
|
<button id="calc-btn-7" class="common">7</button>
|
||||||
|
<button id="calc-btn-8" class="common">8</button>
|
||||||
|
<button id="calc-btn-9" class="common">9</button>
|
||||||
|
<button id="calc-btn-EQ" class="common">=</button>
|
||||||
|
<button id="calc-btn-PT" class="common">.</button>
|
||||||
|
<button id="calc-btn-BCK">⬅</button>
|
||||||
|
<button id="calc-btn-ADD">+</button>
|
||||||
|
<button id="calc-btn-SUB">-</button>
|
||||||
|
<button id="calc-btn-MLT">x</button>
|
||||||
|
<button id="calc-btn-DIV">/</button>
|
||||||
|
<button id="calc-btn-CLR">C</button>
|
||||||
|
<button id="calc-btn-PRC">%</button>
|
||||||
|
<button id="calc-btn-RP">)</button>
|
||||||
|
<button id="calc-btn-LP">(</button>
|
||||||
|
<button id="calc-btn-ABS">|x|</button>
|
||||||
|
<button id="calc-btn-SIN">sin</button>
|
||||||
|
<button id="calc-btn-COS">cos</button>
|
||||||
|
<button id="calc-btn-TAN">tan</button>
|
||||||
|
<button id="calc-btn-SQR">√</button>
|
||||||
|
<button id="calc-btn-EXP">^</button>
|
||||||
|
<button id="calc-btn-E">ℇ</button>
|
||||||
|
<button id="calc-btn-PI">π</button>
|
||||||
|
<button id="calc-btn-LOG">log</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// JS does not have this by default.
|
||||||
|
// from https://www.freecodecamp.org/news/how-to-factorialize-a-number-in-javascript-9263c89a4b38/
|
||||||
|
function factorial(num) {
|
||||||
|
if (num < 0)
|
||||||
|
return -1;
|
||||||
|
else if (num === 0)
|
||||||
|
return 1;
|
||||||
|
else {
|
||||||
|
return (num * factorial(num - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// returns true if the user is currently focused on the calculator widget
|
||||||
|
function usingCalculator() {
|
||||||
|
let activeElement = document.activeElement;
|
||||||
|
while (true) {
|
||||||
|
if (!activeElement) return false;
|
||||||
|
if (activeElement.id === "calculator-wrapper") return true;
|
||||||
|
activeElement = activeElement.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const $ = q => document.querySelectorAll(q);
|
||||||
|
// key bindings for commonly used buttons
|
||||||
|
const keybindings = {
|
||||||
|
"0": "0",
|
||||||
|
"1": "1",
|
||||||
|
"2": "2",
|
||||||
|
"3": "3",
|
||||||
|
"4": "4",
|
||||||
|
"5": "5",
|
||||||
|
"6": "6",
|
||||||
|
"7": "7",
|
||||||
|
"8": "8",
|
||||||
|
"9": "9",
|
||||||
|
"Enter": "EQ",
|
||||||
|
".": "PT",
|
||||||
|
"+": "ADD",
|
||||||
|
"-": "SUB",
|
||||||
|
"*": "MLT",
|
||||||
|
"/": "DIV",
|
||||||
|
"%": "PRC",
|
||||||
|
"c": "CLR",
|
||||||
|
"(": "LP",
|
||||||
|
")": "RP",
|
||||||
|
"Backspace": "BCK",
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", event => {
|
||||||
|
if (!usingCalculator()) return;
|
||||||
|
if (event.key === "Enter" && document.activeElement.id !== "search-bar")
|
||||||
|
event.preventDefault();
|
||||||
|
if (keybindings[event.key])
|
||||||
|
document.getElementById("calc-btn-" + keybindings[event.key]).click();
|
||||||
|
})
|
||||||
|
// calculates the string
|
||||||
|
const calc = () => {
|
||||||
|
var mathtext = document.getElementById("calc-text");
|
||||||
|
var statement = mathtext.innerHTML
|
||||||
|
// remove empty ()
|
||||||
|
.replace("()", "")
|
||||||
|
// special constants
|
||||||
|
.replace("π", "(Math.PI)")
|
||||||
|
.replace("ℇ", "(Math.E)")
|
||||||
|
// turns 3(1+2) into 3*(1+2) (for example)
|
||||||
|
.replace(/(?<=[0-9\)])(?<=[^+\-x*\/%^])\(/, "x(")
|
||||||
|
// same except reversed
|
||||||
|
.replace(/\)(?=[0-9\(])(?=[^+\-x*\/%^])/, ")x")
|
||||||
|
// replace human friendly x with JS *
|
||||||
|
.replace("x", "*")
|
||||||
|
// trig & misc functions
|
||||||
|
.replace("sin", "Math.sin")
|
||||||
|
.replace("cos", "Math.cos")
|
||||||
|
.replace("tan", "Math.tan")
|
||||||
|
.replace("√", "Math.sqrt")
|
||||||
|
.replace("^", "**")
|
||||||
|
.replace("abs", "Math.abs")
|
||||||
|
.replace("log", "Math.log")
|
||||||
|
;
|
||||||
|
// add any missing )s to the end
|
||||||
|
while(true) if (
|
||||||
|
(statement.match(/\(/g) || []).length >
|
||||||
|
(statement.match(/\)/g) || []).length
|
||||||
|
) statement += ")"; else break;
|
||||||
|
// evaluate the expression.
|
||||||
|
console.log("calculating [" + statement + "]");
|
||||||
|
try {
|
||||||
|
var result = eval(statement);
|
||||||
|
document.getElementById("prev-equation").innerHTML = mathtext.innerHTML + " = ";
|
||||||
|
mathtext.innerHTML = result;
|
||||||
|
mathtext.classList.remove("error-border");
|
||||||
|
} catch (e) {
|
||||||
|
mathtext.classList.add("error-border");
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updateCalc = (e) => {
|
||||||
|
// character(s) recieved from button
|
||||||
|
var c = event.target.innerHTML;
|
||||||
|
var mathtext = document.getElementById("calc-text");
|
||||||
|
if (mathtext.innerHTML === "0") mathtext.innerHTML = "";
|
||||||
|
// special cases
|
||||||
|
switch (c) {
|
||||||
|
case "C":
|
||||||
|
// Clear
|
||||||
|
mathtext.innerHTML = "0";
|
||||||
|
break;
|
||||||
|
case "⬅":
|
||||||
|
// Delete
|
||||||
|
mathtext.innerHTML = mathtext.innerHTML.slice(0, -1);
|
||||||
|
if (mathtext.innerHTML.length === 0) {
|
||||||
|
mathtext.innerHTML = "0";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "=":
|
||||||
|
calc()
|
||||||
|
break;
|
||||||
|
case "sin":
|
||||||
|
case "cos":
|
||||||
|
case "tan":
|
||||||
|
case "log":
|
||||||
|
case "√":
|
||||||
|
mathtext.innerHTML += `${c}(`;
|
||||||
|
break;
|
||||||
|
case "|x|":
|
||||||
|
mathtext.innerHTML += "abs("
|
||||||
|
break;
|
||||||
|
case "+":
|
||||||
|
case "-":
|
||||||
|
case "x":
|
||||||
|
case "/":
|
||||||
|
case "%":
|
||||||
|
case "^":
|
||||||
|
if (mathtext.innerHTML.length === 0) mathtext.innerHTML = "0";
|
||||||
|
// prevent typing 2 operators in a row
|
||||||
|
if (mathtext.innerHTML.match(/[+\-x\/%^] $/))
|
||||||
|
mathtext.innerHTML = mathtext.innerHTML.slice(0, -3);
|
||||||
|
mathtext.innerHTML += ` ${c} `;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mathtext.innerHTML += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i of $("#calc-btns button")) {
|
||||||
|
i.addEventListener('click', event => {
|
||||||
|
updateCalc(event);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -2,27 +2,55 @@
|
|||||||
<head>
|
<head>
|
||||||
<link rel="shortcut icon" href="static/img/favicon.ico" type="image/x-icon">
|
<link rel="shortcut icon" href="static/img/favicon.ico" type="image/x-icon">
|
||||||
<link rel="icon" href="static/img/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="static/img/favicon.ico" type="image/x-icon">
|
||||||
|
{% if not search_type %}
|
||||||
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
||||||
|
{% else %}
|
||||||
|
<link rel="search" href="opensearch.xml?tbm={{ search_type }}" type="application/opensearchdescription+xml" title="Whoogle Search ({{ search_name }})">
|
||||||
|
{% endif %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="referrer" content="no-referrer">
|
<meta name="referrer" content="no-referrer">
|
||||||
<link rel="stylesheet" href="static/css/search.css">
|
{% if bundle_static() %}
|
||||||
<link rel="stylesheet" href="static/css/variables.css">
|
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||||
<link rel="stylesheet" href="static/css/header.css">
|
{% else %}
|
||||||
<link rel="stylesheet" href="static/css/{{ 'dark' if config.dark else 'light' }}-theme.css"/>
|
<link rel="stylesheet" href="{{ cb_url('logo.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ cb_url('input.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ cb_url('search.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ cb_url('header.css') }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if config.theme %}
|
||||||
|
{% if config.theme == 'system' %}
|
||||||
|
<style>
|
||||||
|
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||||
|
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||||
|
</style>
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url(('dark' if config.dark else 'light') + '-theme.css') }}"/>
|
||||||
|
{% endif %}
|
||||||
<style>{{ config.style }}</style>
|
<style>{{ config.style }}</style>
|
||||||
<title>{{ query }} - Whoogle Search</title>
|
<title>{{ clean_query(query) }} - Whoogle Search</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{ search_header|safe }}
|
{{ search_header|safe }}
|
||||||
|
{% if is_translation %}
|
||||||
|
<iframe
|
||||||
|
id="lingva-iframe"
|
||||||
|
src="{{ lingva_url }}/auto/{{ translate_to }}/{{ translate_str }}">
|
||||||
|
</iframe>
|
||||||
|
{% endif %}
|
||||||
{{ response|safe }}
|
{{ response|safe }}
|
||||||
</body>
|
</body>
|
||||||
<footer>
|
{% include 'footer.html' %}
|
||||||
<p style="color: {{ '#fff' if config.dark else '#000' }};">
|
{% if bundle_static() %}
|
||||||
Whoogle Search v{{ version_number }} ||
|
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||||
<a style="color: #685e79" href="https://github.com/benbusby/whoogle-search">View on GitHub</a>
|
{% else %}
|
||||||
</p>
|
{% if autocomplete_enabled == '1' %}
|
||||||
</footer>
|
<script src="{{ cb_url('autocomplete.js') }}"></script>
|
||||||
<script src="static/js/autocomplete.js"></script>
|
{% endif %}
|
||||||
<script src="static/js/utils.js"></script>
|
<script src="{{ cb_url('utils.js') }}"></script>
|
||||||
<script src="static/js/keyboard.js"></script>
|
<script src="{{ cb_url('keyboard.js') }}"></script>
|
||||||
|
<script src="{{ cb_url('currency.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,6 +1,130 @@
|
|||||||
|
{% if config.theme %}
|
||||||
|
{% if config.theme == 'system' %}
|
||||||
|
<style>
|
||||||
|
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||||
|
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||||
|
</style>
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url(('dark' if config.dark else 'light') + '-theme.css') }}"/>
|
||||||
|
{% endif %}
|
||||||
|
{% if bundle_static() %}
|
||||||
|
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ cb_url('error.css') }}">
|
||||||
|
{% endif %}
|
||||||
|
<style>{{ config.style }}</style>
|
||||||
|
<div>
|
||||||
<h1>Error</h1>
|
<h1>Error</h1>
|
||||||
<hr>
|
|
||||||
<p>
|
<p>
|
||||||
Error: "{{ error_message|safe }}"
|
{{ error_message }}
|
||||||
</p>
|
</p>
|
||||||
<a href="/">Return Home</a>
|
<hr>
|
||||||
|
{% if query and translation %}
|
||||||
|
<p>
|
||||||
|
<h4><a class="link" href="https://farside.link">{{ translation['continue-search'] }}</a></h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/benbusby/whoogle-search">Whoogle</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="{{farside}}/whoogle/search?q={{query}}{{params}}">
|
||||||
|
{{farside}}/whoogle/search?q={{query}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/searxng/searxng">SearXNG</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="{{farside}}/searxng/search?q={{query}}">
|
||||||
|
{{farside}}/searxng/search?q={{query}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://git.lolcat.ca/lolcat/4get">4get</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="{{farside}}/4get/web?s={{query}}&scraper=google">
|
||||||
|
{{farside}}/4get/web?s={{query}}&scraper=google
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h4>Other options:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://kagi.com">Kagi</a>
|
||||||
|
<ul>
|
||||||
|
<li>Requires account</li>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="https://kagi.com/search?q={{query}}">
|
||||||
|
kagi.com/search?q={{query}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://4get.ca">4get</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="https://4get.ca/web?s={{query}}">
|
||||||
|
4get.ca/web?s={{query}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://duckduckgo.com">DuckDuckGo</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="https://duckduckgo.com/?q={{query}}">
|
||||||
|
duckduckgo.com/?q={{query}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://search.brave.com">Brave Search</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="https://search.brave.com/search?q={{query}}">
|
||||||
|
search.brave.com/search?q={{query}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://ecosia.com">Ecosia</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="https://ecosia.com/search?q={{query}}">
|
||||||
|
ecosia.com/search?q={{query}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://google.com">Google</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a class="link-color" href="https://google.com/search?q={{query}}">
|
||||||
|
google.com/search?q={{query}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<a class="link" href="home">Return Home</a>
|
||||||
|
</div>
|
||||||
|
|||||||
9
app/templates/footer.html
Normal file
9
app/templates/footer.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<footer>
|
||||||
|
<p class="footer">
|
||||||
|
Whoogle Search v{{ version_number }} ||
|
||||||
|
<a class="link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
|
||||||
|
{% if has_update %}
|
||||||
|
|| <span class="update_available">Update Available 🟢</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
@ -1,48 +1,95 @@
|
|||||||
{% if mobile %}
|
{% if mobile %}
|
||||||
<header>
|
<header>
|
||||||
<div class="bz1lBb">
|
<div class="header-div">
|
||||||
<form class="Pg70bf" id="search-form" method="POST">
|
<form class="search-form header"
|
||||||
<a class="logo-link mobile-logo"
|
id="search-form"
|
||||||
href="/"
|
method="{{ 'GET' if config.get_only else 'POST' }}">
|
||||||
style="display:flex; justify-content:center; align-items:center;">
|
<a class="logo-link mobile-logo" href="{{ home_url }}">
|
||||||
<div style="height: 1.75em;">
|
<div id="mobile-header-logo">
|
||||||
{{ logo|safe }}
|
{{ logo|safe }}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="H0PQec" style="width: 100%;">
|
<div class="H0PQec mobile-input-div">
|
||||||
<div class="sbc esbc autocomplete">
|
<div class="autocomplete-mobile esbc autocomplete">
|
||||||
<input id="search-bar" autocapitalize="none" autocomplete="off" class="noHIxc" name="q"
|
{% if config.preferences %}
|
||||||
style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;
|
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||||
color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};
|
{% endif %}
|
||||||
border: {{ '2px solid var(--whoogle-dark-element-bg)' if config.dark else '' }}; border-radius: 8px;"
|
<input
|
||||||
spellcheck="false" type="text" value="{{ query }}">
|
id="search-bar"
|
||||||
|
class="mobile-search-bar"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="search-bar-input"
|
||||||
|
name="q"
|
||||||
|
type="text"
|
||||||
|
value="{{ clean_query(query) }}"
|
||||||
|
dir="auto">
|
||||||
|
<input id="search-reset" type="reset" value="x">
|
||||||
<input name="tbm" value="{{ search_type }}" style="display: none">
|
<input name="tbm" value="{{ search_type }}" style="display: none">
|
||||||
|
<input name="country" value="{{ config.country }}" style="display: none;">
|
||||||
<input type="submit" style="display: none;">
|
<input type="submit" style="display: none;">
|
||||||
<div class="sc"></div>
|
<div class="sc"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="header-tab-div">
|
||||||
|
<div class="header-tab-div-2">
|
||||||
|
<div class="header-tab-div-3">
|
||||||
|
<div class="mobile-header header-tab">
|
||||||
|
{% for tab_id, tab_content in tabs.items() %}
|
||||||
|
{% if tab_content['selected'] %}
|
||||||
|
<span class="mobile-tab-span">{{ tab_content['name'] }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a class="header-tab-a" href="{{ tab_content['href'] }}">{{ tab_content['name'] }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<label for="adv-search-toggle" id="adv-search-label" class="adv-search">⚙</label>
|
||||||
|
<input id="adv-search-toggle" type="checkbox">
|
||||||
|
<div class="header-tab-div-end"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="" id="s">
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{% else %}
|
{% else %}
|
||||||
<header>
|
<header>
|
||||||
<div class="logo-div">
|
<div class="logo-div">
|
||||||
<a class="logo-link" href="/">
|
<a class="logo-link" href="{{ home_url }}">
|
||||||
<div style="height: 1.65em;">
|
<div class="desktop-header-logo">
|
||||||
{{ logo|safe }}
|
{{ logo|safe }}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-div">
|
<div class="search-div">
|
||||||
<form id="search-form" class="search-form" id="sf" method="POST">
|
<form id="search-form"
|
||||||
<div class="autocomplete" style="width: 100%; flex: 1">
|
class="search-form"
|
||||||
|
id="sf"
|
||||||
|
method="{{ 'GET' if config.get_only else 'POST' }}">
|
||||||
|
<div class="autocomplete header-autocomplete">
|
||||||
<div style="width: 100%; display: flex">
|
<div style="width: 100%; display: flex">
|
||||||
<input id="search-bar" autocapitalize="none" autocomplete="off" class="noHIxc" name="q"
|
{% if config.preferences %}
|
||||||
spellcheck="false" type="text" value="{{ query }}"
|
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||||
style="background-color: {{ 'var(--whoogle-dark-result-bg)' if config.dark else 'var(--whoogle-result-bg)' }} !important;
|
{% endif %}
|
||||||
color: {{ 'var(--whoogle-dark-text)' if config.dark else 'var(--whoogle-text)' }};
|
<input
|
||||||
border: {{ '2px solid var(--whoogle-dark-element-bg)' if config.dark else '' }}; border-radius: 8px;">
|
id="search-bar"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
class="search-bar-desktop search-bar-input"
|
||||||
|
name="q"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
value="{{ clean_query(query) }}"
|
||||||
|
dir="auto">
|
||||||
<input name="tbm" value="{{ search_type }}" style="display: none">
|
<input name="tbm" value="{{ search_type }}" style="display: none">
|
||||||
|
<input name="country" value="{{ config.country }}" style="display: none;">
|
||||||
|
<input name="tbs" value="{{ config.tbs }}" style="display: none;">
|
||||||
<input type="submit" style="display: none;">
|
<input type="submit" style="display: none;">
|
||||||
<div class="sc"></div>
|
<div class="sc"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -50,6 +97,66 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div>
|
||||||
|
<div class="header-tab-div">
|
||||||
|
<div class="header-tab-div-2">
|
||||||
|
<div class="header-tab-div-3">
|
||||||
|
<div class="desktop-header header-tab">
|
||||||
|
{% for tab_id, tab_content in tabs.items() %}
|
||||||
|
{% if tab_content['selected'] %}
|
||||||
|
<span class="header-tab-span">{{ tab_content['name'] }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a class="header-tab-a" href="{{ tab_content['href'] }}">{{ tab_content['name'] }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<label for="adv-search-toggle" id="adv-search-label" class="adv-search">⚙</label>
|
||||||
|
<input id="adv-search-toggle" type="checkbox">
|
||||||
|
<div class="header-tab-div-end"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="" id="s">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="result-collapsible" id="adv-search-div">
|
||||||
|
<div class="result-config">
|
||||||
|
<label for="config-country">{{ translation['config-country'] }}: </label>
|
||||||
|
<select name="country" id="result-country">
|
||||||
|
{% for country in countries %}
|
||||||
|
<option value="{{ country.value }}"
|
||||||
|
{% if (
|
||||||
|
config.country != '' and config.country in country.value
|
||||||
|
) or (
|
||||||
|
config.country == '' and country.value == '')
|
||||||
|
%}
|
||||||
|
selected
|
||||||
|
{% endif %}>
|
||||||
|
{{ country.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<br />
|
||||||
|
<label for="config-time-period">{{ translation['config-time-period'] }}: </label>
|
||||||
|
<select name="tbs" id="result-time-period">
|
||||||
|
{% for time_period in time_periods %}
|
||||||
|
<option value="{{ time_period.value }}"
|
||||||
|
{% if (
|
||||||
|
config.tbs != '' and config.tbs in time_period.value
|
||||||
|
) or (
|
||||||
|
config.tbs == '' and time_period.value == '')
|
||||||
|
%}
|
||||||
|
selected
|
||||||
|
{% endif %}>
|
||||||
|
{{ translation[time_period.value] }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript" src="static/js/header.js"></script>
|
{% if bundle_static() %}
|
||||||
|
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||||
|
{% else %}
|
||||||
|
<script type="text/javascript" src="{{ cb_url('header.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
385
app/templates/imageresults.html
Normal file
385
app/templates/imageresults.html
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
<div>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
font-family: Roboto, Helvetica Neue, Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
color: #3c4043;
|
||||||
|
word-wrap: break-word;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
padding: 0 8px;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 736px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a img {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FbhRzb {
|
||||||
|
border-left: thin solid #dadce0;
|
||||||
|
border-right: thin solid #dadce0;
|
||||||
|
border-top: thin solid #dadce0;
|
||||||
|
height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.n692Zd {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.cvifge {
|
||||||
|
height: 40px;
|
||||||
|
border-spacing: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.QvGUP {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 8px 0 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.O4cRJf {
|
||||||
|
height: 40px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
.O1ePr {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.kgJEQe {
|
||||||
|
height: 36px;
|
||||||
|
width: 98px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.lXLRf {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.MhzMZd {
|
||||||
|
border: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
.xB0fq {
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #4285f4;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 16px;
|
||||||
|
margin: 0;
|
||||||
|
vertical-align: top;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.xB0fq:focus {
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
.M7pB2 {
|
||||||
|
border: thin solid #dadce0;
|
||||||
|
margin: 0 0 3px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.euZec {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
text-align: center;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
table.euZec td {
|
||||||
|
padding: 0;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
.QIqI7 {
|
||||||
|
display: inline-block;
|
||||||
|
padding-top: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4285f4;
|
||||||
|
}
|
||||||
|
.EY24We {
|
||||||
|
border-bottom: 2px solid #4285f4;
|
||||||
|
}
|
||||||
|
.CsQyDc {
|
||||||
|
display: inline-block;
|
||||||
|
color: #70757a;
|
||||||
|
}
|
||||||
|
.TuS8Ad {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.HddGcc {
|
||||||
|
padding: 8px;
|
||||||
|
color: #70757a;
|
||||||
|
}
|
||||||
|
.dzp8ae {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #3c4043;
|
||||||
|
}
|
||||||
|
.rEM8G {
|
||||||
|
color: #70757a;
|
||||||
|
}
|
||||||
|
.bookcf {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
.InWNIe {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.uZgmoc {
|
||||||
|
border: thin solid #dadce0;
|
||||||
|
color: #70757a;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.frGj1b {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 0 12px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.BnJWBc {
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 0 13px 0;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
.e3goi {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.GpQGbf {
|
||||||
|
margin: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.X6ZCif {
|
||||||
|
color: #202124;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
display: inline-block;
|
||||||
|
padding-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.TwVfHd {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: thin solid #dadce0;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.yekiAe {
|
||||||
|
background-color: #dadce0;
|
||||||
|
}
|
||||||
|
.svla5d {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ezO2md {
|
||||||
|
border: thin solid #dadce0;
|
||||||
|
padding: 12px 16px 12px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TxbwNb {
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
.K35ahc {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.owohpf {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.RAyV4b {
|
||||||
|
line-height: 140px;
|
||||||
|
overflow: "hidden";
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.t0fcAb {
|
||||||
|
text-align: center;
|
||||||
|
margin: auto;
|
||||||
|
vertical-align: middle;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.Tor4Ec {
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fYyStc {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.ynsChf {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.Fj3V3b {
|
||||||
|
color: #1967d2;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
.FrIlee {
|
||||||
|
color: #202124;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.F9iS2e {
|
||||||
|
color: #70757a;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.WMQ2Le {
|
||||||
|
color: #70757a;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.x3G5ab {
|
||||||
|
color: #202124;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.fuLhoc {
|
||||||
|
color: #1967d2;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
.epoveb {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #202124;
|
||||||
|
}
|
||||||
|
.dXDvrc {
|
||||||
|
color: #0d652d;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.dloBPe {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.YVIcad {
|
||||||
|
color: #70757a;
|
||||||
|
}
|
||||||
|
.JkVVdd {
|
||||||
|
color: #ea4335;
|
||||||
|
}
|
||||||
|
.oXZRFd {
|
||||||
|
color: #ea4335;
|
||||||
|
}
|
||||||
|
.MQHtg {
|
||||||
|
color: #fbbc04;
|
||||||
|
}
|
||||||
|
.pyMRrb {
|
||||||
|
color: #1e8e3e;
|
||||||
|
}
|
||||||
|
.EtTZid {
|
||||||
|
color: #1e8e3e;
|
||||||
|
}
|
||||||
|
.M3vVJe {
|
||||||
|
color: #1967d2;
|
||||||
|
}
|
||||||
|
.qXLe6d {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.NHQNef {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.Cb8Z7c {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
a.ZWRArf {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a .CVA68e:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div class="lIMUZd">
|
||||||
|
<table class="By0U9">
|
||||||
|
<!-- correction suggested -->
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="GpQGbf">
|
||||||
|
{% for i in range((length // 4) + 1) %}
|
||||||
|
<tr>
|
||||||
|
{% for j in range([length - (i*4), 4]|min) %}
|
||||||
|
<td align="center" class="e3goi">
|
||||||
|
<div class="svla5d">
|
||||||
|
<div>
|
||||||
|
<div class="lIMUZd">
|
||||||
|
<div>
|
||||||
|
<table class="TxbwNb">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ results[(i*4)+j].web_page }}">
|
||||||
|
<div class="RAyV4b">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
class="t0fcAb"
|
||||||
|
src="{{ results[(i*4)+j].img_tbn }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ results[(i*4)+j].web_page }}">
|
||||||
|
<div class="Tor4Ec">
|
||||||
|
<span class="qXLe6d x3G5ab">
|
||||||
|
<span class="fYyStc">
|
||||||
|
{{ results[(i*4)+j].domain }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="{{ results[(i*4)+j].img_url }}">
|
||||||
|
<div class="Tor4Ec">
|
||||||
|
<span class="qXLe6d F9iS2e">
|
||||||
|
<span class="fYyStc"> {{ view_label }} </span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<table class="uZgmoc">
|
||||||
|
<!-- next page object -->
|
||||||
|
</table>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
<html>
|
<html style="background: #000;">
|
||||||
<head>
|
<head>
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="static/img/favicon/apple-icon-57x57.png">
|
<link rel="apple-touch-icon" sizes="57x57" href="static/img/favicon/apple-icon-57x57.png">
|
||||||
<link rel="apple-touch-icon" sizes="60x60" href="static/img/favicon/apple-icon-60x60.png">
|
<link rel="apple-touch-icon" sizes="60x60" href="static/img/favicon/apple-icon-60x60.png">
|
||||||
@ -17,24 +17,58 @@
|
|||||||
<meta name="referrer" content="no-referrer">
|
<meta name="referrer" content="no-referrer">
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
<meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png">
|
<meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png">
|
||||||
<script type="text/javascript" src="static/js/autocomplete.js"></script>
|
{% if bundle_static() %}
|
||||||
<script type="text/javascript" src="static/js/controller.js"></script>
|
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||||
|
{% else %}
|
||||||
|
{% if autocomplete_enabled == '1' %}
|
||||||
|
<script src="{{ cb_url('autocomplete.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
|
<script type="text/javascript" src="{{ cb_url('controller.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="static/css/variables.css">
|
{% if bundle_static() %}
|
||||||
<link rel="stylesheet" href="static/css/main.css">
|
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||||
<link rel="stylesheet" href="static/css/{{ 'dark' if config.dark else 'light' }}-theme.css"/>
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url('logo.css') }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if config.theme %}
|
||||||
|
{% if config.theme == 'system' %}
|
||||||
|
<style>
|
||||||
|
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||||
|
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||||
|
</style>
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url(('dark' if config.dark else 'light') + '-theme.css') }}"/>
|
||||||
|
{% endif %}
|
||||||
|
{% if not bundle_static() %}
|
||||||
|
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
|
||||||
|
{% endif %}
|
||||||
<noscript>
|
<noscript>
|
||||||
<style>
|
<style>
|
||||||
#main { display: inherit !important; }
|
#main {
|
||||||
.content { max-height: 720px; padding: 18px; border-radius: 10px; }
|
display: inherit !important;
|
||||||
.collapsible { display: none; }
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-height: 400px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</noscript>
|
</noscript>
|
||||||
<style>{{ config.style }}</style>
|
<style>{{ config.style }}</style>
|
||||||
<title>Whoogle Search</title>
|
<title>Whoogle Search</title>
|
||||||
</head>
|
</head>
|
||||||
<body id="main" style="display: none; background-color: {{ '#000' if config.dark else '#fff' }}">
|
<body id="main">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
{{ logo|safe }}
|
{{ logo|safe }}
|
||||||
@ -42,32 +76,67 @@
|
|||||||
<form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}">
|
<form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}">
|
||||||
<div class="search-fields">
|
<div class="search-fields">
|
||||||
<div class="autocomplete">
|
<div class="autocomplete">
|
||||||
<input type="text" name="q" id="search-bar" class="home-search" autofocus="autofocus" autocapitalize="none" autocomplete="off">
|
{% if config.preferences %}
|
||||||
|
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||||
|
{% endif %}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
id="search-bar"
|
||||||
|
class="home-search"
|
||||||
|
autofocus="autofocus"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false"
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
dir="auto">
|
||||||
</div>
|
</div>
|
||||||
<input type="submit" id="search-submit" value="Search">
|
<input type="submit" id="search-submit" value="{{ translation['search'] }}">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% if not config_disabled %}
|
||||||
<br/>
|
<br/>
|
||||||
<button id="config-collapsible" class="collapsible">Configuration</button>
|
<button id="config-collapsible" class="collapsible">{{ translation['config'] }}</button>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="config-fields">
|
<div class="config-fields">
|
||||||
<form id="config-form" action="config" method="post">
|
<form id="config-form" action="config" method="post">
|
||||||
<div class="config-div config-div-ctry">
|
<div class="config-options">
|
||||||
<label for="config-ctry">Filter Results by Country: </label>
|
<div class="config-div config-div-country">
|
||||||
<select name="ctry" id="config-ctry">
|
<label for="config-country">{{ translation['config-country'] }}: </label>
|
||||||
{% for ctry in countries %}
|
<select name="country" id="config-country">
|
||||||
<option value="{{ ctry.value }}"
|
{% for country in countries %}
|
||||||
{% if ctry.value in config.ctry %}
|
<option value="{{ country.value }}"
|
||||||
|
{% if (
|
||||||
|
config.country != '' and config.country in country.value
|
||||||
|
) or (
|
||||||
|
config.country == '' and country.value == '')
|
||||||
|
%}
|
||||||
selected
|
selected
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
{{ ctry.name }}
|
{{ country.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="config-div">
|
||||||
|
<label for="config-time-period">{{ translation['config-time-period'] }}</label>
|
||||||
|
<select name="tbs" id="config-time-period">
|
||||||
|
{% for time_period in time_periods %}
|
||||||
|
<option value="{{ time_period.value }}"
|
||||||
|
{% if (
|
||||||
|
config.tbs != '' and config.tbs in time_period.value
|
||||||
|
) or (
|
||||||
|
config.tbs == '' and time_period.value == '')
|
||||||
|
%}
|
||||||
|
selected
|
||||||
|
{% endif %}>
|
||||||
|
{{ translation[time_period.value] }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div><span class="info-text"> — Note: If enabled, a website will only appear in the results if it is *hosted* in the selected country.</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-lang">
|
<div class="config-div config-div-lang">
|
||||||
<label for="config-lang-interface">Interface Language: </label>
|
<label for="config-lang-interface">{{ translation['config-lang'] }}: </label>
|
||||||
<select name="lang_interface" id="config-lang-interface">
|
<select name="lang_interface" id="config-lang-interface">
|
||||||
{% for lang in languages %}
|
{% for lang in languages %}
|
||||||
<option value="{{ lang.value }}"
|
<option value="{{ lang.value }}"
|
||||||
@ -80,7 +149,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-search-lang">
|
<div class="config-div config-div-search-lang">
|
||||||
<label for="config-lang-search">Search Language: </label>
|
<label for="config-lang-search">{{ translation['config-lang-search'] }}: </label>
|
||||||
<select name="lang_search" id="config-lang-search">
|
<select name="lang_search" id="config-lang-search">
|
||||||
{% for lang in languages %}
|
{% for lang in languages %}
|
||||||
<option value="{{ lang.value }}"
|
<option value="{{ lang.value }}"
|
||||||
@ -93,61 +162,145 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-near">
|
<div class="config-div config-div-near">
|
||||||
<label for="config-near">Near: </label>
|
<label for="config-near">{{ translation['config-near'] }}: </label>
|
||||||
<input type="text" name="near" id="config-near" placeholder="City Name" value="{{ config.near }}">
|
<input type="text" name="near" id="config-near"
|
||||||
|
placeholder="{{ translation['config-near-help'] }}" value="{{ config.near }}">
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-block">
|
||||||
|
<label for="config-block">{{ translation['config-block'] }}: </label>
|
||||||
|
<input type="text" name="block" id="config-block"
|
||||||
|
placeholder="{{ translation['config-block-help'] }}" value="{{ config.block }}">
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-block">
|
||||||
|
<label for="config-block-title">{{ translation['config-block-title'] }}: </label>
|
||||||
|
<input type="text" name="block_title" id="config-block"
|
||||||
|
placeholder="{{ translation['config-block-title-help'] }}"
|
||||||
|
value="{{ config.block_title }}">
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-block">
|
||||||
|
<label for="config-block-url">{{ translation['config-block-url'] }}: </label>
|
||||||
|
<input type="text" name="block_url" id="config-block"
|
||||||
|
placeholder="{{ translation['config-block-url-help'] }}" value="{{ config.block_url }}">
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-anon-view">
|
||||||
|
<label for="config-anon-view">{{ translation['config-anon-view'] }}: </label>
|
||||||
|
<input type="checkbox" name="anon_view" id="config-anon-view" {{ 'checked' if config.anon_view else '' }}>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-nojs">
|
<div class="config-div config-div-nojs">
|
||||||
<label for="config-nojs">Show NoJS Links: </label>
|
<label for="config-nojs">{{ translation['config-nojs'] }}: </label>
|
||||||
<input type="checkbox" name="nojs" id="config-nojs" {{ 'checked' if config.nojs else '' }}>
|
<input type="checkbox" name="nojs" id="config-nojs" {{ 'checked' if config.nojs else '' }}>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-dark">
|
<div class="config-div config-div-theme">
|
||||||
<label for="config-dark">Dark Mode: </label>
|
<label for="config-theme">{{ translation['config-theme'] }}: </label>
|
||||||
<input type="checkbox" name="dark" id="config-dark" {{ 'checked' if config.dark else '' }}>
|
<select name="theme" id="config-theme">
|
||||||
|
{% for theme in themes %}
|
||||||
|
<option value="{{ theme }}"
|
||||||
|
{% if theme in config.theme %}
|
||||||
|
selected
|
||||||
|
{% endif %}>
|
||||||
|
{{ translation[theme].capitalize() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- DEPRECATED -->
|
||||||
|
<!--<div class="config-div config-div-dark">-->
|
||||||
|
<!--<label for="config-dark">{{ translation['config-dark'] }}: </label>-->
|
||||||
|
<!--<input type="checkbox" name="dark" id="config-dark" {{ 'checked' if config.dark else '' }}>-->
|
||||||
|
<!--</div>-->
|
||||||
<div class="config-div config-div-safe">
|
<div class="config-div config-div-safe">
|
||||||
<label for="config-safe">Safe Search: </label>
|
<label for="config-safe">{{ translation['config-safe'] }}: </label>
|
||||||
<input type="checkbox" name="safe" id="config-safe" {{ 'checked' if config.safe else '' }}>
|
<input type="checkbox" name="safe" id="config-safe" {{ 'checked' if config.safe else '' }}>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-alts">
|
<div class="config-div config-div-alts">
|
||||||
<label class="tooltip" for="config-alts">Replace Social Media Links: </label>
|
<label class="tooltip" for="config-alts">{{ translation['config-alts'] }}: </label>
|
||||||
<input type="checkbox" name="alts" id="config-alts" {{ 'checked' if config.alts else '' }}>
|
<input type="checkbox" name="alts" id="config-alts" {{ 'checked' if config.alts else '' }}>
|
||||||
<div><span class="info-text"> — Replaces Twitter/YouTube/Instagram/Reddit links
|
<div><span class="info-text"> — {{ translation['config-alts-help'] }}</span></div>
|
||||||
with Nitter/Invidious/Bibliogram/Libreddit links.</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-new-tab">
|
<div class="config-div config-div-new-tab">
|
||||||
<label for="config-new-tab">Open Links in New Tab: </label>
|
<label for="config-new-tab">{{ translation['config-new-tab'] }}: </label>
|
||||||
<input type="checkbox" name="new_tab" id="config-new-tab" {{ 'checked' if config.new_tab else '' }}>
|
<input type="checkbox" name="new_tab"
|
||||||
|
id="config-new-tab" {{ 'checked' if config.new_tab else '' }}>
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-view-image">
|
||||||
|
<label for="config-view-image">{{ translation['config-images'] }}: </label>
|
||||||
|
<input type="checkbox" name="view_image"
|
||||||
|
id="config-view-image" {{ 'checked' if config.view_image else '' }}>
|
||||||
|
<div><span class="info-text"> — {{ translation['config-images-help'] }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-tor">
|
<div class="config-div config-div-tor">
|
||||||
<label for="config-tor">Use Tor: {{ '' if tor_available else 'Unavailable' }}</label>
|
<label for="config-tor">{{ translation['config-tor'] }}: {{ '' if tor_available else 'Unavailable' }}</label>
|
||||||
<input type="checkbox" name="tor" id="config-tor" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>
|
<input type="checkbox" name="tor"
|
||||||
|
id="config-tor" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-leta">
|
||||||
|
<label class="tooltip" for="config-leta">Use Mullvad Leta Backend: </label>
|
||||||
|
<input type="checkbox" name="use_leta"
|
||||||
|
id="config-leta" {{ 'checked' if config.use_leta else '' }}>
|
||||||
|
<div><span class="info-text"> — Uses Mullvad's privacy-focused search. Only supports regular web search (no images/videos/news/maps).</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-get-only">
|
<div class="config-div config-div-get-only">
|
||||||
<label for="config-get-only">GET Requests Only: </label>
|
<label for="config-get-only">{{ translation['config-get-only'] }}: </label>
|
||||||
<input type="checkbox" name="get_only" id="config-get-only" {{ 'checked' if config.get_only else '' }}>
|
<input type="checkbox" name="get_only"
|
||||||
|
id="config-get-only" {{ 'checked' if config.get_only else '' }}>
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-user-agent">
|
||||||
|
<label for="config-user-agent">User Agent: </label>
|
||||||
|
<select name="user_agent" id="config-user-agent">
|
||||||
|
<option value="env_conf" {% if config.user_agent == 'env_conf' %}selected{% endif %}>Use ENV Conf</option>
|
||||||
|
<option value="default" {% if config.user_agent == 'default' %}selected{% endif %}>Default</option>
|
||||||
|
<option value="custom" {% if config.user_agent == 'custom' %}selected{% endif %}>Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-custom-user-agent" {% if config.user_agent != 'custom' %}style="display: none;"{% endif %}>
|
||||||
|
<label for="config-custom-user-agent">Custom User Agent: </label>
|
||||||
|
<input type="text" name="custom_user_agent" id="config-custom-user-agent"
|
||||||
|
value="{{ config.custom_user_agent }}"
|
||||||
|
placeholder="Enter custom user agent string">
|
||||||
|
<div><span class="info-text"> — <a href="https://github.com/benbusby/whoogle-search/wiki/User-Agents">User Agent Wiki</a></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-div-accept-language">
|
||||||
|
<label for="config-accept-language">Set Accept-Language: </label>
|
||||||
|
<input type="checkbox" name="accept_language"
|
||||||
|
id="config-accept-language" {{ 'checked' if config.accept_language else '' }}>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-root-url">
|
<div class="config-div config-div-root-url">
|
||||||
<label for="config-url">Root URL: </label>
|
<label for="config-url">{{ translation['config-url'] }}: </label>
|
||||||
<input type="text" name="url" id="config-url" value="{{ config.url }}">
|
<input type="text" name="url" id="config-url" value="{{ config.url }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div config-div-custom-css">
|
<div class="config-div config-div-custom-css">
|
||||||
<label for="config-style">Custom CSS:</label>
|
<a id="css-link"
|
||||||
<textarea name="style" id="config-style" value="">{{ config.style }}</textarea>
|
href="https://github.com/benbusby/whoogle-search/wiki/User-Contributed-CSS-Themes">
|
||||||
|
{{ translation['config-css'] }}:
|
||||||
|
</a>
|
||||||
|
<textarea
|
||||||
|
name="style_modified"
|
||||||
|
id="config-style"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
autocorrect="off"
|
||||||
|
value="">{{ config.style_modified.replace('\t', '') }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-div">
|
<div class="config-div config-div-pref-url">
|
||||||
<input type="submit" id="config-load" value="Load">
|
<label for="config-pref-encryption">{{ translation['config-pref-encryption'] }}: </label>
|
||||||
<input type="submit" id="config-submit" value="Apply">
|
<input type="checkbox" name="preferences_encrypted"
|
||||||
<input type="submit" id="config-save" value="Save As...">
|
id="config-pref-encryption" {{ 'checked' if config.preferences_encrypted and config.preferences_key else '' }}>
|
||||||
|
<div><span class="info-text"> — {{ translation['config-pref-help'] }}</span></div>
|
||||||
|
<label for="config-pref-url">{{ translation['config-pref-url'] }}: </label>
|
||||||
|
<input type="text" name="pref-url" id="config-pref-url" value="{{ config.url }}?preferences={{ config.preferences }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-div config-buttons">
|
||||||
|
<input type="submit" id="config-load" value="{{ translation['load'] }}">
|
||||||
|
<input type="submit" id="config-submit" value="{{ translation['apply'] }}">
|
||||||
|
<input type="submit" id="config-save" value="{{ translation['save-as'] }}">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
{% include 'footer.html' %}
|
||||||
<p style="color: {{ '#fff' if config.dark else '#000' }};">
|
|
||||||
Whoogle Search v{{ version_number }} ||
|
|
||||||
<a id="gh-link" href="https://github.com/benbusby/whoogle-search">View on GitHub</a>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
<link rel="stylesheet" href="static/css/logo.css">
|
|
||||||
<svg id="Layer_1" class="whoogle-svg" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1028 254">
|
<svg id="Layer_1" class="whoogle-svg" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1028 254">
|
||||||
<style>
|
|
||||||
path {
|
|
||||||
fill: {{ 'var(--whoogle-dark-logo)' if config.dark else 'var(--whoogle-logo)' }};
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<defs>
|
<defs>
|
||||||
<style>
|
<style>
|
||||||
</style>
|
</style>
|
||||||
@ -22,4 +16,3 @@
|
|||||||
<path class="cls-1" d="M950.51,539.43c-.31,20.82-10.91,37.89-28,44.71-25.32,10.11-53.89-7-57.87-34.41-1.51-10.43-1.06-20.59,2.68-30.44,7.08-18.66,25.09-29.59,45-27.58,17.76,1.79,33.92,17.68,36.86,36.35C949.79,531.82,950.08,535.64,950.51,539.43Z" transform="translate(-446 -413)"></path>
|
<path class="cls-1" d="M950.51,539.43c-.31,20.82-10.91,37.89-28,44.71-25.32,10.11-53.89-7-57.87-34.41-1.51-10.43-1.06-20.59,2.68-30.44,7.08-18.66,25.09-29.59,45-27.58,17.76,1.79,33.92,17.68,36.86,36.35C949.79,531.82,950.08,535.64,950.51,539.43Z" transform="translate(-446 -413)"></path>
|
||||||
<path class="cls-1" d="M1099.71,539.39c-.39,22.14-11.74,39.51-30.16,45.6-25.8,8.54-53.64-10.27-55.87-37.67-.78-9.54-.55-18.93,3-28,7.25-18.72,24.95-29.59,45-27.62,17.2,1.68,33.14,16.78,36.57,34.84C1099,530.77,1099.23,535.1,1099.71,539.39Z" transform="translate(-446 -413)"></path>
|
<path class="cls-1" d="M1099.71,539.39c-.39,22.14-11.74,39.51-30.16,45.6-25.8,8.54-53.64-10.27-55.87-37.67-.78-9.54-.55-18.93,3-28,7.25-18.72,24.95-29.59,45-27.62,17.2,1.68,33.14,16.78,36.57,34.84C1099,530.77,1099.23,535.1,1099.71,539.39Z" transform="translate(-446 -413)"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.4 KiB |
File diff suppressed because one or more lines are too long
14
app/templates/search.html
Normal file
14
app/templates/search.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<form id="search-form" action="search" method="post">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
style="width: 90%;"
|
||||||
|
autofocus="autofocus"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false"
|
||||||
|
autocorrect="off"
|
||||||
|
placeholder="Whoogle Search"
|
||||||
|
autocomplete="off"
|
||||||
|
dir="auto">
|
||||||
|
<input type="submit" style="width: 9%" id="search-submit" value="Search">
|
||||||
|
</form>
|
||||||
@ -1,7 +1,57 @@
|
|||||||
import json
|
import json
|
||||||
import requests
|
import httpx
|
||||||
|
import urllib.parse as urlparse
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
|
||||||
DDG_BANGS = 'https://duckduckgo.com/bang.v255.js'
|
bangs_dict = {}
|
||||||
|
DDG_BANGS = 'https://duckduckgo.com/bang.js'
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_bangs(ddg_bangs_file: str, ddg_bangs: dict = {}):
|
||||||
|
"""Loads all the bang files in alphabetical order
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ddg_bangs_file: The str path to the new DDG bangs json file
|
||||||
|
ddg_bangs: The dict of ddg bangs. If this is empty, it will load the
|
||||||
|
bangs from the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
|
||||||
|
"""
|
||||||
|
global bangs_dict
|
||||||
|
ddg_bangs_file = os.path.normpath(ddg_bangs_file)
|
||||||
|
|
||||||
|
if (bangs_dict and not ddg_bangs) or os.path.getsize(ddg_bangs_file) <= 4:
|
||||||
|
return
|
||||||
|
|
||||||
|
bangs = {}
|
||||||
|
bangs_dir = os.path.dirname(ddg_bangs_file)
|
||||||
|
bang_files = glob.glob(os.path.join(bangs_dir, '*.json'))
|
||||||
|
|
||||||
|
# Normalize the paths
|
||||||
|
bang_files = [os.path.normpath(f) for f in bang_files]
|
||||||
|
|
||||||
|
# Move the ddg bangs file to the beginning
|
||||||
|
bang_files = sorted([f for f in bang_files if f != ddg_bangs_file])
|
||||||
|
|
||||||
|
if ddg_bangs:
|
||||||
|
bangs |= ddg_bangs
|
||||||
|
else:
|
||||||
|
bang_files.insert(0, ddg_bangs_file)
|
||||||
|
|
||||||
|
for i, bang_file in enumerate(bang_files):
|
||||||
|
try:
|
||||||
|
with open(bang_file, 'r', encoding='utf-8') as f:
|
||||||
|
bangs |= json.load(f)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
# Ignore decoding error only for the ddg bangs file, since this can
|
||||||
|
# occur if file is still being written
|
||||||
|
if i != 0:
|
||||||
|
raise
|
||||||
|
|
||||||
|
bangs_dict = dict(sorted(bangs.items()))
|
||||||
|
|
||||||
|
|
||||||
def gen_bangs_json(bangs_file: str) -> None:
|
def gen_bangs_json(bangs_file: str) -> None:
|
||||||
@ -14,12 +64,9 @@ def gen_bangs_json(bangs_file: str) -> None:
|
|||||||
None
|
None
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
# Request full list from DDG
|
# Request full list from DDG
|
||||||
r = requests.get(DDG_BANGS)
|
r = httpx.get(DDG_BANGS)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as err:
|
|
||||||
raise SystemExit(err)
|
|
||||||
|
|
||||||
# Convert to json
|
# Convert to json
|
||||||
data = json.loads(r.text)
|
data = json.loads(r.text)
|
||||||
@ -34,28 +81,70 @@ def gen_bangs_json(bangs_file: str) -> None:
|
|||||||
'suggestion': bang_command + ' (' + row['s'] + ')'
|
'suggestion': bang_command + ' (' + row['s'] + ')'
|
||||||
}
|
}
|
||||||
|
|
||||||
json.dump(bangs_data, open(bangs_file, 'w'))
|
with open(bangs_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(bangs_data, f)
|
||||||
|
print('* Finished creating ddg bangs json')
|
||||||
|
load_all_bangs(bangs_file, bangs_data)
|
||||||
|
|
||||||
|
|
||||||
def resolve_bang(query: str, bangs_dict: dict) -> str:
|
def suggest_bang(query: str) -> list[str]:
|
||||||
|
"""Suggests bangs for a user's query
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: A list of bang suggestions
|
||||||
|
|
||||||
|
"""
|
||||||
|
global bangs_dict
|
||||||
|
return [bangs_dict[_]['suggestion'] for _ in bangs_dict if _.startswith(query)]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bang(query: str) -> str:
|
||||||
"""Transform's a user's query to a bang search, if an operator is found
|
"""Transform's a user's query to a bang search, if an operator is found
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The search query
|
query: The search query
|
||||||
bangs_dict: The dict of available bang operators, with corresponding
|
|
||||||
format string search URLs
|
|
||||||
(i.e. "!w": "https://en.wikipedia.org...?search={}")
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: A formatted redirect for a bang search, or an empty str if there
|
str: A formatted redirect for a bang search, or an empty str if there
|
||||||
wasn't a match or didn't contain a bang operator
|
wasn't a match or didn't contain a bang operator
|
||||||
|
|
||||||
"""
|
"""
|
||||||
split_query = query.split(' ')
|
global bangs_dict
|
||||||
for operator in bangs_dict.keys():
|
|
||||||
if operator not in split_query:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return bangs_dict[operator]['url'].format(
|
#if ! not in query simply return (speed up processing)
|
||||||
query.replace(operator, '').strip())
|
if '!' not in query:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
split_query = query.strip().split(' ')
|
||||||
|
|
||||||
|
# look for operator in query if one is found, list operator should be of
|
||||||
|
# length 1, operator should not be case-sensitive here to remove it later
|
||||||
|
operator = [
|
||||||
|
word
|
||||||
|
for word in split_query
|
||||||
|
if word.lower() in bangs_dict
|
||||||
|
]
|
||||||
|
if len(operator) == 1:
|
||||||
|
# get operator
|
||||||
|
operator = operator[0]
|
||||||
|
|
||||||
|
# removes operator from query
|
||||||
|
split_query.remove(operator)
|
||||||
|
|
||||||
|
# rebuild the query string
|
||||||
|
bang_query = ' '.join(split_query).strip()
|
||||||
|
|
||||||
|
# Check if operator is a key in bangs and get bang if exists
|
||||||
|
bang = bangs_dict.get(operator.lower(), None)
|
||||||
|
if bang:
|
||||||
|
bang_url = bang['url']
|
||||||
|
|
||||||
|
if bang_query:
|
||||||
|
return bang_url.replace('{}', bang_query, 1)
|
||||||
|
else:
|
||||||
|
parsed_url = urlparse.urlparse(bang_url)
|
||||||
|
return f'{parsed_url.scheme}://{parsed_url.netloc}'
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
143
app/utils/misc.py
Normal file
143
app/utils/misc.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from bs4 import BeautifulSoup as bsoup
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from flask import Request
|
||||||
|
|
||||||
|
ddg_favicon_site = 'http://icons.duckduckgo.com/ip2'
|
||||||
|
|
||||||
|
empty_gif = base64.b64decode(
|
||||||
|
'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')
|
||||||
|
|
||||||
|
placeholder_img = base64.b64decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABF0lEQVRIS8XWPw9EMBQA8Eok' \
|
||||||
|
'JBKrMFqMBt//GzAYLTZ/VomExPDu6uLiaPteqVynBn0/75W2Vp7nEIYhe6p1XcespmmAd7Is' \
|
||||||
|
'M+4URcGiKPogvMMvmIS2eN9MOMKbKWgf54SYgI4vKkTuQKJKSJErkKzUSkQHUs0lilAg7GMh' \
|
||||||
|
'ISoIA/hYMiKCKIA2soeowCWEMkfHtUmrXLcyGYYBfN9HF8djiaglWzNZlgVs21YisoAUaEXG' \
|
||||||
|
'cQTP86QIFgi7vyLzPIPjOEIEC7ANQv/4aZrAdd0TUtc1i+MYnSsMWjPp+x6CIPgJVlUVS5KE' \
|
||||||
|
'DKig/+wnVzM4pnzaGeHd+ENlWbI0TbVLJBtw2uMfP63wc9d2kDCWxi5Q27bsBerSJ9afJbeL' \
|
||||||
|
'AAAAAElFTkSuQmCC'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_favicon(url: str) -> bytes:
|
||||||
|
"""Fetches a favicon using DuckDuckGo's favicon retriever
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The url to fetch the favicon from
|
||||||
|
Returns:
|
||||||
|
bytes - the favicon bytes, or a placeholder image if one
|
||||||
|
was not returned
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = httpx.get(f'{ddg_favicon_site}/{urlparse(url).netloc}.ico', timeout=2.0)
|
||||||
|
|
||||||
|
if response.status_code == 200 and len(response.content) > 0:
|
||||||
|
tmp_mem = io.BytesIO()
|
||||||
|
tmp_mem.write(response.content)
|
||||||
|
tmp_mem.seek(0)
|
||||||
|
|
||||||
|
return tmp_mem.read()
|
||||||
|
except Exception:
|
||||||
|
# If favicon fetch fails, return placeholder
|
||||||
|
pass
|
||||||
|
return placeholder_img
|
||||||
|
|
||||||
|
|
||||||
|
def gen_file_hash(path: str, static_file: str) -> str:
|
||||||
|
with open(os.path.join(path, static_file), 'rb') as f:
|
||||||
|
file_contents = f.read()
|
||||||
|
file_hash = hashlib.md5(file_contents).hexdigest()[:8]
|
||||||
|
filename_split = os.path.splitext(static_file)
|
||||||
|
|
||||||
|
return f'{filename_split[0]}.{file_hash}{filename_split[-1]}'
|
||||||
|
|
||||||
|
|
||||||
|
def read_config_bool(var: str, default: bool=False) -> bool:
|
||||||
|
val = os.getenv(var, '1' if default else '0')
|
||||||
|
# user can specify one of the following values as 'true' inputs (all
|
||||||
|
# variants with upper case letters will also work):
|
||||||
|
# ('true', 't', '1', 'yes', 'y')
|
||||||
|
return val.lower() in ('true', 't', '1', 'yes', 'y')
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(r: Request) -> str:
|
||||||
|
if r.environ.get('HTTP_X_FORWARDED_FOR') is None:
|
||||||
|
return r.environ['REMOTE_ADDR']
|
||||||
|
|
||||||
|
return r.environ['HTTP_X_FORWARDED_FOR']
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_url(url: str) -> str:
|
||||||
|
if os.getenv('HTTPS_ONLY', False):
|
||||||
|
return url.replace('http://', 'https://', 1)
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def get_proxy_host_url(r: Request, default: str, root=False) -> str:
|
||||||
|
scheme = r.headers.get('X-Forwarded-Proto', 'https')
|
||||||
|
http_host = r.headers.get('X-Forwarded-Host')
|
||||||
|
|
||||||
|
full_path = r.full_path if not root else ''
|
||||||
|
if full_path.startswith('/'):
|
||||||
|
full_path = f'/{full_path}'
|
||||||
|
|
||||||
|
if http_host:
|
||||||
|
prefix = os.environ.get('WHOOGLE_URL_PREFIX', '')
|
||||||
|
if prefix:
|
||||||
|
prefix = f'/{re.sub("[^0-9a-zA-Z]+", "", prefix)}'
|
||||||
|
return f'{scheme}://{http_host}{prefix}{full_path}'
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_update(version_url: str, current: str) -> int:
|
||||||
|
# Check for the latest version of Whoogle
|
||||||
|
has_update = ''
|
||||||
|
with contextlib.suppress(httpx.RequestError, AttributeError):
|
||||||
|
update = bsoup(httpx.get(version_url).text, 'html.parser')
|
||||||
|
latest = update.select_one('[class="Link--primary"]').string[1:]
|
||||||
|
current = int(''.join(filter(str.isdigit, current)))
|
||||||
|
latest = int(''.join(filter(str.isdigit, latest)))
|
||||||
|
has_update = '' if current >= latest else latest
|
||||||
|
|
||||||
|
return has_update
|
||||||
|
|
||||||
|
|
||||||
|
def get_abs_url(url, page_url):
|
||||||
|
# Creates a valid absolute URL using a partial or relative URL
|
||||||
|
urls = {
|
||||||
|
"//": f"https:{url}",
|
||||||
|
"/": f"{urlparse(page_url).netloc}{url}",
|
||||||
|
"./": f"{page_url}{url[2:]}"
|
||||||
|
}
|
||||||
|
for start in urls:
|
||||||
|
if url.startswith(start):
|
||||||
|
return urls[start]
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def list_to_dict(lst: list) -> dict:
|
||||||
|
if len(lst) < 2:
|
||||||
|
return {}
|
||||||
|
return {lst[i].replace(' ', ''): lst[i+1].replace(' ', '')
|
||||||
|
for i in range(0, len(lst), 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_string(key: bytes, string: str) -> str:
|
||||||
|
cipher_suite = Fernet(key)
|
||||||
|
return cipher_suite.encrypt(string.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_string(key: bytes, string: str) -> str:
|
||||||
|
cipher_suite = Fernet(g.session_key)
|
||||||
|
return cipher_suite.decrypt(string.encode()).decode()
|
||||||
@ -1,34 +1,126 @@
|
|||||||
from bs4 import BeautifulSoup
|
from app.models.config import Config
|
||||||
|
from app.models.endpoint import Endpoint
|
||||||
|
from app.utils.misc import list_to_dict
|
||||||
|
from bs4 import BeautifulSoup, NavigableString, MarkupResemblesLocatorWarning
|
||||||
|
import warnings
|
||||||
|
import copy
|
||||||
|
from flask import current_app
|
||||||
|
import html
|
||||||
import os
|
import os
|
||||||
import urllib.parse as urlparse
|
import urllib.parse as urlparse
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
import re
|
||||||
|
warnings.filterwarnings('ignore', category=MarkupResemblesLocatorWarning)
|
||||||
|
|
||||||
SKIP_ARGS = ['ref_src', 'utm']
|
SKIP_ARGS = ['ref_src', 'utm']
|
||||||
SKIP_PREFIX = ['//www.', '//mobile.', '//m.']
|
SKIP_PREFIX = ['//www.', '//mobile.', '//m.']
|
||||||
GOOG_STATIC = 'www.gstatic.com'
|
GOOG_STATIC = 'www.gstatic.com'
|
||||||
|
G_M_LOGO_URL = 'https://www.gstatic.com/m/images/icons/googleg.gif'
|
||||||
GOOG_IMG = '/images/branding/searchlogo/1x/googlelogo'
|
GOOG_IMG = '/images/branding/searchlogo/1x/googlelogo'
|
||||||
LOGO_URL = GOOG_IMG + '_desk'
|
LOGO_URL = GOOG_IMG + '_desk'
|
||||||
BLANK_B64 = ('data:image/png;base64,'
|
BLANK_B64 = ('data:image/png;base64,'
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkw'
|
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkw'
|
||||||
'AIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC')
|
'AIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC')
|
||||||
|
|
||||||
|
|
||||||
# Ad keywords
|
# Ad keywords
|
||||||
BLACKLIST = [
|
BLACKLIST = [
|
||||||
'ad', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告', 'Reklama',
|
'ad', 'ads', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告',
|
||||||
'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan', '広告', 'Augl.',
|
'Reklama', 'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan',
|
||||||
'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन', 'Reklam', 'آگهی',
|
'広告', 'Augl.', 'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन',
|
||||||
'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés', 'Anúncio'
|
'Reklam', 'آگهی', 'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés',
|
||||||
|
'Anúncio', 'Quảng cáo', 'โฆษณา', 'sponsored', 'patrocinado', 'gesponsert',
|
||||||
|
'Sponzorováno', '스폰서', 'Gesponsord', 'Sponsorisé'
|
||||||
]
|
]
|
||||||
|
|
||||||
SITE_ALTS = {
|
SITE_ALTS = {
|
||||||
'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'nitter.net'),
|
'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'farside.link/nitter'),
|
||||||
'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'invidious.snopyta.org'),
|
'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'farside.link/invidious'),
|
||||||
'instagram.com': os.getenv('WHOOGLE_ALT_IG', 'bibliogram.art/u'),
|
'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'farside.link/libreddit'),
|
||||||
'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'libredd.it')
|
**dict.fromkeys([
|
||||||
|
'medium.com',
|
||||||
|
'levelup.gitconnected.com'
|
||||||
|
], os.getenv('WHOOGLE_ALT_MD', 'farside.link/scribe')),
|
||||||
|
'imgur.com': os.getenv('WHOOGLE_ALT_IMG', 'farside.link/rimgo'),
|
||||||
|
'wikipedia.org': os.getenv('WHOOGLE_ALT_WIKI', 'farside.link/wikiless'),
|
||||||
|
'imdb.com': os.getenv('WHOOGLE_ALT_IMDB', 'farside.link/libremdb'),
|
||||||
|
'quora.com': os.getenv('WHOOGLE_ALT_QUORA', 'farside.link/quetre'),
|
||||||
|
'stackoverflow.com': os.getenv('WHOOGLE_ALT_SO', 'farside.link/anonymousoverflow')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Include custom site redirects from WHOOGLE_REDIRECTS
|
||||||
|
SITE_ALTS.update(list_to_dict(re.split(',|:', os.getenv('WHOOGLE_REDIRECTS', ''))))
|
||||||
|
|
||||||
|
|
||||||
|
def contains_cjko(s: str) -> bool:
|
||||||
|
"""This function check whether or not a string contains Chinese, Japanese,
|
||||||
|
or Korean characters. It employs regex and uses the u escape sequence to
|
||||||
|
match any character in a set of Unicode ranges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
s (str): string to be checked
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the input s contains the characters and False otherwise
|
||||||
|
"""
|
||||||
|
unicode_ranges = ('\u4e00-\u9fff' # Chinese characters
|
||||||
|
'\u3040-\u309f' # Japanese hiragana
|
||||||
|
'\u30a0-\u30ff' # Japanese katakana
|
||||||
|
'\u4e00-\u9faf' # Japanese kanji
|
||||||
|
'\uac00-\ud7af' # Korean hangul syllables
|
||||||
|
'\u1100-\u11ff' # Korean hangul jamo
|
||||||
|
)
|
||||||
|
return bool(re.search(fr'[{unicode_ranges}]', s))
|
||||||
|
|
||||||
|
|
||||||
|
def bold_search_terms(response: str, query: str) -> BeautifulSoup:
|
||||||
|
"""Wraps all search terms in bold tags (<b>). If any terms are wrapped
|
||||||
|
in quotes, only that exact phrase will be made bold.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: The initial response body for the query
|
||||||
|
query: The original search query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BeautifulSoup: modified soup object with bold items
|
||||||
|
"""
|
||||||
|
response = BeautifulSoup(response, 'html.parser')
|
||||||
|
|
||||||
|
def replace_any_case(element: NavigableString, target_word: str) -> None:
|
||||||
|
# Replace all instances of the word, but maintaining the same case in
|
||||||
|
# the replacement
|
||||||
|
if len(element) == len(target_word):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure target word is escaped for regex
|
||||||
|
target_word = re.escape(target_word)
|
||||||
|
|
||||||
|
# Check if the word contains Chinese, Japanese, or Korean characters
|
||||||
|
if contains_cjko(target_word):
|
||||||
|
reg_pattern = fr'((?![{{}}<>-]){target_word}(?![{{}}<>-]))'
|
||||||
|
else:
|
||||||
|
reg_pattern = fr'\b((?![{{}}<>-]){target_word}(?![{{}}<>-]))\b'
|
||||||
|
|
||||||
|
if re.match(r'.*[@_!#$%^&*()<>?/\|}{~:].*', target_word) or (
|
||||||
|
element.parent and element.parent.name == 'style'):
|
||||||
|
return
|
||||||
|
|
||||||
|
element.replace_with(BeautifulSoup(
|
||||||
|
re.sub(reg_pattern,
|
||||||
|
r'<b>\1</b>',
|
||||||
|
element,
|
||||||
|
flags=re.I), 'html.parser')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Split all words out of query, grouping the ones wrapped in quotes
|
||||||
|
for word in re.split(r'\s+(?=[^"]*(?:"[^"]*"[^"]*)*$)', query):
|
||||||
|
word = re.sub(r'[@_!#$%^&*()<>?/\|}{~:]+', '', word)
|
||||||
|
target = response.find_all(
|
||||||
|
string=re.compile(r'' + re.escape(word), re.I))
|
||||||
|
for nav_str in target:
|
||||||
|
replace_any_case(nav_str, word)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def has_ad_content(element: str) -> bool:
|
def has_ad_content(element: str) -> bool:
|
||||||
"""Inspects an HTML element for ad related content
|
"""Inspects an HTML element for ad related content
|
||||||
@ -40,11 +132,12 @@ def has_ad_content(element: str) -> bool:
|
|||||||
bool: True/False for the element containing an ad
|
bool: True/False for the element containing an ad
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return (element.upper() in (value.upper() for value in BLACKLIST)
|
element_str = ''.join(filter(str.isalpha, element))
|
||||||
|
return (element_str.upper() in (value.upper() for value in BLACKLIST)
|
||||||
or 'ⓘ' in element)
|
or 'ⓘ' in element)
|
||||||
|
|
||||||
|
|
||||||
def get_first_link(soup: BeautifulSoup) -> str:
|
def get_first_link(soup) -> str:
|
||||||
"""Retrieves the first result link from the query response
|
"""Retrieves the first result link from the query response
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -54,34 +147,82 @@ def get_first_link(soup: BeautifulSoup) -> str:
|
|||||||
str: A str link to the first result
|
str: A str link to the first result
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Replace hrefs with only the intended destination (no "utm" type tags)
|
first_link = ''
|
||||||
|
|
||||||
|
# Find the first valid search result link, excluding details elements
|
||||||
for a in soup.find_all('a', href=True):
|
for a in soup.find_all('a', href=True):
|
||||||
|
# Skip links that are inside details elements (collapsible sections)
|
||||||
|
if a.find_parent('details'):
|
||||||
|
continue
|
||||||
|
|
||||||
# Return the first search result URL
|
# Return the first search result URL
|
||||||
if 'url?q=' in a['href']:
|
if a['href'].startswith('http://') or a['href'].startswith('https://'):
|
||||||
return filter_link_args(a['href'])
|
first_link = a['href']
|
||||||
return ''
|
break
|
||||||
|
|
||||||
|
return first_link
|
||||||
|
|
||||||
|
|
||||||
def get_site_alt(link: str) -> str:
|
def get_site_alt(link: str, site_alts: dict = SITE_ALTS) -> str:
|
||||||
"""Returns an alternative to a particular site, if one is configured
|
"""Returns an alternative to a particular site, if one is configured
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
link: A string result URL to check against the SITE_ALTS map
|
link: A string result URL to check against the site_alts map
|
||||||
|
site_alts: A map of site alternatives to replace with. defaults to SITE_ALTS
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: An updated (or ignored) result link
|
str: An updated (or ignored) result link
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# Need to replace full hostname with alternative to encapsulate
|
||||||
|
# subdomains as well
|
||||||
|
parsed_link = urlparse.urlparse(link)
|
||||||
|
|
||||||
for site_key in SITE_ALTS.keys():
|
# Extract subdomain separately from the domain+tld. The subdomain
|
||||||
if site_key not in link:
|
# is used for wikiless translations.
|
||||||
|
split_host = parsed_link.netloc.split('.')
|
||||||
|
subdomain = split_host[0] if len(split_host) > 2 else ''
|
||||||
|
hostname = '.'.join(split_host[-2:])
|
||||||
|
|
||||||
|
# The full scheme + hostname is used when comparing against the list of
|
||||||
|
# available alternative services, due to how Medium links are constructed.
|
||||||
|
# (i.e. for medium.com: "https://something.medium.com" should match,
|
||||||
|
# "https://medium.com/..." should match, but "philomedium.com" should not)
|
||||||
|
hostcomp = f'{parsed_link.scheme}://{hostname}'
|
||||||
|
|
||||||
|
for site_key in site_alts.keys():
|
||||||
|
site_alt = f'{parsed_link.scheme}://{site_key}'
|
||||||
|
if not hostname or site_alt not in hostcomp or not site_alts[site_key]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
link = link.replace(site_key, SITE_ALTS[site_key])
|
# Wikipedia -> Wikiless replacements require the subdomain (if it's
|
||||||
break
|
# a 2-char language code) to be passed as a URL param to Wikiless
|
||||||
|
# in order to preserve the language setting.
|
||||||
|
params = ''
|
||||||
|
if 'wikipedia' in hostname and len(subdomain) == 2:
|
||||||
|
hostname = f'{subdomain}.{hostname}'
|
||||||
|
params = f'?lang={subdomain}'
|
||||||
|
elif 'medium' in hostname and len(subdomain) > 0:
|
||||||
|
hostname = f'{subdomain}.{hostname}'
|
||||||
|
|
||||||
|
parsed_alt = urlparse.urlparse(site_alts[site_key])
|
||||||
|
link = link.replace(hostname, site_alts[site_key]) + params
|
||||||
|
# If a scheme is specified in the alternative, this results in a
|
||||||
|
# replaced link that looks like "https://http://altservice.tld".
|
||||||
|
# In this case, we can remove the original scheme from the result
|
||||||
|
# and use the one specified for the alt.
|
||||||
|
if parsed_alt.scheme:
|
||||||
|
link = '//'.join(link.split('//')[1:])
|
||||||
|
|
||||||
for prefix in SKIP_PREFIX:
|
for prefix in SKIP_PREFIX:
|
||||||
link = link.replace(prefix, '//')
|
if parsed_alt.scheme:
|
||||||
|
# If a scheme is specified, remove everything before the
|
||||||
|
# first occurence of it
|
||||||
|
link = f'{parsed_alt.scheme}{link.split(parsed_alt.scheme, 1)[-1]}'
|
||||||
|
else:
|
||||||
|
# Otherwise, replace the first occurrence of the prefix
|
||||||
|
link = link.replace(prefix, '//', 1)
|
||||||
|
break
|
||||||
|
|
||||||
return link
|
return link
|
||||||
|
|
||||||
@ -130,8 +271,199 @@ def append_nojs(result: BeautifulSoup) -> None:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
nojs_link = BeautifulSoup(features='html.parser').new_tag('a')
|
nojs_link = BeautifulSoup(features='html.parser').new_tag('a')
|
||||||
nojs_link['href'] = '/window?location=' + result['href']
|
nojs_link['href'] = f'{Endpoint.window}?nojs=1&location=' + result['href']
|
||||||
nojs_link['style'] = 'display:block;width:100%;'
|
nojs_link.string = ' NoJS Link'
|
||||||
nojs_link.string = 'NoJS Link: ' + nojs_link['href']
|
|
||||||
result.append(BeautifulSoup('<br><hr><br>', 'html.parser'))
|
|
||||||
result.append(nojs_link)
|
result.append(nojs_link)
|
||||||
|
|
||||||
|
|
||||||
|
def append_anon_view(result: BeautifulSoup, config: Config) -> None:
|
||||||
|
"""Appends an 'anonymous view' for a search result, where all site
|
||||||
|
contents are viewed through Whoogle as a proxy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: The search result to append an anon view link to
|
||||||
|
nojs: Remove Javascript from Anonymous View
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
|
||||||
|
"""
|
||||||
|
av_link = BeautifulSoup(features='html.parser').new_tag('a')
|
||||||
|
nojs = 'nojs=1' if config.nojs else 'nojs=0'
|
||||||
|
location = f'location={result["href"]}'
|
||||||
|
av_link['href'] = f'{Endpoint.window}?{nojs}&{location}'
|
||||||
|
translation = current_app.config['TRANSLATIONS'][
|
||||||
|
config.get_localization_lang()
|
||||||
|
]
|
||||||
|
av_link.string = f'{translation["anon-view"]}'
|
||||||
|
av_link['class'] = 'anon-view'
|
||||||
|
result.append(av_link)
|
||||||
|
|
||||||
|
def check_currency(response: str) -> dict:
|
||||||
|
"""Check whether the results have currency conversion
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: Search query Result
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Consists of currency names and values
|
||||||
|
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(response, 'html.parser')
|
||||||
|
currency_link = soup.find('a', {'href': 'https://g.co/gfd'})
|
||||||
|
if currency_link:
|
||||||
|
while 'class' not in currency_link.attrs or \
|
||||||
|
'ZINbbc' not in currency_link.attrs['class']:
|
||||||
|
if currency_link.parent:
|
||||||
|
currency_link = currency_link.parent
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
currency_link = currency_link.find_all(class_='BNeawe')
|
||||||
|
currency1 = currency_link[0].text
|
||||||
|
currency2 = currency_link[1].text
|
||||||
|
currency1 = currency1.rstrip('=').split(' ', 1)
|
||||||
|
currency2 = currency2.split(' ', 1)
|
||||||
|
|
||||||
|
# Handle differences in currency formatting
|
||||||
|
# i.e. "5.000" vs "5,000"
|
||||||
|
if currency2[0][-3] == ',':
|
||||||
|
currency1[0] = currency1[0].replace('.', '')
|
||||||
|
currency1[0] = currency1[0].replace(',', '.')
|
||||||
|
currency2[0] = currency2[0].replace('.', '')
|
||||||
|
currency2[0] = currency2[0].replace(',', '.')
|
||||||
|
else:
|
||||||
|
currency1[0] = currency1[0].replace(',', '')
|
||||||
|
currency2[0] = currency2[0].replace(',', '')
|
||||||
|
|
||||||
|
currency1_value = float(re.sub(r'[^\d\.]', '', currency1[0]))
|
||||||
|
currency1_label = currency1[1]
|
||||||
|
|
||||||
|
currency2_value = float(re.sub(r'[^\d\.]', '', currency2[0]))
|
||||||
|
currency2_label = currency2[1]
|
||||||
|
|
||||||
|
return {'currencyValue1': currency1_value,
|
||||||
|
'currencyLabel1': currency1_label,
|
||||||
|
'currencyValue2': currency2_value,
|
||||||
|
'currencyLabel2': currency2_label
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def add_currency_card(soup: BeautifulSoup,
|
||||||
|
conversion_details: dict) -> BeautifulSoup:
|
||||||
|
"""Adds the currency conversion boxes
|
||||||
|
to response of the search query
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: Parsed search result
|
||||||
|
conversion_details: Dictionary of currency
|
||||||
|
related information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BeautifulSoup
|
||||||
|
"""
|
||||||
|
# Element before which the code will be changed
|
||||||
|
# (This is the 'disclaimer' link)
|
||||||
|
element1 = soup.find('a', {'href': 'https://g.co/gfd'})
|
||||||
|
|
||||||
|
while 'class' not in element1.attrs or \
|
||||||
|
'nXE3Ob' not in element1.attrs['class']:
|
||||||
|
element1 = element1.parent
|
||||||
|
|
||||||
|
# Creating the conversion factor
|
||||||
|
conversion_factor = (conversion_details['currencyValue1'] /
|
||||||
|
conversion_details['currencyValue2'])
|
||||||
|
|
||||||
|
# Creating a new div for the input boxes
|
||||||
|
conversion_box = soup.new_tag('div')
|
||||||
|
conversion_box['class'] = 'conversion_box'
|
||||||
|
|
||||||
|
# Currency to be converted from
|
||||||
|
input_box1 = soup.new_tag('input')
|
||||||
|
input_box1['id'] = 'cb1'
|
||||||
|
input_box1['type'] = 'number'
|
||||||
|
input_box1['class'] = 'cb'
|
||||||
|
input_box1['value'] = conversion_details['currencyValue1']
|
||||||
|
input_box1['oninput'] = f'convert(1, 2, {1 / conversion_factor})'
|
||||||
|
|
||||||
|
label_box1 = soup.new_tag('label')
|
||||||
|
label_box1['for'] = 'cb1'
|
||||||
|
label_box1['class'] = 'cb_label'
|
||||||
|
label_box1.append(conversion_details['currencyLabel1'])
|
||||||
|
|
||||||
|
br = soup.new_tag('br')
|
||||||
|
|
||||||
|
# Currency to be converted to
|
||||||
|
input_box2 = soup.new_tag('input')
|
||||||
|
input_box2['id'] = 'cb2'
|
||||||
|
input_box2['type'] = 'number'
|
||||||
|
input_box2['class'] = 'cb'
|
||||||
|
input_box2['value'] = conversion_details['currencyValue2']
|
||||||
|
input_box2['oninput'] = f'convert(2, 1, {conversion_factor})'
|
||||||
|
|
||||||
|
label_box2 = soup.new_tag('label')
|
||||||
|
label_box2['for'] = 'cb2'
|
||||||
|
label_box2['class'] = 'cb_label'
|
||||||
|
label_box2.append(conversion_details['currencyLabel2'])
|
||||||
|
|
||||||
|
conversion_box.append(input_box1)
|
||||||
|
conversion_box.append(label_box1)
|
||||||
|
conversion_box.append(br)
|
||||||
|
conversion_box.append(input_box2)
|
||||||
|
conversion_box.append(label_box2)
|
||||||
|
|
||||||
|
element1.insert_before(conversion_box)
|
||||||
|
return soup
|
||||||
|
|
||||||
|
|
||||||
|
def get_tabs_content(tabs: dict,
|
||||||
|
full_query: str,
|
||||||
|
search_type: str,
|
||||||
|
preferences: str,
|
||||||
|
translation: dict,
|
||||||
|
use_leta: bool = False) -> dict:
|
||||||
|
"""Takes the default tabs content and updates it according to the query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tabs: The default content for the tabs
|
||||||
|
full_query: The original search query
|
||||||
|
search_type: The current search_type
|
||||||
|
translation: The translation to get the names of the tabs
|
||||||
|
use_leta: Whether Mullvad Leta backend is being used
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: contains the name, the href and if the tab is selected or not
|
||||||
|
"""
|
||||||
|
map_query = full_query
|
||||||
|
if '-site:' in full_query:
|
||||||
|
block_idx = full_query.index('-site:')
|
||||||
|
map_query = map_query[:block_idx]
|
||||||
|
tabs = copy.deepcopy(tabs)
|
||||||
|
|
||||||
|
# If using Leta, remove unsupported tabs (images, videos, news, maps)
|
||||||
|
if use_leta:
|
||||||
|
tabs = {k: v for k, v in tabs.items() if k == 'all'}
|
||||||
|
|
||||||
|
for tab_id, tab_content in tabs.items():
|
||||||
|
# update name to desired language
|
||||||
|
if tab_id in translation:
|
||||||
|
tab_content['name'] = translation[tab_id]
|
||||||
|
|
||||||
|
# update href with query
|
||||||
|
query = full_query.replace(f'&tbm={search_type}', '')
|
||||||
|
|
||||||
|
if tab_content['tbm'] is not None:
|
||||||
|
query = f"{query}&tbm={tab_content['tbm']}"
|
||||||
|
|
||||||
|
if preferences:
|
||||||
|
query = f"{query}&preferences={preferences}"
|
||||||
|
|
||||||
|
tab_content['href'] = tab_content['href'].format(
|
||||||
|
query=query,
|
||||||
|
map_query=map_query)
|
||||||
|
|
||||||
|
# update if selected tab (default all tab is selected)
|
||||||
|
if tab_content['tbm'] == search_type:
|
||||||
|
tabs['all']['selected'] = False
|
||||||
|
tab_content['selected'] = True
|
||||||
|
return tabs
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
from app.filter import Filter, get_first_link
|
import os
|
||||||
from app.utils.session import generate_user_key
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from app.filter import Filter
|
||||||
from app.request import gen_query
|
from app.request import gen_query
|
||||||
|
from app.utils.misc import get_proxy_host_url
|
||||||
|
from app.utils.results import get_first_link
|
||||||
from bs4 import BeautifulSoup as bsoup
|
from bs4 import BeautifulSoup as bsoup
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
from flask import g
|
from flask import g
|
||||||
from typing import Any, Tuple
|
|
||||||
import os
|
|
||||||
|
|
||||||
TOR_BANNER = '<hr><h1 style="text-align: center">You are using Tor</h1><hr>'
|
TOR_BANNER = '<hr><h1 style="text-align: center">You are using Tor</h1><hr>'
|
||||||
CAPTCHA = 'div class="g-recaptcha"'
|
CAPTCHA = 'div class="g-recaptcha"'
|
||||||
@ -51,17 +53,20 @@ class Search:
|
|||||||
Attributes:
|
Attributes:
|
||||||
request: the incoming flask request
|
request: the incoming flask request
|
||||||
config: the current user config settings
|
config: the current user config settings
|
||||||
session: the flask user session
|
session_key: the flask user fernet key
|
||||||
"""
|
"""
|
||||||
def __init__(self, request, config, session, cookies_disabled=False):
|
def __init__(self, request, config, session_key, cookies_disabled=False, user_request=None):
|
||||||
method = request.method
|
method = request.method
|
||||||
|
self.request = request
|
||||||
self.request_params = request.args if method == 'GET' else request.form
|
self.request_params = request.args if method == 'GET' else request.form
|
||||||
self.user_agent = request.headers.get('User-Agent')
|
self.user_agent = request.headers.get('User-Agent')
|
||||||
self.feeling_lucky = False
|
self.feeling_lucky = False
|
||||||
self.config = config
|
self.config = config
|
||||||
self.session = session
|
self.session_key = session_key
|
||||||
self.query = ''
|
self.query = ''
|
||||||
|
self.widget = ''
|
||||||
self.cookies_disabled = cookies_disabled
|
self.cookies_disabled = cookies_disabled
|
||||||
|
self.user_request = user_request
|
||||||
self.search_type = self.request_params.get(
|
self.search_type = self.request_params.get(
|
||||||
'tbm') if 'tbm' in self.request_params else ''
|
'tbm') if 'tbm' in self.request_params else ''
|
||||||
|
|
||||||
@ -94,13 +99,26 @@ class Search:
|
|||||||
else:
|
else:
|
||||||
# Attempt to decrypt if this is an internal link
|
# Attempt to decrypt if this is an internal link
|
||||||
try:
|
try:
|
||||||
q = Fernet(self.session['key']).decrypt(q.encode()).decode()
|
q = Fernet(self.session_key).decrypt(q.encode()).decode()
|
||||||
except InvalidToken:
|
except InvalidToken:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Strip leading '! ' for "feeling lucky" queries
|
# Strip '!' for "feeling lucky" queries
|
||||||
self.feeling_lucky = q.startswith('! ')
|
if match := re.search(r"(^|\s)!($|\s)", q):
|
||||||
self.query = q[2:] if self.feeling_lucky else q
|
self.feeling_lucky = True
|
||||||
|
start, end = match.span()
|
||||||
|
self.query = " ".join([seg for seg in [q[:start], q[end:]] if seg])
|
||||||
|
else:
|
||||||
|
self.feeling_lucky = False
|
||||||
|
self.query = q
|
||||||
|
|
||||||
|
# Check for possible widgets
|
||||||
|
self.widget = "ip" if re.search("([^a-z0-9]|^)my *[^a-z0-9] *(ip|internet protocol)" +
|
||||||
|
"($|( *[^a-z0-9] *(((addres|address|adres|" +
|
||||||
|
"adress)|a)? *$)))", self.query.lower()) else self.widget
|
||||||
|
self.widget = 'calculator' if re.search(
|
||||||
|
r"\bcalculator\b|\bcalc\b|\bcalclator\b|\bmath\b",
|
||||||
|
self.query.lower()) else self.widget
|
||||||
return self.query
|
return self.query
|
||||||
|
|
||||||
def generate_response(self) -> str:
|
def generate_response(self) -> str:
|
||||||
@ -112,29 +130,56 @@ class Search:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent
|
mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent
|
||||||
|
# reconstruct url if X-Forwarded-Host header present
|
||||||
|
root_url = get_proxy_host_url(
|
||||||
|
self.request,
|
||||||
|
self.request.url_root,
|
||||||
|
root=True)
|
||||||
|
|
||||||
content_filter = Filter(self.session['key'],
|
content_filter = Filter(self.session_key,
|
||||||
|
root_url=root_url,
|
||||||
mobile=mobile,
|
mobile=mobile,
|
||||||
config=self.config)
|
config=self.config,
|
||||||
|
query=self.query)
|
||||||
full_query = gen_query(self.query,
|
full_query = gen_query(self.query,
|
||||||
self.request_params,
|
self.request_params,
|
||||||
self.config,
|
self.config)
|
||||||
content_filter.near)
|
self.full_query = full_query
|
||||||
get_body = g.user_request.send(query=full_query)
|
|
||||||
|
# force mobile search when view image is true and
|
||||||
|
# the request is not already made by a mobile
|
||||||
|
view_image = ('tbm=isch' in full_query
|
||||||
|
and self.config.view_image)
|
||||||
|
|
||||||
|
client = self.user_request or g.user_request
|
||||||
|
get_body = client.send(query=full_query,
|
||||||
|
force_mobile=self.config.view_image,
|
||||||
|
user_agent=self.user_agent)
|
||||||
|
|
||||||
# Produce cleanable html soup from response
|
# Produce cleanable html soup from response
|
||||||
html_soup = bsoup(content_filter.reskin(get_body.text), 'html.parser')
|
get_body_safed = get_body.text.replace("<","andlt;").replace(">","andgt;")
|
||||||
|
html_soup = bsoup(get_body_safed, 'html.parser')
|
||||||
|
|
||||||
|
# Ensure we extract only the content within <html> if it exists
|
||||||
|
# This prevents doctype declarations from appearing in the output
|
||||||
|
if html_soup.html:
|
||||||
|
html_soup = html_soup.html
|
||||||
|
|
||||||
|
# Replace current soup if view_image is active
|
||||||
|
if view_image:
|
||||||
|
html_soup = content_filter.view_image(html_soup)
|
||||||
|
|
||||||
# Indicate whether or not a Tor connection is active
|
# Indicate whether or not a Tor connection is active
|
||||||
tor_banner = bsoup('', 'html.parser')
|
if (self.user_request or g.user_request).tor_valid:
|
||||||
if g.user_request.tor_valid:
|
html_soup.insert(0, bsoup(TOR_BANNER, 'html.parser'))
|
||||||
tor_banner = bsoup(TOR_BANNER, 'html.parser')
|
|
||||||
html_soup.insert(0, tor_banner)
|
|
||||||
|
|
||||||
if self.feeling_lucky:
|
|
||||||
return get_first_link(html_soup)
|
|
||||||
else:
|
|
||||||
formatted_results = content_filter.clean(html_soup)
|
formatted_results = content_filter.clean(html_soup)
|
||||||
|
if self.feeling_lucky:
|
||||||
|
if lucky_link := get_first_link(formatted_results):
|
||||||
|
return lucky_link
|
||||||
|
|
||||||
|
# Fall through to regular search if unable to find link
|
||||||
|
self.feeling_lucky = False
|
||||||
|
|
||||||
# Append user config to all search links, if available
|
# Append user config to all search links, if available
|
||||||
param_str = ''.join('&{}={}'.format(k, v)
|
param_str = ''.join('&{}={}'.format(k, v)
|
||||||
@ -142,9 +187,11 @@ class Search:
|
|||||||
self.request_params.to_dict(flat=True).items()
|
self.request_params.to_dict(flat=True).items()
|
||||||
if self.config.is_safe_key(k))
|
if self.config.is_safe_key(k))
|
||||||
for link in formatted_results.find_all('a', href=True):
|
for link in formatted_results.find_all('a', href=True):
|
||||||
|
link['rel'] = "nofollow noopener noreferrer"
|
||||||
if 'search?' not in link['href'] or link['href'].index(
|
if 'search?' not in link['href'] or link['href'].index(
|
||||||
'search?') > 1:
|
'search?') > 1:
|
||||||
continue
|
continue
|
||||||
link['href'] += param_str
|
link['href'] += param_str
|
||||||
|
|
||||||
return str(formatted_results)
|
return str(formatted_results)
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
|
|
||||||
REQUIRED_SESSION_VALUES = ['uuid', 'config', 'key']
|
REQUIRED_SESSION_VALUES = ['uuid', 'config', 'key', 'auth']
|
||||||
|
|
||||||
|
|
||||||
def generate_user_key(cookies_disabled=False) -> bytes:
|
def generate_key() -> bytes:
|
||||||
"""Generates a key for encrypting searches and element URLs
|
"""Generates a key for encrypting searches and element URLs
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -16,9 +16,6 @@ def generate_user_key(cookies_disabled=False) -> bytes:
|
|||||||
str: A unique Fernet key
|
str: A unique Fernet key
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if cookies_disabled:
|
|
||||||
return app.default_key
|
|
||||||
|
|
||||||
# Generate/regenerate unique key per user
|
# Generate/regenerate unique key per user
|
||||||
return Fernet.generate_key()
|
return Fernet.generate_key()
|
||||||
|
|
||||||
|
|||||||
71
app/utils/widgets.py
Normal file
71
app/utils/widgets.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
# root
|
||||||
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
def add_ip_card(html_soup: BeautifulSoup, ip: str) -> BeautifulSoup:
|
||||||
|
"""Adds the client's IP address to the search results
|
||||||
|
if query contains keywords
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_soup: The parsed search result containing the keywords
|
||||||
|
ip: ip address of the client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BeautifulSoup
|
||||||
|
|
||||||
|
"""
|
||||||
|
main_div = html_soup.select_one('#main')
|
||||||
|
if main_div:
|
||||||
|
# HTML IP card tag
|
||||||
|
ip_tag = html_soup.new_tag('div')
|
||||||
|
ip_tag['class'] = 'ZINbbc xpd O9g5cc uUPGi'
|
||||||
|
|
||||||
|
# For IP Address html tag
|
||||||
|
ip_address = html_soup.new_tag('div')
|
||||||
|
ip_address['class'] = 'kCrYT ip-address-div'
|
||||||
|
ip_address.string = ip
|
||||||
|
|
||||||
|
# Text below the IP address
|
||||||
|
ip_text = html_soup.new_tag('div')
|
||||||
|
ip_text.string = 'Your public IP address'
|
||||||
|
ip_text['class'] = 'kCrYT ip-text-div'
|
||||||
|
|
||||||
|
# Adding all the above html tags to the IP card
|
||||||
|
ip_tag.append(ip_address)
|
||||||
|
ip_tag.append(ip_text)
|
||||||
|
|
||||||
|
# Insert the element at the top of the result list
|
||||||
|
main_div.insert_before(ip_tag)
|
||||||
|
return html_soup
|
||||||
|
|
||||||
|
def add_calculator_card(html_soup: BeautifulSoup) -> BeautifulSoup:
|
||||||
|
"""Adds the a calculator widget to the search results
|
||||||
|
if query contains keywords
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_soup: The parsed search result containing the keywords
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BeautifulSoup
|
||||||
|
"""
|
||||||
|
main_div = html_soup.select_one('#main')
|
||||||
|
if main_div:
|
||||||
|
# absolute path
|
||||||
|
widget_file = open(BASE_DIR / 'app/static/widgets/calculator.html', encoding="utf8")
|
||||||
|
widget_tag = html_soup.new_tag('div')
|
||||||
|
widget_tag['class'] = 'ZINbbc xpd O9g5cc uUPGi'
|
||||||
|
widget_tag['id'] = 'calculator-wrapper'
|
||||||
|
calculator_text = html_soup.new_tag('div')
|
||||||
|
calculator_text['class'] = 'kCrYT ip-address-div'
|
||||||
|
calculator_text.string = 'Calculator'
|
||||||
|
calculator_widget = html_soup.new_tag('div')
|
||||||
|
calculator_widget.append(BeautifulSoup(widget_file, 'html.parser'))
|
||||||
|
calculator_widget['class'] = 'kCrYT ip-text-div'
|
||||||
|
widget_tag.append(calculator_text)
|
||||||
|
widget_tag.append(calculator_widget)
|
||||||
|
main_div.insert_before(widget_tag)
|
||||||
|
widget_file.close()
|
||||||
|
return html_soup
|
||||||
7
app/version.py
Normal file
7
app/version.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
optional_dev_tag = ''
|
||||||
|
if os.getenv('DEV_BUILD'):
|
||||||
|
optional_dev_tag = '.dev' + os.getenv('DEV_BUILD')
|
||||||
|
|
||||||
|
__version__ = '1.1.0' + optional_dev_tag
|
||||||
23
charts/whoogle/.helmignore
Normal file
23
charts/whoogle/.helmignore
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Patterns to ignore when building packages.
|
||||||
|
# This supports shell glob matching, relative path matching, and
|
||||||
|
# negation (prefixed with !). Only one pattern per line.
|
||||||
|
.DS_Store
|
||||||
|
# Common VCS dirs
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.bzr/
|
||||||
|
.bzrignore
|
||||||
|
.hg/
|
||||||
|
.hgignore
|
||||||
|
.svn/
|
||||||
|
# Common backup files
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
# Various IDEs
|
||||||
|
.project
|
||||||
|
.idea/
|
||||||
|
*.tmproj
|
||||||
|
.vscode/
|
||||||
23
charts/whoogle/Chart.yaml
Normal file
23
charts/whoogle/Chart.yaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: whoogle
|
||||||
|
description: A self hosted search engine on Kubernetes
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: 0.9.4
|
||||||
|
|
||||||
|
icon: https://github.com/benbusby/whoogle-search/raw/main/app/static/img/favicon/favicon-96x96.png
|
||||||
|
|
||||||
|
sources:
|
||||||
|
- https://github.com/benbusby/whoogle-search
|
||||||
|
- https://gitlab.com/benbusby/whoogle-search
|
||||||
|
- https://gogs.benbusby.com/benbusby/whoogle-search
|
||||||
|
|
||||||
|
keywords:
|
||||||
|
- whoogle
|
||||||
|
- degoogle
|
||||||
|
- search
|
||||||
|
- google
|
||||||
|
- search-engine
|
||||||
|
- privacy
|
||||||
|
- tor
|
||||||
|
- python
|
||||||
22
charts/whoogle/templates/NOTES.txt
Normal file
22
charts/whoogle/templates/NOTES.txt
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
1. Get the application URL by running these commands:
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
{{- range $host := .Values.ingress.hosts }}
|
||||||
|
{{- range .paths }}
|
||||||
|
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else if contains "NodePort" .Values.service.type }}
|
||||||
|
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "whoogle.fullname" . }})
|
||||||
|
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||||
|
echo http://$NODE_IP:$NODE_PORT
|
||||||
|
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||||
|
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||||
|
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "whoogle.fullname" . }}'
|
||||||
|
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "whoogle.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||||
|
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||||
|
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||||
|
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "whoogle.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||||
|
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||||
|
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||||
|
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||||
|
{{- end }}
|
||||||
62
charts/whoogle/templates/_helpers.tpl
Normal file
62
charts/whoogle/templates/_helpers.tpl
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "whoogle.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||||
|
If release name contains chart name it will be used as a full name.
|
||||||
|
*/}}
|
||||||
|
{{- define "whoogle.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create chart name and version as used by the chart label.
|
||||||
|
*/}}
|
||||||
|
{{- define "whoogle.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Common labels
|
||||||
|
*/}}
|
||||||
|
{{- define "whoogle.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "whoogle.chart" . }}
|
||||||
|
{{ include "whoogle.selectorLabels" . }}
|
||||||
|
{{- if .Chart.AppVersion }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "whoogle.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "whoogle.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the service account to use
|
||||||
|
*/}}
|
||||||
|
{{- define "whoogle.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
{{- default (include "whoogle.fullname" .) .Values.serviceAccount.name }}
|
||||||
|
{{- else }}
|
||||||
|
{{- default "default" .Values.serviceAccount.name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
82
charts/whoogle/templates/deployment.yaml
Normal file
82
charts/whoogle/templates/deployment.yaml
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "whoogle.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "whoogle.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
{{- if not .Values.autoscaling.enabled }}
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "whoogle.selectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
{{- with .Values.podAnnotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "whoogle.selectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.image.pullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- range .}}
|
||||||
|
- name: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
serviceAccountName: {{ include "whoogle.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
|
containers:
|
||||||
|
- name: whoogle
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
{{- with .Values.conf }}
|
||||||
|
env:
|
||||||
|
{{- range $k,$v := . }}
|
||||||
|
{{- if $v }}
|
||||||
|
- name: {{ $k }}
|
||||||
|
value: {{ tpl (toString $v) $ | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ default 5000 .Values.conf.EXPOSE_PORT }}
|
||||||
|
protocol: TCP
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
{{- if and .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS }}
|
||||||
|
httpHeaders:
|
||||||
|
- name: Authorization
|
||||||
|
value: Basic {{ b64enc (printf "%s:%s" .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS) }}
|
||||||
|
{{- end }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
{{- if and .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS }}
|
||||||
|
httpHeaders:
|
||||||
|
- name: Authorization
|
||||||
|
value: Basic {{ b64enc (printf "%s:%s" .Values.conf.WHOOGLE_USER .Values.conf.WHOOGLE_PASS) }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
44
charts/whoogle/templates/hpa.yaml
Normal file
44
charts/whoogle/templates/hpa.yaml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{{- if .Values.autoscaling.enabled }}
|
||||||
|
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
{{- else -}}
|
||||||
|
apiVersion: autoscaling/v2beta1
|
||||||
|
{{- end }}
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: {{ include "whoogle.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "whoogle.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: {{ include "whoogle.fullname" . }}
|
||||||
|
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||||
|
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||||
|
metrics:
|
||||||
|
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
{{- else -}}
|
||||||
|
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: memory
|
||||||
|
{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
{{- else -}}
|
||||||
|
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
61
charts/whoogle/templates/ingress.yaml
Normal file
61
charts/whoogle/templates/ingress.yaml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{{- if .Values.ingress.enabled -}}
|
||||||
|
{{- $fullName := include "whoogle.fullname" . -}}
|
||||||
|
{{- $svcPort := .Values.service.port -}}
|
||||||
|
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||||
|
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
|
||||||
|
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||||
|
apiVersion: networking.k8s.io/v1beta1
|
||||||
|
{{- else -}}
|
||||||
|
apiVersion: extensions/v1beta1
|
||||||
|
{{- end }}
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
labels:
|
||||||
|
{{- include "whoogle.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- range .Values.ingress.tls }}
|
||||||
|
- hosts:
|
||||||
|
{{- range .hosts }}
|
||||||
|
- {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
secretName: {{ .secretName }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
{{- end }}
|
||||||
|
backend:
|
||||||
|
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||||
|
service:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
port:
|
||||||
|
number: {{ $svcPort }}
|
||||||
|
{{- else }}
|
||||||
|
serviceName: {{ $fullName }}
|
||||||
|
servicePort: {{ $svcPort }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
15
charts/whoogle/templates/service.yaml
Normal file
15
charts/whoogle/templates/service.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "whoogle.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "whoogle.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "whoogle.selectorLabels" . | nindent 4 }}
|
||||||
12
charts/whoogle/templates/serviceaccount.yaml
Normal file
12
charts/whoogle/templates/serviceaccount.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{{- if .Values.serviceAccount.create -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "whoogle.serviceAccountName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "whoogle.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
15
charts/whoogle/templates/tests/test-connection.yaml
Normal file
15
charts/whoogle/templates/tests/test-connection.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: "{{ include "whoogle.fullname" . }}-test-connection"
|
||||||
|
labels:
|
||||||
|
{{- include "whoogle.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
"helm.sh/hook": test
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: wget
|
||||||
|
image: busybox
|
||||||
|
command: ['wget']
|
||||||
|
args: ['{{ include "whoogle.fullname" . }}:{{ .Values.service.port }}']
|
||||||
|
restartPolicy: Never
|
||||||
115
charts/whoogle/values.yaml
Normal file
115
charts/whoogle/values.yaml
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# Default values for whoogle.
|
||||||
|
# This is a YAML-formatted file.
|
||||||
|
# Declare variables to be passed into your templates.
|
||||||
|
|
||||||
|
nameOverride: ""
|
||||||
|
fullnameOverride: ""
|
||||||
|
|
||||||
|
replicaCount: 1
|
||||||
|
image:
|
||||||
|
repository: benbusby/whoogle-search
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
# Overrides the image tag whose default is the chart appVersion.
|
||||||
|
tag: ""
|
||||||
|
pullSecrets: []
|
||||||
|
# - my-image-pull-secret
|
||||||
|
|
||||||
|
serviceAccount:
|
||||||
|
# Specifies whether a service account should be created
|
||||||
|
create: true
|
||||||
|
# Annotations to add to the service account
|
||||||
|
annotations: {}
|
||||||
|
# The name of the service account to use.
|
||||||
|
# If not set and create is true, a name is generated using the fullname template
|
||||||
|
name: ""
|
||||||
|
|
||||||
|
conf: {}
|
||||||
|
# WHOOGLE_URL_PREFIX: "" # The URL prefix to use for the whoogle instance (i.e. "/whoogle")
|
||||||
|
# WHOOGLE_DOTENV: "" # Load environment variables in whoogle.env
|
||||||
|
# WHOOGLE_USER: "" # The username for basic auth. WHOOGLE_PASS must also be set if used.
|
||||||
|
# WHOOGLE_PASS: "" # The password for basic auth. WHOOGLE_USER must also be set if used.
|
||||||
|
# WHOOGLE_PROXY_USER: "" # The username of the proxy server.
|
||||||
|
# WHOOGLE_PROXY_PASS: "" # The password of the proxy server.
|
||||||
|
# WHOOGLE_PROXY_TYPE: "" # The type of the proxy server. Can be "socks5", "socks4", or "http".
|
||||||
|
# WHOOGLE_PROXY_LOC: "" # The location of the proxy server (host or ip).
|
||||||
|
# EXPOSE_PORT: "" # The port where Whoogle will be exposed. (default 5000)
|
||||||
|
# HTTPS_ONLY: "" # Enforce HTTPS. (See https://github.com/benbusby/whoogle-search#https-enforcement)
|
||||||
|
# WHOOGLE_ALT_TW: "" # The twitter.com alternative to use when site alternatives are enabled in the config.
|
||||||
|
# WHOOGLE_ALT_YT: "" # The youtube.com alternative to use when site alternatives are enabled in the config.
|
||||||
|
# WHOOGLE_ALT_RD: "" # The reddit.com alternative to use when site alternatives are enabled in the config.
|
||||||
|
# WHOOGLE_ALT_TL: "" # The Google Translate alternative to use. This is used for all "translate ____" searches.
|
||||||
|
# WHOOGLE_ALT_MD: "" # The medium.com alternative to use when site alternatives are enabled in the config.
|
||||||
|
# WHOOGLE_ALT_IMG: "" # The imgur.com alternative to use when site alternatives are enabled in the config.
|
||||||
|
# WHOOGLE_ALT_WIKI: "" # The wikipedia.com alternative to use when site alternatives are enabled in the config.
|
||||||
|
# WHOOGLE_ALT_IMDB: "" # The imdb.com alternative to use. Set to "" to continue using imdb.com when site alternatives are enabled.
|
||||||
|
# WHOOGLE_ALT_QUORA: "" # The quora.com alternative to use. Set to "" to continue using quora.com when site alternatives are enabled.
|
||||||
|
# WHOOGLE_ALT_SO: "" # The stackoverflow.com alternative to use. Set to "" to continue using stackoverflow.com when site alternatives are enabled.
|
||||||
|
# WHOOGLE_AUTOCOMPLETE: "" # Controls visibility of autocomplete/search suggestions. Default on -- use '0' to disable
|
||||||
|
# WHOOGLE_MINIMAL: "" # Remove everything except basic result cards from all search queries.
|
||||||
|
|
||||||
|
# WHOOGLE_CONFIG_DISABLE: "" # Hide config from UI and disallow changes to config by client
|
||||||
|
# WHOOGLE_CONFIG_COUNTRY: "" # Filter results by hosting country
|
||||||
|
# WHOOGLE_CONFIG_LANGUAGE: "" # Set interface language
|
||||||
|
# WHOOGLE_CONFIG_SEARCH_LANGUAGE: "" # Set search result language
|
||||||
|
# WHOOGLE_CONFIG_BLOCK: "" # Block websites from search results (use comma-separated list)
|
||||||
|
# WHOOGLE_CONFIG_THEME: "" # Set theme mode (light, dark, or system)
|
||||||
|
# WHOOGLE_CONFIG_SAFE: "" # Enable safe searches
|
||||||
|
# WHOOGLE_CONFIG_ALTS: "" # Use social media site alternatives (nitter, invidious, etc)
|
||||||
|
# WHOOGLE_CONFIG_NEAR: "" # Restrict results to only those near a particular city
|
||||||
|
# WHOOGLE_CONFIG_TOR: "" # Use Tor routing (if available)
|
||||||
|
# WHOOGLE_CONFIG_NEW_TAB: "" # Always open results in new tab
|
||||||
|
# WHOOGLE_CONFIG_VIEW_IMAGE: "" # Enable View Image option
|
||||||
|
# WHOOGLE_CONFIG_GET_ONLY: "" # Search using GET requests only
|
||||||
|
# WHOOGLE_CONFIG_URL: "" # The root url of the instance (https://<your url>/)
|
||||||
|
# WHOOGLE_CONFIG_STYLE: "" # The custom CSS to use for styling (should be single line)
|
||||||
|
# WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED: "" # Encrypt preferences token, requires key
|
||||||
|
# WHOOGLE_CONFIG_PREFERENCES_KEY: "" # Key to encrypt preferences in URL (REQUIRED to show url)
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
podSecurityContext: {}
|
||||||
|
# fsGroup: 2000
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
# capabilities:
|
||||||
|
# drop:
|
||||||
|
# - ALL
|
||||||
|
# readOnlyRootFilesystem: true
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 5000
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
annotations: {}
|
||||||
|
# kubernetes.io/ingress.class: nginx
|
||||||
|
# kubernetes.io/tls-acme: "true"
|
||||||
|
hosts:
|
||||||
|
- host: whoogle.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: ImplementationSpecific
|
||||||
|
tls: []
|
||||||
|
# - secretName: chart-example-tls
|
||||||
|
# hosts:
|
||||||
|
# - whoogle.example.com
|
||||||
|
|
||||||
|
resources: {}
|
||||||
|
# requests:
|
||||||
|
# cpu: 100m
|
||||||
|
# memory: 128Mi
|
||||||
|
# limits:
|
||||||
|
# cpu: 100m
|
||||||
|
# memory: 128Mi
|
||||||
|
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
minReplicas: 1
|
||||||
|
maxReplicas: 100
|
||||||
|
targetCPUUtilizationPercentage: 80
|
||||||
|
# targetMemoryUtilizationPercentage: 80
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
81
docker-compose-traefik.yaml
Normal file
81
docker-compose-traefik.yaml
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# can't use mem_limit in a 3.x docker-compose file in non swarm mode
|
||||||
|
# see https://github.com/docker/compose/issues/4513
|
||||||
|
version: "2.4"
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: "traefik:v2.7"
|
||||||
|
container_name: "traefik"
|
||||||
|
command:
|
||||||
|
#- "--log.level=DEBUG"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entrypoints.websecure.address=:443"
|
||||||
|
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
|
||||||
|
#- "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||||
|
- "--certificatesresolvers.myresolver.acme.email=change@domain.name"
|
||||||
|
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
|
||||||
|
ports:
|
||||||
|
- "443:443"
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- "./letsencrypt:/letsencrypt"
|
||||||
|
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||||
|
|
||||||
|
whoogle-search:
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.whoami.rule=Host(`change.host.name`)"
|
||||||
|
- "traefik.http.routers.whoami.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.whoami.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.services.whoogle-search.loadbalancer.server.port=5000"
|
||||||
|
image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search}
|
||||||
|
container_name: whoogle-search
|
||||||
|
restart: unless-stopped
|
||||||
|
pids_limit: 50
|
||||||
|
mem_limit: 256mb
|
||||||
|
memswap_limit: 256mb
|
||||||
|
# user debian-tor from tor package
|
||||||
|
user: whoogle
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
tmpfs:
|
||||||
|
- /config/:size=10M,uid=927,gid=927,mode=1700
|
||||||
|
- /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700
|
||||||
|
- /run/tor/:size=1M,uid=927,gid=927,mode=1700
|
||||||
|
environment: # Uncomment to configure environment variables
|
||||||
|
# Basic auth configuration, uncomment to enable
|
||||||
|
#- WHOOGLE_USER=<auth username>
|
||||||
|
#- WHOOGLE_PASS=<auth password>
|
||||||
|
# Proxy configuration, uncomment to enable
|
||||||
|
#- WHOOGLE_PROXY_USER=<proxy username>
|
||||||
|
#- WHOOGLE_PROXY_PASS=<proxy password>
|
||||||
|
#- WHOOGLE_PROXY_TYPE=<proxy type (http|https|socks4|socks5)
|
||||||
|
#- WHOOGLE_PROXY_LOC=<proxy host/ip>
|
||||||
|
# Site alternative configurations, uncomment to enable
|
||||||
|
# Note: If not set, the feature will still be available
|
||||||
|
# with default values.
|
||||||
|
#- WHOOGLE_ALT_TW=farside.link/nitter
|
||||||
|
#- WHOOGLE_ALT_YT=farside.link/invidious
|
||||||
|
#- WHOOGLE_ALT_IG=farside.link/bibliogram/u
|
||||||
|
#- WHOOGLE_ALT_RD=farside.link/libreddit
|
||||||
|
#- WHOOGLE_ALT_MD=farside.link/scribe
|
||||||
|
#- WHOOGLE_ALT_TL=farside.link/lingva
|
||||||
|
#- WHOOGLE_ALT_IMG=farside.link/rimgo
|
||||||
|
#- WHOOGLE_ALT_WIKI=farside.link/wikiless
|
||||||
|
#- WHOOGLE_ALT_IMDB=farside.link/libremdb
|
||||||
|
#- WHOOGLE_ALT_QUORA=farside.link/quetre
|
||||||
|
#- WHOOGLE_ALT_SO=farside.link/anonymousoverflow
|
||||||
|
# - WHOOGLE_CONFIG_DISABLE=1
|
||||||
|
# - WHOOGLE_CONFIG_SEARCH_LANGUAGE=lang_en
|
||||||
|
# - WHOOGLE_CONFIG_GET_ONLY=1
|
||||||
|
# - WHOOGLE_CONFIG_COUNTRY=FR
|
||||||
|
# - WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED=1
|
||||||
|
# - WHOOGLE_CONFIG_PREFERENCES_KEY="NEEDS_TO_BE_MODIFIED"
|
||||||
|
#env_file: # Alternatively, load variables from whoogle.env
|
||||||
|
#- whoogle.env
|
||||||
|
ports:
|
||||||
|
- 8000:5000
|
||||||
@ -1,26 +1,25 @@
|
|||||||
# cant use mem_limit in a 3.x docker-compose file in non swarm mode
|
# can't use mem_limit in a 3.x docker-compose file in non swarm mode
|
||||||
# see https://github.com/docker/compose/issues/4513
|
# see https://github.com/docker/compose/issues/4513
|
||||||
version: "2.4"
|
version: "2.4"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
whoogle-search:
|
whoogle-search:
|
||||||
image: benbusby/whoogle-search
|
image: ${WHOOGLE_IMAGE:-benbusby/whoogle-search}
|
||||||
container_name: whoogle-search
|
container_name: whoogle-search
|
||||||
restart: on-failure:5
|
restart: unless-stopped
|
||||||
pids_limit: 50
|
pids_limit: 50
|
||||||
mem_limit: 256mb
|
mem_limit: 256mb
|
||||||
memswap_limit: 256mb
|
memswap_limit: 256mb
|
||||||
# user debian-tor from tor package
|
# user debian-tor from tor package
|
||||||
user: '102'
|
user: whoogle
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges
|
- no-new-privileges
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /config/:size=10M,uid=102,gid=102,mode=1700
|
- /config/:size=10M,uid=927,gid=927,mode=1700
|
||||||
- /var/lib/tor/:size=10M,uid=102,gid=102,mode=1700
|
- /var/lib/tor/:size=15M,uid=927,gid=927,mode=1700
|
||||||
- /run/tor/:size=1M,uid=102,gid=102,mode=1700
|
- /run/tor/:size=1M,uid=927,gid=927,mode=1700
|
||||||
#environment: # Uncomment to configure environment variables
|
#environment: # Uncomment to configure environment variables
|
||||||
# Basic auth configuration, uncomment to enable
|
# Basic auth configuration, uncomment to enable
|
||||||
#- WHOOGLE_USER=<auth username>
|
#- WHOOGLE_USER=<auth username>
|
||||||
@ -28,17 +27,23 @@ services:
|
|||||||
# Proxy configuration, uncomment to enable
|
# Proxy configuration, uncomment to enable
|
||||||
#- WHOOGLE_PROXY_USER=<proxy username>
|
#- WHOOGLE_PROXY_USER=<proxy username>
|
||||||
#- WHOOGLE_PROXY_PASS=<proxy password>
|
#- WHOOGLE_PROXY_PASS=<proxy password>
|
||||||
#- WHOOGLE_PROXY_TYPE=<proxy type (http|socks4|socks5)
|
#- WHOOGLE_PROXY_TYPE=<proxy type (http|https|socks4|socks5)
|
||||||
#- WHOOGLE_PROXY_LOC=<proxy host/ip>
|
#- WHOOGLE_PROXY_LOC=<proxy host/ip>
|
||||||
# Site alternative configurations, uncomment to enable
|
# Site alternative configurations, uncomment to enable
|
||||||
# Note: If not set, the feature will still be available
|
# Note: If not set, the feature will still be available
|
||||||
# with default values.
|
# with default values.
|
||||||
#- WHOOGLE_ALT_TW=nitter.net
|
#- WHOOGLE_ALT_TW=farside.link/nitter
|
||||||
#- WHOOGLE_ALT_YT=invidious.snopyta.org
|
#- WHOOGLE_ALT_YT=farside.link/invidious
|
||||||
#- WHOOGLE_ALT_IG=bibliogram.art/u
|
#- WHOOGLE_ALT_IG=farside.link/bibliogram/u
|
||||||
#- WHOOGLE_ALT_RD=libredd.it
|
#- WHOOGLE_ALT_RD=farside.link/libreddit
|
||||||
# Load environment variables from whoogle.env
|
#- WHOOGLE_ALT_MD=farside.link/scribe
|
||||||
#- WHOOGLE_DOTENV=1
|
#- WHOOGLE_ALT_TL=farside.link/lingva
|
||||||
|
#- WHOOGLE_ALT_IMG=farside.link/rimgo
|
||||||
|
#- WHOOGLE_ALT_WIKI=farside.link/wikiless
|
||||||
|
#- WHOOGLE_ALT_IMDB=farside.link/libremdb
|
||||||
|
#- WHOOGLE_ALT_QUORA=farside.link/quetre
|
||||||
|
#- WHOOGLE_ALT_SO=farside.link/anonymousoverflow
|
||||||
|
#env_file: # Alternatively, load variables from whoogle.env
|
||||||
|
#- whoogle.env
|
||||||
ports:
|
ports:
|
||||||
- 5000:5000
|
- 5000:5000
|
||||||
restart: unless-stopped
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 215 KiB |
BIN
docs/screenshot_desktop.png
Normal file
BIN
docs/screenshot_desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 139 KiB |
BIN
docs/screenshot_mobile.png
Normal file
BIN
docs/screenshot_mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
0
letsencrypt/acme.json
Normal file
0
letsencrypt/acme.json
Normal file
6
misc/instances.txt
Normal file
6
misc/instances.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
https://search.garudalinux.org
|
||||||
|
https://search.sethforprivacy.com
|
||||||
|
https://whoogle.privacydev.net
|
||||||
|
https://wg.vern.cc
|
||||||
|
https://whoogle.lunar.icu
|
||||||
|
https://whoogle.4040940.xyz
|
||||||
5
misc/replit.py
Normal file
5
misc/replit.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import subprocess
|
||||||
|
|
||||||
|
# A plague upon Replit and all who have built it
|
||||||
|
replit_cmd = "killall -q python3 > /dev/null 2>&1; pip install -r requirements.txt && ./run"
|
||||||
|
subprocess.run(replit_cmd, shell=True)
|
||||||
1
misc/tor/control.conf
Normal file
1
misc/tor/control.conf
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Place password here. Keep this safe.
|
||||||
@ -1,7 +1,33 @@
|
|||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
|
|
||||||
|
FF_STRING="FascistFirewall 1"
|
||||||
|
|
||||||
|
if [ "$WHOOGLE_TOR_SERVICE" == "0" ]; then
|
||||||
|
echo "Skipping Tor startup..."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$WHOOGLE_TOR_FF" == "1" ]; then
|
||||||
|
if (grep -q "$FF_STRING" /etc/tor/torrc); then
|
||||||
|
echo "FascistFirewall feature already enabled."
|
||||||
|
else
|
||||||
|
echo "$FF_STRING" >> /etc/tor/torrc
|
||||||
|
|
||||||
|
if [ "$?" -eq 0 ]; then
|
||||||
|
echo "FascistFirewall added to /etc/tor/torrc"
|
||||||
|
else
|
||||||
|
echo "ERROR: Unable to modify /etc/tor/torrc with $FF_STRING."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$(whoami)" != "root" ]; then
|
if [ "$(whoami)" != "root" ]; then
|
||||||
tor -f /etc/tor/torrc
|
tor -f /etc/tor/torrc
|
||||||
|
else
|
||||||
|
if (grep alpine /etc/os-release >/dev/null); then
|
||||||
|
rc-service tor start
|
||||||
else
|
else
|
||||||
service tor start
|
service tor start
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
@ -6,3 +6,7 @@ CookieAuthFileGroupReadable 1
|
|||||||
ExtORPortCookieAuthFileGroupReadable 1
|
ExtORPortCookieAuthFileGroupReadable 1
|
||||||
CacheDirectoryGroupReadable 1
|
CacheDirectoryGroupReadable 1
|
||||||
CookieAuthFile /var/lib/tor/control_auth_cookie
|
CookieAuthFile /var/lib/tor/control_auth_cookie
|
||||||
|
Log debug-notice file /dev/null
|
||||||
|
# UseBridges 1
|
||||||
|
# ClientTransportPlugin obfs4 exec /usr/bin/obfs4proxy
|
||||||
|
# Bridge obfs4 ip and so on
|
||||||
|
|||||||
67
misc/update-translations.py
Normal file
67
misc/update-translations.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
lingva = 'https://lingva.ml/api/v1/en'
|
||||||
|
|
||||||
|
|
||||||
|
def format_lang(lang: str) -> str:
|
||||||
|
# Chinese (traditional and simplified) require
|
||||||
|
# a different format for lingva translations
|
||||||
|
if 'zh-' in lang:
|
||||||
|
if lang == 'zh-TW':
|
||||||
|
return 'zh_HANT'
|
||||||
|
return 'zh'
|
||||||
|
|
||||||
|
# Strip lang prefix to leave only the actual
|
||||||
|
# language code (i.e. 'en', 'fr', etc)
|
||||||
|
return lang.replace('lang_', '')
|
||||||
|
|
||||||
|
|
||||||
|
def translate(v: str, lang: str) -> str:
|
||||||
|
# Strip lang prefix to leave only the actual
|
||||||
|
#language code (i.e. "es", "fr", etc)
|
||||||
|
lang = format_lang(lang)
|
||||||
|
|
||||||
|
lingva_req = f'{lingva}/{lang}/{v}'
|
||||||
|
|
||||||
|
response = httpx.get(lingva_req).json()
|
||||||
|
|
||||||
|
if 'translation' in response:
|
||||||
|
return response['translation']
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
file_path = pathlib.Path(__file__).parent.resolve()
|
||||||
|
tl_path = 'app/static/settings/translations.json'
|
||||||
|
|
||||||
|
with open(f'{file_path}/../{tl_path}', 'r+', encoding='utf-8') as tl_file:
|
||||||
|
tl_data = json.load(tl_file)
|
||||||
|
|
||||||
|
# If there are any english translations that don't
|
||||||
|
# exist for other languages, extract them and translate
|
||||||
|
# them now
|
||||||
|
en_tl = tl_data['lang_en']
|
||||||
|
for k, v in en_tl.items():
|
||||||
|
for lang in tl_data:
|
||||||
|
if lang == 'lang_en' or k in tl_data[lang]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
translation = ''
|
||||||
|
if len(k) == 0:
|
||||||
|
# Special case for placeholder text that gets used
|
||||||
|
# for translations without any key present
|
||||||
|
translation = v
|
||||||
|
else:
|
||||||
|
# Translate the string using lingva
|
||||||
|
translation = translate(v, lang)
|
||||||
|
|
||||||
|
if len(translation) == 0:
|
||||||
|
print(f'! Unable to translate {lang}[{k}]')
|
||||||
|
continue
|
||||||
|
print(f'{lang}[{k}] = {translation}')
|
||||||
|
tl_data[lang][k] = translation
|
||||||
|
|
||||||
|
# Write out updated translations json
|
||||||
|
print(json.dumps(tl_data, indent=4, ensure_ascii=False))
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
lint.select = [
|
||||||
|
"E", "F", "W", # pycodestyle/pyflakes
|
||||||
|
"I", # isort
|
||||||
|
]
|
||||||
|
lint.ignore = []
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ['py311']
|
||||||
@ -1,34 +1,36 @@
|
|||||||
attrs==19.3.0
|
attrs==25.3.0
|
||||||
beautifulsoup4==4.8.2
|
beautifulsoup4==4.13.5
|
||||||
bs4==0.0.1
|
brotli==1.1.0
|
||||||
cachelib==0.1
|
certifi==2025.8.3
|
||||||
certifi==2020.4.5.1
|
cffi==2.0.0
|
||||||
cffi==1.13.2
|
click==8.3.0
|
||||||
chardet==3.0.4
|
cryptography==3.3.2; platform_machine == 'armv7l'
|
||||||
Click==7.0
|
cryptography==46.0.1; platform_machine != 'armv7l'
|
||||||
cryptography==3.3.2
|
cssutils==2.11.1
|
||||||
Flask==1.1.1
|
defusedxml==0.7.1
|
||||||
Flask-Session==0.3.2
|
Flask==2.3.2
|
||||||
idna==2.9
|
idna==3.10
|
||||||
itsdangerous==1.1.0
|
itsdangerous==2.1.2
|
||||||
Jinja2==2.11.3
|
Jinja2==3.1.6
|
||||||
MarkupSafe==1.1.1
|
MarkupSafe==3.0.2
|
||||||
more-itertools==8.3.0
|
more-itertools==10.8.0
|
||||||
packaging==20.4
|
packaging==25.0
|
||||||
pluggy==0.13.1
|
pluggy==1.6.0
|
||||||
py==1.8.1
|
pycodestyle==2.14.0
|
||||||
pycodestyle==2.6.0
|
pycparser==2.22
|
||||||
pycparser==2.19
|
pyOpenSSL==19.1.0; platform_machine == 'armv7l'
|
||||||
pyOpenSSL==19.1.0
|
pyOpenSSL==25.3.0; platform_machine != 'armv7l'
|
||||||
pyparsing==2.4.7
|
pyparsing==3.2.5
|
||||||
PySocks==1.7.1
|
pytest==7.2.1
|
||||||
pytest==5.4.1
|
python-dateutil==2.9.0.post0
|
||||||
python-dateutil==2.8.1
|
httpx[http2,socks]==0.28.1
|
||||||
requests==2.23.0
|
cachetools==6.2.0
|
||||||
soupsieve==1.9.5
|
soupsieve==2.8
|
||||||
stem==1.8.0
|
stem==1.8.2
|
||||||
urllib3==1.25.9
|
httpcore>=1.0.9
|
||||||
waitress==1.4.3
|
h11>=0.16.0
|
||||||
wcwidth==0.1.9
|
validators==0.35.0
|
||||||
Werkzeug==0.16.0
|
waitress==3.0.2
|
||||||
python-dotenv==0.16.0
|
wcwidth==0.2.14
|
||||||
|
Werkzeug==3.0.6
|
||||||
|
python-dotenv==1.1.1
|
||||||
|
|||||||
19
run
19
run
@ -1,26 +1,37 @@
|
|||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./run # Runs the full web app
|
# ./run # Runs the full web app
|
||||||
# ./run test # Runs the testing suite
|
# ./run test # Runs the testing suite
|
||||||
|
|
||||||
set -euo pipefail
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
SCRIPT_DIR="$(CDPATH= command cd -- "$(dirname -- "$0")" && pwd -P)"
|
||||||
|
|
||||||
# Set directory to serve static content from
|
# Set directory to serve static content from
|
||||||
SUBDIR="${1:-app}"
|
SUBDIR="${1:-app}"
|
||||||
export APP_ROOT="$SCRIPT_DIR/$SUBDIR"
|
export APP_ROOT="$SCRIPT_DIR/$SUBDIR"
|
||||||
export STATIC_FOLDER="$APP_ROOT/static"
|
export STATIC_FOLDER="$APP_ROOT/static"
|
||||||
|
|
||||||
|
# Clear out build directory
|
||||||
|
rm -f "$SCRIPT_DIR"/app/static/build/*.js
|
||||||
|
rm -f "$SCRIPT_DIR"/app/static/build/*.css
|
||||||
|
|
||||||
# Check for regular vs test run
|
# Check for regular vs test run
|
||||||
if [[ "$SUBDIR" == "test" ]]; then
|
if [ "$SUBDIR" = "test" ]; then
|
||||||
# Set up static files for testing
|
# Set up static files for testing
|
||||||
rm -rf "$STATIC_FOLDER"
|
rm -rf "$STATIC_FOLDER"
|
||||||
ln -s "$SCRIPT_DIR/app/static" "$STATIC_FOLDER"
|
ln -s "$SCRIPT_DIR/app/static" "$STATIC_FOLDER"
|
||||||
pytest -sv
|
pytest -sv
|
||||||
else
|
else
|
||||||
mkdir -p "$STATIC_FOLDER"
|
mkdir -p "$STATIC_FOLDER"
|
||||||
|
|
||||||
|
if [ ! -z "$UNIX_SOCKET" ]; then
|
||||||
|
python3 -um app \
|
||||||
|
--unix-socket "$UNIX_SOCKET"
|
||||||
|
else
|
||||||
|
echo "Running on http://${ADDRESS:-0.0.0.0}:${PORT:-"${EXPOSE_PORT:-5000}"}"
|
||||||
python3 -um app \
|
python3 -um app \
|
||||||
--host "${ADDRESS:-0.0.0.0}" \
|
--host "${ADDRESS:-0.0.0.0}" \
|
||||||
--port "${PORT:-"${EXPOSE_PORT:-5000}"}"
|
--port "${PORT:-"${EXPOSE_PORT:-5000}"}"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
45
setup.cfg
Normal file
45
setup.cfg
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
[metadata]
|
||||||
|
name = whoogle-search
|
||||||
|
version = attr: app.version.__version__
|
||||||
|
url = https://github.com/benbusby/whoogle-search
|
||||||
|
description = Self-hosted, ad-free, privacy-respecting metasearch engine
|
||||||
|
long_description = file: README.md
|
||||||
|
long_description_content_type = text/markdown
|
||||||
|
keywords = search, metasearch, flask, adblock, degoogle, privacy
|
||||||
|
author = Ben Busby
|
||||||
|
author_email = contact@benbusby.com
|
||||||
|
license = MIT
|
||||||
|
classifiers =
|
||||||
|
Programming Language :: Python :: 3
|
||||||
|
License :: OSI Approved :: MIT License
|
||||||
|
Operating System :: OS Independent
|
||||||
|
|
||||||
|
[options]
|
||||||
|
packages = find:
|
||||||
|
include_package_data = True
|
||||||
|
install_requires=
|
||||||
|
beautifulsoup4
|
||||||
|
brotli
|
||||||
|
cssutils
|
||||||
|
cryptography
|
||||||
|
defusedxml
|
||||||
|
Flask
|
||||||
|
python-dotenv
|
||||||
|
httpx[http2,socks]
|
||||||
|
stem
|
||||||
|
validators
|
||||||
|
waitress
|
||||||
|
|
||||||
|
[options.extras_require]
|
||||||
|
test =
|
||||||
|
pytest
|
||||||
|
python-dateutil
|
||||||
|
dev = pycodestyle
|
||||||
|
|
||||||
|
[options.packages.find]
|
||||||
|
exclude =
|
||||||
|
test*
|
||||||
|
|
||||||
|
[options.entry_points]
|
||||||
|
console_scripts =
|
||||||
|
whoogle-search = app.routes:run_app
|
||||||
29
setup.py
29
setup.py
@ -1,29 +0,0 @@
|
|||||||
import setuptools
|
|
||||||
|
|
||||||
long_description = open('README.md', 'r').read()
|
|
||||||
|
|
||||||
requirements = list(open('requirements.txt', 'r'))
|
|
||||||
|
|
||||||
setuptools.setup(
|
|
||||||
author='Ben Busby',
|
|
||||||
author_email='benbusby@protonmail.com',
|
|
||||||
name='whoogle-search',
|
|
||||||
version='0.4.1',
|
|
||||||
include_package_data=True,
|
|
||||||
install_requires=requirements,
|
|
||||||
description='Self-hosted, ad-free, privacy-respecting metasearch engine',
|
|
||||||
long_description=long_description,
|
|
||||||
long_description_content_type='text/markdown',
|
|
||||||
url='https://github.com/benbusby/whoogle-search',
|
|
||||||
entry_points={
|
|
||||||
'console_scripts': [
|
|
||||||
'whoogle-search=app.routes:run_app',
|
|
||||||
]
|
|
||||||
},
|
|
||||||
packages=setuptools.find_packages(),
|
|
||||||
classifiers=[
|
|
||||||
'Programming Language :: Python :: 3',
|
|
||||||
'License :: OSI Approved :: MIT License',
|
|
||||||
'Operating System :: OS Independent',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@ -1,15 +1,15 @@
|
|||||||
from app import app
|
from app import app
|
||||||
from app.utils.session import generate_user_key
|
from app.utils.session import generate_key
|
||||||
import pytest
|
import pytest
|
||||||
import random
|
import random
|
||||||
|
|
||||||
demo_config = {
|
demo_config = {
|
||||||
'near': random.choice(['Seattle', 'New York', 'San Francisco']),
|
'near': random.choice(['Seattle', 'New York', 'San Francisco']),
|
||||||
'dark_mode': str(random.getrandbits(1)),
|
'dark': str(random.getrandbits(1)),
|
||||||
'nojs': str(random.getrandbits(1)),
|
'nojs': str(random.getrandbits(1)),
|
||||||
'lang_interface': random.choice(app.config['LANGUAGES'])['value'],
|
'lang_interface': random.choice(app.config['LANGUAGES'])['value'],
|
||||||
'lang_search': random.choice(app.config['LANGUAGES'])['value'],
|
'lang_search': random.choice(app.config['LANGUAGES'])['value'],
|
||||||
'ctry': random.choice(app.config['COUNTRIES'])['value']
|
'country': random.choice(app.config['COUNTRIES'])['value']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -18,6 +18,7 @@ def client():
|
|||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
with client.session_transaction() as session:
|
with client.session_transaction() as session:
|
||||||
session['uuid'] = 'test'
|
session['uuid'] = 'test'
|
||||||
session['key'] = generate_user_key()
|
session['key'] = app.enc_key
|
||||||
session['config'] = {}
|
session['config'] = {}
|
||||||
|
session['auth'] = False
|
||||||
yield client
|
yield client
|
||||||
|
|||||||
114
test/test_alts.py
Normal file
114
test/test_alts.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import copy
|
||||||
|
import os
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from app import app
|
||||||
|
from app.filter import Filter
|
||||||
|
from app.models.config import Config
|
||||||
|
from app.utils.session import generate_key
|
||||||
|
from app.utils import results as results_mod
|
||||||
|
|
||||||
|
|
||||||
|
def build_soup(html: str):
|
||||||
|
return BeautifulSoup(html, 'html.parser')
|
||||||
|
|
||||||
|
|
||||||
|
def make_filter(soup: BeautifulSoup):
|
||||||
|
secret_key = generate_key()
|
||||||
|
with app.app_context():
|
||||||
|
cfg = Config(**{'alts': True})
|
||||||
|
f = Filter(user_key=secret_key, config=cfg)
|
||||||
|
f.soup = soup
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_duplicate_alt_prefix_reddit(monkeypatch):
|
||||||
|
original_site_alts = copy.deepcopy(results_mod.SITE_ALTS)
|
||||||
|
try:
|
||||||
|
# Simulate user setting alt to old.reddit.com
|
||||||
|
monkeypatch.setitem(results_mod.SITE_ALTS, 'reddit.com', 'old.reddit.com')
|
||||||
|
|
||||||
|
html = '''
|
||||||
|
<div id="main">
|
||||||
|
<a href="https://www.reddit.com/r/whoogle">www.reddit.com</a>
|
||||||
|
<div>www.reddit.com</div>
|
||||||
|
<div>old.reddit.com</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
soup = build_soup(html)
|
||||||
|
f = make_filter(soup)
|
||||||
|
f.site_alt_swap()
|
||||||
|
|
||||||
|
# Href replaced once
|
||||||
|
a = soup.find('a')
|
||||||
|
assert a['href'].startswith('https://old.reddit.com')
|
||||||
|
|
||||||
|
# Bare domain replaced, but already-alt text stays unchanged (no old.old...)
|
||||||
|
divs = [d.get_text() for d in soup.find_all('div') if d.get_text().strip()]
|
||||||
|
assert 'old.reddit.com' in divs
|
||||||
|
assert 'old.old.reddit.com' not in ''.join(divs)
|
||||||
|
finally:
|
||||||
|
results_mod.SITE_ALTS.clear()
|
||||||
|
results_mod.SITE_ALTS.update(original_site_alts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wikipedia_simple_no_lang_param(monkeypatch):
|
||||||
|
original_site_alts = copy.deepcopy(results_mod.SITE_ALTS)
|
||||||
|
try:
|
||||||
|
monkeypatch.setitem(results_mod.SITE_ALTS, 'wikipedia.org', 'https://wikiless.example')
|
||||||
|
|
||||||
|
html = '''
|
||||||
|
<div id="main">
|
||||||
|
<a href="https://simple.wikipedia.org/wiki/Whoogle">https://simple.wikipedia.org/wiki/Whoogle</a>
|
||||||
|
<div>simple.wikipedia.org</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
soup = build_soup(html)
|
||||||
|
f = make_filter(soup)
|
||||||
|
f.site_alt_swap()
|
||||||
|
|
||||||
|
a = soup.find('a')
|
||||||
|
# Should be rewritten to the alt host, without ?lang
|
||||||
|
assert a['href'].startswith('https://wikiless.example')
|
||||||
|
assert '?lang=' not in a['href']
|
||||||
|
|
||||||
|
# Description host replaced once
|
||||||
|
text = soup.find('div').get_text()
|
||||||
|
assert 'wikiless.example' in text
|
||||||
|
assert 'simple.wikipedia.org' not in text
|
||||||
|
finally:
|
||||||
|
results_mod.SITE_ALTS.clear()
|
||||||
|
results_mod.SITE_ALTS.update(original_site_alts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_pass_description_replacement(monkeypatch):
|
||||||
|
original_site_alts = copy.deepcopy(results_mod.SITE_ALTS)
|
||||||
|
try:
|
||||||
|
monkeypatch.setitem(results_mod.SITE_ALTS, 'twitter.com', 'https://nitter.example')
|
||||||
|
|
||||||
|
html = '''
|
||||||
|
<div id="main">
|
||||||
|
<a href="https://twitter.com/whoogle">https://twitter.com/whoogle</a>
|
||||||
|
<div>https://www.twitter.com</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
soup = build_soup(html)
|
||||||
|
f = make_filter(soup)
|
||||||
|
f.site_alt_swap()
|
||||||
|
|
||||||
|
a = soup.find('a')
|
||||||
|
assert a['href'].startswith('https://nitter.example')
|
||||||
|
|
||||||
|
# Ensure description got host swapped once, no double scheme or duplication
|
||||||
|
main_div = soup.find('div', id='main')
|
||||||
|
# The description div is the first inner div under #main in this fixture
|
||||||
|
text = main_div.find_all('div')[0].get_text().strip()
|
||||||
|
assert text.startswith('https://nitter.example')
|
||||||
|
assert 'https://https://' not in text
|
||||||
|
assert 'nitter.examplenitter.example' not in text
|
||||||
|
finally:
|
||||||
|
results_mod.SITE_ALTS.clear()
|
||||||
|
results_mod.SITE_ALTS.update(original_site_alts)
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user