Compare commits
744 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2949510d68 | ||
|
|
255f1a2c12 | ||
|
|
4852e5b64f | ||
|
|
9c5b3150aa | ||
|
|
6c7ca7c082 | ||
|
|
ff3a44b91e | ||
|
|
b3c09ade5c | ||
|
|
a2ec4e9f22 | ||
|
|
db6d031e86 | ||
|
|
c96f5ada2e | ||
|
|
ccdeb60fc0 | ||
|
|
20ed493671 | ||
|
|
20753224f3 | ||
|
|
71a2c10e58 | ||
|
|
9ff2d2f90a | ||
|
|
0f000a676b | ||
|
|
7b56aa053b | ||
|
|
f9f54115e3 | ||
|
|
c008090d83 | ||
|
|
6bcde23501 | ||
|
|
3698d9065e | ||
|
|
cffef7aa15 | ||
|
|
178d67a73f | ||
|
|
65326e37b4 | ||
|
|
490fc6c4f9 | ||
|
|
9b3a6ce550 | ||
|
|
5f17b82735 | ||
|
|
00d8aec2fb | ||
|
|
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 | ||
|
|
a321d55f13 | ||
|
|
0baba58896 | ||
|
|
8a6e0709b8 | ||
|
|
b48090c23a | ||
|
|
b6b175a2ee | ||
|
|
6cb1b30240 | ||
|
|
30be540b97 | ||
|
|
0b9600b564 | ||
|
|
0fed2fc295 | ||
|
|
50c888f9a7 | ||
|
|
df0b7afa50 | ||
|
|
ed4432f3f8 | ||
|
|
3312072cc1 | ||
|
|
7b9ee37beb | ||
|
|
c944f3cb06 | ||
|
|
8a10efaa01 | ||
|
|
b2416394ff | ||
|
|
d301ba81f3 | ||
|
|
8f6d9cf3f5 | ||
|
|
8ad8e66d37 | ||
|
|
892b646a4e | ||
|
|
e7c63afc1a | ||
|
|
ad0b48b034 | ||
|
|
c89e5b3f4e | ||
|
|
083c3758a1 | ||
|
|
62a9b9e949 | ||
|
|
337d0ebe37 | ||
|
|
2114cb87c0 | ||
|
|
6f46facf9e | ||
|
|
5884001f05 | ||
|
|
b87619a133 | ||
|
|
fea10828cc | ||
|
|
0e2757fc07 | ||
|
|
e5d1f6a292 | ||
|
|
f8dfc78539 | ||
|
|
1d612c68a4 | ||
|
|
dcb80ac250 | ||
|
|
b7b6fb7c04 | ||
|
|
d146016860 | ||
|
|
36b350e1cd | ||
|
|
48c8e9d14b | ||
|
|
ecb7885a56 | ||
|
|
64567a63ea | ||
|
|
03bd4b6871 | ||
|
|
7f3a284e04 | ||
|
|
5538ac862e | ||
|
|
3ed0cf02bf | ||
|
|
4f4e7ef035 | ||
|
|
6600d8580c | ||
|
|
b57c86a1d0 | ||
|
|
fdd4ee590f | ||
|
|
0a6575d219 | ||
|
|
329c38efb0 | ||
|
|
5c69283e80 | ||
|
|
406e236666 | ||
|
|
7e39b4e7a0 | ||
|
|
fad937fe08 | ||
|
|
934749e0b8 | ||
|
|
440c4e9c50 | ||
|
|
2bbc649903 | ||
|
|
6e7ec9918a | ||
|
|
d4a4a03de1 | ||
|
|
375f4ee9fd | ||
|
|
b55aad3fdf | ||
|
|
b695179c79 | ||
|
|
3978241d28 | ||
|
|
5b5c2588ed | ||
|
|
dad80ff8fb | ||
|
|
e6db3112f7 | ||
|
|
51e69b579b | ||
|
|
6c429e6dd1 | ||
|
|
44a5da1895 | ||
|
|
54109874fb | ||
|
|
2d0823b012 | ||
|
|
0afd59056f | ||
|
|
0d0f32d108 | ||
|
|
a519de90af | ||
|
|
72cbc342af | ||
|
|
a97e837b09 | ||
|
|
4447cb682a | ||
|
|
f88d1fbb66 | ||
|
|
1148a7fb8d | ||
|
|
933ce7e068 | ||
|
|
7a61220aa5 | ||
|
|
0ef098069e | ||
|
|
83433432ec | ||
|
|
f3bb1e22b4 | ||
|
|
58a7868d25 | ||
|
|
57ca6e99ba | ||
|
|
ae05e8ff8b | ||
|
|
2126742b76 | ||
|
|
b01b6d8c69 | ||
|
|
558e3e1514 | ||
|
|
dfb1e81fa1 | ||
|
|
1f07e4e235 | ||
|
|
9a03b4111d | ||
|
|
9afe5f81bd | ||
|
|
a1894975af | ||
|
|
6ba5e8f165 | ||
|
|
e471b012a0 | ||
|
|
0c0a01b83f | ||
|
|
b2ecd8dc78 | ||
|
|
f4eca3711b | ||
|
|
975ece8cd0 | ||
|
|
3d7456f37b | ||
|
|
4577c11d4c | ||
|
|
6ef7ab663a | ||
|
|
348301f201 | ||
|
|
5575bcd0af | ||
|
|
bf4bf1ff2c | ||
|
|
dd9d87d25b | ||
|
|
4e970a4796 | ||
|
|
5f8309d2f0 | ||
|
|
f7380ae15d | ||
|
|
f86a44b637 | ||
|
|
4324fcd8f8 | ||
|
|
6ec65f8754 | ||
|
|
32e837a5e0 | ||
|
|
64af72abb5 | ||
|
|
b6fb4723f9 |
@ -1,2 +1,3 @@
|
||||
.git/
|
||||
venv/
|
||||
test/
|
||||
|
||||
9
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# These are supported funding model platforms
|
||||
github: benbusby
|
||||
ko_fi: benbusby
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -7,6 +7,12 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
DO NOT REQUEST UI/THEME/GUI/APPEARANCE IMPROVEMENTS HERE
|
||||
THESE SHOULD GO IN ISSUE #60
|
||||
REQUESTING A NEW FEATURE SHOULD BE STRICTLY RELATED TO NEW FUNCTIONALITY
|
||||
-->
|
||||
|
||||
**Describe the feature you'd like to see added**
|
||||
A short description of the feature, and what it would accomplish.
|
||||
|
||||
|
||||
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: #______;
|
||||
}
|
||||
```
|
||||
92
.github/workflows/buildx.yml
vendored
Normal file
@ -0,0 +1,92 @@
|
||||
name: buildx
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["docker_main"]
|
||||
branches: [main, updates]
|
||||
types:
|
||||
- completed
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
on-success:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for tests to succeed
|
||||
if: ${{ github.event.workflow_run.conclusion != 'success' && startsWith(github.ref, 'refs/tags') != true }}
|
||||
run: exit 1
|
||||
- name: Debug workflow context
|
||||
run: |
|
||||
echo "Event name: ${{ github.event_name }}"
|
||||
echo "Ref: ${{ github.ref }}"
|
||||
echo "Actor: ${{ github.actor }}"
|
||||
echo "Branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
echo "Conclusion: ${{ github.event.workflow_run.conclusion }}"
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disabled: only build on release events now
|
||||
# - name: build and push the image
|
||||
# if: startsWith(github.ref, 'refs/heads/main') && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||
# run: |
|
||||
# docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
# docker buildx ls
|
||||
# docker buildx build --push \
|
||||
# --tag benbusby/whoogle-search:latest \
|
||||
# --platform linux/amd64,linux/arm64 .
|
||||
# docker buildx build --push \
|
||||
# --tag ghcr.io/benbusby/whoogle-search:latest \
|
||||
# --platform linux/amd64,linux/arm64 .
|
||||
- name: build and push updates branch (update-testing tag)
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'updates' && github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.actor.login == 'benbusby' || github.event.workflow_run.actor.login == 'Don-Swanson')
|
||||
run: |
|
||||
docker buildx build --push \
|
||||
--tag benbusby/whoogle-search:update-testing \
|
||||
--tag ghcr.io/benbusby/whoogle-search:update-testing \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
- name: build and push release (version + latest)
|
||||
if: github.event_name == 'release' && github.event.release.prerelease == false && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
docker buildx build --push \
|
||||
--tag benbusby/whoogle-search:${VERSION} \
|
||||
--tag benbusby/whoogle-search:latest \
|
||||
--tag ghcr.io/benbusby/whoogle-search:${VERSION} \
|
||||
--tag ghcr.io/benbusby/whoogle-search:latest \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
- name: build and push pre-release (version only)
|
||||
if: github.event_name == 'release' && github.event.release.prerelease == true && (github.actor == 'benbusby' || github.actor == 'Don-Swanson')
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
docker buildx build --push \
|
||||
--tag benbusby/whoogle-search:${VERSION} \
|
||||
--tag ghcr.io/benbusby/whoogle-search:${VERSION} \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
- name: build and push tag
|
||||
if: startsWith(github.ref, 'refs/tags')
|
||||
run: |
|
||||
docker buildx build --push \
|
||||
--tag benbusby/whoogle-search:${GITHUB_REF#refs/*/v} \
|
||||
--tag ghcr.io/benbusby/whoogle-search:${GITHUB_REF#refs/*/v} \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
29
.github/workflows/docker_main.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
name: docker_main
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["tests"]
|
||||
branches: [main, updates]
|
||||
types:
|
||||
- completed
|
||||
|
||||
# TODO: Needs refactoring to use reusable workflows and share w/ docker_tests
|
||||
jobs:
|
||||
on-success:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: build and test (docker)
|
||||
run: |
|
||||
docker build --tag whoogle-search:test .
|
||||
docker run --publish 5000:5000 --detach --name whoogle-search-nocompose whoogle-search:test
|
||||
sleep 15
|
||||
docker exec whoogle-search-nocompose curl -f http://localhost:5000/healthz || exit 1
|
||||
- name: build and test (docker-compose)
|
||||
run: |
|
||||
docker rm -f whoogle-search-nocompose
|
||||
WHOOGLE_IMAGE="whoogle-search:test" docker compose up --detach
|
||||
sleep 15
|
||||
docker exec whoogle-search curl -f http://localhost:5000/healthz || exit 1
|
||||
26
.github/workflows/docker_tests.yml
vendored
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
|
||||
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
@ -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
@ -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
@ -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
|
||||
13
.gitignore
vendored
@ -1,16 +1,27 @@
|
||||
venv/
|
||||
.venv/
|
||||
.idea/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pem
|
||||
*.conf
|
||||
*.key
|
||||
config.json
|
||||
test/static
|
||||
flask_session/
|
||||
app/static/config
|
||||
app/static/custom_config
|
||||
app/static/bangs/*
|
||||
!app/static/bangs/00-whoogle.json
|
||||
|
||||
# pip stuff
|
||||
build/
|
||||
/build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# env
|
||||
whoogle.env
|
||||
|
||||
# vim
|
||||
*~
|
||||
*.swp
|
||||
|
||||
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]
|
||||
|
||||
3
.replit
@ -1,2 +1 @@
|
||||
language = "python3"
|
||||
run = "pip install -r requirements.txt && ./run"
|
||||
entrypoint = "misc/replit.py"
|
||||
|
||||
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
|
||||
114
Dockerfile
@ -1,28 +1,116 @@
|
||||
FROM python:3.8-slim
|
||||
# NOTE: ARMv7 support has been dropped due to lack of pre-built cryptography wheels for Alpine/musl.
|
||||
# To restore ARMv7 support for local builds:
|
||||
# 1. Change requirements.txt:
|
||||
# cryptography==3.3.2; platform_machine == 'armv7l'
|
||||
# cryptography==46.0.1; platform_machine != 'armv7l'
|
||||
# pyOpenSSL==19.1.0; platform_machine == 'armv7l'
|
||||
# pyOpenSSL==25.3.0; platform_machine != 'armv7l'
|
||||
# 2. Add linux/arm/v7 to --platform flag when building:
|
||||
# docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64 .
|
||||
|
||||
FROM python:3.12-alpine3.22 AS builder
|
||||
|
||||
RUN apk --no-cache add \
|
||||
build-base \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
openssl-dev \
|
||||
libffi-dev
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
RUN apt-get update && apt-get install -y build-essential libcurl4-openssl-dev libssl-dev
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install --prefix /install --no-warn-script-location --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.12-alpine3.22
|
||||
|
||||
# Remove bridge package to avoid CVEs (not needed for Docker containers)
|
||||
RUN apk add --no-cache --no-scripts tor curl openrc libstdc++ && \
|
||||
apk del --no-cache bridge || true
|
||||
# git go //for obfs4proxy
|
||||
# libcurl4-openssl-dev
|
||||
RUN pip install --upgrade pip
|
||||
RUN apk --no-cache upgrade && \
|
||||
apk del --no-cache --rdepends bridge || true
|
||||
|
||||
# uncomment to build obfs4proxy
|
||||
# RUN git clone https://gitlab.com/yawning/obfs4.git
|
||||
# WORKDIR /obfs4
|
||||
# RUN go build -o obfs4proxy/obfs4proxy ./obfs4proxy
|
||||
# RUN cp ./obfs4proxy/obfs4proxy /usr/bin/obfs4proxy
|
||||
|
||||
ARG DOCKER_USER=whoogle
|
||||
ARG DOCKER_USERID=927
|
||||
ARG config_dir=/config
|
||||
RUN mkdir -p $config_dir
|
||||
RUN chmod a+w $config_dir
|
||||
VOLUME $config_dir
|
||||
ENV CONFIG_VOLUME=$config_dir
|
||||
|
||||
ARG url_prefix=''
|
||||
ARG username=''
|
||||
ENV WHOOGLE_USER=$username
|
||||
ARG password=''
|
||||
ENV WHOOGLE_PASS=$password
|
||||
|
||||
ARG proxyuser=''
|
||||
ARG proxypass=''
|
||||
ARG proxytype=''
|
||||
ARG proxyloc=''
|
||||
ARG whoogle_dotenv=''
|
||||
ARG use_https=''
|
||||
ENV HTTPS_ONLY=$use_https
|
||||
|
||||
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'
|
||||
|
||||
COPY . .
|
||||
ENV CONFIG_VOLUME=$config_dir \
|
||||
WHOOGLE_URL_PREFIX=$url_prefix \
|
||||
WHOOGLE_USER=$username \
|
||||
WHOOGLE_PASS=$password \
|
||||
WHOOGLE_PROXY_USER=$proxyuser \
|
||||
WHOOGLE_PROXY_PASS=$proxypass \
|
||||
WHOOGLE_PROXY_TYPE=$proxytype \
|
||||
WHOOGLE_PROXY_LOC=$proxyloc \
|
||||
WHOOGLE_DOTENV=$whoogle_dotenv \
|
||||
HTTPS_ONLY=$use_https \
|
||||
EXPOSE_PORT=$whoogle_port \
|
||||
WHOOGLE_ALT_TW=$twitter_alt \
|
||||
WHOOGLE_ALT_YT=$youtube_alt \
|
||||
WHOOGLE_ALT_RD=$reddit_alt \
|
||||
WHOOGLE_ALT_MD=$medium_alt \
|
||||
WHOOGLE_ALT_TL=$translate_alt \
|
||||
WHOOGLE_ALT_IMG=$imgur_alt \
|
||||
WHOOGLE_ALT_WIKI=$wikipedia_alt \
|
||||
WHOOGLE_ALT_IMDB=$imdb_alt \
|
||||
WHOOGLE_ALT_QUORA=$quora_alt \
|
||||
WHOOGLE_ALT_SO=$so_alt
|
||||
|
||||
WORKDIR /whoogle
|
||||
|
||||
COPY --from=builder /install /usr/local
|
||||
COPY misc/tor/torrc /etc/tor/torrc
|
||||
COPY misc/tor/start-tor.sh misc/tor/start-tor.sh
|
||||
COPY app/ app/
|
||||
COPY run whoogle.env* ./
|
||||
|
||||
# Create user/group to run as
|
||||
RUN adduser -D -g $DOCKER_USERID -u $DOCKER_USERID $DOCKER_USER
|
||||
|
||||
# Fix ownership / permissions
|
||||
RUN chown -R ${DOCKER_USER}:${DOCKER_USER} /whoogle /var/lib/tor
|
||||
|
||||
# Allow writing symlinks to build dir
|
||||
RUN chown $DOCKER_USERID:$DOCKER_USERID app/static/build
|
||||
|
||||
USER $DOCKER_USER:$DOCKER_USER
|
||||
|
||||
EXPOSE $EXPOSE_PORT
|
||||
|
||||
CMD ["./run"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s \
|
||||
CMD curl -f http://localhost:${EXPOSE_PORT}/healthz || exit 1
|
||||
|
||||
CMD ["/bin/sh", "-c", "misc/tor/start-tor.sh & ./run"]
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
graft app/static
|
||||
graft app/templates
|
||||
graft app/misc
|
||||
include requirements.txt
|
||||
recursive-include test
|
||||
global-exclude *.pyc
|
||||
|
||||
198
app.json
@ -1,8 +1,194 @@
|
||||
{
|
||||
"name": "Whoogle Search",
|
||||
"description": "A lightweight, privacy-oriented, containerized Google search proxy for desktop/mobile that removes Javascript, AMP links, tracking, and ads/sponsored content",
|
||||
"repository": "https://github.com/benbusby/whoogle-search",
|
||||
"logo": "https://raw.githubusercontent.com/benbusby/whoogle-search/master/app/static/img/favicon/ms-icon-150x150.png",
|
||||
"keywords": ["search", "metasearch", "flask", "docker", "heroku", "adblock", "degoogle", "privacy"],
|
||||
"stack": "container"
|
||||
"name": "Whoogle Search",
|
||||
"description": "A lightweight, privacy-oriented, containerized Google search proxy for desktop/mobile that removes Javascript, AMP links, tracking, and ads/sponsored content",
|
||||
"repository": "https://github.com/benbusby/whoogle-search",
|
||||
"logo": "https://raw.githubusercontent.com/benbusby/whoogle-search/master/app/static/img/favicon/ms-icon-150x150.png",
|
||||
"keywords": [
|
||||
"search",
|
||||
"metasearch",
|
||||
"flask",
|
||||
"docker",
|
||||
"heroku",
|
||||
"adblock",
|
||||
"degoogle",
|
||||
"privacy"
|
||||
],
|
||||
"stack": "container",
|
||||
"env": {
|
||||
"WHOOGLE_URL_PREFIX": {
|
||||
"description": "The URL prefix to use for the whoogle instance (i.e. \"/whoogle\")",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_USER": {
|
||||
"description": "The username for basic auth. WHOOGLE_PASS must also be set if used. Leave empty to disable.",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_PASS": {
|
||||
"description": "The password for basic auth. WHOOGLE_USER must also be set if used. Leave empty to disable.",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_PROXY_USER": {
|
||||
"description": "The username of the proxy server. Leave empty to disable.",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_PROXY_PASS": {
|
||||
"description": "The password of the proxy server. Leave empty to disable.",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_PROXY_TYPE": {
|
||||
"description": "The type of the proxy server. For example \"socks5\". Leave empty to disable.",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_PROXY_LOC": {
|
||||
"description": "The location of the proxy server (host or ip). Leave empty to disable.",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_TW": {
|
||||
"description": "The site to use as a replacement for twitter.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/nitter",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_YT": {
|
||||
"description": "The site to use as a replacement for youtube.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/invidious",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_RD": {
|
||||
"description": "The site to use as a replacement for reddit.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/libreddit",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_MD": {
|
||||
"description": "The site to use as a replacement for medium.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/scribe",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_TL": {
|
||||
"description": "The Google Translate alternative to use for all searches following the 'translate ___' structure.",
|
||||
"value": "farside.link/lingva",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_IMG": {
|
||||
"description": "The site to use as a replacement for imgur.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/rimgo",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_WIKI": {
|
||||
"description": "The site to use as a replacement for wikipedia.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/wikiless",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_IMDB": {
|
||||
"description": "The site to use as a replacement for imdb.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/libremdb",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_QUORA": {
|
||||
"description": "The site to use as a replacement for quora.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/quetre",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_ALT_SO": {
|
||||
"description": "The site to use as a replacement for stackoverflow.com when site alternatives are enabled in the config.",
|
||||
"value": "farside.link/anonymousoverflow",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_MINIMAL": {
|
||||
"description": "Remove everything except basic result cards from all search queries (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_COUNTRY": {
|
||||
"description": "[CONFIG] The country to use for restricting search results (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/countries.json)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_TIME_PERIOD" : {
|
||||
"description": "[CONFIG] The time period to use for restricting search results",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_LANGUAGE": {
|
||||
"description": "[CONFIG] The language to use for the interface (use values from https://raw.githubusercontent.com/benbusby/whoogle-search/develop/app/static/settings/languages.json)",
|
||||
"value": "",
|
||||
"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": {
|
||||
"description": "[CONFIG] Use safe mode for searches (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_ALTS": {
|
||||
"description": "[CONFIG] Use social media alternatives (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_NEAR": {
|
||||
"description": "[CONFIG] Restrict results to only those near a particular city",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_TOR": {
|
||||
"description": "[CONFIG] Use Tor, if available (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_NEW_TAB": {
|
||||
"description": "[CONFIG] Always open results in new tab (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
"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": {
|
||||
"description": "[CONFIG] Search using GET requests only (set to 1 or leave blank)",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_STYLE": {
|
||||
"description": "[CONFIG] Custom CSS styling (paste in CSS or leave blank)",
|
||||
"value": ":root { /* LIGHT THEME COLORS */ --whoogle-background: #d8dee9; --whoogle-accent: #2e3440; --whoogle-text: #3B4252; --whoogle-contrast-text: #eceff4; --whoogle-secondary-text: #70757a; --whoogle-result-bg: #fff; --whoogle-result-title: #4c566a; --whoogle-result-url: #81a1c1; --whoogle-result-visited: #a3be8c; /* DARK THEME COLORS */ --whoogle-dark-background: #222; --whoogle-dark-accent: #685e79; --whoogle-dark-text: #fff; --whoogle-dark-contrast-text: #000; --whoogle-dark-secondary-text: #bbb; --whoogle-dark-result-bg: #000; --whoogle-dark-result-title: #1967d2; --whoogle-dark-result-url: #4b11a8; --whoogle-dark-result-visited: #bbbbff; }",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_PREFERENCES_ENCRYPTED": {
|
||||
"description": "[CONFIG] Encrypt preferences token, requires WHOOGLE_CONFIG_PREFERENCES_KEY to be set",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"WHOOGLE_CONFIG_PREFERENCES_KEY": {
|
||||
"description": "[CONFIG] Key to encrypt preferences",
|
||||
"value": "NEEDS_TO_BE_MODIFIED",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
310
app/__init__.py
@ -1,27 +1,305 @@
|
||||
from app.utils.session_utils import generate_user_keys
|
||||
from app.filter import clean_query
|
||||
from app.request import send_tor_signal
|
||||
from app.utils.session import generate_key
|
||||
from app.utils.bangs import gen_bangs_json, load_all_bangs
|
||||
from app.utils.misc import gen_file_hash, read_config_bool
|
||||
from app.utils.ua_generator import load_ua_pool
|
||||
from base64 import b64encode
|
||||
from bs4 import MarkupResemblesLocatorWarning
|
||||
from datetime import datetime, timedelta
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask
|
||||
from flask_session import Session
|
||||
import json
|
||||
import logging.config
|
||||
import os
|
||||
import sys
|
||||
from stem import Signal
|
||||
import threading
|
||||
import warnings
|
||||
|
||||
app = Flask(__name__, static_folder=os.path.dirname(os.path.abspath(__file__)) + '/static')
|
||||
app.user_elements = {}
|
||||
app.default_key_set = generate_user_keys()
|
||||
app.no_cookie_ips = []
|
||||
app.config['SECRET_KEY'] = os.urandom(32)
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
app.config['VERSION_NUMBER'] = '0.2.1'
|
||||
app.config['APP_ROOT'] = os.getenv('APP_ROOT', os.path.dirname(os.path.abspath(__file__)))
|
||||
app.config['STATIC_FOLDER'] = os.getenv('STATIC_FOLDER', os.path.join(app.config['APP_ROOT'], 'static'))
|
||||
app.config['CONFIG_PATH'] = os.getenv('CONFIG_VOLUME', os.path.join(app.config['STATIC_FOLDER'], 'config'))
|
||||
app.config['DEFAULT_CONFIG'] = os.path.join(app.config['CONFIG_PATH'], 'config.json')
|
||||
app.config['SESSION_FILE_DIR'] = os.path.join(app.config['CONFIG_PATH'], 'session')
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from app.services.http_client import HttpxClient
|
||||
from app.services.provider import close_all_clients
|
||||
from app.version import __version__
|
||||
|
||||
app = Flask(__name__, static_folder=os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'static'))
|
||||
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app)
|
||||
|
||||
# look for WHOOGLE_ENV, else look in parent directory
|
||||
dot_env_path = os.getenv(
|
||||
"WHOOGLE_DOTENV_PATH",
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../whoogle.env"))
|
||||
|
||||
# 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_ROOT',
|
||||
os.path.dirname(os.path.abspath(__file__)))
|
||||
app.config['STATIC_FOLDER'] = os.getenv(
|
||||
'STATIC_FOLDER',
|
||||
os.path.join(app.config['APP_ROOT'], 'static'))
|
||||
app.config['BUILD_FOLDER'] = os.path.join(
|
||||
app.config['STATIC_FOLDER'], 'build')
|
||||
app.config['CACHE_BUSTING_MAP'] = {}
|
||||
app.config['BUNDLE_STATIC'] = read_config_bool('WHOOGLE_BUNDLE_STATIC')
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/languages.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['LANGUAGES'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/countries.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['COUNTRIES'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/time_periods.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['TIME_PERIODS'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/translations.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['TRANSLATIONS'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/themes.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['THEMES'] = json.load(f)
|
||||
with open(os.path.join(app.config['STATIC_FOLDER'], 'settings/header_tabs.json'), 'r', encoding='utf-8') as f:
|
||||
app.config['HEADER_TABS'] = json.load(f)
|
||||
app.config['CONFIG_PATH'] = os.getenv(
|
||||
'CONFIG_VOLUME',
|
||||
os.path.join(app.config['STATIC_FOLDER'], 'config'))
|
||||
app.config['DEFAULT_CONFIG'] = os.path.join(
|
||||
app.config['CONFIG_PATH'],
|
||||
'config.json')
|
||||
app.config['CONFIG_DISABLE'] = read_config_bool('WHOOGLE_CONFIG_DISABLE')
|
||||
app.config['SESSION_FILE_DIR'] = os.path.join(
|
||||
app.config['CONFIG_PATH'],
|
||||
'session')
|
||||
# Maximum session file size in bytes (4KB limit to prevent abuse and disk exhaustion)
|
||||
# Session files larger than this are ignored during cleanup to avoid processing
|
||||
# potentially malicious or corrupted files
|
||||
app.config['MAX_SESSION_SIZE'] = 4000
|
||||
app.config['BANG_PATH'] = os.getenv(
|
||||
'CONFIG_VOLUME',
|
||||
os.path.join(app.config['STATIC_FOLDER'], 'bangs'))
|
||||
app.config['BANG_FILE'] = os.path.join(
|
||||
app.config['BANG_PATH'],
|
||||
'bangs.json')
|
||||
|
||||
# Global services registry (simple DI)
|
||||
app.services = {}
|
||||
|
||||
|
||||
@app.teardown_appcontext
|
||||
def _teardown_clients(exception):
|
||||
try:
|
||||
close_all_clients()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure all necessary directories exist
|
||||
if not os.path.exists(app.config['CONFIG_PATH']):
|
||||
os.makedirs(app.config['CONFIG_PATH'])
|
||||
|
||||
if not os.path.exists(app.config['SESSION_FILE_DIR']):
|
||||
os.makedirs(app.config['SESSION_FILE_DIR'])
|
||||
|
||||
Session(app)
|
||||
if not os.path.exists(app.config['BANG_PATH']):
|
||||
os.makedirs(app.config['BANG_PATH'])
|
||||
|
||||
from app import routes
|
||||
if not os.path.exists(app.config['BUILD_FOLDER']):
|
||||
os.makedirs(app.config['BUILD_FOLDER'])
|
||||
|
||||
# Initialize User Agent pool
|
||||
app.config['UA_CACHE_PATH'] = os.path.join(app.config['CONFIG_PATH'], 'ua_cache.json')
|
||||
try:
|
||||
app.config['UA_POOL'] = load_ua_pool(app.config['UA_CACHE_PATH'], count=10)
|
||||
except Exception as e:
|
||||
# If UA pool loading fails, log warning and set empty pool
|
||||
# The gen_user_agent function will handle the fallback
|
||||
print(f"Warning: Could not initialize UA pool: {e}")
|
||||
app.config['UA_POOL'] = []
|
||||
|
||||
# Session values - Secret key management
|
||||
# Priority: environment variable → file → generate new
|
||||
def get_secret_key():
|
||||
"""Load or generate secret key with validation.
|
||||
|
||||
Priority order:
|
||||
1. WHOOGLE_SECRET_KEY environment variable
|
||||
2. Existing key file
|
||||
3. Generate new key and save to file
|
||||
|
||||
Returns:
|
||||
str: Valid secret key for Flask sessions
|
||||
"""
|
||||
# Check environment variable first
|
||||
env_key = os.getenv('WHOOGLE_SECRET_KEY', '').strip()
|
||||
if env_key:
|
||||
# Validate env key has minimum length
|
||||
if len(env_key) >= 32:
|
||||
return env_key
|
||||
else:
|
||||
print(f"Warning: WHOOGLE_SECRET_KEY too short ({len(env_key)} chars, need 32+). Using file/generated key instead.", file=sys.stderr)
|
||||
|
||||
# Check file-based key
|
||||
app_key_path = os.path.join(app.config['CONFIG_PATH'], 'whoogle.key')
|
||||
if os.path.exists(app_key_path):
|
||||
try:
|
||||
with open(app_key_path, 'r', encoding='utf-8') as f:
|
||||
key = f.read().strip()
|
||||
# Validate file key
|
||||
if len(key) >= 32:
|
||||
return key
|
||||
else:
|
||||
print(f"Warning: Key file too short, regenerating", file=sys.stderr)
|
||||
except (PermissionError, IOError) as e:
|
||||
print(f"Warning: Could not read key file: {e}", file=sys.stderr)
|
||||
|
||||
# Generate new key
|
||||
new_key = str(b64encode(os.urandom(32)))
|
||||
try:
|
||||
with open(app_key_path, 'w', encoding='utf-8') as key_file:
|
||||
key_file.write(new_key)
|
||||
except (PermissionError, IOError) as e:
|
||||
print(f"Warning: Could not save key file: {e}. Key will not persist across restarts.", file=sys.stderr)
|
||||
|
||||
return new_key
|
||||
|
||||
app.config['SECRET_KEY'] = get_secret_key()
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365)
|
||||
|
||||
# NOTE: SESSION_COOKIE_SAMESITE must be set to 'lax' to allow the user's
|
||||
# previous session to persist when accessing the instance from an external
|
||||
# link. Setting this value to 'strict' causes Whoogle to revalidate a new
|
||||
# session, and fail, resulting in cookies being disabled.
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
|
||||
# Config fields that are used to check for updates
|
||||
app.config['RELEASES_URL'] = 'https://github.com/' \
|
||||
'benbusby/whoogle-search/releases'
|
||||
app.config['LAST_UPDATE_CHECK'] = datetime.now() - timedelta(hours=24)
|
||||
app.config['HAS_UPDATE'] = ''
|
||||
|
||||
# The alternative to Google Translate is treated a bit differently than other
|
||||
# social media site alternatives, in that it is used for any translation
|
||||
# related searches.
|
||||
translate_url = os.getenv('WHOOGLE_ALT_TL', 'https://farside.link/lingva')
|
||||
if not translate_url.startswith('http'):
|
||||
translate_url = 'https://' + translate_url
|
||||
app.config['TRANSLATE_URL'] = translate_url
|
||||
|
||||
app.config['CSP'] = 'default-src \'none\';' \
|
||||
'frame-src ' + translate_url + ';' \
|
||||
'manifest-src \'self\';' \
|
||||
'img-src \'self\' data:;' \
|
||||
'style-src \'self\' \'unsafe-inline\';' \
|
||||
'script-src \'self\';' \
|
||||
'media-src \'self\';' \
|
||||
'connect-src \'self\';'
|
||||
|
||||
# Generate DDG bang filter
|
||||
generating_bangs = False
|
||||
if not os.path.exists(app.config['BANG_FILE']):
|
||||
generating_bangs = True
|
||||
with open(app.config['BANG_FILE'], 'w', encoding='utf-8') as f:
|
||||
json.dump({}, f)
|
||||
bangs_thread = threading.Thread(
|
||||
target=gen_bangs_json,
|
||||
args=(app.config['BANG_FILE'],))
|
||||
bangs_thread.start()
|
||||
|
||||
# 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
|
||||
send_tor_signal(Signal.HEARTBEAT)
|
||||
|
||||
# Suppress spurious warnings from BeautifulSoup
|
||||
warnings.simplefilter('ignore', MarkupResemblesLocatorWarning)
|
||||
|
||||
from app import routes # noqa
|
||||
|
||||
# The gen_bangs_json function takes care of loading bangs, so skip it here if
|
||||
# it's already being loaded
|
||||
if not generating_bangs:
|
||||
load_all_bangs(app.config['BANG_FILE'])
|
||||
|
||||
# Disable logging from imported modules
|
||||
logging.config.dictConfig({
|
||||
'version': 1,
|
||||
'disable_existing_loggers': True,
|
||||
})
|
||||
|
||||
1024
app/filter.py
@ -1,317 +1,117 @@
|
||||
from inspect import Attribute
|
||||
from typing import Optional
|
||||
from app.utils.misc import read_config_bool
|
||||
from flask import current_app
|
||||
import os
|
||||
from base64 import urlsafe_b64encode, urlsafe_b64decode
|
||||
from cryptography.fernet import Fernet
|
||||
import hashlib
|
||||
import brotli
|
||||
import logging
|
||||
import json
|
||||
|
||||
import cssutils
|
||||
from cssutils.css.cssstylesheet import CSSStyleSheet
|
||||
from cssutils.css.cssstylerule import CSSStyleRule
|
||||
|
||||
# removes warnings from cssutils
|
||||
cssutils.log.setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
def get_rule_for_selector(stylesheet: CSSStyleSheet,
|
||||
selector: str) -> Optional[CSSStyleRule]:
|
||||
"""Search for a rule that matches a given selector in a stylesheet.
|
||||
|
||||
Args:
|
||||
stylesheet (CSSStyleSheet) -- the stylesheet to search
|
||||
selector (str) -- the selector to search for
|
||||
|
||||
Returns:
|
||||
Optional[CSSStyleRule] -- the rule that matches the selector or None
|
||||
"""
|
||||
for rule in stylesheet.cssRules:
|
||||
if hasattr(rule, "selectorText") and selector == rule.selectorText:
|
||||
return rule
|
||||
return None
|
||||
|
||||
|
||||
class Config:
|
||||
# Derived from here:
|
||||
# https://sites.google.com/site/tomihasa/google-language-codes#searchlanguage
|
||||
LANGUAGES = [
|
||||
{'name': 'Default (none specified)', 'value': ''},
|
||||
{'name': 'English', 'value': 'lang_en'},
|
||||
{'name': 'Afrikaans', 'value': 'lang_af'},
|
||||
{'name': 'Arabic', 'value': 'lang_ar'},
|
||||
{'name': 'Armenian', 'value': 'lang_hy'},
|
||||
{'name': 'Belarusian', 'value': 'lang_be'},
|
||||
{'name': 'Bulgarian', 'value': 'lang_bg'},
|
||||
{'name': 'Catalan', 'value': 'lang_ca'},
|
||||
{'name': 'Chinese (Simplified)', 'value': 'lang_zh-CN'},
|
||||
{'name': 'Chinese (Traditional)', 'value': 'lang_zh-TW'},
|
||||
{'name': 'Croatian', 'value': 'lang_hr'},
|
||||
{'name': 'Czech', 'value': 'lang_cs'},
|
||||
{'name': 'Danish', 'value': 'lang_da'},
|
||||
{'name': 'Dutch', 'value': 'lang_nl'},
|
||||
{'name': 'Esperanto', 'value': 'lang_eo'},
|
||||
{'name': 'Estonian', 'value': 'lang_et'},
|
||||
{'name': 'Filipino', 'value': 'lang_tl'},
|
||||
{'name': 'Finnish', 'value': 'lang_fi'},
|
||||
{'name': 'French', 'value': 'lang_fr'},
|
||||
{'name': 'German', 'value': 'lang_de'},
|
||||
{'name': 'Greek', 'value': 'lang_el'},
|
||||
{'name': 'Hebrew', 'value': 'lang_iw'},
|
||||
{'name': 'Hindi', 'value': 'lang_hi'},
|
||||
{'name': 'Hungarian', 'value': 'lang_hu'},
|
||||
{'name': 'Icelandic', 'value': 'lang_is'},
|
||||
{'name': 'Indonesian', 'value': 'lang_id'},
|
||||
{'name': 'Italian', 'value': 'lang_it'},
|
||||
{'name': 'Japanese', 'value': 'lang_ja'},
|
||||
{'name': 'Korean', 'value': 'lang_ko'},
|
||||
{'name': 'Latvian', 'value': 'lang_lv'},
|
||||
{'name': 'Lithuanian', 'value': 'lang_lt'},
|
||||
{'name': 'Norwegian', 'value': 'lang_no'},
|
||||
{'name': 'Persian', 'value': 'lang_fa'},
|
||||
{'name': 'Polish', 'value': 'lang_pl'},
|
||||
{'name': 'Portuguese', 'value': 'lang_pt'},
|
||||
{'name': 'Romanian', 'value': 'lang_ro'},
|
||||
{'name': 'Russian', 'value': 'lang_ru'},
|
||||
{'name': 'Serbian', 'value': 'lang_sr'},
|
||||
{'name': 'Slovak', 'value': 'lang_sk'},
|
||||
{'name': 'Slovenian', 'value': 'lang_sl'},
|
||||
{'name': 'Spanish', 'value': 'lang_es'},
|
||||
{'name': 'Swahili', 'value': 'lang_sw'},
|
||||
{'name': 'Swedish', 'value': 'lang_sv'},
|
||||
{'name': 'Thai', 'value': 'lang_th'},
|
||||
{'name': 'Turkish', 'value': 'lang_tr'},
|
||||
{'name': 'Ukrainian', 'value': 'lang_uk'},
|
||||
{'name': 'Vietnamese', 'value': 'lang_vi'},
|
||||
]
|
||||
|
||||
COUNTRIES = [
|
||||
{'name': 'Default (none)', 'value': ''},
|
||||
{'name': 'Afghanistan', 'value': 'countryAF'},
|
||||
{'name': 'Albania', 'value': 'countryAL'},
|
||||
{'name': 'Algeria', 'value': 'countryDZ'},
|
||||
{'name': 'American Samoa', 'value': 'countryAS'},
|
||||
{'name': 'Andorra', 'value': 'countryAD'},
|
||||
{'name': 'Angola', 'value': 'countryAO'},
|
||||
{'name': 'Anguilla', 'value': 'countryAI'},
|
||||
{'name': 'Antarctica', 'value': 'countryAQ'},
|
||||
{'name': 'Antigua and Barbuda', 'value': 'countryAG'},
|
||||
{'name': 'Argentina', 'value': 'countryAR'},
|
||||
{'name': 'Armenia', 'value': 'countryAM'},
|
||||
{'name': 'Aruba', 'value': 'countryAW'},
|
||||
{'name': 'Australia', 'value': 'countryAU'},
|
||||
{'name': 'Austria', 'value': 'countryAT'},
|
||||
{'name': 'Azerbaijan', 'value': 'countryAZ'},
|
||||
{'name': 'Bahamas', 'value': 'countryBS'},
|
||||
{'name': 'Bahrain', 'value': 'countryBH'},
|
||||
{'name': 'Bangladesh', 'value': 'countryBD'},
|
||||
{'name': 'Barbados', 'value': 'countryBB'},
|
||||
{'name': 'Belarus', 'value': 'countryBY'},
|
||||
{'name': 'Belgium', 'value': 'countryBE'},
|
||||
{'name': 'Belize', 'value': 'countryBZ'},
|
||||
{'name': 'Benin', 'value': 'countryBJ'},
|
||||
{'name': 'Bermuda', 'value': 'countryBM'},
|
||||
{'name': 'Bhutan', 'value': 'countryBT'},
|
||||
{'name': 'Bolivia', 'value': 'countryBO'},
|
||||
{'name': 'Bosnia and Herzegovina', 'value': 'countryBA'},
|
||||
{'name': 'Botswana', 'value': 'countryBW'},
|
||||
{'name': 'Bouvet Island', 'value': 'countryBV'},
|
||||
{'name': 'Brazil', 'value': 'countryBR'},
|
||||
{'name': 'British Indian Ocean Territory', 'value': 'countryIO'},
|
||||
{'name': 'Brunei Darussalam', 'value': 'countryBN'},
|
||||
{'name': 'Bulgaria', 'value': 'countryBG'},
|
||||
{'name': 'Burkina Faso', 'value': 'countryBF'},
|
||||
{'name': 'Burundi', 'value': 'countryBI'},
|
||||
{'name': 'Cambodia', 'value': 'countryKH'},
|
||||
{'name': 'Cameroon', 'value': 'countryCM'},
|
||||
{'name': 'Canada', 'value': 'countryCA'},
|
||||
{'name': 'Cape Verde', 'value': 'countryCV'},
|
||||
{'name': 'Cayman Islands', 'value': 'countryKY'},
|
||||
{'name': 'Central African Republic', 'value': 'countryCF'},
|
||||
{'name': 'Chad', 'value': 'countryTD'},
|
||||
{'name': 'Chile', 'value': 'countryCL'},
|
||||
{'name': 'China', 'value': 'countryCN'},
|
||||
{'name': 'Christmas Island', 'value': 'countryCX'},
|
||||
{'name': 'Cocos (Keeling) Islands', 'value': 'countryCC'},
|
||||
{'name': 'Colombia', 'value': 'countryCO'},
|
||||
{'name': 'Comoros', 'value': 'countryKM'},
|
||||
{'name': 'Congo', 'value': 'countryCG'},
|
||||
{'name': 'Congo, Democratic Republic of the', 'value': 'countryCD'},
|
||||
{'name': 'Cook Islands', 'value': 'countryCK'},
|
||||
{'name': 'Costa Rica', 'value': 'countryCR'},
|
||||
{'name': 'Cote D\'ivoire', 'value': 'countryCI'},
|
||||
{'name': 'Croatia (Hrvatska)', 'value': 'countryHR'},
|
||||
{'name': 'Cuba', 'value': 'countryCU'},
|
||||
{'name': 'Cyprus', 'value': 'countryCY'},
|
||||
{'name': 'Czech Republic', 'value': 'countryCZ'},
|
||||
{'name': 'Denmark', 'value': 'countryDK'},
|
||||
{'name': 'Djibouti', 'value': 'countryDJ'},
|
||||
{'name': 'Dominica', 'value': 'countryDM'},
|
||||
{'name': 'Dominican Republic', 'value': 'countryDO'},
|
||||
{'name': 'East Timor', 'value': 'countryTP'},
|
||||
{'name': 'Ecuador', 'value': 'countryEC'},
|
||||
{'name': 'Egypt', 'value': 'countryEG'},
|
||||
{'name': 'El Salvador', 'value': 'countrySV'},
|
||||
{'name': 'Equatorial Guinea', 'value': 'countryGQ'},
|
||||
{'name': 'Eritrea', 'value': 'countryER'},
|
||||
{'name': 'Estonia', 'value': 'countryEE'},
|
||||
{'name': 'Ethiopia', 'value': 'countryET'},
|
||||
{'name': 'European Union', 'value': 'countryEU'},
|
||||
{'name': 'Falkland Islands (Malvinas)', 'value': 'countryFK'},
|
||||
{'name': 'Faroe Islands', 'value': 'countryFO'},
|
||||
{'name': 'Fiji', 'value': 'countryFJ'},
|
||||
{'name': 'Finland', 'value': 'countryFI'},
|
||||
{'name': 'France', 'value': 'countryFR'},
|
||||
{'name': 'France\, Metropolitan', 'value': 'countryFX'},
|
||||
{'name': 'French Guiana', 'value': 'countryGF'},
|
||||
{'name': 'French Polynesia', 'value': 'countryPF'},
|
||||
{'name': 'French Southern Territories', 'value': 'countryTF'},
|
||||
{'name': 'Gabon', 'value': 'countryGA'},
|
||||
{'name': 'Gambia', 'value': 'countryGM'},
|
||||
{'name': 'Georgia', 'value': 'countryGE'},
|
||||
{'name': 'Germany', 'value': 'countryDE'},
|
||||
{'name': 'Ghana', 'value': 'countryGH'},
|
||||
{'name': 'Gibraltar', 'value': 'countryGI'},
|
||||
{'name': 'Greece', 'value': 'countryGR'},
|
||||
{'name': 'Greenland', 'value': 'countryGL'},
|
||||
{'name': 'Grenada', 'value': 'countryGD'},
|
||||
{'name': 'Guadeloupe', 'value': 'countryGP'},
|
||||
{'name': 'Guam', 'value': 'countryGU'},
|
||||
{'name': 'Guatemala', 'value': 'countryGT'},
|
||||
{'name': 'Guinea', 'value': 'countryGN'},
|
||||
{'name': 'Guinea-Bissau', 'value': 'countryGW'},
|
||||
{'name': 'Guyana', 'value': 'countryGY'},
|
||||
{'name': 'Haiti', 'value': 'countryHT'},
|
||||
{'name': 'Heard Island and Mcdonald Islands', 'value': 'countryHM'},
|
||||
{'name': 'Holy See (Vatican City State)', 'value': 'countryVA'},
|
||||
{'name': 'Honduras', 'value': 'countryHN'},
|
||||
{'name': 'Hong Kong', 'value': 'countryHK'},
|
||||
{'name': 'Hungary', 'value': 'countryHU'},
|
||||
{'name': 'Iceland', 'value': 'countryIS'},
|
||||
{'name': 'India', 'value': 'countryIN'},
|
||||
{'name': 'Indonesia', 'value': 'countryID'},
|
||||
{'name': 'Iran, Islamic Republic of', 'value': 'countryIR'},
|
||||
{'name': 'Iraq', 'value': 'countryIQ'},
|
||||
{'name': 'Ireland', 'value': 'countryIE'},
|
||||
{'name': 'Israel', 'value': 'countryIL'},
|
||||
{'name': 'Italy', 'value': 'countryIT'},
|
||||
{'name': 'Jamaica', 'value': 'countryJM'},
|
||||
{'name': 'Japan', 'value': 'countryJP'},
|
||||
{'name': 'Jordan', 'value': 'countryJO'},
|
||||
{'name': 'Kazakhstan', 'value': 'countryKZ'},
|
||||
{'name': 'Kenya', 'value': 'countryKE'},
|
||||
{'name': 'Kiribati', 'value': 'countryKI'},
|
||||
{'name': 'Korea, Democratic People\'s Republic of', 'value': 'countryKP'},
|
||||
{'name': 'Korea, Republic of', 'value': 'countryKR'},
|
||||
{'name': 'Kuwait', 'value': 'countryKW'},
|
||||
{'name': 'Kyrgyzstan', 'value': 'countryKG'},
|
||||
{'name': 'Lao People\'s Democratic Republic', 'value': 'countryLA'},
|
||||
{'name': 'Latvia', 'value': 'countryLV'},
|
||||
{'name': 'Lebanon', 'value': 'countryLB'},
|
||||
{'name': 'Lesotho', 'value': 'countryLS'},
|
||||
{'name': 'Liberia', 'value': 'countryLR'},
|
||||
{'name': 'Libyan Arab Jamahiriya', 'value': 'countryLY'},
|
||||
{'name': 'Liechtenstein', 'value': 'countryLI'},
|
||||
{'name': 'Lithuania', 'value': 'countryLT'},
|
||||
{'name': 'Luxembourg', 'value': 'countryLU'},
|
||||
{'name': 'Macao', 'value': 'countryMO'},
|
||||
{'name': 'Macedonia, the Former Yugosalv Republic of', 'value': 'countryMK'},
|
||||
{'name': 'Madagascar', 'value': 'countryMG'},
|
||||
{'name': 'Malawi', 'value': 'countryMW'},
|
||||
{'name': 'Malaysia', 'value': 'countryMY'},
|
||||
{'name': 'Maldives', 'value': 'countryMV'},
|
||||
{'name': 'Mali', 'value': 'countryML'},
|
||||
{'name': 'Malta', 'value': 'countryMT'},
|
||||
{'name': 'Marshall Islands', 'value': 'countryMH'},
|
||||
{'name': 'Martinique', 'value': 'countryMQ'},
|
||||
{'name': 'Mauritania', 'value': 'countryMR'},
|
||||
{'name': 'Mauritius', 'value': 'countryMU'},
|
||||
{'name': 'Mayotte', 'value': 'countryYT'},
|
||||
{'name': 'Mexico', 'value': 'countryMX'},
|
||||
{'name': 'Micronesia, Federated States of', 'value': 'countryFM'},
|
||||
{'name': 'Moldova, Republic of', 'value': 'countryMD'},
|
||||
{'name': 'Monaco', 'value': 'countryMC'},
|
||||
{'name': 'Mongolia', 'value': 'countryMN'},
|
||||
{'name': 'Montserrat', 'value': 'countryMS'},
|
||||
{'name': 'Morocco', 'value': 'countryMA'},
|
||||
{'name': 'Mozambique', 'value': 'countryMZ'},
|
||||
{'name': 'Myanmar', 'value': 'countryMM'},
|
||||
{'name': 'Namibia', 'value': 'countryNA'},
|
||||
{'name': 'Nauru', 'value': 'countryNR'},
|
||||
{'name': 'Nepal', 'value': 'countryNP'},
|
||||
{'name': 'Netherlands', 'value': 'countryNL'},
|
||||
{'name': 'Netherlands Antilles', 'value': 'countryAN'},
|
||||
{'name': 'New Caledonia', 'value': 'countryNC'},
|
||||
{'name': 'New Zealand', 'value': 'countryNZ'},
|
||||
{'name': 'Nicaragua', 'value': 'countryNI'},
|
||||
{'name': 'Niger', 'value': 'countryNE'},
|
||||
{'name': 'Nigeria', 'value': 'countryNG'},
|
||||
{'name': 'Niue', 'value': 'countryNU'},
|
||||
{'name': 'Norfolk Island', 'value': 'countryNF'},
|
||||
{'name': 'Northern Mariana Islands', 'value': 'countryMP'},
|
||||
{'name': 'Norway', 'value': 'countryNO'},
|
||||
{'name': 'Oman', 'value': 'countryOM'},
|
||||
{'name': 'Pakistan', 'value': 'countryPK'},
|
||||
{'name': 'Palau', 'value': 'countryPW'},
|
||||
{'name': 'Palestinian Territory', 'value': 'countryPS'},
|
||||
{'name': 'Panama', 'value': 'countryPA'},
|
||||
{'name': 'Papua New Guinea', 'value': 'countryPG'},
|
||||
{'name': 'Paraguay', 'value': 'countryPY'},
|
||||
{'name': 'Peru', 'value': 'countryPE'},
|
||||
{'name': 'Philippines', 'value': 'countryPH'},
|
||||
{'name': 'Pitcairn', 'value': 'countryPN'},
|
||||
{'name': 'Poland', 'value': 'countryPL'},
|
||||
{'name': 'Portugal', 'value': 'countryPT'},
|
||||
{'name': 'Puerto Rico', 'value': 'countryPR'},
|
||||
{'name': 'Qatar', 'value': 'countryQA'},
|
||||
{'name': 'Reunion', 'value': 'countryRE'},
|
||||
{'name': 'Romania', 'value': 'countryRO'},
|
||||
{'name': 'Russian Federation', 'value': 'countryRU'},
|
||||
{'name': 'Rwanda', 'value': 'countryRW'},
|
||||
{'name': 'Saint Helena', 'value': 'countrySH'},
|
||||
{'name': 'Saint Kitts and Nevis', 'value': 'countryKN'},
|
||||
{'name': 'Saint Lucia', 'value': 'countryLC'},
|
||||
{'name': 'Saint Pierre and Miquelon', 'value': 'countryPM'},
|
||||
{'name': 'Saint Vincent and the Grenadines', 'value': 'countryVC'},
|
||||
{'name': 'Samoa', 'value': 'countryWS'},
|
||||
{'name': 'San Marino', 'value': 'countrySM'},
|
||||
{'name': 'Sao Tome and Principe', 'value': 'countryST'},
|
||||
{'name': 'Saudi Arabia', 'value': 'countrySA'},
|
||||
{'name': 'Senegal', 'value': 'countrySN'},
|
||||
{'name': 'Serbia and Montenegro', 'value': 'countryCS'},
|
||||
{'name': 'Seychelles', 'value': 'countrySC'},
|
||||
{'name': 'Sierra Leone', 'value': 'countrySL'},
|
||||
{'name': 'Singapore', 'value': 'countrySG'},
|
||||
{'name': 'Slovakia', 'value': 'countrySK'},
|
||||
{'name': 'Slovenia', 'value': 'countrySI'},
|
||||
{'name': 'Solomon Islands', 'value': 'countrySB'},
|
||||
{'name': 'Somalia', 'value': 'countrySO'},
|
||||
{'name': 'South Africa', 'value': 'countryZA'},
|
||||
{'name': 'South Georgia and the South Sandwich Islands', 'value': 'countryGS'},
|
||||
{'name': 'Spain', 'value': 'countryES'},
|
||||
{'name': 'Sri Lanka', 'value': 'countryLK'},
|
||||
{'name': 'Sudan', 'value': 'countrySD'},
|
||||
{'name': 'Suriname', 'value': 'countrySR'},
|
||||
{'name': 'Svalbard and Jan Mayen', 'value': 'countrySJ'},
|
||||
{'name': 'Swaziland', 'value': 'countrySZ'},
|
||||
{'name': 'Sweden', 'value': 'countrySE'},
|
||||
{'name': 'Switzerland', 'value': 'countryCH'},
|
||||
{'name': 'Syrian Arab Republic', 'value': 'countrySY'},
|
||||
{'name': 'Taiwan, Province of China', 'value': 'countryTW'},
|
||||
{'name': 'Tajikistan', 'value': 'countryTJ'},
|
||||
{'name': 'Tanzania, United Republic of', 'value': 'countryTZ'},
|
||||
{'name': 'Thailand', 'value': 'countryTH'},
|
||||
{'name': 'Togo', 'value': 'countryTG'},
|
||||
{'name': 'Tokelau', 'value': 'countryTK'},
|
||||
{'name': 'Tonga', 'value': 'countryTO'},
|
||||
{'name': 'Trinidad and Tobago', 'value': 'countryTT'},
|
||||
{'name': 'Tunisia', 'value': 'countryTN'},
|
||||
{'name': 'Turkey', 'value': 'countryTR'},
|
||||
{'name': 'Turkmenistan', 'value': 'countryTM'},
|
||||
{'name': 'Turks and Caicos Islands', 'value': 'countryTC'},
|
||||
{'name': 'Tuvalu', 'value': 'countryTV'},
|
||||
{'name': 'Uganda', 'value': 'countryUG'},
|
||||
{'name': 'Ukraine', 'value': 'countryUA'},
|
||||
{'name': 'United Arab Emirates', 'value': 'countryAE'},
|
||||
{'name': 'United Kingdom', 'value': 'countryUK'},
|
||||
{'name': 'United States', 'value': 'countryUS'},
|
||||
{'name': 'United States Minor Outlying Islands', 'value': 'countryUM'},
|
||||
{'name': 'Uruguay', 'value': 'countryUY'},
|
||||
{'name': 'Uzbekistan', 'value': 'countryUZ'},
|
||||
{'name': 'Vanuatu', 'value': 'countryVU'},
|
||||
{'name': 'Venezuela', 'value': 'countryVE'},
|
||||
{'name': 'Vietnam', 'value': 'countryVN'},
|
||||
{'name': 'Virgin Islands, British', 'value': 'countryVG'},
|
||||
{'name': 'Virgin Islands, U.S.', 'value': 'countryVI'},
|
||||
{'name': 'Wallis and Futuna', 'value': 'countryWF'},
|
||||
{'name': 'Western Sahara', 'value': 'countryEH'},
|
||||
{'name': 'Yemen', 'value': 'countryYE'},
|
||||
{'name': 'Yugoslavia', 'value': 'countryYU'},
|
||||
{'name': 'Zambia', 'value': 'countryZM'},
|
||||
{'name': 'Zimbabwe', 'value': 'countryZW'}
|
||||
]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.url = ''
|
||||
self.lang_search = ''
|
||||
self.lang_interface = ''
|
||||
self.ctry = ''
|
||||
self.safe = False
|
||||
self.dark = False
|
||||
self.nojs = False
|
||||
self.near = ''
|
||||
self.alts = False
|
||||
self.new_tab = False
|
||||
self.get_only = False
|
||||
# User agent configuration - default to env_conf if environment variables exist, otherwise default
|
||||
env_user_agent = os.getenv('WHOOGLE_USER_AGENT', '')
|
||||
env_mobile_agent = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')
|
||||
default_ua_option = 'env_conf' if (env_user_agent or env_mobile_agent) else 'default'
|
||||
|
||||
self.user_agent = kwargs.get('user_agent', default_ua_option)
|
||||
self.custom_user_agent = kwargs.get('custom_user_agent', '')
|
||||
self.use_custom_user_agent = kwargs.get('use_custom_user_agent', False)
|
||||
self.show_user_agent = read_config_bool('WHOOGLE_CONFIG_SHOW_USER_AGENT')
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
# Add user agent related keys to safe_keys
|
||||
# Note: CSE credentials (cse_api_key, cse_id) are intentionally NOT included
|
||||
# in safe_keys for security - they should not be shareable via URL
|
||||
self.safe_keys = [
|
||||
'lang_search',
|
||||
'lang_interface',
|
||||
'country',
|
||||
'theme',
|
||||
'alts',
|
||||
'new_tab',
|
||||
'view_image',
|
||||
'block',
|
||||
'safe',
|
||||
'nojs',
|
||||
'anon_view',
|
||||
'preferences_encrypted',
|
||||
'tbs',
|
||||
'user_agent',
|
||||
'custom_user_agent',
|
||||
'use_custom_user_agent',
|
||||
'show_user_agent'
|
||||
]
|
||||
|
||||
app_config = current_app.config
|
||||
self.url = os.getenv('WHOOGLE_CONFIG_URL', '')
|
||||
self.lang_search = os.getenv('WHOOGLE_CONFIG_SEARCH_LANGUAGE', '')
|
||||
self.lang_interface = os.getenv('WHOOGLE_CONFIG_LANGUAGE', '')
|
||||
self.style_modified = os.getenv(
|
||||
'WHOOGLE_CONFIG_STYLE', '')
|
||||
self.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.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', '')
|
||||
|
||||
# Google Custom Search Engine (CSE) BYOK settings
|
||||
self.cse_api_key = os.getenv('WHOOGLE_CSE_API_KEY', '')
|
||||
self.cse_id = os.getenv('WHOOGLE_CSE_ID', '')
|
||||
self.use_cse = read_config_bool('WHOOGLE_USE_CSE')
|
||||
|
||||
self.accept_language = False
|
||||
|
||||
# Skip setting custom config if there isn't one
|
||||
if kwargs:
|
||||
mutable_attrs = self.get_mutable_attrs()
|
||||
for attr in mutable_attrs:
|
||||
if attr == 'show_user_agent':
|
||||
# Handle show_user_agent as boolean
|
||||
self.show_user_agent = bool(kwargs.get(attr))
|
||||
elif attr in kwargs.keys():
|
||||
setattr(self, attr, kwargs[attr])
|
||||
elif attr not in kwargs.keys() and mutable_attrs[attr] == bool:
|
||||
setattr(self, attr, False)
|
||||
|
||||
def __getitem__(self, name):
|
||||
return getattr(self, name)
|
||||
@ -324,3 +124,192 @@ class Config:
|
||||
|
||||
def __contains__(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:
|
||||
"""Establishes a group of config options that are safe to set
|
||||
in the url.
|
||||
|
||||
Args:
|
||||
key (str) -- the key to check against
|
||||
|
||||
Returns:
|
||||
bool -- True/False depending on if the key is in the "safe"
|
||||
array
|
||||
"""
|
||||
|
||||
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':
|
||||
"""Modify user config with search parameters. This is primarily
|
||||
used for specifying configuration on a search-by-search basis on
|
||||
public instances.
|
||||
|
||||
Args:
|
||||
params -- the url arguments (can be any deemed safe by is_safe())
|
||||
|
||||
Returns:
|
||||
Config -- a modified config object
|
||||
"""
|
||||
if 'preferences' in params:
|
||||
params_new = self._decode_preferences(params['preferences'])
|
||||
# if preferences leads to an empty dictionary it means preferences
|
||||
# parameter was not decrypted successfully
|
||||
if len(params_new):
|
||||
params = params_new
|
||||
|
||||
for param_key in params.keys():
|
||||
if not self.is_safe_key(param_key):
|
||||
continue
|
||||
param_val = params.get(param_key)
|
||||
|
||||
if param_val == 'off':
|
||||
param_val = False
|
||||
elif isinstance(param_val, str):
|
||||
if param_val.isdigit():
|
||||
param_val = int(param_val)
|
||||
|
||||
self[param_key] = param_val
|
||||
return self
|
||||
|
||||
def to_params(self, keys: list = []) -> str:
|
||||
"""Generates a set of safe params for using in Whoogle URLs
|
||||
|
||||
Args:
|
||||
keys (list) -- optional list of keys of URL parameters
|
||||
|
||||
Returns:
|
||||
str -- a set of URL parameters
|
||||
"""
|
||||
if not len(keys):
|
||||
keys = self.safe_keys
|
||||
|
||||
param_str = ''
|
||||
for safe_key in keys:
|
||||
if not self[safe_key]:
|
||||
continue
|
||||
param_str = param_str + f'&{safe_key}={self[safe_key]}'
|
||||
|
||||
return param_str
|
||||
|
||||
def _get_fernet_key(self, password: str) -> bytes:
|
||||
"""Derive a Fernet-compatible key from a password using PBKDF2.
|
||||
|
||||
Note: This uses a static salt for simplicity. This is a breaking change
|
||||
from the previous MD5-based implementation. Existing encrypted preferences
|
||||
will need to be re-encrypted.
|
||||
|
||||
Args:
|
||||
password: The password to derive the key from
|
||||
|
||||
Returns:
|
||||
bytes: A URL-safe base64 encoded 32-byte key suitable for Fernet
|
||||
"""
|
||||
# Use a static salt derived from app context
|
||||
# In a production system, you'd want to store per-user salts
|
||||
salt = b'whoogle-preferences-salt-v2'
|
||||
|
||||
# Derive a 32-byte key using PBKDF2 with SHA256
|
||||
# 100,000 iterations is a reasonable balance of security and performance
|
||||
kdf_key = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
password.encode('utf-8'),
|
||||
salt,
|
||||
100000,
|
||||
dklen=32
|
||||
)
|
||||
|
||||
# Fernet requires a URL-safe base64 encoded key
|
||||
return urlsafe_b64encode(kdf_key)
|
||||
|
||||
def _encode_preferences(self) -> str:
|
||||
preferences_json = json.dumps(self.get_attrs()).encode()
|
||||
compressed_preferences = brotli.compress(preferences_json)
|
||||
|
||||
if self.preferences_encrypted and self.preferences_key:
|
||||
key = self._get_fernet_key(self.preferences_key)
|
||||
encrypted_preferences = Fernet(key).encrypt(compressed_preferences)
|
||||
compressed_preferences = brotli.compress(encrypted_preferences)
|
||||
|
||||
return urlsafe_b64encode(compressed_preferences).decode()
|
||||
|
||||
def _decode_preferences(self, preferences: str) -> dict:
|
||||
mode = preferences[0]
|
||||
preferences = preferences[1:]
|
||||
|
||||
try:
|
||||
decoded_data = brotli.decompress(urlsafe_b64decode(preferences.encode() + b'=='))
|
||||
|
||||
if mode == 'e' and self.preferences_key:
|
||||
# preferences are encrypted
|
||||
key = self._get_fernet_key(self.preferences_key)
|
||||
decrypted_data = Fernet(key).decrypt(decoded_data)
|
||||
decoded_data = brotli.decompress(decrypted_data)
|
||||
|
||||
config = json.loads(decoded_data)
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
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
@ -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
|
||||
430
app/request.py
@ -1,49 +1,145 @@
|
||||
from lxml import etree
|
||||
import random
|
||||
import requests
|
||||
from requests import Response
|
||||
from app.models.config import Config
|
||||
from app.utils.misc import read_config_bool
|
||||
from app.services.provider import get_http_client
|
||||
from app.utils.ua_generator import load_ua_pool, get_random_ua, DEFAULT_FALLBACK_UA
|
||||
from defusedxml import ElementTree as ET
|
||||
import httpx
|
||||
import urllib.parse as urlparse
|
||||
import os
|
||||
from stem import Signal, SocketError
|
||||
from stem.connection import AuthenticationFailure
|
||||
from stem.control import Controller
|
||||
from stem.connection import authenticate_cookie, authenticate_password
|
||||
|
||||
# Core Google search URLs
|
||||
SEARCH_URL = 'https://www.google.com/search?gbv=1&q='
|
||||
AUTOCOMPLETE_URL = 'https://suggestqueries.google.com/complete/search?client=toolbar&'
|
||||
|
||||
MOBILE_UA = '{}/5.0 (Android 0; Mobile; rv:54.0) Gecko/54.0 {}/59.0'
|
||||
DESKTOP_UA = '{}/5.0 (X11; {} x86_64; rv:75.0) Gecko/20100101 {}/75.0'
|
||||
MAPS_URL = 'https://maps.google.com/maps'
|
||||
AUTOCOMPLETE_URL = ('https://suggestqueries.google.com/'
|
||||
'complete/search?client=toolbar&')
|
||||
|
||||
# Valid query params
|
||||
VALID_PARAMS = ['tbs', 'tbm', 'start', 'near', 'source', 'nfpr']
|
||||
|
||||
|
||||
def gen_user_agent(is_mobile):
|
||||
mozilla = random.choice(['Moo', 'Woah', 'Bro', 'Slow']) + 'zilla'
|
||||
firefox = random.choice(['Choir', 'Squier', 'Higher', 'Wire']) + 'fox'
|
||||
linux = random.choice(['Win', 'Sin', 'Gin', 'Fin', 'Kin']) + 'ux'
|
||||
class TorError(Exception):
|
||||
"""Exception raised for errors in Tor requests.
|
||||
|
||||
if is_mobile:
|
||||
return MOBILE_UA.format(mozilla, firefox)
|
||||
Attributes:
|
||||
message: a message describing the error that occurred
|
||||
disable: optionally disables Tor in the user config (note:
|
||||
this should only happen if the connection has been dropped
|
||||
altogether).
|
||||
"""
|
||||
|
||||
return DESKTOP_UA.format(mozilla, linux, firefox)
|
||||
def __init__(self, message, disable=False) -> None:
|
||||
self.message = message
|
||||
self.disable = disable
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def gen_query(query, args, config, near_city=None):
|
||||
def send_tor_signal(signal: Signal) -> bool:
|
||||
use_pass = read_config_bool('WHOOGLE_TOR_USE_PASS')
|
||||
|
||||
confloc = './misc/tor/control.conf'
|
||||
# Check that the custom location of conf is real.
|
||||
temp = os.getenv('WHOOGLE_TOR_CONF', '')
|
||||
if os.path.isfile(temp):
|
||||
confloc = temp
|
||||
|
||||
# Attempt to authenticate and send signal.
|
||||
try:
|
||||
with Controller.from_port(port=9051) as c:
|
||||
if use_pass:
|
||||
with open(confloc, "r") as conf:
|
||||
# Scan for the last line of the file.
|
||||
for line in conf:
|
||||
pass
|
||||
secret = line.strip('\n')
|
||||
authenticate_password(c, password=secret)
|
||||
else:
|
||||
cookie_path = '/var/lib/tor/control_auth_cookie'
|
||||
authenticate_cookie(c, cookie_path=cookie_path)
|
||||
c.signal(signal)
|
||||
os.environ['TOR_AVAILABLE'] = '1'
|
||||
return True
|
||||
except (SocketError, AuthenticationFailure,
|
||||
ConnectionRefusedError, ConnectionError):
|
||||
# TODO: Handle Tor authentication (password and cookie)
|
||||
os.environ['TOR_AVAILABLE'] = '0'
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def gen_user_agent(config, is_mobile) -> str:
|
||||
# If using custom user agent, return the custom string
|
||||
if config.user_agent == 'custom' and config.custom_user_agent:
|
||||
return config.custom_user_agent
|
||||
|
||||
# If using environment configuration
|
||||
if config.user_agent == 'env_conf':
|
||||
if is_mobile:
|
||||
env_ua = os.getenv('WHOOGLE_USER_AGENT_MOBILE', '')
|
||||
if env_ua:
|
||||
return env_ua
|
||||
else:
|
||||
env_ua = os.getenv('WHOOGLE_USER_AGENT', '')
|
||||
if env_ua:
|
||||
return env_ua
|
||||
# If env vars are not set, fall back to Opera UA
|
||||
return DEFAULT_FALLBACK_UA
|
||||
|
||||
# If using default user agent - use auto-generated Opera UA pool
|
||||
if config.user_agent == 'default':
|
||||
try:
|
||||
# Try to load UA pool from cache (lazy loading if not in app.config)
|
||||
# First check if we have access to Flask app context
|
||||
try:
|
||||
from flask import current_app
|
||||
if hasattr(current_app, 'config') and 'UA_POOL' in current_app.config:
|
||||
ua_pool = current_app.config['UA_POOL']
|
||||
else:
|
||||
# Fall back to loading from disk
|
||||
raise ImportError("UA_POOL not in app config")
|
||||
except (ImportError, RuntimeError):
|
||||
# No Flask context available or UA_POOL not in config, load from disk
|
||||
config_path = os.environ.get('CONFIG_VOLUME',
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'static', 'config'))
|
||||
cache_path = os.path.join(config_path, 'ua_cache.json')
|
||||
ua_pool = load_ua_pool(cache_path, count=10)
|
||||
|
||||
return get_random_ua(ua_pool)
|
||||
except Exception as e:
|
||||
# If anything goes wrong, fall back to default Opera UA
|
||||
print(f"Warning: Could not load UA pool, using fallback Opera UA: {e}")
|
||||
return DEFAULT_FALLBACK_UA
|
||||
|
||||
# Fallback for backwards compatibility (old configs or invalid user_agent values)
|
||||
return DEFAULT_FALLBACK_UA
|
||||
|
||||
|
||||
def gen_query(query, args, config) -> str:
|
||||
param_dict = {key: '' for key in VALID_PARAMS}
|
||||
|
||||
# Use :past(hour/day/week/month/year) if available
|
||||
# example search "new restaurants :past month"
|
||||
sub_lang = ''
|
||||
lang = ''
|
||||
if ':past' in query and 'tbs' not in args:
|
||||
time_range = str.strip(query.split(':past', 1)[-1])
|
||||
param_dict['tbs'] = '&tbs=' + ('qdr:' + str.lower(time_range[0]))
|
||||
elif 'tbs' in args:
|
||||
result_tbs = args.get('tbs')
|
||||
elif 'tbs' in args or 'tbs' in config:
|
||||
result_tbs = args.get('tbs') if 'tbs' in args else config['tbs']
|
||||
param_dict['tbs'] = '&tbs=' + result_tbs
|
||||
|
||||
# Occasionally the 'tbs' param provided by google also contains a field for 'lr', but formatted
|
||||
# strangely. This is a (admittedly not very elegant) solution for this.
|
||||
# Ex/ &tbs=qdr:h,lr:lang_1pl --> the lr param needs to be extracted and have the "1" digit removed in this case
|
||||
sub_lang = [_ for _ in result_tbs.split(',') if 'lr:' in _]
|
||||
sub_lang = sub_lang[0][sub_lang[0].find('lr:') + 3:len(sub_lang[0])] if len(sub_lang) > 0 else ''
|
||||
# Occasionally the 'tbs' param provided by google also contains a
|
||||
# field for 'lr', but formatted strangely. This is a rough solution
|
||||
# for this.
|
||||
#
|
||||
# Example:
|
||||
# &tbs=qdr:h,lr:lang_1pl
|
||||
# -- the lr param needs to be extracted and remove the leading '1'
|
||||
result_params = [_ for _ in result_tbs.split(',') if 'lr:' in _]
|
||||
if len(result_params) > 0:
|
||||
result_param = result_params[0]
|
||||
lang = result_param[result_param.find('lr:') + 3:len(result_param)]
|
||||
|
||||
# Ensure search query is parsable
|
||||
query = urlparse.quote(query)
|
||||
@ -51,31 +147,56 @@ def gen_query(query, args, config, near_city=None):
|
||||
# Pass along type of results (news, images, books, etc)
|
||||
if 'tbm' in args:
|
||||
param_dict['tbm'] = '&tbm=' + args.get('tbm')
|
||||
# Google Images now expects the modern udm=2 layout; force it when
|
||||
# requesting images to avoid redirects to the new AI/text layout.
|
||||
if args.get('tbm') == 'isch' and 'udm' not in args:
|
||||
param_dict['udm'] = '&udm=2'
|
||||
|
||||
# Get results page start value (10 per page, ie page 2 start val = 20)
|
||||
if 'start' in args:
|
||||
param_dict['start'] = '&start=' + args.get('start')
|
||||
|
||||
# Search for results near a particular city, if available
|
||||
if near_city:
|
||||
param_dict['near'] = '&near=' + urlparse.quote(near_city)
|
||||
if config.near:
|
||||
param_dict['near'] = '&near=' + urlparse.quote(config.near)
|
||||
|
||||
# Set language for results (lr) if source isn't set, otherwise use the result
|
||||
# language param provided by google (but with the strange digit(s) removed)
|
||||
# Set language for results (lr) if source isn't set, otherwise use the
|
||||
# result language param provided in the results
|
||||
if 'source' in args:
|
||||
param_dict['source'] = '&source=' + args.get('source')
|
||||
param_dict['lr'] = ('&lr=' + ''.join([_ for _ in sub_lang if not _.isdigit()])) if sub_lang else ''
|
||||
param_dict['lr'] = ('&lr=' + ''.join(
|
||||
[_ for _ in lang if not _.isdigit()]
|
||||
)) if lang else ''
|
||||
else:
|
||||
param_dict['lr'] = ('&lr=' + config.lang_search) if config.lang_search else ''
|
||||
param_dict['lr'] = (
|
||||
'&lr=' + config.lang_search
|
||||
) if config.lang_search else ''
|
||||
|
||||
# Set autocorrected search ignore
|
||||
# 'nfpr' defines the exclusion of results from an auto-corrected query
|
||||
if 'nfpr' in args:
|
||||
param_dict['nfpr'] = '&nfpr=' + args.get('nfpr')
|
||||
|
||||
param_dict['cr'] = ('&cr=' + config.ctry) if config.ctry else ''
|
||||
param_dict['hl'] = ('&hl=' + config.lang_interface.replace('lang_', '')) if config.lang_interface 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'] = (
|
||||
'&hl=' + config.lang_interface.replace('lang_', '')
|
||||
) if config.lang_interface else ''
|
||||
param_dict['safe'] = '&safe=' + ('active' if config.safe else 'off')
|
||||
|
||||
# Block all sites specified in the user config
|
||||
unquoted_query = urlparse.unquote(query)
|
||||
for blocked_site in config.block.replace(' ', '').split(','):
|
||||
if not blocked_site:
|
||||
continue
|
||||
block = (' -site:' + blocked_site)
|
||||
query += block if block not in unquoted_query else ''
|
||||
|
||||
for val in param_dict.values():
|
||||
if not val:
|
||||
continue
|
||||
@ -85,27 +206,238 @@ def gen_query(query, args, config, near_city=None):
|
||||
|
||||
|
||||
class Request:
|
||||
def __init__(self, normal_ua, language='lang_en'):
|
||||
self.language = language
|
||||
self.mobile = 'Android' in normal_ua or 'iPhone' in normal_ua
|
||||
self.modified_user_agent = gen_user_agent(self.mobile)
|
||||
"""Class used for handling all outbound requests, including search queries,
|
||||
search suggestions, and loading of external content (images, audio, etc).
|
||||
|
||||
Attributes:
|
||||
normal_ua: the user's current user agent
|
||||
root_path: the root path of the whoogle instance
|
||||
config: the user's current whoogle configuration
|
||||
"""
|
||||
|
||||
def __init__(self, normal_ua, root_path, config: Config, http_client=None):
|
||||
self.search_url = 'https://www.google.com/search?gbv=1&q='
|
||||
# Google Images rejects the lightweight gbv=1 interface. Use the
|
||||
# modern udm=2 entrypoint specifically for image searches to avoid the
|
||||
# "update your browser" interstitial.
|
||||
self.image_search_url = 'https://www.google.com/search?udm=2&q='
|
||||
# Optionally send heartbeat to Tor to determine availability
|
||||
# Only when Tor is enabled in config to avoid unnecessary socket usage
|
||||
if config.tor:
|
||||
send_tor_signal(Signal.HEARTBEAT)
|
||||
|
||||
self.language = config.lang_search if config.lang_search else ''
|
||||
self.country = config.country if config.country else ''
|
||||
|
||||
# For setting Accept-language Header
|
||||
self.lang_interface = ''
|
||||
if config.accept_language:
|
||||
self.lang_interface = config.lang_interface
|
||||
|
||||
self.mobile = bool(normal_ua) and ('Android' in normal_ua
|
||||
or 'iPhone' in normal_ua)
|
||||
|
||||
# Generate user agent based on config
|
||||
self.modified_user_agent = gen_user_agent(config, self.mobile)
|
||||
if not self.mobile:
|
||||
self.modified_user_agent_mobile = gen_user_agent(config, True)
|
||||
|
||||
# Dedicated modern UA to use when Google rejects legacy ones (e.g. Images)
|
||||
self.image_user_agent = (
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/127.0.0.0 Safari/537.36'
|
||||
)
|
||||
|
||||
# Set up proxy configuration
|
||||
proxy_path = os.environ.get('WHOOGLE_PROXY_LOC', '')
|
||||
if proxy_path:
|
||||
proxy_type = os.environ.get('WHOOGLE_PROXY_TYPE', '')
|
||||
proxy_user = os.environ.get('WHOOGLE_PROXY_USER', '')
|
||||
proxy_pass = os.environ.get('WHOOGLE_PROXY_PASS', '')
|
||||
auth_str = ''
|
||||
if proxy_user:
|
||||
auth_str = f'{proxy_user}:{proxy_pass}@'
|
||||
|
||||
proxy_str = f'{proxy_type}://{auth_str}{proxy_path}'
|
||||
self.proxies = {
|
||||
'https': proxy_str,
|
||||
'http': proxy_str
|
||||
}
|
||||
else:
|
||||
self.proxies = {
|
||||
'http': 'socks5://127.0.0.1:9050',
|
||||
'https': 'socks5://127.0.0.1:9050'
|
||||
} if config.tor else {}
|
||||
|
||||
self.tor = config.tor
|
||||
self.tor_valid = False
|
||||
self.root_path = root_path
|
||||
# Initialize HTTP client (shared per proxies)
|
||||
self.http_client = http_client or get_http_client(self.proxies)
|
||||
|
||||
def __getitem__(self, name):
|
||||
return getattr(self, name)
|
||||
|
||||
def autocomplete(self, query):
|
||||
ac_query = dict(hl=self.language, q=query)
|
||||
response = self.send(base_url=AUTOCOMPLETE_URL, query=urlparse.urlencode(ac_query)).text
|
||||
def autocomplete(self, query) -> list:
|
||||
"""Sends a query to Google's search suggestion service
|
||||
|
||||
if response:
|
||||
dom = etree.fromstring(response)
|
||||
return dom.xpath('//suggestion/@data')
|
||||
Args:
|
||||
query: The in-progress query to send
|
||||
|
||||
return []
|
||||
Returns:
|
||||
list: The list of matches for possible search suggestions
|
||||
|
||||
"""
|
||||
# Check if autocomplete is disabled via environment variable
|
||||
if os.environ.get('WHOOGLE_AUTOCOMPLETE', '1') == '0':
|
||||
return []
|
||||
|
||||
try:
|
||||
ac_query = dict(q=query)
|
||||
if self.language:
|
||||
ac_query['lr'] = self.language
|
||||
if self.country:
|
||||
ac_query['gl'] = self.country
|
||||
if self.lang_interface:
|
||||
ac_query['hl'] = self.lang_interface
|
||||
|
||||
response = self.send(base_url=AUTOCOMPLETE_URL,
|
||||
query=urlparse.urlencode(ac_query)).text
|
||||
|
||||
if not response:
|
||||
return []
|
||||
|
||||
try:
|
||||
root = ET.fromstring(response)
|
||||
return [_.attrib['data'] for _ in
|
||||
root.findall('.//suggestion/[@data]')]
|
||||
except ET.ParseError:
|
||||
# Malformed XML response
|
||||
return []
|
||||
except Exception as e:
|
||||
# Log the error but don't crash - autocomplete is non-essential
|
||||
print(f"Autocomplete error: {str(e)}")
|
||||
return []
|
||||
|
||||
def send(self, base_url='', query='', attempt=0,
|
||||
force_mobile=False, user_agent=''):
|
||||
"""Sends an outbound request to a URL. Optionally sends the request
|
||||
using Tor, if enabled by the user.
|
||||
|
||||
Args:
|
||||
base_url: The URL to use in the request
|
||||
query: The optional query string for the request
|
||||
attempt: The number of attempts made for the request
|
||||
(used for cycling through Tor identities, if enabled)
|
||||
force_mobile: Optional flag to enable a mobile user agent
|
||||
(used for fetching full size images in search results)
|
||||
|
||||
Returns:
|
||||
Response: The Response object returned by the requests call
|
||||
|
||||
"""
|
||||
use_client_user_agent = int(os.environ.get('WHOOGLE_USE_CLIENT_USER_AGENT', '0'))
|
||||
if user_agent and use_client_user_agent == 1:
|
||||
modified_user_agent = user_agent
|
||||
else:
|
||||
if force_mobile and not self.mobile:
|
||||
modified_user_agent = self.modified_user_agent_mobile
|
||||
else:
|
||||
modified_user_agent = self.modified_user_agent
|
||||
|
||||
# Some Google endpoints (notably Images) now refuse legacy user agents.
|
||||
# If an image search is detected and the generated UA isn't Chromium-
|
||||
# like, retry with a modern Chrome string to avoid the "update your
|
||||
# browser" interstitial.
|
||||
if (('tbm=isch' in query) or ('udm=2' in query)) and 'Chrome' not in modified_user_agent:
|
||||
modified_user_agent = self.image_user_agent
|
||||
|
||||
def send(self, base_url=SEARCH_URL, query='') -> Response:
|
||||
headers = {
|
||||
'User-Agent': self.modified_user_agent
|
||||
'User-Agent': modified_user_agent,
|
||||
'Accept': ('text/html,application/xhtml+xml,application/xml;'
|
||||
'q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8'),
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Cache-Control': 'max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Sec-Fetch-Dest': 'document'
|
||||
}
|
||||
# Only attach client hints when using a Chromium-like user agent to
|
||||
# avoid sending conflicting information that can trigger unsupported
|
||||
# browser pages.
|
||||
if 'Chrome' in headers['User-Agent']:
|
||||
headers.update({
|
||||
'Sec-CH-UA': (
|
||||
'"Not/A)Brand";v="8", '
|
||||
'"Chromium";v="127", '
|
||||
'"Google Chrome";v="127"'
|
||||
),
|
||||
'Sec-CH-UA-Mobile': '?0',
|
||||
'Sec-CH-UA-Platform': '"Windows"'
|
||||
})
|
||||
|
||||
|
||||
# Add Accept-Language header tied to the current config if requested
|
||||
if self.lang_interface:
|
||||
headers['Accept-Language'] = (
|
||||
self.lang_interface.replace('lang_', '') + ';q=1.0'
|
||||
)
|
||||
|
||||
# Consent cookies keep Google from showing the interstitial consent wall
|
||||
consent_cookies = {
|
||||
'CONSENT': 'PENDING+987',
|
||||
'SOCS': 'CAESHAgBEhIaAB'
|
||||
}
|
||||
|
||||
return requests.get(base_url + query, headers=headers)
|
||||
# Validate Tor conn and request new identity if the last one failed
|
||||
if self.tor and not send_tor_signal(
|
||||
Signal.NEWNYM if attempt > 0 else Signal.HEARTBEAT):
|
||||
raise TorError(
|
||||
"Tor was previously enabled, but the connection has been "
|
||||
"dropped. Please check your Tor configuration and try again.",
|
||||
disable=True)
|
||||
|
||||
# Make sure that the tor connection is valid, if enabled
|
||||
if self.tor:
|
||||
try:
|
||||
tor_check = self.http_client.get('https://check.torproject.org/',
|
||||
headers=headers,
|
||||
retries=1)
|
||||
self.tor_valid = 'Congratulations' in tor_check.text
|
||||
|
||||
if not self.tor_valid:
|
||||
raise TorError(
|
||||
"Tor connection succeeded, but the connection could "
|
||||
"not be validated by torproject.org",
|
||||
disable=True)
|
||||
except httpx.RequestError:
|
||||
raise TorError(
|
||||
"Error raised during Tor connection validation",
|
||||
disable=True)
|
||||
|
||||
search_base = base_url or self.search_url
|
||||
if not base_url and ('tbm=isch' in query or 'udm=2' in query):
|
||||
search_base = self.image_search_url
|
||||
|
||||
try:
|
||||
response = self.http_client.get(
|
||||
search_base + query,
|
||||
headers=headers,
|
||||
cookies=consent_cookies)
|
||||
except httpx.HTTPError as e:
|
||||
raise
|
||||
|
||||
# Retry query with new identity if using Tor (max 10 attempts)
|
||||
if 'form id="captcha-form"' in response.text and self.tor:
|
||||
attempt += 1
|
||||
if attempt > 10:
|
||||
raise TorError("Tor query failed -- max attempts exceeded 10")
|
||||
return self.send(search_base, query, attempt)
|
||||
|
||||
return response
|
||||
|
||||
918
app/routes.py
2
app/services/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
452
app/services/cse_client.py
Normal file
@ -0,0 +1,452 @@
|
||||
"""Google Custom Search Engine (CSE) API Client
|
||||
|
||||
This module provides a client for Google's Custom Search JSON API,
|
||||
allowing users to bring their own API key (BYOK) for search functionality.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import render_template
|
||||
|
||||
|
||||
# Google Custom Search API endpoint
|
||||
CSE_API_URL = 'https://www.googleapis.com/customsearch/v1'
|
||||
|
||||
|
||||
class CSEException(Exception):
|
||||
"""Exception raised for CSE API errors"""
|
||||
def __init__(self, message: str, code: int = 500, is_quota_error: bool = False):
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.is_quota_error = is_quota_error
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSEError:
|
||||
"""Represents an error from the CSE API"""
|
||||
code: int
|
||||
message: str
|
||||
|
||||
@property
|
||||
def is_quota_exceeded(self) -> bool:
|
||||
return self.code == 429 or 'quota' in self.message.lower()
|
||||
|
||||
@property
|
||||
def is_invalid_key(self) -> bool:
|
||||
return self.code == 400 or 'invalid' in self.message.lower()
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSEResult:
|
||||
"""Represents a single search result from CSE API"""
|
||||
title: str
|
||||
link: str
|
||||
snippet: str
|
||||
display_link: str
|
||||
html_title: Optional[str] = None
|
||||
html_snippet: Optional[str] = None
|
||||
# Image-specific fields (populated for image search)
|
||||
image_url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None
|
||||
image_width: Optional[int] = None
|
||||
image_height: Optional[int] = None
|
||||
context_link: Optional[str] = None # Page where image was found
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSEResponse:
|
||||
"""Represents a complete CSE API response"""
|
||||
results: list[CSEResult]
|
||||
total_results: str
|
||||
search_time: float
|
||||
query: str
|
||||
start_index: int
|
||||
is_image_search: bool = False
|
||||
error: Optional[CSEError] = None
|
||||
|
||||
@property
|
||||
def has_error(self) -> bool:
|
||||
return self.error is not None
|
||||
|
||||
@property
|
||||
def has_results(self) -> bool:
|
||||
return len(self.results) > 0
|
||||
|
||||
|
||||
class CSEClient:
|
||||
"""Client for Google Custom Search Engine API
|
||||
|
||||
Usage:
|
||||
client = CSEClient(api_key='your-key', cse_id='your-cse-id')
|
||||
response = client.search('python programming')
|
||||
|
||||
if response.has_error:
|
||||
print(f"Error: {response.error.message}")
|
||||
else:
|
||||
for result in response.results:
|
||||
print(f"{result.title}: {result.link}")
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, cse_id: str, timeout: float = 10.0):
|
||||
"""Initialize CSE client
|
||||
|
||||
Args:
|
||||
api_key: Google API key with Custom Search API enabled
|
||||
cse_id: Custom Search Engine ID (cx parameter)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.cse_id = cse_id
|
||||
self.timeout = timeout
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
start: int = 1,
|
||||
num: int = 10,
|
||||
safe: str = 'off',
|
||||
language: str = '',
|
||||
country: str = '',
|
||||
search_type: str = ''
|
||||
) -> CSEResponse:
|
||||
"""Execute a search query against the CSE API
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
start: Starting result index (1-based, for pagination)
|
||||
num: Number of results to return (max 10)
|
||||
safe: Safe search setting ('off', 'medium', 'high')
|
||||
language: Language restriction (e.g., 'lang_en')
|
||||
country: Country restriction (e.g., 'countryUS')
|
||||
search_type: Type of search ('image' for image search, '' for web)
|
||||
|
||||
Returns:
|
||||
CSEResponse with results or error information
|
||||
"""
|
||||
params = {
|
||||
'key': self.api_key,
|
||||
'cx': self.cse_id,
|
||||
'q': query,
|
||||
'start': start,
|
||||
'num': min(num, 10), # API max is 10
|
||||
'safe': safe,
|
||||
}
|
||||
|
||||
# Add search type for image search
|
||||
if search_type == 'image':
|
||||
params['searchType'] = 'image'
|
||||
|
||||
# Add optional parameters
|
||||
if language:
|
||||
# CSE uses 'lr' for language restrict
|
||||
params['lr'] = language
|
||||
if country:
|
||||
# CSE uses 'cr' for country restrict
|
||||
params['cr'] = country
|
||||
|
||||
try:
|
||||
response = self._client.get(CSE_API_URL, params=params)
|
||||
data = response.json()
|
||||
|
||||
# Check for API errors
|
||||
if 'error' in data:
|
||||
error_info = data['error']
|
||||
return CSEResponse(
|
||||
results=[],
|
||||
total_results='0',
|
||||
search_time=0.0,
|
||||
query=query,
|
||||
start_index=start,
|
||||
error=CSEError(
|
||||
code=error_info.get('code', 500),
|
||||
message=error_info.get('message', 'Unknown error')
|
||||
)
|
||||
)
|
||||
|
||||
# Parse successful response
|
||||
search_info = data.get('searchInformation', {})
|
||||
items = data.get('items', [])
|
||||
is_image = search_type == 'image'
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
# Extract image-specific data if present
|
||||
image_data = item.get('image', {})
|
||||
|
||||
results.append(CSEResult(
|
||||
title=item.get('title', ''),
|
||||
link=item.get('link', ''),
|
||||
snippet=item.get('snippet', ''),
|
||||
display_link=item.get('displayLink', ''),
|
||||
html_title=item.get('htmlTitle'),
|
||||
html_snippet=item.get('htmlSnippet'),
|
||||
# Image fields
|
||||
image_url=item.get('link') if is_image else None,
|
||||
thumbnail_url=image_data.get('thumbnailLink'),
|
||||
image_width=image_data.get('width'),
|
||||
image_height=image_data.get('height'),
|
||||
context_link=image_data.get('contextLink')
|
||||
))
|
||||
|
||||
return CSEResponse(
|
||||
results=results,
|
||||
total_results=search_info.get('totalResults', '0'),
|
||||
search_time=float(search_info.get('searchTime', 0)),
|
||||
query=query,
|
||||
start_index=start,
|
||||
is_image_search=is_image
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return CSEResponse(
|
||||
results=[],
|
||||
total_results='0',
|
||||
search_time=0.0,
|
||||
query=query,
|
||||
start_index=start,
|
||||
error=CSEError(code=408, message='Request timed out')
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return CSEResponse(
|
||||
results=[],
|
||||
total_results='0',
|
||||
search_time=0.0,
|
||||
query=query,
|
||||
start_index=start,
|
||||
error=CSEError(code=500, message=f'Request failed: {str(e)}')
|
||||
)
|
||||
except Exception as e:
|
||||
return CSEResponse(
|
||||
results=[],
|
||||
total_results='0',
|
||||
search_time=0.0,
|
||||
query=query,
|
||||
start_index=start,
|
||||
error=CSEError(code=500, message=f'Unexpected error: {str(e)}')
|
||||
)
|
||||
|
||||
def close(self):
|
||||
"""Close the HTTP client"""
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
def cse_results_to_html(response: CSEResponse, query: str) -> str:
|
||||
"""Convert CSE API response to HTML matching Whoogle's result format
|
||||
|
||||
This generates HTML that mimics the structure expected by Whoogle's
|
||||
existing filter and result processing pipeline.
|
||||
|
||||
Args:
|
||||
response: CSEResponse from the API
|
||||
query: Original search query
|
||||
|
||||
Returns:
|
||||
HTML string formatted like Google search results
|
||||
"""
|
||||
if response.has_error:
|
||||
error = response.error
|
||||
if error.is_quota_exceeded:
|
||||
return _error_html(
|
||||
'API Quota Exceeded',
|
||||
'Your Google Custom Search API quota has been exceeded. '
|
||||
'Free tier allows 100 queries/day. Wait until midnight PT '
|
||||
'or enable billing in Google Cloud Console.'
|
||||
)
|
||||
elif error.is_invalid_key:
|
||||
return _error_html(
|
||||
'Invalid API Key',
|
||||
'Your Google Custom Search API key is invalid. '
|
||||
'Please check your API key and CSE ID in settings.'
|
||||
)
|
||||
else:
|
||||
return _error_html('Search Error', error.message)
|
||||
|
||||
if not response.has_results:
|
||||
return _no_results_html(query)
|
||||
|
||||
# Use different HTML structure for image vs web results
|
||||
if response.is_image_search:
|
||||
return _image_results_html(response, query)
|
||||
|
||||
# Build HTML results matching Whoogle's expected structure
|
||||
results_html = []
|
||||
|
||||
for result in response.results:
|
||||
# Escape HTML in content
|
||||
title = _escape_html(result.title)
|
||||
snippet = _escape_html(result.snippet)
|
||||
link = result.link
|
||||
display_link = _escape_html(result.display_link)
|
||||
|
||||
# Use HTML versions if available (they have bold tags for query terms)
|
||||
if result.html_title:
|
||||
title = result.html_title
|
||||
if result.html_snippet:
|
||||
snippet = result.html_snippet
|
||||
|
||||
# Match the structure used by Google/mock results
|
||||
result_html = f'''
|
||||
<div class="ZINbbc xpd O9g5cc uUPGi">
|
||||
<div class="kCrYT">
|
||||
<a href="{link}">
|
||||
<h3 class="BNeawe vvjwJb AP7Wnd">{title}</h3>
|
||||
<div class="BNeawe UPmit AP7Wnd luh4tb" style="color: var(--whoogle-result-url);">{display_link}</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="kCrYT">
|
||||
<div class="BNeawe s3v9rd AP7Wnd">
|
||||
<span class="VwiC3b">{snippet}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
results_html.append(result_html)
|
||||
|
||||
# Build pagination if needed
|
||||
pagination_html = ''
|
||||
if int(response.total_results) > 10:
|
||||
pagination_html = _pagination_html(response.start_index, response.query)
|
||||
|
||||
# Wrap in expected structure
|
||||
# Add data-cse attribute to prevent collapse_sections from collapsing these results
|
||||
return f'''
|
||||
<html>
|
||||
<body>
|
||||
<div id="main" data-cse="true">
|
||||
<div id="cnt">
|
||||
<div id="rcnt">
|
||||
<div id="center_col">
|
||||
<div id="res">
|
||||
<div id="search">
|
||||
<div id="rso">
|
||||
{''.join(results_html)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pagination_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Escape HTML special characters"""
|
||||
if not text:
|
||||
return ''
|
||||
return (text
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace("'", '''))
|
||||
|
||||
|
||||
def _error_html(title: str, message: str) -> str:
|
||||
"""Generate error HTML"""
|
||||
return f'''
|
||||
<html>
|
||||
<body>
|
||||
<div id="main">
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<h2 style="color: #d93025;">{_escape_html(title)}</h2>
|
||||
<p>{_escape_html(message)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
def _no_results_html(query: str) -> str:
|
||||
"""Generate no results HTML"""
|
||||
return f'''
|
||||
<html>
|
||||
<body>
|
||||
<div id="main">
|
||||
<div style="padding: 20px;">
|
||||
<p>No results found for <b>{_escape_html(query)}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
def _image_results_html(response: CSEResponse, query: str) -> str:
|
||||
"""Generate HTML for image search results using the imageresults template
|
||||
|
||||
Args:
|
||||
response: CSEResponse with image results
|
||||
query: Original search query
|
||||
|
||||
Returns:
|
||||
HTML string formatted for image results display
|
||||
"""
|
||||
# Convert CSE results to the format expected by imageresults.html template
|
||||
results = []
|
||||
for result in response.results:
|
||||
image_url = result.image_url or result.link
|
||||
thumbnail_url = result.thumbnail_url or image_url
|
||||
web_page = result.context_link or result.link
|
||||
domain = urlparse(web_page).netloc if web_page else result.display_link
|
||||
|
||||
results.append({
|
||||
'domain': domain,
|
||||
'img_url': image_url,
|
||||
'web_page': web_page,
|
||||
'img_tbn': thumbnail_url
|
||||
})
|
||||
|
||||
# Build pagination link if needed
|
||||
next_link = None
|
||||
if int(response.total_results) > response.start_index + len(response.results) - 1:
|
||||
next_start = response.start_index + 10
|
||||
next_link = f'search?q={query}&tbm=isch&start={next_start}'
|
||||
|
||||
# Use the same template as regular image results
|
||||
return render_template(
|
||||
'imageresults.html',
|
||||
length=len(results),
|
||||
results=results,
|
||||
view_label="View Image",
|
||||
next_link=next_link
|
||||
)
|
||||
|
||||
|
||||
def _pagination_html(current_start: int, query: str) -> str:
|
||||
"""Generate pagination links"""
|
||||
# CSE API uses 1-based indexing, 10 results per page
|
||||
current_page = (current_start - 1) // 10 + 1
|
||||
|
||||
prev_link = ''
|
||||
next_link = ''
|
||||
|
||||
if current_page > 1:
|
||||
prev_start = (current_page - 2) * 10 + 1
|
||||
prev_link = f'<a href="search?q={query}&start={prev_start}">Previous</a>'
|
||||
|
||||
next_start = current_page * 10 + 1
|
||||
next_link = f'<a href="search?q={query}&start={next_start}">Next</a>'
|
||||
|
||||
return f'''
|
||||
<div id="foot" style="text-align: center; padding: 20px;">
|
||||
{prev_link}
|
||||
<span style="margin: 0 20px;">Page {current_page}</span>
|
||||
{next_link}
|
||||
</div>
|
||||
'''
|
||||
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
@ -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
@ -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
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@ -1,42 +1,222 @@
|
||||
html {
|
||||
background-color: #000 !important;
|
||||
background: var(--whoogle-dark-page-bg) !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #222 !important;
|
||||
background: var(--whoogle-dark-page-bg) !important;
|
||||
}
|
||||
|
||||
div {
|
||||
/*background-color: #111 !important;*/
|
||||
color: #fff !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
a:visited h3 div {
|
||||
color: #bbbbff !important;
|
||||
label {
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
a:link h3 div {
|
||||
color: #4b8eea !important;
|
||||
li a {
|
||||
color: var(--whoogle-dark-result-url) !important;
|
||||
}
|
||||
|
||||
a:link div {
|
||||
color: #aaffaa !important;
|
||||
li {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.anon-view {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: var(--whoogle-dark-page-bg) !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
a:visited h3 div, a:visited .qXLe6d {
|
||||
color: var(--whoogle-dark-result-visited) !important;
|
||||
}
|
||||
|
||||
a:link h3 div, a:link .qXLe6d {
|
||||
color: var(--whoogle-dark-result-title) !important;
|
||||
}
|
||||
|
||||
a:link div, a:link .fYyStc {
|
||||
color: var(--whoogle-dark-result-url) !important;
|
||||
}
|
||||
|
||||
div span {
|
||||
color: #bbb !important;
|
||||
color: var(--whoogle-dark-secondary-text) !important;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: #111 !important;
|
||||
color: #fff !important;
|
||||
background-color: var(--whoogle-dark-page-bg) !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
#search-bar {
|
||||
color: #fff !important;
|
||||
background-color: #000 !important;
|
||||
select {
|
||||
background: var(--whoogle-dark-page-bg) !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
background-color: #000 !important;
|
||||
background-color: var(--whoogle-dark-page-bg) !important;
|
||||
}
|
||||
|
||||
.ZINbbc, .ezO2md {
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 0 !important;
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
margin-bottom: 10px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.BsXmcf {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.KP7LCb {
|
||||
box-shadow: 0 0 0 0 !important;
|
||||
}
|
||||
|
||||
.BVG0Nb {
|
||||
box-shadow: 0 0 0 0 !important;
|
||||
background-color: var(--whoogle-dark-page-bg) !important;
|
||||
}
|
||||
|
||||
.ZINbbc.luh4tb {
|
||||
background: var(--whoogle-dark-result-bg) !important;
|
||||
margin-bottom: 24px !important;
|
||||
}
|
||||
|
||||
.bRsWnc {
|
||||
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 {
|
||||
border-color: var(--whoogle-dark-element-bg) !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
border-bottom: 2px solid var(--whoogle-dark-element-bg);
|
||||
}
|
||||
|
||||
#search-bar:focus {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
#search-submit {
|
||||
border: 1px solid var(--whoogle-dark-element-bg) !important;
|
||||
background: var(--whoogle-dark-element-bg) !important;
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
opacity: 75%;
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.collapsible:after {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: var(--whoogle-dark-element-bg) !important;
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.content, .result-config {
|
||||
background-color: var(--whoogle-dark-element-bg) !important;
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
.active:after {
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--whoogle-dark-contrast-text);
|
||||
}
|
||||
|
||||
.link-color {
|
||||
color: var(--whoogle-dark-result-url) !important;
|
||||
}
|
||||
|
||||
.autocomplete-items {
|
||||
border: 1px solid var(--whoogle-dark-element-bg);
|
||||
}
|
||||
|
||||
.autocomplete-items div {
|
||||
color: var(--whoogle-dark-text);
|
||||
background-color: var(--whoogle-dark-page-bg);
|
||||
border-bottom: 1px solid var(--whoogle-dark-element-bg);
|
||||
}
|
||||
|
||||
.autocomplete-items div:hover {
|
||||
background-color: var(--whoogle-dark-element-bg);
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.autocomplete-active {
|
||||
background-color: var(--whoogle-dark-element-bg) !important;
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: var(--whoogle-dark-text);
|
||||
}
|
||||
|
||||
path {
|
||||
fill: var(--whoogle-dark-logo);
|
||||
}
|
||||
|
||||
.header-div {
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
}
|
||||
|
||||
#search-reset {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.mobile-search-bar {
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.search-bar-desktop {
|
||||
color: var(--whoogle-dark-text) !important;
|
||||
}
|
||||
|
||||
.ip-text-div, .update_available, .cb_label, .cb {
|
||||
color: var(--whoogle-dark-secondary-text) !important;
|
||||
}
|
||||
|
||||
.cb:focus {
|
||||
color: var(--whoogle-dark-contrast-text) !important;
|
||||
}
|
||||
|
||||
.desktop-header, .mobile-header {
|
||||
background-color: var(--whoogle-dark-result-bg) !important;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.result-config {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mobile-logo {
|
||||
font: 22px/36px Futura, Arial, sans-serif;
|
||||
padding-left: 5px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-div {
|
||||
@ -27,6 +36,11 @@ header {
|
||||
font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.search-bar-desktop {
|
||||
border-radius: 8px 8px 0 0;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.search-div {
|
||||
border-radius: 8px 8px 0 0;
|
||||
box-shadow: 0 1px 6px rgba(32, 33, 36, 0.18);
|
||||
@ -37,6 +51,7 @@ header {
|
||||
height: 39px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@ -52,4 +67,184 @@ header {
|
||||
width: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0,0,0,0);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.tracking-link {
|
||||
font-size: large;
|
||||
text-align: center;
|
||||
margin: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#main>div:focus-within {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 6px 1px #2375e8;
|
||||
}
|
||||
|
||||
#mobile-header-logo {
|
||||
height: 1.75em;
|
||||
}
|
||||
|
||||
.mobile-input-div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-search-bar {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
padding: 0 0 0 8px;
|
||||
padding-right: 0px;
|
||||
-webkit-box-flex: 1;
|
||||
height: 35px;
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0,0,0,.00);
|
||||
overflow: hidden;
|
||||
border: 0px !important;
|
||||
}
|
||||
|
||||
.autocomplete-mobile{
|
||||
display: -webkit-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desktop-header-logo {
|
||||
height: 1.65em;
|
||||
}
|
||||
|
||||
.header-autocomplete {
|
||||
width: 100%;
|
||||
flex: 1
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1967D2;
|
||||
text-decoration: none;
|
||||
tap-highlight-color: rgba(0, 0, 0, .10);
|
||||
}
|
||||
|
||||
.header-tab-div {
|
||||
border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 2px 3px rgba(32, 33, 36, 0.18);
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-tab-div-2 {
|
||||
border-top: 1px solid #dadce0;
|
||||
height: 39px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-tab-div-3 {
|
||||
height: 51px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.desktop-header {
|
||||
height: 39px;
|
||||
display: box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-tab {
|
||||
box-pack: justify;
|
||||
font-size: 14px;
|
||||
line-height: 37px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.desktop-header a, .desktop-header span {
|
||||
color: #70757a;
|
||||
display: block;
|
||||
flex: none;
|
||||
padding: 0 16px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
span.header-tab-span {
|
||||
border-bottom: 2px solid #4285f4;
|
||||
color: #4285f4;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
height: 39px;
|
||||
display: box;
|
||||
display: flex;
|
||||
overflow-x: scroll;
|
||||
width: 100%;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.mobile-header a, .mobile-header span {
|
||||
color: #70757a;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
/* padding: 8px 12px 8px 12px; */
|
||||
}
|
||||
|
||||
span.mobile-tab-span {
|
||||
border-bottom: 2px solid #202124;
|
||||
color: #202124;
|
||||
height: 26px;
|
||||
/* margin: 0 12px; */
|
||||
/* padding: 0; */
|
||||
}
|
||||
|
||||
.desktop-header input {
|
||||
margin: 2px 4px 2px 8px;
|
||||
}
|
||||
|
||||
a.header-tab-a:visited {
|
||||
color: #70757a;
|
||||
}
|
||||
|
||||
.header-tab-div-end {
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.adv-search {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.adv-search:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#adv-search-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-collapsible {
|
||||
max-height: 0px;
|
||||
overflow: hidden;
|
||||
transition: max-height .25s linear;
|
||||
}
|
||||
|
||||
.search-bar-input {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
padding: 0 0 0 8px;
|
||||
flex: 1;
|
||||
height: 35px;
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#result-country {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 801px) {
|
||||
.header-tab-div {
|
||||
margin-bottom: 10px !important
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
209
app/static/css/light-theme.css
Normal file
@ -0,0 +1,209 @@
|
||||
html {
|
||||
background: var(--whoogle-page-bg) !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--whoogle-page-bg) !important;
|
||||
}
|
||||
|
||||
div {
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
li a {
|
||||
color: var(--whoogle-result-url) !important;
|
||||
}
|
||||
|
||||
li {
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.anon-view {
|
||||
color: var(--whoogle-text) !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: var(--whoogle-page-bg) !important;
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--whoogle-page-bg) !important;
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.ZINbbc {
|
||||
overflow: hidden;
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
margin-bottom: 10px !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 1px 6px rgba(32,33,36,0.28) !important;
|
||||
}
|
||||
|
||||
.BsXmcf {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.BVG0Nb {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
|
||||
.ZINbbc.luh4tb {
|
||||
background: var(--whoogle-result-bg) !important;
|
||||
margin-bottom: 24px !important;
|
||||
}
|
||||
|
||||
.bRsWnc {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
a:link div, a:link .qXLe6d {
|
||||
color: var(--whoogle-result-title) !important;
|
||||
}
|
||||
|
||||
a:link div, a:link .fYyStc {
|
||||
color: var(--whoogle-result-url) !important;
|
||||
}
|
||||
|
||||
div span {
|
||||
color: var(--whoogle-secondary-text) !important;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: var(--whoogle-page-bg) !important;
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
#search-bar {
|
||||
color: var(--whoogle-text) !important;
|
||||
background-color: var(--whoogle-page-bg);
|
||||
}
|
||||
|
||||
.home-search {
|
||||
border-color: var(--whoogle-element-bg) !important;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
background-color: var(--whoogle-page-bg) !important;
|
||||
}
|
||||
|
||||
#search-submit {
|
||||
border: 1px solid var(--whoogle-element-bg) !important;
|
||||
background: var(--whoogle-element-bg) !important;
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
opacity: 75%;
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.collapsible:after {
|
||||
color: var(--whoogle-text);
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: var(--whoogle-element-bg) !important;
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
.content, .result-config {
|
||||
background-color: var(--whoogle-element-bg) !important;
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
.active:after {
|
||||
color: var(--whoogle-contrast-text);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--whoogle-element-bg);
|
||||
}
|
||||
|
||||
.link-color {
|
||||
color: var(--whoogle-result-url) !important;
|
||||
}
|
||||
|
||||
.autocomplete-items {
|
||||
border: 1px solid var(--whoogle-element-bg);
|
||||
}
|
||||
|
||||
.autocomplete-items div {
|
||||
background-color: var(--whoogle-page-bg);
|
||||
border-bottom: 1px solid var(--whoogle-element-bg);
|
||||
}
|
||||
|
||||
.autocomplete-items div:hover {
|
||||
background-color: var(--whoogle-element-bg);
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
.autocomplete-active {
|
||||
background-color: var(--whoogle-element-bg) !important;
|
||||
color: var(--whoogle-contrast-text) !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: var(--whoogle-text);
|
||||
}
|
||||
|
||||
path {
|
||||
fill: var(--whoogle-logo);
|
||||
}
|
||||
|
||||
.header-div {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
|
||||
#search-reset {
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.mobile-search-bar {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.search-bar-desktop {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
color: var(--whoogle-text);
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.ip-text-div, .update_available, .cb_label, .cb {
|
||||
color: var(--whoogle-secondary-text) !important;
|
||||
}
|
||||
|
||||
.cb:focus {
|
||||
color: var(--whoogle-text) !important;
|
||||
}
|
||||
|
||||
.desktop-header, .mobile-header {
|
||||
background-color: var(--whoogle-result-bg) !important;
|
||||
}
|
||||
18
app/static/css/logo.css
Normal file
@ -0,0 +1,18 @@
|
||||
.cls-1 {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
svg {
|
||||
margin-top: .3em;
|
||||
height: 70%;
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,17 @@ body {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.home-search {
|
||||
background: transparent !important;
|
||||
border: 3px solid;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
background: transparent !important;
|
||||
width: 80%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@ -26,29 +36,21 @@ body {
|
||||
}
|
||||
|
||||
#search-bar {
|
||||
background: transparent !important;
|
||||
width: 100%;
|
||||
border: 3px solid #685e79;
|
||||
padding: 5px;
|
||||
height: 40px;
|
||||
outline: none;
|
||||
font-size: 24px;
|
||||
color: #685e79;
|
||||
border-radius: 10px 10px 0 0;
|
||||
max-width: 600px;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
#search-bar:focus {
|
||||
color: #685e79;
|
||||
}
|
||||
|
||||
#search-submit {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid #685e79;
|
||||
background: #685e79 !important;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
align-content: center;
|
||||
@ -59,6 +61,15 @@ body {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.config-options {
|
||||
max-height: 370px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.config-buttons {
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
.config-div {
|
||||
padding: 5px;
|
||||
}
|
||||
@ -70,7 +81,6 @@ button::-moz-focus-inner {
|
||||
.collapsible {
|
||||
outline: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: #685e79;
|
||||
cursor: pointer;
|
||||
padding: 18px;
|
||||
width: 100%;
|
||||
@ -81,14 +91,8 @@ button::-moz-focus-inner {
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: #685e79;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.collapsible:after {
|
||||
content: '\002B';
|
||||
color: #685e79;
|
||||
font-weight: bold;
|
||||
float: right;
|
||||
margin-left: 5px;
|
||||
@ -96,7 +100,6 @@ button::-moz-focus-inner {
|
||||
|
||||
.active:after {
|
||||
content: "\2212";
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content {
|
||||
@ -104,8 +107,6 @@ button::-moz-focus-inner {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.2s ease-out;
|
||||
background-color: #685e79;
|
||||
color: white;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
@ -113,12 +114,6 @@ button::-moz-focus-inner {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.ua-span {
|
||||
color: white;
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@ -135,3 +130,61 @@ footer {
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#config-style {
|
||||
resize: none;
|
||||
overflow-y: scroll;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.whoogle-logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.whoogle-svg {
|
||||
width: 80%;
|
||||
height: initial;
|
||||
display: block;
|
||||
margin: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autocomplete-items {
|
||||
position: absolute;
|
||||
border-bottom: none;
|
||||
border-top: none;
|
||||
z-index: 99;
|
||||
|
||||
/*position the autocomplete items to be the same width as the container:*/
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.autocomplete-items div {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details summary {
|
||||
padding: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 1000px) {
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#search-bar {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
.autocomplete {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autocomplete-items {
|
||||
position: absolute;
|
||||
border: 1px solid #685e79;
|
||||
border-bottom: none;
|
||||
border-top: none;
|
||||
z-index: 99;
|
||||
|
||||
/*position the autocomplete items to be the same width as the container:*/
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.autocomplete-items div {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background-color: #000;
|
||||
border-bottom: 1px solid #242424;
|
||||
}
|
||||
|
||||
.autocomplete-items div:hover {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
.autocomplete-active {
|
||||
background-color: #685e79 !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
@ -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 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@ -6,7 +21,6 @@
|
||||
|
||||
.autocomplete-items {
|
||||
position: absolute;
|
||||
border: 1px solid #d4d4d4;
|
||||
border-bottom: none;
|
||||
border-top: none;
|
||||
z-index: 99;
|
||||
@ -20,15 +34,64 @@
|
||||
.autocomplete-items div {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.autocomplete-items div:hover {
|
||||
background-color: #e9e9e9;
|
||||
details summary {
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.autocomplete-active {
|
||||
background-color: #685e79 !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
54
app/static/css/variables.css
Normal file
@ -0,0 +1,54 @@
|
||||
/* Colors */
|
||||
:root {
|
||||
/* LIGHT THEME COLORS */
|
||||
--whoogle-logo: #685e79;
|
||||
--whoogle-page-bg: #ffffff;
|
||||
--whoogle-element-bg: #4285f4;
|
||||
--whoogle-text: #000000;
|
||||
--whoogle-contrast-text: #ffffff;
|
||||
--whoogle-secondary-text: #70757a;
|
||||
--whoogle-result-bg: #ffffff;
|
||||
--whoogle-result-title: #1967d2;
|
||||
--whoogle-result-url: #0d652d;
|
||||
--whoogle-result-visited: #4b11a8;
|
||||
|
||||
/* DARK THEME COLORS */
|
||||
--whoogle-dark-logo: #685e79;
|
||||
--whoogle-dark-page-bg: #101020;
|
||||
--whoogle-dark-element-bg: #4285f4;
|
||||
--whoogle-dark-text: #ffffff;
|
||||
--whoogle-dark-contrast-text: #ffffff;
|
||||
--whoogle-dark-secondary-text: #bbbbbb;
|
||||
--whoogle-dark-result-bg: #212131;
|
||||
--whoogle-dark-result-title: #64a7f6;
|
||||
--whoogle-dark-result-url: #34a853;
|
||||
--whoogle-dark-result-visited: #bbbbff;
|
||||
}
|
||||
|
||||
#whoogle-w {
|
||||
fill: #4285f4;
|
||||
}
|
||||
|
||||
#whoogle-h {
|
||||
fill: #ea4335;
|
||||
}
|
||||
|
||||
#whoogle-o-1 {
|
||||
fill: #fbbc05;
|
||||
}
|
||||
|
||||
#whoogle-o-2 {
|
||||
fill: #4285f4;
|
||||
}
|
||||
|
||||
#whoogle-g {
|
||||
fill: #34a853;
|
||||
}
|
||||
|
||||
#whoogle-l {
|
||||
fill: #ea4335;
|
||||
}
|
||||
|
||||
#whoogle-e {
|
||||
fill: #fbbc05;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 139 KiB |
@ -1,41 +1,44 @@
|
||||
{
|
||||
"name": "App",
|
||||
"name": "Whoogle Search",
|
||||
"short_name": "Whoogle",
|
||||
"display": "fullscreen",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"src": "android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"src": "android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"src": "android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"src": "android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"src": "android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"src": "android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1
app/static/img/whoogle.svg
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
@ -1,6 +1,11 @@
|
||||
const handleUserInput = searchBar => {
|
||||
let searchInput;
|
||||
let currentFocus;
|
||||
let originalSearch;
|
||||
let autocompleteResults;
|
||||
|
||||
const handleUserInput = () => {
|
||||
let xhrRequest = new XMLHttpRequest();
|
||||
xhrRequest.open("POST", "/autocomplete");
|
||||
xhrRequest.open("POST", "autocomplete");
|
||||
xhrRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
||||
xhrRequest.onload = function () {
|
||||
if (xhrRequest.readyState === 4 && xhrRequest.status !== 200) {
|
||||
@ -9,112 +14,114 @@ const handleUserInput = searchBar => {
|
||||
}
|
||||
|
||||
// Fill autocomplete with fetched results
|
||||
let autocompleteResults = JSON.parse(xhrRequest.responseText);
|
||||
autocomplete(searchBar, autocompleteResults[1]);
|
||||
autocompleteResults = JSON.parse(xhrRequest.responseText)[1];
|
||||
updateAutocompleteList();
|
||||
};
|
||||
|
||||
xhrRequest.send('q=' + searchBar.value);
|
||||
xhrRequest.send('q=' + searchInput.value);
|
||||
};
|
||||
|
||||
const autocomplete = (searchInput, autocompleteResults) => {
|
||||
let currentFocus;
|
||||
let originalSearch;
|
||||
const removeActive = suggestion => {
|
||||
// Remove "autocomplete-active" class from previously active suggestion
|
||||
for (let i = 0; i < suggestion.length; i++) {
|
||||
suggestion[i].classList.remove("autocomplete-active");
|
||||
}
|
||||
};
|
||||
|
||||
searchInput.addEventListener("input", function () {
|
||||
let autocompleteList, autocompleteItem, i, val = this.value;
|
||||
closeAllLists();
|
||||
|
||||
if (!val || !autocompleteResults) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentFocus = -1;
|
||||
autocompleteList = document.createElement("div");
|
||||
autocompleteList.setAttribute("id", this.id + "-autocomplete-list");
|
||||
autocompleteList.setAttribute("class", "autocomplete-items");
|
||||
this.parentNode.appendChild(autocompleteList);
|
||||
|
||||
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) {
|
||||
let suggestion = document.getElementById(this.id + "-autocomplete-list");
|
||||
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
|
||||
if (e.keyCode === 40) { // down
|
||||
e.preventDefault();
|
||||
currentFocus++;
|
||||
addActive(suggestion);
|
||||
} else if (e.keyCode === 38) { //up
|
||||
e.preventDefault();
|
||||
currentFocus--;
|
||||
addActive(suggestion);
|
||||
} else if (e.keyCode === 13) { // enter
|
||||
e.preventDefault();
|
||||
if (currentFocus > -1) {
|
||||
if (suggestion) suggestion[currentFocus].click();
|
||||
}
|
||||
const addActive = (suggestion) => {
|
||||
// Handle navigation outside of suggestion list
|
||||
if (!suggestion || !suggestion[currentFocus]) {
|
||||
if (currentFocus >= suggestion.length) {
|
||||
// Move selection back to the beginning
|
||||
currentFocus = 0;
|
||||
} else if (currentFocus < 0) {
|
||||
// Retrieve original search and remove active suggestion selection
|
||||
currentFocus = -1;
|
||||
searchInput.value = originalSearch;
|
||||
removeActive(suggestion);
|
||||
return;
|
||||
} else {
|
||||
originalSearch = document.getElementById("search-bar").value;
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const addActive = suggestion => {
|
||||
let searchBar = document.getElementById("search-bar");
|
||||
removeActive(suggestion);
|
||||
suggestion[currentFocus].classList.add("autocomplete-active");
|
||||
|
||||
// Handle navigation outside of suggestion list
|
||||
if (!suggestion || !suggestion[currentFocus]) {
|
||||
if (currentFocus >= suggestion.length) {
|
||||
// Move selection back to the beginning
|
||||
currentFocus = 0;
|
||||
} else if (currentFocus < 0) {
|
||||
// Retrieve original search and remove active suggestion selection
|
||||
currentFocus = -1;
|
||||
searchBar.value = originalSearch;
|
||||
removeActive(suggestion);
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Autofill search bar with suggestion content (minus the "bang name" if using a bang operator)
|
||||
let searchContent = suggestion[currentFocus].textContent;
|
||||
if (searchContent.indexOf('(') > 0) {
|
||||
searchInput.value = searchContent.substring(0, searchContent.indexOf('('));
|
||||
} else {
|
||||
searchInput.value = searchContent;
|
||||
}
|
||||
|
||||
searchInput.focus();
|
||||
};
|
||||
|
||||
const autocompleteInput = (e) => {
|
||||
// Handle navigation between autocomplete suggestions
|
||||
let suggestion = document.getElementById("autocomplete-list");
|
||||
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
|
||||
if (e.keyCode === 40) { // down
|
||||
e.preventDefault();
|
||||
currentFocus++;
|
||||
addActive(suggestion);
|
||||
} else if (e.keyCode === 38) { //up
|
||||
e.preventDefault();
|
||||
currentFocus--;
|
||||
addActive(suggestion);
|
||||
} else if (e.keyCode === 13) { // enter
|
||||
e.preventDefault();
|
||||
if (currentFocus > -1) {
|
||||
if (suggestion) suggestion[currentFocus].click();
|
||||
}
|
||||
} else {
|
||||
originalSearch = searchInput.value;
|
||||
}
|
||||
};
|
||||
|
||||
removeActive(suggestion);
|
||||
suggestion[currentFocus].classList.add("autocomplete-active");
|
||||
const updateAutocompleteList = () => {
|
||||
let autocompleteItem, i;
|
||||
let val = originalSearch;
|
||||
|
||||
// Autofill search bar with suggestion content
|
||||
searchBar.value = suggestion[currentFocus].textContent;
|
||||
searchBar.focus();
|
||||
};
|
||||
let autocompleteList = document.getElementById("autocomplete-list");
|
||||
autocompleteList.innerHTML = "";
|
||||
|
||||
const removeActive = suggestion => {
|
||||
for (let i = 0; i < suggestion.length; i++) {
|
||||
suggestion[i].classList.remove("autocomplete-active");
|
||||
if (!val || !autocompleteResults) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentFocus = -1;
|
||||
|
||||
for (i = 0; i < autocompleteResults.length; i++) {
|
||||
if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {
|
||||
autocompleteItem = document.createElement("div");
|
||||
autocompleteItem.setAttribute("class", "autocomplete-item");
|
||||
autocompleteItem.innerHTML = "<strong>" + autocompleteResults[i].substr(0, val.length) + "</strong>";
|
||||
autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);
|
||||
autocompleteItem.innerHTML += "<input type=\"hidden\" value=\"" + autocompleteResults[i] + "\">";
|
||||
autocompleteItem.addEventListener("click", function () {
|
||||
searchInput.value = this.getElementsByTagName("input")[0].value;
|
||||
autocompleteList.innerHTML = "";
|
||||
document.getElementById("search-form").submit();
|
||||
});
|
||||
autocompleteList.appendChild(autocompleteItem);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
};
|
||||
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));
|
||||
|
||||
// Close lists and search when user selects a suggestion
|
||||
document.addEventListener("click", function (e) {
|
||||
closeAllLists(e.target);
|
||||
autocompleteList.innerHTML = "";
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,18 +1,9 @@
|
||||
// Whoogle configurations that use boolean values and checkboxes
|
||||
CONFIG_BOOLS = [
|
||||
"nojs", "dark", "safe", "alts", "new_tab", "get_only"
|
||||
];
|
||||
|
||||
// Whoogle configurations that use string values and input fields
|
||||
CONFIG_STRS = [
|
||||
"near", "url"
|
||||
];
|
||||
|
||||
|
||||
const setupSearchLayout = () => {
|
||||
// Setup search field
|
||||
const searchBar = document.getElementById("search-bar");
|
||||
const searchBtn = document.getElementById("search-submit");
|
||||
const arrowKeys = [37, 38, 39, 40];
|
||||
let searchValue = searchBar.value;
|
||||
|
||||
// Automatically focus on search field
|
||||
searchBar.focus();
|
||||
@ -22,39 +13,13 @@ const setupSearchLayout = () => {
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault();
|
||||
searchBtn.click();
|
||||
} else {
|
||||
handleUserInput(searchBar);
|
||||
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
|
||||
searchValue = searchBar.value;
|
||||
handleUserInput();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fillConfigValues = () => {
|
||||
// Request existing config info
|
||||
let xhrGET = new XMLHttpRequest();
|
||||
xhrGET.open("GET", "/config");
|
||||
xhrGET.onload = function() {
|
||||
if (xhrGET.readyState === 4 && xhrGET.status !== 200) {
|
||||
alert("Error loading Whoogle config");
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow for updating/saving config values
|
||||
let configSettings = JSON.parse(xhrGET.responseText);
|
||||
|
||||
CONFIG_STRS.forEach(function(item) {
|
||||
let configElement = document.getElementById("config-" + item.replace("_", "-"));
|
||||
configElement.value = configSettings[item] ? configSettings[item] : "";
|
||||
});
|
||||
|
||||
CONFIG_BOOLS.forEach(function(item) {
|
||||
let configElement = document.getElementById("config-" + item.replace("_", "-"));
|
||||
configElement.checked = !!configSettings[item];
|
||||
});
|
||||
};
|
||||
|
||||
xhrGET.send();
|
||||
};
|
||||
|
||||
const setupConfigLayout = () => {
|
||||
// Setup whoogle config
|
||||
const collapsible = document.getElementById("config-collapsible");
|
||||
@ -64,13 +29,25 @@ const setupConfigLayout = () => {
|
||||
if (content.style.maxHeight) {
|
||||
content.style.maxHeight = null;
|
||||
} else {
|
||||
content.style.maxHeight = content.scrollHeight + "px";
|
||||
content.style.maxHeight = "400px";
|
||||
}
|
||||
|
||||
content.classList.toggle("open");
|
||||
});
|
||||
|
||||
fillConfigValues();
|
||||
// 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 => {
|
||||
@ -82,7 +59,7 @@ const loadConfig = event => {
|
||||
}
|
||||
|
||||
let xhrPUT = new XMLHttpRequest();
|
||||
xhrPUT.open("PUT", "/config?name=" + config + ".conf");
|
||||
xhrPUT.open("PUT", "config?name=" + config + ".conf");
|
||||
xhrPUT.onload = function() {
|
||||
if (xhrPUT.readyState === 4 && xhrPUT.status !== 200) {
|
||||
alert("Error loading Whoogle config");
|
||||
@ -104,7 +81,7 @@ const saveConfig = event => {
|
||||
}
|
||||
|
||||
let configForm = document.getElementById("config-form");
|
||||
configForm.action = '/config?name=' + config + ".conf";
|
||||
configForm.action = 'config?name=' + config + ".conf";
|
||||
configForm.submit();
|
||||
};
|
||||
|
||||
@ -116,6 +93,9 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
setupSearchLayout();
|
||||
setupConfigLayout();
|
||||
|
||||
document.getElementById("config-load").addEventListener("click", loadConfig);
|
||||
document.getElementById("config-save").addEventListener("click", saveConfig);
|
||||
|
||||
// Focusing on the search input field requires a delay for elements to finish
|
||||
// loading (seemingly only on FF)
|
||||
setTimeout(function() { document.getElementById("search-bar").focus(); }, 250);
|
||||
|
||||
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));
|
||||
}
|
||||
67
app/static/js/header.js
Normal file
@ -0,0 +1,67 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const advSearchToggle = document.getElementById("adv-search-toggle");
|
||||
const advSearchDiv = document.getElementById("adv-search-div");
|
||||
const searchBar = document.getElementById("search-bar");
|
||||
const countrySelect = document.getElementById("result-country");
|
||||
const timePeriodSelect = document.getElementById("result-time-period");
|
||||
const arrowKeys = [37, 38, 39, 40];
|
||||
let searchValue = searchBar.value;
|
||||
|
||||
countrySelect.onchange = () => {
|
||||
let str = window.location.href;
|
||||
n = str.lastIndexOf("/search");
|
||||
if (n > 0) {
|
||||
str = str.substring(0, n) + `/search?q=${searchBar.value}`;
|
||||
str = tackOnParams(str);
|
||||
window.location.href = str;
|
||||
}
|
||||
}
|
||||
|
||||
timePeriodSelect.onchange = () => {
|
||||
let str = window.location.href;
|
||||
n = str.lastIndexOf("/search");
|
||||
if (n > 0) {
|
||||
str = str.substring(0, n) + `/search?q=${searchBar.value}`;
|
||||
str = tackOnParams(str);
|
||||
window.location.href = str;
|
||||
}
|
||||
}
|
||||
|
||||
function tackOnParams(str) {
|
||||
if (timePeriodSelect.value != "") {
|
||||
str = str + `&tbs=${timePeriodSelect.value}`;
|
||||
}
|
||||
if (countrySelect.value != "") {
|
||||
str = str + `&country=${countrySelect.value}`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const toggleAdvancedSearch = on => {
|
||||
if (on) {
|
||||
advSearchDiv.style.maxHeight = "70px";
|
||||
} else {
|
||||
advSearchDiv.style.maxHeight = "0px";
|
||||
}
|
||||
localStorage.advSearchToggled = on;
|
||||
}
|
||||
|
||||
try {
|
||||
toggleAdvancedSearch(JSON.parse(localStorage.advSearchToggled));
|
||||
} catch (error) {
|
||||
console.warn("Did not recover advanced search toggle state");
|
||||
}
|
||||
|
||||
advSearchToggle.onclick = () => {
|
||||
toggleAdvancedSearch(advSearchToggle.checked);
|
||||
}
|
||||
|
||||
searchBar.addEventListener("keyup", function(event) {
|
||||
if (event.keyCode === 13) {
|
||||
document.getElementById("search-form").submit();
|
||||
} else if (searchBar.value !== searchValue && !arrowKeys.includes(event.keyCode)) {
|
||||
searchValue = searchBar.value;
|
||||
handleUserInput();
|
||||
}
|
||||
});
|
||||
});
|
||||
62
app/static/js/keyboard.js
Normal file
@ -0,0 +1,62 @@
|
||||
(function () {
|
||||
let searchBar, results;
|
||||
let shift = false;
|
||||
const keymap = {
|
||||
ArrowUp: goUp,
|
||||
ArrowDown: goDown,
|
||||
ShiftTab: goUp,
|
||||
Tab: goDown,
|
||||
k: goUp,
|
||||
j: goDown,
|
||||
'/': focusSearch,
|
||||
};
|
||||
let activeIdx = -1;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
searchBar = document.querySelector('#search-bar');
|
||||
results = document.querySelectorAll('#main>div>div>div>a');
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Shift') {
|
||||
shift = true;
|
||||
}
|
||||
|
||||
if (e.target.tagName === 'INPUT') return true;
|
||||
if (typeof keymap[e.key] === 'function') {
|
||||
e.preventDefault();
|
||||
|
||||
keymap[`${shift && e.key == 'Tab' ? 'Shift' : ''}${e.key}`]();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Shift') {
|
||||
shift = false;
|
||||
}
|
||||
});
|
||||
|
||||
function goUp () {
|
||||
if (activeIdx > 0) focusResult(activeIdx - 1);
|
||||
else focusSearch();
|
||||
}
|
||||
|
||||
function goDown () {
|
||||
if (activeIdx < results.length - 1) focusResult(activeIdx + 1);
|
||||
}
|
||||
|
||||
function focusResult (idx) {
|
||||
activeIdx = idx;
|
||||
results[activeIdx].scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
results[activeIdx].focus();
|
||||
}
|
||||
|
||||
function focusSearch () {
|
||||
if (window.usingCalculator) {
|
||||
// if this function exists, it means the calculator widget has been displayed
|
||||
if (usingCalculator()) return;
|
||||
}
|
||||
activeIdx = -1;
|
||||
searchBar.focus();
|
||||
}
|
||||
}());
|
||||
76
app/static/js/utils.js
Normal file
@ -0,0 +1,76 @@
|
||||
const checkForTracking = () => {
|
||||
const mainDiv = document.getElementById("main");
|
||||
const searchBar = document.getElementById("search-bar");
|
||||
// some pages (e.g. images) do not have these
|
||||
if (!mainDiv || !searchBar)
|
||||
return;
|
||||
const query = searchBar.value.replace(/\s+/g, '');
|
||||
|
||||
// Note: regex functions for checking for tracking queries were derived
|
||||
// from here -- https://stackoverflow.com/questions/619977
|
||||
const matchTracking = {
|
||||
"ups": {
|
||||
"link": `https://www.ups.com/track?tracknum=${query}`,
|
||||
"expr": [
|
||||
/\b(1Z ?[0-9A-Z]{3} ?[0-9A-Z]{3} ?[0-9A-Z]{2} ?[0-9A-Z]{4} ?[0-9A-Z]{3} ?[0-9A-Z]|[\dT]\d\d\d ?\d\d\d\d ?\d\d\d)\b/
|
||||
]
|
||||
},
|
||||
"usps": {
|
||||
"link": `https://tools.usps.com/go/TrackConfirmAction_input?origTrackNum=${query}`,
|
||||
"expr": [
|
||||
/(\b\d{30}\b)|(\b91\d+\b)|(\b\d{20}\b)/,
|
||||
/^E\D{1}\d{9}\D{2}$|^9\d{15,21}$/,
|
||||
/^91[0-9]+$/,
|
||||
/^[A-Za-z]{2}[0-9]+US$/
|
||||
]
|
||||
},
|
||||
"fedex": {
|
||||
"link": `https://www.fedex.com/apps/fedextrack/?tracknumbers=${query}`,
|
||||
"expr": [
|
||||
/(\b96\d{20}\b)|(\b\d{15}\b)|(\b\d{12}\b)/,
|
||||
/\b((98\d\d\d\d\d?\d\d\d\d|98\d\d) ?\d\d\d\d ?\d\d\d\d( ?\d\d\d)?)\b/,
|
||||
/^[0-9]{15}$/
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Creates a link to a UPS/USPS/FedEx tracking page
|
||||
const createTrackingLink = href => {
|
||||
let link = document.createElement("a");
|
||||
link.className = "tracking-link";
|
||||
link.innerHTML = "View Tracking Info";
|
||||
link.href = href;
|
||||
mainDiv.prepend(link);
|
||||
};
|
||||
|
||||
// Compares the query against a set of regex patterns
|
||||
// for tracking numbers
|
||||
const compareQuery = provider => {
|
||||
provider.expr.some(regex => {
|
||||
if (query.match(regex)) {
|
||||
createTrackingLink(provider.link);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
for (const key of Object.keys(matchTracking)) {
|
||||
compareQuery(matchTracking[key]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
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();
|
||||
});
|
||||
});
|
||||
245
app/static/settings/countries.json
Normal file
@ -0,0 +1,245 @@
|
||||
[
|
||||
{"name": "-------", "value": ""},
|
||||
{"name": "Afghanistan", "value": "AF"},
|
||||
{"name": "Albania", "value": "AL"},
|
||||
{"name": "Algeria", "value": "DZ"},
|
||||
{"name": "American Samoa", "value": "AS"},
|
||||
{"name": "Andorra", "value": "AD"},
|
||||
{"name": "Angola", "value": "AO"},
|
||||
{"name": "Anguilla", "value": "AI"},
|
||||
{"name": "Antarctica", "value": "AQ"},
|
||||
{"name": "Antigua and Barbuda", "value": "AG"},
|
||||
{"name": "Argentina", "value": "AR"},
|
||||
{"name": "Armenia", "value": "AM"},
|
||||
{"name": "Aruba", "value": "AW"},
|
||||
{"name": "Australia", "value": "AU"},
|
||||
{"name": "Austria", "value": "AT"},
|
||||
{"name": "Azerbaijan", "value": "AZ"},
|
||||
{"name": "Bahamas", "value": "BS"},
|
||||
{"name": "Bahrain", "value": "BH"},
|
||||
{"name": "Bangladesh", "value": "BD"},
|
||||
{"name": "Barbados", "value": "BB"},
|
||||
{"name": "Belarus", "value": "BY"},
|
||||
{"name": "Belgium", "value": "BE"},
|
||||
{"name": "Belize", "value": "BZ"},
|
||||
{"name": "Benin", "value": "BJ"},
|
||||
{"name": "Bermuda", "value": "BM"},
|
||||
{"name": "Bhutan", "value": "BT"},
|
||||
{"name": "Bolivia", "value": "BO"},
|
||||
{"name": "Bosnia and Herzegovina", "value": "BA"},
|
||||
{"name": "Botswana", "value": "BW"},
|
||||
{"name": "Bouvet Island", "value": "BV"},
|
||||
{"name": "Brazil", "value": "BR"},
|
||||
{"name": "British Indian Ocean Territory", "value": "IO"},
|
||||
{"name": "Brunei Darussalam", "value": "BN"},
|
||||
{"name": "Bulgaria", "value": "BG"},
|
||||
{"name": "Burkina Faso", "value": "BF"},
|
||||
{"name": "Burundi", "value": "BI"},
|
||||
{"name": "Cambodia", "value": "KH"},
|
||||
{"name": "Cameroon", "value": "CM"},
|
||||
{"name": "Canada", "value": "CA"},
|
||||
{"name": "Cape Verde", "value": "CV"},
|
||||
{"name": "Cayman Islands", "value": "KY"},
|
||||
{"name": "Central African Republic", "value": "CF"},
|
||||
{"name": "Chad", "value": "TD"},
|
||||
{"name": "Chile", "value": "CL"},
|
||||
{"name": "China", "value": "CN"},
|
||||
{"name": "Christmas Island", "value": "CX"},
|
||||
{"name": "Cocos (Keeling) Islands", "value": "CC"},
|
||||
{"name": "Colombia", "value": "CO"},
|
||||
{"name": "Comoros", "value": "KM"},
|
||||
{"name": "Congo", "value": "CG"},
|
||||
{"name": "Congo, Democratic Republic of the", "value": "CD"},
|
||||
{"name": "Cook Islands", "value": "CK"},
|
||||
{"name": "Costa Rica", "value": "CR"},
|
||||
{"name": "Cote D'ivoire", "value": "CI"},
|
||||
{"name": "Croatia (Hrvatska)", "value": "HR"},
|
||||
{"name": "Cuba", "value": "CU"},
|
||||
{"name": "Cyprus", "value": "CY"},
|
||||
{"name": "Czech Republic", "value": "CZ"},
|
||||
{"name": "Denmark", "value": "DK"},
|
||||
{"name": "Djibouti", "value": "DJ"},
|
||||
{"name": "Dominica", "value": "DM"},
|
||||
{"name": "Dominican Republic", "value": "DO"},
|
||||
{"name": "East Timor", "value": "TP"},
|
||||
{"name": "Ecuador", "value": "EC"},
|
||||
{"name": "Egypt", "value": "EG"},
|
||||
{"name": "El Salvador", "value": "SV"},
|
||||
{"name": "Equatorial Guinea", "value": "GQ"},
|
||||
{"name": "Eritrea", "value": "ER"},
|
||||
{"name": "Estonia", "value": "EE"},
|
||||
{"name": "Ethiopia", "value": "ET"},
|
||||
{"name": "European Union", "value": "EU"},
|
||||
{"name": "Falkland Islands (Malvinas)", "value": "FK"},
|
||||
{"name": "Faroe Islands", "value": "FO"},
|
||||
{"name": "Fiji", "value": "FJ"},
|
||||
{"name": "Finland", "value": "FI"},
|
||||
{"name": "France", "value": "FR"},
|
||||
{"name": "France, Metropolitan", "value": "FX"},
|
||||
{"name": "French Guiana", "value": "GF"},
|
||||
{"name": "French Polynesia", "value": "PF"},
|
||||
{"name": "French Southern Territories", "value": "TF"},
|
||||
{"name": "Gabon", "value": "GA"},
|
||||
{"name": "Gambia", "value": "GM"},
|
||||
{"name": "Georgia", "value": "GE"},
|
||||
{"name": "Germany", "value": "DE"},
|
||||
{"name": "Ghana", "value": "GH"},
|
||||
{"name": "Gibraltar", "value": "GI"},
|
||||
{"name": "Greece", "value": "GR"},
|
||||
{"name": "Greenland", "value": "GL"},
|
||||
{"name": "Grenada", "value": "GD"},
|
||||
{"name": "Guadeloupe", "value": "GP"},
|
||||
{"name": "Guam", "value": "GU"},
|
||||
{"name": "Guatemala", "value": "GT"},
|
||||
{"name": "Guinea", "value": "GN"},
|
||||
{"name": "Guinea-Bissau", "value": "GW"},
|
||||
{"name": "Guyana", "value": "GY"},
|
||||
{"name": "Haiti", "value": "HT"},
|
||||
{"name": "Heard Island and Mcdonald Islands", "value": "HM"},
|
||||
{"name": "Holy See (Vatican City State)", "value": "VA"},
|
||||
{"name": "Honduras", "value": "HN"},
|
||||
{"name": "Hong Kong", "value": "HK"},
|
||||
{"name": "Hungary", "value": "HU"},
|
||||
{"name": "Iceland", "value": "IS"},
|
||||
{"name": "India", "value": "IN"},
|
||||
{"name": "Indonesia", "value": "ID"},
|
||||
{"name": "Iran, Islamic Republic of", "value": "IR"},
|
||||
{"name": "Iraq", "value": "IQ"},
|
||||
{"name": "Ireland", "value": "IE"},
|
||||
{"name": "Israel", "value": "IL"},
|
||||
{"name": "Italy", "value": "IT"},
|
||||
{"name": "Jamaica", "value": "JM"},
|
||||
{"name": "Japan", "value": "JP"},
|
||||
{"name": "Jordan", "value": "JO"},
|
||||
{"name": "Kazakhstan", "value": "KZ"},
|
||||
{"name": "Kenya", "value": "KE"},
|
||||
{"name": "Kiribati", "value": "KI"},
|
||||
{"name": "Korea, Democratic People's Republic of", "value": "KP"},
|
||||
{"name": "Korea, Republic of", "value": "KR"},
|
||||
{"name": "Kuwait", "value": "KW"},
|
||||
{"name": "Kyrgyzstan", "value": "KG"},
|
||||
{"name": "Lao People's Democratic Republic", "value": "LA"},
|
||||
{"name": "Latvia", "value": "LV"},
|
||||
{"name": "Lebanon", "value": "LB"},
|
||||
{"name": "Lesotho", "value": "LS"},
|
||||
{"name": "Liberia", "value": "LR"},
|
||||
{"name": "Libyan Arab Jamahiriya", "value": "LY"},
|
||||
{"name": "Liechtenstein", "value": "LI"},
|
||||
{"name": "Lithuania", "value": "LT"},
|
||||
{"name": "Luxembourg", "value": "LU"},
|
||||
{"name": "Macao", "value": "MO"},
|
||||
{"name": "Madagascar", "value": "MG"},
|
||||
{"name": "Malawi", "value": "MW"},
|
||||
{"name": "Malaysia", "value": "MY"},
|
||||
{"name": "Maldives", "value": "MV"},
|
||||
{"name": "Mali", "value": "ML"},
|
||||
{"name": "Malta", "value": "MT"},
|
||||
{"name": "Marshall Islands", "value": "MH"},
|
||||
{"name": "Martinique", "value": "MQ"},
|
||||
{"name": "Mauritania", "value": "MR"},
|
||||
{"name": "Mauritius", "value": "MU"},
|
||||
{"name": "Mayotte", "value": "YT"},
|
||||
{"name": "Mexico", "value": "MX"},
|
||||
{"name": "Micronesia, Federated States of", "value": "FM"},
|
||||
{"name": "Moldova, Republic of", "value": "MD"},
|
||||
{"name": "Monaco", "value": "MC"},
|
||||
{"name": "Mongolia", "value": "MN"},
|
||||
{"name": "Montserrat", "value": "MS"},
|
||||
{"name": "Morocco", "value": "MA"},
|
||||
{"name": "Mozambique", "value": "MZ"},
|
||||
{"name": "Myanmar", "value": "MM"},
|
||||
{"name": "Namibia", "value": "NA"},
|
||||
{"name": "Nauru", "value": "NR"},
|
||||
{"name": "Nepal", "value": "NP"},
|
||||
{"name": "Netherlands", "value": "NL"},
|
||||
{"name": "Netherlands Antilles", "value": "AN"},
|
||||
{"name": "New Caledonia", "value": "NC"},
|
||||
{"name": "New Zealand", "value": "NZ"},
|
||||
{"name": "Nicaragua", "value": "NI"},
|
||||
{"name": "Niger", "value": "NE"},
|
||||
{"name": "Nigeria", "value": "NG"},
|
||||
{"name": "Niue", "value": "NU"},
|
||||
{"name": "Norfolk Island", "value": "NF"},
|
||||
{"name": "North Macedonia", "value": "MK"},
|
||||
{"name": "Northern Mariana Islands", "value": "MP"},
|
||||
{"name": "Norway", "value": "NO"},
|
||||
{"name": "Oman", "value": "OM"},
|
||||
{"name": "Pakistan", "value": "PK"},
|
||||
{"name": "Palau", "value": "PW"},
|
||||
{"name": "Palestinian Territory", "value": "PS"},
|
||||
{"name": "Panama", "value": "PA"},
|
||||
{"name": "Papua New Guinea", "value": "PG"},
|
||||
{"name": "Paraguay", "value": "PY"},
|
||||
{"name": "Peru", "value": "PE"},
|
||||
{"name": "Philippines", "value": "PH"},
|
||||
{"name": "Pitcairn", "value": "PN"},
|
||||
{"name": "Poland", "value": "PL"},
|
||||
{"name": "Portugal", "value": "PT"},
|
||||
{"name": "Puerto Rico", "value": "PR"},
|
||||
{"name": "Qatar", "value": "QA"},
|
||||
{"name": "Reunion", "value": "RE"},
|
||||
{"name": "Romania", "value": "RO"},
|
||||
{"name": "Russian Federation", "value": "RU"},
|
||||
{"name": "Rwanda", "value": "RW"},
|
||||
{"name": "Saint Helena", "value": "SH"},
|
||||
{"name": "Saint Kitts and Nevis", "value": "KN"},
|
||||
{"name": "Saint Lucia", "value": "LC"},
|
||||
{"name": "Saint Pierre and Miquelon", "value": "PM"},
|
||||
{"name": "Saint Vincent and the Grenadines", "value": "VC"},
|
||||
{"name": "Samoa", "value": "WS"},
|
||||
{"name": "San Marino", "value": "SM"},
|
||||
{"name": "Sao Tome and Principe", "value": "ST"},
|
||||
{"name": "Saudi Arabia", "value": "SA"},
|
||||
{"name": "Senegal", "value": "SN"},
|
||||
{"name": "Serbia and Montenegro", "value": "CS"},
|
||||
{"name": "Seychelles", "value": "SC"},
|
||||
{"name": "Sierra Leone", "value": "SL"},
|
||||
{"name": "Singapore", "value": "SG"},
|
||||
{"name": "Slovakia", "value": "SK"},
|
||||
{"name": "Slovenia", "value": "SI"},
|
||||
{"name": "Solomon Islands", "value": "SB"},
|
||||
{"name": "Somalia", "value": "SO"},
|
||||
{"name": "South Africa", "value": "ZA"},
|
||||
{"name": "South Georgia and the South Sandwich Islands", "value": "GS"},
|
||||
{"name": "Spain", "value": "ES"},
|
||||
{"name": "Sri Lanka", "value": "LK"},
|
||||
{"name": "Sudan", "value": "SD"},
|
||||
{"name": "Suriname", "value": "SR"},
|
||||
{"name": "Svalbard and Jan Mayen", "value": "SJ"},
|
||||
{"name": "Swaziland", "value": "SZ"},
|
||||
{"name": "Sweden", "value": "SE"},
|
||||
{"name": "Switzerland", "value": "CH"},
|
||||
{"name": "Syrian Arab Republic", "value": "SY"},
|
||||
{"name": "Taiwan", "value": "TW"},
|
||||
{"name": "Tajikistan", "value": "TJ"},
|
||||
{"name": "Tanzania, United Republic of", "value": "TZ"},
|
||||
{"name": "Thailand", "value": "TH"},
|
||||
{"name": "Togo", "value": "TG"},
|
||||
{"name": "Tokelau", "value": "TK"},
|
||||
{"name": "Tonga", "value": "TO"},
|
||||
{"name": "Trinidad and Tobago", "value": "TT"},
|
||||
{"name": "Tunisia", "value": "TN"},
|
||||
{"name": "Turkmenistan", "value": "TM"},
|
||||
{"name": "Turks and Caicos Islands", "value": "TC"},
|
||||
{"name": "Tuvalu", "value": "TV"},
|
||||
{"name": "Türkiye", "value": "TR"},
|
||||
{"name": "Uganda", "value": "UG"},
|
||||
{"name": "Ukraine", "value": "UA"},
|
||||
{"name": "United Arab Emirates", "value": "AE"},
|
||||
{"name": "United Kingdom", "value": "UK"},
|
||||
{"name": "United States", "value": "US"},
|
||||
{"name": "United States Minor Outlying Islands", "value": "UM"},
|
||||
{"name": "Uruguay", "value": "UY"},
|
||||
{"name": "Uzbekistan", "value": "UZ"},
|
||||
{"name": "Vanuatu", "value": "VU"},
|
||||
{"name": "Venezuela", "value": "VE"},
|
||||
{"name": "Vietnam", "value": "VN"},
|
||||
{"name": "Virgin Islands, British", "value": "VG"},
|
||||
{"name": "Virgin Islands, U.S.", "value": "VI"},
|
||||
{"name": "Wallis and Futuna", "value": "WF"},
|
||||
{"name": "Western Sahara", "value": "EH"},
|
||||
{"name": "Yemen", "value": "YE"},
|
||||
{"name": "Yugoslavia", "value": "YU"},
|
||||
{"name": "Zambia", "value": "ZM"},
|
||||
{"name": "Zimbabwe", "value": "ZW"}
|
||||
]
|
||||
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
|
||||
}
|
||||
}
|
||||
55
app/static/settings/languages.json
Normal file
@ -0,0 +1,55 @@
|
||||
[
|
||||
{"name": "-------", "value": ""},
|
||||
{"name": "English", "value": "lang_en"},
|
||||
{"name": "Afrikaans (Afrikaans)", "value": "lang_af"},
|
||||
{"name": "Arabic (عربى)", "value": "lang_ar"},
|
||||
{"name": "Armenian (հայերեն)", "value": "lang_hy"},
|
||||
{"name": "Azerbaijani (Azərbaycanca)", "value": "lang_az"},
|
||||
{"name": "Belarusian (Беларуская)", "value": "lang_be"},
|
||||
{"name": "Bulgarian (български)", "value": "lang_bg"},
|
||||
{"name": "Catalan (Català)", "value": "lang_ca"},
|
||||
{"name": "Chinese, Simplified (简体中文)", "value": "lang_zh-CN"},
|
||||
{"name": "Chinese, Traditional (正體中文)", "value": "lang_zh-TW"},
|
||||
{"name": "Croatian (Hrvatski)", "value": "lang_hr"},
|
||||
{"name": "Czech (čeština)", "value": "lang_cs"},
|
||||
{"name": "Danish (Dansk)", "value": "lang_da"},
|
||||
{"name": "Dutch (Nederlands)", "value": "lang_nl"},
|
||||
{"name": "Esperanto (Esperanto)", "value": "lang_eo"},
|
||||
{"name": "Estonian (Eestlane)", "value": "lang_et"},
|
||||
{"name": "Filipino (Pilipino)", "value": "lang_tl"},
|
||||
{"name": "Finnish (Suomalainen)", "value": "lang_fi"},
|
||||
{"name": "French (Français)", "value": "lang_fr"},
|
||||
{"name": "German (Deutsch)", "value": "lang_de"},
|
||||
{"name": "Greek (Ελληνικά)", "value": "lang_el"},
|
||||
{"name": "Hebrew (עִברִית)", "value": "lang_iw"},
|
||||
{"name": "Hindi (हिंदी)", "value": "lang_hi"},
|
||||
{"name": "Hungarian (Magyar)", "value": "lang_hu"},
|
||||
{"name": "Icelandic (Íslenska)", "value": "lang_is"},
|
||||
{"name": "Indonesian (Indonesian)", "value": "lang_id"},
|
||||
{"name": "Italian (Italiano)", "value": "lang_it"},
|
||||
{"name": "Japanese (日本語)", "value": "lang_ja"},
|
||||
{"name": "Korean (한국어)", "value": "lang_ko"},
|
||||
{"name": "Kurdish (Kurdî)", "value": "lang_ku"},
|
||||
{"name": "Latvian (Latvietis)", "value": "lang_lv"},
|
||||
{"name": "Lithuanian (Lietuvis)", "value": "lang_lt"},
|
||||
{"name": "Norwegian (Norwegian)", "value": "lang_no"},
|
||||
{"name": "Persian (فارسی)", "value": "lang_fa"},
|
||||
{"name": "Polish (Polskie)", "value": "lang_pl"},
|
||||
{"name": "Portuguese (Português)", "value": "lang_pt"},
|
||||
{"name": "Romanian (Română)", "value": "lang_ro"},
|
||||
{"name": "Russian (русский)", "value": "lang_ru"},
|
||||
{"name": "Serbian (Српски)", "value": "lang_sr"},
|
||||
{"name": "Sinhala (සිංහල)", "value": "lang_si"},
|
||||
{"name": "Slovak (Slovák)", "value": "lang_sk"},
|
||||
{"name": "Slovenian (Slovenščina)", "value": "lang_sl"},
|
||||
{"name": "Spanish (Español)", "value": "lang_es"},
|
||||
{"name": "Swahili (Kiswahili)", "value": "lang_sw"},
|
||||
{"name": "Swedish (Svenska)", "value": "lang_sv"},
|
||||
{"name": "Thai (ไทย)", "value": "lang_th"},
|
||||
{"name": "Turkish (Türkçe)", "value": "lang_tr"},
|
||||
{"name": "Ukrainian (Українська)", "value": "lang_uk"},
|
||||
{"name": "Vietnamese (Tiếng Việt)", "value": "lang_vi"},
|
||||
{"name": "Welsh (Cymraeg)", "value": "lang_cy"},
|
||||
{"name": "Xhosa (isiXhosa)", "value": "lang_xh"},
|
||||
{"name": "Zulu (isiZulu)", "value": "lang_zu"}
|
||||
]
|
||||
5
app/static/settings/themes.json
Normal file
@ -0,0 +1,5 @@
|
||||
[
|
||||
"light",
|
||||
"dark",
|
||||
"system"
|
||||
]
|
||||
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
263
app/static/widgets/calculator.html
Normal file
@ -0,0 +1,263 @@
|
||||
<!--
|
||||
Calculator widget.
|
||||
This file should contain all required
|
||||
CSS, HTML, and JS for it.
|
||||
-->
|
||||
|
||||
<style>
|
||||
#calc-text {
|
||||
background: var(--whoogle-dark-page-bg);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
color: var(--whoogle-dark-text);
|
||||
}
|
||||
#prev-equation {
|
||||
text-align: right;
|
||||
}
|
||||
.error-border {
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
#calc-btns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-rows: repeat(5, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
#calc-btns button {
|
||||
background: #313141;
|
||||
color: var(--whoogle-dark-text);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#calc-btns button:hover {
|
||||
background: #414151;
|
||||
}
|
||||
#calc-btns .common {
|
||||
background: #51516a;
|
||||
}
|
||||
#calc-btns .common:hover {
|
||||
background: #61617a;
|
||||
}
|
||||
#calc-btn-0 { grid-row: 5; grid-column: 3; }
|
||||
#calc-btn-1 { grid-row: 4; grid-column: 3; }
|
||||
#calc-btn-2 { grid-row: 4; grid-column: 4; }
|
||||
#calc-btn-3 { grid-row: 4; grid-column: 5; }
|
||||
#calc-btn-4 { grid-row: 3; grid-column: 3; }
|
||||
#calc-btn-5 { grid-row: 3; grid-column: 4; }
|
||||
#calc-btn-6 { grid-row: 3; grid-column: 5; }
|
||||
#calc-btn-7 { grid-row: 2; grid-column: 3; }
|
||||
#calc-btn-8 { grid-row: 2; grid-column: 4; }
|
||||
#calc-btn-9 { grid-row: 2; grid-column: 5; }
|
||||
#calc-btn-EQ { grid-row: 5; grid-column: 5; }
|
||||
#calc-btn-PT { grid-row: 5; grid-column: 4; }
|
||||
#calc-btn-BCK { grid-row: 5; grid-column: 6; }
|
||||
#calc-btn-ADD { grid-row: 4; grid-column: 6; }
|
||||
#calc-btn-SUB { grid-row: 3; grid-column: 6; }
|
||||
#calc-btn-MLT { grid-row: 2; grid-column: 6; }
|
||||
#calc-btn-DIV { grid-row: 1; grid-column: 6; }
|
||||
#calc-btn-CLR { grid-row: 1; grid-column: 5; }
|
||||
#calc-btn-PRC{ grid-row: 1; grid-column: 4; }
|
||||
#calc-btn-RP { grid-row: 1; grid-column: 3; }
|
||||
#calc-btn-LP { grid-row: 1; grid-column: 2; }
|
||||
#calc-btn-ABS { grid-row: 1; grid-column: 1; }
|
||||
#calc-btn-SIN { grid-row: 2; grid-column: 2; }
|
||||
#calc-btn-COS { grid-row: 3; grid-column: 2; }
|
||||
#calc-btn-TAN { grid-row: 4; grid-column: 2; }
|
||||
#calc-btn-SQR { grid-row: 5; grid-column: 2; }
|
||||
#calc-btn-EXP { grid-row: 2; grid-column: 1; }
|
||||
#calc-btn-E { grid-row: 3; grid-column: 1; }
|
||||
#calc-btn-PI { grid-row: 4; grid-column: 1; }
|
||||
#calc-btn-LOG { grid-row: 5; grid-column: 1; }
|
||||
</style>
|
||||
<p id="prev-equation"></p>
|
||||
<div id="calculator-widget">
|
||||
<p id="calc-text">0</p>
|
||||
<div id="calc-btns">
|
||||
<button id="calc-btn-0" class="common">0</button>
|
||||
<button id="calc-btn-1" class="common">1</button>
|
||||
<button id="calc-btn-2" class="common">2</button>
|
||||
<button id="calc-btn-3" class="common">3</button>
|
||||
<button id="calc-btn-4" class="common">4</button>
|
||||
<button id="calc-btn-5" class="common">5</button>
|
||||
<button id="calc-btn-6" class="common">6</button>
|
||||
<button id="calc-btn-7" class="common">7</button>
|
||||
<button id="calc-btn-8" class="common">8</button>
|
||||
<button id="calc-btn-9" class="common">9</button>
|
||||
<button id="calc-btn-EQ" class="common">=</button>
|
||||
<button id="calc-btn-PT" class="common">.</button>
|
||||
<button id="calc-btn-BCK">⬅</button>
|
||||
<button id="calc-btn-ADD">+</button>
|
||||
<button id="calc-btn-SUB">-</button>
|
||||
<button id="calc-btn-MLT">x</button>
|
||||
<button id="calc-btn-DIV">/</button>
|
||||
<button id="calc-btn-CLR">C</button>
|
||||
<button id="calc-btn-PRC">%</button>
|
||||
<button id="calc-btn-RP">)</button>
|
||||
<button id="calc-btn-LP">(</button>
|
||||
<button id="calc-btn-ABS">|x|</button>
|
||||
<button id="calc-btn-SIN">sin</button>
|
||||
<button id="calc-btn-COS">cos</button>
|
||||
<button id="calc-btn-TAN">tan</button>
|
||||
<button id="calc-btn-SQR">√</button>
|
||||
<button id="calc-btn-EXP">^</button>
|
||||
<button id="calc-btn-E">ℇ</button>
|
||||
<button id="calc-btn-PI">π</button>
|
||||
<button id="calc-btn-LOG">log</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// JS does not have this by default.
|
||||
// from https://www.freecodecamp.org/news/how-to-factorialize-a-number-in-javascript-9263c89a4b38/
|
||||
function factorial(num) {
|
||||
if (num < 0)
|
||||
return -1;
|
||||
else if (num === 0)
|
||||
return 1;
|
||||
else {
|
||||
return (num * factorial(num - 1));
|
||||
}
|
||||
}
|
||||
// returns true if the user is currently focused on the calculator widget
|
||||
function usingCalculator() {
|
||||
let activeElement = document.activeElement;
|
||||
while (true) {
|
||||
if (!activeElement) return false;
|
||||
if (activeElement.id === "calculator-wrapper") return true;
|
||||
activeElement = activeElement.parentElement;
|
||||
}
|
||||
}
|
||||
const $ = q => document.querySelectorAll(q);
|
||||
// key bindings for commonly used buttons
|
||||
const keybindings = {
|
||||
"0": "0",
|
||||
"1": "1",
|
||||
"2": "2",
|
||||
"3": "3",
|
||||
"4": "4",
|
||||
"5": "5",
|
||||
"6": "6",
|
||||
"7": "7",
|
||||
"8": "8",
|
||||
"9": "9",
|
||||
"Enter": "EQ",
|
||||
".": "PT",
|
||||
"+": "ADD",
|
||||
"-": "SUB",
|
||||
"*": "MLT",
|
||||
"/": "DIV",
|
||||
"%": "PRC",
|
||||
"c": "CLR",
|
||||
"(": "LP",
|
||||
")": "RP",
|
||||
"Backspace": "BCK",
|
||||
}
|
||||
window.addEventListener("keydown", event => {
|
||||
if (!usingCalculator()) return;
|
||||
if (event.key === "Enter" && document.activeElement.id !== "search-bar")
|
||||
event.preventDefault();
|
||||
if (keybindings[event.key])
|
||||
document.getElementById("calc-btn-" + keybindings[event.key]).click();
|
||||
})
|
||||
// calculates the string
|
||||
const calc = () => {
|
||||
var mathtext = document.getElementById("calc-text");
|
||||
var statement = mathtext.innerHTML
|
||||
// remove empty ()
|
||||
.replace("()", "")
|
||||
// special constants
|
||||
.replace("π", "(Math.PI)")
|
||||
.replace("ℇ", "(Math.E)")
|
||||
// turns 3(1+2) into 3*(1+2) (for example)
|
||||
.replace(/(?<=[0-9\)])(?<=[^+\-x*\/%^])\(/, "x(")
|
||||
// same except reversed
|
||||
.replace(/\)(?=[0-9\(])(?=[^+\-x*\/%^])/, ")x")
|
||||
// replace human friendly x with JS *
|
||||
.replace("x", "*")
|
||||
// trig & misc functions
|
||||
.replace("sin", "Math.sin")
|
||||
.replace("cos", "Math.cos")
|
||||
.replace("tan", "Math.tan")
|
||||
.replace("√", "Math.sqrt")
|
||||
.replace("^", "**")
|
||||
.replace("abs", "Math.abs")
|
||||
.replace("log", "Math.log")
|
||||
;
|
||||
// add any missing )s to the end
|
||||
while(true) if (
|
||||
(statement.match(/\(/g) || []).length >
|
||||
(statement.match(/\)/g) || []).length
|
||||
) statement += ")"; else break;
|
||||
// evaluate the expression using a safe evaluator (no eval())
|
||||
console.log("calculating [" + statement + "]");
|
||||
try {
|
||||
// Safe evaluation: create a sandboxed function with only Math object available
|
||||
// This prevents arbitrary code execution while allowing mathematical operations
|
||||
const safeEval = new Function('Math', `'use strict'; return (${statement})`);
|
||||
var result = safeEval(Math);
|
||||
document.getElementById("prev-equation").innerHTML = mathtext.innerHTML + " = ";
|
||||
mathtext.innerHTML = result;
|
||||
mathtext.classList.remove("error-border");
|
||||
} catch (e) {
|
||||
mathtext.classList.add("error-border");
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
const updateCalc = (e) => {
|
||||
// character(s) recieved from button
|
||||
var c = event.target.innerHTML;
|
||||
var mathtext = document.getElementById("calc-text");
|
||||
if (mathtext.innerHTML === "0") mathtext.innerHTML = "";
|
||||
// special cases
|
||||
switch (c) {
|
||||
case "C":
|
||||
// Clear
|
||||
mathtext.innerHTML = "0";
|
||||
break;
|
||||
case "⬅":
|
||||
// Delete
|
||||
mathtext.innerHTML = mathtext.innerHTML.slice(0, -1);
|
||||
if (mathtext.innerHTML.length === 0) {
|
||||
mathtext.innerHTML = "0";
|
||||
}
|
||||
break;
|
||||
case "=":
|
||||
calc()
|
||||
break;
|
||||
case "sin":
|
||||
case "cos":
|
||||
case "tan":
|
||||
case "log":
|
||||
case "√":
|
||||
mathtext.innerHTML += `${c}(`;
|
||||
break;
|
||||
case "|x|":
|
||||
mathtext.innerHTML += "abs("
|
||||
break;
|
||||
case "+":
|
||||
case "-":
|
||||
case "x":
|
||||
case "/":
|
||||
case "%":
|
||||
case "^":
|
||||
if (mathtext.innerHTML.length === 0) mathtext.innerHTML = "0";
|
||||
// prevent typing 2 operators in a row
|
||||
if (mathtext.innerHTML.match(/[+\-x\/%^] $/))
|
||||
mathtext.innerHTML = mathtext.innerHTML.slice(0, -3);
|
||||
mathtext.innerHTML += ` ${c} `;
|
||||
break;
|
||||
default:
|
||||
mathtext.innerHTML += c;
|
||||
}
|
||||
}
|
||||
for (let i of $("#calc-btns button")) {
|
||||
i.addEventListener('click', event => {
|
||||
updateCalc(event);
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -1,26 +1,58 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel="shortcut icon" href="/static/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/static/img/favicon.ico" type="image/x-icon">
|
||||
<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="referrer" content="no-referrer">
|
||||
<script type="text/javascript" src="/static/js/autocomplete.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/{{ 'search-dark' if dark_mode else 'search' }}.css">
|
||||
<link rel="stylesheet" href="/static/css/header.css">
|
||||
{% if dark_mode %}
|
||||
<link rel="stylesheet" href="/static/css/dark-theme.css"/>
|
||||
<head>
|
||||
<link rel="shortcut icon" href="static/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="static/img/favicon.ico" type="image/x-icon">
|
||||
{% if not search_type %}
|
||||
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
||||
{% else %}
|
||||
<link rel="search" href="opensearch.xml?tbm={{ search_type }}" type="application/opensearchdescription+xml" title="Whoogle Search ({{ search_name }})">
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
{% if bundle_static() %}
|
||||
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url('logo.css') }}">
|
||||
<link rel="stylesheet" href="{{ cb_url('input.css') }}">
|
||||
<link rel="stylesheet" href="{{ cb_url('search.css') }}">
|
||||
<link rel="stylesheet" href="{{ cb_url('header.css') }}">
|
||||
{% endif %}
|
||||
{% if config.theme %}
|
||||
{% if config.theme == 'system' %}
|
||||
<style>
|
||||
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||
</style>
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||
{% endif %}
|
||||
<title>{{ query }} - Whoogle Search</title>
|
||||
</head>
|
||||
<body>
|
||||
{{ search_header|safe }}
|
||||
{{ response|safe }}
|
||||
</body>
|
||||
<footer>
|
||||
<p style="color: {{ '#fff' if dark_mode else '#000' }};">
|
||||
Whoogle Search v{{ version_number }} ||
|
||||
<a style="color: #685e79" href="https://github.com/benbusby/whoogle-search">View on GitHub</a>
|
||||
</p>
|
||||
</footer>
|
||||
{% endif %}
|
||||
{% if config.style %}
|
||||
<style>
|
||||
{{ config.style }}
|
||||
</style>
|
||||
{% endif %}
|
||||
<title>{{ clean_query(query) }} - Whoogle Search</title>
|
||||
</head>
|
||||
<body>
|
||||
{{ search_header|safe }}
|
||||
{% if is_translation %}
|
||||
<iframe
|
||||
id="lingva-iframe"
|
||||
src="{{ lingva_url }}/auto/{{ translate_to }}/{{ translate_str }}">
|
||||
</iframe>
|
||||
{% endif %}
|
||||
{{ response|safe }}
|
||||
</body>
|
||||
{% include 'footer.html' %}
|
||||
{% if bundle_static() %}
|
||||
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||
{% else %}
|
||||
{% if autocomplete_enabled == '1' %}
|
||||
<script src="{{ cb_url('autocomplete.js') }}"></script>
|
||||
{% endif %}
|
||||
<script src="{{ cb_url('utils.js') }}"></script>
|
||||
<script src="{{ cb_url('keyboard.js') }}"></script>
|
||||
<script src="{{ cb_url('currency.js') }}"></script>
|
||||
{% endif %}
|
||||
</html>
|
||||
|
||||
@ -1,5 +1,128 @@
|
||||
<h1>Error</h1>
|
||||
<hr>
|
||||
<p>
|
||||
Error parsing "{{ query }}"
|
||||
</p>
|
||||
{% if config.theme %}
|
||||
{% if config.theme == 'system' %}
|
||||
<style>
|
||||
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||
</style>
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bundle_static() %}
|
||||
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
|
||||
<link rel="stylesheet" href="{{ cb_url('error.css') }}">
|
||||
{% endif %}
|
||||
<style>{{ config.style }}</style>
|
||||
<div>
|
||||
<h1>Error</h1>
|
||||
<p>
|
||||
{{ error_message }}
|
||||
</p>
|
||||
<hr>
|
||||
{% if query and translation %}
|
||||
<p>
|
||||
<h4><a class="link" href="https://farside.link">{{ translation['continue-search'] }}</a></h4>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/benbusby/whoogle-search">Whoogle</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="{{farside}}/whoogle/search?q={{query}}{{params}}">
|
||||
{{farside}}/whoogle/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/searxng/searxng">SearXNG</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="{{farside}}/searxng/search?q={{query}}">
|
||||
{{farside}}/searxng/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://git.lolcat.ca/lolcat/4get">4get</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="{{farside}}/4get/web?s={{query}}&scraper=google">
|
||||
{{farside}}/4get/web?s={{query}}&scraper=google
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h4>Other options:</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://kagi.com">Kagi</a>
|
||||
<ul>
|
||||
<li>Requires account</li>
|
||||
<li>
|
||||
<a class="link-color" href="https://kagi.com/search?q={{query}}">
|
||||
kagi.com/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://4get.ca">4get</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://4get.ca/web?s={{query}}">
|
||||
4get.ca/web?s={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://duckduckgo.com">DuckDuckGo</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://duckduckgo.com/?q={{query}}">
|
||||
duckduckgo.com/?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://search.brave.com">Brave Search</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://search.brave.com/search?q={{query}}">
|
||||
search.brave.com/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://ecosia.com">Ecosia</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://ecosia.com/search?q={{query}}">
|
||||
ecosia.com/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://google.com">Google</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-color" href="https://google.com/search?q={{query}}">
|
||||
google.com/search?q={{query}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
</p>
|
||||
{% endif %}
|
||||
<a class="link" href="home">Return Home</a>
|
||||
</div>
|
||||
|
||||
12
app/templates/footer.html
Normal file
@ -0,0 +1,12 @@
|
||||
<footer>
|
||||
<p class="footer">
|
||||
Whoogle Search v{{ version_number }} ||
|
||||
<a class="link" href="https://github.com/benbusby/whoogle-search">{{ translation['github-link'] }}</a>
|
||||
{% if has_update %}
|
||||
|| <span class="update_available">Update Available 🟢</span>
|
||||
{% endif %}
|
||||
{% if config.show_user_agent and used_user_agent %}
|
||||
<br><span class="user-agent-display" style="font-size: 0.85em; color: #666;">User Agent: {{ used_user_agent }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</footer>
|
||||
@ -1,63 +1,162 @@
|
||||
{% if mobile %}
|
||||
<header>
|
||||
<div class="bz1lBb">
|
||||
<form class="Pg70bf" id="search-form" method="POST">
|
||||
<a class="logo-link mobile-logo"
|
||||
href="/"
|
||||
style="display:flex; justify-content:center; align-items:center; color:#685e79; font-size:18px; ">
|
||||
<span class="V6gwVd">Wh</span><span class="iWkuvd">o</span><span class="cDrQ7">o</span><span
|
||||
class="V6gwVd">g</span><span class="ntlR9">l</span><span
|
||||
class="iWkuvd tJ3Myc">e</span>
|
||||
<div class="header-div">
|
||||
<form class="search-form header"
|
||||
id="search-form"
|
||||
method="{{ 'GET' if config.get_only else 'POST' }}">
|
||||
<a class="logo-link mobile-logo" href="{{ home_url }}">
|
||||
<div id="mobile-header-logo">
|
||||
{{ logo|safe }}
|
||||
</div>
|
||||
</a>
|
||||
<div class="H0PQec" style="width: 100%;">
|
||||
<div class="sbc esbc autocomplete">
|
||||
<input id="search-bar" autocapitalize="none" autocomplete="off" class="noHIxc" name="q"
|
||||
style="background-color: {{ '#000' if dark_mode else '#fff' }};
|
||||
color: {{ '#685e79' if dark_mode else '#000' }};
|
||||
border: {{ '1px solid #685e79' if dark_mode else '' }}"
|
||||
spellcheck="false" type="text" value="{{ query }}">
|
||||
<div class="H0PQec mobile-input-div">
|
||||
<div class="autocomplete-mobile esbc autocomplete">
|
||||
{% if config.preferences %}
|
||||
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||
{% endif %}
|
||||
<input
|
||||
id="search-bar"
|
||||
class="mobile-search-bar"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="search-bar-input"
|
||||
name="q"
|
||||
type="text"
|
||||
value="{{ clean_query(query) }}"
|
||||
dir="auto">
|
||||
<input id="search-reset" type="reset" value="x">
|
||||
<input name="tbm" value="{{ search_type }}" style="display: none">
|
||||
<input name="country" value="{{ config.country }}" style="display: none;">
|
||||
<input type="submit" style="display: none;">
|
||||
<div class="sc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<div class="header-tab-div">
|
||||
<div class="header-tab-div-2">
|
||||
<div class="header-tab-div-3">
|
||||
<div class="mobile-header header-tab">
|
||||
{% for tab_id, tab_content in tabs.items() %}
|
||||
{% if tab_content['selected'] %}
|
||||
<span class="mobile-tab-span">{{ tab_content['name'] }}</span>
|
||||
{% else %}
|
||||
<a class="header-tab-a" href="{{ tab_content['href'] }}">{{ tab_content['name'] }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<label for="adv-search-toggle" id="adv-search-label" class="adv-search">⚙</label>
|
||||
<input id="adv-search-toggle" type="checkbox">
|
||||
<div class="header-tab-div-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="" id="s">
|
||||
</div>
|
||||
</header>
|
||||
{% else %}
|
||||
<header>
|
||||
<div class="logo-div">
|
||||
<a class="logo-link" href="/">
|
||||
<span class="V6gwVd logo-letter">Wh</span><span class="iWkuvd logo-letter">o</span><span
|
||||
class="cDrQ7 logo-letter">o</span><span class="V6gwVd logo-letter">g</span><span
|
||||
class="ntlR9 logo-letter">l</span><span class="iWkuvd tJ3Myc logo-letter">e</span>
|
||||
<a class="logo-link" href="{{ home_url }}">
|
||||
<div class="desktop-header-logo">
|
||||
{{ logo|safe }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="search-div">
|
||||
<form id="search-form" class="search-form" id="sf" method="POST">
|
||||
<div class="autocomplete" style="width: 100%; flex: 1">
|
||||
<form id="search-form"
|
||||
class="search-form"
|
||||
id="sf"
|
||||
method="{{ 'GET' if config.get_only else 'POST' }}">
|
||||
<div class="autocomplete header-autocomplete">
|
||||
<div style="width: 100%; display: flex">
|
||||
<input id="search-bar" autocapitalize="none" autocomplete="off" class="noHIxc" name="q"
|
||||
spellcheck="false" type="text" value="{{ query }}"
|
||||
style="background-color: {{ '#000' if dark_mode else '#fff' }};
|
||||
color: {{ '#685e79' if dark_mode else '#000' }};
|
||||
border: {{ '1px solid #685e79' if dark_mode else '' }}">
|
||||
{% if config.preferences %}
|
||||
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||
{% endif %}
|
||||
<input
|
||||
id="search-bar"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class="search-bar-desktop search-bar-input"
|
||||
name="q"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value="{{ clean_query(query) }}"
|
||||
dir="auto">
|
||||
<input name="tbm" value="{{ search_type }}" style="display: none">
|
||||
<input name="country" value="{{ config.country }}" style="display: none;">
|
||||
<input name="tbs" value="{{ config.tbs }}" style="display: none;">
|
||||
<input type="submit" style="display: none;">
|
||||
<div class="sc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<div>
|
||||
<div class="header-tab-div">
|
||||
<div class="header-tab-div-2">
|
||||
<div class="header-tab-div-3">
|
||||
<div class="desktop-header header-tab">
|
||||
{% for tab_id, tab_content in tabs.items() %}
|
||||
{% if tab_content['selected'] %}
|
||||
<span class="header-tab-span">{{ tab_content['name'] }}</span>
|
||||
{% else %}
|
||||
<a class="header-tab-a" href="{{ tab_content['href'] }}">{{ tab_content['name'] }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<label for="adv-search-toggle" id="adv-search-label" class="adv-search">⚙</label>
|
||||
<input id="adv-search-toggle" type="checkbox">
|
||||
<div class="header-tab-div-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="" id="s">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="result-collapsible" id="adv-search-div">
|
||||
<div class="result-config">
|
||||
<label for="config-country">{{ translation['config-country'] }}: </label>
|
||||
<select name="country" id="result-country">
|
||||
{% for country in countries %}
|
||||
<option value="{{ country.value }}"
|
||||
{% if (
|
||||
config.country != '' and config.country in country.value
|
||||
) or (
|
||||
config.country == '' and country.value == '')
|
||||
%}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ country.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<br />
|
||||
<label for="config-time-period">{{ translation['config-time-period'] }}: </label>
|
||||
<select name="tbs" id="result-time-period">
|
||||
{% for time_period in time_periods %}
|
||||
<option value="{{ time_period.value }}"
|
||||
{% if (
|
||||
config.tbs != '' and config.tbs in time_period.value
|
||||
) or (
|
||||
config.tbs == '' and time_period.value == '')
|
||||
%}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ translation[time_period.value] }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const searchBar = document.getElementById("search-bar");
|
||||
|
||||
searchBar.addEventListener("keyup", function (event) {
|
||||
if (event.keyCode !== 13) {
|
||||
handleUserInput(searchBar);
|
||||
} else {
|
||||
document.getElementById("search-form").submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% if bundle_static() %}
|
||||
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||
{% else %}
|
||||
<script type="text/javascript" src="{{ cb_url('header.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
409
app/templates/imageresults.html
Normal file
@ -0,0 +1,409 @@
|
||||
<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 12px;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a img {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.FbhRzb {
|
||||
border-left: thin solid #dadce0;
|
||||
border-right: thin solid #dadce0;
|
||||
border-top: thin solid #dadce0;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.n692Zd {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cvifge {
|
||||
height: 40px;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.QvGUP {
|
||||
height: 40px;
|
||||
padding: 0 8px 0 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.O4cRJf {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
padding-right: 16px;
|
||||
}
|
||||
.O1ePr {
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.kgJEQe {
|
||||
height: 36px;
|
||||
width: 98px;
|
||||
vertical-align: top;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.lXLRf {
|
||||
vertical-align: top;
|
||||
}
|
||||
.MhzMZd {
|
||||
border: 0;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.xB0fq {
|
||||
height: 40px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
background-color: #4285f4;
|
||||
color: #fff;
|
||||
padding: 0 16px;
|
||||
margin: 0;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xB0fq:focus {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
.M7pB2 {
|
||||
border: thin solid #dadce0;
|
||||
margin: 0 0 3px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
height: 40px;
|
||||
}
|
||||
.euZec {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
border-spacing: 0;
|
||||
}
|
||||
table.euZec td {
|
||||
padding: 0;
|
||||
width: 25%;
|
||||
}
|
||||
.QIqI7 {
|
||||
display: inline-block;
|
||||
padding-top: 4px;
|
||||
font-weight: bold;
|
||||
color: #4285f4;
|
||||
}
|
||||
.EY24We {
|
||||
border-bottom: 2px solid #4285f4;
|
||||
}
|
||||
.CsQyDc {
|
||||
display: inline-block;
|
||||
color: #70757a;
|
||||
}
|
||||
.TuS8Ad {
|
||||
font-size: 14px;
|
||||
}
|
||||
.HddGcc {
|
||||
padding: 8px;
|
||||
color: #70757a;
|
||||
}
|
||||
.dzp8ae {
|
||||
font-weight: bold;
|
||||
color: #3c4043;
|
||||
}
|
||||
.rEM8G {
|
||||
color: #70757a;
|
||||
}
|
||||
.bookcf {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
.InWNIe {
|
||||
text-align: center;
|
||||
}
|
||||
.uZgmoc {
|
||||
border: thin solid #dadce0;
|
||||
color: #70757a;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
.frGj1b {
|
||||
display: block;
|
||||
padding: 12px 0 12px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.BnJWBc {
|
||||
text-align: center;
|
||||
padding: 6px 0 13px 0;
|
||||
height: 35px;
|
||||
}
|
||||
.e3goi {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
.GpQGbf {
|
||||
margin: auto;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.X6ZCif {
|
||||
color: #202124;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
display: inline-block;
|
||||
padding-top: 2px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.TwVfHd {
|
||||
border-radius: 16px;
|
||||
border: thin solid #dadce0;
|
||||
display: inline-block;
|
||||
padding: 8px 8px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.yekiAe {
|
||||
background-color: #dadce0;
|
||||
}
|
||||
.svla5d {
|
||||
width: 100%;
|
||||
}
|
||||
.ezO2md {
|
||||
border: thin solid #dadce0;
|
||||
padding: 12px 16px 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.TxbwNb {
|
||||
border-spacing: 0;
|
||||
}
|
||||
.K35ahc {
|
||||
width: 100%;
|
||||
}
|
||||
.owohpf {
|
||||
text-align: center;
|
||||
}
|
||||
.RAyV4b {
|
||||
height: 220px;
|
||||
line-height: 220px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
.t0fcAb {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
vertical-align: middle;
|
||||
object-fit: cover;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 220px;
|
||||
display: block;
|
||||
}
|
||||
.Tor4Ec {
|
||||
padding-top: 2px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.fYyStc {
|
||||
word-break: break-word;
|
||||
}
|
||||
.ynsChf {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.Fj3V3b {
|
||||
color: #1967d2;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.FrIlee {
|
||||
color: #202124;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.F9iS2e {
|
||||
color: #70757a;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.WMQ2Le {
|
||||
color: #70757a;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.x3G5ab {
|
||||
color: #202124;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.fuLhoc {
|
||||
color: #1967d2;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.epoveb {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: 400;
|
||||
color: #202124;
|
||||
}
|
||||
.dXDvrc {
|
||||
color: #0d652d;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.dloBPe {
|
||||
font-weight: bold;
|
||||
}
|
||||
.YVIcad {
|
||||
color: #70757a;
|
||||
}
|
||||
.JkVVdd {
|
||||
color: #ea4335;
|
||||
}
|
||||
.oXZRFd {
|
||||
color: #ea4335;
|
||||
}
|
||||
.MQHtg {
|
||||
color: #fbbc04;
|
||||
}
|
||||
.pyMRrb {
|
||||
color: #1e8e3e;
|
||||
}
|
||||
.EtTZid {
|
||||
color: #1e8e3e;
|
||||
}
|
||||
.M3vVJe {
|
||||
color: #1967d2;
|
||||
}
|
||||
.qXLe6d {
|
||||
display: block;
|
||||
}
|
||||
.NHQNef {
|
||||
font-style: italic;
|
||||
}
|
||||
.Cb8Z7c {
|
||||
white-space: pre;
|
||||
}
|
||||
a.ZWRArf {
|
||||
text-decoration: none;
|
||||
}
|
||||
a .CVA68e:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.e3goi {
|
||||
width: 25%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.svla5d {
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.e3goi {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.e3goi {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</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,137 +1,323 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/static/img/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/static/img/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/static/img/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/static/img/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/static/img/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/static/img/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/static/img/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/static/img/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/img/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/img/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/img/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/static/img/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/img/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/static/img/favicon/manifest.json">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/static/img/favicon/ms-icon-144x144.png">
|
||||
<script type="text/javascript" src="/static/js/autocomplete.js"></script>
|
||||
<script type="text/javascript" src="/static/js/controller.js"></script>
|
||||
<link rel="search" href="/opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/static/css/{{ 'search-dark' if config.dark else 'search' }}.css">
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
{% if config.dark %}
|
||||
<link rel="stylesheet" href="/static/css/dark-theme.css"/>
|
||||
<html style="background: #000;">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="static/img/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="static/img/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="static/img/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="static/img/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="static/img/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="static/img/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="static/img/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="static/img/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="static/img/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="static/img/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="static/img/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="static/img/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="static/img/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="static/img/favicon/manifest.json">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="static/img/favicon/ms-icon-144x144.png">
|
||||
{% if bundle_static() %}
|
||||
<script src="/{{ cb_url('bundle.js') }}" defer></script>
|
||||
{% else %}
|
||||
{% if autocomplete_enabled == '1' %}
|
||||
<script src="{{ cb_url('autocomplete.js') }}"></script>
|
||||
{% endif %}
|
||||
<title>Whoogle Search</title>
|
||||
</head>
|
||||
<body id="main" style="display: none; background-color: {{ '#000' if config.dark else '#fff' }}">
|
||||
<div class="search-container">
|
||||
<img class="logo" src="/static/img/logo.png">
|
||||
<form id="search-form" action="/search" method="{{ 'get' if config.get_only else 'post' }}">
|
||||
<div class="search-fields">
|
||||
<div class="autocomplete">
|
||||
<input type="text" name="q" id="search-bar" autofocus="autofocus" autocomplete="off">
|
||||
</div>
|
||||
<input type="submit" id="search-submit" value="Search">
|
||||
</div>
|
||||
</form>
|
||||
<br/>
|
||||
<button id="config-collapsible" class="collapsible">Configuration</button>
|
||||
<div class="content">
|
||||
<div class="config-fields">
|
||||
<form id="config-form" action="/config" method="post">
|
||||
<div class="config-div">
|
||||
<label for="config-ctry">Filter Results by Country: </label>
|
||||
<select name="ctry" id="config-ctry">
|
||||
{% for ctry in countries %}
|
||||
<option value="{{ ctry.value }}"
|
||||
{% if ctry.value in config.ctry %}
|
||||
<script type="text/javascript" src="{{ cb_url('controller.js') }}"></script>
|
||||
{% endif %}
|
||||
<link rel="search" href="opensearch.xml" type="application/opensearchdescription+xml" title="Whoogle Search">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% if bundle_static() %}
|
||||
<link rel="stylesheet" href="/{{ cb_url('bundle.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url('logo.css') }}">
|
||||
{% endif %}
|
||||
{% if config.theme %}
|
||||
{% if config.theme == 'system' %}
|
||||
<style>
|
||||
@import "{{ cb_url('light-theme.css') }}" screen;
|
||||
@import "{{ cb_url('dark-theme.css') }}" screen and (prefers-color-scheme: dark);
|
||||
</style>
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="{{ cb_url(config.theme + '-theme.css') }}"/>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not bundle_static() %}
|
||||
<link rel="stylesheet" href="{{ cb_url('main.css') }}">
|
||||
{% endif %}
|
||||
<noscript>
|
||||
<style>
|
||||
#main {
|
||||
display: inherit !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-height: 400px;
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
<style>{{ config.style }}</style>
|
||||
<title>Whoogle Search</title>
|
||||
</head>
|
||||
<body id="main">
|
||||
<div class="search-container">
|
||||
<div class="logo-container">
|
||||
{{ logo|safe }}
|
||||
</div>
|
||||
<form id="search-form" action="search" method="{{ 'get' if config.get_only else 'post' }}">
|
||||
<div class="search-fields">
|
||||
<div class="autocomplete">
|
||||
{% if config.preferences %}
|
||||
<input type="hidden" name="preferences" value="{{ config.preferences }}" />
|
||||
{% endif %}
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
id="search-bar"
|
||||
class="home-search"
|
||||
autofocus="autofocus"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
dir="auto">
|
||||
</div>
|
||||
<input type="submit" id="search-submit" value="{{ translation['search'] }}">
|
||||
</div>
|
||||
</form>
|
||||
{% if not config_disabled %}
|
||||
<br/>
|
||||
<button id="config-collapsible" class="collapsible">{{ translation['config'] }}</button>
|
||||
<div class="content">
|
||||
<div class="config-fields">
|
||||
<form id="config-form" action="config" method="post">
|
||||
<div class="config-options">
|
||||
<div class="config-div config-div-country">
|
||||
<label for="config-country">{{ translation['config-country'] }}: </label>
|
||||
<select name="country" id="config-country">
|
||||
{% for country in countries %}
|
||||
<option value="{{ country.value }}"
|
||||
{% if (
|
||||
config.country != '' and config.country in country.value
|
||||
) or (
|
||||
config.country == '' and country.value == '')
|
||||
%}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ ctry.name }}
|
||||
{{ country.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</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 class="config-div">
|
||||
<label for="config-lang-interface">Interface Language: </label>
|
||||
<label for="config-time-period">{{ translation['config-time-period'] }}</label>
|
||||
<select name="tbs" id="config-time-period">
|
||||
{% for time_period in time_periods %}
|
||||
<option value="{{ time_period.value }}"
|
||||
{% if (
|
||||
config.tbs != '' and config.tbs in time_period.value
|
||||
) or (
|
||||
config.tbs == '' and time_period.value == '')
|
||||
%}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ translation[time_period.value] }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div config-div-lang">
|
||||
<label for="config-lang-interface">{{ translation['config-lang'] }}: </label>
|
||||
<select name="lang_interface" id="config-lang-interface">
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang.value }}"
|
||||
{% if lang.value in config.lang_interface %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
<option value="{{ lang.value }}"
|
||||
{% if lang.value in config.lang_interface %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<label for="config-lang-search">Search Language: </label>
|
||||
<div class="config-div config-div-search-lang">
|
||||
<label for="config-lang-search">{{ translation['config-lang-search'] }}: </label>
|
||||
<select name="lang_search" id="config-lang-search">
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang.value }}"
|
||||
{% if lang.value in config.lang_search %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
<option value="{{ lang.value }}"
|
||||
{% if lang.value in config.lang_search %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<label for="config-near">Near: </label>
|
||||
<input type="text" name="near" id="config-near" placeholder="City Name">
|
||||
<div class="config-div config-div-near">
|
||||
<label for="config-near">{{ translation['config-near'] }}: </label>
|
||||
<input type="text" name="near" id="config-near"
|
||||
placeholder="{{ translation['config-near-help'] }}" value="{{ config.near }}">
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<label for="config-nojs">Show NoJS Links: </label>
|
||||
<input type="checkbox" name="nojs" id="config-nojs">
|
||||
<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">
|
||||
<label for="config-dark">Dark Mode: </label>
|
||||
<input type="checkbox" name="dark" id="config-dark">
|
||||
<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">
|
||||
<label for="config-safe">Safe Search: </label>
|
||||
<input type="checkbox" name="safe" id="config-safe">
|
||||
<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">
|
||||
<label class="tooltip" for="config-alts">Replace Social Media Links: </label>
|
||||
<input type="checkbox" name="alts" id="config-alts">
|
||||
<div><span class="info-text"> — Replaces Twitter/YouTube/Instagram links
|
||||
with Nitter/Invidious/Bibliogram links.</span></div>
|
||||
<div class="config-div config-div-anon-view">
|
||||
<label for="config-anon-view">{{ translation['config-anon-view'] }}: </label>
|
||||
<input type="checkbox" name="anon_view" id="config-anon-view" {{ 'checked' if config.anon_view else '' }}>
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<label for="config-new-tab">Open Links in New Tab: </label>
|
||||
<input type="checkbox" name="new_tab" id="config-new-tab">
|
||||
<div class="config-div config-div-nojs">
|
||||
<label for="config-nojs">{{ translation['config-nojs'] }}: </label>
|
||||
<input type="checkbox" name="nojs" id="config-nojs" {{ 'checked' if config.nojs else '' }}>
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<label for="config-get-only">GET Requests Only: </label>
|
||||
<input type="checkbox" name="get_only" id="config-get-only">
|
||||
<div class="config-div config-div-theme">
|
||||
<label for="config-theme">{{ translation['config-theme'] }}: </label>
|
||||
<select name="theme" id="config-theme">
|
||||
{% for theme in themes %}
|
||||
<option value="{{ theme }}"
|
||||
{% if theme in config.theme %}
|
||||
selected
|
||||
{% endif %}>
|
||||
{{ translation[theme].capitalize() }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<label for="config-url">Root URL: </label>
|
||||
<input type="text" name="url" id="config-url" value="">
|
||||
<!-- DEPRECATED -->
|
||||
<div class="config-div config-div-safe">
|
||||
<label for="config-safe">{{ translation['config-safe'] }}: </label>
|
||||
<input type="checkbox" name="safe" id="config-safe" {{ 'checked' if config.safe else '' }}>
|
||||
</div>
|
||||
<div class="config-div">
|
||||
<input type="submit" id="config-load" onclick="loadConfig(event)" value="Load">
|
||||
<input type="submit" id="config-submit" value="Apply">
|
||||
<input type="submit" id="config-submit" onclick="saveConfig(event)" value="Save As...">
|
||||
<div class="config-div config-div-alts">
|
||||
<label class="tooltip" for="config-alts">{{ translation['config-alts'] }}: </label>
|
||||
<input type="checkbox" name="alts" id="config-alts" {{ 'checked' if config.alts else '' }}>
|
||||
<div><span class="info-text"> — {{ translation['config-alts-help'] }}</span></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="config-div config-div-new-tab">
|
||||
<label for="config-new-tab">{{ translation['config-new-tab'] }}: </label>
|
||||
<input type="checkbox" name="new_tab"
|
||||
id="config-new-tab" {{ 'checked' if config.new_tab else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-view-image">
|
||||
<label for="config-view-image">{{ translation['config-images'] }}: </label>
|
||||
<input type="checkbox" name="view_image"
|
||||
id="config-view-image" {{ 'checked' if config.view_image else '' }}>
|
||||
<div><span class="info-text"> — {{ translation['config-images-help'] }}</span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-tor">
|
||||
<label for="config-tor">{{ translation['config-tor'] }}: {{ '' if tor_available else 'Unavailable' }}</label>
|
||||
<input type="checkbox" name="tor"
|
||||
id="config-tor" {{ '' if tor_available else 'hidden' }} {{ 'checked' if config.tor else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-get-only">
|
||||
<label for="config-get-only">{{ translation['config-get-only'] }}: </label>
|
||||
<input type="checkbox" name="get_only"
|
||||
id="config-get-only" {{ 'checked' if config.get_only else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-user-agent">
|
||||
<label for="config-user-agent">User Agent: </label>
|
||||
<select name="user_agent" id="config-user-agent">
|
||||
<option value="env_conf" {% if config.user_agent == 'env_conf' %}selected{% endif %}>Use ENV Conf</option>
|
||||
<option value="default" {% if config.user_agent == 'default' %}selected{% endif %}>Default</option>
|
||||
<option value="custom" {% if config.user_agent == 'custom' %}selected{% endif %}>Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-div config-div-custom-user-agent" {% if config.user_agent != 'custom' %}style="display: none;"{% endif %}>
|
||||
<label for="config-custom-user-agent">Custom User Agent: </label>
|
||||
<input type="text" name="custom_user_agent" id="config-custom-user-agent"
|
||||
value="{{ config.custom_user_agent }}"
|
||||
placeholder="Enter custom user agent string">
|
||||
<div><span class="info-text"> — <a href="https://github.com/benbusby/whoogle-search/wiki/User-Agents">User Agent Wiki</a></span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-accept-language">
|
||||
<label for="config-accept-language">Set Accept-Language: </label>
|
||||
<input type="checkbox" name="accept_language"
|
||||
id="config-accept-language" {{ 'checked' if config.accept_language else '' }}>
|
||||
</div>
|
||||
<div class="config-div config-div-show-user-agent">
|
||||
<label for="config-show-user-agent">Show User Agent in Footer: </label>
|
||||
<input type="checkbox" name="show_user_agent"
|
||||
id="config-show-user-agent" {{ 'checked' if config.show_user_agent else '' }}>
|
||||
</div>
|
||||
<!-- Google Custom Search Engine (BYOK) Settings -->
|
||||
<div class="config-div config-div-cse-header" style="margin-top: 20px; border-top: 1px solid var(--result-bg); padding-top: 15px;">
|
||||
<strong>Google Custom Search (BYOK)</strong>
|
||||
<div><span class="info-text"> — <a href="https://github.com/benbusby/whoogle-search#google-custom-search-byok">Setup Guide</a></span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-use-cse">
|
||||
<label for="config-use-cse">Use Custom Search API: </label>
|
||||
<input type="checkbox" name="use_cse" id="config-use-cse" {{ 'checked' if config.use_cse else '' }}>
|
||||
<div><span class="info-text"> — Enable to use your own Google API key (100 free queries/day)</span></div>
|
||||
</div>
|
||||
<div class="config-div config-div-cse-api-key">
|
||||
<label for="config-cse-api-key">CSE API Key: </label>
|
||||
<input type="password" name="cse_api_key" id="config-cse-api-key"
|
||||
value="{{ config.cse_api_key }}"
|
||||
placeholder="AIza..."
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="config-div config-div-cse-id">
|
||||
<label for="config-cse-id">CSE ID: </label>
|
||||
<input type="text" name="cse_id" id="config-cse-id"
|
||||
value="{{ config.cse_id }}"
|
||||
placeholder="abc123..."
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="config-div config-div-root-url">
|
||||
<label for="config-url">{{ translation['config-url'] }}: </label>
|
||||
<input type="text" name="url" id="config-url" value="{{ config.url }}">
|
||||
</div>
|
||||
<div class="config-div config-div-custom-css">
|
||||
<a id="css-link"
|
||||
href="https://github.com/benbusby/whoogle-search/wiki/User-Contributed-CSS-Themes">
|
||||
{{ translation['config-css'] }}:
|
||||
</a>
|
||||
<textarea
|
||||
name="style_modified"
|
||||
id="config-style"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
value="">{{ config.style_modified.replace('\t', '') }}</textarea>
|
||||
</div>
|
||||
<div class="config-div config-div-pref-url">
|
||||
<label for="config-pref-encryption">{{ translation['config-pref-encryption'] }}: </label>
|
||||
<input type="checkbox" name="preferences_encrypted"
|
||||
id="config-pref-encryption" {{ 'checked' if config.preferences_encrypted and config.preferences_key else '' }}>
|
||||
<div><span class="info-text"> — {{ translation['config-pref-help'] }}</span></div>
|
||||
<label for="config-pref-url">{{ translation['config-pref-url'] }}: </label>
|
||||
<input type="text" name="pref-url" id="config-pref-url" value="{{ config.url }}?preferences={{ config.preferences }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-div config-buttons">
|
||||
<input type="submit" id="config-load" value="{{ translation['load'] }}">
|
||||
<input type="submit" id="config-submit" value="{{ translation['apply'] }}">
|
||||
<input type="submit" id="config-save" value="{{ translation['save-as'] }}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<p style="color: {{ '#fff' if config.dark else '#000' }};">
|
||||
Whoogle Search v{{ version_number }} ||
|
||||
<a style="color: #685e79" href="https://github.com/benbusby/whoogle-search">View on GitHub</a>
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'footer.html' %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
18
app/templates/logo.html
Normal file
@ -0,0 +1,18 @@
|
||||
<svg id="Layer_1" class="whoogle-svg" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1028 254">
|
||||
<defs>
|
||||
<style>
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M1197,667H446V413H1474V667H1208a26.41,26.41,0,0,1,4.26-1.16c32.7-3.35,55.65-27.55,56.45-60.44.57-23.65.27-47.33.32-71,0-17.84-.16-35.67.11-53.5.07-4.92-1.57-6.54-6.3-6.11a74.65,74.65,0,0,1-11,0c-3.63-.2-5.18,1.13-5,4.87.22,4.22.05,8.45.05,12.68a6.16,6.16,0,0,1-3.78-2c-20-23.41-53.18-26.6-77.53-7.84-34,26.17-33.8,79.89-7.68,107.44,24.9,26.24,66,24.37,85.69-1.54a14.39,14.39,0,0,1,2.73-2c0,6.94.39,13.22-.08,19.42-1.18,15.5-7.79,28.06-22.32,34.72-15,6.85-30.27,7.21-44-2.92-5.82-4.28-10.1-10.66-15.66-16.71l-19.87,8.29c8.77,16.61,20.28,29.09,38.17,34.48C1187.28,665.12,1192.18,665.92,1197,667ZM447.16,414.27c.39,1.85.57,3,.86,4q25.22,91.07,50.4,182.12c.92,3.32,2.43,4.55,5.92,4.29a82,82,0,0,1,13.48,0c4.6.43,6.56-1.13,8-5.68,12.37-38.63,25-77.15,37.66-115.7.52-1.6,1.26-3.12,1.89-4.67l1.35.06c.81,2.26,1.68,4.51,2.42,6.79q18.62,57.13,37.12,114.31c1.13,3.5,2.61,5.23,6.58,4.89a80.69,80.69,0,0,1,14,0c4.15.37,5.75-1.19,6.79-5.11Q655,518.89,676.57,438.23c2.07-7.78,4.06-15.58,6.24-24-6.92,0-13.07.29-19.19-.11-4.21-.27-5.6,1.31-6.59,5.25q-17.61,70.1-35.6,140.11c-.42,1.61-1.07,3.17-1.62,4.75a10,10,0,0,1-3.16-4.88q-17.11-51.6-34.21-103.21c-1.72-5.19-2.29-12.33-6-14.86-3.9-2.7-10.86-.78-16.45-1.28-4.1-.37-5.73,1.25-7,5.08q-18.7,57.12-37.79,114.11c-.59,1.77-1.43,3.45-2.15,5.18a9.31,9.31,0,0,1-2.68-4.69Q500.5,522.88,490.62,486c-6-22.47-12-45-18.13-67.39-.44-1.63-2-4.13-3.12-4.19C462.13,414.08,454.86,414.27,447.16,414.27ZM1473.38,543.71c-1-8.62-1.16-16.45-2.77-24-5.08-23.65-18.41-40.82-42.31-47.12-24.75-6.52-47.33-2-65,18.14-15.82,18.09-19.77,39.44-16.45,62.6,4,27.73,26.6,52.65,58.1,54.81,21.42,1.46,39.91-3.91,54.24-20.46,3.51-4.05,6.13-8.88,9.54-13.92l-20.94-8.68c-13.71,20.22-30.84,26.7-50.55,19.53-17.08-6.21-29-23.88-27.23-40.92Zm-746-51.07-1.12-.55V414.65H703.69V604.22h23v-6.36c0-21.84-.08-43.68,0-65.52.07-11.59,3.84-21.92,11.82-30.46,9.41-10.07,21.15-11.89,34-8.78,11.13,2.72,17.67,10.23,20.26,21.14a55.72,55.72,0,0,1,1.46,12.34c.13,24,.07,48,.07,72v5.6h23.49v-4.87c0-24.84.05-49.68-.06-74.52a101.29,101.29,0,0,0-1.06-13.91c-2.8-19.45-15.29-34.48-32.34-38.55-21.17-5-39.58-.47-54.11,16.51C729.19,490.07,728.29,491.38,727.34,492.64Zm179.93-22.47c-38.65,0-66.92,28.86-67,68.47-.06,40.49,28.07,70,66.72,70,38.38,0,66.64-29.26,66.67-69C973.71,499.1,946.09,470.21,907.27,470.17Zm82.22,69.31c.57,5.12.76,10.32,1.76,15.35,10.69,53.81,69.71,66.73,104.35,41.39,20.15-14.74,27.8-35.52,27.31-60.14-.88-44.18-40.84-78.15-90-62.12C1006.24,482.67,989.72,508.59,989.49,539.48Zm333.81,64.95V414.62h-22.65V604.43Z" transform="translate(-446 -413)"></path>
|
||||
<path id="whoogle-g" d="M1197,667c-4.82-1.08-9.72-1.88-14.44-3.3-17.89-5.39-29.4-17.87-38.17-34.48l19.87-8.29c5.56,6.05,9.84,12.43,15.66,16.71,13.75,10.13,29.07,9.77,44,2.92,14.53-6.66,21.14-19.22,22.32-34.72.47-6.2.08-12.48.08-19.42a14.39,14.39,0,0,0-2.73,2c-19.7,25.91-60.79,27.78-85.69,1.54-26.12-27.55-26.3-81.27,7.68-107.44,24.35-18.76,57.56-15.57,77.53,7.84a6.16,6.16,0,0,0,3.78,2c0-4.23.17-8.46-.05-12.68-.19-3.74,1.36-5.07,5-4.87a74.65,74.65,0,0,0,11,0c4.73-.43,6.37,1.19,6.3,6.11-.27,17.83-.08,35.66-.11,53.5,0,23.67.25,47.35-.32,71-.8,32.89-23.75,57.09-56.45,60.44A26.41,26.41,0,0,0,1208,667Zm50-127.58c-.58-4.61-.86-9.29-1.79-13.83a42.26,42.26,0,0,0-37.31-33.75c-16.16-1.75-33.25,8.46-40.62,24.47-5.34,11.62-5.79,23.83-3.48,36.18,5.94,31.62,42.76,45.77,66.74,25.67C1242.58,568.08,1246.76,554.62,1247,539.42Z" transform="translate(-446 -413)"></path>
|
||||
<path id="whoogle-w" d="M447.16,414.27c7.7,0,15-.19,22.21.19,1.14.06,2.68,2.56,3.12,4.19,6.13,22.44,12.1,44.92,18.13,67.39q9.88,36.84,19.81,73.66a9.31,9.31,0,0,0,2.68,4.69c.72-1.73,1.56-3.41,2.15-5.18q19-57,37.79-114.11c1.25-3.83,2.88-5.45,7-5.08,5.59.5,12.55-1.42,16.45,1.28,3.67,2.53,4.24,9.67,6,14.86q17.14,51.58,34.21,103.21a10,10,0,0,0,3.16,4.88c.55-1.58,1.2-3.14,1.62-4.75q17.87-70,35.6-140.11c1-3.94,2.38-5.52,6.59-5.25,6.12.4,12.27.11,19.19.11-2.18,8.4-4.17,16.2-6.24,24q-21.5,80.68-42.93,161.39c-1,3.92-2.64,5.48-6.79,5.11a80.69,80.69,0,0,0-14,0c-4,.34-5.45-1.39-6.58-4.89q-18.43-57.2-37.12-114.31c-.74-2.28-1.61-4.53-2.42-6.79l-1.35-.06c-.63,1.55-1.37,3.07-1.89,4.67-12.61,38.55-25.29,77.07-37.66,115.7-1.46,4.55-3.42,6.11-8,5.68a82,82,0,0,0-13.48,0c-3.49.26-5-1-5.92-4.29Q473.31,509.34,448,418.3C447.73,417.23,447.55,416.12,447.16,414.27Z" transform="translate(-446 -413)"></path>
|
||||
<path id="whoogle-e" d="M1473.38,543.71H1370c-1.76,17,10.15,34.71,27.23,40.92,19.71,7.17,36.84.69,50.55-19.53l20.94,8.68c-3.41,5-6,9.87-9.54,13.92-14.33,16.55-32.82,21.92-54.24,20.46-31.5-2.16-54.12-27.08-58.1-54.81-3.32-23.16.63-44.51,16.45-62.6,17.64-20.17,40.22-24.66,65-18.14,23.9,6.3,37.23,23.47,42.31,47.12C1472.22,527.26,1472.43,535.09,1473.38,543.71Zm-26.69-19.8c2.09-14-14.21-30.54-31.43-32.19-22.21-2.13-43.06,13.12-43.63,32.19Z" transform="translate(-446 -413)"></path>
|
||||
<path id="whoogle-h" d="M727.34,492.64c.95-1.26,1.85-2.57,2.88-3.77,14.53-17,32.94-21.55,54.11-16.51,17,4.07,29.54,19.1,32.34,38.55a101.29,101.29,0,0,1,1.06,13.91c.11,24.84.06,49.68.06,74.52v4.87H794.3v-5.6c0-24,.06-48-.07-72a55.72,55.72,0,0,0-1.46-12.34c-2.59-10.91-9.13-18.42-20.26-21.14-12.81-3.11-24.55-1.29-34,8.78-8,8.54-11.75,18.87-11.82,30.46-.12,21.84,0,43.68,0,65.52v6.36h-23V414.65h22.53v77.44Z" transform="translate(-446 -413)"></path>
|
||||
<path id="whoogle-o-1" d="M907.27,470.17c38.82,0,66.44,28.93,66.41,69.47,0,39.73-28.29,69-66.67,69-38.65,0-66.78-29.5-66.72-70C840.35,499,868.62,470.13,907.27,470.17Zm43.24,69.26c-.43-3.79-.72-7.61-1.31-11.37-2.94-18.67-19.1-34.56-36.86-36.35-19.93-2-37.94,8.92-45,27.58-3.74,9.85-4.19,20-2.68,30.44,4,27.42,32.55,44.52,57.87,34.41C939.6,577.32,950.2,560.25,950.51,539.43Z" transform="translate(-446 -413)"></path>
|
||||
<path id="whoogle-o-2" d="M989.49,539.48c.23-30.89,16.75-56.81,43.45-65.52,49.13-16,89.09,17.94,90,62.12.49,24.62-7.16,45.4-27.31,60.14-34.64,25.34-93.66,12.42-104.35-41.39C990.25,549.8,990.06,544.6,989.49,539.48Zm110.22-.09c-.48-4.29-.7-8.62-1.5-12.84-3.43-18.06-19.37-33.16-36.57-34.84-20.05-2-37.75,8.9-45,27.62-3.51,9.06-3.74,18.45-3,28,2.23,27.4,30.07,46.21,55.87,37.67C1088,578.9,1099.32,561.53,1099.71,539.39Z" transform="translate(-446 -413)"></path>
|
||||
<path id="whoogle-l" d="M1323.3,604.43h-22.65V414.62h22.65Z" transform="translate(-446 -413)"></path>
|
||||
<path class="cls-1" d="M1247,539.42c-.24,15.2-4.42,28.66-16.46,38.74-24,20.1-60.8,6-66.74-25.67-2.31-12.35-1.86-24.56,3.48-36.18,7.37-16,24.46-26.22,40.62-24.47a42.26,42.26,0,0,1,37.31,33.75C1246.14,530.13,1246.42,534.81,1247,539.42Z" transform="translate(-446 -413)"></path>
|
||||
<path class="cls-1" d="M1446.69,523.91h-75.06c.57-19.07,21.42-34.32,43.63-32.19C1432.48,493.37,1448.78,509.88,1446.69,523.91Z" 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>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
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>
|
||||
150
app/utils/bangs.py
Normal file
@ -0,0 +1,150 @@
|
||||
import json
|
||||
import httpx
|
||||
import urllib.parse as urlparse
|
||||
import os
|
||||
import glob
|
||||
|
||||
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:
|
||||
"""Generates a json file from the DDG bangs list
|
||||
|
||||
Args:
|
||||
bangs_file: The str path to the new DDG bangs json file
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
# Request full list from DDG
|
||||
r = httpx.get(DDG_BANGS)
|
||||
r.raise_for_status()
|
||||
|
||||
# Convert to json
|
||||
data = json.loads(r.text)
|
||||
|
||||
# Set up a json object (with better formatting) for all available bangs
|
||||
bangs_data = {}
|
||||
|
||||
for row in data:
|
||||
bang_command = '!' + row['t']
|
||||
bangs_data[bang_command] = {
|
||||
'url': row['u'].replace('{{{s}}}', '{}'),
|
||||
'suggestion': bang_command + ' (' + row['s'] + ')'
|
||||
}
|
||||
|
||||
with open(bangs_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(bangs_data, f)
|
||||
print('* Finished creating ddg bangs json')
|
||||
load_all_bangs(bangs_file, bangs_data)
|
||||
|
||||
|
||||
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
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
|
||||
Returns:
|
||||
str: A formatted redirect for a bang search, or an empty str if there
|
||||
wasn't a match or didn't contain a bang operator
|
||||
|
||||
"""
|
||||
global bangs_dict
|
||||
|
||||
#if ! not in query simply return (speed up processing)
|
||||
if '!' not in query:
|
||||
return ''
|
||||
|
||||
split_query = query.strip().split(' ')
|
||||
|
||||
# look for operator in query if one is found, list operator should be of
|
||||
# length 1, operator should not be case-sensitive here to remove it later
|
||||
operator = [
|
||||
word
|
||||
for word in split_query
|
||||
if word.lower() in bangs_dict
|
||||
]
|
||||
if len(operator) == 1:
|
||||
# get operator
|
||||
operator = operator[0]
|
||||
|
||||
# removes operator from query
|
||||
split_query.remove(operator)
|
||||
|
||||
# rebuild the query string
|
||||
bang_query = ' '.join(split_query).strip()
|
||||
|
||||
# Check if operator is a key in bangs and get bang if exists
|
||||
bang = bangs_dict.get(operator.lower(), None)
|
||||
if bang:
|
||||
bang_url = bang['url']
|
||||
|
||||
if bang_query:
|
||||
return bang_url.replace('{}', bang_query, 1)
|
||||
else:
|
||||
parsed_url = urlparse.urlparse(bang_url)
|
||||
return f'{parsed_url.scheme}://{parsed_url.netloc}'
|
||||
return ''
|
||||
@ -1,79 +0,0 @@
|
||||
from bs4 import BeautifulSoup
|
||||
import urllib.parse as urlparse
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
SKIP_ARGS = ['ref_src', 'utm']
|
||||
FULL_RES_IMG = '<br/><a href="{}">Full Image</a>'
|
||||
GOOG_IMG = '/images/branding/searchlogo/1x/googlelogo'
|
||||
LOGO_URL = GOOG_IMG + '_desk'
|
||||
BLANK_B64 = '''
|
||||
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkwAIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC
|
||||
'''
|
||||
|
||||
BLACKLIST = [
|
||||
'ad', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告', 'Reklama', 'Реклама', 'Anunț', '광고',
|
||||
'annons', 'Annonse', 'Iklan', '広告', 'Augl.', 'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन', 'Reklam',
|
||||
'آگهی', 'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés'
|
||||
]
|
||||
|
||||
SITE_ALTS = {
|
||||
'twitter.com': 'nitter.net',
|
||||
'youtube.com': 'invidiou.site',
|
||||
'instagram.com': 'bibliogram.art/u'
|
||||
}
|
||||
|
||||
|
||||
def has_ad_content(element: str):
|
||||
return element.upper() in (value.upper() for value in BLACKLIST) or 'ⓘ' in element
|
||||
|
||||
|
||||
def get_first_link(soup):
|
||||
# Replace hrefs with only the intended destination (no "utm" type tags)
|
||||
for a in soup.find_all('a', href=True):
|
||||
# Return the first search result URL
|
||||
if 'url?q=' in a['href']:
|
||||
return filter_link_args(a['href'])
|
||||
|
||||
|
||||
def get_site_alt(link: str):
|
||||
for site_key in SITE_ALTS.keys():
|
||||
if site_key not in link:
|
||||
continue
|
||||
|
||||
link = link.replace(site_key, SITE_ALTS[site_key])
|
||||
break
|
||||
|
||||
return link
|
||||
|
||||
|
||||
def filter_link_args(query_link):
|
||||
parsed_link = urlparse.urlparse(query_link)
|
||||
link_args = parse_qs(parsed_link.query)
|
||||
safe_args = {}
|
||||
|
||||
if len(link_args) == 0 and len(parsed_link) > 0:
|
||||
return query_link
|
||||
|
||||
for arg in link_args.keys():
|
||||
if arg in SKIP_ARGS:
|
||||
continue
|
||||
|
||||
safe_args[arg] = link_args[arg]
|
||||
|
||||
# Remove original link query and replace with filtered args
|
||||
query_link = query_link.replace(parsed_link.query, '')
|
||||
if len(safe_args) > 0:
|
||||
query_link = query_link + urlparse.urlencode(safe_args, doseq=True)
|
||||
else:
|
||||
query_link = query_link.replace('?', '')
|
||||
|
||||
return query_link
|
||||
|
||||
|
||||
def gen_nojs(sibling):
|
||||
nojs_link = BeautifulSoup().new_tag('a')
|
||||
nojs_link['href'] = '/window?location=' + sibling['href']
|
||||
nojs_link['style'] = 'display:block;width:100%;'
|
||||
nojs_link.string = 'NoJS Link: ' + nojs_link['href']
|
||||
sibling.append(BeautifulSoup('<br><hr><br>', 'html.parser'))
|
||||
sibling.append(nojs_link)
|
||||
139
app/utils/misc.py
Normal file
@ -0,0 +1,139 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
from bs4 import BeautifulSoup as bsoup
|
||||
from cryptography.fernet import Fernet
|
||||
from flask import Request
|
||||
|
||||
ddg_favicon_site = 'http://icons.duckduckgo.com/ip2'
|
||||
|
||||
empty_gif = base64.b64decode(
|
||||
'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')
|
||||
|
||||
placeholder_img = base64.b64decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABF0lEQVRIS8XWPw9EMBQA8Eok' \
|
||||
'JBKrMFqMBt//GzAYLTZ/VomExPDu6uLiaPteqVynBn0/75W2Vp7nEIYhe6p1XcespmmAd7Is' \
|
||||
'M+4URcGiKPogvMMvmIS2eN9MOMKbKWgf54SYgI4vKkTuQKJKSJErkKzUSkQHUs0lilAg7GMh' \
|
||||
'ISoIA/hYMiKCKIA2soeowCWEMkfHtUmrXLcyGYYBfN9HF8djiaglWzNZlgVs21YisoAUaEXG' \
|
||||
'cQTP86QIFgi7vyLzPIPjOEIEC7ANQv/4aZrAdd0TUtc1i+MYnSsMWjPp+x6CIPgJVlUVS5KE' \
|
||||
'DKig/+wnVzM4pnzaGeHd+ENlWbI0TbVLJBtw2uMfP63wc9d2kDCWxi5Q27bsBerSJ9afJbeL' \
|
||||
'AAAAAElFTkSuQmCC'
|
||||
)
|
||||
|
||||
|
||||
def fetch_favicon(url: str) -> bytes:
|
||||
"""Fetches a favicon using DuckDuckGo's favicon retriever
|
||||
|
||||
Args:
|
||||
url: The url to fetch the favicon from
|
||||
Returns:
|
||||
bytes - the favicon bytes, or a placeholder image if one
|
||||
was not returned
|
||||
"""
|
||||
response = httpx.get(f'{ddg_favicon_site}/{urlparse(url).netloc}.ico')
|
||||
|
||||
if response.status_code == 200 and len(response.content) > 0:
|
||||
tmp_mem = io.BytesIO()
|
||||
tmp_mem.write(response.content)
|
||||
tmp_mem.seek(0)
|
||||
|
||||
return tmp_mem.read()
|
||||
return placeholder_img
|
||||
|
||||
|
||||
def gen_file_hash(path: str, static_file: str) -> str:
|
||||
with open(os.path.join(path, static_file), 'rb') as f:
|
||||
file_contents = f.read()
|
||||
file_hash = hashlib.md5(file_contents).hexdigest()[:8]
|
||||
filename_split = os.path.splitext(static_file)
|
||||
|
||||
return f'{filename_split[0]}.{file_hash}{filename_split[-1]}'
|
||||
|
||||
|
||||
def read_config_bool(var: str, default: bool=False) -> bool:
|
||||
val = os.getenv(var, '1' if default else '0')
|
||||
# user can specify one of the following values as 'true' inputs (all
|
||||
# variants with upper case letters will also work):
|
||||
# ('true', 't', '1', 'yes', 'y')
|
||||
return val.lower() in ('true', 't', '1', 'yes', 'y')
|
||||
|
||||
|
||||
def get_client_ip(r: Request) -> str:
|
||||
if r.environ.get('HTTP_X_FORWARDED_FOR') is None:
|
||||
return r.environ['REMOTE_ADDR']
|
||||
|
||||
return r.environ['HTTP_X_FORWARDED_FOR']
|
||||
|
||||
|
||||
def get_request_url(url: str) -> str:
|
||||
if os.getenv('HTTPS_ONLY', False):
|
||||
return url.replace('http://', 'https://', 1)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def get_proxy_host_url(r: Request, default: str, root=False) -> str:
|
||||
scheme = r.headers.get('X-Forwarded-Proto', 'https')
|
||||
http_host = r.headers.get('X-Forwarded-Host')
|
||||
|
||||
full_path = r.full_path if not root else ''
|
||||
if full_path.startswith('/'):
|
||||
full_path = f'/{full_path}'
|
||||
|
||||
if http_host:
|
||||
prefix = os.environ.get('WHOOGLE_URL_PREFIX', '')
|
||||
if prefix:
|
||||
prefix = f'/{re.sub("[^0-9a-zA-Z]+", "", prefix)}'
|
||||
return f'{scheme}://{http_host}{prefix}{full_path}'
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def check_for_update(version_url: str, current: str) -> int:
|
||||
# Check for the latest version of Whoogle
|
||||
has_update = ''
|
||||
with contextlib.suppress(httpx.RequestError, AttributeError):
|
||||
update = bsoup(httpx.get(version_url).text, 'html.parser')
|
||||
latest = update.select_one('[class="Link--primary"]').string[1:]
|
||||
current = int(''.join(filter(str.isdigit, current)))
|
||||
latest = int(''.join(filter(str.isdigit, latest)))
|
||||
has_update = '' if current >= latest else latest
|
||||
|
||||
return has_update
|
||||
|
||||
|
||||
def get_abs_url(url, page_url):
|
||||
# Creates a valid absolute URL using a partial or relative URL
|
||||
urls = {
|
||||
"//": f"https:{url}",
|
||||
"/": f"{urlparse(page_url).netloc}{url}",
|
||||
"./": f"{page_url}{url[2:]}"
|
||||
}
|
||||
for start in urls:
|
||||
if url.startswith(start):
|
||||
return urls[start]
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def list_to_dict(lst: list) -> dict:
|
||||
if len(lst) < 2:
|
||||
return {}
|
||||
return {lst[i].replace(' ', ''): lst[i+1].replace(' ', '')
|
||||
for i in range(0, len(lst), 2)}
|
||||
|
||||
|
||||
def encrypt_string(key: bytes, string: str) -> str:
|
||||
cipher_suite = Fernet(key)
|
||||
return cipher_suite.encrypt(string.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_string(key: bytes, string: str) -> str:
|
||||
cipher_suite = Fernet(g.session_key)
|
||||
return cipher_suite.decrypt(string.encode()).decode()
|
||||
462
app/utils/results.py
Normal file
@ -0,0 +1,462 @@
|
||||
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 urllib.parse as urlparse
|
||||
from urllib.parse import parse_qs
|
||||
import re
|
||||
warnings.filterwarnings('ignore', category=MarkupResemblesLocatorWarning)
|
||||
|
||||
SKIP_ARGS = ['ref_src', 'utm']
|
||||
SKIP_PREFIX = ['//www.', '//mobile.', '//m.']
|
||||
GOOG_STATIC = 'www.gstatic.com'
|
||||
G_M_LOGO_URL = 'https://www.gstatic.com/m/images/icons/googleg.gif'
|
||||
GOOG_IMG = '/images/branding/searchlogo/1x/googlelogo'
|
||||
LOGO_URL = GOOG_IMG + '_desk'
|
||||
BLANK_B64 = ('data:image/png;base64,'
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkw'
|
||||
'AIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC')
|
||||
|
||||
# Ad keywords
|
||||
BLACKLIST = [
|
||||
'ad', 'ads', 'anuncio', 'annuncio', 'annonce', 'Anzeige', '广告', '廣告',
|
||||
'Reklama', 'Реклама', 'Anunț', '광고', 'annons', 'Annonse', 'Iklan',
|
||||
'広告', 'Augl.', 'Mainos', 'Advertentie', 'إعلان', 'Գովազդ', 'विज्ञापन',
|
||||
'Reklam', 'آگهی', 'Reklāma', 'Reklaam', 'Διαφήμιση', 'מודעה', 'Hirdetés',
|
||||
'Anúncio', 'Quảng cáo', 'โฆษณา', 'sponsored', 'patrocinado', 'gesponsert',
|
||||
'Sponzorováno', '스폰서', 'Gesponsord', 'Sponsorisé'
|
||||
]
|
||||
|
||||
SITE_ALTS = {
|
||||
'twitter.com': os.getenv('WHOOGLE_ALT_TW', 'farside.link/nitter'),
|
||||
'youtube.com': os.getenv('WHOOGLE_ALT_YT', 'farside.link/invidious'),
|
||||
'reddit.com': os.getenv('WHOOGLE_ALT_RD', 'farside.link/libreddit'),
|
||||
**dict.fromkeys([
|
||||
'medium.com',
|
||||
'levelup.gitconnected.com'
|
||||
], os.getenv('WHOOGLE_ALT_MD', 'farside.link/scribe')),
|
||||
'imgur.com': os.getenv('WHOOGLE_ALT_IMG', 'farside.link/rimgo'),
|
||||
'wikipedia.org': os.getenv('WHOOGLE_ALT_WIKI', 'farside.link/wikiless'),
|
||||
'imdb.com': os.getenv('WHOOGLE_ALT_IMDB', 'farside.link/libremdb'),
|
||||
'quora.com': os.getenv('WHOOGLE_ALT_QUORA', 'farside.link/quetre'),
|
||||
'stackoverflow.com': os.getenv('WHOOGLE_ALT_SO', 'farside.link/anonymousoverflow')
|
||||
}
|
||||
|
||||
# Include custom site redirects from WHOOGLE_REDIRECTS
|
||||
SITE_ALTS.update(list_to_dict(re.split(',|:', os.getenv('WHOOGLE_REDIRECTS', ''))))
|
||||
|
||||
|
||||
def contains_cjko(s: str) -> bool:
|
||||
"""This function check whether or not a string contains Chinese, Japanese,
|
||||
or Korean characters. It employs regex and uses the u escape sequence to
|
||||
match any character in a set of Unicode ranges.
|
||||
|
||||
Args:
|
||||
s (str): string to be checked
|
||||
|
||||
Returns:
|
||||
bool: True if the input s contains the characters and False otherwise
|
||||
"""
|
||||
unicode_ranges = ('\u4e00-\u9fff' # Chinese characters
|
||||
'\u3040-\u309f' # Japanese hiragana
|
||||
'\u30a0-\u30ff' # Japanese katakana
|
||||
'\u4e00-\u9faf' # Japanese kanji
|
||||
'\uac00-\ud7af' # Korean hangul syllables
|
||||
'\u1100-\u11ff' # Korean hangul jamo
|
||||
)
|
||||
return bool(re.search(fr'[{unicode_ranges}]', s))
|
||||
|
||||
|
||||
def bold_search_terms(response: str, query: str) -> BeautifulSoup:
|
||||
"""Wraps all search terms in bold tags (<b>). If any terms are wrapped
|
||||
in quotes, only that exact phrase will be made bold.
|
||||
|
||||
Args:
|
||||
response: The initial response body for the query
|
||||
query: The original search query
|
||||
|
||||
Returns:
|
||||
BeautifulSoup: modified soup object with bold items
|
||||
"""
|
||||
response = BeautifulSoup(response, 'html.parser')
|
||||
|
||||
def replace_any_case(element: NavigableString, target_word: str) -> None:
|
||||
# Replace all instances of the word, but maintaining the same case in
|
||||
# the replacement
|
||||
if len(element) == len(target_word):
|
||||
return
|
||||
|
||||
# Ensure target word is escaped for regex
|
||||
target_word = re.escape(target_word)
|
||||
|
||||
# Check if the word contains Chinese, Japanese, or Korean characters
|
||||
if contains_cjko(target_word):
|
||||
reg_pattern = fr'((?![{{}}<>-]){target_word}(?![{{}}<>-]))'
|
||||
else:
|
||||
reg_pattern = fr'\b((?![{{}}<>-]){target_word}(?![{{}}<>-]))\b'
|
||||
|
||||
if re.match(r'.*[@_!#$%^&*()<>?/\|}{~:].*', target_word) or (
|
||||
element.parent and element.parent.name == 'style'):
|
||||
return
|
||||
|
||||
element.replace_with(BeautifulSoup(
|
||||
re.sub(reg_pattern,
|
||||
r'<b>\1</b>',
|
||||
element,
|
||||
flags=re.I), 'html.parser')
|
||||
)
|
||||
|
||||
# Split all words out of query, grouping the ones wrapped in quotes
|
||||
for word in re.split(r'\s+(?=[^"]*(?:"[^"]*"[^"]*)*$)', query):
|
||||
word = re.sub(r'[@_!#$%^&*()<>?/\|}{~:]+', '', word)
|
||||
target = response.find_all(
|
||||
string=re.compile(r'' + re.escape(word), re.I))
|
||||
for nav_str in target:
|
||||
replace_any_case(nav_str, word)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def has_ad_content(element: str) -> bool:
|
||||
"""Inspects an HTML element for ad related content
|
||||
|
||||
Args:
|
||||
element: The HTML element to inspect
|
||||
|
||||
Returns:
|
||||
bool: True/False for the element containing an ad
|
||||
|
||||
"""
|
||||
element_str = ''.join(filter(str.isalpha, element))
|
||||
return (element_str.upper() in (value.upper() for value in BLACKLIST)
|
||||
or 'ⓘ' in element)
|
||||
|
||||
|
||||
def get_first_link(soup) -> str:
|
||||
"""Retrieves the first result link from the query response
|
||||
|
||||
Args:
|
||||
soup: The BeautifulSoup response body
|
||||
|
||||
Returns:
|
||||
str: A str link to the first result
|
||||
|
||||
"""
|
||||
first_link = ''
|
||||
|
||||
# Find the first valid search result link, excluding details elements
|
||||
for a in soup.find_all('a', href=True):
|
||||
# Skip links that are inside details elements (collapsible sections)
|
||||
if a.find_parent('details'):
|
||||
continue
|
||||
|
||||
# Return the first search result URL
|
||||
if a['href'].startswith('http://') or a['href'].startswith('https://'):
|
||||
first_link = a['href']
|
||||
break
|
||||
|
||||
return first_link
|
||||
|
||||
|
||||
def get_site_alt(link: str, site_alts: dict = SITE_ALTS) -> str:
|
||||
"""Returns an alternative to a particular site, if one is configured
|
||||
|
||||
Args:
|
||||
link: A string result URL to check against the site_alts map
|
||||
site_alts: A map of site alternatives to replace with. defaults to SITE_ALTS
|
||||
|
||||
Returns:
|
||||
str: An updated (or ignored) result link
|
||||
|
||||
"""
|
||||
# Need to replace full hostname with alternative to encapsulate
|
||||
# subdomains as well
|
||||
parsed_link = urlparse.urlparse(link)
|
||||
|
||||
# Extract subdomain separately from the domain+tld. The subdomain
|
||||
# is used for wikiless translations.
|
||||
split_host = parsed_link.netloc.split('.')
|
||||
subdomain = split_host[0] if len(split_host) > 2 else ''
|
||||
hostname = '.'.join(split_host[-2:])
|
||||
|
||||
# The full scheme + hostname is used when comparing against the list of
|
||||
# available alternative services, due to how Medium links are constructed.
|
||||
# (i.e. for medium.com: "https://something.medium.com" should match,
|
||||
# "https://medium.com/..." should match, but "philomedium.com" should not)
|
||||
hostcomp = f'{parsed_link.scheme}://{hostname}'
|
||||
|
||||
for site_key in site_alts.keys():
|
||||
site_alt = f'{parsed_link.scheme}://{site_key}'
|
||||
if not hostname or site_alt not in hostcomp or not site_alts[site_key]:
|
||||
continue
|
||||
|
||||
# Wikipedia -> Wikiless replacements require the subdomain (if it's
|
||||
# a 2-char language code) to be passed as a URL param to Wikiless
|
||||
# in order to preserve the language setting.
|
||||
params = ''
|
||||
if 'wikipedia' in hostname and len(subdomain) == 2:
|
||||
hostname = f'{subdomain}.{hostname}'
|
||||
params = f'?lang={subdomain}'
|
||||
elif 'medium' in hostname and len(subdomain) > 0:
|
||||
hostname = f'{subdomain}.{hostname}'
|
||||
|
||||
parsed_alt = urlparse.urlparse(site_alts[site_key])
|
||||
link = link.replace(hostname, site_alts[site_key]) + params
|
||||
# If a scheme is specified in the alternative, this results in a
|
||||
# replaced link that looks like "https://http://altservice.tld".
|
||||
# In this case, we can remove the original scheme from the result
|
||||
# and use the one specified for the alt.
|
||||
if parsed_alt.scheme:
|
||||
link = '//'.join(link.split('//')[1:])
|
||||
|
||||
for prefix in SKIP_PREFIX:
|
||||
if parsed_alt.scheme:
|
||||
# If a scheme is specified, remove everything before the
|
||||
# first occurence of it
|
||||
link = f'{parsed_alt.scheme}{link.split(parsed_alt.scheme, 1)[-1]}'
|
||||
else:
|
||||
# Otherwise, replace the first occurrence of the prefix
|
||||
link = link.replace(prefix, '//', 1)
|
||||
break
|
||||
|
||||
return link
|
||||
|
||||
|
||||
def filter_link_args(link: str) -> str:
|
||||
"""Filters out unnecessary URL args from a result link
|
||||
|
||||
Args:
|
||||
link: The string result link to check for extraneous URL params
|
||||
|
||||
Returns:
|
||||
str: An updated (or ignored) result link
|
||||
|
||||
"""
|
||||
parsed_link = urlparse.urlparse(link)
|
||||
link_args = parse_qs(parsed_link.query)
|
||||
safe_args = {}
|
||||
|
||||
if len(link_args) == 0 and len(parsed_link) > 0:
|
||||
return link
|
||||
|
||||
for arg in link_args.keys():
|
||||
if arg in SKIP_ARGS:
|
||||
continue
|
||||
|
||||
safe_args[arg] = link_args[arg]
|
||||
|
||||
# Remove original link query and replace with filtered args
|
||||
link = link.replace(parsed_link.query, '')
|
||||
if len(safe_args) > 0:
|
||||
link = link + urlparse.urlencode(safe_args, doseq=True)
|
||||
else:
|
||||
link = link.replace('?', '')
|
||||
|
||||
return link
|
||||
|
||||
|
||||
def append_nojs(result: BeautifulSoup) -> None:
|
||||
"""Appends a no-Javascript alternative for a search result
|
||||
|
||||
Args:
|
||||
result: The search result to append a no-JS link to
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
nojs_link = BeautifulSoup(features='html.parser').new_tag('a')
|
||||
nojs_link['href'] = f'{Endpoint.window}?nojs=1&location=' + result['href']
|
||||
nojs_link.string = ' NoJS Link'
|
||||
result.append(nojs_link)
|
||||
|
||||
|
||||
def append_anon_view(result: BeautifulSoup, config: Config) -> None:
|
||||
"""Appends an 'anonymous view' for a search result, where all site
|
||||
contents are viewed through Whoogle as a proxy.
|
||||
|
||||
Args:
|
||||
result: The search result to append an anon view link to
|
||||
nojs: Remove Javascript from Anonymous View
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
av_link = BeautifulSoup(features='html.parser').new_tag('a')
|
||||
nojs = 'nojs=1' if config.nojs else 'nojs=0'
|
||||
location = f'location={result["href"]}'
|
||||
av_link['href'] = f'{Endpoint.window}?{nojs}&{location}'
|
||||
translation = current_app.config['TRANSLATIONS'][
|
||||
config.get_localization_lang()
|
||||
]
|
||||
av_link.string = f'{translation["anon-view"]}'
|
||||
av_link['class'] = 'anon-view'
|
||||
result.append(av_link)
|
||||
|
||||
def check_currency(response: str) -> dict:
|
||||
"""Check whether the results have currency conversion
|
||||
|
||||
Args:
|
||||
response: Search query Result
|
||||
|
||||
Returns:
|
||||
dict: Consists of currency names and values
|
||||
|
||||
"""
|
||||
soup = BeautifulSoup(response, 'html.parser')
|
||||
currency_link = soup.find('a', {'href': 'https://g.co/gfd'})
|
||||
if currency_link:
|
||||
while 'class' not in currency_link.attrs or \
|
||||
'ZINbbc' not in currency_link.attrs['class']:
|
||||
if currency_link.parent:
|
||||
currency_link = currency_link.parent
|
||||
else:
|
||||
return {}
|
||||
currency_link = currency_link.find_all(class_='BNeawe')
|
||||
currency1 = currency_link[0].text
|
||||
currency2 = currency_link[1].text
|
||||
currency1 = currency1.rstrip('=').split(' ', 1)
|
||||
currency2 = currency2.split(' ', 1)
|
||||
|
||||
# Handle differences in currency formatting
|
||||
# i.e. "5.000" vs "5,000"
|
||||
if currency2[0][-3] == ',':
|
||||
currency1[0] = currency1[0].replace('.', '')
|
||||
currency1[0] = currency1[0].replace(',', '.')
|
||||
currency2[0] = currency2[0].replace('.', '')
|
||||
currency2[0] = currency2[0].replace(',', '.')
|
||||
else:
|
||||
currency1[0] = currency1[0].replace(',', '')
|
||||
currency2[0] = currency2[0].replace(',', '')
|
||||
|
||||
currency1_value = float(re.sub(r'[^\d\.]', '', currency1[0]))
|
||||
currency1_label = currency1[1]
|
||||
|
||||
currency2_value = float(re.sub(r'[^\d\.]', '', currency2[0]))
|
||||
currency2_label = currency2[1]
|
||||
|
||||
return {'currencyValue1': currency1_value,
|
||||
'currencyLabel1': currency1_label,
|
||||
'currencyValue2': currency2_value,
|
||||
'currencyLabel2': currency2_label
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def add_currency_card(soup: BeautifulSoup,
|
||||
conversion_details: dict) -> BeautifulSoup:
|
||||
"""Adds the currency conversion boxes
|
||||
to response of the search query
|
||||
|
||||
Args:
|
||||
soup: Parsed search result
|
||||
conversion_details: Dictionary of currency
|
||||
related information
|
||||
|
||||
Returns:
|
||||
BeautifulSoup
|
||||
"""
|
||||
# Element before which the code will be changed
|
||||
# (This is the 'disclaimer' link)
|
||||
element1 = soup.find('a', {'href': 'https://g.co/gfd'})
|
||||
|
||||
while 'class' not in element1.attrs or \
|
||||
'nXE3Ob' not in element1.attrs['class']:
|
||||
element1 = element1.parent
|
||||
|
||||
# Creating the conversion factor
|
||||
conversion_factor = (conversion_details['currencyValue1'] /
|
||||
conversion_details['currencyValue2'])
|
||||
|
||||
# Creating a new div for the input boxes
|
||||
conversion_box = soup.new_tag('div')
|
||||
conversion_box['class'] = 'conversion_box'
|
||||
|
||||
# Currency to be converted from
|
||||
input_box1 = soup.new_tag('input')
|
||||
input_box1['id'] = 'cb1'
|
||||
input_box1['type'] = 'number'
|
||||
input_box1['class'] = 'cb'
|
||||
input_box1['value'] = conversion_details['currencyValue1']
|
||||
input_box1['oninput'] = f'convert(1, 2, {1 / conversion_factor})'
|
||||
|
||||
label_box1 = soup.new_tag('label')
|
||||
label_box1['for'] = 'cb1'
|
||||
label_box1['class'] = 'cb_label'
|
||||
label_box1.append(conversion_details['currencyLabel1'])
|
||||
|
||||
br = soup.new_tag('br')
|
||||
|
||||
# Currency to be converted to
|
||||
input_box2 = soup.new_tag('input')
|
||||
input_box2['id'] = 'cb2'
|
||||
input_box2['type'] = 'number'
|
||||
input_box2['class'] = 'cb'
|
||||
input_box2['value'] = conversion_details['currencyValue2']
|
||||
input_box2['oninput'] = f'convert(2, 1, {conversion_factor})'
|
||||
|
||||
label_box2 = soup.new_tag('label')
|
||||
label_box2['for'] = 'cb2'
|
||||
label_box2['class'] = 'cb_label'
|
||||
label_box2.append(conversion_details['currencyLabel2'])
|
||||
|
||||
conversion_box.append(input_box1)
|
||||
conversion_box.append(label_box1)
|
||||
conversion_box.append(br)
|
||||
conversion_box.append(input_box2)
|
||||
conversion_box.append(label_box2)
|
||||
|
||||
element1.insert_before(conversion_box)
|
||||
return soup
|
||||
|
||||
|
||||
def get_tabs_content(tabs: dict,
|
||||
full_query: str,
|
||||
search_type: str,
|
||||
preferences: str,
|
||||
translation: dict) -> dict:
|
||||
"""Takes the default tabs content and updates it according to the query.
|
||||
|
||||
Args:
|
||||
tabs: The default content for the tabs
|
||||
full_query: The original search query
|
||||
search_type: The current search_type
|
||||
translation: The translation to get the names of the tabs
|
||||
|
||||
Returns:
|
||||
dict: contains the name, the href and if the tab is selected or not
|
||||
"""
|
||||
map_query = full_query
|
||||
if '-site:' in full_query:
|
||||
block_idx = full_query.index('-site:')
|
||||
map_query = map_query[:block_idx]
|
||||
tabs = copy.deepcopy(tabs)
|
||||
for tab_id, tab_content in tabs.items():
|
||||
# update name to desired language
|
||||
if tab_id in translation:
|
||||
tab_content['name'] = translation[tab_id]
|
||||
|
||||
# update href with query
|
||||
query = full_query.replace(f'&tbm={search_type}', '')
|
||||
|
||||
if tab_content['tbm'] is not None:
|
||||
query = f"{query}&tbm={tab_content['tbm']}"
|
||||
|
||||
if preferences:
|
||||
query = f"{query}&preferences={preferences}"
|
||||
|
||||
tab_content['href'] = tab_content['href'].format(
|
||||
query=query,
|
||||
map_query=map_query)
|
||||
|
||||
# update if selected tab (default all tab is selected)
|
||||
if tab_content['tbm'] == search_type:
|
||||
tabs['all']['selected'] = False
|
||||
tab_content['selected'] = True
|
||||
return tabs
|
||||
@ -1,72 +0,0 @@
|
||||
from app.filter import Filter, get_first_link
|
||||
from app.utils.session_utils import generate_user_keys
|
||||
from app.request import gen_query
|
||||
from bs4 import BeautifulSoup
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from flask import g
|
||||
from typing import Any, Tuple
|
||||
|
||||
|
||||
class RoutingUtils:
|
||||
def __init__(self, request, config, session, cookies_disabled=False):
|
||||
self.request_params = request.args if request.method == 'GET' else request.form
|
||||
self.user_agent = request.headers.get('User-Agent')
|
||||
self.feeling_lucky = False
|
||||
self.config = config
|
||||
self.session = session
|
||||
self.query = ''
|
||||
self.cookies_disabled = cookies_disabled
|
||||
self.search_type = self.request_params.get('tbm') if 'tbm' in self.request_params else ''
|
||||
|
||||
def __getitem__(self, name):
|
||||
return getattr(self, name)
|
||||
|
||||
def __setitem__(self, name, value):
|
||||
return setattr(self, name, value)
|
||||
|
||||
def __delitem__(self, name):
|
||||
return delattr(self, name)
|
||||
|
||||
def __contains__(self, name):
|
||||
return hasattr(self, name)
|
||||
|
||||
def new_search_query(self) -> str:
|
||||
# Generate a new element key each time a new search is performed
|
||||
self.session['fernet_keys']['element_key'] = generate_user_keys(
|
||||
cookies_disabled=self.cookies_disabled)['element_key']
|
||||
|
||||
q = self.request_params.get('q')
|
||||
|
||||
if q is None or len(q) == 0:
|
||||
return ''
|
||||
else:
|
||||
# Attempt to decrypt if this is an internal link
|
||||
try:
|
||||
q = Fernet(self.session['fernet_keys']['text_key']).decrypt(q.encode()).decode()
|
||||
except InvalidToken:
|
||||
pass
|
||||
|
||||
# Reset text key
|
||||
self.session['fernet_keys']['text_key'] = generate_user_keys(
|
||||
cookies_disabled=self.cookies_disabled)['text_key']
|
||||
|
||||
# Format depending on whether or not the query is a "feeling lucky" query
|
||||
self.feeling_lucky = q.startswith('! ')
|
||||
self.query = q[2:] if self.feeling_lucky else q
|
||||
return self.query
|
||||
|
||||
def generate_response(self) -> Tuple[Any, int]:
|
||||
mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent
|
||||
|
||||
content_filter = Filter(self.session['fernet_keys'], mobile=mobile, config=self.config)
|
||||
full_query = gen_query(self.query, self.request_params, self.config, content_filter.near)
|
||||
get_body = g.user_request.send(query=full_query).text
|
||||
|
||||
# Produce cleanable html soup from response
|
||||
html_soup = BeautifulSoup(content_filter.reskin(get_body), 'html.parser')
|
||||
|
||||
if self.feeling_lucky:
|
||||
return get_first_link(html_soup), 1
|
||||
else:
|
||||
formatted_results = content_filter.clean(html_soup)
|
||||
return formatted_results, content_filter.elements
|
||||
283
app/utils/search.py
Normal file
@ -0,0 +1,283 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
from app.filter import Filter
|
||||
from app.request import gen_query
|
||||
from app.utils.misc import get_proxy_host_url
|
||||
from app.utils.results import get_first_link
|
||||
from app.services.cse_client import CSEClient, cse_results_to_html
|
||||
from bs4 import BeautifulSoup as bsoup
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from flask import g
|
||||
|
||||
TOR_BANNER = '<hr><h1 style="text-align: center">You are using Tor</h1><hr>'
|
||||
CAPTCHA = 'div class="g-recaptcha"'
|
||||
|
||||
|
||||
def needs_https(url: str) -> bool:
|
||||
"""Checks if the current instance needs to be upgraded to HTTPS
|
||||
|
||||
Note that all Heroku instances are available by default over HTTPS, but
|
||||
do not automatically set up a redirect when visited over HTTP.
|
||||
|
||||
Args:
|
||||
url: The instance url
|
||||
|
||||
Returns:
|
||||
bool: True/False representing the need to upgrade
|
||||
|
||||
"""
|
||||
https_only = bool(os.getenv('HTTPS_ONLY', 0))
|
||||
is_heroku = url.endswith('.herokuapp.com')
|
||||
is_http = url.startswith('http://')
|
||||
|
||||
return (is_heroku and is_http) or (https_only and is_http)
|
||||
|
||||
|
||||
def has_captcha(results: str) -> bool:
|
||||
"""Checks to see if the search results are blocked by a captcha
|
||||
|
||||
Args:
|
||||
results: The search page html as a string
|
||||
|
||||
Returns:
|
||||
bool: True/False indicating if a captcha element was found
|
||||
|
||||
"""
|
||||
return CAPTCHA in results
|
||||
|
||||
|
||||
class Search:
|
||||
"""Search query preprocessor - used before submitting the query or
|
||||
redirecting to another site
|
||||
|
||||
Attributes:
|
||||
request: the incoming flask request
|
||||
config: the current user config settings
|
||||
session_key: the flask user fernet key
|
||||
"""
|
||||
def __init__(self, request, config, session_key, cookies_disabled=False, user_request=None):
|
||||
method = request.method
|
||||
self.request = request
|
||||
self.request_params = request.args if method == 'GET' else request.form
|
||||
self.user_agent = request.headers.get('User-Agent')
|
||||
self.feeling_lucky = False
|
||||
self.config = config
|
||||
self.session_key = session_key
|
||||
self.query = ''
|
||||
self.widget = ''
|
||||
self.cookies_disabled = cookies_disabled
|
||||
self.user_request = user_request
|
||||
self.search_type = self.request_params.get(
|
||||
'tbm') if 'tbm' in self.request_params else ''
|
||||
|
||||
def __getitem__(self, name) -> Any:
|
||||
return getattr(self, name)
|
||||
|
||||
def __setitem__(self, name, value) -> None:
|
||||
return setattr(self, name, value)
|
||||
|
||||
def __delitem__(self, name) -> None:
|
||||
return delattr(self, name)
|
||||
|
||||
def __contains__(self, name) -> bool:
|
||||
return hasattr(self, name)
|
||||
|
||||
def new_search_query(self) -> str:
|
||||
"""Parses a plaintext query into a valid string for submission
|
||||
|
||||
Also decrypts the query string, if encrypted (in the case of
|
||||
paginated results).
|
||||
|
||||
Returns:
|
||||
str: A valid query string
|
||||
|
||||
"""
|
||||
q = self.request_params.get('q')
|
||||
|
||||
if q is None or len(q) == 0:
|
||||
return ''
|
||||
else:
|
||||
# Attempt to decrypt if this is an internal link
|
||||
try:
|
||||
q = Fernet(self.session_key).decrypt(q.encode()).decode()
|
||||
except InvalidToken:
|
||||
pass
|
||||
|
||||
# Strip '!' for "feeling lucky" queries
|
||||
if match := re.search(r"(^|\s)!($|\s)", q):
|
||||
self.feeling_lucky = True
|
||||
start, end = match.span()
|
||||
self.query = " ".join([seg for seg in [q[:start], q[end:]] if seg])
|
||||
else:
|
||||
self.feeling_lucky = False
|
||||
self.query = q
|
||||
|
||||
# Check for possible widgets
|
||||
self.widget = "ip" if re.search("([^a-z0-9]|^)my *[^a-z0-9] *(ip|internet protocol)" +
|
||||
"($|( *[^a-z0-9] *(((addres|address|adres|" +
|
||||
"adress)|a)? *$)))", self.query.lower()) else self.widget
|
||||
self.widget = 'calculator' if re.search(
|
||||
r"\bcalculator\b|\bcalc\b|\bcalclator\b|\bmath\b",
|
||||
self.query.lower()) else self.widget
|
||||
return self.query
|
||||
|
||||
def generate_response(self) -> str:
|
||||
"""Generates a response for the user's query
|
||||
|
||||
Returns:
|
||||
str: A string response to the search query, in the form of a URL
|
||||
or string representation of HTML content.
|
||||
|
||||
"""
|
||||
mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent
|
||||
# reconstruct url if X-Forwarded-Host header present
|
||||
root_url = get_proxy_host_url(
|
||||
self.request,
|
||||
self.request.url_root,
|
||||
root=True)
|
||||
|
||||
content_filter = Filter(self.session_key,
|
||||
root_url=root_url,
|
||||
mobile=mobile,
|
||||
config=self.config,
|
||||
query=self.query,
|
||||
page_url=self.request.url)
|
||||
|
||||
# Check if CSE (Custom Search Engine) should be used
|
||||
use_cse = (
|
||||
self.config.use_cse and
|
||||
self.config.cse_api_key and
|
||||
self.config.cse_id
|
||||
)
|
||||
|
||||
if use_cse:
|
||||
# Use Google Custom Search API
|
||||
return self._generate_cse_response(content_filter, root_url, mobile)
|
||||
|
||||
# Default: Use traditional scraping method
|
||||
return self._generate_scrape_response(content_filter, root_url, mobile)
|
||||
|
||||
def _generate_cse_response(self, content_filter: Filter, root_url: str, mobile: bool) -> str:
|
||||
"""Generate response using Google Custom Search API
|
||||
|
||||
Args:
|
||||
content_filter: Filter instance for processing results
|
||||
root_url: Root URL of the instance
|
||||
mobile: Whether this is a mobile request
|
||||
|
||||
Returns:
|
||||
str: HTML response string
|
||||
"""
|
||||
# Get pagination start index from request params
|
||||
start = int(self.request_params.get('start', 1))
|
||||
|
||||
# Determine safe search setting
|
||||
safe = 'high' if self.config.safe else 'off'
|
||||
|
||||
# Determine search type (web or image)
|
||||
# tbm=isch or udm=2 indicates image search
|
||||
search_type = ''
|
||||
if self.search_type == 'isch' or self.request_params.get('udm') == '2':
|
||||
search_type = 'image'
|
||||
|
||||
# Create CSE client and perform search
|
||||
with CSEClient(
|
||||
api_key=self.config.cse_api_key,
|
||||
cse_id=self.config.cse_id
|
||||
) as client:
|
||||
response = client.search(
|
||||
query=self.query,
|
||||
start=start,
|
||||
safe=safe,
|
||||
language=self.config.lang_search,
|
||||
country=self.config.country,
|
||||
search_type=search_type
|
||||
)
|
||||
|
||||
# Convert CSE response to HTML
|
||||
html_content = cse_results_to_html(response, self.query)
|
||||
|
||||
# Store full query for tabs
|
||||
self.full_query = self.query
|
||||
|
||||
# Parse and filter the HTML
|
||||
html_soup = bsoup(html_content, 'html.parser')
|
||||
|
||||
# Handle feeling lucky
|
||||
if self.feeling_lucky:
|
||||
if response.has_results and response.results:
|
||||
return response.results[0].link
|
||||
self.feeling_lucky = False
|
||||
|
||||
# Apply content filter (encrypts links, applies CSS, etc.)
|
||||
formatted_results = content_filter.clean(html_soup)
|
||||
|
||||
return str(formatted_results)
|
||||
|
||||
def _generate_scrape_response(self, content_filter: Filter, root_url: str, mobile: bool) -> str:
|
||||
"""Generate response using traditional HTML scraping
|
||||
|
||||
Args:
|
||||
content_filter: Filter instance for processing results
|
||||
root_url: Root URL of the instance
|
||||
mobile: Whether this is a mobile request
|
||||
|
||||
Returns:
|
||||
str: HTML response string
|
||||
"""
|
||||
full_query = gen_query(self.query,
|
||||
self.request_params,
|
||||
self.config)
|
||||
self.full_query = full_query
|
||||
|
||||
# force mobile search when view image is true and
|
||||
# the request is not already made by a mobile
|
||||
is_image_query = ('tbm=isch' in full_query) or ('udm=2' in full_query)
|
||||
# Always parse image results when hitting the images endpoint (udm=2)
|
||||
# to avoid Google returning only text/AI blocks.
|
||||
view_image = is_image_query
|
||||
|
||||
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
|
||||
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
|
||||
if (self.user_request or g.user_request).tor_valid:
|
||||
html_soup.insert(0, bsoup(TOR_BANNER, 'html.parser'))
|
||||
|
||||
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
|
||||
param_str = ''.join('&{}={}'.format(k, v)
|
||||
for k, v in
|
||||
self.request_params.to_dict(flat=True).items()
|
||||
if self.config.is_safe_key(k))
|
||||
for link in formatted_results.find_all('a', href=True):
|
||||
link['rel'] = "nofollow noopener noreferrer"
|
||||
if 'search?' not in link['href'] or link['href'].index(
|
||||
'search?') > 1:
|
||||
continue
|
||||
link['href'] += param_str
|
||||
|
||||
return str(formatted_results)
|
||||
39
app/utils/session.py
Normal file
@ -0,0 +1,39 @@
|
||||
from cryptography.fernet import Fernet
|
||||
from flask import current_app as app
|
||||
|
||||
REQUIRED_SESSION_VALUES = ['uuid', 'config', 'key', 'auth']
|
||||
|
||||
|
||||
def generate_key() -> bytes:
|
||||
"""Generates a key for encrypting searches and element URLs
|
||||
|
||||
Args:
|
||||
cookies_disabled: Flag for whether or not cookies are disabled by the
|
||||
user. If so, the user can only use the default key
|
||||
generated on app init for queries.
|
||||
|
||||
Returns:
|
||||
str: A unique Fernet key
|
||||
|
||||
"""
|
||||
# Generate/regenerate unique key per user
|
||||
return Fernet.generate_key()
|
||||
|
||||
|
||||
def valid_user_session(session: dict) -> bool:
|
||||
"""Validates the current user session
|
||||
|
||||
Args:
|
||||
session: The current Flask user session
|
||||
|
||||
Returns:
|
||||
bool: True/False indicating that all required session values are
|
||||
available
|
||||
|
||||
"""
|
||||
# Generate secret key for user if unavailable
|
||||
for value in REQUIRED_SESSION_VALUES:
|
||||
if value not in session:
|
||||
return False
|
||||
|
||||
return True
|
||||
@ -1,24 +0,0 @@
|
||||
from cryptography.fernet import Fernet
|
||||
from flask import current_app as app
|
||||
|
||||
REQUIRED_SESSION_VALUES = ['uuid', 'config', 'fernet_keys']
|
||||
|
||||
|
||||
def generate_user_keys(cookies_disabled=False) -> dict:
|
||||
if cookies_disabled:
|
||||
return app.default_key_set
|
||||
|
||||
# Generate/regenerate unique key per user
|
||||
return {
|
||||
'element_key': Fernet.generate_key(),
|
||||
'text_key': Fernet.generate_key()
|
||||
}
|
||||
|
||||
|
||||
def valid_user_session(session):
|
||||
# Generate secret key for user if unavailable
|
||||
for value in REQUIRED_SESSION_VALUES:
|
||||
if value not in session:
|
||||
return False
|
||||
|
||||
return True
|
||||
336
app/utils/ua_generator.py
Normal file
@ -0,0 +1,336 @@
|
||||
"""
|
||||
User Agent Generator for Opera-based UA strings.
|
||||
|
||||
This module generates realistic Opera User Agent strings based on patterns
|
||||
found in working UA strings that successfully bypass Google's restrictions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
# Default fallback UA if generation fails
|
||||
DEFAULT_FALLBACK_UA = "Opera/9.80 (iPad; Opera Mini/5.0.17381/503; U; eu) Presto/2.6.35 Version/11.10)"
|
||||
|
||||
# Opera UA Pattern Templates
|
||||
OPERA_PATTERNS = [
|
||||
# Opera Mini (J2ME/MIDP)
|
||||
"Opera/9.80 (J2ME/MIDP; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
|
||||
# Opera Mobile (Android)
|
||||
"Opera/9.80 (Android; Linux; Opera Mobi/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
|
||||
# Opera Mobile (iPhone)
|
||||
"Opera/9.80 (iPhone; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
|
||||
# Opera Mobile (iPad)
|
||||
"Opera/9.80 (iPad; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
]
|
||||
|
||||
# Randomization pools based on working UAs
|
||||
OPERA_MINI_VERSIONS = [
|
||||
"4.0", "4.1.11321", "4.1.12965", "4.1.13573", "4.1.13907", "4.1.14287",
|
||||
"4.1.15082", "4.2.13057", "4.2.13221", "4.2.13265", "4.2.13337",
|
||||
"4.2.13400", "4.2.13918", "4.2.13943", "4.2.14320", "4.2.14409",
|
||||
"4.2.14753", "4.2.14881", "4.2.14885", "4.2.14912", "4.2.15066",
|
||||
"4.2.15410", "4.2.16007", "4.2.16320", "4.2.18887", "4.2.19634",
|
||||
"4.2.21465", "4.2.22228", "4.2.23453", "4.2.24721", "4.3.13337",
|
||||
"4.3.24214", "4.4.26736", "4.4.29476", "4.5.33867", "4.5.40312",
|
||||
"5.0.15650", "5.0.16823", "5.0.17381", "5.0.17443", "5.0.18635",
|
||||
"5.0.18741", "5.0.19683", "5.0.19693", "5.0.20873", "5.0.22349",
|
||||
"5.1.21051", "5.1.21126", "5.1.21214", "5.1.21415", "5.1.21594",
|
||||
"5.1.21595", "5.1.22296", "5.1.22303", "5.1.22396", "5.1.22460",
|
||||
"5.1.22783", "5.1.22784", "6.0.24095", "6.0.24212", "6.0.24455",
|
||||
"6.1.25375", "6.1.25378", "6.1.25759", "6.24093", "6.24096",
|
||||
"6.24209", "6.24288", "6.5.26955", "6.5.29702", "7.0.29952",
|
||||
"7.1.32052", "7.1.32444", "7.1.32694", "7.29530", "7.5.33361",
|
||||
"7.6.35766", "9.80", "36.2.2254"
|
||||
]
|
||||
|
||||
OPERA_MOBI_BUILDS = [
|
||||
"27", "49", "447", "498", "1181", "1209", "3730",
|
||||
"ADR-1011151731", "ADR-1012211514", "ADR-1012221546", "ADR-1012272315",
|
||||
"SYB-1103211396", "SYB-1104061449", "SYB-1107071606",
|
||||
"ADR-1111101157"
|
||||
]
|
||||
|
||||
BUILD_NUMBERS = [
|
||||
"18.678", "18.684", "18.738", "18.794", "19.892", "19.916",
|
||||
"20.2477", "20.2479", "20.2485", "20.2489", "21.529", "22.387",
|
||||
"22.394", "22.401", "22.414", "22.453", "22.478", "23.317",
|
||||
"23.333", "23.334", "23.377", "23.390", "24.741", "24.743",
|
||||
"24.746", "24.783", "24.838", "24.871", "24.899", "25.657",
|
||||
"25.677", "25.729", "25.872", "26.1305", "27.1366", "27.1407",
|
||||
"27.1573", "28.2075", "28.2555", "28.2647", "28.2766", "29.3594",
|
||||
"30.3316", "31.1350", "35.2883", "35.5706", "37.6584", "119.132",
|
||||
"170.51", "170.54", "764", "870", "886", "490", "503"
|
||||
]
|
||||
|
||||
PRESTO_VERSIONS = [
|
||||
"2.2.0", "2.4.15", "2.4.154.15", "2.4.18", "2.5.25", "2.5.28",
|
||||
"2.6.35", "2.7.60", "2.7.81", "2.8.119", "2.8.149", "2.8.191",
|
||||
"2.9.201", "2.12.423"
|
||||
]
|
||||
|
||||
FINAL_VERSIONS = [
|
||||
"10.00", "10.1", "10.5", "10.54", "10.5454", "11.00", "11.10",
|
||||
"12.02", "12.16", "13.00"
|
||||
]
|
||||
|
||||
LANGUAGES = [
|
||||
# English variants
|
||||
"en", "en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-ZA", "en-IN", "en-SG",
|
||||
# Western European
|
||||
"de", "de-DE", "de-AT", "de-CH",
|
||||
"fr", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU",
|
||||
"es", "es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-LA",
|
||||
"it", "it-IT", "it-CH",
|
||||
"pt", "pt-PT", "pt-BR",
|
||||
"nl", "nl-NL", "nl-BE",
|
||||
# Nordic languages
|
||||
"da", "da-DK",
|
||||
"sv", "sv-SE",
|
||||
"no", "no-NO", "nb", "nn",
|
||||
"fi", "fi-FI",
|
||||
"is", "is-IS",
|
||||
# Eastern European
|
||||
"pl", "pl-PL",
|
||||
"cs", "cs-CZ",
|
||||
"sk", "sk-SK",
|
||||
"hu", "hu-HU",
|
||||
"ro", "ro-RO",
|
||||
"bg", "bg-BG",
|
||||
"hr", "hr-HR",
|
||||
"sr", "sr-RS",
|
||||
"sl", "sl-SI",
|
||||
"uk", "uk-UA",
|
||||
"ru", "ru-RU",
|
||||
# Asian languages
|
||||
"zh", "zh-CN", "zh-TW", "zh-HK",
|
||||
"ja", "ja-JP",
|
||||
"ko", "ko-KR",
|
||||
"th", "th-TH",
|
||||
"vi", "vi-VN",
|
||||
"id", "id-ID",
|
||||
"ms", "ms-MY",
|
||||
"fil", "tl",
|
||||
# Middle Eastern
|
||||
"tr", "tr-TR",
|
||||
"ar", "ar-SA", "ar-AE", "ar-EG",
|
||||
"he", "he-IL",
|
||||
"fa", "fa-IR",
|
||||
# Other
|
||||
"hi", "hi-IN",
|
||||
"bn", "bn-IN",
|
||||
"ta", "ta-IN",
|
||||
"te", "te-IN",
|
||||
"mr", "mr-IN",
|
||||
"el", "el-GR",
|
||||
"ca", "ca-ES",
|
||||
"eu", "eu-ES"
|
||||
]
|
||||
|
||||
|
||||
|
||||
def generate_opera_ua() -> str:
|
||||
"""
|
||||
Generate a single random Opera User Agent string.
|
||||
|
||||
Returns:
|
||||
str: A randomly generated Opera UA string
|
||||
"""
|
||||
pattern = random.choice(OPERA_PATTERNS)
|
||||
|
||||
# Determine which parameters to use based on the pattern
|
||||
params = {
|
||||
'lang': random.choice(LANGUAGES)
|
||||
}
|
||||
|
||||
if '{version}' in pattern:
|
||||
params['version'] = random.choice(OPERA_MINI_VERSIONS)
|
||||
|
||||
if '{build}' in pattern:
|
||||
# Use MOBI build for "Opera Mobi", regular build for "Opera Mini"
|
||||
if "Opera Mobi" in pattern:
|
||||
params['build'] = random.choice(OPERA_MOBI_BUILDS)
|
||||
else:
|
||||
params['build'] = random.choice(BUILD_NUMBERS)
|
||||
|
||||
if '{presto}' in pattern:
|
||||
params['presto'] = random.choice(PRESTO_VERSIONS)
|
||||
|
||||
if '{final}' in pattern:
|
||||
params['final'] = random.choice(FINAL_VERSIONS)
|
||||
|
||||
return pattern.format(**params)
|
||||
|
||||
|
||||
def generate_ua_pool(count: int = 10) -> List[str]:
|
||||
"""
|
||||
Generate a pool of unique Opera User Agent strings.
|
||||
|
||||
Args:
|
||||
count: Number of UA strings to generate (default: 10)
|
||||
|
||||
Returns:
|
||||
List[str]: List of unique UA strings
|
||||
"""
|
||||
ua_pool = set()
|
||||
|
||||
# Keep generating until we have enough unique UAs
|
||||
# Add safety limit to prevent infinite loop
|
||||
max_attempts = count * 100
|
||||
attempts = 0
|
||||
|
||||
try:
|
||||
while len(ua_pool) < count and attempts < max_attempts:
|
||||
ua = generate_opera_ua()
|
||||
ua_pool.add(ua)
|
||||
attempts += 1
|
||||
except Exception:
|
||||
# If generation fails entirely, return at least the default fallback
|
||||
if not ua_pool:
|
||||
return [DEFAULT_FALLBACK_UA]
|
||||
|
||||
# If we couldn't generate enough, fill remaining with default
|
||||
result = list(ua_pool)
|
||||
while len(result) < count:
|
||||
result.append(DEFAULT_FALLBACK_UA)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def save_ua_pool(uas: List[str], cache_path: str) -> None:
|
||||
"""
|
||||
Save UA pool to cache file.
|
||||
|
||||
Args:
|
||||
uas: List of UA strings to save
|
||||
cache_path: Path to cache file
|
||||
"""
|
||||
cache_data = {
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'user_agents': uas
|
||||
}
|
||||
|
||||
# Ensure directory exists
|
||||
cache_dir = os.path.dirname(cache_path)
|
||||
if cache_dir and not os.path.exists(cache_dir):
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_data, f, indent=2)
|
||||
|
||||
|
||||
def load_custom_ua_list(file_path: str) -> List[str]:
|
||||
"""
|
||||
Load custom UA list from a text file.
|
||||
|
||||
Args:
|
||||
file_path: Path to text file containing UA strings (one per line)
|
||||
|
||||
Returns:
|
||||
List[str]: List of UA strings, or empty list if file is invalid
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
uas = [line.strip() for line in f if line.strip()]
|
||||
|
||||
# Validate that we have at least one UA
|
||||
if not uas:
|
||||
return []
|
||||
|
||||
return uas
|
||||
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def load_ua_pool(cache_path: str, count: int = 10) -> List[str]:
|
||||
"""
|
||||
Load UA pool from custom list file, cache, or generate new one.
|
||||
|
||||
Priority order:
|
||||
1. Custom UA list file (if WHOOGLE_UA_LIST_FILE is set)
|
||||
2. Cached auto-generated UAs
|
||||
3. Newly generated UAs
|
||||
|
||||
Args:
|
||||
cache_path: Path to cache file
|
||||
count: Number of UAs to generate if cache is invalid (default: 10)
|
||||
|
||||
Returns:
|
||||
List[str]: List of UA strings
|
||||
"""
|
||||
# Check for custom UA list file first (highest priority)
|
||||
custom_ua_file = os.environ.get('WHOOGLE_UA_LIST_FILE', '').strip()
|
||||
if custom_ua_file:
|
||||
custom_uas = load_custom_ua_list(custom_ua_file)
|
||||
if custom_uas:
|
||||
# Custom list loaded successfully
|
||||
return custom_uas
|
||||
else:
|
||||
# Custom file specified but invalid, log warning and fall back
|
||||
print(f"Warning: Custom UA list file '{custom_ua_file}' not found or invalid, falling back to auto-generated UAs")
|
||||
|
||||
# Check if we should use cache
|
||||
use_cache = os.environ.get('WHOOGLE_UA_CACHE_PERSISTENT', '1') == '1'
|
||||
refresh_days = int(os.environ.get('WHOOGLE_UA_CACHE_REFRESH_DAYS', '0'))
|
||||
|
||||
# If cache disabled, always generate new
|
||||
if not use_cache:
|
||||
uas = generate_ua_pool(count)
|
||||
save_ua_pool(uas, cache_path)
|
||||
return uas
|
||||
|
||||
# Try to load from cache
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Check if cache is expired (if refresh_days > 0)
|
||||
if refresh_days > 0:
|
||||
generated_at = datetime.fromisoformat(cache_data['generated_at'])
|
||||
age_days = (datetime.now() - generated_at).days
|
||||
|
||||
if age_days >= refresh_days:
|
||||
# Cache expired, generate new
|
||||
uas = generate_ua_pool(count)
|
||||
save_ua_pool(uas, cache_path)
|
||||
return uas
|
||||
|
||||
# Cache is valid, return it
|
||||
return cache_data['user_agents']
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
# Cache file is corrupted, generate new
|
||||
pass
|
||||
|
||||
# No valid cache, generate new
|
||||
uas = generate_ua_pool(count)
|
||||
save_ua_pool(uas, cache_path)
|
||||
return uas
|
||||
|
||||
|
||||
def get_random_ua(ua_pool: List[str]) -> str:
|
||||
"""
|
||||
Get a random UA from the pool.
|
||||
|
||||
Args:
|
||||
ua_pool: List of UA strings
|
||||
|
||||
Returns:
|
||||
str: Random UA string from the pool
|
||||
"""
|
||||
if not ua_pool:
|
||||
# Fallback to generating one if pool is empty
|
||||
try:
|
||||
return generate_opera_ua()
|
||||
except Exception:
|
||||
# If generation fails, use default fallback
|
||||
return DEFAULT_FALLBACK_UA
|
||||
|
||||
return random.choice(ua_pool)
|
||||
|
||||
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
|
||||
8
app/version.py
Normal file
@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
optional_dev_tag = ''
|
||||
if os.getenv('DEV_BUILD'):
|
||||
optional_dev_tag = '.dev' + os.getenv('DEV_BUILD')
|
||||
|
||||
__version__ = '1.2.2' + optional_dev_tag
|
||||
|
||||
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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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,9 +1,48 @@
|
||||
version: "3"
|
||||
# Modern docker-compose format (v2+) does not require version specification
|
||||
# Memory limits are supported in Compose v2+ without version field
|
||||
|
||||
services:
|
||||
whoogle-search:
|
||||
image: benbusby/whoogle-search
|
||||
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
|
||||
#env_file: # Alternatively, load variables from whoogle.env
|
||||
#- whoogle.env
|
||||
ports:
|
||||
- 5000:5000
|
||||
restart: unless-stopped
|
||||
|
||||
BIN
docs/banner.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/screenshot_desktop.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
docs/screenshot_mobile.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
0
letsencrypt/acme.json
Normal file
363
misc/check_google_user_agents.py
Executable file
@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test User Agent strings against Google to find which ones return actual search results
|
||||
instead of JavaScript pages or upgrade browser messages.
|
||||
|
||||
Usage:
|
||||
python test_google_user_agents.py <user_agent_file> [--output <output_file>] [--query <search_query>]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from typing import List, Tuple
|
||||
import requests
|
||||
|
||||
# Common search queries to cycle through for more realistic testing
|
||||
DEFAULT_SEARCH_QUERIES = [
|
||||
"python programming",
|
||||
"weather today",
|
||||
"news",
|
||||
"how to cook pasta",
|
||||
"best movies 2025",
|
||||
"restaurants near me",
|
||||
"translate hello",
|
||||
"calculator",
|
||||
"time",
|
||||
"maps",
|
||||
"images",
|
||||
"videos",
|
||||
"shopping",
|
||||
"travel",
|
||||
"sports scores",
|
||||
"stock market",
|
||||
"recipes",
|
||||
"music",
|
||||
"books",
|
||||
"technology",
|
||||
"AI",
|
||||
"AI programming",
|
||||
"Why does google hate users?"
|
||||
]
|
||||
|
||||
# Markers that indicate blocked/JS pages
|
||||
BLOCK_MARKERS = [
|
||||
"unusual traffic",
|
||||
"sorry but your computer",
|
||||
"solve the captcha",
|
||||
"request looks automated",
|
||||
"g-recaptcha",
|
||||
"upgrade your browser",
|
||||
"browser is not supported",
|
||||
"please upgrade",
|
||||
"isn't supported",
|
||||
"isn\"t supported", # With escaped quote
|
||||
"upgrade to a recent version",
|
||||
"update your browser",
|
||||
"your browser isn't supported",
|
||||
]
|
||||
|
||||
# Markers that indicate actual search results
|
||||
SUCCESS_MARKERS = [
|
||||
'<div class="g"', # Google search result container
|
||||
'<div id="search"', # Search results container
|
||||
'<div class="rc"', # Result container
|
||||
'class="yuRUbf"', # Result link container
|
||||
'class="LC20lb"', # Result title
|
||||
'- Google Search</title>', # Page title indicator
|
||||
'id="rso"', # Results container
|
||||
'class="g"', # Result class (without div tag)
|
||||
]
|
||||
|
||||
|
||||
def read_user_agents(file_path: str) -> List[str]:
|
||||
"""Read user agent strings from a file, one per line."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
user_agents = [line.strip() for line in f if line.strip()]
|
||||
return user_agents
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File '{file_path}' not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def test_user_agent(user_agent: str, query: str = "test", timeout: float = 10.0) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test a user agent against Google search.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_working: bool, reason: str)
|
||||
"""
|
||||
url = "https://www.google.com/search"
|
||||
params = {"q": query, "gbv": "1", "num": "10"}
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=timeout)
|
||||
|
||||
# Check HTTP status
|
||||
if response.status_code == 429:
|
||||
# Rate limited - raise this so we can handle it specially
|
||||
raise Exception(f"Rate limited (429)")
|
||||
if response.status_code >= 500:
|
||||
return False, f"Server error ({response.status_code})"
|
||||
if response.status_code == 403:
|
||||
return False, f"Blocked ({response.status_code})"
|
||||
if response.status_code >= 400:
|
||||
return False, f"HTTP {response.status_code}"
|
||||
|
||||
body_lower = response.text.lower()
|
||||
|
||||
# Check for block markers
|
||||
for marker in BLOCK_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
return False, f"Blocked: {marker}"
|
||||
|
||||
# Check for redirect indicators first - these indicate non-working responses
|
||||
has_redirect = ("window.location" in body_lower or "location.href" in body_lower) and "google.com" not in body_lower
|
||||
if has_redirect:
|
||||
return False, "JavaScript redirect detected"
|
||||
|
||||
# Check for noscript redirect (another indicator of JS-only page)
|
||||
if 'noscript' in body_lower and 'http-equiv="refresh"' in body_lower:
|
||||
return False, "NoScript redirect page"
|
||||
|
||||
# Check for success markers (actual search results)
|
||||
# We need at least one strong indicator of search results
|
||||
has_results = any(marker in response.text for marker in SUCCESS_MARKERS)
|
||||
|
||||
if has_results:
|
||||
return True, "OK - Has search results"
|
||||
else:
|
||||
# Check for very short responses (likely error pages)
|
||||
if len(response.text) < 1000:
|
||||
return False, "Response too short (likely error page)"
|
||||
# If we don't have success markers, it's not a working response
|
||||
# Even if it's substantial and doesn't have block markers, it might be a JS-only page
|
||||
return False, "No search results found"
|
||||
|
||||
except requests.Timeout:
|
||||
return False, "Request timeout"
|
||||
except requests.HTTPError as e:
|
||||
if e.response and e.response.status_code == 429:
|
||||
# Rate limited - raise this so we can handle it specially
|
||||
raise Exception(f"Rate limited (429) - {str(e)}")
|
||||
return False, f"HTTP error: {str(e)}"
|
||||
except requests.RequestException as e:
|
||||
# Check if it's a 429 in the response
|
||||
if hasattr(e, 'response') and e.response and e.response.status_code == 429:
|
||||
raise Exception(f"Rate limited (429) - {str(e)}")
|
||||
return False, f"Request error: {str(e)}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test User Agent strings against Google to find working ones.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python test_google_user_agents.py UAs.txt
|
||||
python test_google_user_agents.py UAs.txt --output working_uas.txt
|
||||
python test_google_user_agents.py UAs.txt --query "python programming"
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
"user_agent_file",
|
||||
help="Path to file containing user agent strings (one per line)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
help="Output file to write working user agents (default: stdout)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query", "-q",
|
||||
default=None,
|
||||
help="Search query to use for testing (default: cycles through random queries)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--random-queries", "-r",
|
||||
action="store_true",
|
||||
help="Use random queries from a predefined list (default: True if --query not specified)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", "-t",
|
||||
type=float,
|
||||
default=10.0,
|
||||
help="Request timeout in seconds (default: 10.0)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay", "-d",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Delay between requests in seconds (default: 0.5)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Show detailed results for each user agent"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine query strategy
|
||||
use_random_queries = args.random_queries or (args.query is None)
|
||||
if use_random_queries:
|
||||
search_queries = DEFAULT_SEARCH_QUERIES.copy()
|
||||
random.shuffle(search_queries) # Shuffle for variety
|
||||
current_query_idx = 0
|
||||
query_display = f"cycling through {len(search_queries)} random queries"
|
||||
else:
|
||||
search_queries = [args.query]
|
||||
query_display = f"'{args.query}'"
|
||||
|
||||
# Read user agents
|
||||
user_agents = read_user_agents(args.user_agent_file)
|
||||
if not user_agents:
|
||||
print("No user agents found in file.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Testing {len(user_agents)} user agents against Google...", file=sys.stderr)
|
||||
print(f"Query: {query_display}", file=sys.stderr)
|
||||
if args.output:
|
||||
print(f"Output file: {args.output} (appending results incrementally)", file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
# Load existing working user agents from output file to avoid duplicates
|
||||
existing_working = set()
|
||||
if args.output:
|
||||
try:
|
||||
with open(args.output, 'r', encoding='utf-8') as f:
|
||||
existing_working = {line.strip() for line in f if line.strip()}
|
||||
if existing_working:
|
||||
print(f"Found {len(existing_working)} existing user agents in output file", file=sys.stderr)
|
||||
except FileNotFoundError:
|
||||
# File doesn't exist yet, that's fine
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read existing output file: {e}", file=sys.stderr)
|
||||
|
||||
# Open output file for incremental writing if specified (append mode)
|
||||
output_file = None
|
||||
if args.output:
|
||||
try:
|
||||
output_file = open(args.output, 'a', encoding='utf-8')
|
||||
except Exception as e:
|
||||
print(f"Error opening output file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
working_agents = []
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
last_successful_idx = 0
|
||||
|
||||
try:
|
||||
for idx, ua in enumerate(user_agents, 1):
|
||||
# Skip testing if this UA is already in the working file
|
||||
if args.output and ua in existing_working:
|
||||
skipped_count += 1
|
||||
if args.verbose:
|
||||
print(f"[{idx}/{len(user_agents)}] ⊘ SKIPPED - Already in working file", file=sys.stderr)
|
||||
last_successful_idx = idx
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get the next query (cycle through if using random queries)
|
||||
if use_random_queries:
|
||||
query = search_queries[current_query_idx % len(search_queries)]
|
||||
current_query_idx += 1
|
||||
else:
|
||||
query = args.query
|
||||
|
||||
is_working, reason = test_user_agent(ua, query, args.timeout)
|
||||
|
||||
if is_working:
|
||||
working_agents.append(ua)
|
||||
status = "✓"
|
||||
# Write immediately to output file if specified (skip if duplicate)
|
||||
if output_file:
|
||||
if ua not in existing_working:
|
||||
output_file.write(ua + '\n')
|
||||
output_file.flush() # Ensure it's written to disk
|
||||
existing_working.add(ua) # Track it to avoid duplicates
|
||||
else:
|
||||
if args.verbose:
|
||||
print(f"[{idx}/{len(user_agents)}] {status} WORKING (duplicate, skipped) - {reason}", file=sys.stderr)
|
||||
# Also print to stdout if no output file
|
||||
if not args.output:
|
||||
print(ua)
|
||||
|
||||
if args.verbose:
|
||||
print(f"[{idx}/{len(user_agents)}] {status} WORKING - {reason}", file=sys.stderr)
|
||||
else:
|
||||
failed_count += 1
|
||||
status = "✗"
|
||||
if args.verbose:
|
||||
print(f"[{idx}/{len(user_agents)}] {status} FAILED - {reason}", file=sys.stderr)
|
||||
|
||||
last_successful_idx = idx
|
||||
|
||||
# Progress indicator for non-verbose mode
|
||||
if not args.verbose and idx % 10 == 0:
|
||||
print(f"Progress: {idx}/{len(user_agents)} tested ({len(working_agents)} working, {failed_count} failed)", file=sys.stderr)
|
||||
|
||||
# Delay between requests to avoid rate limiting
|
||||
if idx < len(user_agents):
|
||||
time.sleep(args.delay)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(file=sys.stderr)
|
||||
print(f"\nInterrupted by user at index {idx}/{len(user_agents)}", file=sys.stderr)
|
||||
print(f"Last successful test: {last_successful_idx}/{len(user_agents)}", file=sys.stderr)
|
||||
break
|
||||
except Exception as e:
|
||||
# Handle unexpected errors (like network issues or rate limits)
|
||||
error_msg = str(e)
|
||||
if "429" in error_msg or "Rate limited" in error_msg:
|
||||
print(file=sys.stderr)
|
||||
print(f"\n⚠️ RATE LIMIT DETECTED at index {idx}/{len(user_agents)}", file=sys.stderr)
|
||||
print(f"Last successful test: {last_successful_idx}/{len(user_agents)}", file=sys.stderr)
|
||||
print(f"Working user agents found so far: {len(working_agents)}", file=sys.stderr)
|
||||
if args.output:
|
||||
print(f"Results saved to: {args.output}", file=sys.stderr)
|
||||
print(f"\nTo resume later, you can skip the first {last_successful_idx} user agents.", file=sys.stderr)
|
||||
raise # Re-raise to exit the loop
|
||||
else:
|
||||
print(f"[{idx}/{len(user_agents)}] ERROR - {error_msg}", file=sys.stderr)
|
||||
failed_count += 1
|
||||
last_successful_idx = idx
|
||||
if idx < len(user_agents):
|
||||
time.sleep(args.delay)
|
||||
continue
|
||||
|
||||
finally:
|
||||
# Close output file if opened
|
||||
if output_file:
|
||||
output_file.close()
|
||||
|
||||
# Summary
|
||||
print(file=sys.stderr)
|
||||
tested_count = last_successful_idx - skipped_count
|
||||
print(f"Summary: {len(working_agents)} working, {failed_count} failed, {skipped_count} skipped out of {last_successful_idx} processed (of {len(user_agents)} total)", file=sys.stderr)
|
||||
if last_successful_idx < len(user_agents):
|
||||
print(f"Note: Processing stopped at index {last_successful_idx}. {len(user_agents) - last_successful_idx} user agents not processed.", file=sys.stderr)
|
||||
if args.output:
|
||||
print(f"Results saved to: {args.output}", file=sys.stderr)
|
||||
|
||||
return 0 if working_agents else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
198
misc/generate_uas.py
Executable file
@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone Opera User Agent String Generator
|
||||
|
||||
This tool generates Opera-based User Agent strings that can be used with Whoogle.
|
||||
It can be run independently to generate and display UA strings on demand.
|
||||
|
||||
Usage:
|
||||
python misc/generate_uas.py [count]
|
||||
|
||||
Arguments:
|
||||
count: Number of UA strings to generate (default: 10)
|
||||
|
||||
Examples:
|
||||
python misc/generate_uas.py # Generate 10 UAs
|
||||
python misc/generate_uas.py 20 # Generate 20 UAs
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Default fallback UA if generation fails
|
||||
DEFAULT_FALLBACK_UA = "Opera/9.30 (Nintendo Wii; U; ; 3642; en)"
|
||||
|
||||
# Try to import from the app module if available
|
||||
try:
|
||||
# Add parent directory to path to allow imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from app.utils.ua_generator import generate_ua_pool
|
||||
USE_APP_MODULE = True
|
||||
except ImportError:
|
||||
USE_APP_MODULE = False
|
||||
# Self-contained version if app module is not available
|
||||
import random
|
||||
|
||||
# Opera UA Pattern Templates
|
||||
OPERA_PATTERNS = [
|
||||
"Opera/9.80 (J2ME/MIDP; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
"Opera/9.80 (Android; Linux; Opera Mobi/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
"Opera/9.80 (iPhone; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
"Opera/9.80 (iPad; Opera Mini/{version}/{build}; U; {lang}) Presto/{presto} Version/{final}",
|
||||
]
|
||||
|
||||
OPERA_MINI_VERSIONS = [
|
||||
"4.0", "4.1.11321", "4.2.13337", "4.2.14912", "4.2.15410", "4.3.24214",
|
||||
"5.0.18741", "5.1.22296", "5.1.22783", "6.0.24095", "6.24093", "7.1.32444",
|
||||
"7.6.35766", "36.2.2254"
|
||||
]
|
||||
|
||||
OPERA_MOBI_BUILDS = [
|
||||
"27", "49", "447", "1209", "3730", "ADR-1012221546", "SYB-1107071606"
|
||||
]
|
||||
|
||||
BUILD_NUMBERS = [
|
||||
"22.387", "22.478", "23.334", "23.377", "24.746", "24.783", "25.657",
|
||||
"27.1407", "28.2647", "35.5706", "119.132", "870", "886"
|
||||
]
|
||||
|
||||
PRESTO_VERSIONS = [
|
||||
"2.4.15", "2.4.18", "2.5.25", "2.8.119", "2.12.423"
|
||||
]
|
||||
|
||||
FINAL_VERSIONS = [
|
||||
"10.00", "10.1", "10.54", "11.10", "12.16", "13.00"
|
||||
]
|
||||
|
||||
LANGUAGES = [
|
||||
# English variants
|
||||
"en", "en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-ZA", "en-IN", "en-SG",
|
||||
# Western European
|
||||
"de", "de-DE", "de-AT", "de-CH",
|
||||
"fr", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU",
|
||||
"es", "es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-LA",
|
||||
"it", "it-IT", "it-CH",
|
||||
"pt", "pt-PT", "pt-BR",
|
||||
"nl", "nl-NL", "nl-BE",
|
||||
# Nordic languages
|
||||
"da", "da-DK",
|
||||
"sv", "sv-SE",
|
||||
"no", "no-NO", "nb", "nn",
|
||||
"fi", "fi-FI",
|
||||
"is", "is-IS",
|
||||
# Eastern European
|
||||
"pl", "pl-PL",
|
||||
"cs", "cs-CZ",
|
||||
"sk", "sk-SK",
|
||||
"hu", "hu-HU",
|
||||
"ro", "ro-RO",
|
||||
"bg", "bg-BG",
|
||||
"hr", "hr-HR",
|
||||
"sr", "sr-RS",
|
||||
"sl", "sl-SI",
|
||||
"uk", "uk-UA",
|
||||
"ru", "ru-RU",
|
||||
# Asian languages
|
||||
"zh", "zh-CN", "zh-TW", "zh-HK",
|
||||
"ja", "ja-JP",
|
||||
"ko", "ko-KR",
|
||||
"th", "th-TH",
|
||||
"vi", "vi-VN",
|
||||
"id", "id-ID",
|
||||
"ms", "ms-MY",
|
||||
"fil", "tl",
|
||||
# Middle Eastern
|
||||
"tr", "tr-TR",
|
||||
"ar", "ar-SA", "ar-AE", "ar-EG",
|
||||
"he", "he-IL",
|
||||
"fa", "fa-IR",
|
||||
# Other
|
||||
"hi", "hi-IN",
|
||||
"bn", "bn-IN",
|
||||
"ta", "ta-IN",
|
||||
"te", "te-IN",
|
||||
"mr", "mr-IN",
|
||||
"el", "el-GR",
|
||||
"ca", "ca-ES",
|
||||
"eu", "eu-ES"
|
||||
]
|
||||
|
||||
def generate_opera_ua():
|
||||
"""Generate a single random Opera User Agent string."""
|
||||
pattern = random.choice(OPERA_PATTERNS)
|
||||
params = {'lang': random.choice(LANGUAGES)}
|
||||
|
||||
if '{version}' in pattern:
|
||||
params['version'] = random.choice(OPERA_MINI_VERSIONS)
|
||||
if '{build}' in pattern:
|
||||
if "Opera Mobi" in pattern:
|
||||
params['build'] = random.choice(OPERA_MOBI_BUILDS)
|
||||
else:
|
||||
params['build'] = random.choice(BUILD_NUMBERS)
|
||||
if '{presto}' in pattern:
|
||||
params['presto'] = random.choice(PRESTO_VERSIONS)
|
||||
if '{final}' in pattern:
|
||||
params['final'] = random.choice(FINAL_VERSIONS)
|
||||
|
||||
return pattern.format(**params)
|
||||
|
||||
def generate_ua_pool(count=10):
|
||||
"""Generate a pool of unique Opera User Agent strings."""
|
||||
ua_pool = set()
|
||||
max_attempts = count * 100
|
||||
attempts = 0
|
||||
|
||||
try:
|
||||
while len(ua_pool) < count and attempts < max_attempts:
|
||||
ua = generate_opera_ua()
|
||||
ua_pool.add(ua)
|
||||
attempts += 1
|
||||
except Exception:
|
||||
# If generation fails entirely, return at least the default fallback
|
||||
if not ua_pool:
|
||||
return [DEFAULT_FALLBACK_UA]
|
||||
|
||||
# If we couldn't generate enough, fill remaining with default
|
||||
result = list(ua_pool)
|
||||
while len(result) < count:
|
||||
result.append(DEFAULT_FALLBACK_UA)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to generate and display UA strings."""
|
||||
# Parse command line argument
|
||||
count = 10 # Default
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
count = int(sys.argv[1])
|
||||
if count < 1:
|
||||
print("Error: Count must be a positive integer", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid count '{sys.argv[1]}'. Must be an integer.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Show which mode we're using (to stderr so it doesn't interfere with output)
|
||||
if USE_APP_MODULE:
|
||||
print(f"# Using app.utils.ua_generator module", file=sys.stderr)
|
||||
else:
|
||||
print(f"# Using standalone generator (app module not available)", file=sys.stderr)
|
||||
|
||||
print(f"# Generating {count} Opera User Agent strings...\n", file=sys.stderr)
|
||||
|
||||
# Generate UAs
|
||||
uas = generate_ua_pool(count)
|
||||
|
||||
# Display them (one per line, no numbering)
|
||||
for ua in uas:
|
||||
print(ua)
|
||||
|
||||
# Summary to stderr so it doesn't interfere with piping
|
||||
print(f"\n# Generated {len(uas)} unique User Agent strings", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
29
misc/heroku-regen.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Assumes this is being executed from a session that has already logged
|
||||
# into Heroku with "heroku login -i" beforehand.
|
||||
#
|
||||
# You can set this up to run every night when you aren't using the
|
||||
# instance with a cronjob. For example:
|
||||
# 0 3 * * * /home/pi/whoogle-search/config/heroku-regen.sh <app_name>
|
||||
|
||||
HEROKU_CLI_SITE="https://devcenter.heroku.com/articles/heroku-cli"
|
||||
|
||||
if ! [[ -x "$(command -v heroku)" ]]; then
|
||||
echo "Must have heroku cli installed: $HEROKU_CLI_SITE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(builtin cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/../"
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo -e "Must provide the name of the Whoogle instance to regenerate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_NAME="$1"
|
||||
|
||||
heroku apps:destroy "$APP_NAME" --confirm "$APP_NAME"
|
||||
heroku apps:create "$APP_NAME"
|
||||
heroku container:login
|
||||
heroku container:push web
|
||||
heroku container:release web
|
||||
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
|
||||