From 5d2562a723b9edd2cca37bb4fd69dff1fbcb367e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Mon, 17 Jun 2024 02:09:20 +0200 Subject: [PATCH] feat: huge refactor feat: interfaces for results feat: concurrency everywhere --- LICENSE | 312 ++++++++--------- Makefile | 6 - docker/Dockerfile | 2 +- generate/searcher/searcher.go | 33 +- go.mod | 57 ++- go.sum | 84 +---- hearchco_example.yaml | 2 +- src/cache/actions_results.go | 54 ++- src/cache/badger/badger.go | 153 -------- src/cache/badger/badger_test.go | 245 ------------- src/cache/{structs.go => db.go} | 15 +- src/cache/{interfaces.go => driver.go} | 0 src/cache/nocache/nocache_test.go | 16 +- src/cache/redis/redis.go | 6 +- src/cache/redis/redis_test.go | 24 +- src/cache/utils.go | 15 - src/cli/climode.go | 98 ------ src/cli/flags.go | 23 ++ src/cli/setup.go | 64 +--- src/cli/structs.go | 58 --- src/cli/version.go | 25 ++ src/config/defaults.go | 210 ++--------- src/config/defaults_cat_general.go | 48 +++ src/config/defaults_cat_images.go | 32 ++ src/config/defaults_cat_science.go | 30 ++ src/config/defaults_cat_thorough.go | 52 +++ src/config/defaults_ranking.go | 29 ++ src/config/load.go | 331 +++++++++--------- src/config/structs_category.go | 89 +++-- src/config/structs_config.go | 13 +- src/config/structs_server.go | 93 +++-- src/logger/logger.go | 36 -- src/logger/setup.go | 34 ++ src/main.go | 69 ++-- src/{profiling.go => profiler/run.go} | 25 +- src/router/compress.go | 24 -- src/router/middlewares/compress.go | 24 ++ src/router/{ => middlewares}/logging.go | 14 +- .../{middlewares.go => middlewares/setup.go} | 20 +- src/router/proxy.go | 110 ------ src/router/router.go | 25 +- src/router/routes.go | 57 --- src/router/{types.go => routes/errors.go} | 2 +- src/router/routes/params.go | 13 + src/router/{utils.go => routes/responses.go} | 11 +- src/router/routes/route_proxy.go | 106 ++++++ src/router/routes/route_search.go | 181 ++++++++++ src/router/routes/setup.go | 68 ++++ src/router/search.go | 152 -------- src/search/bucket/addresult.go | 103 ------ src/search/bucket/makeresult.go | 70 ---- src/search/bucket/relay.go | 12 - src/search/bucket/setresponse.go | 32 -- src/search/cache.go | 63 ---- src/search/category/category.go | 28 -- src/search/category/name.go | 23 +- src/search/context_cancel.go | 79 +++++ src/search/engine_interface.go | 13 - src/search/engines/_engines_test/run.go | 86 +++++ src/search/engines/_engines_test/structs.go | 39 ++- src/search/engines/_engines_test/tester.go | 59 ---- src/search/engines/_sedefaults/colly.go | 80 ----- src/search/engines/_sedefaults/init.go | 101 ------ src/search/engines/_sedefaults/pagesColly.go | 1 - src/search/engines/_sedefaults/prepare.go | 41 --- src/search/engines/_sedefaults/requests.go | 39 --- src/search/engines/bing/bing.go | 119 ------- src/search/engines/bing/bing.md | 3 +- src/search/engines/bing/bing_test.go | 36 -- src/search/engines/bing/dompaths.go | 12 + src/search/engines/bing/info.go | 23 -- src/search/engines/bing/infoparams.go | 20 ++ src/search/engines/bing/params.go | 13 + src/search/engines/bing/search.go | 112 ++++++ src/search/engines/bing/search_test.go | 41 +++ src/search/engines/bing/telemetry.go | 29 ++ .../engines/bingimages/bingimages_test.go | 36 -- .../bingimages/{dom.go => dompaths.go} | 0 src/search/engines/bingimages/infoparams.go | 23 ++ src/search/engines/bingimages/json.go | 2 +- src/search/engines/bingimages/options.go | 16 - src/search/engines/bingimages/params.go | 13 + .../bingimages/{bingimages.go => search.go} | 145 ++++---- src/search/engines/bingimages/search_test.go | 41 +++ src/search/engines/brave/brave.go | 100 ------ src/search/engines/brave/brave_test.go | 36 -- src/search/engines/brave/cookies.go | 21 ++ src/search/engines/brave/dompaths.go | 12 + src/search/engines/brave/infoparams.go | 22 ++ src/search/engines/brave/options.go | 24 -- src/search/engines/brave/search.go | 101 ++++++ src/search/engines/brave/search_test.go | 41 +++ src/search/engines/duckduckgo/cookies.go | 13 + src/search/engines/duckduckgo/ddg.md | 5 +- src/search/engines/duckduckgo/dompaths.go | 12 + src/search/engines/duckduckgo/duckduckgo.go | 112 ------ .../engines/duckduckgo/duckduckgo_test.go | 36 -- src/search/engines/duckduckgo/infoparams.go | 19 + src/search/engines/duckduckgo/options.go | 23 -- src/search/engines/duckduckgo/search.go | 127 +++++++ src/search/engines/duckduckgo/search_test.go | 41 +++ src/search/engines/etools/captcha.png | Bin 61229 -> 0 bytes src/search/engines/etools/dompaths.go | 12 + src/search/engines/etools/etools.go | 108 ------ src/search/engines/etools/etools_test.go | 36 -- src/search/engines/etools/infoparams.go | 23 ++ src/search/engines/etools/options.go | 25 -- src/search/engines/etools/params.go | 13 + src/search/engines/etools/search.go | 141 ++++++++ src/search/engines/etools/search_test.go | 41 +++ src/search/engines/google/dompaths.go | 12 + src/search/engines/google/google.go | 71 ---- src/search/engines/google/google_test.go | 36 -- src/search/engines/google/infoparams.go | 22 ++ src/search/engines/google/options.go | 21 -- src/search/engines/google/params.go | 21 ++ src/search/engines/google/search.go | 91 +++++ src/search/engines/google/search_test.go | 41 +++ .../engines/googleimages/googleimages.go | 129 ------- .../engines/googleimages/googleimages_test.go | 36 -- src/search/engines/googleimages/infoparams.go | 24 ++ src/search/engines/googleimages/json.go | 36 +- src/search/engines/googleimages/options.go | 14 - src/search/engines/googleimages/params.go | 21 ++ src/search/engines/googleimages/search.go | 137 ++++++++ .../engines/googleimages/search_test.go | 41 +++ src/search/engines/googlescholar/dompaths.go | 12 + .../engines/googlescholar/googlescholar.go | 96 ----- .../googlescholar/googlescholar_test.go | 36 -- .../engines/googlescholar/infoparams.go | 22 ++ src/search/engines/googlescholar/options.go | 21 -- src/search/engines/googlescholar/params.go | 21 ++ src/search/engines/googlescholar/search.go | 104 ++++++ .../engines/googlescholar/search_test.go | 41 +++ src/search/engines/googlescholar/telemetry.go | 21 ++ src/search/engines/mojeek/dompaths.go | 12 + src/search/engines/mojeek/infoparams.go | 20 ++ src/search/engines/mojeek/mojeek.go | 88 ----- src/search/engines/mojeek/mojeek_test.go | 36 -- src/search/engines/mojeek/options.go | 22 -- src/search/engines/mojeek/params.go | 21 ++ src/search/engines/mojeek/search.go | 91 +++++ src/search/engines/mojeek/search_test.go | 41 +++ src/search/engines/name.go | 10 +- src/search/engines/options/locale.go | 47 +++ src/search/engines/options/structs.go | 15 + src/search/engines/presearch/cookies.go | 13 + src/search/engines/presearch/infoparams.go | 18 + .../presearch/{json_response.go => json.go} | 6 +- src/search/engines/presearch/options.go | 24 -- src/search/engines/presearch/presearch.go | 128 ------- .../engines/presearch/presearch_test.go | 36 -- src/search/engines/presearch/search.go | 134 +++++++ src/search/engines/presearch/search_test.go | 41 +++ src/search/engines/qwant/infoparams.go | 21 ++ src/search/engines/qwant/json.go | 23 ++ src/search/engines/qwant/json_response.go | 23 -- src/search/engines/qwant/options.go | 18 - src/search/engines/qwant/params.go | 35 ++ src/search/engines/qwant/qwant.go | 153 -------- src/search/engines/qwant/qwant.md | 2 +- src/search/engines/qwant/qwant_test.go | 36 -- src/search/engines/qwant/search.go | 109 ++++++ src/search/engines/qwant/search_test.go | 41 +++ src/search/engines/startpage/dompaths.go | 12 + src/search/engines/startpage/image-1.png | Bin 69075 -> 0 bytes src/search/engines/startpage/image-2.png | Bin 30226 -> 0 bytes src/search/engines/startpage/image.png | Bin 106047 -> 0 bytes src/search/engines/startpage/infoparams.go | 18 + src/search/engines/startpage/options.go | 21 -- src/search/engines/startpage/params.go | 13 + src/search/engines/startpage/search.go | 103 ++++++ src/search/engines/startpage/search_test.go | 41 +++ src/search/engines/startpage/startpage.go | 95 ----- .../engines/startpage/startpage_test.go | 36 -- src/search/engines/structs.go | 62 ---- src/search/engines/swisscows/authenticator.go | 98 +++--- src/search/engines/swisscows/image-1.png | Bin 4850 -> 0 bytes src/search/engines/swisscows/image.png | Bin 66547 -> 0 bytes src/search/engines/swisscows/infoparams.go | 21 ++ .../swisscows/{json_response.go => json.go} | 10 +- src/search/engines/swisscows/options.go | 16 - src/search/engines/swisscows/params.go | 13 + src/search/engines/swisscows/search.go | 128 +++++++ src/search/engines/swisscows/search_test.go | 41 +++ src/search/engines/swisscows/swisscows.go | 151 -------- src/search/engines/swisscows/swisscows.md | 19 - .../engines/swisscows/swisscows_test.go | 36 -- src/search/engines/timeout.go | 12 - src/search/engines/yahoo/cookies.go | 13 + src/search/engines/yahoo/dompaths.go | 12 + src/search/engines/yahoo/infoparams.go | 20 ++ src/search/engines/yahoo/options.go | 27 -- src/search/engines/yahoo/search.go | 129 +++++++ src/search/engines/yahoo/search_test.go | 41 +++ src/search/engines/yahoo/telemetry.go | 22 ++ src/search/engines/yahoo/yahoo.go | 146 -------- src/search/engines/yahoo/yahoo_test.go | 36 -- src/search/engines/yep/infoparams.go | 23 ++ src/search/engines/yep/json.go | 21 +- src/search/engines/yep/options.go | 26 -- src/search/engines/yep/params.go | 21 ++ src/search/engines/yep/search.go | 131 +++++++ src/search/engines/yep/search_test.go | 41 +++ src/search/engines/yep/yep.go | 131 ------- src/search/engines/yep/yep.md | 16 - src/search/engines/yep/yep_test.go | 36 -- src/search/init.go | 17 + src/search/once.go | 46 +++ src/search/params.go | 24 ++ src/search/perform.go | 180 ---------- src/search/rank/filler.go | 36 -- src/search/rank/math.go | 15 - src/search/rank/rank.go | 29 -- src/search/rank/score.go | 24 -- src/search/rank/sorting.go | 36 -- src/search/receiver.go | 59 ++++ src/search/result/construct.go | 91 +++++ src/search/result/general.go | 67 ++++ src/search/result/general_scraped.go | 37 ++ src/search/result/images.go | 54 +++ src/search/result/images_output.go | 12 + src/search/result/images_scraped.go | 73 ++++ src/search/result/interface.go | 35 ++ src/search/result/map.go | 41 +++ src/search/result/output.go | 55 --- src/search/result/rank.go | 56 +++ src/search/result/rank/filler.go | 27 ++ src/search/result/rank/filler_test.go | 113 ++++++ src/search/result/rank/rank.go | 48 +++ src/search/result/rank/score.go | 31 ++ src/search/result/rank/sorting.go | 38 ++ src/search/result/rank/structs_test.go | 6 + src/search/result/rank_scraped.go | 48 +++ src/search/result/result.go | 33 -- src/search/result/retrieved.go | 22 -- src/search/result/shorten.go | 72 +++- src/search/result/shorten_test.go | 219 +++++++----- src/search/run_preferred_engines.go | 55 +++ src/search/run_preferred_origins.go | 91 +++++ src/search/run_required_engines.go | 55 +++ src/search/run_required_origins.go | 90 +++++ src/search/scraper/collector.go | 121 +++++++ src/search/scraper/dompaths.go | 25 ++ src/search/scraper/enginer.go | 54 +++ src/search/scraper/infoparams.go | 17 + .../_sedefaults => scraper}/pagecontext.go | 13 +- src/search/scraper/pagerankcounter.go | 25 ++ .../_sedefaults => scraper/parse}/fields.go | 30 +- src/search/{ => scraper}/parse/parse.go | 13 +- src/search/scraper/requests.go | 39 +++ src/search/scraper/scrape.go | 28 ++ src/search/scraper/timeout.go | 21 ++ src/search/search.go | 129 +++++-- src/search/useragent/useragent.go | 28 +- src/{ => utils}/anonymize/hash.go | 6 +- src/{ => utils}/anonymize/hash_test.go | 6 +- src/{ => utils}/anonymize/string.go | 46 ++- src/{ => utils}/anonymize/string_test.go | 12 +- src/{ => utils}/anonymize/structs_test.go | 2 +- src/{ => utils}/gotypelimits/ints.go | 0 src/{ => utils}/gotypelimits/uints.go | 0 src/utils/morestrings/join.go | 24 ++ .../fancy.go => utils/moretime/convert.go} | 22 +- .../time.go => utils/moretime/types.go} | 4 +- 265 files changed, 6719 insertions(+), 5997 deletions(-) delete mode 100644 src/cache/badger/badger.go delete mode 100644 src/cache/badger/badger_test.go rename src/cache/{structs.go => db.go} (55%) rename src/cache/{interfaces.go => driver.go} (100%) delete mode 100644 src/cache/utils.go delete mode 100644 src/cli/climode.go create mode 100644 src/cli/flags.go delete mode 100644 src/cli/structs.go create mode 100644 src/cli/version.go create mode 100644 src/config/defaults_cat_general.go create mode 100644 src/config/defaults_cat_images.go create mode 100644 src/config/defaults_cat_science.go create mode 100644 src/config/defaults_cat_thorough.go create mode 100644 src/config/defaults_ranking.go delete mode 100644 src/logger/logger.go create mode 100644 src/logger/setup.go rename src/{profiling.go => profiler/run.go} (77%) delete mode 100644 src/router/compress.go create mode 100644 src/router/middlewares/compress.go rename src/router/{ => middlewares}/logging.go (79%) rename src/router/{middlewares.go => middlewares/setup.go} (68%) delete mode 100644 src/router/proxy.go delete mode 100644 src/router/routes.go rename src/router/{types.go => routes/errors.go} (86%) create mode 100644 src/router/routes/params.go rename src/router/{utils.go => routes/responses.go} (74%) create mode 100644 src/router/routes/route_proxy.go create mode 100644 src/router/routes/route_search.go create mode 100644 src/router/routes/setup.go delete mode 100644 src/router/search.go delete mode 100644 src/search/bucket/addresult.go delete mode 100644 src/search/bucket/makeresult.go delete mode 100644 src/search/bucket/relay.go delete mode 100644 src/search/bucket/setresponse.go delete mode 100644 src/search/cache.go delete mode 100644 src/search/category/category.go create mode 100644 src/search/context_cancel.go delete mode 100644 src/search/engine_interface.go create mode 100644 src/search/engines/_engines_test/run.go delete mode 100644 src/search/engines/_engines_test/tester.go delete mode 100644 src/search/engines/_sedefaults/colly.go delete mode 100644 src/search/engines/_sedefaults/init.go delete mode 100644 src/search/engines/_sedefaults/pagesColly.go delete mode 100644 src/search/engines/_sedefaults/prepare.go delete mode 100644 src/search/engines/_sedefaults/requests.go delete mode 100644 src/search/engines/bing/bing.go delete mode 100644 src/search/engines/bing/bing_test.go create mode 100644 src/search/engines/bing/dompaths.go delete mode 100644 src/search/engines/bing/info.go create mode 100644 src/search/engines/bing/infoparams.go create mode 100644 src/search/engines/bing/params.go create mode 100644 src/search/engines/bing/search.go create mode 100644 src/search/engines/bing/search_test.go create mode 100644 src/search/engines/bing/telemetry.go delete mode 100644 src/search/engines/bingimages/bingimages_test.go rename src/search/engines/bingimages/{dom.go => dompaths.go} (100%) create mode 100644 src/search/engines/bingimages/infoparams.go delete mode 100644 src/search/engines/bingimages/options.go create mode 100644 src/search/engines/bingimages/params.go rename src/search/engines/bingimages/{bingimages.go => search.go} (57%) create mode 100644 src/search/engines/bingimages/search_test.go delete mode 100644 src/search/engines/brave/brave.go delete mode 100644 src/search/engines/brave/brave_test.go create mode 100644 src/search/engines/brave/cookies.go create mode 100644 src/search/engines/brave/dompaths.go create mode 100644 src/search/engines/brave/infoparams.go delete mode 100644 src/search/engines/brave/options.go create mode 100644 src/search/engines/brave/search.go create mode 100644 src/search/engines/brave/search_test.go create mode 100644 src/search/engines/duckduckgo/cookies.go create mode 100644 src/search/engines/duckduckgo/dompaths.go delete mode 100644 src/search/engines/duckduckgo/duckduckgo.go delete mode 100644 src/search/engines/duckduckgo/duckduckgo_test.go create mode 100644 src/search/engines/duckduckgo/infoparams.go delete mode 100644 src/search/engines/duckduckgo/options.go create mode 100644 src/search/engines/duckduckgo/search.go create mode 100644 src/search/engines/duckduckgo/search_test.go delete mode 100644 src/search/engines/etools/captcha.png create mode 100644 src/search/engines/etools/dompaths.go delete mode 100644 src/search/engines/etools/etools.go delete mode 100644 src/search/engines/etools/etools_test.go create mode 100644 src/search/engines/etools/infoparams.go delete mode 100644 src/search/engines/etools/options.go create mode 100644 src/search/engines/etools/params.go create mode 100644 src/search/engines/etools/search.go create mode 100644 src/search/engines/etools/search_test.go create mode 100644 src/search/engines/google/dompaths.go delete mode 100644 src/search/engines/google/google.go delete mode 100644 src/search/engines/google/google_test.go create mode 100644 src/search/engines/google/infoparams.go delete mode 100644 src/search/engines/google/options.go create mode 100644 src/search/engines/google/params.go create mode 100644 src/search/engines/google/search.go create mode 100644 src/search/engines/google/search_test.go delete mode 100644 src/search/engines/googleimages/googleimages.go delete mode 100644 src/search/engines/googleimages/googleimages_test.go create mode 100644 src/search/engines/googleimages/infoparams.go delete mode 100644 src/search/engines/googleimages/options.go create mode 100644 src/search/engines/googleimages/params.go create mode 100644 src/search/engines/googleimages/search.go create mode 100644 src/search/engines/googleimages/search_test.go create mode 100644 src/search/engines/googlescholar/dompaths.go delete mode 100644 src/search/engines/googlescholar/googlescholar.go delete mode 100644 src/search/engines/googlescholar/googlescholar_test.go create mode 100644 src/search/engines/googlescholar/infoparams.go delete mode 100644 src/search/engines/googlescholar/options.go create mode 100644 src/search/engines/googlescholar/params.go create mode 100644 src/search/engines/googlescholar/search.go create mode 100644 src/search/engines/googlescholar/search_test.go create mode 100644 src/search/engines/googlescholar/telemetry.go create mode 100644 src/search/engines/mojeek/dompaths.go create mode 100644 src/search/engines/mojeek/infoparams.go delete mode 100644 src/search/engines/mojeek/mojeek.go delete mode 100644 src/search/engines/mojeek/mojeek_test.go delete mode 100644 src/search/engines/mojeek/options.go create mode 100644 src/search/engines/mojeek/params.go create mode 100644 src/search/engines/mojeek/search.go create mode 100644 src/search/engines/mojeek/search_test.go create mode 100644 src/search/engines/options/locale.go create mode 100644 src/search/engines/options/structs.go create mode 100644 src/search/engines/presearch/cookies.go create mode 100644 src/search/engines/presearch/infoparams.go rename src/search/engines/presearch/{json_response.go => json.go} (64%) delete mode 100644 src/search/engines/presearch/options.go delete mode 100644 src/search/engines/presearch/presearch.go delete mode 100644 src/search/engines/presearch/presearch_test.go create mode 100644 src/search/engines/presearch/search.go create mode 100644 src/search/engines/presearch/search_test.go create mode 100644 src/search/engines/qwant/infoparams.go create mode 100644 src/search/engines/qwant/json.go delete mode 100644 src/search/engines/qwant/json_response.go delete mode 100644 src/search/engines/qwant/options.go create mode 100644 src/search/engines/qwant/params.go delete mode 100644 src/search/engines/qwant/qwant.go delete mode 100644 src/search/engines/qwant/qwant_test.go create mode 100644 src/search/engines/qwant/search.go create mode 100644 src/search/engines/qwant/search_test.go create mode 100644 src/search/engines/startpage/dompaths.go delete mode 100644 src/search/engines/startpage/image-1.png delete mode 100644 src/search/engines/startpage/image-2.png delete mode 100644 src/search/engines/startpage/image.png create mode 100644 src/search/engines/startpage/infoparams.go delete mode 100644 src/search/engines/startpage/options.go create mode 100644 src/search/engines/startpage/params.go create mode 100644 src/search/engines/startpage/search.go create mode 100644 src/search/engines/startpage/search_test.go delete mode 100644 src/search/engines/startpage/startpage.go delete mode 100644 src/search/engines/startpage/startpage_test.go delete mode 100644 src/search/engines/structs.go delete mode 100644 src/search/engines/swisscows/image-1.png delete mode 100644 src/search/engines/swisscows/image.png create mode 100644 src/search/engines/swisscows/infoparams.go rename src/search/engines/swisscows/{json_response.go => json.go} (70%) delete mode 100644 src/search/engines/swisscows/options.go create mode 100644 src/search/engines/swisscows/params.go create mode 100644 src/search/engines/swisscows/search.go create mode 100644 src/search/engines/swisscows/search_test.go delete mode 100644 src/search/engines/swisscows/swisscows.go delete mode 100644 src/search/engines/swisscows/swisscows.md delete mode 100644 src/search/engines/swisscows/swisscows_test.go delete mode 100644 src/search/engines/timeout.go create mode 100644 src/search/engines/yahoo/cookies.go create mode 100644 src/search/engines/yahoo/dompaths.go create mode 100644 src/search/engines/yahoo/infoparams.go delete mode 100644 src/search/engines/yahoo/options.go create mode 100644 src/search/engines/yahoo/search.go create mode 100644 src/search/engines/yahoo/search_test.go create mode 100644 src/search/engines/yahoo/telemetry.go delete mode 100644 src/search/engines/yahoo/yahoo.go delete mode 100644 src/search/engines/yahoo/yahoo_test.go create mode 100644 src/search/engines/yep/infoparams.go delete mode 100644 src/search/engines/yep/options.go create mode 100644 src/search/engines/yep/params.go create mode 100644 src/search/engines/yep/search.go create mode 100644 src/search/engines/yep/search_test.go delete mode 100644 src/search/engines/yep/yep.go delete mode 100644 src/search/engines/yep/yep.md delete mode 100644 src/search/engines/yep/yep_test.go create mode 100644 src/search/init.go create mode 100644 src/search/once.go create mode 100644 src/search/params.go delete mode 100644 src/search/perform.go delete mode 100644 src/search/rank/filler.go delete mode 100644 src/search/rank/math.go delete mode 100644 src/search/rank/rank.go delete mode 100644 src/search/rank/score.go delete mode 100644 src/search/rank/sorting.go create mode 100644 src/search/receiver.go create mode 100644 src/search/result/construct.go create mode 100644 src/search/result/general.go create mode 100644 src/search/result/general_scraped.go create mode 100644 src/search/result/images.go create mode 100644 src/search/result/images_output.go create mode 100644 src/search/result/images_scraped.go create mode 100644 src/search/result/interface.go create mode 100644 src/search/result/map.go delete mode 100644 src/search/result/output.go create mode 100644 src/search/result/rank.go create mode 100644 src/search/result/rank/filler.go create mode 100644 src/search/result/rank/filler_test.go create mode 100644 src/search/result/rank/rank.go create mode 100644 src/search/result/rank/score.go create mode 100644 src/search/result/rank/sorting.go create mode 100644 src/search/result/rank/structs_test.go create mode 100644 src/search/result/rank_scraped.go delete mode 100644 src/search/result/result.go delete mode 100644 src/search/result/retrieved.go create mode 100644 src/search/run_preferred_engines.go create mode 100644 src/search/run_preferred_origins.go create mode 100644 src/search/run_required_engines.go create mode 100644 src/search/run_required_origins.go create mode 100644 src/search/scraper/collector.go create mode 100644 src/search/scraper/dompaths.go create mode 100644 src/search/scraper/enginer.go create mode 100644 src/search/scraper/infoparams.go rename src/search/{engines/_sedefaults => scraper}/pagecontext.go (50%) create mode 100644 src/search/scraper/pagerankcounter.go rename src/search/{engines/_sedefaults => scraper/parse}/fields.go (61%) rename src/search/{ => scraper}/parse/parse.go (77%) create mode 100644 src/search/scraper/requests.go create mode 100644 src/search/scraper/scrape.go create mode 100644 src/search/scraper/timeout.go rename src/{ => utils}/anonymize/hash.go (74%) rename src/{ => utils}/anonymize/hash_test.go (89%) rename src/{ => utils}/anonymize/string.go (58%) rename src/{ => utils}/anonymize/string_test.go (88%) rename src/{ => utils}/anonymize/structs_test.go (72%) rename src/{ => utils}/gotypelimits/ints.go (100%) rename src/{ => utils}/gotypelimits/uints.go (100%) create mode 100644 src/utils/morestrings/join.go rename src/{moretime/fancy.go => utils/moretime/convert.go} (82%) rename src/{moretime/time.go => utils/moretime/types.go} (89%) diff --git a/LICENSE b/LICENSE index 8fe1f629..1c085165 100644 --- a/LICENSE +++ b/LICENSE @@ -1,178 +1,178 @@ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. Preamble - The GNU Affero General Public License is a free, copyleft license for +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - Developers that use our General Public Licenses protect your rights +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. - A secondary benefit of defending all users' freedom is that +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. - The GNU Affero General Public License is designed specifically to +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to +to the community. It requires the operator of a network server to provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on +users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. - The precise terms and conditions for copying, distribution and +The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS - 0. Definitions. +0. Definitions. - "This License" refers to version 3 of the GNU Affero General Public License. +"This License" refers to version 3 of the GNU Affero General Public License. - "Copyright" also means copyright-like laws that apply to other kinds of +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. - To "modify" a work means to copy from or adapt all or part of the work +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the +exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. - A "covered work" means either the unmodified Program or a work based +A "covered work" means either the unmodified Program or a work based on the Program. - To "propagate" a work means to do anything with it that, without +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, +computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. - An interactive user interface displays "Appropriate Legal Notices" +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If +work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. - 1. Source Code. +1. Source Code. - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source form of a work. - A "Standard Interface" means an interface that either is an official +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. - The "System Libraries" of an executable work include anything, other +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A +implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. - The "Corresponding Source" for a work in object code form means all +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's +control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source +which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. - The Corresponding Source need not include anything that users +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. - The Corresponding Source for a work in source code form is that +The Corresponding Source for a work in source code form is that same work. - 2. Basic Permissions. +2. Basic Permissions. - All rights granted under this License are granted for the term of +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your +content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. - You may make, run and propagate covered works that you do not +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose +in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works +not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. +3. Protecting Users' Legal Rights From Anti-Circumvention Law. - No covered work shall be deemed part of an effective technological +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. - When you convey a covered work, you waive any legal power to forbid +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or @@ -180,9 +180,9 @@ modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. - 4. Conveying Verbatim Copies. +4. Conveying Verbatim Copies. - You may convey verbatim copies of the Program's source code as you +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any @@ -190,12 +190,12 @@ non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. - You may charge any price or no price for each copy that you convey, +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. - 5. Conveying Modified Source Versions. +5. Conveying Modified Source Versions. - You may convey a work based on the Program, or the modifications to +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: @@ -220,19 +220,19 @@ terms of section 4, provided that you also meet all of these conditions: interfaces that do not display Appropriate Legal Notices, your work need not make them do so. - A compilation of a covered work with other separate and independent +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work +beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. - 6. Conveying Non-Source Forms. +6. Conveying Non-Source Forms. - You may convey a covered work in object code form under the terms +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: @@ -278,75 +278,75 @@ in one of these ways: Source of the work are being offered to the general public at no charge under subsection 6d. - A separable portion of the object code, whose source code is excluded +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. - A "User Product" is either (1) a "consumer product", which means any +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product +actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. - "Installation Information" for a User Product means any methods, +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must +a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. - If you convey an object code work under this section in, or with, or +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply +by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). - The requirement to provide Installation Information does not include a +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a +the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. - Corresponding Source conveyed, and Installation Information provided, +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. - 7. Additional Terms. +7. Additional Terms. - "Additional permissions" are terms that supplement the terms of this +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions +that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. - When you convey a copy of a covered work, you may at your option +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. - Notwithstanding any other provision of this License, for material you +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: @@ -373,74 +373,74 @@ that material) supplement the terms of this License with terms: any liability that these contractual assumptions directly impose on those licensors and authors. - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains +restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. - If you add terms to a covered work in accord with this section, you +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. - Additional terms, permissive or non-permissive, may be stated in the +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. - 8. Termination. +8. Termination. - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). - However, if you cease all violation of this License, then your +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. - Moreover, your license from a particular copyright holder is +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. - Termination of your rights under this section does not terminate the +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently +this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. - 9. Acceptance Not Required for Having Copies. +9. Acceptance Not Required for Having Copies. - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, +to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. - 10. Automatic Licensing of Downstream Recipients. +10. Automatic Licensing of Downstream Recipients. - Each time you convey a covered work, the recipient automatically +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible +propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. - An "entity transaction" is a transaction transferring control of an +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered +organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could @@ -448,43 +448,43 @@ give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. - 11. Patents. +11. Patents. - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". - A contributor's "essential patent claims" are all patent claims +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For +consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. - Each contributor grants you a non-exclusive, worldwide, royalty-free +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. - In the following three paragraphs, a "patent license" is any express +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a +sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. - If you convey a covered work, knowingly relying on a patent license, +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, @@ -492,13 +492,13 @@ then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. - If, pursuant to or in connection with a single transaction or +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify @@ -506,10 +506,10 @@ or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. - A patent license is "discriminatory" if it does not include within +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered +specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying @@ -521,83 +521,83 @@ for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. - Nothing in this License shall be construed as excluding or limiting +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. - 12. No Surrender of Others' Freedom. +12. No Surrender of Others' Freedom. - If conditions are imposed on you (whether by court order, agreement or +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a +excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Remote Network Interaction; Use with the GNU General Public License. +13. Remote Network Interaction; Use with the GNU General Public License. - Notwithstanding any other provision of this License, if you modify the +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source +means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. - Notwithstanding any other provision of this License, you have +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this +combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. - 14. Revised Versions of this License. +14. Revised Versions of this License. - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - Each version is given a distinguishing version number. If the +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the +Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. - If the Program specifies that a proxy can decide which future +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. - 15. Disclaimer of Warranty. +15. Disclaimer of Warranty. - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - 16. Limitation of Liability. +16. Limitation of Liability. - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE @@ -607,9 +607,9 @@ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - 17. Interpretation of Sections 15 and 16. +17. Interpretation of Sections 15 and 16. - If the disclaimer of warranty and limitation of liability provided +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the @@ -620,17 +620,17 @@ copy of the Program in return for a fee. How to Apply These Terms to Your New Programs - If you develop a new program, and you want it to be of the greatest +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. - To do so, attach the following notices to the program. It is safest +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Hearchco metasearch engine - Copyright (C) 2023 Hearchco + Copyright (C) 2024 Hearchco This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published @@ -647,15 +647,15 @@ the "copyright" line and a pointer to where the full notice is found. Also add information on how to contact you by electronic and paper mail. - If your software can interact with users remotely through a computer +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its +get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different +of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. - You should also get your employer (if you work as a programmer) or school, +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/Makefile b/Makefile index 588e0ca8..de1fc7d6 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,11 @@ run: air -- --pretty -run-cli: - go run ./src --cli --pretty debug: air -- --pretty -v -debug-cli: - go run ./srv --cli --pretty -v trace: air -- --pretty -vv -trace-cli: - go run ./src --cli --pretty -vv install: go get ./... diff --git a/docker/Dockerfile b/docker/Dockerfile index 412daca2..003332de 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -11,4 +11,4 @@ VOLUME [ "/data" ] EXPOSE 3030 -LABEL org.opencontainers.image.source="https://github.com/hearchco/hearchco" +LABEL org.opencontainers.image.source="https://github.com/hearchco/agent" diff --git a/generate/searcher/searcher.go b/generate/searcher/searcher.go index 98924800..1a22dba5 100644 --- a/generate/searcher/searcher.go +++ b/generate/searcher/searcher.go @@ -17,13 +17,15 @@ import ( ) var ( - typeName = flag.String("type", "", "type name; must be set") - output = flag.String("output", "", "output file name; default srcdir/_searcher.go") - trimprefix = flag.String("trimprefix", "", "trim the `prefix` from the generated constant names") - buildTags = flag.String("tags", "", "comma-separated list of build tags to apply") - packageName = flag.String("packagename", "", "name of the package for generated code; default current package") - enginesImport = flag.String("enginesimport", "github.com/hearchco/hearchco/src/search/engines", "source of the engines import, which is prefixed to imports for consts; default github.com/hearchco/hearchco/src/search/engines") - linecomment = flag.Bool("linecomment", false, "use line comment text as printed text when present") + typeName = flag.String("type", "", "type name; must be set") + output = flag.String("output", "", "output file name; default srcdir/_searcher.go") + trimprefix = flag.String("trimprefix", "", "trim the `prefix` from the generated constant names") + buildTags = flag.String("tags", "", "comma-separated list of build tags to apply") + packageName = flag.String("packagename", "", "name of the package for generated code; default current package") + interfaceImport = flag.String("interfaceimport", "github.com/hearchco/agent/src/search/scraper", "source of the interface import, which is prefixed to interfaces; default github.com/hearchco/agent/src/search/scraper") + interfaceName = flag.String("interfacename", "scraper.Enginer", "name of the interface; default scraper.Enginer") + enginesImport = flag.String("enginesimport", "github.com/hearchco/agent/src/search/engines", "source of the engines import, which is prefixed to imports for engines; default github.com/hearchco/agent/src/search/engines") + linecomment = flag.Bool("linecomment", false, "use line comment text as printed text when present") ) // Usage is a replacement usage function for the flags package. @@ -90,7 +92,8 @@ func main() { } g.Printf("package %s", pkgName) g.Printf("\n") - g.Printf("import \"%s\"\n", *enginesImport) // Used by all methods. + g.Printf("import \"%s\"\n", *interfaceImport) + g.Printf("import \"%s\"\n", *enginesImport) // Run generate for each type. for _, typeName := range types { @@ -195,7 +198,7 @@ func (g *Generator) generate(typeName string) { } g.Printf("}\n") - g.buildOneRun(values) + g.printEnginer(values, *interfaceName) } // format returns the gofmt-ed contents of the Generator's buffer. @@ -314,12 +317,10 @@ func (f *File) genDecl(node ast.Node) bool { return false } -// buildOneRun generates the variables and NewEngineStarter func for a single run of contiguous values. -func (g *Generator) buildOneRun(values []Value) { +func (g *Generator) printEnginer(values []Value, interfaceName string) { g.Printf("\n") - // The generated code is simple enough to write as a Printf format. - g.Printf("\nfunc NewEngineStarter() [%d]Searcher {", len(values)) - g.Printf("\n\tvar engineArray [%d]Searcher", len(values)) + g.Printf("\nfunc enginerArray() [%d]%s {", len(values), interfaceName) + g.Printf("\n\tvar engineArray [%d]%s", len(values), interfaceName) for _, v := range values { if validConst(v) { g.Printf("\n\tengineArray[%s.%s] = %s.New()", g.pkg.name, v.name, strings.ToLower(v.name)) @@ -327,4 +328,8 @@ func (g *Generator) buildOneRun(values []Value) { } g.Printf("\n\treturn engineArray") g.Printf("\n}") + + g.Printf("\n") + g.Printf("\nconst enginerLen = %d", len(values)) + g.Printf("\n") } diff --git a/go.mod b/go.mod index 7e7c0d22..b0427a4a 100644 --- a/go.mod +++ b/go.mod @@ -1,78 +1,61 @@ -module github.com/hearchco/hearchco +module github.com/hearchco/agent go 1.22 toolchain go1.22.0 require ( + github.com/PuerkitoBio/goquery v1.9.2 github.com/alecthomas/kong v0.9.0 github.com/andybalholm/brotli v1.1.0 github.com/aws/aws-lambda-go v1.47.0 github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 - github.com/dgraph-io/badger/v4 v4.2.0 github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/cors v1.2.1 - github.com/gocolly/colly/v2 v2.1.1-0.20231020184023-3c987f1982ed + github.com/gocolly/colly/v2 v2.1.1-0.20231020184023-3c987f1982ed // Hearchco's PR github.com/knadh/koanf/parsers/yaml v0.1.0 github.com/knadh/koanf/providers/env v0.1.0 github.com/knadh/koanf/providers/file v0.1.0 github.com/knadh/koanf/providers/structs v0.1.0 github.com/knadh/koanf/v2 v2.1.1 github.com/pkg/profile v1.7.0 - github.com/redis/go-redis/v9 v9.5.2 + github.com/redis/go-redis/v9 v9.5.3 github.com/rs/zerolog v1.33.0 + golang.org/x/net v0.26.0 golang.org/x/tools v0.22.0 ) require ( + github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/antchfx/htmlquery v1.3.1 // indirect + github.com/antchfx/xmlquery v1.4.0 // indirect + github.com/antchfx/xpath v1.3.0 // indirect github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/fgprof v0.9.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/glog v1.2.1 // indirect - github.com/golang/snappy v0.0.4 // indirect - github.com/google/flatbuffers v24.3.25+incompatible // indirect - github.com/google/pprof v0.0.0-20240528025155-186aa0362fba // indirect - github.com/klauspost/compress v1.17.8 // indirect - github.com/knadh/koanf/maps v0.1.1 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/nlnwa/whatwg-url v0.4.1 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect - github.com/rs/xid v1.5.0 // indirect - github.com/stretchr/testify v1.8.4 // indirect - go.opencensus.io v0.24.0 // indirect - golang.org/x/mod v0.18.0 // indirect - golang.org/x/sync v0.7.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -require ( - github.com/PuerkitoBio/goquery v1.9.2 - github.com/andybalholm/cascadia v1.3.2 // indirect - github.com/antchfx/htmlquery v1.3.1 // indirect - github.com/antchfx/xmlquery v1.4.0 // indirect - github.com/antchfx/xpath v1.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/pprof v0.0.0-20240528025155-186aa0362fba // indirect github.com/kennygrant/sanitize v1.2.4 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/nlnwa/whatwg-url v0.4.1 // indirect + github.com/rs/xid v1.5.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/temoto/robotstxt v1.1.2 // indirect - golang.org/x/net v0.26.0 + golang.org/x/mod v0.18.0 // indirect + golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.16.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.34.1 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 58fc46d9..a4424338 100644 --- a/go.sum +++ b/go.sum @@ -37,7 +37,6 @@ github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdb github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= @@ -50,26 +49,13 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= -github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= -github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= -github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= @@ -82,8 +68,8 @@ github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= -github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc= +github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= @@ -92,11 +78,7 @@ github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/K github.com/gocolly/colly/v2 v2.1.1-0.20231020184023-3c987f1982ed h1:JBVpXGF611yz+zSXdRbWOqZ4C6gs7YpthVvm752yO4Q= github.com/gocolly/colly/v2 v2.1.1-0.20231020184023-3c987f1982ed/go.mod h1:bpukTX2Y+tFDoVBr4gAh7osKn/IbhWTgdmL1sMP0u0c= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.2.1 h1:OptwRhECazUx5ix5TTWC3EZhsZEHWcYWY4FQHTIubm4= -github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -111,21 +93,14 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= -github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -133,7 +108,6 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8I github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/google/pprof v0.0.0-20240528025155-186aa0362fba h1:ql1qNgCyOB7iAEk8JTNM+zJrgIbnyCKX/wdlyPufP5g= github.com/google/pprof v0.0.0-20240528025155-186aa0362fba/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= @@ -142,10 +116,6 @@ github.com/jawher/mow.cli v1.1.0/go.mod h1:aNaQlc7ozF3vw6IJ2dHjp2ZFiA4ozMIYY6Pyu github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= -github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= @@ -158,13 +128,6 @@ github.com/knadh/koanf/providers/structs v0.1.0 h1:wJRteCNn1qvLtE5h8KQBvLJovidSd github.com/knadh/koanf/providers/structs v0.1.0/go.mod h1:sw2YZ3txUcqA3Z27gPlmmBzWn1h8Nt9O6EP/91MkcWE= github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -187,19 +150,14 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042 github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU= github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/redis/go-redis/v9 v9.5.2 h1:L0L3fcSNReTRGyZ6AqAEN0K56wYeYAwapBIhkvh0f3E= -github.com/redis/go-redis/v9 v9.5.2/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU= +github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= @@ -210,24 +168,16 @@ github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/temoto/robotstxt v1.1.1/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= @@ -237,8 +187,6 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= @@ -252,11 +200,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -274,8 +219,6 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= @@ -284,7 +227,6 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -293,7 +235,6 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -331,16 +272,12 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= @@ -351,9 +288,7 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -363,17 +298,14 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hearchco_example.yaml b/hearchco_example.yaml index 3ddbe87e..598aa988 100644 --- a/hearchco_example.yaml +++ b/hearchco_example.yaml @@ -2,7 +2,7 @@ server: frontendurls: http://localhost:5173,https://*hearch.co cache: type: none - proxy: + imageproxy: salt: changemepls # categories: # science: diff --git a/src/cache/actions_results.go b/src/cache/actions_results.go index 408494bb..0268d921 100644 --- a/src/cache/actions_results.go +++ b/src/cache/actions_results.go @@ -1,29 +1,61 @@ package cache import ( + "fmt" "time" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/result" + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" ) -func (db DB) SetResults(query string, options engines.Options, results []result.Result, ttl ...time.Duration) error { - key := combineQueryWithOptions(query, options) +func (db DB) SetResults(q string, cat category.Name, opts options.Options, results []result.Result, ttl ...time.Duration) error { + key := combineQueryWithOptions(q, cat, opts) return db.driver.Set(key, results, ttl...) } -func (db DB) GetResults(query string, options engines.Options) ([]result.Result, error) { - key := combineQueryWithOptions(query, options) +func (db DB) GetResults(q string, cat category.Name, opts options.Options) ([]result.Result, error) { var results []result.Result - err := db.driver.Get(key, &results) + var err error + + key := combineQueryWithOptions(q, cat, opts) + if cat == category.IMAGES { + var imgResults []result.Images + err = db.driver.Get(key, &imgResults) + results = make([]result.Result, 0, len(imgResults)) + for _, imgResult := range imgResults { + results = append(results, &imgResult) + } + + } else { + var genResults []result.General + err = db.driver.Get(key, &genResults) + results = make([]result.Result, 0, len(genResults)) + for _, imgResult := range genResults { + results = append(results, &imgResult) + } + } + return results, err } -func (db DB) GetResultsTTL(query string, options engines.Options) (time.Duration, error) { - key := combineQueryWithOptions(query, options) +func (db DB) GetResultsTTL(q string, cat category.Name, opts options.Options) (time.Duration, error) { + key := combineQueryWithOptions(q, cat, opts) return db.driver.GetTTL(key) } -func combineQueryWithOptions(query string, options engines.Options) string { - return combineIntoKey(query, options.VisitPages, options.SafeSearch, options.Pages.Start, options.Pages.Max, options.Locale, options.Category.String()) +func combineQueryWithOptions(q string, cat category.Name, opts options.Options) string { + return combineIntoKey(q, cat.String(), opts.Pages.Start, opts.Pages.Max, opts.Locale, opts.SafeSearch) +} + +func combineIntoKey(s ...any) string { + var key string + for i, v := range s { + if i == 0 { + key = fmt.Sprintf("%v", v) + } else { + key = fmt.Sprintf("%v_%v", key, v) + } + } + return key } diff --git a/src/cache/badger/badger.go b/src/cache/badger/badger.go deleted file mode 100644 index a84081a1..00000000 --- a/src/cache/badger/badger.go +++ /dev/null @@ -1,153 +0,0 @@ -package badger - -import ( - "encoding/json" - "fmt" - "path" - "time" - - "github.com/dgraph-io/badger/v4" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/rs/zerolog/log" -) - -type DRV struct { - keyPrefix string - client *badger.DB -} - -func New(dataDirPath string, keyPrefix string, config config.Badger) (DRV, error) { - badgerPath := path.Join(dataDirPath, "database") - - var opt badger.Options - if config.Persist { - opt = badger.DefaultOptions(badgerPath).WithLoggingLevel(badger.WARNING) - } else { - opt = badger.DefaultOptions("").WithInMemory(true).WithLoggingLevel(badger.WARNING) - } - - client, err := badger.Open(opt) - if err != nil { - log.Error(). - Err(err). - Bool("persistence", config.Persist). - Str("path", badgerPath). - Msg("Error opening badger") - } else if config.Persist { - log.Info(). - Bool("persistence", config.Persist). - Str("path", badgerPath). - Msg("Successfully opened badger") - } else { - log.Info(). - Bool("persistence", config.Persist). - Msg("Successfully opened in-memory badger") - } - - return DRV{keyPrefix, client}, err -} - -func (drv DRV) Close() { - if err := drv.client.Close(); err != nil { - log.Error(). - Err(err). - Msg("Error closing badger") - } else { - log.Debug().Msg("Successfully closed badger") - } -} - -func (drv DRV) Set(k string, v any, ttl ...time.Duration) error { - log.Debug().Msg("Caching...") - cacheTimer := time.Now() - - var setTtl time.Duration = 0 - if len(ttl) > 0 { - setTtl = ttl[0] - } - - key := anonymize.HashToSHA256B64(fmt.Sprintf("%v%v", drv.keyPrefix, k)) - if val, err := json.Marshal(v); err != nil { - return fmt.Errorf("badger.Set(): error marshaling value: %w", err) - } else if err := drv.client.Update(func(txn *badger.Txn) error { - var e *badger.Entry - if setTtl != 0 { - e = badger.NewEntry([]byte(key), val).WithTTL(ttl[0]) - } else { - e = badger.NewEntry([]byte(key), val) - } - return txn.SetEntry(e) - // ^returns error into else if - }); err != nil { - return fmt.Errorf("badger.Set(): error setting KV to badger: %w", err) - } else { - log.Trace(). - Dur("duration", time.Since(cacheTimer)). - Msg("Cached results") - } - - return nil -} - -func (drv DRV) Get(k string, o any) error { - key := anonymize.HashToSHA256B64(fmt.Sprintf("%v%v", drv.keyPrefix, k)) - - var val []byte - err := drv.client.View(func(txn *badger.Txn) error { - item, err := txn.Get([]byte(key)) - if err != nil { - return err - } - - v, err := item.ValueCopy(nil) - val = v - - return err - }) - - if err == badger.ErrKeyNotFound { - log.Trace(). - Str("key", key). - Msg("Found no value in badger") - } else if err != nil { - return fmt.Errorf("badger.Get(): error getting value from badger for key %v: %w", key, err) - } else if err := json.Unmarshal(val, o); err != nil { - return fmt.Errorf("badger.Get(): failed unmarshaling value from badger for key %v: %w", key, err) - } - - return nil -} - -// returns time until the key expires, not the time it will be considered expired -func (drv DRV) GetTTL(k string) (time.Duration, error) { - key := anonymize.HashToSHA256B64(fmt.Sprintf("%v%v", drv.keyPrefix, k)) - - var expiresIn time.Duration - err := drv.client.View(func(txn *badger.Txn) error { - item, err := txn.Get([]byte(key)) - if err != nil { - return err - } - - expiresAtUnix := time.Unix(int64(item.ExpiresAt()), 0) - expiresIn = time.Until(expiresAtUnix) - - // returns negative time.Since() if expiresAtUnix is in the past - if expiresIn < 0 { - expiresIn = 0 - } - - return err - }) - - if err == badger.ErrKeyNotFound { - log.Trace(). - Str("key", key). - Msg("Found no value in badger") - } else if err != nil { - return expiresIn, fmt.Errorf("badger.Get(): error getting value from badger for key %v: %w", key, err) - } - - return expiresIn, nil -} diff --git a/src/cache/badger/badger_test.go b/src/cache/badger/badger_test.go deleted file mode 100644 index 1536a7f9..00000000 --- a/src/cache/badger/badger_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package badger_test - -import ( - "testing" - "time" - - badgerog "github.com/dgraph-io/badger/v4" - "github.com/hearchco/hearchco/src/cache/badger" - "github.com/hearchco/hearchco/src/config" -) - -func TestNewInMemory(t *testing.T) { - _, err := badger.New("", "TEST_", config.Badger{Persist: false}) - if err != nil { - t.Errorf("error opening in-memory badger: %v", err) - } -} - -func TestNewPersistence(t *testing.T) { - path := "./testdump/new" - _, err := badger.New(path, "TEST_", config.Badger{Persist: true}) - if err != nil { - t.Errorf("error opening badger at %v: %v", path, err) - } -} - -func TestCloseInMemory(t *testing.T) { - db, err := badger.New("", "TEST_", config.Badger{Persist: false}) - if err != nil { - t.Errorf("error opening in-memory badger: %v", err) - } - - db.Close() -} - -func TestClosePersistence(t *testing.T) { - path := "./testdump/close" - db, err := badger.New(path, "TEST_", config.Badger{Persist: true}) - if err != nil { - t.Errorf("error opening badger at %v: %v", path, err) - } - - db.Close() -} - -func TestSetInMemory(t *testing.T) { - db, err := badger.New("", "TEST_", config.Badger{Persist: false}) - if err != nil { - t.Errorf("error opening in-memory badger: %v", err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue") - if err != nil { - t.Errorf("error setting key-value pair: %v", err) - } -} - -func TestSetPersistence(t *testing.T) { - path := "./testdump/set" - db, err := badger.New(path, "TEST_", config.Badger{Persist: true}) - if err != nil { - t.Errorf("error opening badger at %v: %v", path, err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue") - if err != nil { - t.Errorf("error setting key-value pair: %v", err) - } -} - -func TestSetTTLInMemory(t *testing.T) { - db, err := badger.New("", "TEST_", config.Badger{Persist: false}) - if err != nil { - t.Errorf("error opening in-memory badger: %v", err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue", 100*time.Second) - if err != nil { - t.Errorf("error setting key-value pair with TTL: %v", err) - } -} - -func TestSetTTLPersistence(t *testing.T) { - path := "./testdump/setttl" - db, err := badger.New(path, "TEST_", config.Badger{Persist: true}) - if err != nil { - t.Errorf("error opening badger at %v: %v", path, err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue", 100*time.Second) - if err != nil { - t.Errorf("error setting key-value pair with TTL: %v", err) - } -} - -func TestGetInMemory(t *testing.T) { - db, err := badger.New("", "TEST_", config.Badger{Persist: false}) - if err != nil { - t.Errorf("error opening in-memory badger: %v", err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue") - if err != nil { - t.Errorf("error setting key-value pair: %v", err) - } - - var value string - err = db.Get("testkey", &value) - if err != nil { - t.Errorf("error getting key-value pair: %v", err) - } - - if value != "testvalue" { - t.Errorf("expected value: testvalue, got: %v", value) - } -} - -func TestGetPersistence(t *testing.T) { - path := "./testdump/get" - db, err := badger.New(path, "TEST_", config.Badger{Persist: true}) - if err != nil { - t.Errorf("error opening badger at %v: %v", path, err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue") - if err != nil { - t.Errorf("error setting key-value pair: %v", err) - } - - var value string - err = db.Get("testkey", &value) - if err != nil { - t.Errorf("error getting key-value pair: %v", err) - } - - if value != "testvalue" { - t.Errorf("expected value: testvalue, got: %v", value) - } -} - -func TestGetTTLInMemory(t *testing.T) { - db, err := badger.New("", "TEST_", config.Badger{Persist: false}) - if err != nil { - t.Errorf("error opening in-memory badger: %v", err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue", 100*time.Second) - if err != nil { - t.Errorf("error setting key-value pair with TTL: %v", err) - } - - ttl, err := db.GetTTL("testkey") - if err != nil { - t.Errorf("error getting TTL: %v", err) - } - - // TTL is not exact, so we check for a range - if ttl > 100*time.Second || ttl < 99*time.Second { - t.Errorf("expected 100s >= ttl >= 99s, got: %v", ttl) - } -} - -func TestGetTTLPersistence(t *testing.T) { - path := "./testdump/getttl" - db, err := badger.New(path, "TEST_", config.Badger{Persist: true}) - if err != nil { - t.Errorf("error opening badger at %v: %v", path, err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue", 100*time.Second) - if err != nil { - t.Errorf("error setting key-value pair with TTL: %v", err) - } - - ttl, err := db.GetTTL("testkey") - if err != nil { - t.Errorf("error getting TTL: %v", err) - } - - // TTL is not exact, so we check for a range - if ttl > 100*time.Second || ttl < 99*time.Second { - t.Errorf("expected 100s >= ttl >= 99s, got: %v", ttl) - } -} - -func TestGetInMemoryExpired(t *testing.T) { - db, err := badger.New("", "TEST_", config.Badger{Persist: false}) - if err != nil { - t.Errorf("error opening in-memory badger: %v", err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue", 1*time.Second) - if err != nil { - t.Errorf("error setting key-value pair with TTL: %v", err) - } - - time.Sleep(1 * time.Second) - - var value string - err = db.Get("testkey", &value) - if err != nil && err != badgerog.ErrKeyNotFound { - t.Errorf("error getting key-value pair: %v", err) - } -} - -func TestGetPersistenceExpired(t *testing.T) { - path := "./testdump/getexpired" - db, err := badger.New(path, "TEST_", config.Badger{Persist: true}) - if err != nil { - t.Errorf("error opening badger at %v: %v", path, err) - } - - defer db.Close() - - err = db.Set("testkey", "testvalue", 1*time.Second) - if err != nil { - t.Errorf("error setting key-value pair with TTL: %v", err) - } - - time.Sleep(1 * time.Second) - - var value string - err = db.Get("testkey", &value) - if err != nil && err != badgerog.ErrKeyNotFound { - t.Errorf("error getting key-value pair: %v", err) - } -} diff --git a/src/cache/structs.go b/src/cache/db.go similarity index 55% rename from src/cache/structs.go rename to src/cache/db.go index c072a6ba..a9663b9c 100644 --- a/src/cache/structs.go +++ b/src/cache/db.go @@ -4,27 +4,22 @@ import ( "context" "fmt" - "github.com/hearchco/hearchco/src/cache/badger" - "github.com/hearchco/hearchco/src/cache/nocache" - "github.com/hearchco/hearchco/src/cache/redis" - "github.com/hearchco/hearchco/src/config" "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/cache/nocache" + "github.com/hearchco/agent/src/cache/redis" + "github.com/hearchco/agent/src/config" ) type DB struct { driver Driver } -func New(ctx context.Context, fileDbPath string, cacheConf config.Cache) (DB, error) { +func New(ctx context.Context, cacheConf config.Cache) (DB, error) { var drv Driver var err error switch cacheConf.Type { - case "badger": - drv, err = badger.New(fileDbPath, cacheConf.KeyPrefix, cacheConf.Badger) - if err != nil { - err = fmt.Errorf("failed creating a badger cache: %w", err) - } case "redis": drv, err = redis.New(ctx, cacheConf.KeyPrefix, cacheConf.Redis) if err != nil { diff --git a/src/cache/interfaces.go b/src/cache/driver.go similarity index 100% rename from src/cache/interfaces.go rename to src/cache/driver.go diff --git a/src/cache/nocache/nocache_test.go b/src/cache/nocache/nocache_test.go index 491abc45..6e070f2e 100644 --- a/src/cache/nocache/nocache_test.go +++ b/src/cache/nocache/nocache_test.go @@ -1,20 +1,18 @@ -package nocache_test +package nocache import ( "testing" - - "github.com/hearchco/hearchco/src/cache/nocache" ) func TestNew(t *testing.T) { - _, err := nocache.New() + _, err := New() if err != nil { t.Errorf("error creating nocache: %v", err) } } func TestClose(t *testing.T) { - db, err := nocache.New() + db, err := New() if err != nil { t.Errorf("error creating nocache: %v", err) } @@ -23,7 +21,7 @@ func TestClose(t *testing.T) { } func TestSet(t *testing.T) { - db, err := nocache.New() + db, err := New() if err != nil { t.Errorf("error creating nocache: %v", err) } @@ -37,7 +35,7 @@ func TestSet(t *testing.T) { } func TestSetTTL(t *testing.T) { - db, err := nocache.New() + db, err := New() if err != nil { t.Errorf("error creating nocache: %v", err) } @@ -51,7 +49,7 @@ func TestSetTTL(t *testing.T) { } func TestGet(t *testing.T) { - db, err := nocache.New() + db, err := New() if err != nil { t.Errorf("error creating nocache: %v", err) } @@ -75,7 +73,7 @@ func TestGet(t *testing.T) { } func TestGetTTL(t *testing.T) { - db, err := nocache.New() + db, err := New() if err != nil { t.Errorf("error creating nocache: %v", err) } diff --git a/src/cache/redis/redis.go b/src/cache/redis/redis.go index 4f84a119..bca42ec4 100644 --- a/src/cache/redis/redis.go +++ b/src/cache/redis/redis.go @@ -6,10 +6,11 @@ import ( "fmt" "time" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" "github.com/redis/go-redis/v9" "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/utils/anonymize" ) type DRV struct { @@ -81,6 +82,7 @@ func (drv DRV) Get(k string, o any) error { log.Trace(). Str("key", key). Msg("Found no value in redis") + return nil } else if err != nil { return fmt.Errorf("redis.Get(): error getting value from redis for key %v: %w", key, err) } else if err := json.Unmarshal([]byte(val), o); err != nil { diff --git a/src/cache/redis/redis_test.go b/src/cache/redis/redis_test.go index 8ff4962e..e4a93a0e 100644 --- a/src/cache/redis/redis_test.go +++ b/src/cache/redis/redis_test.go @@ -1,4 +1,4 @@ -package redis_test +package redis import ( "context" @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/hearchco/hearchco/src/cache/redis" - "github.com/hearchco/hearchco/src/config" - redisog "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9" + + "github.com/hearchco/agent/src/config" ) func newRedisConf() config.Redis { @@ -57,7 +57,7 @@ var redisConf = newRedisConf() func TestNew(t *testing.T) { ctx := context.Background() - _, err := redis.New(ctx, "TEST_", redisConf) + _, err := New(ctx, "TEST_", redisConf) if err != nil { t.Errorf("error creating redis: %v", err) } @@ -65,7 +65,7 @@ func TestNew(t *testing.T) { func TestClose(t *testing.T) { ctx := context.Background() - db, err := redis.New(ctx, "TEST_", redisConf) + db, err := New(ctx, "TEST_", redisConf) if err != nil { t.Errorf("error creating redis: %v", err) } @@ -75,7 +75,7 @@ func TestClose(t *testing.T) { func TestSet(t *testing.T) { ctx := context.Background() - db, err := redis.New(ctx, "TEST_", redisConf) + db, err := New(ctx, "TEST_", redisConf) if err != nil { t.Errorf("error creating redis: %v", err) } @@ -90,7 +90,7 @@ func TestSet(t *testing.T) { func TestSetTTL(t *testing.T) { ctx := context.Background() - db, err := redis.New(ctx, "TEST_", redisConf) + db, err := New(ctx, "TEST_", redisConf) if err != nil { t.Errorf("error creating redis: %v", err) } @@ -105,7 +105,7 @@ func TestSetTTL(t *testing.T) { func TestGet(t *testing.T) { ctx := context.Background() - db, err := redis.New(ctx, "TEST_", redisConf) + db, err := New(ctx, "TEST_", redisConf) if err != nil { t.Errorf("error creating redis: %v", err) } @@ -130,7 +130,7 @@ func TestGet(t *testing.T) { func TestGetTTL(t *testing.T) { ctx := context.Background() - db, err := redis.New(ctx, "TEST_", redisConf) + db, err := New(ctx, "TEST_", redisConf) if err != nil { t.Errorf("error creating redis: %v", err) } @@ -155,7 +155,7 @@ func TestGetTTL(t *testing.T) { func TestGetExpired(t *testing.T) { ctx := context.Background() - db, err := redis.New(ctx, "TEST_", redisConf) + db, err := New(ctx, "TEST_", redisConf) if err != nil { t.Errorf("error creating redis: %v", err) } @@ -171,7 +171,7 @@ func TestGetExpired(t *testing.T) { var value string err = db.Get("testkeygetexpired", &value) - if err != nil && err != redisog.Nil { + if err != nil && err != redis.Nil { t.Errorf("error getting value: %v", err) } } diff --git a/src/cache/utils.go b/src/cache/utils.go deleted file mode 100644 index b231e06b..00000000 --- a/src/cache/utils.go +++ /dev/null @@ -1,15 +0,0 @@ -package cache - -import "fmt" - -func combineIntoKey(s ...any) string { - var key string - for i, v := range s { - if i == 0 { - key = fmt.Sprintf("%v", v) - } else { - key = fmt.Sprintf("%v_%v", key, v) - } - } - return key -} diff --git a/src/cli/climode.go b/src/cli/climode.go deleted file mode 100644 index 2ee0952d..00000000 --- a/src/cli/climode.go +++ /dev/null @@ -1,98 +0,0 @@ -package cli - -import ( - "fmt" - "time" - - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/cache" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search" - "github.com/hearchco/hearchco/src/search/category" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/result" - "github.com/rs/zerolog/log" -) - -func printImageResult(r result.Result) { - fmt.Printf("%v (%.2f) -----\n\t%q\n\t%q\n\t%q\n\t%q\n\t%q\n\t%q\n\t-", r.Rank, r.Score, r.Title, r.URL, r.Description, r.ImageResult.Source, r.ImageResult.SourceURL, r.ImageResult.ThumbnailURL) - for seInd := range len(r.EngineRanks) { - fmt.Printf("%v", r.EngineRanks[seInd].SearchEngine.ToLower()) - if seInd != len(r.EngineRanks)-1 { - fmt.Print(", ") - } - } - fmt.Printf("\n") -} - -func printResult(r result.Result) { - fmt.Printf("%v (%.2f) -----\n\t%q\n\t%q\n\t%q\n\t-", r.Rank, r.Score, r.Title, r.URL, r.Description) - for seInd := range len(r.EngineRanks) { - fmt.Printf("%v", r.EngineRanks[seInd].SearchEngine.ToLower()) - if seInd != len(r.EngineRanks)-1 { - fmt.Print(", ") - } - } - fmt.Printf("\n") -} - -func printResults(results []result.Result) { - fmt.Print("\n\tThe Search Results:\n\n") - - images := false - if len(results) > 0 && results[0].ImageResult.Source != "" { - images = true - } - - for _, r := range results { - if images { - printImageResult(r) - } else { - printResult(r) - } - } -} - -func Run(flags Flags, db cache.DB, conf config.Config) { - log.Info(). - Str("queryAnon", anonymize.String(flags.Query)). - Str("queryHash", anonymize.HashToSHA256B64(flags.Query)). - Int("maxPages", flags.PagesMax). - Bool("visit", flags.Visit). - Msg("Started searching...") - - categoryName, err := category.FromString(flags.Category) - if err != nil { - log.Fatal(). - Caller(). - Err(err). - Msg("Invalid category") - } - - // all of these have default values set and are validated beforehand - options := engines.Options{ - VisitPages: flags.Visit, - SafeSearch: flags.SafeSearch, - Pages: engines.Pages{ - Start: flags.PagesStart, - Max: flags.PagesMax, - }, - Locale: flags.Locale, - Category: categoryName, - } - - start := time.Now() - - results, foundInDB := search.Search(flags.Query, options, db, conf.Categories[options.Category], conf.Settings, conf.Server.Proxy.Salt) - - if !flags.Silent { - printResults(results) - } - - log.Info(). - Int("number", len(results)). - Dur("duration", time.Since(start)). - Msg("Found results") - - search.CacheAndUpdateResults(flags.Query, options, db, conf.Server.Cache.TTL, conf.Categories[options.Category], conf.Settings, results, foundInDB, conf.Server.Proxy.Salt) -} diff --git a/src/cli/flags.go b/src/cli/flags.go new file mode 100644 index 00000000..9e43066c --- /dev/null +++ b/src/cli/flags.go @@ -0,0 +1,23 @@ +package cli + +type Flags struct { + Version versionFlag `name:"version" help:"Print version information and quit"` + Pretty bool `type:"bool" default:"false" env:"HEARCHCO_PRETTY" help:"Make logs pretty"` + Verbosity int8 `type:"counter" default:"0" short:"v" env:"HEARCHCO_VERBOSITY" help:"Log level verbosity"` + ConfigPath string `type:"path" default:"hearchco.yaml" env:"HEARCHCO_CONFIG_PATH" help:"Config file path"` + + Profiler +} + +type Profiler struct { + ProfilerServe bool `type:"bool" default:"false" help:"Run the profiler and serve at /debug/pprof/ http endpoint"` + ProfilerCPU bool `type:"bool" default:"false" help:"Use cpu profiling"` + ProfilerHeap bool `type:"bool" default:"false" help:"Use heap profiling"` + ProfilerGOR bool `type:"bool" default:"false" help:"Use goroutine profiling"` + ProfilerThread bool `type:"bool" default:"false" help:"Use threadcreate profiling"` + ProfilerAlloc bool `type:"bool" default:"false" help:"Use alloc profiling"` + ProfilerBlock bool `type:"bool" default:"false" help:"Use block profiling"` + ProfilerMutex bool `type:"bool" default:"false" help:"Use mutex profiling"` + ProfilerClock bool `type:"bool" default:"false" help:"Use clock profiling"` + ProfilerTrace bool `type:"bool" default:"false" help:"Use trace profiling"` +} diff --git a/src/cli/setup.go b/src/cli/setup.go index d8c4426e..c5dfee51 100644 --- a/src/cli/setup.go +++ b/src/cli/setup.go @@ -1,15 +1,18 @@ package cli import ( - "fmt" - "github.com/alecthomas/kong" - "github.com/hearchco/hearchco/src/gotypelimits" - "github.com/hearchco/hearchco/src/search/category" - "github.com/hearchco/hearchco/src/search/engines" "github.com/rs/zerolog/log" ) +var ( + // Release variables. + Version string + Timestamp string + GitCommit string +) + +// Returns flags struct from parsed cli arguments. func Setup() Flags { var cli Flags ctx := kong.Parse(&cli, @@ -21,9 +24,7 @@ func Setup() Flags { Compact: true, }), kong.Vars{ - "version": fmt.Sprintf("%v (%v@%v)", Version, GitCommit, Timestamp), - "data_folder": ".", - "query_string": "banana death", + "version": VersionString(), }, ) @@ -35,52 +36,5 @@ func Setup() Flags { // ^PANIC } - if cli.Query == "" { - log.Fatal(). - Caller(). - Msg("Query cannot be empty or whitespace") - // ^FATAL - } - - // TODO: make upper limit configurable - pagesMaxUpperLimit := 10 - if cli.PagesMax < 1 || cli.PagesMax > pagesMaxUpperLimit { - log.Fatal(). - Caller(). - Int("pages", cli.PagesMax). - Int("min", 1). - Int("max", pagesMaxUpperLimit). - Msg("Pages value out of range") - // ^FATAL - } - - if cli.PagesStart < 1 || cli.PagesStart > gotypelimits.MaxInt-pagesMaxUpperLimit { - log.Fatal(). - Caller(). - Int("start", cli.PagesStart). - Int("min", 1). - Int("max", gotypelimits.MaxInt-pagesMaxUpperLimit). - Msg("Start value out of range") - // ^FATAL - } else { - // since it's >=1, we decrement it to match the 0-based index - cli.PagesStart -= 1 - } - - if err := engines.ValidateLocale(cli.Locale); err != nil { - log.Fatal(). - Caller(). - Err(err). - Msg("Invalid locale flag") - // ^FATAL - } - - if _, err := category.FromString(cli.Category); err != nil { - log.Fatal(). - Caller(). - Msg("Invalid category flag") - // ^FATAL - } - return cli } diff --git a/src/cli/structs.go b/src/cli/structs.go deleted file mode 100644 index e8d899d1..00000000 --- a/src/cli/structs.go +++ /dev/null @@ -1,58 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/alecthomas/kong" -) - -type Flags struct { - Globals - - // flags - Query string `type:"string" default:"${query_string}" env:"HEARCHCO_QUERY" help:"Query string used for search"` - Cli bool `type:"bool" default:"false" env:"HEARCHCO_CLI" help:"Use CLI mode"` - Pretty bool `type:"bool" default:"false" env:"HEARCHCO_PRETTY" help:"Make logs pretty"` - Silent bool `type:"bool" default:"false" short:"s" env:"HEARCHCO_SILENT" help:"Should results be printed"` - DataDirPath string `type:"path" default:"${data_folder}" env:"HEARCHCO_DATA_DIR" help:"Data folder path"` - Verbosity int8 `type:"counter" default:"0" short:"v" env:"HEARCHCO_VERBOSITY" help:"Log level verbosity"` - // options - Visit bool `type:"bool" default:"false" env:"HEARCHCO_VISIT" help:"Should results be visited"` - SafeSearch bool `type:"bool" default:"false" env:"HEARCHCO_SAFE_SEARCH" help:"Whether to use safe search"` - PagesStart int `type:"counter" default:"1" env:"HEARCHCO_PAGES_START" help:"Page from which to start searching (>=1)"` - PagesMax int `type:"counter" default:"1" env:"HEARCHCO_PAGES_MAX" help:"Number of pages to search (>=1)"` - Locale string `type:"string" default:"en_US" env:"HEARCHCO_LOCALE" help:"Locale string specifying result language and region preference. The format is en_US"` - Category string `type:"string" default:"general" short:"c" env:"HEARCHCO_CATEGORY" help:"Search result category. Can also be supplied through the query (e.g. \"!images smartphone\")."` - // profiler - CPUProfile bool `type:"bool" default:"false" env:"HEARCHCO_CPUPROFILE" help:"Use cpu profiling"` - HeapProfile bool `type:"bool" default:"false" env:"HEARCHCO_HEAPPROFILE" help:"Use heap profiling"` - GORProfile bool `type:"bool" default:"false" env:"HEARCHCO_GORPROFILE" help:"Use goroutine profiling"` - ThreadProfile bool `type:"bool" default:"false" env:"HEARCHCO_THREADPROFILE" help:"Use threadcreate profiling"` - AllocProfile bool `type:"bool" default:"false" env:"HEARCHCO_MEMALLOCPROFILE" help:"Use alloc profiling"` - BlockProfile bool `type:"bool" default:"false" env:"HEARCHCO_BLOCKPROFILE" help:"Use block profiling"` - MutexProfile bool `type:"bool" default:"false" env:"HEARCHCO_MUTEXPROFILE" help:"Use mutex profiling"` - ClockProfile bool `type:"bool" default:"false" env:"HEARCHCO_CLOCKPROFILE" help:"Use clock profiling"` - TraceProfile bool `type:"bool" default:"false" env:"HEARCHCO_TRACEPROFILE" help:"Use trace profiling"` - ServeProfiler bool `type:"bool" default:"false" env:"HEARCHCO_SERVEPROFILER" help:"Run the profiler and serve at /debug/pprof/ http endpoint"` -} - -var ( - // release variables - Version string - Timestamp string - GitCommit string -) - -type Globals struct { - Version versionFlag `name:"version" help:"Print version information and quit"` -} - -type versionFlag string - -func (v versionFlag) Decode(ctx *kong.DecodeContext) error { return nil } -func (v versionFlag) IsBool() bool { return true } -func (v versionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error { - fmt.Println(vars["version"]) - app.Exit(0) - return nil -} diff --git a/src/cli/version.go b/src/cli/version.go new file mode 100644 index 00000000..42ad7557 --- /dev/null +++ b/src/cli/version.go @@ -0,0 +1,25 @@ +package cli + +import ( + "fmt" + + "github.com/alecthomas/kong" +) + +type versionFlag string + +func (v versionFlag) Decode(ctx *kong.DecodeContext) error { return nil } +func (v versionFlag) IsBool() bool { return true } +func (v versionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error { + fmt.Println(vars["version"]) + app.Exit(0) + return nil +} + +func VersionString() string { + if Version == "" { + return "dev" + } else { + return fmt.Sprintf("%v (%v@%v)", Version, GitCommit, Timestamp) + } +} diff --git a/src/config/defaults.go b/src/config/defaults.go index d4790f4e..85d42731 100644 --- a/src/config/defaults.go +++ b/src/config/defaults.go @@ -3,139 +3,10 @@ package config import ( "time" - "github.com/hearchco/hearchco/src/moretime" - "github.com/hearchco/hearchco/src/search/category" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/rs/zerolog/log" + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/utils/moretime" ) -const DefaultLocale string = "en_US" - -func EmptyRanking() CategoryRanking { - rnk := CategoryRanking{ - REXP: 0.5, - A: 1, - B: 0, - C: 1, - D: 0, - TRA: 1, - TRB: 0, - TRC: 1, - TRD: 0, - Engines: map[string]CategoryEngineRanking{}, - } - - for _, eng := range engines.Names() { - rnk.Engines[eng.ToLower()] = CategoryEngineRanking{ - Mul: 1, - Const: 0, - } - } - - return rnk -} - -func NewRanking() CategoryRanking { - return EmptyRanking() -} - -func NewSettings() map[engines.Name]Settings { - mp := map[engines.Name]Settings{ - engines.BING: { - Shortcut: "bi", - }, - engines.BINGIMAGES: { - Shortcut: "biimg", - }, - engines.BRAVE: { - Shortcut: "br", - }, - engines.DUCKDUCKGO: { - Shortcut: "ddg", - }, - engines.ETOOLS: { - Shortcut: "ets", - }, - engines.GOOGLE: { - Shortcut: "g", - }, - engines.GOOGLEIMAGES: { - Shortcut: "gimg", - }, - engines.GOOGLESCHOLAR: { - Shortcut: "gs", - }, - engines.MOJEEK: { - Shortcut: "mjk", - }, - engines.PRESEARCH: { - Shortcut: "ps", - }, - engines.QWANT: { - Shortcut: "qw", - }, - engines.STARTPAGE: { - Shortcut: "sp", - }, - engines.SWISSCOWS: { - Shortcut: "sc", - }, - engines.YAHOO: { - Shortcut: "yh", - }, - engines.YEP: { - Shortcut: "yep", - }, - } - - // Check if all search engines have a shortcut set - for _, eng := range engines.Names() { - if _, ok := mp[eng]; !ok { - log.Fatal(). - Caller(). - Str("engine", eng.String()). - Msg("No shortcut set") - // ^FATAL - } - } - - return mp -} - -func NewAllEnabled() []engines.Name { - return engines.Names() -} - -func NewGeneral() []engines.Name { - return []engines.Name{ - engines.BING, - engines.BRAVE, - engines.DUCKDUCKGO, - engines.ETOOLS, - engines.GOOGLE, - engines.MOJEEK, - engines.PRESEARCH, - engines.QWANT, - engines.STARTPAGE, - engines.SWISSCOWS, - engines.YAHOO, - engines.YEP, - } -} - -func NewImage() []engines.Name { - return []engines.Name{ - engines.BINGIMAGES, - engines.GOOGLEIMAGES, - } -} - -func NewScience() []engines.Name { - return []engines.Name{ - engines.GOOGLESCHOLAR, - } -} - func New() Config { return Config{ Server: Server{ @@ -146,18 +17,14 @@ func New() Config { Type: "none", KeyPrefix: "HEARCHCO_", TTL: TTL{ - Time: moretime.Week, - RefreshTime: 3 * moretime.Day, - }, - Badger: Badger{ - Persist: true, + Time: moretime.Week, }, Redis: Redis{ Host: "localhost", Port: 6379, }, }, - Proxy: ImageProxy{ + ImageProxy: ImageProxy{ Timeouts: ImageProxyTimeouts{ Dial: 3 * time.Second, KeepAlive: 3 * time.Second, @@ -165,55 +32,42 @@ func New() Config { }, }, }, - Settings: NewSettings(), Categories: map[category.Name]Category{ category.GENERAL: { - Engines: NewGeneral(), - Ranking: NewRanking(), - Timings: CategoryTimings{ - PreferredTimeoutMin: 500 * time.Millisecond, - PreferredTimeoutMax: 1200 * time.Millisecond, - PreferredResultsNumber: 20, - StepTime: 50 * time.Millisecond, - MinimumResultsNumber: 11, - HardTimeout: 3 * time.Second, - }, + Engines: generalEngines, + RequiredEngines: generalRequiredEngines, + RequiredByOriginEngines: generalRequiredByOriginEngines, + PreferredEngines: generalPreferredEngines, + PreferredByOriginEngines: generalPreferredByOriginEngines, + Ranking: generalRanking(), + Timings: generalTimings, }, category.IMAGES: { - Engines: NewImage(), - Ranking: NewRanking(), - Timings: CategoryTimings{ - PreferredTimeoutMin: 500 * time.Millisecond, - PreferredTimeoutMax: 1200 * time.Millisecond, - PreferredResultsNumber: 40, - StepTime: 50 * time.Millisecond, - MinimumResultsNumber: 20, - HardTimeout: 3 * time.Second, - }, + Engines: imagesEngines, + RequiredEngines: imagesRequiredEngines, + RequiredByOriginEngines: imagesRequiredByOriginEngines, + PreferredEngines: imagesPreferredEngines, + PreferredByOriginEngines: imagesPreferredByOriginEngines, + Ranking: imagesRanking(), + Timings: imagesTimings, }, category.SCIENCE: { - Engines: NewScience(), - Ranking: NewRanking(), - Timings: CategoryTimings{ - PreferredTimeoutMin: 1 * time.Second, - PreferredTimeoutMax: 2 * time.Second, - PreferredResultsNumber: 10, - StepTime: 100 * time.Millisecond, - MinimumResultsNumber: 5, - HardTimeout: 3 * time.Second, - }, + Engines: scienceEngines, + RequiredEngines: scienceRequiredEngines, + RequiredByOriginEngines: scienceRequiredByOriginEngines, + PreferredEngines: sciencePreferredEngines, + PreferredByOriginEngines: sciencePreferredByOriginEngines, + Ranking: scienceRanking(), + Timings: scienceTimings, }, category.THOROUGH: { - Engines: NewGeneral(), - Ranking: NewRanking(), - Timings: CategoryTimings{ - PreferredTimeoutMin: 1 * time.Second, - PreferredTimeoutMax: 2 * time.Second, - PreferredResultsNumber: 50, - StepTime: 100 * time.Millisecond, - MinimumResultsNumber: 30, - HardTimeout: 3 * time.Second, - }, + Engines: thoroughEngines, + RequiredEngines: thoroughRequiredEngines, + RequiredByOriginEngines: thoroughRequiredByOriginEngines, + PreferredEngines: thoroughPreferredEngines, + PreferredByOriginEngines: thoroughPreferredByOriginEngines, + Ranking: thoroughRanking(), + Timings: thoroughTimings, }, }, } diff --git a/src/config/defaults_cat_general.go b/src/config/defaults_cat_general.go new file mode 100644 index 00000000..f4d02ce9 --- /dev/null +++ b/src/config/defaults_cat_general.go @@ -0,0 +1,48 @@ +package config + +import ( + "time" + + "github.com/hearchco/agent/src/search/engines" +) + +var generalEngines = []engines.Name{ + engines.BING, + engines.BRAVE, + engines.DUCKDUCKGO, + engines.ETOOLS, + engines.GOOGLE, + engines.MOJEEK, + engines.PRESEARCH, + engines.QWANT, + engines.STARTPAGE, + engines.SWISSCOWS, + engines.YAHOO, + // engines.YEP, +} + +var generalRequiredEngines = []engines.Name{} + +var generalRequiredByOriginEngines = []engines.Name{ + engines.BING, + engines.GOOGLE, +} + +var generalPreferredEngines = []engines.Name{ + engines.ETOOLS, +} + +var generalPreferredByOriginEngines = []engines.Name{ + engines.BRAVE, + engines.MOJEEK, + // engines.YEP, +} + +func generalRanking() CategoryRanking { + return EmptyRanking(generalEngines) +} + +var generalTimings = CategoryTimings{ + PreferredTimeout: 700 * time.Millisecond, + HardTimeout: 3 * time.Second, +} diff --git a/src/config/defaults_cat_images.go b/src/config/defaults_cat_images.go new file mode 100644 index 00000000..76af5b34 --- /dev/null +++ b/src/config/defaults_cat_images.go @@ -0,0 +1,32 @@ +package config + +import ( + "time" + + "github.com/hearchco/agent/src/search/engines" +) + +var imagesEngines = []engines.Name{ + engines.BINGIMAGES, + engines.GOOGLEIMAGES, +} + +var imagesRequiredEngines = []engines.Name{} + +var imagesRequiredByOriginEngines = []engines.Name{ + engines.BINGIMAGES, + engines.GOOGLEIMAGES, +} + +var imagesPreferredEngines = []engines.Name{} + +var imagesPreferredByOriginEngines = []engines.Name{} + +func imagesRanking() CategoryRanking { + return EmptyRanking(imagesEngines) +} + +var imagesTimings = CategoryTimings{ + PreferredTimeout: 700 * time.Millisecond, + HardTimeout: 3 * time.Second, +} diff --git a/src/config/defaults_cat_science.go b/src/config/defaults_cat_science.go new file mode 100644 index 00000000..edadce1a --- /dev/null +++ b/src/config/defaults_cat_science.go @@ -0,0 +1,30 @@ +package config + +import ( + "time" + + "github.com/hearchco/agent/src/search/engines" +) + +var scienceEngines = []engines.Name{ + engines.GOOGLESCHOLAR, +} + +var scienceRequiredEngines = []engines.Name{} + +var scienceRequiredByOriginEngines = []engines.Name{ + engines.GOOGLESCHOLAR, +} + +var sciencePreferredEngines = []engines.Name{} + +var sciencePreferredByOriginEngines = []engines.Name{} + +func scienceRanking() CategoryRanking { + return EmptyRanking(scienceEngines) +} + +var scienceTimings = CategoryTimings{ + PreferredTimeout: 700 * time.Millisecond, + HardTimeout: 3 * time.Second, +} diff --git a/src/config/defaults_cat_thorough.go b/src/config/defaults_cat_thorough.go new file mode 100644 index 00000000..17bcc77a --- /dev/null +++ b/src/config/defaults_cat_thorough.go @@ -0,0 +1,52 @@ +package config + +import ( + "time" + + "github.com/hearchco/agent/src/search/engines" +) + +var thoroughEngines = []engines.Name{ + engines.BING, + engines.BRAVE, + engines.DUCKDUCKGO, + engines.ETOOLS, + engines.GOOGLE, + engines.MOJEEK, + engines.PRESEARCH, + engines.QWANT, + engines.STARTPAGE, + engines.SWISSCOWS, + engines.YAHOO, + // engines.YEP, +} + +var thoroughRequiredEngines = []engines.Name{ + engines.BING, + engines.BRAVE, + engines.DUCKDUCKGO, + engines.ETOOLS, + engines.GOOGLE, + engines.MOJEEK, + engines.PRESEARCH, + engines.QWANT, + engines.STARTPAGE, + engines.SWISSCOWS, + engines.YAHOO, + // engines.YEP, +} + +var thoroughRequiredByOriginEngines = []engines.Name{} + +var thoroughPreferredEngines = []engines.Name{} + +var thoroughPreferredByOriginEngines = []engines.Name{} + +func thoroughRanking() CategoryRanking { + return EmptyRanking(thoroughEngines) +} + +var thoroughTimings = CategoryTimings{ + PreferredTimeout: 3 * time.Second, + HardTimeout: 5 * time.Second, +} diff --git a/src/config/defaults_ranking.go b/src/config/defaults_ranking.go new file mode 100644 index 00000000..d40d2437 --- /dev/null +++ b/src/config/defaults_ranking.go @@ -0,0 +1,29 @@ +package config + +import ( + "github.com/hearchco/agent/src/search/engines" +) + +func EmptyRanking(engs []engines.Name) CategoryRanking { + rnk := CategoryRanking{ + REXP: 0.5, + A: 1, + B: 0, + C: 1, + D: 0, + TRA: 1, + TRB: 0, + TRC: 1, + TRD: 0, + Engines: map[string]CategoryEngineRanking{}, + } + + for _, eng := range engs { + rnk.Engines[eng.ToLower()] = CategoryEngineRanking{ + Mul: 1, + Const: 0, + } + } + + return rnk +} diff --git a/src/config/load.go b/src/config/load.go index 7993115c..cfb9ec9b 100644 --- a/src/config/load.go +++ b/src/config/load.go @@ -2,108 +2,77 @@ package config import ( "os" - "path" + "slices" "strings" - "github.com/hearchco/hearchco/src/moretime" - "github.com/hearchco/hearchco/src/search/category" - "github.com/hearchco/hearchco/src/search/engines" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/structs" "github.com/knadh/koanf/v2" "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/utils/moretime" ) -// passed as pointer since config is modified -func (c *Config) fromReader(rc ReaderConfig) { - if rc.Server.Proxy.Salt == "" { - log.Fatal(). +func (c *Config) Load(configPath string) { + rc := c.getReader() + + // Use "." as the key path delimiter. This can be "/" or any character. + k := koanf.New(".") + + // Load default values using the structs provider. + if err := k.Load(structs.Provider(&rc, "koanf"), nil); err != nil { + log.Panic(). Caller(). - Msg("Image proxy salt is empty") + Err(err). + Msg("Failed loading default values") + // ^PANIC } - nc := Config{ - Server: Server{ - Environment: rc.Server.Environment, - Port: rc.Server.Port, - FrontendUrls: strings.Split(rc.Server.FrontendUrls, ","), - Cache: Cache{ - Type: rc.Server.Cache.Type, - TTL: TTL{ - Time: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.Time), - RefreshTime: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.RefreshTime), - }, - Badger: rc.Server.Cache.Badger, - Redis: rc.Server.Cache.Redis, - }, - Proxy: ImageProxy{ - Salt: rc.Server.Proxy.Salt, - Timeouts: ImageProxyTimeouts{ - Dial: moretime.ConvertFromFancyTime(rc.Server.Proxy.Timeouts.Dial), - KeepAlive: moretime.ConvertFromFancyTime(rc.Server.Proxy.Timeouts.KeepAlive), - TLSHandshake: moretime.ConvertFromFancyTime(rc.Server.Proxy.Timeouts.TLSHandshake), - }, - }, - }, - Settings: map[engines.Name]Settings{}, - Categories: map[category.Name]Category{}, + // Load YAML config. + if _, err := os.Stat(configPath); err != nil { + log.Warn(). + Caller(). + Str("path", configPath). + Msg("No config found on path") + } else if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { + log.Panic(). + Caller(). + Err(err). + Msg("Error loading config") + // ^PANIC } - for key, val := range rc.Settings { - keyName, err := engines.NameString(key) - if err != nil { - log.Panic(). - Caller(). - Err(err). - Str("engine", key). - Msg("Invalid engine name") - // ^PANIC - } - nc.Settings[keyName] = val + // Load ENV config. + if err := k.Load(env.Provider("HEARCHCO_", ".", func(s string) string { + return strings.Replace(strings.ToLower(strings.TrimPrefix(s, "HEARCHCO_")), "_", ".", -1) + }), nil); err != nil { + log.Panic(). + Caller(). + Err(err). + Msg("Error loading env config") + // ^PANIC } - for key, val := range rc.RCategories { - engArr := []engines.Name{} - for name, eng := range val.REngines { - if eng.Enabled { - engineName, nameErr := engines.NameString(name) - if nameErr != nil { - log.Panic(). - Caller(). - Err(nameErr). - Msg("Failed converting string to engine name") - // ^PANIC - } - - engArr = append(engArr, engineName) - } - } - tim := CategoryTimings{ - PreferredTimeoutMin: moretime.ConvertFromFancyTime(val.RTimings.PreferredTimeoutMin), - PreferredTimeoutMax: moretime.ConvertFromFancyTime(val.RTimings.PreferredTimeoutMax), - PreferredResultsNumber: val.RTimings.PreferredResultsNumber, - StepTime: moretime.ConvertFromFancyTime(val.RTimings.StepTime), - MinimumResultsNumber: val.RTimings.MinimumResultsNumber, - HardTimeout: moretime.ConvertFromFancyTime(val.RTimings.HardTimeout), - Delay: moretime.ConvertFromFancyTime(val.RTimings.Delay), - RandomDelay: moretime.ConvertFromFancyTime(val.RTimings.RandomDelay), - Parallelism: val.RTimings.Parallelism, - } - nc.Categories[key] = Category{ - Ranking: val.Ranking, - Engines: engArr, - Timings: tim, - } + // Unmarshal config into struct. + if err := k.Unmarshal("", &rc); err != nil { + log.Panic(). + Caller(). + Err(err). + Msg("Failed unmarshaling koanf config") + // ^PANIC } - *c = nc + c.fromReader(rc) } -// called when loading default config, before merging with yaml and env +// Called when loading default config, before merging with YAML and ENV. func (c Config) getReader() ReaderConfig { rc := ReaderConfig{ + // Server config. Server: ReaderServer{ Environment: c.Server.Environment, Port: c.Server.Port, @@ -111,119 +80,147 @@ func (c Config) getReader() ReaderConfig { Cache: ReaderCache{ Type: c.Server.Cache.Type, TTL: ReaderTTL{ - Time: moretime.ConvertToFancyTime(c.Server.Cache.TTL.Time), - RefreshTime: moretime.ConvertToFancyTime(c.Server.Cache.TTL.RefreshTime), + Time: moretime.ConvertToFancyTime(c.Server.Cache.TTL.Time), }, - Badger: c.Server.Cache.Badger, - Redis: c.Server.Cache.Redis, + Redis: c.Server.Cache.Redis, }, - Proxy: ReaderImageProxy{ - Salt: c.Server.Proxy.Salt, + ImageProxy: ReaderImageProxy{ + Salt: c.Server.ImageProxy.Salt, Timeouts: ReaderImageProxyTimeouts{ - Dial: moretime.ConvertToFancyTime(c.Server.Proxy.Timeouts.Dial), - KeepAlive: moretime.ConvertToFancyTime(c.Server.Proxy.Timeouts.KeepAlive), - TLSHandshake: moretime.ConvertToFancyTime(c.Server.Proxy.Timeouts.TLSHandshake), + Dial: moretime.ConvertToFancyTime(c.Server.ImageProxy.Timeouts.Dial), + KeepAlive: moretime.ConvertToFancyTime(c.Server.ImageProxy.Timeouts.KeepAlive), + TLSHandshake: moretime.ConvertToFancyTime(c.Server.ImageProxy.Timeouts.TLSHandshake), }, }, }, + // Initialize the categories map. RCategories: map[category.Name]ReaderCategory{}, - Settings: map[string]Settings{}, } - for key, val := range c.Categories { - tim := ReaderCategoryTimings{ - PreferredTimeoutMin: moretime.ConvertToFancyTime(val.Timings.PreferredTimeoutMin), - PreferredTimeoutMax: moretime.ConvertToFancyTime(val.Timings.PreferredTimeoutMax), - PreferredResultsNumber: val.Timings.PreferredResultsNumber, - StepTime: moretime.ConvertToFancyTime(val.Timings.StepTime), - MinimumResultsNumber: val.Timings.MinimumResultsNumber, - HardTimeout: moretime.ConvertToFancyTime(val.Timings.HardTimeout), - Delay: moretime.ConvertToFancyTime(val.Timings.Delay), - RandomDelay: moretime.ConvertToFancyTime(val.Timings.RandomDelay), - Parallelism: val.Timings.Parallelism, + // Set the categories map config. + for catName, catConf := range c.Categories { + // Timings config. + timingsConf := ReaderCategoryTimings{ + PreferredTimeout: moretime.ConvertToFancyTime(catConf.Timings.PreferredTimeout), + HardTimeout: moretime.ConvertToFancyTime(catConf.Timings.HardTimeout), + Delay: moretime.ConvertToFancyTime(catConf.Timings.Delay), + RandomDelay: moretime.ConvertToFancyTime(catConf.Timings.RandomDelay), + Parallelism: catConf.Timings.Parallelism, } - rc.RCategories[key] = ReaderCategory{ - Ranking: val.Ranking, + + // Set the category config. + rc.RCategories[catName] = ReaderCategory{ + // Initialize the engines map. REngines: map[string]ReaderCategoryEngine{}, - RTimings: tim, - } - for _, eng := range val.Engines { - rc.RCategories[key].REngines[eng.ToLower()] = ReaderCategoryEngine{Enabled: true} + Ranking: catConf.Ranking, + RTimings: timingsConf, } - } - for key, val := range c.Settings { - rc.Settings[key.ToLower()] = val + // Set the engines map config. + for _, eng := range catConf.Engines { + rc.RCategories[catName].REngines[eng.ToLower()] = ReaderCategoryEngine{ + Enabled: true, + Required: slices.Contains(catConf.RequiredEngines, eng), + RequiredByOrigin: slices.Contains(catConf.RequiredByOriginEngines, eng), + Preferred: slices.Contains(catConf.PreferredEngines, eng), + PreferredByOrigin: slices.Contains(catConf.PreferredByOriginEngines, eng), + } + } } return rc } // passed as pointer since config is modified -func (c *Config) Load(dataDirPath string) { - rc := c.getReader() - - // Use "." as the key path delimiter. This can be "/" or any character. - k := koanf.New(".") - - // Load default values using the structs provider. - // We provide a struct along with the struct tag `koanf` to the - // provider. - if err := k.Load(structs.Provider(&rc, "koanf"), nil); err != nil { - log.Panic(). +func (c *Config) fromReader(rc ReaderConfig) { + if rc.Server.ImageProxy.Salt == "" { + log.Fatal(). Caller(). - Err(err). - Msg("Failed loading default values") - // ^PANIC + Msg("Image proxy salt is empty") } - // Load YAML config - yamlPath := path.Join(dataDirPath, "hearchco.yaml") - if _, err := os.Stat(yamlPath); err != nil { - log.Trace(). - Caller(). - Str("path", yamlPath). - Msg("No yaml (.yaml) config found, looking for .yml") - yamlPath = path.Join(dataDirPath, "hearchco.yml") - if _, errr := os.Stat(yamlPath); errr != nil { - log.Trace(). - Caller(). - Str("path", yamlPath). - Msg("No yaml (.yml) config found") - } else if errr := k.Load(file.Provider(yamlPath), yaml.Parser()); errr != nil { - log.Panic(). - Caller(). - Err(err). - Msg("Error loading yaml config") - // ^PANIC - } - } else if err := k.Load(file.Provider(yamlPath), yaml.Parser()); err != nil { - log.Panic(). - Caller(). - Err(err). - Msg("Error loading yaml config") - // ^PANIC + nc := Config{ + // Server config. + Server: Server{ + Environment: rc.Server.Environment, + Port: rc.Server.Port, + FrontendUrls: strings.Split(rc.Server.FrontendUrls, ","), + Cache: Cache{ + Type: rc.Server.Cache.Type, + TTL: TTL{ + Time: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.Time), + }, + Redis: rc.Server.Cache.Redis, + }, + ImageProxy: ImageProxy{ + Salt: rc.Server.ImageProxy.Salt, + Timeouts: ImageProxyTimeouts{ + Dial: moretime.ConvertFromFancyTime(rc.Server.ImageProxy.Timeouts.Dial), + KeepAlive: moretime.ConvertFromFancyTime(rc.Server.ImageProxy.Timeouts.KeepAlive), + TLSHandshake: moretime.ConvertFromFancyTime(rc.Server.ImageProxy.Timeouts.TLSHandshake), + }, + }, + }, + // Initialize the categories map. + Categories: map[category.Name]Category{}, } - // Load ENV config - if err := k.Load(env.Provider("HEARCHCO_", ".", func(s string) string { - return strings.Replace(strings.ToLower(strings.TrimPrefix(s, "HEARCHCO_")), "_", ".", -1) - }), nil); err != nil { - log.Panic(). - Caller(). - Err(err). - Msg("Error loading env config") - // ^PANIC - } + // Set the categories map config. + for catName, catRConf := range rc.RCategories { + // Initialize the engines slices. + engEnabled := make([]engines.Name, 0) + engRequired := make([]engines.Name, 0) + engRequiredByOrigin := make([]engines.Name, 0) + engPreferred := make([]engines.Name, 0) + engPreferredByOrigin := make([]engines.Name, 0) + + // Set the engines slices according to the reader config. + for engS, engRConf := range catRConf.REngines { + engName, err := engines.NameString(engS) + if err != nil { + log.Panic(). + Caller(). + Err(err). + Msg("Failed converting string to engine name") + // ^PANIC + } - // Unmarshal config into struct - if err := k.Unmarshal("", &rc); err != nil { - log.Panic(). - Caller(). - Err(err). - Msg("Failed unmarshaling koanf config") - // ^PANIC + if engRConf.Enabled { + engEnabled = append(engEnabled, engName) + + if engRConf.Required { + engRequired = append(engRequired, engName) + } else if engRConf.RequiredByOrigin { + engRequiredByOrigin = append(engRequiredByOrigin, engName) + } else if engRConf.Preferred { + engPreferred = append(engPreferred, engName) + } else if engRConf.PreferredByOrigin { + engPreferredByOrigin = append(engPreferredByOrigin, engName) + } + } + } + + // Timings config. + timingsConf := CategoryTimings{ + PreferredTimeout: moretime.ConvertFromFancyTime(catRConf.RTimings.PreferredTimeout), + HardTimeout: moretime.ConvertFromFancyTime(catRConf.RTimings.HardTimeout), + Delay: moretime.ConvertFromFancyTime(catRConf.RTimings.Delay), + RandomDelay: moretime.ConvertFromFancyTime(catRConf.RTimings.RandomDelay), + Parallelism: catRConf.RTimings.Parallelism, + } + + // Set the category config. + nc.Categories[catName] = Category{ + Engines: engEnabled, + RequiredEngines: engRequired, + RequiredByOriginEngines: engRequiredByOrigin, + PreferredEngines: engPreferred, + PreferredByOriginEngines: engPreferredByOrigin, + Ranking: catRConf.Ranking, + Timings: timingsConf, + } } - c.fromReader(rc) + // Set the new config. + *c = nc } diff --git a/src/config/structs_category.go b/src/config/structs_category.go index f8912036..ba1c6d4b 100644 --- a/src/config/structs_category.go +++ b/src/config/structs_category.go @@ -3,24 +3,39 @@ package config import ( "time" - "github.com/hearchco/hearchco/src/search/engines" + "github.com/hearchco/agent/src/search/engines" ) -// ReaderCategory is format in which the config is read from the config file +// ReaderCategory is format in which the config is read from the config file and environment variables. type ReaderCategory struct { REngines map[string]ReaderCategoryEngine `koanf:"engines"` Ranking CategoryRanking `koanf:"ranking"` RTimings ReaderCategoryTimings `koanf:"timings"` } type Category struct { - Engines []engines.Name - Ranking CategoryRanking - Timings CategoryTimings + Engines []engines.Name + RequiredEngines []engines.Name + RequiredByOriginEngines []engines.Name + PreferredEngines []engines.Name + PreferredByOriginEngines []engines.Name + Ranking CategoryRanking + Timings CategoryTimings } -// ReaderEngine is format in which the config is read from the config file +// ReaderEngine is format in which the config is read from the config file and environment variables. type ReaderCategoryEngine struct { + // If false, the engine will not be used and other options will be ignored. Enabled bool `koanf:"enabled"` + // If true, the engine will be awaited unless the hard timeout is reached. + Required bool `koanf:"required"` + // If true, the fastest engine that has this engine in "Origins" will be awaited unless the hard timeout is reached. + // This means that we want to get results from this engine or any engine that has this engine in "Origins", whichever responds the fastest. + RequiredByOrigin bool `koanf:"requiredbyorigin"` + // If true, the engine will be awaited unless the preferred timeout is reached. + Preferred bool `koanf:"preferred"` + // If true, the fastest engine that has this engine in "Origins" will be awaited unless the preferred timeout is reached. + // This means that we want to get results from this engine or any engine that has this engine in "Origins", whichever responds the fastest. + PreferredByOrigin bool `koanf:"preferredbyorigin"` } type CategoryRanking struct { @@ -41,58 +56,36 @@ type CategoryEngineRanking struct { Const float64 `koanf:"const"` } -// ReaderTimings is format in which the config is read from the config file -// In format -// Example: 1s, 1m, 1h, 1d, 1w, 1M, 1y -// If unit is not specified, it is assumed to be milliseconds -// Delegates Timeout, PageTimeout to colly.Collector.SetRequestTimeout(); Note: See https://github.com/gocolly/colly/issues/644 -// Delegates Delay, RandomDelay, Parallelism to colly.Collector.Limit() +// ReaderTimings is format in which the config is read from the config file and environment variables. +// In format. +// Example: 1s, 1m, 1h, 1d, 1w, 1M, 1y. +// If unit is not specified, it is assumed to be milliseconds. +// Delegates Delay, RandomDelay, Parallelism to colly.Collector.Limit(). type ReaderCategoryTimings struct { - // Minimum amount of time to wait before starting to check the number of results - // Search will wait for at least this amount of time (unless all engines respond) - PreferredTimeoutMin string `koanf:"preferredtimeoutmin"` - // Maximum amount of time to wait until the number of results is satisfactory - // Search will wait for at most this amount of time (unless all engines respond or the preferred number of results is found) - PreferredTimeoutMax string `koanf:"preferredtimeoutmax"` - // Preferred number of results to find - PreferredResultsNumber int `koanf:"preferredresultsnumber"` - // Time of the steps for checking if the number of results is satisfactory - StepTime string `koanf:"steptime"` - // Minimum number of results required after the maximum preferred time - // If this number isn't met, the search will continue after the maximum preferred time - MinimumResultsNumber int `koanf:"minimumresultsnumber"` - // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond) + // Maximum amount of time to wait for the PreferredEngines (or ByOrigin) to respond. + // If the search is still waiting for the RequiredEngines (or ByOrigin) after this time, the search will continue. + PreferredTimeout string `koanf:"preferredtimeout"` + // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond). HardTimeout string `koanf:"hardtimeout"` - // Colly delay + // Colly delay. Delay string `koanf:"delay"` - // Colly random delay + // Colly random delay. RandomDelay string `koanf:"randomdelay"` - // Colly parallelism + // Colly parallelism. Parallelism int `koanf:"parallelism"` } -// Delegates Timeout, PageTimeout to colly.Collector.SetRequestTimeout(); Note: See https://github.com/gocolly/colly/issues/644 -// Delegates Delay, RandomDelay, Parallelism to colly.Collector.Limit() +// Delegates Delay, RandomDelay, Parallelism to colly.Collector.Limit(). type CategoryTimings struct { - // Minimum amount of time to wait before starting to check the number of results - // Search will wait for at least this amount of time (unless all engines respond) - PreferredTimeoutMin time.Duration - // Maximum amount of time to wait until the number of results is satisfactory - // Search will wait for at most this amount of time (unless all engines respond or the preferred number of results is found) - PreferredTimeoutMax time.Duration - // Preferred number of results to find - PreferredResultsNumber int - // Time of the steps for checking if the number of results is satisfactory - StepTime time.Duration - // Minimum number of results required after the maximum preferred time - // If this number isn't met, the search will continue after the maximum preferred time - MinimumResultsNumber int - // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond) + // Maximum amount of time to wait for the PreferredEngines (or ByOrigin) to respond. + // If the search is still waiting for the RequiredEngines (or ByOrigin) after this time, the search will continue. + PreferredTimeout time.Duration + // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond). HardTimeout time.Duration - // Colly delay + // Colly delay. Delay time.Duration - // Colly random delay + // Colly random delay. RandomDelay time.Duration - // Colly parallelism + // Colly parallelism. Parallelism int } diff --git a/src/config/structs_config.go b/src/config/structs_config.go index e6205a1c..a7d69ac9 100644 --- a/src/config/structs_config.go +++ b/src/config/structs_config.go @@ -1,24 +1,15 @@ package config import ( - "github.com/hearchco/hearchco/src/search/category" - "github.com/hearchco/hearchco/src/search/engines" + "github.com/hearchco/agent/src/search/category" ) -type Settings struct { - RequestedResultsPerPage int `koanf:"requestedresults"` - Shortcut string `koanf:"shortcut"` - Proxies []string `koanf:"proxies"` -} - -// ReaderConfig is format in which the config is read from the config file +// ReaderConfig is format in which the config is read from the config file and environment variables. type ReaderConfig struct { Server ReaderServer `koanf:"server"` RCategories map[category.Name]ReaderCategory `koanf:"categories"` - Settings map[string]Settings `koanf:"settings"` } type Config struct { Server Server Categories map[category.Name]Category - Settings map[engines.Name]Settings } diff --git a/src/config/structs_server.go b/src/config/structs_server.go index edb85fa0..6385b577 100644 --- a/src/config/structs_server.go +++ b/src/config/structs_server.go @@ -1,86 +1,71 @@ package config -import "time" +import ( + "time" +) -// ReaderServer is format in which the config is read from the config file +// ReaderServer is format in which the config is read from the config file and environment variables. type ReaderServer struct { - // Environment in which the server is running (normal or lambda) + // Environment in which the server is running (normal or lambda). Environment string `koanf:"environment"` - // Port on which the API server listens + // Port on which the API server listens. Port int `koanf:"port"` - // URLs used for CORS (wildcards allowed) - // comma separated + // URLs used for CORS (wildcards allowed). + // Comma separated. FrontendUrls string `koanf:"frontendurls"` - // Cache settings + // Cache settings. Cache ReaderCache `koanf:"cache"` - // Image proxy settings - Proxy ReaderImageProxy `koanf:"proxy"` + // Image proxy settings. + ImageProxy ReaderImageProxy `koanf:"imageproxy"` } type Server struct { - // Environment in which the server is running (normal or lambda) + // Environment in which the server is running (normal or lambda). Environment string - // Port on which the API server listens + // Port on which the API server listens. Port int - // URLs used for CORS (wildcards allowed) + // URLs used for CORS (wildcards allowed). FrontendUrls []string - // Cache settings + // Cache settings. Cache Cache - // Image proxy settings - Proxy ImageProxy + // Image proxy settings. + ImageProxy ImageProxy } -// ReaderCache is format in which the config is read from the config file +// ReaderCache is format in which the config is read from the config file and environment variables. type ReaderCache struct { - // Can be "none", "badger" or "redis" + // Can be "none" or "redis". Type string `koanf:"type"` - // Prefix to use for cache keys + // Prefix to use for cache keys. KeyPrefix string `koanf:"keyprefix"` - // Has no effect if type is "none" + // Has no effect if type is "none". TTL ReaderTTL `koanf:"ttl"` - // Badger specific settings - Badger Badger `koanf:"badger"` - // Redis specific settings + // Redis specific settings. Redis Redis `koanf:"redis"` } type Cache struct { - // Can be "none", "badger" or "redis" + // Can be "none" or "redis". Type string - // Prefix to use for cache keys + // Prefix to use for cache keys. KeyPrefix string - // Has no effect if type is "none" + // Has no effect if type is "none". TTL TTL - // Badger specific settings - Badger Badger - // Redis specific settings + // Redis specific settings. Redis Redis } -// ReaderTTL is format in which the config is read from the config file -// In format -// Example: 1s, 1m, 1h, 1d, 1w, 1M, 1y -// If unit is not specified, it is assumed to be milliseconds +// ReaderTTL is format in which the config is read from the config file and environment variables. +// In format. +// Example: 1s, 1m, 1h, 1d, 1w, 1M, 1y. +// If unit is not specified, it is assumed to be milliseconds. type ReaderTTL struct { - // how long to store the results in cache - // setting this to 0 caches the results forever + // How long to store the results in cache. + // Setting this to 0 caches the results forever. Time string `koanf:"time"` - // if the remaining TTL when retrieving from cache is less than this, update the cache entry and reset the TTL - // setting this to 0 disables this feature - // setting this to the same value (or higher) as Results will update the cache entry every time - RefreshTime string `koanf:"refreshtime"` } type TTL struct { - // How long to store the results in cache - // Setting this to 0 caches the results forever + // How long to store the results in cache. + // Setting this to 0 caches the results forever. Time time.Duration - // If the remaining TTL when retrieving from cache is less than this, update the cache entry and reset the TTL - // Setting this to 0 disables this feature - // Setting this to the same value (or higher) as Results will update the cache entry every time - RefreshTime time.Duration -} - -type Badger struct { - // Setting this to false will result in badger not persisting the cache to disk making it run "in-memory" - Persist bool `koanf:"persist"` } type Redis struct { @@ -90,7 +75,7 @@ type Redis struct { Database uint8 `koanf:"database"` } -// ReaderProxy is format in which the config is read from the config file +// ReaderProxy is format in which the config is read from the config file and environment variables. type ReaderImageProxy struct { Salt string `koanf:"salt"` Timeouts ReaderImageProxyTimeouts `koanf:"timeouts"` @@ -100,10 +85,10 @@ type ImageProxy struct { Timeouts ImageProxyTimeouts } -// ReaderProxyTimeouts is format in which the config is read from the config file -// In format -// Example: 1s, 1m, 1h, 1d, 1w, 1M, 1y -// If unit is not specified, it is assumed to be milliseconds +// ReaderProxyTimeouts is format in which the config is read from the config file and environment variables. +// In format. +// Example: 1s, 1m, 1h, 1d, 1w, 1M, 1y. +// If unit is not specified, it is assumed to be milliseconds. type ReaderImageProxyTimeouts struct { Dial string `koanf:"dial"` KeepAlive string `koanf:"keepalive"` diff --git a/src/logger/logger.go b/src/logger/logger.go deleted file mode 100644 index 3f51fd08..00000000 --- a/src/logger/logger.go +++ /dev/null @@ -1,36 +0,0 @@ -package logger - -import ( - "os" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -func Setup(verbosity int8, pretty bool) zerolog.Logger { - // Setup logger - var lgr zerolog.Logger - if pretty { - lgr = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.Stamp}) - } else { - lgr = zerolog.New(os.Stderr).With().Timestamp().Logger() - } - - // Setup verbosity - switch { - // TRACE - case verbosity > 1: - lgr = lgr.With().Caller().Logger().Level(zerolog.TraceLevel) - // DEBUG - case verbosity == 1: - lgr = lgr.Level(zerolog.DebugLevel) - // INFO - default: - lgr = lgr.Level(zerolog.InfoLevel) - } - - // Set the logger to global and return it - log.Logger = lgr - return lgr -} diff --git a/src/logger/setup.go b/src/logger/setup.go new file mode 100644 index 00000000..c6d95400 --- /dev/null +++ b/src/logger/setup.go @@ -0,0 +1,34 @@ +package logger + +import ( + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func Setup(verbosity int8, pretty bool) zerolog.Logger { + // Setup logger. + var l zerolog.Logger + if pretty { + // This is much slower to print. + l = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.Stamp}) + } else { + l = zerolog.New(os.Stderr).With().Timestamp().Logger() + } + + // Setup verbosity. + switch { + case verbosity > 1: // TRACE + l = l.With().Caller().Logger().Level(zerolog.TraceLevel) + case verbosity == 1: // DEBUG + l = l.Level(zerolog.DebugLevel) + default: // INFO + l = l.Level(zerolog.InfoLevel) + } + + // Set the logger to be global. + log.Logger = l + return l +} diff --git a/src/main.go b/src/main.go index c63acf79..c21f0cd7 100644 --- a/src/main.go +++ b/src/main.go @@ -5,70 +5,57 @@ import ( "os" "os/signal" "syscall" - "time" - "github.com/hearchco/hearchco/src/cache" - "github.com/hearchco/hearchco/src/cli" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/logger" - "github.com/hearchco/hearchco/src/router" "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/cache" + "github.com/hearchco/agent/src/cli" + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/logger" + "github.com/hearchco/agent/src/profiler" + "github.com/hearchco/agent/src/router" ) func main() { - mainTimer := time.Now() - - // setup signal interrupt (CTRL+C) + // Setup signal interrupt (CTRL+C). ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) defer cancel() - // parse cli arguments + // Parse cli flags. cliFlags := cli.Setup() - // configure logger + // Configure logger. lgr := logger.Setup(cliFlags.Verbosity, cliFlags.Pretty) - // start profiler - _, stopProfiler := runProfiler(cliFlags) - defer stopProfiler() - - // load config file + // Load config file. conf := config.New() - conf.Load(cliFlags.DataDirPath) + conf.Load(cliFlags.ConfigPath) - // setup cache - db, err := cache.New(ctx, cliFlags.DataDirPath, conf.Server.Cache) + // Setup cache database. + db, err := cache.New(ctx, conf.Server.Cache) if err != nil { log.Fatal(). Caller(). Err(err). - Msg("Failed creating a new db") + Msg("Failed creating a new cache database") // ^FATAL } - // startup - if cliFlags.Cli { - cli.Run(cliFlags, db, conf) - } else { - rw := router.New(lgr, conf, db, cliFlags.ServeProfiler) - switch conf.Server.Environment { - case "lambda": - rw.StartLambda() - default: - rw.Start(ctx) - } + // Start profiler if enabled. + _, stopProfiler := profiler.Run(cliFlags) + defer stopProfiler() + + // Start router. + rw := router.New(lgr, conf, db, cliFlags.ProfilerServe, cli.VersionString()) + switch conf.Server.Environment { + case "lambda": + rw.StartLambda() + default: + rw.Start(ctx) } - // program cleanup + // Program cleanup. db.Close() - if cliFlags.Cli { - log.Debug(). - Dur("duration", time.Since(mainTimer)). - Msg("Program finished") - } else { - log.Info(). - Dur("duration", time.Since(mainTimer)). - Msg("Program finished") - } + log.Info().Msg("Program finished") } diff --git a/src/profiling.go b/src/profiler/run.go similarity index 77% rename from src/profiling.go rename to src/profiler/run.go index b1729502..c4226fcb 100644 --- a/src/profiling.go +++ b/src/profiler/run.go @@ -1,9 +1,10 @@ -package main +package profiler import ( - "github.com/hearchco/hearchco/src/cli" "github.com/pkg/profile" "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/cli" ) type profiler struct { @@ -11,7 +12,7 @@ type profiler struct { profile func(p *profile.Profile) } -func runProfiler(cliFlags cli.Flags) (bool, func()) { +func Run(cliFlags cli.Flags) (bool, func()) { /* goroutine — stack traces of all current goroutines heap — a sampling of memory allocations of live objects @@ -22,31 +23,31 @@ func runProfiler(cliFlags cli.Flags) (bool, func()) { */ profilers := [...]profiler{{ - enabled: cliFlags.CPUProfile, + enabled: cliFlags.ProfilerCPU, profile: profile.CPUProfile, }, { - enabled: cliFlags.HeapProfile, + enabled: cliFlags.ProfilerHeap, profile: profile.MemProfileHeap, }, { - enabled: cliFlags.GORProfile, + enabled: cliFlags.ProfilerGOR, profile: profile.GoroutineProfile, }, { - enabled: cliFlags.ThreadProfile, + enabled: cliFlags.ProfilerThread, profile: profile.ThreadcreationProfile, }, { - enabled: cliFlags.BlockProfile, + enabled: cliFlags.ProfilerBlock, profile: profile.BlockProfile, }, { - enabled: cliFlags.AllocProfile, + enabled: cliFlags.ProfilerAlloc, profile: profile.MemProfileAllocs, }, { - enabled: cliFlags.MutexProfile, + enabled: cliFlags.ProfilerMutex, profile: profile.MutexProfile, }, { - enabled: cliFlags.ClockProfile, + enabled: cliFlags.ProfilerClock, profile: profile.ClockProfile, }, { - enabled: cliFlags.TraceProfile, + enabled: cliFlags.ProfilerTrace, profile: profile.TraceProfile, }} diff --git a/src/router/compress.go b/src/router/compress.go deleted file mode 100644 index 299666c0..00000000 --- a/src/router/compress.go +++ /dev/null @@ -1,24 +0,0 @@ -package router - -import ( - "io" - "net/http" - - "github.com/andybalholm/brotli" - "github.com/go-chi/chi/v5/middleware" -) - -func compress(level int, types ...string) [](func(http.Handler) http.Handler) { - // deflate & gzip - dig := middleware.Compress(level, types...) - - // brotli - br := middleware.NewCompressor(level, types...) - br.SetEncoder("br", func(w io.Writer, level int) io.Writer { - return brotli.NewWriterOptions(w, brotli.WriterOptions{ - Quality: level, - }) - }) - - return [](func(http.Handler) http.Handler){dig, br.Handler} -} diff --git a/src/router/middlewares/compress.go b/src/router/middlewares/compress.go new file mode 100644 index 00000000..31db9e08 --- /dev/null +++ b/src/router/middlewares/compress.go @@ -0,0 +1,24 @@ +package middlewares + +import ( + "io" + "net/http" + + "github.com/andybalholm/brotli" + "github.com/go-chi/chi/v5/middleware" +) + +func compress(lvl int, types ...string) [](func(http.Handler) http.Handler) { + // Deflate & GZIP. + dig := middleware.Compress(lvl, types...) + + // Brotli. + br := middleware.NewCompressor(lvl, types...) + br.SetEncoder("br", func(w io.Writer, lvl int) io.Writer { + return brotli.NewWriterOptions(w, brotli.WriterOptions{ + Quality: lvl, + }) + }) + + return [](func(http.Handler) http.Handler){dig, br.Handler} +} diff --git a/src/router/logging.go b/src/router/middlewares/logging.go similarity index 79% rename from src/router/logging.go rename to src/router/middlewares/logging.go index 6f687a53..05243908 100644 --- a/src/router/logging.go +++ b/src/router/middlewares/logging.go @@ -1,4 +1,4 @@ -package router +package middlewares import ( "net/http" @@ -8,9 +8,9 @@ import ( "github.com/rs/zerolog/hlog" ) -func ignoredPath(path string, skipPaths []string) bool { - for _, p := range skipPaths { - if p == path { +func ignoredPath(p string, skipPaths []string) bool { + for _, sp := range skipPaths { + if sp == p { return true } } @@ -20,15 +20,12 @@ func ignoredPath(path string, skipPaths []string) bool { func zerologMiddleware(lgr zerolog.Logger, skipPaths []string) [](func(http.Handler) http.Handler) { newHandler := hlog.NewHandler(lgr) fieldsHandler := hlog.AccessHandler(func(r *http.Request, status int, size int, duration time.Duration) { - // skip logging for ignored paths + // Skip logging for ignored paths. if ignoredPath(r.URL.Path, skipPaths) { return } - // get logger from context lgr := hlog.FromRequest(r) - - // decide on log level event := lgr.Info() if status >= 500 { event = lgr.Error() @@ -36,7 +33,6 @@ func zerologMiddleware(lgr zerolog.Logger, skipPaths []string) [](func(http.Hand event = lgr.Warn() } - // log event. Str("method", r.Method). Str("path", r.URL.Path). diff --git a/src/router/middlewares.go b/src/router/middlewares/setup.go similarity index 68% rename from src/router/middlewares.go rename to src/router/middlewares/setup.go index 81137a0a..359765b8 100644 --- a/src/router/middlewares.go +++ b/src/router/middlewares/setup.go @@ -1,4 +1,4 @@ -package router +package middlewares import ( "net/http" @@ -11,19 +11,19 @@ import ( "github.com/rs/zerolog/log" ) -func setupMiddlewares(mux *chi.Mux, lgr zerolog.Logger, frontendUrls []string, serveProfiler bool) { - // use custom zerolog middleware - // TODO: make skipped paths configurable - skipPaths := []string{"/healthz"} +func Setup(mux *chi.Mux, lgr zerolog.Logger, frontendUrls []string, serveProfiler bool) { + // Use custom zerolog middleware. + // TODO: Make skipped paths configurable. + skipPaths := []string{"/healthz", "/versionz"} mux.Use(zerologMiddleware(lgr, skipPaths)...) - // use recovery middleware + // Use recovery middleware. mux.Use(middleware.Recoverer) - // use compression middleware + // Use compression middleware. mux.Use(compress(5)...) - // use CORS middleware + // Use CORS middleware. mux.Use(cors.Handler(cors.Options{ AllowedOrigins: frontendUrls, AllowedMethods: []string{"GET", "POST", "OPTIONS"}, @@ -43,12 +43,12 @@ func setupMiddlewares(mux *chi.Mux, lgr zerolog.Logger, frontendUrls []string, s Strs("url", frontendUrls). Msg("Using CORS") - // use strip slashes middleware except for pprof + // Use strip slashes middleware, except for pprof. mux.Use(middleware.Maybe(middleware.StripSlashes, func(r *http.Request) bool { return !strings.HasPrefix(r.URL.Path, "/debug") })) - // use pprof router if enabled + // Use pprof router if profiling is enabled. if serveProfiler { mux.Mount("/debug", middleware.Profiler()) } diff --git a/src/router/proxy.go b/src/router/proxy.go deleted file mode 100644 index 428b46f9..00000000 --- a/src/router/proxy.go +++ /dev/null @@ -1,110 +0,0 @@ -package router - -import ( - "fmt" - "net" - "net/http" - "net/http/httputil" - "net/url" - - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/useragent" - "github.com/rs/zerolog/log" -) - -func Proxy(w http.ResponseWriter, r *http.Request, salt string, timeouts config.ImageProxyTimeouts) error { - err := r.ParseForm() - if err != nil { - // server error - werr := writeResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse form: %v", err)) - if werr != nil { - return fmt.Errorf("%w: %w", werr, err) - } - return err - } - - params := r.Form - - urlParam := getParamOrDefault(params, "url") - hashParam := getParamOrDefault(params, "hash") - - if urlParam == "" || hashParam == "" { - // user error - return writeResponse(w, http.StatusBadRequest, "url and hash are required") - } - - // check if hash is valid - if !anonymize.CheckHash(hashParam, urlParam, salt) { - // user error - log.Debug(). - Str("url", urlParam). - Str("hash", hashParam). - Msg("Invalid hash") - return writeResponse(w, http.StatusUnauthorized, "invalid hash") - } - - // parse the url - target, err := url.Parse(urlParam) - if err != nil { - // user error - log.Debug(). - Str("url", urlParam). - Msg("Invalid url") - return writeResponse(w, http.StatusBadRequest, "invalid url") - } - - // get random user agent and corresponding Sec-Ch-Ua header - userAgent, secChUa := useragent.RandomUserAgentWithHeader() - - // create new request - nr := &http.Request{ - Method: http.MethodGet, - URL: target, - Host: target.Host, - // RemoteAddr: "127.0.0.1", // TODO: implement server IP getting (should be cached) - RequestURI: target.RequestURI(), - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, - Header: map[string][]string{ - "Accept": {"image/avif", "image/webp", "image/apng", "image/svg+xml", "image/*", "*/*;q=0.8"}, - "Accept-Encoding": {"gzip", "deflate", "br"}, // Google Chrome also has "zstd" but that isn't supported by Firefox and Safari - "Accept-Language": {"en-US,en;q=0.9"}, - // "Connection": {"keep-alive"}, // commented since it's not present by default in Google Chrome - // "DNT": {"1"}, // do not track, commented since it's not present by default in Google Chrome - "Sec-Ch-Ua": {secChUa}, // "Google Chrome";v="119", "Chromium";v="119", "Not=A?Brand";v="24" - "Sec-Ch-Ua-Mobile": {"?0"}, - "Sec-Ch-Ua-Platform": {"\"Windows\""}, - "Sec-Fetch-Dest": {"image"}, - "Sec-Fetch-Mode": {"no-cors"}, - "Sec-Fetch-Site": {"same-site"}, - // "Sec-GPC": {"1"}, // don't share info with 3rd parties, commented since it's not present by default in Google Chrome - // "TE": {"trailers"}, // commented since it's not present by default in Google Chrome - "User-Agent": {userAgent}, // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" - }, - } - - log.Trace(). - Caller(). - Str("request", fmt.Sprint(nr)). - Msg("Created new request") - - // create reverse proxy with timeout - rp := httputil.ReverseProxy{Director: func(r *http.Request) {}} - rp.Transport = &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: timeouts.Dial, - KeepAlive: timeouts.KeepAlive, - }).DialContext, - TLSHandshakeTimeout: timeouts.TLSHandshake, - } - - // proxy the request - log.Debug(). - Str("url", target.String()). - Msg("Proxying request") - rp.ServeHTTP(w, nr) // use new request - - return nil -} diff --git a/src/router/router.go b/src/router/router.go index bcd358e2..9cd25c63 100644 --- a/src/router/router.go +++ b/src/router/router.go @@ -10,27 +10,28 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/hearchco/hearchco/src/cache" - "github.com/hearchco/hearchco/src/config" + "github.com/hearchco/agent/src/cache" + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/router/middlewares" + "github.com/hearchco/agent/src/router/routes" ) -// it's okay to store pointer since chi.NewRouter() returns a pointer type RouterWrapper struct { mux *chi.Mux port int } -func New(lgr zerolog.Logger, conf config.Config, db cache.DB, serveProfiler bool) RouterWrapper { +func New(lgr zerolog.Logger, conf config.Config, db cache.DB, serveProfiler bool, version string) RouterWrapper { mux := chi.NewRouter() - setupMiddlewares(mux, lgr, conf.Server.FrontendUrls, serveProfiler) - setupRoutes(mux, db, conf) + middlewares.Setup(mux, lgr, conf.Server.FrontendUrls, serveProfiler) + routes.Setup(mux, version, db, conf) return RouterWrapper{mux: mux, port: conf.Server.Port} } func (rw RouterWrapper) Start(ctx context.Context) { - // create server + // Create server. srv := http.Server{ Addr: ":" + strconv.Itoa(rw.port), Handler: rw.mux, @@ -40,17 +41,17 @@ func (rw RouterWrapper) Start(ctx context.Context) { Int("port", rw.port). Msg("Starting server") - // shut down server gracefully on context cancellation + // Shut down server gracefully on context cancellation. go func() { <-ctx.Done() log.Info().Msg("Shutting down server") - // create a context with timeout of 5 seconds + // Create a context with timeout of 5 seconds. timeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // shutdown gracefully - // after timeout is reached, server will be shut down forcefully + // Shutdown gracefully. + // After the timeout is reached, server will be shut down forcefully. err := srv.Shutdown(timeout) if err != nil { log.Error(). @@ -63,7 +64,7 @@ func (rw RouterWrapper) Start(ctx context.Context) { } }() - // start server + // Start server. err := srv.ListenAndServe() if err != nil && err != http.ErrServerClosed { log.Fatal(). diff --git a/src/router/routes.go b/src/router/routes.go deleted file mode 100644 index 9c35fa30..00000000 --- a/src/router/routes.go +++ /dev/null @@ -1,57 +0,0 @@ -package router - -import ( - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/rs/zerolog/log" - - "github.com/hearchco/hearchco/src/cache" - "github.com/hearchco/hearchco/src/config" -) - -func setupRoutes(mux *chi.Mux, db cache.DB, conf config.Config) { - mux.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { - err := writeResponse(w, http.StatusOK, "OK") - if err != nil { - log.Error(). - Err(err). - Str("path", r.URL.Path). - Str("method", r.Method). - Msg("Failed to healthz") - } - }) - - mux.Get("/search", func(w http.ResponseWriter, r *http.Request) { - err := Search(w, r, db, conf.Server.Cache.TTL, conf.Settings, conf.Categories, conf.Server.Proxy.Salt) - if err != nil { - log.Error(). - Err(err). - Str("path", r.URL.Path). - Str("method", r.Method). - Msg("Failed to search") - } - }) - - mux.Post("/search", func(w http.ResponseWriter, r *http.Request) { - err := Search(w, r, db, conf.Server.Cache.TTL, conf.Settings, conf.Categories, conf.Server.Proxy.Salt) - if err != nil { - log.Error(). - Err(err). - Str("path", r.URL.Path). - Str("method", r.Method). - Msg("Failed to search") - } - }) - - mux.Get("/proxy", func(w http.ResponseWriter, r *http.Request) { - err := Proxy(w, r, conf.Server.Proxy.Salt, conf.Server.Proxy.Timeouts) - if err != nil { - log.Error(). - Err(err). - Str("path", r.URL.Path). - Str("method", r.Method). - Msg("Failed to proxy") - } - }) -} diff --git a/src/router/types.go b/src/router/routes/errors.go similarity index 86% rename from src/router/types.go rename to src/router/routes/errors.go index 5ec8058b..51425342 100644 --- a/src/router/types.go +++ b/src/router/routes/errors.go @@ -1,4 +1,4 @@ -package router +package routes type ErrorResponse struct { Message string `json:"message"` diff --git a/src/router/routes/params.go b/src/router/routes/params.go new file mode 100644 index 00000000..4d9022a8 --- /dev/null +++ b/src/router/routes/params.go @@ -0,0 +1,13 @@ +package routes + +import ( + "net/url" +) + +func getParamOrDefault(params url.Values, key string, fallback ...string) string { + val := params.Get(key) + if val == "" && len(fallback) > 0 { + return fallback[0] + } + return val +} diff --git a/src/router/utils.go b/src/router/routes/responses.go similarity index 74% rename from src/router/utils.go rename to src/router/routes/responses.go index 31ce8c46..bcea900f 100644 --- a/src/router/utils.go +++ b/src/router/routes/responses.go @@ -1,10 +1,9 @@ -package router +package routes import ( "encoding/json" "fmt" "net/http" - "net/url" ) func writeResponse(w http.ResponseWriter, status int, body string) error { @@ -29,11 +28,3 @@ func writeResponseJSON(w http.ResponseWriter, status int, body any) error { _, err = w.Write(res) return err } - -func getParamOrDefault(params url.Values, key string, fallback ...string) string { - val := params.Get(key) - if val == "" && len(fallback) > 0 { - return fallback[0] - } - return val -} diff --git a/src/router/routes/route_proxy.go b/src/router/routes/route_proxy.go new file mode 100644 index 00000000..3986bb73 --- /dev/null +++ b/src/router/routes/route_proxy.go @@ -0,0 +1,106 @@ +package routes + +import ( + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search/useragent" + "github.com/hearchco/agent/src/utils/anonymize" +) + +func routeProxy(w http.ResponseWriter, r *http.Request, salt string, timeouts config.ImageProxyTimeouts) error { + err := r.ParseForm() + if err != nil { + // Server error. + werr := writeResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse form: %v", err)) + if werr != nil { + return fmt.Errorf("%w: %w", werr, err) + } + return err + } + + params := r.Form + + urlParam := getParamOrDefault(params, "url") + hashParam := getParamOrDefault(params, "hash") + + if urlParam == "" || hashParam == "" { + // User error. + return writeResponse(w, http.StatusBadRequest, "url and hash are required") + } + + // Check if hash is valid. + if !anonymize.VerifyHash(hashParam, urlParam, salt) { + // User error. + log.Debug(). + Str("url", urlParam). + Str("hash", hashParam). + Msg("Invalid hash") + return writeResponse(w, http.StatusUnauthorized, "invalid hash") + } + + // Parse the url. + target, err := url.Parse(urlParam) + if err != nil { + // User error. + log.Debug(). + Str("url", urlParam). + Msg("Invalid url") + return writeResponse(w, http.StatusBadRequest, "invalid url") + } + + // Get random UserAgent with corresponding Sec-Ch-Ua headers. + ua := useragent.RandomUserAgentWithHeaders() + + // Create a new request. + nr := &http.Request{ + Method: http.MethodGet, + URL: target, + Host: target.Host, + RequestURI: target.RequestURI(), + Proto: "HTTP/2", + ProtoMajor: 2, + ProtoMinor: 0, + Header: map[string][]string{ + "Accept": {"image/avif", "image/webp", "image/apng", "image/svg+xml", "image/*", "*/*;q=0.8"}, + "Accept-Encoding": r.Header["Accept-Encoding"], // WARN: This is passed from the original request. + "Accept-Language": {"en-US,en;q=0.9"}, + "Sec-Ch-Ua": {ua.SecCHUA}, + "Sec-Ch-Ua-Mobile": {ua.SecCHUAMobile}, + "Sec-Ch-Ua-Platform": {ua.SecCHUAPlatform}, + "Sec-Fetch-Dest": {"image"}, + "Sec-Fetch-Mode": {"no-cors"}, + "Sec-Fetch-Site": {"same-site"}, + "User-Agent": {ua.UserAgent}, + }, + } + + log.Trace(). + Caller(). + Str("request", fmt.Sprint(nr)). + Msg("Created a new request") + + // Create reverse proxy with timeout. + rp := httputil.ReverseProxy{Director: func(r *http.Request) {}} + rp.Transport = &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: timeouts.Dial, + KeepAlive: timeouts.KeepAlive, + }).DialContext, + TLSHandshakeTimeout: timeouts.TLSHandshake, + } + + // Proxy the request. + log.Debug(). + Str("url", target.String()). + Msg("Proxying request") + rp.ServeHTTP(w, nr) // Use the new request. + + return nil +} diff --git a/src/router/routes/route_search.go b/src/router/routes/route_search.go new file mode 100644 index 00000000..d68b9ab6 --- /dev/null +++ b/src/router/routes/route_search.go @@ -0,0 +1,181 @@ +package routes + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/hearchco/agent/src/cache" + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search" + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/result/rank" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/gotypelimits" + "github.com/rs/zerolog/log" +) + +func routeSearch(w http.ResponseWriter, r *http.Request, catsConf map[category.Name]config.Category, ttlConf config.TTL, db cache.DB, salt string) error { + // Parse form data (including query params). + if err := r.ParseForm(); err != nil { + // Server error. + werr := writeResponseJSON(w, http.StatusInternalServerError, ErrorResponse{ + Message: "failed to parse form", + Value: fmt.Sprintf("%v", err), + }) + if werr != nil { + return fmt.Errorf("%w: %w", werr, err) + } + return err + } + + query := strings.TrimSpace(getParamOrDefault(r.Form, "q")) // query is required + if query == "" { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "query cannot be empty or whitespace", + Value: "empty query", + }) + } + + categoryS := getParamOrDefault(r.Form, "category", category.GENERAL.String()) + categoryName, err := category.FromString(categoryS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "invalid category value", + Value: fmt.Sprintf("%v", categoryName), + }) + } + + pagesMaxS := getParamOrDefault(r.Form, "pages", "1") + pagesMax, err := strconv.Atoi(pagesMaxS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusUnprocessableEntity, ErrorResponse{ + Message: "cannot convert pages value to int", + Value: fmt.Sprintf("%v", err), + }) + } + // TODO: Make upper limit configurable. + pagesMaxUpperLimit := 10 + if pagesMax < 1 || pagesMax > pagesMaxUpperLimit { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: fmt.Sprintf("pages value must be at least 1 and at most %v", pagesMaxUpperLimit), + Value: "out of range", + }) + } + + pagesStartS := getParamOrDefault(r.Form, "start", "1") + pagesStart, err := strconv.Atoi(pagesStartS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusUnprocessableEntity, ErrorResponse{ + Message: "cannot convert start value to int", + Value: fmt.Sprintf("%v", err), + }) + } + // Make sure that pagesStart can be safely added to pagesMax. + if pagesStart < 1 || pagesStart > gotypelimits.MaxInt-pagesMaxUpperLimit { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: fmt.Sprintf("start value must be at least 1 and at most %v", gotypelimits.MaxInt-pagesMaxUpperLimit), + Value: "out of range", + }) + } else { + // Since it's >=1, we decrement it to match the 0-based index. + pagesStart -= 1 + } + + localeS := getParamOrDefault(r.Form, "locale", options.LocaleDefault.String()) + locale, err := options.StringToLocale(localeS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "invalid locale value", + Value: fmt.Sprintf("%v", err), + }) + } + + safeSearchS := getParamOrDefault(r.Form, "safesearch", "false") + safeSearch, err := strconv.ParseBool(safeSearchS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusUnprocessableEntity, ErrorResponse{ + Message: "cannot convert safesearch value to bool", + Value: fmt.Sprintf("%v", err), + }) + } + + // All of these have default values set and validated. + opts := options.Options{ + Pages: options.Pages{ + Start: pagesStart, + Max: pagesMax, + }, + Locale: locale, + SafeSearch: safeSearch, + } + + // Check cache for results. + cachedRes, err := db.GetResults(query, categoryName, opts) + if err != nil { + log.Error(). + Err(err). + Str("query", anonymize.String(query)). + Str("category", categoryName.String()). + Msg("Failed to get results from cache") + } else if len(cachedRes) > 0 { + log.Debug(). + Str("query", anonymize.String(query)). + Str("category", categoryName.String()). + Msg("Results found in cache") + + // Convert the results to include the hashes (output format). + outpusRes := result.ConvertToOutput(cachedRes, salt) + + // If writing response failes, return the error. + return writeResponseJSON(w, http.StatusOK, outpusRes) + } else { + log.Debug(). + Str("query", anonymize.String(query)). + Str("category", categoryName.String()). + Msg("No results found in cache") + } + + // Search for results. + scrapedRes, err := search.Search(query, categoryName, opts, catsConf[categoryName]) + if err != nil { + // Server error. + werr := writeResponseJSON(w, http.StatusInternalServerError, ErrorResponse{ + Message: "failed to search", + Value: fmt.Sprintf("%v", err), + }) + if werr != nil { + return fmt.Errorf("%w: %w", werr, err) + } + return err + } + + // Rank the results. + rankedRes := rank.Rank(scrapedRes, catsConf[categoryName].Ranking) + + // Store the results in cache. + if err := db.SetResults(query, categoryName, opts, rankedRes, ttlConf.Time); err != nil { + log.Error(). + Err(err). + Str("query", anonymize.String(query)). + Str("category", categoryName.String()). + Msg("failed to set results in cache") + } + + // Convert the results to include the hashes (output format). + outpusRes := result.ConvertToOutput(rankedRes, salt) + + // If writing response failes, return the error. + return writeResponseJSON(w, http.StatusOK, outpusRes) +} diff --git a/src/router/routes/setup.go b/src/router/routes/setup.go new file mode 100644 index 00000000..0674ba3a --- /dev/null +++ b/src/router/routes/setup.go @@ -0,0 +1,68 @@ +package routes + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/cache" + "github.com/hearchco/agent/src/config" +) + +func Setup(mux *chi.Mux, version string, db cache.DB, conf config.Config) { + mux.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + err := writeResponse(w, http.StatusOK, "OK") + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) + + mux.Get("/versionz", func(w http.ResponseWriter, r *http.Request) { + err := writeResponse(w, http.StatusOK, version) + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) + + mux.Get("/search", func(w http.ResponseWriter, r *http.Request) { + err := routeSearch(w, r, conf.Categories, conf.Server.Cache.TTL, db, conf.Server.ImageProxy.Salt) + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) + + mux.Post("/search", func(w http.ResponseWriter, r *http.Request) { + err := routeSearch(w, r, conf.Categories, conf.Server.Cache.TTL, db, conf.Server.ImageProxy.Salt) + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) + + mux.Get("/proxy", func(w http.ResponseWriter, r *http.Request) { + err := routeProxy(w, r, conf.Server.ImageProxy.Salt, conf.Server.ImageProxy.Timeouts) + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) +} diff --git a/src/router/search.go b/src/router/search.go deleted file mode 100644 index 2c72cc19..00000000 --- a/src/router/search.go +++ /dev/null @@ -1,152 +0,0 @@ -package router - -import ( - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/hearchco/hearchco/src/cache" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/gotypelimits" - "github.com/hearchco/hearchco/src/search" - "github.com/hearchco/hearchco/src/search/category" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/result" -) - -// returns response body, header and error -func Search(w http.ResponseWriter, r *http.Request, db cache.DB, ttlConf config.TTL, settings map[engines.Name]config.Settings, categories map[category.Name]config.Category, salt string) error { - // parse form data (including query params) - if err := r.ParseForm(); err != nil { - // server error - werr := writeResponseJSON(w, http.StatusInternalServerError, ErrorResponse{ - Message: "failed to parse form", - Value: fmt.Sprintf("%v", err), - }) - if werr != nil { - return fmt.Errorf("%w: %w", werr, err) - } - return err - } - - query := strings.TrimSpace(getParamOrDefault(r.Form, "q")) // query is required - if query == "" { - // user error - return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ - Message: "query cannot be empty or whitespace", - Value: "empty query", - }) - } - - visitPagesS := getParamOrDefault(r.Form, "deep", "false") - visitPages, err := strconv.ParseBool(visitPagesS) - if err != nil { - // user error - return writeResponseJSON(w, http.StatusUnprocessableEntity, ErrorResponse{ - Message: "cannot convert deep value to bool", - Value: fmt.Sprintf("%v", err), - }) - } - - safeSearchS := getParamOrDefault(r.Form, "safesearch", "false") - safeSearch, err := strconv.ParseBool(safeSearchS) - if err != nil { - // user error - return writeResponseJSON(w, http.StatusUnprocessableEntity, ErrorResponse{ - Message: "cannot convert safesearch value to bool", - Value: fmt.Sprintf("%v", err), - }) - } - - pagesMaxS := getParamOrDefault(r.Form, "pages", "1") - pagesMax, err := strconv.Atoi(pagesMaxS) - if err != nil { - // user error - return writeResponseJSON(w, http.StatusUnprocessableEntity, ErrorResponse{ - Message: "cannot convert pages value to int", - Value: fmt.Sprintf("%v", err), - }) - } - // TODO: make upper limit configurable - pagesMaxUpperLimit := 10 - if pagesMax < 1 || pagesMax > pagesMaxUpperLimit { - // user error - return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ - Message: fmt.Sprintf("pages value must be at least 1 and at most %v", pagesMaxUpperLimit), - Value: "out of range", - }) - } - - pagesStartS := getParamOrDefault(r.Form, "start", "1") - pagesStart, err := strconv.Atoi(pagesStartS) - if err != nil { - // user error - return writeResponseJSON(w, http.StatusUnprocessableEntity, ErrorResponse{ - Message: "cannot convert start value to int", - Value: fmt.Sprintf("%v", err), - }) - } - // make sure that pagesStart can be safely added to pagesMax - if pagesStart < 1 || pagesStart > gotypelimits.MaxInt-pagesMaxUpperLimit { - // user error - return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ - Message: fmt.Sprintf("start value must be at least 1 and at most %v", gotypelimits.MaxInt-pagesMaxUpperLimit), - Value: "out of range", - }) - } else { - // since it's >=1, we decrement it to match the 0-based index - pagesStart -= 1 - } - - locale := getParamOrDefault(r.Form, "locale", config.DefaultLocale) - err = engines.ValidateLocale(locale) - if err != nil { - // user error - return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ - Message: "invalid locale value", - Value: fmt.Sprintf("%v", err), - }) - } - - categoryS := getParamOrDefault(r.Form, "category", category.GENERAL.String()) - categoryName, err := category.FromString(categoryS) - if err != nil { - // user error - return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ - Message: "invalid category value", - Value: fmt.Sprintf("%v", categoryName), - }) - } - - // all of these have default values set and are validated beforehand - options := engines.Options{ - VisitPages: visitPages, - SafeSearch: safeSearch, - Pages: engines.Pages{ - Start: pagesStart, - Max: pagesMax, - }, - Locale: locale, - Category: categoryName, - } - - // search for results - results, foundInDB := search.Search(query, options, db, categories[options.Category], settings, salt) - - // send response as soon as possible - if categoryName == category.IMAGES { - resultsOutput := result.ConvertToImageOutput(results) - err = writeResponseJSON(w, http.StatusOK, resultsOutput) - } else { - resultsOutput := result.ConvertToGeneralOutput(results) - err = writeResponseJSON(w, http.StatusOK, resultsOutput) - } - - // TODO: this doesn't work on AWS Lambda because the response is already sent (which terminates the process) - // don't return immediately, we want to cache results and update them if necessary - search.CacheAndUpdateResults(query, options, db, ttlConf, categories[options.Category], settings, results, foundInDB, salt) - - // if writing response failed, return the error - return err -} diff --git a/src/search/bucket/addresult.go b/src/search/bucket/addresult.go deleted file mode 100644 index d0f404dc..00000000 --- a/src/search/bucket/addresult.go +++ /dev/null @@ -1,103 +0,0 @@ -package bucket - -import ( - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/result" - "github.com/rs/zerolog/log" -) - -// Checks if the retrieved result is valid. Makes a result object and adds it to the relay. -// Returns true if the result is valid (false otherwise). -func AddSEResult(seResult *result.RetrievedResult, seName engines.Name, relay *Relay, options engines.Options, pagesCol *colly.Collector, nEnabledEngines int) bool { - if seResult == nil { - log.Error(). - Caller(). - Str("engine", seName.String()). - Msg("Result is nil") - return false - } - - // TODO: add a check if image result is valid - if seResult.URL == "" || seResult.Title == "" { - log.Error(). - Caller(). - Str("engine", seName.String()). - Str("url", seResult.URL). - Str("title", seResult.Title). - Str("description", seResult.Description). - Msg("Invalid result, some fields are empty") - return false - } - - log.Trace(). - Str("engine", seName.String()). - Str("title", seResult.Title). - Str("url", seResult.URL). - Msg("Got result") - - relay.Mutex.RLock() - mapRes, exists := relay.ResultMap[seResult.URL] - relay.Mutex.RUnlock() - - if !exists { - // create engine ranks slice with capacity of enabled engines - engineRanks := make([]result.RetrievedRank, 0, nEnabledEngines) - engineRanks = append(engineRanks, seResult.Rank) - - result := result.Result{ - URL: seResult.URL, - URLHash: seResult.URLHash, - Rank: 0, - Title: seResult.Title, - Description: seResult.Description, - EngineRanks: engineRanks, - ImageResult: seResult.ImageResult, - } - - relay.Mutex.Lock() - relay.ResultMap[result.URL] = &result - relay.Mutex.Unlock() - } else { - alreadyIn := false - index := 0 - - relay.Mutex.RLock() - for i, er := range mapRes.EngineRanks { // this could also be done by changing EngineRanks to a map - if seName == er.SearchEngine { - alreadyIn = true - index = i - break - } - } - relay.Mutex.RUnlock() - - relay.Mutex.Lock() - er := &mapRes.EngineRanks[index] - if !alreadyIn { - mapRes.EngineRanks = append(mapRes.EngineRanks, seResult.Rank) - } else if er.Page > seResult.Rank.Page { - er.Page = seResult.Rank.Page - er.OnPageRank = seResult.Rank.OnPageRank - } else if er.Page == seResult.Rank.Page && er.OnPageRank > seResult.Rank.OnPageRank { - er.OnPageRank = seResult.Rank.OnPageRank - } - - if len(mapRes.Description) < len(seResult.Description) { - mapRes.Description = seResult.Description - } - relay.Mutex.Unlock() - } - - if !exists && options.VisitPages { - if err := pagesCol.Visit(seResult.URL); err != nil { - log.Error(). - Caller(). - Err(err). - Str("url", seResult.URL). - Msg("Failed visiting page") - } - } - - return true -} diff --git a/src/search/bucket/makeresult.go b/src/search/bucket/makeresult.go deleted file mode 100644 index 45648a24..00000000 --- a/src/search/bucket/makeresult.go +++ /dev/null @@ -1,70 +0,0 @@ -package bucket - -import ( - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/result" -) - -// Makes result from parameters -func MakeSEResult( - urll, title, desc string, - seName engines.Name, sePage, seOnPageRank int, -) result.RetrievedResult { - - ser := result.RetrievedRank{ - SearchEngine: seName, - Rank: 0, - Page: uint(sePage), - OnPageRank: uint(seOnPageRank), - } - - res := result.RetrievedResult{ - URL: urll, - Title: title, - Description: desc, - Rank: ser, - } - - return res -} - -func MakeSEImageResult( - urll, title, desc string, - src, srcUrl, thmbUrl string, - origH, origW, thmbH, thmbW int, - seName engines.Name, sePage, seOnPageRank int, - salt string, -) result.RetrievedResult { - - ser := result.RetrievedRank{ - SearchEngine: seName, - Rank: 0, - Page: uint(sePage), - OnPageRank: uint(seOnPageRank), - } - - res := result.RetrievedResult{ - URL: urll, - URLHash: anonymize.HashToSHA256B64Salted(urll, salt), - Title: title, - Description: desc, - ImageResult: result.ImageResult{ - Original: result.ImageFormat{ - Height: uint(origH), - Width: uint(origW), - }, - Thumbnail: result.ImageFormat{ - Height: uint(thmbH), - Width: uint(thmbW), - }, - ThumbnailURL: thmbUrl, - ThumbnailURLHash: anonymize.HashToSHA256B64Salted(thmbUrl, salt), - Source: src, - SourceURL: srcUrl, - }, - Rank: ser, - } - - return res -} diff --git a/src/search/bucket/relay.go b/src/search/bucket/relay.go deleted file mode 100644 index 0d69d74a..00000000 --- a/src/search/bucket/relay.go +++ /dev/null @@ -1,12 +0,0 @@ -package bucket - -import ( - "sync" - - "github.com/hearchco/hearchco/src/search/result" -) - -type Relay struct { - ResultMap map[string]*result.Result - Mutex sync.RWMutex -} diff --git a/src/search/bucket/setresponse.go b/src/search/bucket/setresponse.go deleted file mode 100644 index 64c703e3..00000000 --- a/src/search/bucket/setresponse.go +++ /dev/null @@ -1,32 +0,0 @@ -package bucket - -import ( - "fmt" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/rs/zerolog/log" -) - -func SetResultResponse(link string, response *colly.Response, relay *Relay, seName engines.Name) error { - log.Trace(). - Str("engine", seName.String()). - Str("link", link). - Msg("Got response") - - relay.Mutex.Lock() - mapRes, exists := relay.ResultMap[link] - - if !exists { - relay.Mutex.Unlock() - relay.Mutex.RLock() - err := fmt.Errorf("bucket.SetResultResponse(): URL not in map when adding response, should not be possible. URL: %v.\nRelay: %v", link, relay) - relay.Mutex.RUnlock() - return err - } else { - mapRes.Response = response - relay.Mutex.Unlock() - } - - return nil -} diff --git a/src/search/cache.go b/src/search/cache.go deleted file mode 100644 index cead354b..00000000 --- a/src/search/cache.go +++ /dev/null @@ -1,63 +0,0 @@ -package search - -import ( - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/cache" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/result" - "github.com/rs/zerolog/log" -) - -func CacheAndUpdateResults( - query string, options engines.Options, db cache.DB, - ttlConf config.TTL, categoryConf config.Category, settings map[engines.Name]config.Settings, - results []result.Result, foundInDB bool, - salt string, -) { - if !foundInDB { - log.Debug(). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Caching results...") - err := db.SetResults(query, options, results, ttlConf.Time) - if err != nil { - log.Error(). - Caller(). - Err(err). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Error updating database with search results") - } - } else { - log.Debug(). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Checking if results need to be updated") - ttl, err := db.GetResultsTTL(query, options) - if err != nil { - log.Error(). - Caller(). - Err(err). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Error getting TTL from database") - } else if ttl < ttlConf.RefreshTime { - log.Info(). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Updating results...") - newResults := PerformSearch(query, options, categoryConf, settings, salt) - err := db.SetResults(query, options, newResults, ttlConf.Time) - if err != nil { - // Error in updating cache is not returned, just logged - log.Error(). - Caller(). - Err(err). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Error replacing old results while updating database") - } - } - } -} diff --git a/src/search/category/category.go b/src/search/category/category.go deleted file mode 100644 index 3fe47457..00000000 --- a/src/search/category/category.go +++ /dev/null @@ -1,28 +0,0 @@ -package category - -import "fmt" - -var catMap = map[string]Name{ - "general": GENERAL, - "images": IMAGES, - "science": SCIENCE, - "sci": SCIENCE, - "thorough": THOROUGH, - "slow": THOROUGH, -} - -// converts a string to a category name if it exists -// if the string is empty, then GENERAL is returned -// otherwise returns UNDEFINED -func FromString(cat string) (Name, error) { - if cat == "" { - return GENERAL, nil - } - - catName, ok := catMap[cat] - if !ok { - return UNDEFINED, fmt.Errorf("category %q is not defined", cat) - } - - return catName, nil -} diff --git a/src/search/category/name.go b/src/search/category/name.go index c0b094c0..55d5ba89 100644 --- a/src/search/category/name.go +++ b/src/search/category/name.go @@ -1,8 +1,11 @@ package category +import ( + "fmt" +) + type Name string -// enumer not necessary, won't be updated often, have to have FromString anyways const ( UNDEFINED Name = "undefined" GENERAL Name = "general" @@ -14,3 +17,21 @@ const ( func (cat Name) String() string { return string(cat) } + +// Converts a string to a category name if it exists. +// If the string is empty, then GENERAL is returned. +// Otherwise returns UNDEFINED. +func FromString(cat string) (Name, error) { + switch cat { + case "", GENERAL.String(): + return GENERAL, nil + case IMAGES.String(): + return IMAGES, nil + case SCIENCE.String(): + return SCIENCE, nil + case THOROUGH.String(): + return THOROUGH, nil + default: + return UNDEFINED, fmt.Errorf("category %q is not defined", cat) + } +} diff --git a/src/search/context_cancel.go b/src/search/context_cancel.go new file mode 100644 index 00000000..194617ed --- /dev/null +++ b/src/search/context_cancel.go @@ -0,0 +1,79 @@ +package search + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/utils/anonymize" +) + +func cancelHardTimeout(start time.Time, cancel context.CancelFunc, query string, wgRequiredEngines *sync.WaitGroup, requiredEngines []engines.Name, wgRequiredByOriginEngines *sync.WaitGroup, requiredByOriginEngines []engines.Name) { + var wg sync.WaitGroup + wg.Add(2) + + // Wait for all required engines to finish. + go func() { + defer wg.Done() + wgRequiredEngines.Wait() + log.Debug(). + Str("query", anonymize.String(query)). + Str("group", "required"). + Str("engines", fmt.Sprintf("%v", requiredEngines)). + Dur("duration", time.Since(start)). + Msg("Scraping group finished") + }() + + // Wait for all required by origin engines to finish. + go func() { + defer wg.Done() + wgRequiredByOriginEngines.Wait() + log.Debug(). + Str("query", anonymize.String(query)). + Str("group", "required by origin"). + Str("engines", fmt.Sprintf("%v", requiredByOriginEngines)). + Dur("duration", time.Since(start)). + Msg("Scraping group finished") + }() + + wg.Wait() + cancel() +} + +func cancelPreferredTimeout(start time.Time, cancel context.CancelFunc, query string, wgPreferredEngines *sync.WaitGroup, preferredEngines []engines.Name, wgPreferredByOriginEngines *sync.WaitGroup, preferredByOriginEngines []engines.Name) { + var wg sync.WaitGroup + + // Wait for all preferred engines to finish. + wg.Add(1) + go func() { + defer wg.Done() + wgPreferredEngines.Wait() + log.Debug(). + Str("query", anonymize.String(query)). + Str("group", "preferred"). + Str("engines", fmt.Sprintf("%v", preferredEngines)). + Dur("duration", time.Since(start)). + Msg("Scraping group finished") + }() + + // Wait for all preferred by origin engines to finish. + wg.Add(1) + go func() { + defer wg.Done() + wgPreferredByOriginEngines.Wait() + log.Debug(). + Str("query", anonymize.String(query)). + Str("group", "preferred by origin"). + Str("engines", fmt.Sprintf("%v", preferredByOriginEngines)). + Dur("duration", time.Since(start)). + Msg("Scraping group finished") + }() + + // Wait for both groups to finish. + wg.Wait() + cancel() +} diff --git a/src/search/engine_interface.go b/src/search/engine_interface.go deleted file mode 100644 index c96c605f..00000000 --- a/src/search/engine_interface.go +++ /dev/null @@ -1,13 +0,0 @@ -package search - -import ( - "context" - - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" -) - -type Searcher interface { - Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error -} diff --git a/src/search/engines/_engines_test/run.go b/src/search/engines/_engines_test/run.go new file mode 100644 index 00000000..7416d560 --- /dev/null +++ b/src/search/engines/_engines_test/run.go @@ -0,0 +1,86 @@ +package _engines_test + +import ( + "context" + "strings" + "testing" + + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" +) + +func CheckTestCases(t *testing.T, e scraper.Enginer, tchar []TestCaseHasAnyResults, tccr []TestCaseContainsResults, tcrr []TestCaseRankedResults) { + // TestCaseHasAnyResults + for _, tc := range tchar { + e.ReInit(context.Background()) + + resChan := make(chan result.ResultScraped, 100) + go e.Search(tc.Query, tc.Options, resChan) + + results := make([]result.ResultScraped, 0) + for r := range resChan { + results = append(results, r) + } + + if len(results) == 0 { + defer t.Errorf("Got no results for %q", tc.Query) + } + } + + // TestCaseContainsResults + for _, tc := range tccr { + e.ReInit(context.Background()) + + resChan := make(chan result.ResultScraped, 100) + go e.Search(tc.Query, tc.Options, resChan) + + results := make([]result.ResultScraped, 0) + for r := range resChan { + results = append(results, r) + } + + if len(results) == 0 { + defer t.Errorf("Got no results for %q", tc.Query) + } else { + for _, rURL := range tc.ResultURLs { + found := false + + for _, r := range results { + if strings.Contains(r.URL(), rURL) { + found = true + break + } + } + + if !found { + defer t.Errorf("Couldn't find %q (%q).\nThe results: %q", rURL, tc.Query, results) + } + } + } + } + + // TestCaseRankedResults + for _, tc := range tcrr { + e.ReInit(context.Background()) + + resChan := make(chan result.ResultScraped, 100) + go e.Search(tc.Query, tc.Options, resChan) + + results := make([]result.ResultScraped, 0) + for r := range resChan { + results = append(results, r) + } + + if len(results) == 0 { + defer t.Errorf("Got no results for %q", tc.Query) + } else if len(results) < len(tc.ResultURLs) { + defer t.Errorf("Number of results is less than test case URLs.") + } else { + for i, rURL := range tc.ResultURLs { + if !strings.Contains(results[i].URL(), rURL) { + defer t.Errorf("Wrong result on rank %q: %q (%q).\nThe results: %q", i+1, rURL, tc.Query, results) + } + } + } + } +} diff --git a/src/search/engines/_engines_test/structs.go b/src/search/engines/_engines_test/structs.go index 74aa4949..16613690 100644 --- a/src/search/engines/_engines_test/structs.go +++ b/src/search/engines/_engines_test/structs.go @@ -3,41 +3,42 @@ package _engines_test import ( "time" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/category" - "github.com/hearchco/hearchco/src/search/engines" + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/engines/options" ) type TestCaseHasAnyResults struct { Query string - Options engines.Options + Options options.Options } type TestCaseContainsResults struct { - Query string - ResultURL []string - Options engines.Options + Query string + ResultURLs []string + Options options.Options } type TestCaseRankedResults struct { - Query string - ResultURL []string - Options engines.Options + Query string + ResultURLs []string + Options options.Options } -func NewConfig(engineName engines.Name) config.Config { +func NewConfig(seName engines.Name) config.Config { return config.Config{ Categories: map[category.Name]config.Category{ category.GENERAL: { - Engines: []engines.Name{engineName}, - Ranking: config.NewRanking(), + Engines: []engines.Name{seName}, + Ranking: config.EmptyRanking([]engines.Name{seName}), Timings: config.CategoryTimings{ HardTimeout: 10000 * time.Millisecond, }, }, category.IMAGES: { - Engines: []engines.Name{engineName}, - Ranking: config.NewRanking(), + Engines: []engines.Name{seName}, + Ranking: config.EmptyRanking([]engines.Name{seName}), Timings: config.CategoryTimings{ HardTimeout: 10000 * time.Millisecond, }, @@ -46,8 +47,10 @@ func NewConfig(engineName engines.Name) config.Config { } } -func NewOpts() engines.Options { - return engines.Options{ - Pages: engines.Pages{Start: 0, Max: 1}, +func NewOpts() options.Options { + return options.Options{ + Pages: options.Pages{Start: 0, Max: 1}, + Locale: options.LocaleDefault, + SafeSearch: false, } } diff --git a/src/search/engines/_engines_test/tester.go b/src/search/engines/_engines_test/tester.go deleted file mode 100644 index 1cb64947..00000000 --- a/src/search/engines/_engines_test/tester.go +++ /dev/null @@ -1,59 +0,0 @@ -package _engines_test - -import ( - "strings" - "testing" - - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search" -) - -func CheckTestCases(tchar []TestCaseHasAnyResults, tccr []TestCaseContainsResults, - tcrr []TestCaseRankedResults, t *testing.T, conf config.Config) { - - // TestCaseHasAnyResults - for _, tc := range tchar { - if results := search.PerformSearch(tc.Query, tc.Options, conf.Categories[tc.Options.Category], conf.Settings, ""); len(results) == 0 { - defer t.Errorf("Got no results for %q", tc.Query) - } - } - - // TestCaseContainsResults - for _, tc := range tccr { - results := search.PerformSearch(tc.Query, tc.Options, conf.Categories[tc.Options.Category], conf.Settings, "") - if len(results) == 0 { - defer t.Errorf("Got no results for %q", tc.Query) - } else { - for _, rURL := range tc.ResultURL { - found := false - - for _, r := range results { - if strings.Contains(r.URL, rURL) { - found = true - break - } - } - - if !found { - defer t.Errorf("Couldn't find %q (%q).\nThe results: %q", rURL, tc.Query, results) - } - } - } - } - - // TestCaseRankedResults - for _, tc := range tcrr { - results := search.PerformSearch(tc.Query, tc.Options, conf.Categories[tc.Options.Category], conf.Settings, "") - if len(results) == 0 { - defer t.Errorf("Got no results for %q", tc.Query) - } else if len(results) < len(tc.ResultURL) { - defer t.Errorf("Number of results is less than test case URLs.") - } else { - for i, rURL := range tc.ResultURL { - if !strings.Contains(results[i].URL, rURL) { - defer t.Errorf("Wrong result on rank %q: %q (%q).\nThe results: %q", i+1, rURL, tc.Query, results) - } - } - } - } -} diff --git a/src/search/engines/_sedefaults/colly.go b/src/search/engines/_sedefaults/colly.go deleted file mode 100644 index 66b7adc2..00000000 --- a/src/search/engines/_sedefaults/colly.go +++ /dev/null @@ -1,80 +0,0 @@ -package _sedefaults - -import ( - "context" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/rs/zerolog/log" -) - -func colRequest(col *colly.Collector, ctx context.Context, seName engines.Name, saveOrigUrl bool) { - col.OnRequest(func(r *colly.Request) { - if err := ctx.Err(); err != nil { - if engines.IsTimeoutError(err) { - log.Trace(). - Caller(). - Err(err). - Str("engine", seName.String()). - Msg("Context timeout error") - } else { - log.Error(). - Caller(). - Err(err). - Str("engine", seName.String()). - Msg("Context error") - } - r.Abort() - return - } - if saveOrigUrl { - r.Ctx.Put("originalURL", r.URL.String()) - } - }) -} - -func colError(col *colly.Collector, seName engines.Name, visiting bool) { - col.OnError(func(r *colly.Response, err error) { - if engines.IsTimeoutError(err) { - log.Trace(). - // Err(err). // timeout error produces Get "url" error with the query - Str("engine", seName.String()). - // Str("url", urll). // can't reliably anonymize it (because it's engine dependent) - Msg("_sedefaults.colError(): request timeout error for url") - } else { - event := log.Error() - if visiting { - event = log.Trace() - } - event. - Caller(). - Err(err). - Str("engine", seName.String()). - // Str("url", urll). // can't reliably anonymize it (because it's engine dependent) - Int("statusCode", r.StatusCode). - Bytes("response", r.Body). // WARN: query can be present, depending on the response from the engine (example: google has the query in 3 places) - Msg("Request error for url") - } - }) -} - -func pagesColResponse(pagesCol *colly.Collector, seName engines.Name, relay *bucket.Relay) { - pagesCol.OnResponse(func(r *colly.Response) { - urll := r.Ctx.Get("originalURL") - if urll == "" { - log.Error(). - Caller(). - Msg("Error getting original url") - return - } - - err := bucket.SetResultResponse(urll, r, relay, seName) - if err != nil { - log.Error(). - Caller(). - Err(err). - Msg("Error setting result") - } - }) -} diff --git a/src/search/engines/_sedefaults/init.go b/src/search/engines/_sedefaults/init.go deleted file mode 100644 index c5e67636..00000000 --- a/src/search/engines/_sedefaults/init.go +++ /dev/null @@ -1,101 +0,0 @@ -package _sedefaults - -import ( - "context" - "fmt" - - "github.com/gocolly/colly/v2" - "github.com/gocolly/colly/v2/proxy" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/useragent" - "github.com/rs/zerolog/log" -) - -func InitializeCollectors(ctx context.Context, engineName engines.Name, options engines.Options, settings config.Settings, timings config.CategoryTimings, relay *bucket.Relay) (*colly.Collector, *colly.Collector) { - // get random user agent and corresponding Sec-Ch-Ua header - userAgent, secChUa := useragent.RandomUserAgentWithHeader() - - // create collectors - col := colly.NewCollector( - colly.Async(), - colly.MaxDepth(1), - colly.UserAgent(userAgent), - colly.IgnoreRobotsTxt(), - colly.Headers(map[string]string{ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", - // "Accept-Encoding": "gzip, deflate, br", // Chromium-based browsers have "zstd" but that isn't supported by Firefox nor Safari - "Accept-Language": "en-US,en;q=0.9", - "Sec-Ch-Ua": secChUa, // "Google Chrome";v="119", "Chromium";v="119", "Not=A?Brand";v="24" - "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": "\"Windows\"", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - }), - ) - pagesCol := colly.NewCollector( - colly.Async(), - colly.MaxDepth(1), - colly.UserAgent(userAgent), - colly.IgnoreRobotsTxt(), - colly.Headers(map[string]string{ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", - // "Accept-Encoding": "gzip, deflate, br", // Chromium-based browsers have "zstd" but that isn't supported by Firefox nor Safari - "Accept-Language": "en-US,en;q=0.9", - "Sec-Ch-Ua": secChUa, // "Google Chrome";v="119", "Chromium";v="119", "Not=A?Brand";v="24" - "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": "\"Windows\"", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - }), - ) - - // set collector limit rules - limitRule := colly.LimitRule{ - DomainGlob: "*", - Delay: timings.Delay, - RandomDelay: timings.RandomDelay, - Parallelism: timings.Parallelism, - } - if err := col.Limit(&limitRule); err != nil { - log.Error(). - Caller(). - Err(err). - Str("limitRule", fmt.Sprintf("%v", limitRule)). - Msg("Failed adding new limit rule") - } - - // set collector proxies - if settings.Proxies != nil { - log.Debug(). - Strs("proxies", settings.Proxies). - Msg("Using proxies") - - // rotate proxies - rp, err := proxy.RoundRobinProxySwitcher(settings.Proxies...) - if err != nil { - log.Fatal(). - Caller(). - Err(err). - Strs("proxies", settings.Proxies). - Msg("Failed creating proxy switcher") - } - - col.SetProxyFunc(rp) - pagesCol.SetProxyFunc(rp) - } - - // set up collector - colRequest(col, ctx, engineName, false) - colError(col, engineName, false) - - // set up pages collector - colRequest(pagesCol, ctx, engineName, true) - colError(pagesCol, engineName, true) - pagesColResponse(pagesCol, engineName, relay) - - return col, pagesCol -} diff --git a/src/search/engines/_sedefaults/pagesColly.go b/src/search/engines/_sedefaults/pagesColly.go deleted file mode 100644 index 15735917..00000000 --- a/src/search/engines/_sedefaults/pagesColly.go +++ /dev/null @@ -1 +0,0 @@ -package _sedefaults diff --git a/src/search/engines/_sedefaults/prepare.go b/src/search/engines/_sedefaults/prepare.go deleted file mode 100644 index c9c76f1a..00000000 --- a/src/search/engines/_sedefaults/prepare.go +++ /dev/null @@ -1,41 +0,0 @@ -package _sedefaults - -import ( - "context" - - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/rs/zerolog/log" -) - -func Prepare(ctx context.Context, info engines.Info, support engines.SupportedSettings, options engines.Options, settings config.Settings) (context.Context, error) { - // TODO: move to config initialization - if settings.RequestedResultsPerPage != 0 && !support.RequestedResultsPerPage { - log.Panic(). - Caller(). - Str("engine", info.Name.String()). - Int("requestedResultsPerPage", settings.RequestedResultsPerPage). - Msg("Setting not supported by engine") - // ^PANIC - } - - if options.Locale != "" && !support.Locale { - log.Debug(). - Str("engine", info.Name.String()). - Str("locale", options.Locale). - Msg("Setting not supported by engine") - } - - if options.SafeSearch && !support.SafeSearch { - log.Debug(). - Str("engine", info.Name.String()). - Bool("safeSearch", options.SafeSearch). - Msg("Setting not supported by engine") - } - - if ctx == nil { - return context.Background(), nil - } else { - return ctx, nil - } -} diff --git a/src/search/engines/_sedefaults/requests.go b/src/search/engines/_sedefaults/requests.go deleted file mode 100644 index 304f1a8c..00000000 --- a/src/search/engines/_sedefaults/requests.go +++ /dev/null @@ -1,39 +0,0 @@ -package _sedefaults - -import ( - "fmt" - "io" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/rs/zerolog/log" -) - -func DoGetRequest(urll string, anonurll string, colCtx *colly.Context, collector *colly.Collector, packageName engines.Name) error { - log.Trace(). - Str("engine", packageName.String()). - Str("url", anonurll). - Msg("GET") - - err := collector.Request("GET", urll, nil, colCtx, nil) - if err != nil { - return fmt.Errorf("%v.Search(): failed GET request to %v with %w", packageName.ToLower(), urll, err) - } - - return nil -} - -func DoPostRequest(urll string, requestData io.Reader, colCtx *colly.Context, collector *colly.Collector, packageName engines.Name) error { - log.Trace(). - Str("engine", packageName.String()). - // no body logging, so it's already anonymous - Str("url", urll). - Msg("POST") - - err := collector.Request("POST", urll, requestData, colCtx, nil) - if err != nil { - return fmt.Errorf("%v.Search(): failed POST request to %v and body %v. error %w", packageName.ToLower(), requestData, urll, err) - } - - return nil -} diff --git a/src/search/engines/bing/bing.go b/src/search/engines/bing/bing.go deleted file mode 100644 index a82d483a..00000000 --- a/src/search/engines/bing/bing.go +++ /dev/null @@ -1,119 +0,0 @@ -package bing - -import ( - "context" - "encoding/base64" - "net/url" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { - linkText, titleText, descText := _sedefaults.FieldsFromDOM(e.DOM, dompaths, Info.Name) // the telemetry link is a valid link so it can be sanitized - linkText = _sedefaults.SanitizeURL(removeTelemetry(linkText)) - - if descText == "" { - descText = e.DOM.Find("p.b_algoSlug").Text() - } - if strings.Contains(descText, "Web") { - descText = strings.Split(descText, "Web")[1] - } - descText = _sedefaults.SanitizeDescription(descText) - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // static params - localeParam := getLocale(options) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "" - // i == 0 is the first page - if i > 0 { - pageParam = "&first=" + strconv.Itoa(i*10+1) - } - - urll := Info.URL + query + pageParam + localeParam - anonUrll := Info.URL + anonymize.String(query) + pageParam + localeParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func removeTelemetry(link string) string { - if strings.HasPrefix(link, "https://www.bing.com/ck/a?") { - parsedUrl, err := url.Parse(link) - if err != nil { - log.Error(). - Caller(). - Err(err). - Str("url", link). - Msg("Error parsing url") - return "" - } - - // get the first value of u parameter and remove "a1" in front - encodedUrl := parsedUrl.Query().Get("u")[2:] - - cleanUrl, err := base64.RawURLEncoding.DecodeString(encodedUrl) - if err != nil { - log.Error(). - Caller(). - Err(err). - Msg("Failed decoding string from base64") - } - return string(cleanUrl) - } - return link -} - -func getLocale(options engines.Options) string { - spl := strings.SplitN(strings.ToLower(options.Locale), "_", 2) - return "&setlang=" + spl[0] + "&cc=" + spl[1] -} diff --git a/src/search/engines/bing/bing.md b/src/search/engines/bing/bing.md index e7831187..7b8b9a1d 100644 --- a/src/search/engines/bing/bing.md +++ b/src/search/engines/bing/bing.md @@ -8,7 +8,6 @@ https://www.bing.com/ck/a?!&&p=23fcb82b91411b05JmltdHM9MTY5MTEwNzIwMCZpZ3VpZD0xM goes to: https://www.internations.org/magazine/top-10-hobbies-you-ve-never-heard-of-39784 - Description fetching could be improved for complicated results. -`&setlang=en&cc=us` are the UI language and region parameters respectively. \ No newline at end of file +`&setlang=en&cc=us` are the UI language and region parameters respectively. diff --git a/src/search/engines/bing/bing_test.go b/src/search/engines/bing/bing_test.go deleted file mode 100644 index 3d57e6e1..00000000 --- a/src/search/engines/bing/bing_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package bing_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.BING - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/bing/dompaths.go b/src/search/engines/bing/dompaths.go new file mode 100644 index 00000000..12d28634 --- /dev/null +++ b/src/search/engines/bing/dompaths.go @@ -0,0 +1,12 @@ +package bing + +import ( + "github.com/hearchco/agent/src/search/scraper" +) + +var dompaths = scraper.DOMPaths{ + Result: "ol#b_results > li.b_algo", + URL: "h2 > a", + Title: "h2 > a", + Description: "div.b_caption", +} diff --git a/src/search/engines/bing/info.go b/src/search/engines/bing/info.go deleted file mode 100644 index 803bf892..00000000 --- a/src/search/engines/bing/info.go +++ /dev/null @@ -1,23 +0,0 @@ -package bing - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "www.bing.com", - Name: engines.BING, - URL: "https://www.bing.com/search?q=", - ResultsPerPage: 10, -} - -var dompaths = engines.DOMPaths{ - Result: "ol#b_results > li.b_algo", - Link: "h2 > a", - Title: "h2 > a", - Description: "div.b_caption", -} - -var Support = engines.SupportedSettings{ - Locale: true, -} diff --git a/src/search/engines/bing/infoparams.go b/src/search/engines/bing/infoparams.go new file mode 100644 index 00000000..01a2134c --- /dev/null +++ b/src/search/engines/bing/infoparams.go @@ -0,0 +1,20 @@ +package bing + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.BING, + Domain: "www.bing.com", + URL: "https://www.bing.com/search", + Origins: []engines.Name{engines.BING}, +} + +var params = scraper.Params{ + Page: "first", + Locale: "setlang", // Should be first 2 characters of Locale. + LocaleSec: "cc", // Should be last 2 characters of Locale. + SafeSearch: "", // Always enabled. +} diff --git a/src/search/engines/bing/params.go b/src/search/engines/bing/params.go new file mode 100644 index 00000000..bf143df3 --- /dev/null +++ b/src/search/engines/bing/params.go @@ -0,0 +1,13 @@ +package bing + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeParamString(locale options.Locale) string { + spl := strings.SplitN(strings.ToLower(locale.String()), "_", 2) + return fmt.Sprintf("%v=%v&%v=%v", params.Locale, spl[0], params.LocaleSec, spl[1]) +} diff --git a/src/search/engines/bing/search.go b/src/search/engines/bing/search.go new file mode 100644 index 00000000..abbc9842 --- /dev/null +++ b/src/search/engines/bing/search.go @@ -0,0 +1,112 @@ +package bing + +import ( + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + log.Trace(). + Caller(). + Msg("Matched result") + + urlText, titleText, descText := parse.FieldsFromDOM(e.DOM, dompaths, se.Name) // the telemetry link is a valid link so it can be sanitized + + urlWOTelemetry, err := removeTelemetry(urlText) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("url", urlText). + Msg("Failed to remove telemetry") + return + } + urlText = parse.SanitizeURL(urlWOTelemetry) + + if descText == "" { + descText = e.DOM.Find("p.b_algoSlug").Text() + } + descText = strings.TrimPrefix(descText, "WEB") + descText = parse.SanitizeDescription(descText) + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, pageRankCounter.GetPlusOne(pageIndex)) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } + } + }) + + // Static params. + localeParam := localeParamString(opts.Locale) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := "" + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v=%v", params.Page, pageNum0*10+1) + } + + combinedParams := morestrings.JoinNonEmpty([]string{pageParam, localeParam}, "&", "&") + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/bing/search_test.go b/src/search/engines/bing/search_test.go new file mode 100644 index 00000000..be434de1 --- /dev/null +++ b/src/search/engines/bing/search_test.go @@ -0,0 +1,41 @@ +package bing + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/bing/telemetry.go b/src/search/engines/bing/telemetry.go new file mode 100644 index 00000000..fa7a6d47 --- /dev/null +++ b/src/search/engines/bing/telemetry.go @@ -0,0 +1,29 @@ +package bing + +import ( + "encoding/base64" + "fmt" + "net/url" + "strings" +) + +func removeTelemetry(urll string) (string, error) { + if !strings.HasPrefix(urll, "https://www.bing.com/ck/a?") { + return urll, nil + } + + parsedUrl, err := url.Parse(urll) + if err != nil { + return "", fmt.Errorf("failed parsing URL: %w", err) + } + + // Get the first value of "u" parameter and remove "a1" from the beginning. + encodedUrl := parsedUrl.Query().Get("u")[2:] + + cleanUrl, err := base64.RawURLEncoding.DecodeString(encodedUrl) + if err != nil { + return "", fmt.Errorf("failed decoding base64: %w", err) + } + + return string(cleanUrl), nil +} diff --git a/src/search/engines/bingimages/bingimages_test.go b/src/search/engines/bingimages/bingimages_test.go deleted file mode 100644 index 0905e74a..00000000 --- a/src/search/engines/bingimages/bingimages_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package bingimages_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.BINGIMAGES - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "wikipedia logo", - ResultURL: []string{"upload.wikimedia.org"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "linux logo wikipedia", - ResultURL: []string{"logos-world.net"}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/bingimages/dom.go b/src/search/engines/bingimages/dompaths.go similarity index 100% rename from src/search/engines/bingimages/dom.go rename to src/search/engines/bingimages/dompaths.go diff --git a/src/search/engines/bingimages/infoparams.go b/src/search/engines/bingimages/infoparams.go new file mode 100644 index 00000000..fd3f52d1 --- /dev/null +++ b/src/search/engines/bingimages/infoparams.go @@ -0,0 +1,23 @@ +package bingimages + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.BINGIMAGES, + Domain: "www.bing.com", + URL: "https://www.bing.com/images/async", + Origins: []engines.Name{engines.BINGIMAGES}, +} + +var params = scraper.Params{ + Page: "first", + Locale: "setlang", // Should be first 2 characters of Locale. + LocaleSec: "cc", // Should be last 2 characters of Locale. + SafeSearch: "", // Always enabled. +} + +const asyncParam = "async=1" +const countParam = "count=35" diff --git a/src/search/engines/bingimages/json.go b/src/search/engines/bingimages/json.go index 3af5e86e..a8f2b154 100644 --- a/src/search/engines/bingimages/json.go +++ b/src/search/engines/bingimages/json.go @@ -1,6 +1,6 @@ package bingimages -type JsonMetadata struct { +type jsonMetadata struct { PageURL string `json:"purl"` ThumbnailURL string `json:"turl"` ImageURL string `json:"murl"` diff --git a/src/search/engines/bingimages/options.go b/src/search/engines/bingimages/options.go deleted file mode 100644 index ad2d8c46..00000000 --- a/src/search/engines/bingimages/options.go +++ /dev/null @@ -1,16 +0,0 @@ -package bingimages - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "www.bing.com", - Name: engines.BINGIMAGES, - URL: "https://www.bing.com/images/async?q=", - ResultsPerPage: 35, -} - -var Support = engines.SupportedSettings{ - Locale: true, -} diff --git a/src/search/engines/bingimages/params.go b/src/search/engines/bingimages/params.go new file mode 100644 index 00000000..885f1b45 --- /dev/null +++ b/src/search/engines/bingimages/params.go @@ -0,0 +1,13 @@ +package bingimages + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeParamString(locale options.Locale) string { + spl := strings.SplitN(strings.ToLower(locale.String()), "_", 2) + return fmt.Sprintf("%v=%v&%v=%v", params.Locale, spl[0], params.LocaleSec, spl[1]) +} diff --git a/src/search/engines/bingimages/bingimages.go b/src/search/engines/bingimages/search.go similarity index 57% rename from src/search/engines/bingimages/bingimages.go rename to src/search/engines/bingimages/search.go index 84177d9a..e838ea85 100644 --- a/src/search/engines/bingimages/bingimages.go +++ b/src/search/engines/bingimages/search.go @@ -1,44 +1,46 @@ package bingimages import ( - "context" "encoding/json" + "fmt" "strconv" "strings" + "sync/atomic" "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" "github.com/rs/zerolog/log" -) -type Engine struct{} + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) -func New() Engine { - return Engine{} +type Engine struct { + scraper.EngineBase } -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} - pageRankCounter := make([]int, options.Pages.Max) +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { dom := e.DOM - var jsonMetadata JsonMetadata + var jsonMetadata jsonMetadata metadataS, metadataExists := dom.Find(dompaths.Metadata.Path).Attr(dompaths.Metadata.Attr) if !metadataExists { log.Error(). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Msg("Matched result, but couldn't retrieve data") return } @@ -55,7 +57,7 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o if jsonMetadata.ImageURL == "" || jsonMetadata.PageURL == "" || jsonMetadata.ThumbnailURL == "" { log.Error(). Caller(). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("jsonMetadata", metadataS). Str("url", jsonMetadata.PageURL). Str("original", jsonMetadata.ImageURL). @@ -69,7 +71,7 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o // could also use the json data ("t" field), it seems to include weird/erroneous characters though (particularly '\ue000' and '\ue001') log.Error(). Caller(). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("jsonMetadata", metadataS). Msg("Couldn't find title") return @@ -80,7 +82,7 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o if imgFormatS == "" { log.Trace(). Caller(). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("jsonMetadata", metadataS). Str("title", titleText). Msg("Couldn't find image format (probably a video)") @@ -94,12 +96,12 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o // create height and width imgFormat := strings.Split(imgFormatS, "x") - imgH, err := strconv.Atoi(imgFormat[0]) + origH, err := strconv.Atoi(imgFormat[0]) if err != nil { log.Error(). Caller(). Err(err). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("height", imgFormat[0]). Str("jsonMetadata", metadataS). Str("title", titleText). @@ -108,12 +110,12 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o return } - imgW, err := strconv.Atoi(imgFormat[1]) + origW, err := strconv.Atoi(imgFormat[1]) if err != nil { log.Error(). Caller(). Err(err). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("width", imgFormat[1]). Str("jsonMetadata", metadataS). Str("title", titleText). @@ -137,7 +139,7 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o if !found { log.Error(). Caller(). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("jsonMetadata", metadataS). Str("title", titleText). Str("height", thmbHS). @@ -151,7 +153,7 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o log.Error(). Caller(). Err(err). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("height", thmbHS). Str("jsonMetadata", metadataS). Str("title", titleText). @@ -164,7 +166,7 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o log.Error(). Caller(). Err(err). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("width", thmbWS). Str("jsonMetadata", metadataS). Str("title", titleText). @@ -176,70 +178,71 @@ func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, o if source == "" { log.Error(). Caller(). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Str("jsonMetadata", metadataS). Str("title", titleText). Msg("Couldn't find source") return } - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 - res := bucket.MakeSEImageResult( - jsonMetadata.ImageURL, titleText, jsonMetadata.Desc, - source, jsonMetadata.PageURL, jsonMetadata.ThumbnailURL, - imgH, imgW, thmbH, thmbW, - Info.Name, page, pageRankCounter[pageIndex]+1, - salt, + r, err := result.ConstructImagesResult( + se.Name, jsonMetadata.ImageURL, titleText, jsonMetadata.Desc, page, pageRankCounter.GetPlusOne(pageIndex), + origH, origW, thmbH, thmbW, jsonMetadata.ThumbnailURL, source, jsonMetadata.PageURL, ) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } } }) - col.OnResponse(func(r *colly.Response) { + se.OnResponse(func(r *colly.Response) { if len(r.Body) == 0 { log.Error(). - Str("engine", Info.Name.String()). + Str("engine", se.Name.String()). Msg("Got empty response, probably too many requests") } }) - retErrors := make([]error, 0, options.Pages.Max) + // Static params. + localeParam := localeParamString(opts.Locale) - // static params - localeParam := getLocale(options) + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) + // Dynamic params. + pageParam := fmt.Sprintf("%v=%v", params.Page, pageNum0*35+1) - // dynamic params - pageParam := "&first=1" - // i == 0 is the first page - if i > 0 { - pageParam = "&first=" + strconv.Itoa((i+1)*10) - } + combinedParams := morestrings.JoinNonEmpty([]string{asyncParam, pageParam, countParam, localeParam}, "&", "&") - urll := Info.URL + query + "&async=1" + pageParam + "&count=35" + localeParam - anonUrll := Info.URL + anonymize.String(query) + "&async=1" + pageParam + "&count=35" + localeParam + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { + if err := se.Get(ctx, urll, anonUrll); err != nil { retErrors = append(retErrors, err) } } - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getLocale(options engines.Options) string { - spl := strings.SplitN(strings.ToLower(options.Locale), "_", 2) - return "&setlang=" + spl[0] + "&cc=" + spl[1] + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() } diff --git a/src/search/engines/bingimages/search_test.go b/src/search/engines/bingimages/search_test.go new file mode 100644 index 00000000..5e595c18 --- /dev/null +++ b/src/search/engines/bingimages/search_test.go @@ -0,0 +1,41 @@ +package bingimages + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "wikipedia logo", + ResultURLs: []string{"upload.wikimedia.org"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "linux logo wikipedia", + ResultURLs: []string{"logos-world.net"}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar[:], tccr[:], tcrr[:]) +} diff --git a/src/search/engines/brave/brave.go b/src/search/engines/brave/brave.go deleted file mode 100644 index 687f6605..00000000 --- a/src/search/engines/brave/brave.go +++ /dev/null @@ -1,100 +0,0 @@ -package brave - -import ( - "context" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - localeCookie := getLocale(options) - safeSearchCookie := getSafeSearch(options) - - col.OnRequest(func(r *colly.Request) { - r.Headers.Add("Cookie", localeCookie) - r.Headers.Add("Cookie", safeSearchCookie) - }) - - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { - linkText, titleText, descText := _sedefaults.FieldsFromDOM(e.DOM, dompaths, Info.Name) - - if descText == "" { - descText = e.DOM.Find("div.product > div.flex-hcenter > div > div[class=\"text-sm text-gray\"]").Text() - } - if descText == "" { - descText = e.DOM.Find("p.snippet-description").Text() - } - descText = _sedefaults.SanitizeDescription(descText) - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "&source=web" - // i == 0 is the first page - if i > 0 { - pageParam = "&spellcheck=0&offset=" + strconv.Itoa(i) - } - - urll := Info.URL + query + pageParam - anonUrll := Info.URL + anonymize.String(query) + pageParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getLocale(options engines.Options) string { - region := strings.SplitN(strings.ToLower(options.Locale), "_", 2)[1] - return "country=" + region -} - -func getSafeSearch(options engines.Options) string { - if options.SafeSearch { - return "safesearch=strict" - } - return "safesearch=off" -} diff --git a/src/search/engines/brave/brave_test.go b/src/search/engines/brave/brave_test.go deleted file mode 100644 index 569d63d1..00000000 --- a/src/search/engines/brave/brave_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package brave_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.BRAVE - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/brave/cookies.go b/src/search/engines/brave/cookies.go new file mode 100644 index 00000000..da2bc510 --- /dev/null +++ b/src/search/engines/brave/cookies.go @@ -0,0 +1,21 @@ +package brave + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeCookieString(locale options.Locale) string { + region := strings.SplitN(strings.ToLower(locale.String()), "_", 2)[1] + return fmt.Sprintf("%v=%v", params.Locale, region) +} + +func safeSearchCookieString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "strict") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "off") + } +} diff --git a/src/search/engines/brave/dompaths.go b/src/search/engines/brave/dompaths.go new file mode 100644 index 00000000..b6878157 --- /dev/null +++ b/src/search/engines/brave/dompaths.go @@ -0,0 +1,12 @@ +package brave + +import ( + "github.com/hearchco/agent/src/search/scraper" +) + +var dompaths = scraper.DOMPaths{ + Result: "div.snippet[data-type=\"web\"]", + URL: "a", + Title: "div.title", + Description: "div.snippet-description", +} diff --git a/src/search/engines/brave/infoparams.go b/src/search/engines/brave/infoparams.go new file mode 100644 index 00000000..018b9bca --- /dev/null +++ b/src/search/engines/brave/infoparams.go @@ -0,0 +1,22 @@ +package brave + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.BRAVE, + Domain: "search.brave.com", + URL: "https://search.brave.com/search", + Origins: []engines.Name{engines.BRAVE, engines.GOOGLE}, +} + +var params = scraper.Params{ + Page: "offset", + Locale: "country", // Should be last 2 characters of Locale. + SafeSearch: "safesearch", // Can be "off" or "strict". +} + +const sourceParam = "source=web" +const spellcheckParam = "spellcheck=0" diff --git a/src/search/engines/brave/options.go b/src/search/engines/brave/options.go deleted file mode 100644 index 93a5aa9d..00000000 --- a/src/search/engines/brave/options.go +++ /dev/null @@ -1,24 +0,0 @@ -package brave - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "search.brave.com", - Name: engines.BRAVE, - URL: "https://search.brave.com/search?q=", - ResultsPerPage: 20, -} - -var dompaths = engines.DOMPaths{ - Result: "div.snippet[data-type=\"web\"]", - Link: "a", - Title: "div.title", - Description: "div.snippet-description", -} - -var Support = engines.SupportedSettings{ - Locale: true, - SafeSearch: true, -} diff --git a/src/search/engines/brave/search.go b/src/search/engines/brave/search.go new file mode 100644 index 00000000..22c59e78 --- /dev/null +++ b/src/search/engines/brave/search.go @@ -0,0 +1,101 @@ +package brave + +import ( + "fmt" + "strconv" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnRequest(func(r *colly.Request) { + r.Headers.Set("Accept-Encoding", "gzip, deflate") // Brotli lib used has some issues with Brave's brotli compression. + r.Headers.Add("Cookie", localeCookieString(opts.Locale)) + r.Headers.Add("Cookie", safeSearchCookieString(opts.SafeSearch)) + }) + + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + urlText, titleText, descText := parse.FieldsFromDOM(e.DOM, dompaths, se.Name) + + if descText == "" { + descText = e.DOM.Find("div.product > div.flex-hcenter > div > div[class=\"text-sm text-gray\"]").Text() + } + if descText == "" { + descText = e.DOM.Find("p.snippet-description").Text() + } + descText = parse.SanitizeDescription(descText) + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, pageRankCounter.GetPlusOne(pageIndex)) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } + } + }) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := "" + combinedParams := morestrings.JoinNonEmpty([]string{sourceParam, pageParam}, "&", "&") + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v=%v", params.Page, pageNum0) + combinedParams = morestrings.JoinNonEmpty([]string{spellcheckParam, pageParam}, "&", "&") + } + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/brave/search_test.go b/src/search/engines/brave/search_test.go new file mode 100644 index 00000000..422add79 --- /dev/null +++ b/src/search/engines/brave/search_test.go @@ -0,0 +1,41 @@ +package brave + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/duckduckgo/cookies.go b/src/search/engines/duckduckgo/cookies.go new file mode 100644 index 00000000..f2daad25 --- /dev/null +++ b/src/search/engines/duckduckgo/cookies.go @@ -0,0 +1,13 @@ +package duckduckgo + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeCookieString(locale options.Locale) string { + spl := strings.SplitN(strings.ToLower(locale.String()), "_", 2) + return fmt.Sprintf("%v=%v-%v", params.Locale, spl[1], spl[0]) +} diff --git a/src/search/engines/duckduckgo/ddg.md b/src/search/engines/duckduckgo/ddg.md index aaed1882..2fd73f58 100644 --- a/src/search/engines/duckduckgo/ddg.md +++ b/src/search/engines/duckduckgo/ddg.md @@ -1,8 +1,9 @@ -# Duck Duck Go +# DuckDuckGo + Send a [POST request](https://github.com/gocolly/colly/issues/175#issuecomment-400024313) to `https://lite.duckduckgo.com/lite/` with body: `q=&dc=`. It will return 20-22 results. GET requests could be used like `https://lite.duckduckgo.com/lite/?q=&dc=`. First request could be: col.PostRaw(Info.URL, []byte("q="+query+"&dc=1")) This may be useful: http://api.jquery.com/index/ -The href on the title sometimes contains telemetry, and is not a valid URL then. That's why we fetch the scheme from it, and append it to the span text. \ No newline at end of file +The href on the title sometimes contains telemetry, and is not a valid URL then. That's why we fetch the scheme from it, and append it to the span text. diff --git a/src/search/engines/duckduckgo/dompaths.go b/src/search/engines/duckduckgo/dompaths.go new file mode 100644 index 00000000..e63973ef --- /dev/null +++ b/src/search/engines/duckduckgo/dompaths.go @@ -0,0 +1,12 @@ +package duckduckgo + +import ( + "github.com/hearchco/agent/src/search/scraper" +) + +var dompaths = scraper.DOMPaths{ + ResultsContainer: "div.filters > table > tbody", + URL: "td > a.result-link", + Title: "td > a.result-link", + Description: "td.result-snippet", +} diff --git a/src/search/engines/duckduckgo/duckduckgo.go b/src/search/engines/duckduckgo/duckduckgo.go deleted file mode 100644 index 31c624ae..00000000 --- a/src/search/engines/duckduckgo/duckduckgo.go +++ /dev/null @@ -1,112 +0,0 @@ -package duckduckgo - -import ( - "context" - "strconv" - "strings" - - "github.com/PuerkitoBio/goquery" - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - localeCookie := getLocale(options) - - col.OnRequest(func(r *colly.Request) { - r.Headers.Add("Cookie", localeCookie) - }) - - col.OnHTML(dompaths.ResultsContainer, func(e *colly.HTMLElement) { - var linkText, linkScheme, titleText, descText string - var hrefExists bool - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - e.DOM.Children().Each(func(i int, row *goquery.Selection) { - switch i % 4 { - case 0: - var linkHref string - linkHref, hrefExists = row.Find(dompaths.Link).Attr("href") - if strings.Contains(linkHref, "https") { - linkScheme = "https://" - } else { - linkScheme = "http://" - } - titleText = _sedefaults.SanitizeTitle(row.Find(dompaths.Title).Text()) - case 1: - descText = _sedefaults.SanitizeDescription(row.Find(dompaths.Description).Text()) - case 2: - rawURL := linkScheme + row.Find("td > span.link-text").Text() - linkText = _sedefaults.SanitizeURL(rawURL) - case 3: - if !hrefExists { - log.Error(). - Caller(). - Str("engine", Info.Name.String()). - Str("url", linkText). - Str("title", titleText). - Str("description", descText). - Str("link selector", dompaths.Link). - Msg("Href attribute doesn't exist on matched URL element") - return - } - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, (i/4 + 1)) - bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - } - }) - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - var err error - // i == 0 is the first page - if i == 0 { - urll := Info.URL + "?q=" + query - anonUrll := Info.URL + "?q=" + anonymize.String(query) - err = _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - } else { - requestData := strings.NewReader("q=" + query + "&dc=" + strconv.Itoa(i*20)) - err = _sedefaults.DoPostRequest(Info.URL, requestData, colCtx, col, Info.Name) - } - - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getLocale(options engines.Options) string { - spl := strings.SplitN(strings.ToLower(options.Locale), "_", 2) - return "kl=" + spl[1] + "-" + spl[0] -} diff --git a/src/search/engines/duckduckgo/duckduckgo_test.go b/src/search/engines/duckduckgo/duckduckgo_test.go deleted file mode 100644 index ddaec80a..00000000 --- a/src/search/engines/duckduckgo/duckduckgo_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package duckduckgo_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.DUCKDUCKGO - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/duckduckgo/infoparams.go b/src/search/engines/duckduckgo/infoparams.go new file mode 100644 index 00000000..d6158f2b --- /dev/null +++ b/src/search/engines/duckduckgo/infoparams.go @@ -0,0 +1,19 @@ +package duckduckgo + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.DUCKDUCKGO, + Domain: "lite.duckduckgo.com", + URL: "https://lite.duckduckgo.com/lite/", + Origins: []engines.Name{engines.DUCKDUCKGO, engines.BING}, +} + +var params = scraper.Params{ + Page: "dc", + Locale: "kl", // Should be Locale with _ replaced by - and first 2 letters as last and vice versa. + SafeSearch: "", // Always enabled. +} diff --git a/src/search/engines/duckduckgo/options.go b/src/search/engines/duckduckgo/options.go deleted file mode 100644 index 0ae6d424..00000000 --- a/src/search/engines/duckduckgo/options.go +++ /dev/null @@ -1,23 +0,0 @@ -package duckduckgo - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "lite.duckduckgo.com", - Name: engines.DUCKDUCKGO, - URL: "https://lite.duckduckgo.com/lite/", - ResultsPerPage: 10, -} - -var dompaths = engines.DOMPaths{ - ResultsContainer: "div.filters > table > tbody", - Link: "td > a.result-link", - Title: "td > a.result-link", - Description: "td.result-snippet", -} - -var Support = engines.SupportedSettings{ - Locale: true, -} diff --git a/src/search/engines/duckduckgo/search.go b/src/search/engines/duckduckgo/search.go new file mode 100644 index 00000000..ce544340 --- /dev/null +++ b/src/search/engines/duckduckgo/search.go @@ -0,0 +1,127 @@ +package duckduckgo + +import ( + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/PuerkitoBio/goquery" + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + + se.OnRequest(func(r *colly.Request) { + r.Headers.Add("Cookie", localeCookieString(opts.Locale)) + }) + + se.OnHTML(dompaths.ResultsContainer, func(e *colly.HTMLElement) { + log.Trace(). + Caller(). + Msg("Matched results container") + + var urlText, linkScheme, titleText, descText string + var hrefExists bool + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + e.DOM.Children().Each(func(i int, row *goquery.Selection) { + switch i % 4 { + case 0: + var urlHref string + urlHref, hrefExists = row.Find(dompaths.URL).Attr("href") + if strings.Contains(urlHref, "https") { + linkScheme = "https://" + } else { + linkScheme = "http://" + } + titleText = parse.SanitizeTitle(row.Find(dompaths.Title).Text()) + case 1: + descText = parse.SanitizeDescription(row.Find(dompaths.Description).Text()) + case 2: + rawURL := linkScheme + row.Find("td > span.link-text").Text() + urlText = parse.SanitizeURL(rawURL) + case 3: + if !hrefExists { + log.Error(). + Caller(). + Str("engine", se.Name.String()). + Str("url", urlText). + Str("title", titleText). + Str("description", descText). + Str("link selector", dompaths.URL). + Msg("Href attribute doesn't exist on matched URL element") + return + } + + onPageRank := (i/4 + 1) + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, onPageRank) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", onPageRank). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + } + } + }) + }) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + var err error + if pageNum0 == 0 { + urll := fmt.Sprintf("%v?q=%v", info.URL, query) + anonUrll := fmt.Sprintf("%v?q=%v", info.URL, anonymize.String(query)) + err = se.Get(ctx, urll, anonUrll) + } else { + // This value changes depending on how many results were returned on the first page, so it's set to the lowest seen value. + pageParam := fmt.Sprintf("%v=%v", params.Page, pageNum0*20) + body := strings.NewReader(fmt.Sprintf("q=%v&%v", query, pageParam)) + anonBody := fmt.Sprintf("q=%v&%v", anonymize.String(query), pageParam) + err = se.Post(ctx, info.URL, body, anonBody) + } + + if err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/duckduckgo/search_test.go b/src/search/engines/duckduckgo/search_test.go new file mode 100644 index 00000000..8b559bf3 --- /dev/null +++ b/src/search/engines/duckduckgo/search_test.go @@ -0,0 +1,41 @@ +package duckduckgo + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/etools/captcha.png b/src/search/engines/etools/captcha.png deleted file mode 100644 index 1b1c650f8f61c6149811038fa9548b430441ea94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 61229 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 tbody > tr", + URL: "td.record > a", + Title: "td.record > a", + Description: "td.record > div.text", +} diff --git a/src/search/engines/etools/etools.go b/src/search/engines/etools/etools.go deleted file mode 100644 index a56025e9..00000000 --- a/src/search/engines/etools/etools.go +++ /dev/null @@ -1,108 +0,0 @@ -package etools - -import ( - "context" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { - linkText, titleText, descText := _sedefaults.RawFieldsFromDOM(e.DOM, dompaths, Info.Name) // telemetry url isnt valid link so cant pass it to FieldsFromDOM (?) - - // Need to perform this check here so the check below (linkText[0] != 'h') doesn't panic - if linkText == "" { - log.Error(). - Caller(). - Str("title", titleText). - Str("description", descText). - Msg("Invalid result, url is empty") - return - } - - if linkText[0] != 'h' { - //telemetry link, e.g. //web.search.ch/r/redirect?event=website&origin=result!u377d618861533351/https://de.wikipedia.org/wiki/Charles_Paul_Wilp - linkText = "http" + strings.Split(linkText, "http")[1] //works for https, dont worry - } - - linkText, titleText, descText = _sedefaults.SanitizeFields(linkText, titleText, descText) - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - }) - - col.OnResponse(func(r *colly.Response) { - if strings.Contains(string(r.Body), "Sorry for the CAPTCHA") { - log.Error(). - Str("engine", Info.Name.String()). - Msg("Returned CAPTCHA") - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // static params - safeSearchParam := getSafeSearch(options) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - pageStr := strconv.Itoa(i - options.Pages.Start) - colCtx := colly.NewContext() - colCtx.Put("page", pageStr) - - var err error - // i == 0 is the first page - if i == 0 { - requestData := strings.NewReader("query=" + query + "&country=web&language=all" + safeSearchParam) - err = _sedefaults.DoPostRequest(Info.URL, requestData, colCtx, col, Info.Name) - col.Wait() // col.Wait() is needed to save the JSESSION cookie - } else { - // query is not needed as it's saved in the JSESSION cookie - err = _sedefaults.DoGetRequest(pageURL+pageStr, pageURL+pageStr, colCtx, col, Info.Name) - } - - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getSafeSearch(options engines.Options) string { - if options.SafeSearch { - return "&safeSearch=true" - } - return "&safeSearch=false" -} diff --git a/src/search/engines/etools/etools_test.go b/src/search/engines/etools/etools_test.go deleted file mode 100644 index eb8b004e..00000000 --- a/src/search/engines/etools/etools_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package etools_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.ETOOLS - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/etools/infoparams.go b/src/search/engines/etools/infoparams.go new file mode 100644 index 00000000..222ff5df --- /dev/null +++ b/src/search/engines/etools/infoparams.go @@ -0,0 +1,23 @@ +package etools + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.ETOOLS, + Domain: "www.etools.ch", + URL: "https://www.etools.ch/searchSubmit.do", + Origins: []engines.Name{engines.ETOOLS}, // Disabled because ETOOLS has issues most of the time: []engines.Name{engines.BING, engines.BRAVE, engines.DUCKDUCKGO, engines.GOOGLE, engines.MOJEEK, engines.QWANT, engines.YAHOO}, +} + +const pageURL = "https://www.etools.ch/search.do" + +var params = scraper.Params{ + Page: "page", + SafeSearch: "safeSearch", // Can be "true" or "false". +} + +const countryParam = "country=web" +const languageParam = "language=all" diff --git a/src/search/engines/etools/options.go b/src/search/engines/etools/options.go deleted file mode 100644 index fd252503..00000000 --- a/src/search/engines/etools/options.go +++ /dev/null @@ -1,25 +0,0 @@ -package etools - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -const pageURL string = "https://www.etools.ch/search.do?page=" - -var Info = engines.Info{ - Domain: "www.etools.ch", - Name: engines.ETOOLS, - URL: "https://www.etools.ch/searchSubmit.do", - ResultsPerPage: 10, -} - -var dompaths = engines.DOMPaths{ - Result: "table.result > tbody > tr", - Link: "td.record > a", - Title: "td.record > a", - Description: "td.record > div.text", -} - -var Support = engines.SupportedSettings{ - SafeSearch: true, -} diff --git a/src/search/engines/etools/params.go b/src/search/engines/etools/params.go new file mode 100644 index 00000000..0681f964 --- /dev/null +++ b/src/search/engines/etools/params.go @@ -0,0 +1,13 @@ +package etools + +import ( + "fmt" +) + +func safeSearchParamString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "true") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "false") + } +} diff --git a/src/search/engines/etools/search.go b/src/search/engines/etools/search.go new file mode 100644 index 00000000..48c647e2 --- /dev/null +++ b/src/search/engines/etools/search.go @@ -0,0 +1,141 @@ +package etools + +import ( + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + // Ignore the first request if it's not the first page (see below). + ignoreS := e.Request.Ctx.Get("ignore") + if ignoreS == strconv.FormatBool(true) { + return + } + + urlText, titleText, descText := parse.FieldsFromDOM(e.DOM, dompaths, se.Name) + + // Need to perform this check here so the check below doesn't panic. + if urlText == "" { + log.Error(). + Caller(). + Str("title", titleText). + Str("description", descText). + Msg("Invalid result, url is empty") + return + } + + // Telemetry link, e.g. //web.search.ch/r/redirect?event=website&origin=result!u377d618861533351/https://de.wikipedia.org/wiki/Charles_Paul_Wilp. + if urlText[0] != 'h' { + urlText = "http" + strings.Split(urlText, "http")[1] // Works for https as well. + } + + urlText, titleText, descText = parse.SanitizeFields(urlText, titleText, descText) + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, pageRankCounter.GetPlusOne(pageIndex)) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } + } + }) + + se.OnResponse(func(r *colly.Response) { + if strings.Contains(string(r.Body), "Sorry for the CAPTCHA") { + log.Error(). + Caller(). + Msg("Captcha detected") + } + }) + + firstRequest := true + + // Static params. + safeSearchParam := safeSearchParamString(opts.SafeSearch) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + var err error + // eTools requires a request for the first page + if pageNum0 == 0 || firstRequest { + combinedParams := morestrings.JoinNonEmpty([]string{countryParam, languageParam, safeSearchParam}, "&", "&") + + body := strings.NewReader(fmt.Sprintf("query=%v%v", query, combinedParams)) + anonBody := fmt.Sprintf("query=%v%v", anonymize.String(query), combinedParams) + + if firstRequest { + firstCtx := colly.NewContext() + firstCtx.Put("ignore", strconv.FormatBool(true)) + err = se.Post(firstCtx, info.URL, body, anonBody) + } else { + err = se.Post(ctx, info.URL, body, anonBody) + } + + firstRequest = false + se.Wait() // Needed to save the JSESSION cookie + } + + // Since the above can happen for the first request and then we need to request the wanted page. + if pageNum0 > 0 { + // Query isn't needed as it's saved in the JSESSION cookie + pageParam := fmt.Sprintf("%v=%v", params.Page, pageNum0+1) + urll := fmt.Sprintf("%v?%v", pageURL, pageParam) + err = se.Get(ctx, urll, urll) + } + + if err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/etools/search_test.go b/src/search/engines/etools/search_test.go new file mode 100644 index 00000000..974c37dc --- /dev/null +++ b/src/search/engines/etools/search_test.go @@ -0,0 +1,41 @@ +package etools + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/google/dompaths.go b/src/search/engines/google/dompaths.go new file mode 100644 index 00000000..3d798934 --- /dev/null +++ b/src/search/engines/google/dompaths.go @@ -0,0 +1,12 @@ +package google + +import ( + "github.com/hearchco/agent/src/search/scraper" +) + +var dompaths = scraper.DOMPaths{ + Result: "div.g", + URL: "a", + Title: "a > h3", + Description: "div > span", +} diff --git a/src/search/engines/google/google.go b/src/search/engines/google/google.go deleted file mode 100644 index 7a513341..00000000 --- a/src/search/engines/google/google.go +++ /dev/null @@ -1,71 +0,0 @@ -package google - -import ( - "context" - "strconv" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { - linkText, titleText, descText := _sedefaults.FieldsFromDOM(e.DOM, dompaths, Info.Name) - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "" - // i == 0 is the first page - if i > 0 { - pageParam = "&start=" + strconv.Itoa(i*10) - } - - urll := Info.URL + query + pageParam - anonUrll := Info.URL + anonymize.String(query) + pageParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} diff --git a/src/search/engines/google/google_test.go b/src/search/engines/google/google_test.go deleted file mode 100644 index 35cd0432..00000000 --- a/src/search/engines/google/google_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package google_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.GOOGLE - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/google/infoparams.go b/src/search/engines/google/infoparams.go new file mode 100644 index 00000000..e0db00f2 --- /dev/null +++ b/src/search/engines/google/infoparams.go @@ -0,0 +1,22 @@ +package google + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.GOOGLE, + Domain: "www.google.com", + URL: "https://www.google.com/search", + Origins: []engines.Name{engines.GOOGLE}, +} + +var params = scraper.Params{ + Page: "start", + Locale: "hl", // Should be first 2 characters of Locale. + LocaleSec: "lr", // Should be first 2 characters of Locale with prefixed "lang_". + SafeSearch: "safe", // Can be "off", "medium or "high". +} + +const filterParam = "filter=0" diff --git a/src/search/engines/google/options.go b/src/search/engines/google/options.go deleted file mode 100644 index 32e9c954..00000000 --- a/src/search/engines/google/options.go +++ /dev/null @@ -1,21 +0,0 @@ -package google - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "www.google.com", - Name: engines.GOOGLE, - URL: "https://www.google.com/search?q=", - ResultsPerPage: 10, -} - -var dompaths = engines.DOMPaths{ - Result: "div.g", - Link: "a", - Title: "a > h3", - Description: "div > span", -} - -var Support = engines.SupportedSettings{} diff --git a/src/search/engines/google/params.go b/src/search/engines/google/params.go new file mode 100644 index 00000000..762b1a11 --- /dev/null +++ b/src/search/engines/google/params.go @@ -0,0 +1,21 @@ +package google + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeParamString(locale options.Locale) string { + lang := strings.SplitN(strings.ToLower(locale.String()), "_", 2)[0] + return fmt.Sprintf("%v=%v&%v=lang_%v", params.Locale, lang, params.LocaleSec, lang) +} + +func safeSearchParamString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "high") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "off") + } +} diff --git a/src/search/engines/google/search.go b/src/search/engines/google/search.go new file mode 100644 index 00000000..67d88d02 --- /dev/null +++ b/src/search/engines/google/search.go @@ -0,0 +1,91 @@ +package google + +import ( + "fmt" + "strconv" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + urlText, titleText, descText := parse.FieldsFromDOM(e.DOM, dompaths, se.Name) + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, pageRankCounter.GetPlusOne(pageIndex)) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } + } + }) + + // Static params. + localeParam := localeParamString(opts.Locale) + safeSearchParam := safeSearchParamString(opts.SafeSearch) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := "" + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v=%v", params.Page, pageNum0*10) + } + + combinedParams := morestrings.JoinNonEmpty([]string{filterParam, pageParam, localeParam, safeSearchParam}, "&", "&") + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/google/search_test.go b/src/search/engines/google/search_test.go new file mode 100644 index 00000000..af3288cb --- /dev/null +++ b/src/search/engines/google/search_test.go @@ -0,0 +1,41 @@ +package google + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/googleimages/googleimages.go b/src/search/engines/googleimages/googleimages.go deleted file mode 100644 index e4ace963..00000000 --- a/src/search/engines/googleimages/googleimages.go +++ /dev/null @@ -1,129 +0,0 @@ -package googleimages - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - // disable User Agent since Google Images responds with fake data if UA is correct - col.UserAgent = "" - - pageRankCounter := make([]int, options.Pages.Max) - - col.OnResponse(func(e *colly.Response) { - body := string(e.Body) - index := strings.Index(body, "{\"ischj\":") - - if index == -1 { - log.Error(). - Caller(). - Str("engine", Info.Name.String()). - Str("body", body). - Msg("Failed parsing response, couldn't find the start of JSON") - return - } - - body = body[index:] - var jsonResponse JsonResponse - if err := json.Unmarshal([]byte(body), &jsonResponse); err != nil { - log.Error(). - Caller(). - Err(err). - Str("engine", Info.Name.String()). - Str("body", body). - Msg("Failed parsing response, couldn't unmarshal JSON") - return - } - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - for _, metadata := range jsonResponse.ISCHJ.Metadata { - origImg := metadata.OriginalImage - thmbImg := metadata.Thumbnail - resultJson := metadata.Result - textInGridJson := metadata.TextInGrid - - // google images sometimes inverts original height and width - if (thmbImg.Height > thmbImg.Width) != (origImg.Height > origImg.Width) { - origImg.Height, origImg.Width = origImg.Width, origImg.Height - } - - if resultJson.ReferrerUrl != "" && origImg.Url != "" && thmbImg.Url != "" { - res := bucket.MakeSEImageResult( - origImg.Url, resultJson.PageTitle, textInGridJson.Snippet, - resultJson.SiteTitle, resultJson.ReferrerUrl, thmbImg.Url, - origImg.Height, origImg.Width, thmbImg.Height, thmbImg.Width, - Info.Name, page, pageRankCounter[pageIndex]+1, - salt, - ) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - } else { - log.Error(). - Caller(). - Str("engine", Info.Name.String()). - Str("jsonMetadata", fmt.Sprintf("%v", metadata)). - Str("url", resultJson.ReferrerUrl). - Str("original", origImg.Url). - Str("thumbnail", thmbImg.Url). - Msg("Couldn't find image URL") - } - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "&tbm=isch&asearch=isch&async=_fmt:json,p:1,ijn:1" - // i == 0 is the first page - if i > 0 { - pageParam = "&tbm=isch&asearch=isch&async=_fmt:json,p:1,ijn:" + strconv.Itoa(i*10) - } - - urll := Info.URL + query + pageParam - anonUrll := Info.URL + anonymize.String(query) + pageParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} diff --git a/src/search/engines/googleimages/googleimages_test.go b/src/search/engines/googleimages/googleimages_test.go deleted file mode 100644 index 40ff8ec9..00000000 --- a/src/search/engines/googleimages/googleimages_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package googleimages_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.GOOGLEIMAGES - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "wikipedia logo", - ResultURL: []string{"upload.wikimedia.org"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "linux logo wikipedia", - ResultURL: []string{"upload.wikimedia.org"}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/googleimages/infoparams.go b/src/search/engines/googleimages/infoparams.go new file mode 100644 index 00000000..00ff658c --- /dev/null +++ b/src/search/engines/googleimages/infoparams.go @@ -0,0 +1,24 @@ +package googleimages + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.GOOGLEIMAGES, + Domain: "images.google.com", + URL: "https://www.google.com/search", + Origins: []engines.Name{engines.GOOGLEIMAGES}, +} + +var params = scraper.Params{ + Page: "async=_fmt:json,p:1,ijn", + Locale: "hl", // Should be first 2 characters of Locale. + LocaleSec: "lr", // Should be first 2 characters of Locale with prefixed "lang_". + SafeSearch: "safe", // Can be "off", "medium or "high". +} + +const tbmParam = "tbm=isch" +const asearchParam = "asearch=isch" +const filterParam = "filter=0" diff --git a/src/search/engines/googleimages/json.go b/src/search/engines/googleimages/json.go index 9f243fa1..b8b7ec84 100644 --- a/src/search/engines/googleimages/json.go +++ b/src/search/engines/googleimages/json.go @@ -1,32 +1,32 @@ package googleimages -type Result struct { +type jsonResponse struct { + ISCHJ ischj `json:"ischj"` +} + +type ischj struct { + Metadata []metadata `json:"metadata"` +} + +type metadata struct { + Result jsonResult `json:"result"` + TextInGrid textInGrid `json:"text_in_grid"` + OriginalImage image `json:"original_image"` + Thumbnail image `json:"thumbnail"` +} + +type jsonResult struct { ReferrerUrl string `json:"referrer_url"` PageTitle string `json:"page_title"` SiteTitle string `json:"site_title"` } -type TextInGrid struct { +type textInGrid struct { Snippet string `json:"snippet"` } -type Image struct { +type image struct { Url string `json:"url"` Height int `json:"height"` Width int `json:"width"` } - -type Metadata struct { - Result Result `json:"result"` - TextInGrid TextInGrid `json:"text_in_grid"` - OriginalImage Image `json:"original_image"` - Thumbnail Image `json:"thumbnail"` -} - -type ISCHJ struct { - Metadata []Metadata `json:"metadata"` -} - -type JsonResponse struct { - ISCHJ ISCHJ `json:"ischj"` -} diff --git a/src/search/engines/googleimages/options.go b/src/search/engines/googleimages/options.go deleted file mode 100644 index 6c442946..00000000 --- a/src/search/engines/googleimages/options.go +++ /dev/null @@ -1,14 +0,0 @@ -package googleimages - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "images.google.com", - Name: engines.GOOGLEIMAGES, - URL: "https://www.google.com/search?q=", - ResultsPerPage: 10, -} - -var Support = engines.SupportedSettings{} diff --git a/src/search/engines/googleimages/params.go b/src/search/engines/googleimages/params.go new file mode 100644 index 00000000..c422e205 --- /dev/null +++ b/src/search/engines/googleimages/params.go @@ -0,0 +1,21 @@ +package googleimages + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeParamString(locale options.Locale) string { + lang := strings.SplitN(strings.ToLower(locale.String()), "_", 2)[0] + return fmt.Sprintf("%v=%v&%v=lang_%v", params.Locale, lang, params.LocaleSec, lang) +} + +func safeSearchParamString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "high") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "off") + } +} diff --git a/src/search/engines/googleimages/search.go b/src/search/engines/googleimages/search.go new file mode 100644 index 00000000..38c0cd6a --- /dev/null +++ b/src/search/engines/googleimages/search.go @@ -0,0 +1,137 @@ +package googleimages + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnResponse(func(e *colly.Response) { + body := string(e.Body) + index := strings.Index(body, "{\"ischj\":") + + if index == -1 { + log.Error(). + Caller(). + Str("engine", se.Name.String()). + Str("body", body). + Msg("Failed parsing response, couldn't find the start of JSON") + return + } + + body = body[index:] + var jsonResponse jsonResponse + if err := json.Unmarshal([]byte(body), &jsonResponse); err != nil { + log.Error(). + Caller(). + Err(err). + Str("engine", se.Name.String()). + Str("body", body). + Msg("Failed parsing response, couldn't unmarshal JSON") + return + } + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + for _, metadata := range jsonResponse.ISCHJ.Metadata { + origImg := metadata.OriginalImage + thmbImg := metadata.Thumbnail + resultJson := metadata.Result + textInGridJson := metadata.TextInGrid + + // Google Images sometimes inverts original height and width. + if (thmbImg.Height > thmbImg.Width) != (origImg.Height > origImg.Width) { + origImg.Height, origImg.Width = origImg.Width, origImg.Height + } + + if resultJson.ReferrerUrl != "" && origImg.Url != "" && thmbImg.Url != "" { + r, err := result.ConstructImagesResult( + se.Name, origImg.Url, resultJson.PageTitle, textInGridJson.Snippet, page, pageRankCounter.GetPlusOne(pageIndex), + origImg.Height, origImg.Width, thmbImg.Height, thmbImg.Width, thmbImg.Url, resultJson.SiteTitle, resultJson.ReferrerUrl, + ) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + } + } else { + log.Error(). + Caller(). + Str("engine", se.Name.String()). + Str("jsonMetadata", fmt.Sprintf("%v", metadata)). + Str("url", resultJson.ReferrerUrl). + Str("original", origImg.Url). + Str("thumbnail", thmbImg.Url). + Msg("Couldn't find image URL") + } + } + }) + + // Static params. + localeParam := localeParamString(opts.Locale) + safeSearchParam := safeSearchParamString(opts.SafeSearch) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := fmt.Sprintf("%v:1", params.Page) + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v:%v", params.Page, pageNum0*10) + } + + combinedParams := morestrings.JoinNonEmpty([]string{tbmParam, asearchParam, filterParam, pageParam, localeParam, safeSearchParam}, "&", "&") + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/googleimages/search_test.go b/src/search/engines/googleimages/search_test.go new file mode 100644 index 00000000..02eb6488 --- /dev/null +++ b/src/search/engines/googleimages/search_test.go @@ -0,0 +1,41 @@ +package googleimages + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "wikipedia logo", + ResultURLs: []string{"upload.wikimedia.org"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "linux logo wikipedia", + ResultURLs: []string{"upload.wikimedia.org"}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar[:], tccr[:], tcrr[:]) +} diff --git a/src/search/engines/googlescholar/dompaths.go b/src/search/engines/googlescholar/dompaths.go new file mode 100644 index 00000000..3c8974c4 --- /dev/null +++ b/src/search/engines/googlescholar/dompaths.go @@ -0,0 +1,12 @@ +package googlescholar + +import ( + "github.com/hearchco/agent/src/search/scraper" +) + +var dompaths = scraper.DOMPaths{ + Result: "div#gs_res_ccl_mid > div.gs_or", + URL: "h3 > a", + Title: "h3 > a", + Description: "div.gs_rs", +} diff --git a/src/search/engines/googlescholar/googlescholar.go b/src/search/engines/googlescholar/googlescholar.go deleted file mode 100644 index 425c7277..00000000 --- a/src/search/engines/googlescholar/googlescholar.go +++ /dev/null @@ -1,96 +0,0 @@ -package googlescholar - -import ( - "context" - "net/url" - "strconv" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { - linkText, titleText, descText := _sedefaults.FieldsFromDOM(e.DOM, dompaths, Info.Name) - linkText = removeTelemetry(linkText) - citeInfo := _sedefaults.SanitizeDescription(e.DOM.Find("div.gs_a").Text()) // sanitize citeInfo with description sanitization - descText = citeInfo + " || " + descText - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "" - // i == 0 is the first page - if i > 0 { - pageParam = "&start=" + strconv.Itoa(i*10) - } - - urll := Info.URL + query + pageParam - anonUrll := Info.URL + anonymize.String(query) + pageParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func removeTelemetry(link string) string { - parsedURL, err := url.Parse(link) - if err != nil { - log.Error(). - Caller(). - Err(err). - Str("link", link). - Msg("Error parsing link") - return link - } - - // remove seemingly unused params in query - q := parsedURL.Query() - for _, key := range []string{"dq", "lr", "oi", "ots", "sig"} { - q.Del(key) - } - parsedURL.RawQuery = q.Encode() - return parsedURL.String() -} diff --git a/src/search/engines/googlescholar/googlescholar_test.go b/src/search/engines/googlescholar/googlescholar_test.go deleted file mode 100644 index 788a1c1f..00000000 --- a/src/search/engines/googlescholar/googlescholar_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package googlescholar_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.GOOGLESCHOLAR - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "interaction nets", - ResultURL: []string{"https://dl.acm.org/doi/pdf/10.1145/96709.96718"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "On building fast kd-trees for ray tracing, and on doing that in O (N log N)", - ResultURL: []string{"https://ieeexplore.ieee.org/abstract/document/4061547/"}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/googlescholar/infoparams.go b/src/search/engines/googlescholar/infoparams.go new file mode 100644 index 00000000..67b5972a --- /dev/null +++ b/src/search/engines/googlescholar/infoparams.go @@ -0,0 +1,22 @@ +package googlescholar + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.GOOGLESCHOLAR, + Domain: "scholar.google.com", + URL: "https://scholar.google.com/scholar", + Origins: []engines.Name{engines.GOOGLESCHOLAR}, +} + +var params = scraper.Params{ + Page: "start", + Locale: "hl", // Should be first 2 characters of Locale. + LocaleSec: "lr", // Should be first 2 characters of Locale with prefixed "lang_". + SafeSearch: "safe", // Can be "off", "medium or "high". +} + +const filterParam = "filter=0" diff --git a/src/search/engines/googlescholar/options.go b/src/search/engines/googlescholar/options.go deleted file mode 100644 index f3f6c623..00000000 --- a/src/search/engines/googlescholar/options.go +++ /dev/null @@ -1,21 +0,0 @@ -package googlescholar - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "scholar.google.com", - Name: engines.GOOGLESCHOLAR, - URL: "https://scholar.google.com/scholar?q=", - ResultsPerPage: 10, -} - -var dompaths = engines.DOMPaths{ - Result: "div#gs_res_ccl_mid > div.gs_or", - Link: "h3 > a", - Title: "h3 > a", - Description: "div.gs_rs", -} - -var Support = engines.SupportedSettings{} diff --git a/src/search/engines/googlescholar/params.go b/src/search/engines/googlescholar/params.go new file mode 100644 index 00000000..8578cb67 --- /dev/null +++ b/src/search/engines/googlescholar/params.go @@ -0,0 +1,21 @@ +package googlescholar + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeParamString(locale options.Locale) string { + lang := strings.SplitN(strings.ToLower(locale.String()), "_", 2)[0] + return fmt.Sprintf("%v=%v&%v=lang_%v", params.Locale, lang, params.LocaleSec, lang) +} + +func safeSearchParamString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "high") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "off") + } +} diff --git a/src/search/engines/googlescholar/search.go b/src/search/engines/googlescholar/search.go new file mode 100644 index 00000000..85e30e3e --- /dev/null +++ b/src/search/engines/googlescholar/search.go @@ -0,0 +1,104 @@ +package googlescholar + +import ( + "fmt" + "strconv" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + urlText, titleText, descText := parse.FieldsFromDOM(e.DOM, dompaths, se.Name) + + urlText, err := removeTelemetry(urlText) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("url", urlText). + Msg("Failed to remove telemetry") + return + } + + citeInfo := parse.SanitizeDescription(e.DOM.Find("div.gs_a").Text()) // Sanitize citeInfo with description sanitization. + descText = citeInfo + " || " + descText + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, pageRankCounter.GetPlusOne(pageIndex)) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } + } + }) + + // Static params. + localeParam := localeParamString(opts.Locale) + safeSearchParam := safeSearchParamString(opts.SafeSearch) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := "" + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v=%v", params.Page, pageNum0*10) + } + + combinedParams := morestrings.JoinNonEmpty([]string{filterParam, pageParam, localeParam, safeSearchParam}, "&", "&") + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/googlescholar/search_test.go b/src/search/engines/googlescholar/search_test.go new file mode 100644 index 00000000..ab3687af --- /dev/null +++ b/src/search/engines/googlescholar/search_test.go @@ -0,0 +1,41 @@ +package googlescholar + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "interaction nets", + ResultURLs: []string{"https://dl.acm.org/doi/pdf/10.1145/96709.96718"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "On building fast kd-trees for ray tracing, and on doing that in O (N log N)", + ResultURLs: []string{"https://ieeexplore.ieee.org/abstract/document/4061547/"}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/googlescholar/telemetry.go b/src/search/engines/googlescholar/telemetry.go new file mode 100644 index 00000000..b8bb3ec8 --- /dev/null +++ b/src/search/engines/googlescholar/telemetry.go @@ -0,0 +1,21 @@ +package googlescholar + +import ( + "net/url" +) + +// Remove seemingly unused params in query. +func removeTelemetry(link string) (string, error) { + parsedURL, err := url.Parse(link) + if err != nil { + return link, err + } + + q := parsedURL.Query() + for _, key := range []string{"dq", "lr", "oi", "ots", "sig"} { + q.Del(key) + } + parsedURL.RawQuery = q.Encode() + + return parsedURL.String(), nil +} diff --git a/src/search/engines/mojeek/dompaths.go b/src/search/engines/mojeek/dompaths.go new file mode 100644 index 00000000..c4591781 --- /dev/null +++ b/src/search/engines/mojeek/dompaths.go @@ -0,0 +1,12 @@ +package mojeek + +import ( + "github.com/hearchco/agent/src/search/scraper" +) + +var dompaths = scraper.DOMPaths{ + Result: "ul.results-standard > li", + URL: "h2 > a.title", + Title: "h2 > a.title", + Description: "p.s", +} diff --git a/src/search/engines/mojeek/infoparams.go b/src/search/engines/mojeek/infoparams.go new file mode 100644 index 00000000..9563becb --- /dev/null +++ b/src/search/engines/mojeek/infoparams.go @@ -0,0 +1,20 @@ +package mojeek + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.MOJEEK, + Domain: "www.mojeek.com", + URL: "https://www.mojeek.com/search", + Origins: []engines.Name{engines.MOJEEK}, +} + +var params = scraper.Params{ + Page: "s", + Locale: "lb", // Should be first 2 characters of Locale. + LocaleSec: "arc", // Should be last 2 characters of Locale. + SafeSearch: "safe", // Can be "0" or "1". +} diff --git a/src/search/engines/mojeek/mojeek.go b/src/search/engines/mojeek/mojeek.go deleted file mode 100644 index edfa9351..00000000 --- a/src/search/engines/mojeek/mojeek.go +++ /dev/null @@ -1,88 +0,0 @@ -package mojeek - -import ( - "context" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { - linkText, titleText, descText := _sedefaults.FieldsFromDOM(e.DOM, dompaths, Info.Name) - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // static params - localeParam := getLocale(options) - safeSearchParam := getSafeSearch(options) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "" - // i == 0 is the first page - if i > 0 { - pageParam = "&s=" + strconv.Itoa(i*10+1) - } - - urll := Info.URL + query + pageParam + localeParam + safeSearchParam - anonUrll := Info.URL + anonymize.String(query) + pageParam + localeParam + safeSearchParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getLocale(options engines.Options) string { - spl := strings.SplitN(strings.ToLower(options.Locale), "_", 2) - return "&lb=" + spl[0] + "&arc=" + spl[1] -} - -func getSafeSearch(options engines.Options) string { - if options.SafeSearch { - return "&safe=1" - } - return "&safe=0" -} diff --git a/src/search/engines/mojeek/mojeek_test.go b/src/search/engines/mojeek/mojeek_test.go deleted file mode 100644 index 4914b460..00000000 --- a/src/search/engines/mojeek/mojeek_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package mojeek_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.MOJEEK - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/mojeek/options.go b/src/search/engines/mojeek/options.go deleted file mode 100644 index eaca5b77..00000000 --- a/src/search/engines/mojeek/options.go +++ /dev/null @@ -1,22 +0,0 @@ -package mojeek - -import "github.com/hearchco/hearchco/src/search/engines" - -var Info = engines.Info{ - Domain: "www.mojeek.com", - Name: engines.MOJEEK, - URL: "https://www.mojeek.com/search?q=", - ResultsPerPage: 10, -} - -var dompaths = engines.DOMPaths{ - Result: "ul.results-standard > li", - Link: "h2 > a.title", - Title: "h2 > a.title", - Description: "p.s", -} - -var Support = engines.SupportedSettings{ - Locale: true, - SafeSearch: true, -} diff --git a/src/search/engines/mojeek/params.go b/src/search/engines/mojeek/params.go new file mode 100644 index 00000000..fa3aa064 --- /dev/null +++ b/src/search/engines/mojeek/params.go @@ -0,0 +1,21 @@ +package mojeek + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeParamString(locale options.Locale) string { + spl := strings.SplitN(strings.ToLower(locale.String()), "_", 2) + return fmt.Sprintf("%v=%v&%v=%v", params.Locale, spl[0], params.LocaleSec, spl[1]) +} + +func safeSearchParamString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "1") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "0") + } +} diff --git a/src/search/engines/mojeek/search.go b/src/search/engines/mojeek/search.go new file mode 100644 index 00000000..a2bf0744 --- /dev/null +++ b/src/search/engines/mojeek/search.go @@ -0,0 +1,91 @@ +package mojeek + +import ( + "fmt" + "strconv" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + urlText, titleText, descText := parse.FieldsFromDOM(e.DOM, dompaths, se.Name) + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, pageRankCounter.GetPlusOne(pageIndex)) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } + } + }) + + // Static params. + localeParam := localeParamString(opts.Locale) + safeSearchParam := safeSearchParamString(opts.SafeSearch) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := "" + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v=%v", params.Page, pageNum0*10+1) + } + + combinedParams := morestrings.JoinNonEmpty([]string{pageParam, localeParam, safeSearchParam}, "&", "&") + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/mojeek/search_test.go b/src/search/engines/mojeek/search_test.go new file mode 100644 index 00000000..a707a04c --- /dev/null +++ b/src/search/engines/mojeek/search_test.go @@ -0,0 +1,41 @@ +package mojeek + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/name.go b/src/search/engines/name.go index 9e3eb2f0..c1e6371d 100644 --- a/src/search/engines/name.go +++ b/src/search/engines/name.go @@ -2,10 +2,10 @@ package engines import "strings" -type Name uint8 +type Name int -//go:generate enumer -type=Name -json -text -yaml -sql -//go:generate go run github.com/hearchco/hearchco/generate/searcher -type=Name -packagename search -output ../engine_searcher.go +//go:generate enumer -type=Name -json -text -sql +//go:generate go run github.com/hearchco/agent/generate/searcher -type=Name -packagename search -output ../engine_searcher.go const ( UNDEFINED Name = iota BING @@ -22,10 +22,10 @@ const ( STARTPAGE SWISSCOWS YAHOO - YEP + // YEP ) -// Returns Engine Names without UNDEFINED +// Returns engine names without UNDEFINED. func Names() []Name { return _NameValues[1:] } diff --git a/src/search/engines/options/locale.go b/src/search/engines/options/locale.go new file mode 100644 index 00000000..b29f5060 --- /dev/null +++ b/src/search/engines/options/locale.go @@ -0,0 +1,47 @@ +package options + +import ( + "fmt" +) + +// format: en_US +type Locale string + +const LocaleDefault Locale = "en_US" + +func (l Locale) String() string { + return string(l) +} + +func (l Locale) Validate() error { + if l == "" { + return fmt.Errorf("invalid locale: empty") + } + + if len(l) != 5 { + return fmt.Errorf("invalid locale: isn't 5 characters long") + } + + if !(('a' <= l[0] && l[0] <= 'z') && ('a' <= l[1] && l[1] <= 'z')) { + return fmt.Errorf("invalid locale: first two characters must be lowercase ASCII letters") + } + + if !(('A' <= l[3] && l[3] <= 'Z') && ('A' <= l[4] && l[4] <= 'Z')) { + return fmt.Errorf("invalid locale: last two characters must be uppercase ASCII letters") + } + + if l[2] != '_' { + return fmt.Errorf("invalid locale: third character must be underscore") + } + + return nil +} + +func StringToLocale(s string) (Locale, error) { + l := Locale(s) + if err := l.Validate(); err != nil { + return "", err + } + + return l, nil +} diff --git a/src/search/engines/options/structs.go b/src/search/engines/options/structs.go new file mode 100644 index 00000000..3fe348f4 --- /dev/null +++ b/src/search/engines/options/structs.go @@ -0,0 +1,15 @@ +package options + +// User provided options for every search engine. +type Options struct { + Pages Pages + Locale Locale + SafeSearch bool +} + +// Start must be 0-based index. +// Max must be greater than 0. +type Pages struct { + Start int + Max int +} diff --git a/src/search/engines/presearch/cookies.go b/src/search/engines/presearch/cookies.go new file mode 100644 index 00000000..b707cdd0 --- /dev/null +++ b/src/search/engines/presearch/cookies.go @@ -0,0 +1,13 @@ +package presearch + +import ( + "fmt" +) + +func safeSearchCookieString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "true") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "false") + } +} diff --git a/src/search/engines/presearch/infoparams.go b/src/search/engines/presearch/infoparams.go new file mode 100644 index 00000000..484a0d11 --- /dev/null +++ b/src/search/engines/presearch/infoparams.go @@ -0,0 +1,18 @@ +package presearch + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.PRESEARCH, + Domain: "presearch.com", + URL: "https://presearch.com/search", + Origins: []engines.Name{engines.PRESEARCH, engines.GOOGLE}, +} + +var params = scraper.Params{ + Page: "page", + SafeSearch: "use_safe_search", // // Can be "true" or "false". +} diff --git a/src/search/engines/presearch/json_response.go b/src/search/engines/presearch/json.go similarity index 64% rename from src/search/engines/presearch/json_response.go rename to src/search/engines/presearch/json.go index f8738c6b..f3f4f21e 100644 --- a/src/search/engines/presearch/json_response.go +++ b/src/search/engines/presearch/json.go @@ -1,14 +1,14 @@ package presearch -type Result struct { +type jsonResult struct { Title string `json:"title"` Link string `json:"link"` Desc string `json:"description"` Favicon string `json:"favicon"` } -type PresearchResponse struct { +type jsonResponse struct { Results struct { - StandardResults []Result `json:"standardResults"` + StandardResults []jsonResult `json:"standardResults"` } `json:"results"` } diff --git a/src/search/engines/presearch/options.go b/src/search/engines/presearch/options.go deleted file mode 100644 index 831b6afc..00000000 --- a/src/search/engines/presearch/options.go +++ /dev/null @@ -1,24 +0,0 @@ -package presearch - -import "github.com/hearchco/hearchco/src/search/engines" - -var Info = engines.Info{ - Domain: "presearch.com", - Name: engines.PRESEARCH, - URL: "https://presearch.com/search?q=", - ResultsPerPage: 10, -} - -/* -// If the API is not used, these are the selectors for the page -var dompaths engines.DOMPaths = engines.DOMPaths{ - Result: "div[x-data=\"searchResults(true)\"] > div.w-full > div.text-gray-300 > div > div > div", - Link: "div > div > a.text-results-link", - Title: "div > div span[x-html=\"result.title\"]", - Description: "div[x-html*=\"result.description\"]", -} -*/ - -var Support = engines.SupportedSettings{ - SafeSearch: true, -} diff --git a/src/search/engines/presearch/presearch.go b/src/search/engines/presearch/presearch.go deleted file mode 100644 index b6815d04..00000000 --- a/src/search/engines/presearch/presearch.go +++ /dev/null @@ -1,128 +0,0 @@ -package presearch - -import ( - "context" - "encoding/json" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - safeSearch := getSafeSearch(options.SafeSearch) - - col.OnRequest(func(r *colly.Request) { - r.Headers.Add("Cookie", "use_local_search_results=false") - r.Headers.Add("Cookie", "ai_results_disable=1") - r.Headers.Add("Cookie", "use_safe_search="+safeSearch) - }) - - col.OnResponse(func(r *colly.Response) { - pageIndex := _sedefaults.PageFromContext(r.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - var apiStr string = r.Request.Ctx.Get("isAPI") - isApi, _ := strconv.ParseBool(apiStr) - - if isApi { - //json response - var pr PresearchResponse - err := json.Unmarshal(r.Body, &pr) - if err != nil { - log.Error(). - Caller(). - Err(err). - Str("engine", Info.Name.String()). - Bytes("body", r.Body). - Msg("Failed to parse response, couldn't unmarshal JSON") - } - - counter := 1 - for _, result := range pr.Results.StandardResults { - goodURL, goodTitle, goodDesc := _sedefaults.SanitizeFields(result.Link, result.Title, result.Desc) - - res := bucket.MakeSEResult(goodURL, goodTitle, goodDesc, Info.Name, page, counter) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - counter += 1 - } - } - } else { - //html response, forward call to API - suff := strings.SplitN(string(r.Body), "window.searchId = \"", 2)[1] - searchId := strings.SplitN(suff, "\"", 2)[0] - - nextCtx := colly.NewContext() - nextCtx.Put("page", strconv.Itoa(page)) - nextCtx.Put("isAPI", "true") - err := col.Request("GET", "https://presearch.com/results?id="+searchId, nil, nextCtx, nil) - if engines.IsTimeoutError(err) { - log.Trace(). - Err(err). - Str("engine", Info.Name.String()). - Msg("Failed requesting with API due to timeout") - } else if err != nil { - log.Error(). - Err(err). - Str("engine", Info.Name.String()). - Msg("Failed requesting with API") - } - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - colCtx.Put("isAPI", "false") - - // dynamic params - pageParam := "" - // i == 0 is the first page - if i > 0 { - pageParam = "&page=" + strconv.Itoa(i+1) - } - - urll := Info.URL + query + pageParam - anonUrll := Info.URL + anonymize.String(query) + pageParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getSafeSearch(ss bool) string { - if ss { - return "true" - } - return "false" -} diff --git a/src/search/engines/presearch/presearch_test.go b/src/search/engines/presearch/presearch_test.go deleted file mode 100644 index 8b061592..00000000 --- a/src/search/engines/presearch/presearch_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package presearch_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.PRESEARCH - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/presearch/search.go b/src/search/engines/presearch/search.go new file mode 100644 index 00000000..d0b17d1f --- /dev/null +++ b/src/search/engines/presearch/search.go @@ -0,0 +1,134 @@ +package presearch + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + + se.OnRequest(func(r *colly.Request) { + r.Headers.Add("Cookie", "presearch_session=;") + r.Headers.Add("Cookie", "use_local_search_results=false") + r.Headers.Add("Cookie", "ai_results_disable=1") + r.Headers.Add("Cookie", safeSearchCookieString(opts.SafeSearch)) + }) + + se.OnResponse(func(r *colly.Response) { + pageIndex := se.PageFromContext(r.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + apiStr := r.Request.Ctx.Get("isAPI") + isApi, err := strconv.ParseBool(apiStr) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("engine", se.Name.String()). + Msg("Failed to parse isAPI") + } + + if isApi { + // JSON response. + var pr jsonResponse + err := json.Unmarshal(r.Body, &pr) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("engine", se.Name.String()). + Bytes("body", r.Body). + Msg("Failed to parse response, couldn't unmarshal JSON") + } + + counter := 1 + for _, jsonR := range pr.Results.StandardResults { + goodURL, goodTitle, goodDesc := parse.SanitizeFields(jsonR.Link, jsonR.Title, jsonR.Desc) + + r, err := result.ConstructResult(se.Name, goodURL, goodTitle, goodDesc, page, counter) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", counter). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + counter++ + } + } + } else { + // HTML response, forward call to API. + suff := strings.SplitN(string(r.Body), "window.searchId = \"", 2)[1] + searchId := strings.SplitN(suff, "\"", 2)[0] + + nextCtx := colly.NewContext() + nextCtx.Put("page", strconv.Itoa(page)) + nextCtx.Put("isAPI", "true") + + urll := fmt.Sprintf("https://presearch.com/results?id=%v", searchId) + anonUrll := fmt.Sprintf("https://presearch.com/results?id=%v", anonymize.HashToSHA256B64(searchId)) + + if err := se.Get(nextCtx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + }) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := "" + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v=%v", params.Page, pageNum0+1) + } + + combinedParams := morestrings.JoinNonEmpty([]string{pageParam}, "&", "&") + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/presearch/search_test.go b/src/search/engines/presearch/search_test.go new file mode 100644 index 00000000..8f73bf3c --- /dev/null +++ b/src/search/engines/presearch/search_test.go @@ -0,0 +1,41 @@ +package presearch + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/qwant/infoparams.go b/src/search/engines/qwant/infoparams.go new file mode 100644 index 00000000..59c4c8cb --- /dev/null +++ b/src/search/engines/qwant/infoparams.go @@ -0,0 +1,21 @@ +package qwant + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.QWANT, + Domain: "www.qwant.com", + URL: "https://api.qwant.com/v3/search/web", + Origins: []engines.Name{engines.QWANT, engines.BING}, +} + +var params = scraper.Params{ + Page: "offset", + Locale: "locale", // Same as Locale, only the last two characters are lowered and not everything is supported. + SafeSearch: "safesearch", // Can be "0" or "1". +} + +const countParam = "count=10" diff --git a/src/search/engines/qwant/json.go b/src/search/engines/qwant/json.go new file mode 100644 index 00000000..849ba76e --- /dev/null +++ b/src/search/engines/qwant/json.go @@ -0,0 +1,23 @@ +package qwant + +type jsonResponse struct { + Status string `json:"status"` + Data struct { + Res struct { + Items struct { + Mainline []jsonMainlineItems `json:"mainline"` + } `json:"items"` + } `json:"result"` + } `json:"data"` +} + +type jsonMainlineItems struct { + Type string `json:"type"` + Items []jsonResults `json:"items"` +} + +type jsonResults struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"desc"` +} diff --git a/src/search/engines/qwant/json_response.go b/src/search/engines/qwant/json_response.go deleted file mode 100644 index 4694f306..00000000 --- a/src/search/engines/qwant/json_response.go +++ /dev/null @@ -1,23 +0,0 @@ -package qwant - -type QwantResults struct { - Title string `json:"title"` - URL string `json:"url"` //there is also a source field, what is it? - Description string `json:"desc"` -} - -type QwantMainlineItems struct { - Type string `json:"type"` - Items []QwantResults `json:"items"` -} - -type QwantResponse struct { - Status string `json:"status"` - Data struct { - Res struct { - Items struct { - Mainline []QwantMainlineItems `json:"mainline"` - } `json:"items"` - } `json:"result"` - } `json:"data"` -} diff --git a/src/search/engines/qwant/options.go b/src/search/engines/qwant/options.go deleted file mode 100644 index c9f2473c..00000000 --- a/src/search/engines/qwant/options.go +++ /dev/null @@ -1,18 +0,0 @@ -package qwant - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "www.qwant.com", - Name: engines.QWANT, - URL: "https://api.qwant.com/v3/search/web?q=", - ResultsPerPage: 10, -} - -var Support = engines.SupportedSettings{ - Locale: true, - SafeSearch: true, - RequestedResultsPerPage: true, -} diff --git a/src/search/engines/qwant/params.go b/src/search/engines/qwant/params.go new file mode 100644 index 00000000..dfa638de --- /dev/null +++ b/src/search/engines/qwant/params.go @@ -0,0 +1,35 @@ +package qwant + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/rs/zerolog/log" +) + +var validLocales = [...]string{"bg_bg", "br_fr", "ca_ad", "ca_es", "ca_fr", "co_fr", "cs_cz", "cy_gb", "da_dk", "de_at", "de_ch", "de_de", "ec_ca", "el_gr", "en_au", "en_ca", "en_gb", "en_ie", "en_my", "en_nz", "en_us", "es_ad", "es_ar", "es_cl", "es_co", "es_es", "es_mx", "es_pe", "et_ee", "eu_es", "eu_fr", "fc_ca", "fi_fi", "fr_ad", "fr_be", "fr_ca", "fr_ch", "fr_fr", "gd_gb", "he_il", "hu_hu", "it_ch", "it_it", "ko_kr", "nb_no", "nl_be", "nl_nl", "pl_pl", "pt_ad", "pt_pt", "ro_ro", "sv_se", "th_th", "zh_cn", "zh_hk"} + +func localeParamString(locale options.Locale) string { + l := strings.ToLower(locale.String()) + for _, vl := range validLocales { + if l == vl { + return fmt.Sprintf("%v=%v", params.Locale, l) + } + } + + log.Warn(). + Caller(). + Str("locale", locale.String()). + Strs("validLocales", validLocales[:]). + Msg("Unsupported locale supplied for this engine, falling back to default") + return fmt.Sprintf("%v=%v", params.Locale, strings.ToLower(options.LocaleDefault.String())) +} + +func safeSearchParamString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "1") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "2") + } +} diff --git a/src/search/engines/qwant/qwant.go b/src/search/engines/qwant/qwant.go deleted file mode 100644 index 9d0de3c5..00000000 --- a/src/search/engines/qwant/qwant.go +++ /dev/null @@ -1,153 +0,0 @@ -package qwant - -import ( - "context" - "encoding/json" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - col.OnResponse(func(r *colly.Response) { - var pageStr string = r.Ctx.Get("page") - if pageStr == "" { - // If I'm using GET as the first page - return - } - - pageIndex := _sedefaults.PageFromContext(r.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - var parsedResponse QwantResponse - if err := json.Unmarshal(r.Body, &parsedResponse); err != nil { - log.Error(). - Caller(). - Err(err). - Str("engine", Info.Name.String()). - Bytes("body", r.Body). - Msg("Failed to parse response, couldn't unmarshal JSON") - } - - mainline := parsedResponse.Data.Res.Items.Mainline - counter := 1 - for _, group := range mainline { - if group.Type != "web" { - continue - } - for _, result := range group.Items { - goodLink, goodTitle, goodDesc := _sedefaults.SanitizeFields(result.URL, result.Title, result.Description) - - res := bucket.MakeSEResult(goodLink, goodTitle, goodDesc, Info.Name, page, counter) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - counter += 1 - } - } - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // static params - localeParam := getLocale(options) - safeSearchParam := getSafeSearch(options) - countParam := "&count=" + strconv.Itoa(settings.RequestedResultsPerPage) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - offsetParam := "&offset=" + strconv.Itoa(i*settings.RequestedResultsPerPage) - - urll := Info.URL + query + countParam + localeParam + offsetParam + safeSearchParam - anonUrll := Info.URL + anonymize.String(query) + countParam + localeParam + offsetParam + safeSearchParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -// qwant returns this array when an invalid locale is supplied -var validLocales = [...]string{"bg_bg", "br_fr", "ca_ad", "ca_es", "ca_fr", "co_fr", "cs_cz", "cy_gb", "da_dk", "de_at", "de_ch", "de_de", "ec_ca", "el_gr", "en_au", "en_ca", "en_gb", "en_ie", "en_my", "en_nz", "en_us", "es_ad", "es_ar", "es_cl", "es_co", "es_es", "es_mx", "es_pe", "et_ee", "eu_es", "eu_fr", "fc_ca", "fi_fi", "fr_ad", "fr_be", "fr_ca", "fr_ch", "fr_fr", "gd_gb", "he_il", "hu_hu", "it_ch", "it_it", "ko_kr", "nb_no", "nl_be", "nl_nl", "pl_pl", "pt_ad", "pt_pt", "ro_ro", "sv_se", "th_th", "zh_cn", "zh_hk"} - -func getLocale(options engines.Options) string { - locale := strings.ToLower(options.Locale) - for _, vl := range validLocales { - if locale == vl { - return "&locale=" + locale - } - } - - log.Warn(). - Caller(). - Str("locale", options.Locale). - Strs("validLocales", validLocales[:]). - Msg("Invalid locale supplied, falling back to en_US") - return "&locale=" + strings.ToLower(config.DefaultLocale) -} - -func getSafeSearch(options engines.Options) string { - if options.SafeSearch { - return "&safesearch=1" - } - return "&safesearch=0" -} - -/* -col.OnHTML("div[data-testid=\"sectionWeb\"] > div > div", func(e *colly.HTMLElement) { - //first page - idx := e.Index - - dom := e.DOM - baseDOM := dom.Find("div[data-testid=\"webResult\"] > div > div > div > div > div") - hrefElement := baseDOM.Find("a[data-testid=\"serTitle\"]") - linkHref, hrefExists := hrefElement.Attr("href") - linkText := parse.ParseURL(linkHref) - titleText := strings.TrimSpace(hrefElement.Text()) - descText := strings.TrimSpace(baseDOM.Find("div > span").Text()) - - if hrefExists && linkText != "" && linkText != "#" && titleText != "" { - var pageStr string = e.Request.Ctx.Get("page") - page, _ := strconv.Atoi(pageStr) - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, -1, page, idx+1) - bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - } else { - log.Info(). - Str("link", linkText). - Str("title", titleText). - Str("desc", descText). - Msg("Not good!") - } -}) -*/ diff --git a/src/search/engines/qwant/qwant.md b/src/search/engines/qwant/qwant.md index 04f7869d..fcba1a1f 100644 --- a/src/search/engines/qwant/qwant.md +++ b/src/search/engines/qwant/qwant.md @@ -25,4 +25,4 @@ col.Request("GET", Info.URL, nil, colCtx, nil) ^ Instead of colly.Visit(Info.URL) -For the first result page `col.Visit(Info.URL + query + "&t=web&locale=" + qLocale + "&s=" + qSafeSearch)` could be used. This would emulate an actual user better. Its `.OnHTML` is implemented, but it seems to not play well with the API calls, having some results overlapp, this doesn't make any sense whatsoever. If this is used for first page, then `for i := 0; i < options.Pages.Max; i++ {` needs start at 1 (i.e. `for i := 0; ....`). When it works and when it doesn't seems random - so it may be best to not touch it. Last query on which it didn't work: `./main --query="jako cudne stvari" --max-pages=2 -vv --visit` +For the first result page `col.Visit(Info.URL + query + "&t=web&locale=" + qLocale + "&s=" + qSafeSearch)` could be used. This would emulate an actual user better. Its `.OnHTML` is implemented, but it seems to not play well with the API calls, having some results overlapp, this doesn't make any sense whatsoever. If this is used for first page, then `for i := 0; i < opts.Pages.Max; i++ {` needs start at 1 (i.e. `for i := 0; ....`). When it works and when it doesn't seems random - so it may be best to not touch it. Last query on which it didn't work: `./main --query="jako cudne stvari" --max-pages=2 -vv --visit` diff --git a/src/search/engines/qwant/qwant_test.go b/src/search/engines/qwant/qwant_test.go deleted file mode 100644 index 98bc0cfc..00000000 --- a/src/search/engines/qwant/qwant_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package qwant_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.QWANT - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/qwant/search.go b/src/search/engines/qwant/search.go new file mode 100644 index 00000000..403c9d00 --- /dev/null +++ b/src/search/engines/qwant/search.go @@ -0,0 +1,109 @@ +package qwant + +import ( + "encoding/json" + "fmt" + "strconv" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + + se.OnResponse(func(r *colly.Response) { + var pageStr string = r.Ctx.Get("page") + if pageStr == "" { + // If I'm using GET as the first page + return + } + + pageIndex := se.PageFromContext(r.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + var parsedResponse jsonResponse + if err := json.Unmarshal(r.Body, &parsedResponse); err != nil { + log.Error(). + Caller(). + Err(err). + Str("engine", se.Name.String()). + Bytes("body", r.Body). + Msg("Failed to parse response, couldn't unmarshal JSON") + } + + mainline := parsedResponse.Data.Res.Items.Mainline + counter := 1 + for _, group := range mainline { + if group.Type != "web" { + continue + } + for _, jsonResult := range group.Items { + goodURL, goodTitle, goodDesc := parse.SanitizeFields(jsonResult.URL, jsonResult.Title, jsonResult.Description) + + r, err := result.ConstructResult(se.Name, goodURL, goodTitle, goodDesc, page, counter) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", counter). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + counter++ + } + } + } + }) + + // Static params. + localeParam := localeParamString(opts.Locale) + safeSearchParam := safeSearchParamString(opts.SafeSearch) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := fmt.Sprintf("%v=%v", params.Page, pageNum0*10) + combinedParams := morestrings.JoinNonEmpty([]string{countParam, localeParam, pageParam, safeSearchParam}, "&", "&") + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/qwant/search_test.go b/src/search/engines/qwant/search_test.go new file mode 100644 index 00000000..d4ca0c4e --- /dev/null +++ b/src/search/engines/qwant/search_test.go @@ -0,0 +1,41 @@ +package qwant + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/startpage/dompaths.go b/src/search/engines/startpage/dompaths.go new file mode 100644 index 00000000..4d526622 --- /dev/null +++ b/src/search/engines/startpage/dompaths.go @@ -0,0 +1,12 @@ +package startpage + +import ( + "github.com/hearchco/agent/src/search/scraper" +) + +var dompaths = scraper.DOMPaths{ + Result: "div.w-gl > div.result", + URL: "a.result-title", + Title: "a.result-title", + Description: "p.description", +} diff --git a/src/search/engines/startpage/image-1.png b/src/search/engines/startpage/image-1.png deleted file mode 100644 index 130b2af1a17a3c8ea74f343d75646b00b52bde09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69075 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 diff --git a/src/search/engines/startpage/image.png b/src/search/engines/startpage/image.png deleted file mode 100644 index e484789ccfb0a1f54bc577f61c27f06a460ae7c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 106047 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

A8 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 HoD!M div.w-gl__result", - Link: "a.result-link", - Title: "a.w-gl__result-title", - Description: "p.w-gl__description", -} - -var Support = engines.SupportedSettings{ - SafeSearch: true, -} diff --git a/src/search/engines/startpage/params.go b/src/search/engines/startpage/params.go new file mode 100644 index 00000000..5d300319 --- /dev/null +++ b/src/search/engines/startpage/params.go @@ -0,0 +1,13 @@ +package startpage + +import ( + "fmt" +) + +func safeSearchParamString(safesearch bool) string { + if safesearch { + return "" + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "none") + } +} diff --git a/src/search/engines/startpage/search.go b/src/search/engines/startpage/search.go new file mode 100644 index 00000000..9a01eaac --- /dev/null +++ b/src/search/engines/startpage/search.go @@ -0,0 +1,103 @@ +package startpage + +import ( + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + urlText, titleText, descText := parse.FieldsFromDOM(e.DOM, dompaths, se.Name) + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, pageRankCounter.GetPlusOne(pageIndex)) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } + } + }) + + se.OnResponse(func(r *colly.Response) { + if strings.Contains(string(r.Body), "to prevent possible abuse of our service") { + log.Error(). + Str("engine", se.Name.String()). + Msg("Request blocked due to scraping") + } else if strings.Contains(string(r.Body), "This page cannot function without javascript") { + log.Error(). + Str("engine", se.Name.String()). + Msg("Couldn't load requests, needs javascript") + } + }) + + // Static params. + safeSearchParam := safeSearchParamString(opts.SafeSearch) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := "" + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v=%v", params.Page, pageNum0+1) + } + + combinedParams := morestrings.JoinNonEmpty([]string{pageParam, safeSearchParam}, "&", "&") + + urll := fmt.Sprintf("%v?q=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?q=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/startpage/search_test.go b/src/search/engines/startpage/search_test.go new file mode 100644 index 00000000..746935d8 --- /dev/null +++ b/src/search/engines/startpage/search_test.go @@ -0,0 +1,41 @@ +package startpage + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/startpage/startpage.go b/src/search/engines/startpage/startpage.go deleted file mode 100644 index 353738b2..00000000 --- a/src/search/engines/startpage/startpage.go +++ /dev/null @@ -1,95 +0,0 @@ -package startpage - -import ( - "context" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { - linkText, titleText, descText := _sedefaults.FieldsFromDOM(e.DOM, dompaths, Info.Name) - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - }) - - col.OnResponse(func(r *colly.Response) { - if strings.Contains(string(r.Body), "to prevent possible abuse of our service") { - log.Error(). - Str("engine", Info.Name.String()). - Msg("Request blocked due to scraping") - } else if strings.Contains(string(r.Body), "This page cannot function without javascript") { - log.Error(). - Str("engine", Info.Name.String()). - Msg("Couldn't load requests, needs javascript") - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // static params - safeSearch := getSafeSearch(options) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "" - // i == 0 is the first page - if i > 0 { - pageParam = "&page=" + strconv.Itoa(i+1) - } - - urll := Info.URL + query + pageParam + safeSearch - anonUrll := Info.URL + anonymize.String(query) + pageParam + safeSearch - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getSafeSearch(options engines.Options) string { - if options.SafeSearch { - return "" // for startpage, Safe Search is the default - } - return "&qadf=none" -} diff --git a/src/search/engines/startpage/startpage_test.go b/src/search/engines/startpage/startpage_test.go deleted file mode 100644 index 4c255343..00000000 --- a/src/search/engines/startpage/startpage_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package startpage_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.STARTPAGE - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/structs.go b/src/search/engines/structs.go deleted file mode 100644 index 061546f9..00000000 --- a/src/search/engines/structs.go +++ /dev/null @@ -1,62 +0,0 @@ -package engines - -import ( - "fmt" - - "github.com/hearchco/hearchco/src/search/category" -) - -type SupportedSettings struct { - Locale bool - SafeSearch bool - RequestedResultsPerPage bool -} - -type Info struct { - Name Name - ResultsPerPage int - Domain string - URL string -} - -type DOMPaths struct { - ResultsContainer string - Result string // div - Link string // a href - Title string // heading - Description string // paragraph -} - -type Pages struct { - Start int - Max int -} - -type Options struct { - VisitPages bool - SafeSearch bool - Pages Pages - Locale string // format: en_US - Category category.Name -} - -func ValidateLocale(locale string) error { - if locale == "" { - return nil - } - - if len(locale) != 5 { - return fmt.Errorf("engines.validateLocale(): isn't 5 characters long") - } - if !(('a' <= locale[0] && locale[0] <= 'z') && ('a' <= locale[1] && locale[1] <= 'z')) { - return fmt.Errorf("engines.validateLocale(): first two characters must be lowercase ASCII letters") - } - if !(('A' <= locale[3] && locale[3] <= 'Z') && ('A' <= locale[4] && locale[4] <= 'Z')) { - return fmt.Errorf("engines.validateLocale(): last two characters must be uppercase ASCII letters") - } - if locale[2] != '_' { - return fmt.Errorf("engines.validateLocale(): third character must be underscore (_)") - } - - return nil -} diff --git a/src/search/engines/swisscows/authenticator.go b/src/search/engines/swisscows/authenticator.go index 9a6afbe0..d60861bc 100644 --- a/src/search/engines/swisscows/authenticator.go +++ b/src/search/engines/swisscows/authenticator.go @@ -7,15 +7,29 @@ import ( "time" "unicode" - "github.com/hearchco/hearchco/src/anonymize" + "github.com/hearchco/agent/src/utils/anonymize" ) +const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + +// Returns nonce and signature. +func generateAuth(params string) (string, string, error) { + paramsWOP := strings.ReplaceAll(params, "+", " ") + nonce := generateNonce(32) + + auth, err := generateSignature(paramsWOP, nonce) + if err != nil { + return "", "", fmt.Errorf("failed to generate auth (nonce and signature): %w", err) + } + + return nonce, auth, nil +} + func generateNonce(length int) string { r := rand.New(rand.NewSource(time.Now().UnixNano())) - const alphabet string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - var nonce string = "" - for i := 0; i < length; i++ { + nonce := "" + for range length { randInd := r.Intn(length) nonce += string(alphabet[randInd]) } @@ -23,8 +37,35 @@ func generateNonce(length int) string { return nonce } +func generateSignature(params string, nonce string) (string, error) { + rot13Nonce := rot13Switch(nonce) + data := "/web/search" + params + rot13Nonce + encData := anonymize.HashToSHA256B64(data) + encData = strings.ReplaceAll(encData, "=", "") + encData = strings.ReplaceAll(encData, "+", "-") + encData = strings.ReplaceAll(encData, "/", "_") + + return encData, nil +} + +func rot13Switch(str string) string { + return switchCapitalization(rot13(str)) +} + +// Performs rot13 and switches capitalization of each character. +func rot13(str string) string { + result := "" + + for i := range len(str) { + result += string(rot13Byte(str[i])) + } + + return result +} + func rot13Byte(b byte) byte { var a, z byte + switch { case 'a' <= b && b <= 'z': a, z = 'a', 'z' @@ -33,59 +74,20 @@ func rot13Byte(b byte) byte { default: return b } + return (b-a+13)%(z-a+1) + a } func switchCapitalization(str string) string { - var res string = "" - for i := 0; i < len(str); i++ { + res := "" + + for i := range len(str) { if unicode.IsUpper(rune(str[i])) { res += string(unicode.ToLower(rune(str[i]))) } else { res += string(unicode.ToUpper(rune(str[i]))) } } - return res -} - -// performs rot13 and also switches capitalization of each character -func rot13(str string) string { - var result string = "" - for i := 0; i < len(str); i++ { - result += string(rot13Byte(str[i])) - } - return result -} - -func rot13Switch(str string) string { - return switchCapitalization(rot13(str)) -} - -func generateSignature(params string, nonce string) (string, error) { - var rot13Nonce string = rot13Switch(nonce) - var data string = "/web/search" + params + rot13Nonce - - var encData string = anonymize.HashToSHA256B64(data) - encData = strings.ReplaceAll(encData, "=", "") - encData = strings.ReplaceAll(encData, "+", "-") - encData = strings.ReplaceAll(encData, "/", "_") - - // log.Debug(). - // Str("encData", encData). - // Msg("Final") - - return string(encData), nil -} - -// returns nonce, signature -func generateAuth(params string) (string, string, error) { - params = strings.ReplaceAll(params, "+", " ") - - nonce := generateNonce(32) - auth, err := generateSignature(params, nonce) - if err != nil { - return "", "", fmt.Errorf("generateAuth(): %w", err) - } - return nonce, auth, nil + return res } diff --git a/src/search/engines/swisscows/image-1.png b/src/search/engines/swisscows/image-1.png deleted file mode 100644 index a6322f7d59ad1ed0f42cf1a258184cc0d0dfdc43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4850 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 SCPn#|1u)bz)-Bhzi}^p?p?k*w diff --git a/src/search/engines/swisscows/image.png b/src/search/engines/swisscows/image.png deleted file mode 100644 index 9214e9b25d24d28a834873b1498ed4fa5e98f6e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66547 zcmcG$by$?$+b#@JA|)bSN=m16s30NI(%sF_&47w@NOwtzbazQf4&4mh&CvPXgU|bW zpZD1N+xxFQ$HBop_YCV^ajh%P^IQZe$Vp;7A$fv;fPf_}C9Z^k@aP2i33`kQ+!@nv zLPtP&fgmmZ_LE!6?!3=OEsN%j%NniQ?$o4ls&P)-irnsjhFepW26EL4hbWv+9im4KILm{t;<-SzCI%F$$%q2*lAEuX$tV07f?37teOi z;D{CO^Wo;EMf5V#=g#b&SM)L{tmOv7>_Y=D=UzUDf> z=S%u*o%&5N{4wIpCfJLlq@-NcYwgFrch_|dH|h7LDfbt=w>L}$uB`CJ93+H1&zNy* zTh5`LHw`}L2hY}ceD2SEAn|(O#7Qu7N#?!#{lXzHN+l&FC1i?6K)`a|_o4A#1#S`) zsr+Q0gQjx?w~ND<_iHUT@r`H7$11irU{O%hsp|6j`89^yS%lZ=am1%vSC#jq>F_y| zDl<+-*4pftiYjq{b&K9b4i78dwfkJ)++6$IA6%|b-Y0Zd-09s#++U=--?Y3Q0k0ty z&xVM6gwHOnOjTv2evKK9@q*_;(`a-i8ud?zXad>==#tXsRzw6O=ygUM9U@BL>;)em zm1-Y;fA?*L&l%)+MqUH?;-}6JtS>y<)KY}tX{gmix>G@Lr?79wdV4) z$%ozMLO%5FN)&P9BE9?G|1Mn%n)T5C&T^5A`x;6Z<$ckbCqBG|NahdCy#tDk?iCQg zHjpyT=0pSTZ^$u*1?unCz(JC9@UBG7udkabo_TItZtmS5LoQle?wLT)jxXmewH@Ov zepP7c_B%e8f_E2e_l1>6&*AOzzs+M6yDuSyF7JS>Z`#%FDeq78jQUjWc=v9W2_;J5 zA2IGBJXn?7P(H>c;+@J~`caAHeQ3)~NR5o}Lo1O{R~9NND*4@Zwdp+BbGLoxP?q60 z533ly?fQOtC%+)`!E_^OP)^`IrOHcq^kCGH${!J%;A8&7pn8OO+t?jG4}JUMQNL&D z?4H}YjPNhcr|3flHAd(CNVt#4U3SS4sXBKZT|Y~=P_`^UR)5=acs_#N*Tto#FJ=2Lp--Z=&2E$etBKyVgH&r})c`cFUh?emyI= zsJD_YRD)0P=-?IR?J?2hJJFT(WvY;n5N7Dk*P+*4aV!VYgE4A1rD~mm2aE~ufiU=; z5?}Szs5e^IFC2=*TfXya*7om43C}^(-ttg|DelqR9d)Du;p6XZEv|fU4gJ z|NAlRf@kPMOG$EZKl%LCHBWPqTO)n9E`v;E3O!PTz`At%d1tA+y;E_8&$f*jNocAp z>6*gw(@*TNXUlNRNGtFTEMqPThDk%Cia60+G!hyiGrIVDY9(2g#PD%PtK3MJCHwS6 zs}0?S>`q_K)_`CWS|D268qfmk)%S2ild15XPOKlpuZDA8k}@ z9Onz`;|>~oyiS@J+%?P0vHHsCc~6EY6f!a-u36&ES?F+*q<-yvN>*CvG5ER*|JtbNDpd=nnIj7rFEkz1@=c zI_{wd-sjee2{1ZJKraz((Hd_i9#3w7#<8&sScO2^xrmAoM$WdISvSpM3N% zp!(cU$mgGMMAvSccaEA`c<4UUD3!iP**{o_S1P>Jg&K+Hs}J{JuVu(KD}MwKdjWp+}`EuvaT>D5( zXCvJ$I+%E?n~<l zuHRz^x1>wsuZF;<mu_WMUpQ+rdw zFq-fZ>NgzN7Odv&>mYq6LiK~T^_tVj}pLX7M?5jo!9cW$6CGAP2?GW@JnCU zbjSm$h&||K!&<|?H88(6!=rbWVi`Xz^?pPyJ#53wJ#?l+DPIyXnzUx$Gzko9qCzAC zM6}wrtbcsBj;U6xW3;;wGFNZy4w5`OozyGXm3UZ6;T@zaC#(a7yI8y9N77Z9lTGu6+^`=g(orJ$gs4cY`YJBOQLe3q0Kwj z6c_LCcvvvPs(>|)`u&@~(C3p$lMwII& z%OXKGAK#;&MU~somIcV={&@QgCpisvRFiurkQa#R84Z!2lgA9lp5)FxNsrJWpgX9j zR(pRvzT-4Wa5(mEH+;m-=L^dyU5#gYH(~Vo#upyc{xve^ie1mE&*}_+aKNU->@#Bn zxi@4WG{=(*w8!+G1{3EA;Nwcc5AC<0Lo@gAIZ znY+KhA)XE9VsLTvXVTGea&{heHVZpTmc&^P$>DDEqE-eRqIrzDQ_xGvOiyXl+u9YT@t;OT+UiLJ1UtJLjht}Q=21t+>foV5K$H%vAH`0k&8+XV9 z``%~XJz5g%_bZR(@ggA0P>NQ96d9fwidMDT81EX=YBVB!o--Kr`tt1snj7?vd$@b_ zYyni%&7WzAnaZ-9DekeV#+sbt9 zA6@ysrC??)udV&FyewNg`hIfCL9t%oSF}z5IBj-R&`B|dH3D7I!Ed}F>!P~NyC+3)f5ZMm zDfVL5!hF(&`$Tk-*n;{YZZXNy=>Dp%X2WeQI_zggta|muI({o^Rx}R@!7~Gn({Ywu zmwe(&a31Md^ODAL!Zz_17rF8t-(CIc&Uc$eNsjYQWG&|khR?3|dSMDJgv$oKLDHEe zUxAID;M+EFI*OR^&+3Do>N#078%MEv|R{V4;boMsF2O-&~bt*=Q4oM+Bi(vLo zT#d2ktK=s1nHxOfWEjM=#xC>8JD(}v`2fG40S6T2!&nfopJ^Wdg>CqFRJXQQKllTW zXGqXB52u8(*-dohk!ZNhs8zB;x&V(y={K7x!&70#9AHrxaOj^7wXdJI1SH8E@ntio zm}50n1WDw&ACZL%7mRUqjBG1%Zdr(30sdd0-wc-xBC6NMQR!3&!U--(?`|FwaMk)= znIV#_K6Im!HgB9y&i0Xs1tj)(>Vi=mmLp$xxT+>JXS0;tq)ks=XFM^MT=i+P-4!zB zyMlH~OfRL8AkQ}uw%CmJ5Xy2?Z!lzR2JM(mf|Fzp;)h9i>2<_Fg+vt&ykLXe$DgqY z^I=9|Jc4X#-TQg+;4qXW!BI?2`%cEZc+ynCk2o%Nt~t@9lEtYDCI%xB3!**m;_8o< zH#fv!(*}EzCn`A;>Fs2~OYEn6pBw2btD9#fZ=uL7oUwn%-pe=(Yl#Z2F1ZePxs}Bp z$qe{4dq_w~+`Z6Xt{{@m$vUI?Y> z?Zj^)Wsm$p++9}ot@D+1?ZpleRlwio#Q@nI-18MC@{A3+g35O3I@5L;dH)jyP5wqf z&LX+xhbZ_ubY3K7Wc~e*p!jjxO}jd&qIzk)EjrOAN*w#i*H~g)YpJP_^50>R(h&jM zMWK2=PoixY);Lf14mxA~%Qt=xsk3YMzy@wFR& z{J9Y=`Zl5U)3(DAhy4A}8=#uYwe-ugHRtNysao-xq4-eUHn2O6>vi5DUEQN3hm`H5 z6TiA3G;0i14eUL)uY()NegT0JT|lQ#HSH07L$54< zQa4`TAX7s8am65^m8Zff_TMC#e<b9NfQ23R7AUp3Ow3WApjm; zQCy373q;rY`ps<;qq?8u_=7W;P0_g$R%g%AonjPUkccVAX3X4_)wmiy%fsG&;_D< z@DFpKcl|8yPB4pkqEC%dcMwet?(O$^py)p-KOap@`GXUj#ir0mRdJ+%hcM~@{(gYi z_M7f7R8&e#QGidRfa{^xesrm;PFrL9SU_H0BxfUtG zNxc>yZ^fI}Xt4)*DV^Q`<);NLzl<3Bp1^Ov1$=)Uwpv`-W>lv3G9w2Mjn_@Ksp}ji zaVs?_5Pb~uAd`&^k5(Rpii)y;*dOqU(Mp4Ih%Lh2tSB{kjcmYCU@|iT2%5`8hFzb0 z4o;vM(fA^~k|(LWQruf9R8~Jo9DRL|VKs?Rd-jtuiw!w8E-`GE-|6`3W(WG_43}a4 zqrZr|G{#4~=K~J~GIaD>O4Gh|IrEGk^9H za)dW%h--y(wZ^7_1kVCe#{%}9obB^+BRp|>vx@I_N?Jrx-!>Tq(#m|0^+1(|T%E2>CoC6ML@+yDD|QK)eP|aWPd6jpqi|sc9?H+SQ%SzV zZcSd+O1n}ovbH>iA{GwlYjv~B;I1VxkEt{5`voHpaY0o!o9ig4@h`^!3$-|Wy%b$l zBRu&%ezp1@u{eBNf4y85#c5KSdTu`>z>IC5iiG0P z^WW&Lk(}jFU~psLCMa=m^oX&-YWfs7k3x4)QYHx?0N(F)-zftdf3Q55rH;R<;ICuN z&;EbuhFA94zcJC*OZYcso3QT!|g@x7K zY+`i#Er-Xf)`6v?pmJc3m$1%x`WPDC|JOO|cPBLscxr#->QEXB;qt@~Dz+ffq}m>U z{{HbY!Ecb7rA^(Zpsrrq!z+S^Qi}Y@rDa(^NF* z6~>yULB6|)-si!5?KUCydwwP-PL+QB=9G@Fmyvnw&?o($Z4f*Z6yPR{Ut%a@TZp}$ z3=wR#Pcv-=%#?e?X=2633@_}BQ8b=;mm7Lg(LuK$;wg2RK1Bwl>HO2PHviN4Ao$qm z9tte2`tAYZVr|dgv>03`^qRI76}j8M@PA(x4&$a=hoB z`k2xFexFY9OHh3R;K-I@42imjo)xTzf@$6aSP5v*t`Zqqj9;iOW|SH4(o;5Ie1z&5 zmHbLAm~6;r+3X=58bbA!mqqku7QaALO4Egbg9FGg;`aO3sTev4XN#EAYBMs}b0Z~j59~U&oI_EJoH~+R#dRFBS3h3C%(G8fZJuR zhMvPH`=76!?+I3FJiW&X=QFLWr;m%nSH$5SvbNz-DsR8@Q4?~WfWew$a2adA+(ku7 z0TdqS@*El0{4)Q6eVDqG_^al%&o2-0Q&M?neNjm-SX-ORkAa6!*>{#?f{2U@OHe18 zvs2z(MW;XAdEx1PDbrlV9q6*GqhlCop^e63QO9YmNiZ5duMJjKeeixr_afHedBTOe zjli0Hs-}va%btO{qr8s)DHt?({7Ix>?zz!%A(uv)7y#HXX~^M$41X=Kfr^GO*`tzT z(>;-EWAg#$9~rE614kBJCo-CRzB_1k9*ci<-$1J+2NK{Ut~HpOznUGUxAy9l6Fp@u zIfIsm&onwyJDCU8&nW>|I%N%=`2*m2zxt>_(jBaiTurCX-JCs;q9`f)?2map#mUO~ zHF`0nSu1?7c>({OyjD6o+D9HIJ@XtP_Q=BY$0lUGb~4r`q@ZU2reHGd_R?KH4BEG{XgfcVp0&J6y3;V?@{d!!bw5c0#OfGB` zd8T^6{#kV@1yA=vH++>!z0=_$O;R9I=0C;Pa!>~57VZS3Xccx9SuztPqfd0-*lc^Q$z^!=Po-fwb|B;;s(-O!=7i=U`f#1Ox+0gBa&iZP2FIH z?o-^Q_ki~srGxf!hCLH7t!%1EQbRni}8RaCIOM7ek2a`(!XN5*(arp1Q z{t3g?Rg*it+n$QNYUKDV$SJ1fl)@UUbxe>FBK&Ll%^Z}EW=}VOVuNWt3E&&)M;G`Olb)Lz2OCG7 zQP{G;Fd4s}!c*9qcSEiRZ>LdV`rcUHFgW-u&$Squvy%+KKZ@WKS?+hsZ^Gg;_}n+* zwmmM69(372?b&fA%r;Y@DnFo8sn8c&Jx2gKq&77$Vu@F?*ItOQsBdn8U&3&h zem+I4D-AtJ34XRKJB>?TY~eD3HUxKSA&QU#LjhyM04d)oWzd&C2h6R>>@)4;)Ad{Obk(Dyf+cfX1hz1$B$w|dc4uK9Dzog%$?3Eec zdZ|s~_ZIp!bFc9Tj@ItN6GU1P0PC)6KhgKSn41P1^isSS^ZT&wEE%yzvg+s*Gl7V1 zpwN@)S{bJGCrkL^9*8ypfI~T%Wcd3)cxz`P4i0N4>U+{|foJZTt1~A|d0-Vhm}-G4 zBRI-SPn`fVov48{gVMbhlqG&C&GrMUe8cy9)GfJyS_mh3dj@v68^GdrefaTeLQ~5! z?DiJD8rwY_ZvKkT&6IX$$eV9E0m`ClPnXiKIIK)NW-vULduJ%mbk!4ZCW35k*iX^z zTszx_LK%?uPaLxFR7E1bO-F*uJFHdRrN=f4Ih}-JEi5 zL7&f!XGp5Nv(5Fv(IvrUI#JF?(`;7{2&fgs~IR@WWiq><0ghFtyq! zT?N6$Bla+%N7~AI;1>*w=om!O{#=$;7<0Lt|N82@_7PkK|eH7OOJC`_(z< zGz2QzHjPQ~Jag%)D2LUmI>k2=2toBk`q`EMMr1))t$Dz&WHGn|usMMDmoHaHA(RI^ zfLt9_%$)Z6MAJc?Ls3YvV43V|?>SF)DSj3&WiS9aL*ekmQh6T1e1}@Sy@wgm6El&t z@^`XbjC`YIXFa`SCT0TSWSJ?+~r;5L?xoOnD63<44vRK2{H} z%d^xoSm%%$IVZ(sgdsX!%FQ!hrx{U75mcpk0`P*-UWjwOu;wP*`zlTAwt$=Tw%?NF z*4NjMiM_%IQn6EdbTGvk8#WOKl_Cyext6|GDRwOwlmuLO<;?7?VB_%12R1yoqIZBn zAxXtH_{A~tQtDi^_S%Woy(Q_O(gG-(P{=R;_#{nCm>yMO zARH*#h&8r!=B`i%M;o?O3o*R`_VRj! z%{K?&go4!vg#F=z*as%_FDhZO3IBtKr~{qB^ahdu@`m|#p{M9h`Zcak1C2q#2W3Vz z^q@3%jDRRKBd;!X`GDWL3o8j2Go_cLMDr*Uo~4&ICxwKKgdR6M)<;@)tQFGM3rAWL zB8E031UymK{ncy_oe-_^2or(mDbcaD%WvhjyXD^r&`y7ftAd|ir# zqwY>DKfTuOuPD?2e9U@d*v>V^{~FJF$rQlK?_$ud-FLaF;oimA^A2#AddSoYkeNr8 ztP1kAzQgd1LR&7B(c)zl*=I4!V^(a(a>51hi$Ec^{aXo+<1P{3)!rc1%E6lULX4S5ygn=ie?A5*J&o&|@a`8$IthCqY; zWfFq7Yf4slMyCKI2S6$+Y;wNP7mAM31C8{mYzKxOXfC-rJtSA*2$AAkf-Vpt@kdTH zmb;@?^BYG~1FtWpEtf`Tr?&p6+NBZ23m4JN|I|2mEUvV3D{CaXzg&8Wa2dLG$b;70 zOtf_+-x+H0T}me&dUu~b73zF%+}F9PkrW^0j9V< zbN9l{h&|f5wsxr@$fEw+ZL*#ge<3Yj1h~ z@&cT@IhSZU^xPZ*Pt?gi>{8crck~%JDx^cykI6xo?7cQ3p(wX-FYreADj8<|Ml&CKe`kM01-M!Rxl(wSSGtQUzj z05G>txZ;E;yHEq$&LNNr^(6UbFZ#HDP>i@ljIsbPLWbcr;#6W+tWp|h|A$`e6gRba zqTgezGWnsefl!bW*Nd5lNta%hvg*cCdi{M!*HXv3Wfbc@Qv)!=xcmug1#>jaqAyN< z`8S{Uu?gbjXs&fV3V#_l(&xm+9krmSYIrA)zj9)x ztA~cp7wmY9>&i<+$N44>d!8!Hyy*O&{X*v5IwZ=3uA9FsV< zq37+@DHzBb6SlXvm2lp|Z?3AJot=d@MdY3;bXDWkmVR<)Tu^yPpT&>W0IoEjsRll$ zxII4r1omGZ?%#Pe9MQt%$9Vq0ng*sAK6x-IaXF6vMc%QSmqYO;zX`*@7by^rsyFU7eYZvIBjBZEV8sHKq&lV>%qaEls2!L z!oS;+cL04zb4;(9-c5F?S2uDMjE#8bV}oyy8geM$^+2E$A~Vh=Z_B;#As5)Tw@dHt zeZ%+zcOLlo{o_qi)N2c~FhMobDdee?rQX!nmRH4g)7yg&ePYzcwcKQLv^wGhe69A* z6N51bTb#+QS8$T$ue0Wrf_Ym@D;Z%T_~0XNo*PW}0t$ipTG+M7J`1Zu3C_F8`dZEk z3vV^mUwiBIz55%JJ1io%l2^hBc~IAn%ufk1*vX;)I>ua>D5ZqvBEvxBF+WRb7p%)I>|w0C@e^++V^M7x>pVS)1BWf7a$fyr z`X-$5SqAmd47>&maS?KSOUmPel-1Dcfy2`hmUaqo-<|lL2SCvE<%h@O zwmPMV4*(mi--*5%-@UoL{b8=S-u^sIznhc?aelVW65^#T>R`&2R-4c<|8<0Hyvzt%ooJU}vpc zbo#gASZ4*cM1Zf*9u520Dfj6I9BpPi$bgSD`;i9O5QvkjMnZOh=quKdO?;hmMXH+o zU7Q2vR;p~+8){j(j7uSGvJ?>O>CX?$&-Cr!BA6FVM>c=K<6Kbu7BA*N3P2fnaV>VZ zX(ihcWaj->C@yaVLNUWTNhNK7Qbros(ELom`suB=vrN7uEftkgX>@*OiK7C{GGGsH ztMCng84Rm0Zl9Es=yWFIqU4VM>9*R3#k|rX)#lW`0OQL=(p~7_Yd3h&|7MWm95z)0SqVV^sJjKTiiWBoH6Yo}US-DK zBz3!2!YX-(qb_GXYrL)+sCJWf(8uxK=jY?k;gCy{#uFM6lLzehuIfE zM%Oc0)C_%OSUX#QI%jNMBiUd|cEf{YK;lOj97r|=_Ir$s5`Sr7r)ZW4_w;M5Rm%2z~JW?b{m^VIERh5 z7C$2L_8`Q39~2<)ib}$#+52qXwt4~_`?wjHQgbaU(+O_qdR27{0{;#N`C66WNdh+5 zTdRQ=uN%R3Gy%Uf1dvb}yZbjA93t}0PS!>zJom(4thK{;OsiJ3SJ5V%;+sAFVo?-HJSN zhOMdaEkncfUy*^b^7omUy>8hANlA6*yyjaA&c&vSm$naTgTF=YRL!%pBh;Cl>8t&h zq(Mf%%(5X~AHOlJCO9-V1NjX8dS2($)|ys`HV;Uhg8GwV&^f7ta8@HRp1A8sHNnfD zW&sxQsaq!l7CQV&!8c(_c%WWeKqkg@_H0(x52W{{-TROD=Tt(WfH7bGV-_}hyD4Sm zbH^d|C~T2jKsiz$vSaHjRYJINW0UJDkjQw9-ZOj(n?e(QsLYI%f!d1!(Y?6-dP8XT zTO(85Ih{F7-q@N|h>N0xHezR^jO`SeimciIBTqP=@$8@gG!+bC;wu)5U`U@-0}R>W zIc1keB?Tb}LhfriZ>74Fem>RwG=^p95nX z3o-L1=JSfZj0zbmV9V}>pB33aV3T8FnLD)gHHi)E(XZrlI$NtT18t zE$QneG=jK(hcos!kV%s)uN{UMC%emAqa@cF?4B8fqL*&Ti2B=O&EgS*yN8sl4GZxO zwx2oGa(?(EkoV(qvO%zksKGw6K6zC=1*(#(J4IYQOUxtiLg1ADtRX0srYp)kngg32 zn)vc}@kQCYjj?B9t~xZji9%gUfzXJ6+8to66ORW65B}<}VAa2H({%r(QpllYc)ff1 zliUo($UO&YuQmm@4ip~PL+Md3H_vy)hPr$gx_m7>9r|yDny*6rua7trJS2(-e|szM z%-D$C1%=Dr-X6IprR+{- zs11Duc2kOOQ%iO<{(e`VDHWms=R}hzI?T;*+8qVvP(E>KXoK?$y7Yh_DEZ6jD;1{L zHX+A1%Q7Tyr39yGpO?Lcxw6O82c?Am&1E*a^5DocgEC_sJd?rYNBrj^67&B*@%=w$ zDs8olzvku6cIXCH%4PvXI%c{2!?!>83E?1DgJ&%Z>9r`6A)^mzqp@oTh71eWgmb{_ znYuc+kiIP((4gI#W}w>9^&YQAW9;xRW*{SJ4aWCO0(5x(`LEh^na*6DCdif3e$CW* zG?D6v!zsSiRs57pJ&K(l1v@fi-mj-gJHEIB_V-uUH8Z(C^t#$>t}IR+z?oyrxut?u zoF9@lzu%95L}lMDy0cTOF}QkcgmiTGou+5~9OyfbE5<#uPH8y`S(C0(>*!gb9cQh*uz zu=|)y3@(H2>DHao4>s^u^Bv5ONzV!rpvbE1)>brv%7eR~S5S0NLj47JMIfDXC6VxK zJccC%a4!|GC*U)f`dG7&gxVx|t$Vb~;`xGE?j~rVc|Z%)6k9vr>^(d4k~|r%pa!b& zk?+)f?YgR2ia&-K1U?TkWZ7_&&?e#b^K#<*Fblk2sSF8!LsM50Yp|cDMF&Q+v(H2ey87^ z(2HEI;z1kTl~Z2EjtSM|xo)O#PA;4uAASFtdT2%?b|q&^^93fC>(E~;>pjS!eXe&U}_HoHj`2wEJH{q>cAS;c9BF8r#%ieG!XJ?Qpz!+O2e8U~p$* zH=Sn$k!M1Dr~!fv?cXf4LVQq%y73*^TW)g7I@Vq$UR4ysd}&!=kXlmOH_)kyN93&9 z!?7eA-nJR&(z@U-Tc%nVVqq19Efaj}|DF1I5TktyA*;fpd`%dTInK>^!` zwN)h7w+wfmcHXXhQK%=00T$y=zKXiP$n6)!#$`D*LRa6jy3phe!WhP!a#97zO}CpWD+(;1O_3`%|W3 z{pNlHP#pjJ#=pf%O#gt9Z##>{?g2e7A^lH1oMjvkl4i6HdcW|$LLz(>n*6dWXd($E`@m_5*p6|-ip>oQ2{R5#+ z&7#sZhoqYL`~1v>TO?7X0OikcS~D=^`~TSESv>$&k)~bWz5Mkp7<_YtA7=Rv%K56$ z{|?y5Y?O1^Kw5|MWNHlrh9!sEwA}82Fi0j8pBMdQ+g6cdy<(I9eTqON5*&ite*iuu z_f#2QWt4#18E%LRoI@qGqvM_A8F2kOD^j14 z)4T?kFhfUG@l%BdeE^R-$L`7~>y)hb z_@SoB^cYD5aF&3IzWsmN-n%;&!i2__E((Q>vn4>nf%s5d^G^ZD_dmzL;M^tFUp-s4 zA<7BBh-Uq7Vd^3vb%_b8fF4@2vs;hF zec@_07l>E2f0vO9&NRh9v!3HD;H_3+)PIF@xK`Nh*8fxBUSVrl3XS%PYU?h1 z`w##UxXvZ4ES=3^=eVrfikOL&S*1Qi`Se-nSzLx5f7Gd@Ux9?1$D0>igy_l(ca_YK zN>;Ydchi5Z@Y$>k%gE|XSQ5qiT)Hy5 z!sWy5A`#A2PLX(u>iz)mCOBtoe?)MwVd;2D5E4=&)v*shIshmn`s4c8Qp@{?1e48? zNs?3E07&m5--5LbCC5P5pam*$z6p*s>|9#_X9u`Pm1LO3ltV?s*O=pIy*IMvKKBC! z*Ph1}RK-QNkn2H#O&KTYOLUF8-EM^D+I4{E45qg0KXMyfsHxG4nk`@*a+q6E{}FrC zjJu5v+x!&gO}r2|R^yZ8CC@5iT8;f%{^NqsSoIJlce)jdW~~Cs>G;|ac3=w1Hh_$7kmRM27l0%jZHxmr0x1N#KmH2qoc@^xSB+rY^t9}}+>}2H*>xSDww>%Y z$oyPgW!!Aj3Nm_e=IDK`%+sL7(JVlTaIVZMWMM=FUR$fzI1^s~-fd3EVBk$jAF5UU}BZ}Pfgd?n&BMRB?ch^Rtd+x=PZu5N|y28}n{32D;-~}ZSCIjoci+gMX zK-4}~Bf2&U=m*-ItmRpWR^S|L(k1&*os`esoCY``$-*E8^&T<(s8A`IoYdmJ#H1Ng zHp#xic)ZTB#sxJ3ce$n?rBwQLc*yTo++g(_2w%DHhMdp!UYvS6+`N7oN&V_BdupI% zt%u3{{=IuDdW?zxor1)7$}kBHl3o=X5HgG{ATCfMta>6nYrIERJ6Qcj1@gylxf}N5to%c3lo~8 z0D7VuuQ3Odh`pa&Q`{(v!^sqwNKF^PGoIu;5YGhoF%KH(*SyeAkp%ty!+2lh@eYOo zp8q#y_V`OmpGH-0icpfh&wFDK(|;cWkxJ#R74|K;%0J>?pkNOl*z@0$B!)?ZdYp=g z^zyx()^|F_rA+GL01kR|+*a1n14lo;Nr$>t+lB;v-#Tr&;Nu@`#}?h;RRCZshj!h- zfOYijoruTLJIEKMyI$c7HBM5@i>!-)gVclld`YB$N>I{| z)D}wd?+N0jXkhI9_4BQcX%=5f{{+2qc+=89RRymfYL#@4yxfZ&j6O>(vNDCgG;N$1 zgJ_>z99{y#0*DsCwnuAZ0mx*jnXwD=K~IYNFY<0Zv?ZzS^qHaq3JQ_8EZ`sZ3NC5< z6gJVgg?WJ}>>4t-hKLgD#tW;IkPRPp2?4^7S{%PWX?~YaZp=m=zeS$efq51w8M~JZ zaF%-)d7a34A6EsQ-tw(EM^rKNy1{OQ$Y|13nv+&$zt1ar&1R3spTqd5)p^nCZB4n# zkL@LzJkH9aRl7>^!m}0hz>Pa;t>w)3gQ=?Ghi8GxRqp+5y%K5@|kPPcGr&YB$(iGRhcah{IQ(V-RRr0IC<|1naB;Q3D|t zb?E`bWgi7cdH=AIcTxPr)u-BAg~ar)Aykn3l9}aUffE>(mI|SHCjK+L%e14-ZjbGA z3Jyupn^JrX)&A&PhbsPDVVK%B!A}4OL4T)b!>((EtcTMw+sk+P=aoF-&t*H5u*qJC z*f9-rgSMkgC_+(&E2CX-J}#a-MY%oe-8Df4Z{!Vg z!YXW=n0|uh#3bjxv;etyO{C$%%u z&{6kE7dDM!DDh9+aU-)BmEPz<0m9n~>-Ok%y6c+@Evk$R5j}>sA9dAS)mI%fl8=|M zd!xE`MQAGHjX13V=;snOVP!bN+sk8%bcV{&`jcotjVSjSamlrgPY29Zr1M^kG-86c z_K=!eFwDDpb$DGpn7ho*st9XVb5~At@38BgSfLj!@l#^w1^34nee` zDxvF}(*5)t_heSjnPfcAR385l{SvZ>E!uk%cSuBk(N%*4aKie(8Pcz(8o!_9G9Y@! zMf){&dGNbayJQAO{&dGVTZ%^u z0UW@VFI_EQBPyWEj_=O?S*I}d0e43j)0*-G9~`cPE#=je;&{6$G!e_Y?d>>*1c==F z9n)BZmM;YuDt_l0v6(yg%5_awj5!?O2uTk>V?nrFTc;bS748B`5!vq{p{31&I zy@dKwVO*($n;ZINBnL>Ala3+>L(Yd`N7nQ2|C9DmUaxLfe>1q9Z&;G1ZSpEtTRK0unn3s*0TJA}_O!8P+}<}DV5 zTrK=u-6X9APbce@VCY9YYm1*uHy=R$T^b(v*)t?;|2WknfzQwvG7Bz?4%3OKMzkq8 zFwA=0lsUeT-|90gPChT{KD_rh%XdkUpB1Z2Y*v-mD|$%_3rW^k$lZ$2uP&*Z@4tG2 z^9Tnb*9okEDOnhL5uVjR|5Yed)MJC+#!9PpzHE3*I|B^*5>#V0U4n8E*i1Pa>JZ|# z7%QJFMyz*GVdJrvFF$Z>s$p-m+#H7fM2jP<>RMT%*{V;8?t8yTS4Dl#c<3D9<}rn& zJ$r$acYGrgIAj(#rMZ&3^1)cYB1YJ*a@@&bqM%y`ll?fG#lBDh66oo-u>=|+@#j(v znc)qi&01x+6O1o^HjKFNCb1HJ#_JZH&LK9hQ}ttyZZ#gMyzcbkR1JAUZu)4VZUZsoM z*Et@gtk?TguP&=$v&JgV=sfO(u4t?ApF(44D>nKdP#D`pLx_-%%<67w6ZqDnW$Y{8 zM`vf}{D2pE(3gEn8WwON&C4t7i?4C&B!?Xzc;x~*EZG=JWSB3&g0sh=)6Zw_3Io|J zJQ_nTiC&)#bT|JQ)*dn~>`>l4e-W`z!$t36Uv!c&{p-AV%!QDyt_2l1fj_FlaFG8g zw5chy$`oUwGR@s&5;%;dkvq7}$gAWLU@8sEFNx2FYfYSRlf=j?4M&3djK0x_klkIY zG8p3h>0AGzAzAfb$F5XwJQ<@}sEQqwp@WuKO)B9bckN~ z=FXUMNo7lG)*Pr{Pkwjpf~xayJnW4}kL*N9Xez}K2C>ou;u-nnlXcHPe&Juild=pg zFM;yE^t5o$ z|B_K%0mTLc{tq9Vkf~aQC>qEc%CsuHBV z@R7wD1B^ifZHBSj&`ha%KvR2%D&ZQ@kOf!Z;Hl}@0wt8_+IDk3u~$AOT*G_PoX4&k zJ$0bHd+=$xStRcE-Y((h&Ib@;MXdnoH*jdXLU*?;RaX)d_zwg-+dm4dLf1|LfMW+E zBy$B)JK1U6b$P&*|cB06NUxx)yM$%ep znTsT*t*VDc7nR?BH8s59kZwTP2%cO5xMm-mdr{AW^BIY#{reWu!KRl9`i9;;lSMD_ zW(%ZB&pQ1+vn3~fUL+us`w{Y#red*9z5MATv+o2ShXRQmMV|2&5>M3y>q|bxuuy?)9er`*N8#PvNS(I`MBBzj3{xg^=yIUp}rfEL!F1gn=DO&Zo*M@+zXI zW6I;yjbiLhoR77HPw3x+(OxTzp7m3W|f3cp2u7oiX8?Z(1lz8>#&g=p553u^iu-R?m>E>|4KNo~-`FzyFlM18~^ zQSzm{QnOuX|KC*e#)Us30Xh-6A2~-GU$~NF$xn z-3$#1NQcth-8o2icS?6NfJ5Dbe*e4feYr1pt$7(34rlh9z4tHAc!EE#__6SrgWxsH z&DjOL2*zNAz=zSZ@J!OHRI`s}sPEsusiZF3EXc_iXT(u5M^0%XbxaW?Wb$VHcHibJ zZ|A7CE>z+pZ;02s?ZS)?zFALFTbOie9o=}+6ry+ng?b}t_EyACV`F{R>wPo#_)rFF z_>iO2XCwrf(+VcHIImjl`+~aK?#oSNMDOhH%Dh8aqqSP4b%M1bZG;wm(l&FrTezXJI~I6sUWKXUIHiB>)oM$h#7?0K=1v-r1=V>* z&Mb;G_$8Z)s6X^8gVovlLzKVjEtRnU+HsI(jeB$=Js*M3WDHLbu{@r4o<$CKozA;d z1}FY;-=ll%CEO7^NGR`4yS*CnU;VLlEAP+Z9ct&af~o@l9SiDuHlM^6g>uw~Trq}T z@@O<0{@L6|b1@`#MH&Z2!lSmDd89` zEqZ~gUr@cUM3lmH0s$K`jl{zLMegEDOT10F(~LfwupHSp;CxX%Xq0P!slF?YvdL*iWW|kyqbNR0Q zju_E+iL?1+6H~}M>Ug6G%xRqU?u4MW2y;EY+dNbQ0-aE?yRO$Y0x7gu?snvR2%ND1o! zp$hE(dT}d#rf@

-+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 A$bLa`DUmqo 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 z

nxmNJfXd+_Lw<|xoJMf9~RU#2=X^FIaJM3|64`>}56&omscZjIWf+CvPCkIU|~ z`V;@jZ2W5XVm(_YwVsyFZIEGx(EemYLb%Vuo(FMfSs(u%llqbMZ|VbkVDR5L)MV!0 z-MNujf^vUyCUGKR-}FBTRTO^!G~eF@3=~$U69NSmcdma!En7Zea9RBG*RjFTR09>P zDl~Y8-fn-U|C5gXMh#99sbAE<7;o#X+tgRh6@5vn#4>s>&icIhwETS)BlP8NAUV$Vg@s=LG=<4SsL23mulrzs`*M=jx|H9xC9Bup5Wlyx*QW z`5M_gAygH*L+8L{zvb77AHZP9LT^0ed2fOozMe0t0&P)^qznc6Q)|BN8D(tTo4s$8 zf4{_WFHh=ZX-?-YQM|2tj5t{vAHnBrtyQpI_%2s1P7+(z?$7{M|O1{$pLdmhm~X(XMA!s}ui9 zBQMH5!j(fZ^ml_@7Tp_aLcYiPgv_l4mA}u>^ckne<3Yi&3;f@F8rC$D p@V~bV68CR*?RU-ezdv6Oo)9fuYS?l#Phh~0#1~nSav@!x{|`8VlC1y$ diff --git a/src/search/engines/swisscows/infoparams.go b/src/search/engines/swisscows/infoparams.go new file mode 100644 index 00000000..32bdfdf1 --- /dev/null +++ b/src/search/engines/swisscows/infoparams.go @@ -0,0 +1,21 @@ +package swisscows + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.SWISSCOWS, + Domain: "swisscows.com", + URL: "https://api.swisscows.com/web/search", + Origins: []engines.Name{engines.SWISSCOWS, engines.BING}, +} + +var params = scraper.Params{ + Page: "offset", + Locale: "region", // Should be the same as Locale, only with "_" replaced by "-". +} + +const freshnessParam = "freshness=All" +const itemsParam = "itemsCount=10" diff --git a/src/search/engines/swisscows/json_response.go b/src/search/engines/swisscows/json.go similarity index 70% rename from src/search/engines/swisscows/json_response.go rename to src/search/engines/swisscows/json.go index 5d9b627c..744bbf9f 100644 --- a/src/search/engines/swisscows/json_response.go +++ b/src/search/engines/swisscows/json.go @@ -1,13 +1,13 @@ package swisscows -type SCItem struct { +type jsonResponse struct { + Items []jsonItem `json:"items"` +} + +type jsonItem struct { Id string `json:"id"` Title string `json:"title"` Desc string `json:"description"` URL string `json:"url"` DisplayURL string `json:"displayUrl"` } - -type SCResponse struct { - Items []SCItem `json:"items"` -} diff --git a/src/search/engines/swisscows/options.go b/src/search/engines/swisscows/options.go deleted file mode 100644 index 26036243..00000000 --- a/src/search/engines/swisscows/options.go +++ /dev/null @@ -1,16 +0,0 @@ -package swisscows - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "swisscows.com", - Name: engines.SWISSCOWS, - URL: "https://api.swisscows.com/web/search?", - ResultsPerPage: 10, -} - -var Support = engines.SupportedSettings{ - Locale: true, -} diff --git a/src/search/engines/swisscows/params.go b/src/search/engines/swisscows/params.go new file mode 100644 index 00000000..88005194 --- /dev/null +++ b/src/search/engines/swisscows/params.go @@ -0,0 +1,13 @@ +package swisscows + +import ( + "fmt" + "strings" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func localeParamString(locale options.Locale) string { + region := strings.Replace(locale.String(), "_", "-", 1) + return fmt.Sprintf("%v=%v", params.Locale, region) +} diff --git a/src/search/engines/swisscows/search.go b/src/search/engines/swisscows/search.go new file mode 100644 index 00000000..94101e27 --- /dev/null +++ b/src/search/engines/swisscows/search.go @@ -0,0 +1,128 @@ +package swisscows + +import ( + "encoding/json" + "fmt" + "strconv" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + + se.OnRequest(func(r *colly.Request) { + if r.Method == "OPTIONS" { + return + } + + var qry string = "?" + r.URL.RawQuery + nonce, sig, err := generateAuth(qry) + if err != nil { + log.Error(). + Caller(). + Err(err). + Msg("Failed building request, couldn't generate auth") + return + } + + r.Headers.Set("X-Request-Nonce", nonce) + r.Headers.Set("X-Request-Signature", sig) + r.Headers.Set("Pragma", "no-cache") + }) + + se.OnResponse(func(r *colly.Response) { + query := r.Request.URL.Query().Get("query") + urll := r.Request.URL.String() + anonUrll := anonymize.Substring(urll, query) + log.Trace(). + Str("engine", se.Name.String()). + Str("url", anonUrll). + Str("nonce", r.Request.Headers.Get("X-Request-Nonce")). + Str("signature", r.Request.Headers.Get("X-Request-Signature")). + Msg("Got response") + + pageIndex := se.PageFromContext(r.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + var parsedResponse jsonResponse + if err := json.Unmarshal(r.Body, &parsedResponse); err != nil { + log.Error(). + Caller(). + Err(err). + Bytes("body", r.Body). + Msg("Failed to parse response, couldn't unmarshal JSON") + return + } + + counter := 1 + for _, jsonResult := range parsedResponse.Items { + goodURL, goodTitle, goodDesc := parse.SanitizeFields(jsonResult.URL, jsonResult.Title, jsonResult.Desc) + + r, err := result.ConstructResult(se.Name, goodURL, goodTitle, goodDesc, page, counter) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", counter). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + counter++ + } + } + }) + + // Static params. + localeParam := localeParamString(opts.Locale) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := fmt.Sprintf("%v=%v", params.Page, pageNum0*10) + + combinedParams := morestrings.JoinNonEmpty([]string{freshnessParam, itemsParam, pageParam}, "?", "&") + + // Non standard order of parameters required + urll := fmt.Sprintf("%v%v&query=%v&%v", info.URL, combinedParams, query, localeParam) + anonUrll := fmt.Sprintf("%v%v&query=%v&%v", info.URL, combinedParams, anonymize.String(query), localeParam) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/swisscows/search_test.go b/src/search/engines/swisscows/search_test.go new file mode 100644 index 00000000..f38afecb --- /dev/null +++ b/src/search/engines/swisscows/search_test.go @@ -0,0 +1,41 @@ +package swisscows + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/swisscows/swisscows.go b/src/search/engines/swisscows/swisscows.go deleted file mode 100644 index d56d090a..00000000 --- a/src/search/engines/swisscows/swisscows.go +++ /dev/null @@ -1,151 +0,0 @@ -package swisscows - -import ( - "context" - "encoding/json" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - col.OnRequest(func(r *colly.Request) { - if r.Method == "OPTIONS" { - return - } - - var qry string = "?" + r.URL.RawQuery - nonce, sig, err := generateAuth(qry) - if err != nil { - log.Error(). - Caller(). - Err(err). - Msg("Failed building request, couldn't generate auth") - return - } - - r.Headers.Set("X-Request-Nonce", nonce) - r.Headers.Set("X-Request-Signature", sig) - r.Headers.Set("Pragma", "no-cache") - }) - - col.OnResponse(func(r *colly.Response) { - query := r.Request.URL.Query().Get("query") - urll := r.Request.URL.String() - anonUrll := anonymize.Substring(urll, query) - log.Trace(). - Str("engine", Info.Name.String()). - Str("url", anonUrll). - Str("nonce", r.Request.Headers.Get("X-Request-Nonce")). - Str("signature", r.Request.Headers.Get("X-Request-Signature")). - Msg("Got response") - - pageIndex := _sedefaults.PageFromContext(r.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - var parsedResponse SCResponse - err := json.Unmarshal(r.Body, &parsedResponse) - if err != nil { - log.Error(). - Caller(). - Err(err). - Bytes("body", r.Body). - Msg("Failed to parse response, couldn't unmarshal JSON") - - return - } - - counter := 1 - for _, result := range parsedResponse.Items { - goodLink, goodTitle, goodDesc := _sedefaults.SanitizeFields(result.URL, result.Title, result.Desc) - - res := bucket.MakeSEResult(goodLink, goodTitle, goodDesc, Info.Name, page, counter) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - counter += 1 - } - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // static params - localeParam := getLocale(options) - itemsParam := "freshness=All&itemsCount=" + strconv.Itoa(settings.RequestedResultsPerPage) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - //col.Request("OPTIONS", seAPIURL+"freshness=All&itemsCount="+strconv.Itoa(sResCount)+"&offset="+strconv.Itoa(i*10)+"&query="+query+localeURL, nil, colCtx, nil) - //col.Wait() - - // dynamic params - offsetParam := "&offset=" + strconv.Itoa(i*10) - - urll := Info.URL + itemsParam + offsetParam + "&query=" + query + localeParam - anonUrll := Info.URL + itemsParam + offsetParam + "&query=" + anonymize.String(query) + localeParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getLocale(options engines.Options) string { - return "®ion=" + strings.Replace(options.Locale, "_", "-", 1) -} - -/* -var pageRankCounter []int = make([]int, options.Pages.Max*Info.ResPerPage) -col.OnHTML("div.web-results > article.item-web", func(e *colly.HTMLElement) { - dom := e.DOM - - linkHref, hrefExists := dom.Find("a.site").Attr("href") - linkText := parse.ParseURL(linkHref) - titleText := strings.TrimSpace(dom.Find("h2.title").Text()) - descText := strings.TrimSpace(dom.Find("p.description").Text()) - - if hrefExists && linkText != "" && linkText != "#" && titleText != "" { - var pageStr string = e.Request.Ctx.Get("page") - page, _ := strconv.Atoi(pageStr) - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, -1, page, pageRankCounter[page]+1) - bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - pageRankCounter[page]++ - } else { - log.Trace(). - Str("engine", Info.Name.String()). - Str("url", linkText). - Str("title", titleText). - Str("description", descText). - Msg("Matched result, but couldn't retrieve data") - } -}) -*/ diff --git a/src/search/engines/swisscows/swisscows.md b/src/search/engines/swisscows/swisscows.md deleted file mode 100644 index 4944dd68..00000000 --- a/src/search/engines/swisscows/swisscows.md +++ /dev/null @@ -1,19 +0,0 @@ -# Swisscows - -Clicking search makes some HEAD, OPTIONS, GET requests: -HEAD https://swisscows.com/_next/data/cW_SbMyHn51vQiG0qo8e9/en/web.json?query=cars on sale -OPTIONS https://api.swisscows.com/web/search?query=cars+on+sale&offset=0&itemsCount=10®ion=de-CH&freshness=All -GET https://api.swisscows.com/web/search?query=cars+on+sale&offset=0&itemsCount=10®ion=de-CH&freshness=All - -We can use: -https://swisscows.com/en/web?query=\&offset=\<(page-1) * 10> - -Or we can directly call the API: -https://api.swisscows.com/web/search?query=some+wrequest&offset=0&itemsCount=10®ion=de-CH&freshness=All -Response: -![Alt text](image.png) - -To use the API you have to pass these two headers: -![Alt text](image-1.png) - -Converting the complicated JS to Golang is hard, so we just run a JS parser in `dontaskjustenjoy.go`. \ No newline at end of file diff --git a/src/search/engines/swisscows/swisscows_test.go b/src/search/engines/swisscows/swisscows_test.go deleted file mode 100644 index 966a0d5d..00000000 --- a/src/search/engines/swisscows/swisscows_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package swisscows_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.SWISSCOWS - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/timeout.go b/src/search/engines/timeout.go deleted file mode 100644 index 3ccfee77..00000000 --- a/src/search/engines/timeout.go +++ /dev/null @@ -1,12 +0,0 @@ -package engines - -import ( - "net" -) - -func IsTimeoutError(err error) bool { - if perr, ok := err.(net.Error); ok && perr.Timeout() { - return true - } - return false -} diff --git a/src/search/engines/yahoo/cookies.go b/src/search/engines/yahoo/cookies.go new file mode 100644 index 00000000..ca1ac657 --- /dev/null +++ b/src/search/engines/yahoo/cookies.go @@ -0,0 +1,13 @@ +package yahoo + +import ( + "fmt" +) + +func safeSearchCookieString(safesearch bool) string { + if safesearch { + return fmt.Sprintf("%v=%v", params.SafeSearch, "r") + } else { + return fmt.Sprintf("%v=%v", params.SafeSearch, "p") + } +} diff --git a/src/search/engines/yahoo/dompaths.go b/src/search/engines/yahoo/dompaths.go new file mode 100644 index 00000000..aa644be5 --- /dev/null +++ b/src/search/engines/yahoo/dompaths.go @@ -0,0 +1,12 @@ +package yahoo + +import ( + "github.com/hearchco/agent/src/search/scraper" +) + +var dompaths = scraper.DOMPaths{ + Result: "div#main > div > div#web > ol > li > div.algo", + URL: "h3.title > a", + Title: "h3.title > a", + Description: "div > div.compText > p > span", +} diff --git a/src/search/engines/yahoo/infoparams.go b/src/search/engines/yahoo/infoparams.go new file mode 100644 index 00000000..f16444af --- /dev/null +++ b/src/search/engines/yahoo/infoparams.go @@ -0,0 +1,20 @@ +package yahoo + +import ( + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +var info = scraper.Info{ + Name: engines.YAHOO, + Domain: "search.yahoo.com", + URL: "https://search.yahoo.com/search", + Origins: []engines.Name{engines.YAHOO, engines.BING}, +} + +var params = scraper.Params{ + Page: "b", + SafeSearch: "vm", // Can be "p" (disabled) or "r" (enabled). +} + +const safeSearchCookiePrefix = "sB=v=1&pn=10&rw=new&userset=0" diff --git a/src/search/engines/yahoo/options.go b/src/search/engines/yahoo/options.go deleted file mode 100644 index aa3b75be..00000000 --- a/src/search/engines/yahoo/options.go +++ /dev/null @@ -1,27 +0,0 @@ -package yahoo - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -// doesn't catch the yt videos -// the title cathes the link - e.g.: teentitans.fandom.com › wiki › Nya-NyaNya-Nya | Teen Titans Wiki | Fandom -// but should be just: Nya-Nya | Teen Titans Wiki | Fandom - -var Info = engines.Info{ - Domain: "search.yahoo.com", - Name: engines.YAHOO, - URL: "https://search.yahoo.com/search?p=", - ResultsPerPage: 10, -} - -var dompaths = engines.DOMPaths{ - Result: "div#main > div > div#web > ol > li > div.algo", - Link: "h3.title > a", - Title: "h3.title > a", - Description: "div > div.compText > p > span", -} - -var Support = engines.SupportedSettings{ - SafeSearch: true, -} diff --git a/src/search/engines/yahoo/search.go b/src/search/engines/yahoo/search.go new file mode 100644 index 00000000..dd51b9ee --- /dev/null +++ b/src/search/engines/yahoo/search.go @@ -0,0 +1,129 @@ +package yahoo + +import ( + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/search/scraper/parse" + "github.com/hearchco/agent/src/utils/anonymize" + "github.com/hearchco/agent/src/utils/morestrings" +) + +type Engine struct { + scraper.EngineBase +} + +func New() *Engine { + return &Engine{EngineBase: scraper.EngineBase{ + Name: info.Name, + Origins: info.Origins, + }} +} + +func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { + foundResults := atomic.Bool{} + retErrors := make([]error, 0, opts.Pages.Max) + pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + + se.OnRequest(func(r *colly.Request) { + r.Headers.Add("Cookie", fmt.Sprintf("%v&%v", safeSearchCookiePrefix, safeSearchCookieString(opts.SafeSearch))) + }) + + se.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { + dom := e.DOM + + titleEl := dom.Find(dompaths.Title) + titleAria, labelExists := titleEl.Attr("aria-label") + if !labelExists { + log.Error(). + Caller(). + Str("engine", se.Name.String()). + Str("title selector", dompaths.Title). + Msg("Aria attribute doesn't exist on matched title element") + return + } + titleText := strings.TrimSpace(titleAria) + + urlHref, hrefExists := titleEl.Attr("href") + if !hrefExists { + log.Error(). + Caller(). + Str("engine", se.Name.String()). + Str("link selector", dompaths.URL). + Msg("Href attribute doesn't exist on matched URL element") + return + } + + urlText, err := removeTelemetry(urlHref) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("engine", se.Name.String()). + Str("url", urlText). + Msg("Failed to remove telemetry") + return + } + + descText := dom.Find(dompaths.Description).Text() + + urlText, titleText, descText = parse.SanitizeFields(urlText, titleText, descText) + + pageIndex := se.PageFromContext(e.Request.Ctx) + page := pageIndex + opts.Pages.Start + 1 + + r, err := result.ConstructResult(se.Name, urlText, titleText, descText, page, pageRankCounter.GetPlusOne(pageIndex)) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("result", fmt.Sprintf("%v", r)). + Msg("Failed to construct result") + } else { + log.Trace(). + Caller(). + Int("page", page). + Int("rank", pageRankCounter.GetPlusOne(pageIndex)). + Str("result", fmt.Sprintf("%v", r)). + Msg("Sending result to channel") + resChan <- r + pageRankCounter.Increment(pageIndex) + if !foundResults.Load() { + foundResults.Store(true) + } + } + }) + + for i := range opts.Pages.Max { + pageNum0 := i + opts.Pages.Start + ctx := colly.NewContext() + ctx.Put("page", strconv.Itoa(i)) + + // Dynamic params. + pageParam := "" + if pageNum0 > 0 { + pageParam = fmt.Sprintf("%v=%v", params.Page, (pageNum0-1)*7+8) + } + + combinedParams := morestrings.JoinNonEmpty([]string{pageParam}, "&", "&") + + urll := fmt.Sprintf("%v?p=%v%v", info.URL, query, combinedParams) + anonUrll := fmt.Sprintf("%v?p=%v%v", info.URL, anonymize.String(query), combinedParams) + + if err := se.Get(ctx, urll, anonUrll); err != nil { + retErrors = append(retErrors, err) + } + } + + se.Wait() + close(resChan) + return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +} diff --git a/src/search/engines/yahoo/search_test.go b/src/search/engines/yahoo/search_test.go new file mode 100644 index 00000000..e1367dfc --- /dev/null +++ b/src/search/engines/yahoo/search_test.go @@ -0,0 +1,41 @@ +package yahoo + +import ( + "context" + "testing" + + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/_engines_test" +) + +func TestSearch(t *testing.T) { + // Search engine name + seName := info.Name + + // testing options + conf := _engines_test.NewConfig(seName) + opt := _engines_test.NewOpts() + + // test cases + tchar := []_engines_test.TestCaseHasAnyResults{{ + Query: "ping", + Options: opt, + }} + + tccr := []_engines_test.TestCaseContainsResults{{ + Query: "facebook", + ResultURLs: []string{"facebook.com"}, + Options: opt, + }} + + tcrr := []_engines_test.TestCaseRankedResults{{ + Query: "wikipedia", + ResultURLs: []string{"wikipedia."}, + Options: opt, + }} + + se := New() + se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + + _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +} diff --git a/src/search/engines/yahoo/telemetry.go b/src/search/engines/yahoo/telemetry.go new file mode 100644 index 00000000..50cefe9b --- /dev/null +++ b/src/search/engines/yahoo/telemetry.go @@ -0,0 +1,22 @@ +package yahoo + +import ( + "net/url" + "strings" +) + +func removeTelemetry(urll string) (string, error) { + if !strings.Contains(urll, "://r.search.yahoo.com/") { + return urll, nil + } + + suff := strings.SplitAfterN(urll, "/RU=http", 2)[1] + urll = "http" + strings.SplitN(suff, "/RK=", 2)[0] + + newLink, err := url.QueryUnescape(urll) + if err != nil { + return "", err + } + + return newLink, nil +} diff --git a/src/search/engines/yahoo/yahoo.go b/src/search/engines/yahoo/yahoo.go deleted file mode 100644 index 25a1744c..00000000 --- a/src/search/engines/yahoo/yahoo.go +++ /dev/null @@ -1,146 +0,0 @@ -package yahoo - -import ( - "context" - "net/url" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - safeSearchCookieParam := getSafeSearch(options) - - col.OnRequest(func(r *colly.Request) { - r.Headers.Add("Cookie", "sB=v=1&pn=10&rw=new&userset=0"+safeSearchCookieParam) - }) - - col.OnHTML(dompaths.Result, func(e *colly.HTMLElement) { - dom := e.DOM - - titleEl := dom.Find(dompaths.Title) - titleAria, labelExists := titleEl.Attr("aria-label") - titleText := strings.TrimSpace(titleAria) - - linkHref, hrefExists := titleEl.Attr("href") - linkText := removeTelemetry(linkHref) - - descText := dom.Find(dompaths.Description).Text() - - linkText, titleText, descText = _sedefaults.SanitizeFields(linkText, titleText, descText) - - if !hrefExists { - log.Error(). - Caller(). - Str("engine", Info.Name.String()). - Str("url", linkText). - Str("title", titleText). - Str("description", descText). - Str("link selector", dompaths.Link). - Msg("Href attribute doesn't exist on matched URL element") - - return - } - - if !labelExists { - log.Error(). - Caller(). - Str("engine", Info.Name.String()). - Str("url", linkText). - Str("title", titleText). - Str("description", descText). - Str("title selector", dompaths.Title). - Msg("Aria attribute doesn't exist on matched title element") - - return - } - - pageIndex := _sedefaults.PageFromContext(e.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - res := bucket.MakeSEResult(linkText, titleText, descText, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "" - // i == 0 is the first page - if i > 0 { - pageParam = "&b=" + strconv.Itoa((i+1)*10) - } - - urll := Info.URL + query + pageParam - anonUrll := Info.URL + anonymize.String(query) + pageParam - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func removeTelemetry(link string) string { - if !strings.Contains(link, "://r.search.yahoo.com/") { - return link - } - - suff := strings.SplitAfterN(link, "/RU=http", 2)[1] - link = "http" + strings.SplitN(suff, "/RK=", 2)[0] - - newLink, err := url.QueryUnescape(link) - if err != nil { - log.Error(). - Caller(). - Err(err). - Str("url", link). - Msg("Couldn't parse url, url.QueryUnescape() failed") - - return "" - } - - return newLink -} - -func getSafeSearch(options engines.Options) string { - if options.SafeSearch { - return "&vm=r" - } - return "&vm=p" -} diff --git a/src/search/engines/yahoo/yahoo_test.go b/src/search/engines/yahoo/yahoo_test.go deleted file mode 100644 index 4725ad0a..00000000 --- a/src/search/engines/yahoo/yahoo_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package yahoo_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.YAHOO - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "facebook", - ResultURL: []string{"facebook.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/engines/yep/infoparams.go b/src/search/engines/yep/infoparams.go new file mode 100644 index 00000000..75994f58 --- /dev/null +++ b/src/search/engines/yep/infoparams.go @@ -0,0 +1,23 @@ +package yep + +// import ( +// "github.com/hearchco/agent/src/search/engines" +// "github.com/hearchco/agent/src/search/scraper" +// ) + +// var info = scraper.Info{ +// Name: engines.YEP, +// Domain: "yep.com", +// URL: "https://api.yep.com/fs/2/search", +// Origins: []engines.Name{engines.YEP}, +// } + +// var params = scraper.Params{ +// Page: "limit", +// Locale: "gl", // Should be last 2 characters of Locale. +// SafeSearch: "safeSearch", // Can be "off" or "strict". +// } + +// const clientParam = "client=web" +// const no_correctParam = "no_correct=false" +// const typeParam = "type=web" diff --git a/src/search/engines/yep/json.go b/src/search/engines/yep/json.go index fb361a1c..c69de6ae 100644 --- a/src/search/engines/yep/json.go +++ b/src/search/engines/yep/json.go @@ -1,15 +1,12 @@ package yep -type Result struct { - URL string `json:"url"` - Title string `json:"title"` - TType string `json:"type"` - Snippet string `json:"snippet"` - // VisualURL string `json:"visual_url"` - // FirstSeen string `json:"first_seen"` -} +// type jsonResponse struct { +// Results []jsonResult `json:"results"` +// } -type JsonResponse struct { - // Total int `json:"total"` - Results []Result `json:"results"` -} +// type jsonResult struct { +// URL string `json:"url"` +// Title string `json:"title"` +// TType string `json:"type"` +// Snippet string `json:"snippet"` +// } diff --git a/src/search/engines/yep/options.go b/src/search/engines/yep/options.go deleted file mode 100644 index 54550e09..00000000 --- a/src/search/engines/yep/options.go +++ /dev/null @@ -1,26 +0,0 @@ -package yep - -import ( - "github.com/hearchco/hearchco/src/search/engines" -) - -var Info = engines.Info{ - Domain: "yep.com", - Name: engines.YEP, - URL: "https://api.yep.com/fs/2/search?", - ResultsPerPage: 20, -} - -/* -var dompaths = engines.DOMPaths{ - Result: "div.css-102xgmn-card", - Link: "a.css-29ut38-noDecoration", - Title: "a.css-29ut38-noDecoration", - Description: "div.css-1bozosu-snippet", -} -*/ - -var Support = engines.SupportedSettings{ - Locale: true, - SafeSearch: true, -} diff --git a/src/search/engines/yep/params.go b/src/search/engines/yep/params.go new file mode 100644 index 00000000..59d992e2 --- /dev/null +++ b/src/search/engines/yep/params.go @@ -0,0 +1,21 @@ +package yep + +// import ( +// "fmt" +// "strings" + +// "github.com/hearchco/agent/src/search/engines/options" +// ) + +// func localeParamString(locale options.Locale) string { +// country := strings.Split(locale.String(), "_")[1] +// return fmt.Sprintf("%v=%v", params.Locale, country) +// } + +// func safeSearchParamString(safesearch bool) string { +// if safesearch { +// return fmt.Sprintf("%v=%v", params.SafeSearch, "strict") +// } else { +// return fmt.Sprintf("%v=%v", params.SafeSearch, "off") +// } +// } diff --git a/src/search/engines/yep/search.go b/src/search/engines/yep/search.go new file mode 100644 index 00000000..0410bced --- /dev/null +++ b/src/search/engines/yep/search.go @@ -0,0 +1,131 @@ +package yep + +// import ( +// "encoding/json" +// "fmt" +// "strconv" +// "strings" + +// "github.com/gocolly/colly/v2" +// "github.com/rs/zerolog/log" + +// "github.com/hearchco/agent/src/search/engines/options" +// "github.com/hearchco/agent/src/search/result" +// "github.com/hearchco/agent/src/search/scraper" +// "github.com/hearchco/agent/src/search/scraper/parse" +// "github.com/hearchco/agent/src/utils/anonymize" +// "github.com/hearchco/agent/src/utils/morestrings" +// ) + +// type Engine struct { +// scraper.EngineBase +// } + +// func New() *Engine { +// return &Engine{EngineBase: scraper.EngineBase{ +// Name: info.Name, +// Origins: info.Origins, +// }} +// } + +// func (se Engine) Search(query string, opts options.Options, resChan chan result.ResultScraped) ([]error, bool) { +// retErrors := make([]error, 0, opts.Pages.Max) +// pageRankCounter := scraper.NewPageRankCounter(opts.Pages.Max) + +// se.OnRequest(func(r *colly.Request) { +// r.Headers.Set("Accept", "*/*") +// }) + +// se.OnResponse(func(r *colly.Response) { +// body := string(r.Body) +// prefix := "[\"Ok\"," +// suffix := ']' +// index := strings.Index(body, prefix) + +// if index != 0 || body[len(body)-1] != byte(suffix) { +// log.Error(). +// Caller(). +// Str("engine", se.Name.String()). +// Str("body", body). +// Str("prefix", prefix). +// Str("suffix", string(suffix)). +// Msg("Failed parsing response, couldn't find start/end of JSON") +// return +// } + +// // starts after prefix and ends before suffix +// // so after "[\"Ok\"," and before "]" +// resultsJson := body[len(prefix) : len(body)-1] +// var content jsonResponse +// if err := json.Unmarshal([]byte(resultsJson), &content); err != nil { +// log.Error(). +// Caller(). +// Err(err). +// Str("engine", se.Name.String()). +// Str("body", body). +// Str("content", resultsJson). +// Msg("Failed parsing response, couldn't unmarshal JSON") +// return +// } + +// pageIndex := se.PageFromContext(r.Request.Ctx) +// page := pageIndex + opts.Pages.Start + 1 + +// for _, jsonResult := range content.Results { +// if jsonResult.TType != "Organic" { +// continue +// } + +// goodURL, goodTitle, goodDesc := parse.SanitizeFields(jsonResult.URL, jsonResult.Title, jsonResult.Snippet) + +// r, err := result.ConstructResult(se.Name, goodURL, goodTitle, goodDesc, page, pageRankCounter.GetPlusOne(pageIndex)) +// if err != nil { +// log.Error(). +// Caller(). +// Err(err). +// Str("result", fmt.Sprintf("%v", r)). +// Msg("Failed to construct result") +// } else { +// log.Trace(). +// Caller(). +// Int("page", page). +// Int("rank", pageRankCounter.GetPlusOne(pageIndex)). +// Str("result", fmt.Sprintf("%v", r)). +// Msg("Sending result to channel") +// resChan <- r +// pageRankCounter.Increment(pageIndex) +// } +// } +// }) + +// // Static params. +// localeParam := localeParamString(opts.Locale) +// safeSearchParam := safeSearchParamString(opts.SafeSearch) + +// for i := range opts.Pages.Max { +// pageNum := i + opts.Pages.Start +// ctx := colly.NewContext() +// ctx.Put("page", strconv.Itoa(i)) + +// // Dynamic params. +// pageParam := "" +// if pageNum > 0 { +// pageParam = fmt.Sprintf("%v=%v", params.Page, (pageNum+2)*10+1) +// } + +// combinedParamsLeft := morestrings.JoinNonEmpty([]string{clientParam, localeParam, pageParam, no_correctParam}, "&", "&") +// combinedParamsRight := morestrings.JoinNonEmpty([]string{safeSearchParam, typeParam}, "&", "&") + +// // Non standard order of params required +// urll := fmt.Sprintf("%v?%v&q=%v&%v", info.URL, combinedParamsLeft, query, combinedParamsRight) +// anonUrll := fmt.Sprintf("%v?%v&q=%v&%v", info.URL, combinedParamsLeft, anonymize.String(query), combinedParamsRight) + +// if err := se.Get(ctx, urll, anonUrll); err != nil { +// retErrors = append(retErrors, err) +// } +// } + +// se.Wait() +// close(resChan) +// return retErrors[:len(retErrors):len(retErrors)], foundResults.Load() +// } diff --git a/src/search/engines/yep/search_test.go b/src/search/engines/yep/search_test.go new file mode 100644 index 00000000..db85c794 --- /dev/null +++ b/src/search/engines/yep/search_test.go @@ -0,0 +1,41 @@ +package yep + +// import ( +// "context" +// "testing" + +// "github.com/hearchco/agent/src/search/category" +// "github.com/hearchco/agent/src/search/engines/_engines_test" +// ) + +// func TestSearch(t *testing.T) { +// // Search engine name +// seName := info.Name + +// // testing options +// conf := _engines_test.NewConfig(seName) +// opt := _engines_test.NewOpts() + +// // test cases +// tchar := []_engines_test.TestCaseHasAnyResults{{ +// Query: "ping", +// Options: opt, +// }} + +// tccr := []_engines_test.TestCaseContainsResults{{ +// Query: "youtube", +// ResultURLs: []string{"youtube.com"}, +// Options: opt, +// }} + +// tcrr := []_engines_test.TestCaseRankedResults{{ +// Query: "wikipedia", +// ResultURLs: []string{"wikipedia."}, +// Options: opt, +// }} + +// se := New() +// se.Init(context.Background(), conf.Categories[category.GENERAL].Timings) + +// _engines_test.CheckTestCases(t, se, tchar, tccr, tcrr) +// } diff --git a/src/search/engines/yep/yep.go b/src/search/engines/yep/yep.go deleted file mode 100644 index 2d70495a..00000000 --- a/src/search/engines/yep/yep.go +++ /dev/null @@ -1,131 +0,0 @@ -package yep - -import ( - "context" - "encoding/json" - "strconv" - "strings" - - "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_sedefaults" - "github.com/rs/zerolog/log" -) - -type Engine struct{} - -func New() Engine { - return Engine{} -} - -func (e Engine) Search(ctx context.Context, query string, relay *bucket.Relay, options engines.Options, settings config.Settings, timings config.CategoryTimings, salt string, nEnabledEngines int) []error { - ctx, err := _sedefaults.Prepare(ctx, Info, Support, options, settings) - if err != nil { - return []error{err} - } - - col, pagesCol := _sedefaults.InitializeCollectors(ctx, Info.Name, options, settings, timings, relay) - - pageRankCounter := make([]int, options.Pages.Max) - - col.OnRequest(func(r *colly.Request) { - r.Headers.Del("Accept") - }) - - col.OnResponse(func(r *colly.Response) { - body := string(r.Body) - start := "[\"Ok\"," - end := ']' - index := strings.Index(body, start) - - if index != 0 || body[len(body)-1] != byte(end) { - log.Error(). - Caller(). - Str("engine", Info.Name.String()). - Str("body", body). - Str("start", start). - Str("end", string(end)). - Msg("Failed parsing response, couldn't find start/end of JSON") - return - } - - // starts after start and ends before end - // so after "[\"Ok\"," and before "]" - resultsJson := body[len(start) : len(body)-1] - var content JsonResponse - if err := json.Unmarshal([]byte(resultsJson), &content); err != nil { - log.Error(). - Caller(). - Err(err). - Str("engine", Info.Name.String()). - Str("body", body). - Str("content", resultsJson). - Msg("Failed parsing response, couldn't unmarshal JSON") - return - } - - pageIndex := _sedefaults.PageFromContext(r.Request.Ctx, Info.Name) - page := pageIndex + options.Pages.Start + 1 - - for _, result := range content.Results { - if result.TType != "Organic" { - continue - } - - goodLink, goodTitle, goodDescription := _sedefaults.SanitizeFields(result.URL, result.Title, result.Snippet) - - res := bucket.MakeSEResult(goodLink, goodTitle, goodDescription, Info.Name, page, pageRankCounter[pageIndex]+1) - valid := bucket.AddSEResult(&res, Info.Name, relay, options, pagesCol, nEnabledEngines) - if valid { - pageRankCounter[pageIndex]++ - } - } - }) - - retErrors := make([]error, 0, options.Pages.Max) - - // static params - localeParam := getLocale(options) - safeSearchParam := getSafeSearch(options) - - // starts from at least 0 - for i := options.Pages.Start; i < options.Pages.Start+options.Pages.Max; i++ { - colCtx := colly.NewContext() - colCtx.Put("page", strconv.Itoa(i-options.Pages.Start)) - - // dynamic params - pageParam := "" - // i == 0 is the first page - if i > 0 { - pageParam = "&limit=" + strconv.Itoa((i+2)*10+1) - } - - urll := Info.URL + "client=web" + localeParam + pageParam + "&no_correct=false&q=" + query + safeSearchParam + "&type=web" - anonUrll := Info.URL + "client=web" + localeParam + pageParam + "&no_correct=false&q=" + anonymize.String(query) + safeSearchParam + "&type=web" - - err := _sedefaults.DoGetRequest(urll, anonUrll, colCtx, col, Info.Name) - if err != nil { - retErrors = append(retErrors, err) - } - } - - col.Wait() - pagesCol.Wait() - - return retErrors[:len(retErrors):len(retErrors)] -} - -func getLocale(options engines.Options) string { - locale := strings.Split(options.Locale, "_")[1] - return "&gl=" + locale -} - -func getSafeSearch(options engines.Options) string { - if options.SafeSearch { - return "&safeSearch=strict" - } - return "&safeSearch=off" -} diff --git a/src/search/engines/yep/yep.md b/src/search/engines/yep/yep.md deleted file mode 100644 index 4cce8d8a..00000000 --- a/src/search/engines/yep/yep.md +++ /dev/null @@ -1,16 +0,0 @@ -# Yep - -GET call example: https://yep.com/web?q=something -API call default: https://api.yep.com/fs/2/search?client=web&gl=RS&no_correct=false&q=something&safeSearch=off&type=web -API call load more: https://api.yep.com/fs/2/search?client=web&gl=RS&limit=31&no_correct=false&q=something&safeSearch=off&type=web - -The `safeSearch` parameter can have the values: `off`, `moderate`, `strict`. Currently only `off` and `strict` are supported. -The `type` parameter can have the values: `web`, `images`, `news`. - -API logic: - -1. API call without limit gives first 20 results (ranked 0-20) -2. API call for second page (limits=31) gives second 20 results (ranked 0-20) -3. API call for third page (limits=41) gives first 20 and third 20 results (0-20 are repeated of 1, 21-40 are new results) -4. API call for fourth page (limits=51) gives second 20 results (ranked 0-20 are repeated of 2, no new results, probably because it doesn't have any) -5. API call for fifth page (limits=61) gives same as 4 (probably because it doesn't have any) diff --git a/src/search/engines/yep/yep_test.go b/src/search/engines/yep/yep_test.go deleted file mode 100644 index 70d478e0..00000000 --- a/src/search/engines/yep/yep_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package yep_test - -import ( - "testing" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/engines/_engines_test" -) - -func TestSearch(t *testing.T) { - engineName := engines.YEP - - // testing config - conf := _engines_test.NewConfig(engineName) - opt := _engines_test.NewOpts() - - // test cases - tchar := [...]_engines_test.TestCaseHasAnyResults{{ - Query: "ping", - Options: opt, - }} - - tccr := [...]_engines_test.TestCaseContainsResults{{ - Query: "youtube", - ResultURL: []string{"youtube.com"}, - Options: opt, - }} - - tcrr := [...]_engines_test.TestCaseRankedResults{{ - Query: "wikipedia", - ResultURL: []string{"wikipedia."}, - Options: opt, - }} - - _engines_test.CheckTestCases(tchar[:], tccr[:], tcrr[:], t, conf) -} diff --git a/src/search/init.go b/src/search/init.go new file mode 100644 index 00000000..9f7c5c97 --- /dev/null +++ b/src/search/init.go @@ -0,0 +1,17 @@ +package search + +import ( + "context" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" +) + +func initializeEnginers(ctx context.Context, engs []engines.Name, timings config.CategoryTimings) []scraper.Enginer { + enginers := enginerArray() + for _, engName := range engs { + enginers[engName].Init(ctx, timings) + } + return enginers[:] +} diff --git a/src/search/once.go b/src/search/once.go new file mode 100644 index 00000000..203fcdd7 --- /dev/null +++ b/src/search/once.go @@ -0,0 +1,46 @@ +package search + +import ( + "sync" + "sync/atomic" + + "github.com/hearchco/agent/src/search/engines" +) + +type onceWrapper struct { + once *sync.Once + errored atomic.Bool + scraped atomic.Bool +} + +func initOnceWrapper(engs []engines.Name) map[engines.Name]*onceWrapper { + searchOnce := make(map[engines.Name]*onceWrapper, len(engs)) + for _, eng := range engs { + searchOnce[eng] = &onceWrapper{ + once: &sync.Once{}, + errored: atomic.Bool{}, + scraped: atomic.Bool{}, + } + } + return searchOnce +} + +func (ow *onceWrapper) Do(f func()) { + ow.once.Do(f) +} + +func (ow *onceWrapper) Errored() { + if !ow.errored.Load() { + ow.errored.Store(true) + } +} + +func (ow *onceWrapper) Scraped() { + if !ow.scraped.Load() { + ow.scraped.Store(true) + } +} + +func (ow *onceWrapper) Success() bool { + return !ow.errored.Load() && ow.scraped.Load() +} diff --git a/src/search/params.go b/src/search/params.go new file mode 100644 index 00000000..14b9d315 --- /dev/null +++ b/src/search/params.go @@ -0,0 +1,24 @@ +package search + +import ( + "fmt" + + "github.com/hearchco/agent/src/search/engines/options" +) + +func validateParams(query string, opts options.Options) error { + if query == "" { + return fmt.Errorf("query can't be empty") + } + if opts.Locale == "" { + return fmt.Errorf("locale can't be empty") + } + if opts.Pages.Start < 0 { + return fmt.Errorf("pages start can't be negative") + } + if opts.Pages.Max < 1 { + return fmt.Errorf("pages max can't be less than 1") + } + + return nil +} diff --git a/src/search/perform.go b/src/search/perform.go deleted file mode 100644 index b9e4a316..00000000 --- a/src/search/perform.go +++ /dev/null @@ -1,180 +0,0 @@ -package search - -import ( - "context" - "net/url" - "sync" - "time" - - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/bucket" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/rank" - "github.com/hearchco/hearchco/src/search/result" - "github.com/rs/zerolog/log" -) - -func PerformSearch(query string, options engines.Options, categoryConf config.Category, settings map[engines.Name]config.Settings, salt string) []result.Result { - // check for empty query - if query == "" { - log.Trace(). - Caller(). - Msg("Empty search query.") - return []result.Result{} - } - - // start searching - searchTimer := time.Now() - log.Debug(). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Searching...") - - // getting results from engines - resTimer := time.Now() - log.Debug().Msg("Waiting for results from engines...") - - resultMap := runEngines(categoryConf.Engines, url.QueryEscape(query), options, settings, categoryConf.Timings, salt) - - log.Debug(). - Dur("duration", time.Since(resTimer)). - Msg("Got results") - - // ranking results - rankTimer := time.Now() - log.Debug().Msg("Ranking...") - - results := rank.Rank(resultMap, categoryConf.Ranking) - - log.Debug(). - Dur("duration", time.Since(rankTimer)). - Msg("Finished ranking") - - // finish searching - log.Debug(). - Dur("duration", time.Since(searchTimer)). - Msg("Found results") - - return results -} - -func runEngines(engs []engines.Name, query string, options engines.Options, settings map[engines.Name]config.Settings, timings config.CategoryTimings, salt string) map[string]*result.Result { - // create engine strings slice for logging - engsStrs := make([]string, 0, len(engs)) - for _, eng := range engs { - engsStrs = append(engsStrs, eng.String()) - } - - log.Info(). - Int("number", len(engs)). - Strs("engines", engsStrs). - Msg("Enabled engines") - - // create a relay to store results - relay := bucket.Relay{ - ResultMap: make(map[string]*result.Result), - } - - // create a wait group to wait for all engines to finish - var wg sync.WaitGroup - engineStarter := NewEngineStarter() - - start := time.Now() - // initially set the preferred timeout minimum (will be reassigned to step time later) - ctx, cancelCtx := context.WithTimeout(context.Background(), timings.PreferredTimeoutMin) - ctxHard, cancelCtxHard := context.WithTimeout(context.Background(), timings.HardTimeout) - - // run all engines concurrently - for _, eng := range engs { - wg.Add(1) - go func() { - defer wg.Done() - // if an error can be handled inside, it won't be returned - // runs the Search function in the engine package - errs := engineStarter[eng].Search(context.Background(), query, &relay, options, settings[eng], timings, salt, len(engs)) - if len(errs) > 0 { - log.Error(). - Caller(). - Errs("errors", errs). - Str("engine", eng.String()). - Msg("Error(s) while searching") - } - }() - } - - // wait for all engines to finish - waitCh := make(chan struct{}) - go func() { - wg.Wait() - waitCh <- struct{}{} - }() - - // break the loop if the preferred number of results is found before the preferred timeout is reached - // otherwise break the loop when the minimum number of results if found - // or if the hard timeout is reached - // or if all engines finished -Outer: - for { - select { - // preferred timeout (min/max) or step time reached - case <-ctx.Done(): - currTimeout := time.Since(start) - if currTimeout < timings.PreferredTimeoutMax { - // if the preferred number of results isn't reached, continue additional step time - if len(relay.ResultMap) < timings.PreferredResultsNumber { - log.Debug(). - Dur("duration", currTimeout). - Int("results", len(relay.ResultMap)). - Msg("Timeout reached while waiting for engines, waiting additional step time") - cancelCtx() // cancel the current context before creating a new one to prevent context leak - ctx, cancelCtx = context.WithTimeout(context.Background(), timings.StepTime) - } else { - log.Debug(). - Dur("duration", currTimeout). - Int("results", len(relay.ResultMap)). - Msg("Timeout reached while waiting for engines") - break Outer - } - } else { - // if the minimum number of results isn't reached, continue additional step time - if len(relay.ResultMap) < timings.MinimumResultsNumber { - log.Debug(). - Dur("duration", currTimeout). - Int("results", len(relay.ResultMap)). - Msg("Preferred timeout maximum reached, waiting for minimum results required") - cancelCtx() // cancel the current context before creating a new one to prevent context leak - ctx, cancelCtx = context.WithTimeout(context.Background(), timings.StepTime) - } else { - log.Debug(). - Dur("duration", currTimeout). - Int("results", len(relay.ResultMap)). - Msg("Preferred timeout maximum reached") - break Outer - } - } - - // hard timeout reached - case <-ctxHard.Done(): - log.Debug(). - Dur("duration", time.Since(start)). - Int("results", len(relay.ResultMap)). - Msg("Hard timeout reached while waiting for engines") - break Outer - - // all engines finished - case <-waitCh: - log.Debug(). - Dur("duration", time.Since(start)). - Int("results", len(relay.ResultMap)). - Msg("All engines finished") - break Outer - } - } - - // cancel the current contexts to prevent context leak - cancelCtx() - cancelCtxHard() - - return relay.ResultMap -} diff --git a/src/search/rank/filler.go b/src/search/rank/filler.go deleted file mode 100644 index ac2ed8bb..00000000 --- a/src/search/rank/filler.go +++ /dev/null @@ -1,36 +0,0 @@ -package rank - -import ( - "sort" - - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/result" -) - -type RankFiller struct { - ArrInd int - RetRank result.RetrievedRank - RRInd int -} - -func fillRetrievedRank(results []result.Result) { - engResults := make([][]RankFiller, len(engines.NameValues())) - for arrind, res := range results { - for rrind, er := range res.EngineRanks { - rf := RankFiller{ - ArrInd: arrind, - RetRank: er, - RRInd: rrind, - } - engResults[er.SearchEngine] = append(engResults[er.SearchEngine], rf) - } - } - - for _, engRes := range engResults { - sort.Sort(ByRetrievedRank(engRes)) - - for rnk, el := range engRes { - results[el.ArrInd].EngineRanks[el.RRInd].Rank = uint(rnk + 1) - } - } -} diff --git a/src/search/rank/math.go b/src/search/rank/math.go deleted file mode 100644 index 31ff670a..00000000 --- a/src/search/rank/math.go +++ /dev/null @@ -1,15 +0,0 @@ -package rank - -// const magic64 = 0x5FE6EB50C7B537A9 - -// func fastInvSqrt64(n float64) float64 { -// if n < 0 { -// return math.NaN() -// } -// n2, th := n*0.5, float64(1.5) -// b := math.Float64bits(n) -// b = magic64 - (b >> 1) -// f := math.Float64frombits(b) -// f *= th - (n2 * f * f) -// return f -// } diff --git a/src/search/rank/rank.go b/src/search/rank/rank.go deleted file mode 100644 index 6ba27870..00000000 --- a/src/search/rank/rank.go +++ /dev/null @@ -1,29 +0,0 @@ -package rank - -import ( - "sort" - - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/result" -) - -func Rank(resMap map[string]*result.Result, rconf config.CategoryRanking) []result.Result { - results := make([]result.Result, 0, len(resMap)) - for _, res := range resMap { - // set res.EngineRanks slice's capacity to it's length - res.EngineRanks = res.EngineRanks[:len(res.EngineRanks):len(res.EngineRanks)] - results = append(results, *res) - } - - fillRetrievedRank(results) - - for ind := range results { - results[ind].Score = getScore(&results[ind], &rconf) - } - sort.Sort(ByScore(results)) - for ind := range results { - results[ind].Rank = uint(ind + 1) - } - - return results -} diff --git a/src/search/rank/score.go b/src/search/rank/score.go deleted file mode 100644 index 976658aa..00000000 --- a/src/search/rank/score.go +++ /dev/null @@ -1,24 +0,0 @@ -package rank - -import ( - "math" - - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/result" -) - -// package local func that gets result pointer passed down -func getScore(result *result.Result, rconf *config.CategoryRanking) float64 { - retRankScore := float64(0) - for _, er := range result.EngineRanks { - seMul := rconf.Engines[er.SearchEngine.ToLower()].Mul - seConst := rconf.Engines[er.SearchEngine.ToLower()].Const //these 2 could be preproced into array - retRankScore += (100.0/math.Pow(float64(er.Rank)*rconf.A+rconf.B, rconf.REXP)*rconf.C+rconf.D)*seMul + seConst - } - retRankScore /= float64(len(result.EngineRanks)) - - timesReturnedScore := math.Log(float64(len(result.EngineRanks))*rconf.TRA+rconf.TRB)*10*rconf.TRC + rconf.TRD - - score := retRankScore + timesReturnedScore - return score -} diff --git a/src/search/rank/sorting.go b/src/search/rank/sorting.go deleted file mode 100644 index 891215d4..00000000 --- a/src/search/rank/sorting.go +++ /dev/null @@ -1,36 +0,0 @@ -package rank - -import ( - "fmt" - - "github.com/hearchco/hearchco/src/search/result" - "github.com/rs/zerolog/log" -) - -type ByScore []result.Result - -func (r ByScore) Len() int { return len(r) } -func (r ByScore) Swap(i, j int) { r[i], r[j] = r[j], r[i] } -func (r ByScore) Less(i, j int) bool { return r[i].Score > r[j].Score } - -type ByRetrievedRank []RankFiller - -func (r ByRetrievedRank) Len() int { return len(r) } -func (r ByRetrievedRank) Swap(i, j int) { r[i], r[j] = r[j], r[i] } -func (r ByRetrievedRank) Less(i, j int) bool { - if r[i].RetRank.Page != r[j].RetRank.Page { - return r[i].RetRank.Page < r[j].RetRank.Page - } - - if r[i].RetRank.OnPageRank != r[j].RetRank.OnPageRank { - return r[i].RetRank.OnPageRank < r[j].RetRank.OnPageRank - } - - log.Error(). - Caller(). - Str("comparableA", fmt.Sprintf("%v", r[i])). - Str("comparableB", fmt.Sprintf("%v", r[j])). - Msg("Failed at ranking") - - return true -} diff --git a/src/search/receiver.go b/src/search/receiver.go new file mode 100644 index 00000000..8f43eb10 --- /dev/null +++ b/src/search/receiver.go @@ -0,0 +1,59 @@ +package search + +import ( + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/result" +) + +func createReceiver(engChan chan chan result.ResultScraped, results *result.ConcurrentMap, enabledEnginesLen int) { + for resChan := range engChan { + go func() { + for recRes := range resChan { + if recRes.Rank().SearchEngine().String() == "" || recRes.Rank().SearchEngine() == engines.UNDEFINED { + log.Panic(). + Str("engine", recRes.Rank().SearchEngine().String()). + Msg("Received a result with an undefined search engine") + // ^PANIC - Assert because it should never happen. + } + + // Lock the results map due to modifications. + results.Mutex.Lock() + + mapRes, exists := results.Map[recRes.URL()] + if !exists { + // Add the result to the results map. + results.Map[recRes.URL()] = recRes.Convert(enabledEnginesLen) + } else { + var alreadyIn *result.Rank + + // Check if the engine rank is already in the result. + for i, er := range mapRes.EngineRanks() { + if recRes.Rank().SearchEngine() == er.SearchEngine() { + alreadyIn = &mapRes.EngineRanks()[i] + break + } + } + + // Update the result if the new rank is better. + if alreadyIn == nil { + mapRes.AppendEngineRanks(recRes.Rank().Convert()) + } else if alreadyIn.Page() > recRes.Rank().Page() { + alreadyIn.SetPage(recRes.Rank().Page(), recRes.Rank().OnPageRank()) + } else if alreadyIn.Page() == recRes.Rank().Page() && alreadyIn.OnPageRank() > recRes.Rank().OnPageRank() { + alreadyIn.SetOnPageRank(recRes.Rank().OnPageRank()) + } + + // Update the description if the new description is longer. + if len(mapRes.Description()) < len(recRes.Description()) { + mapRes.SetDescription(recRes.Description()) + } + } + + // Unlock the results map. + results.Mutex.Unlock() + } + }() + } +} diff --git a/src/search/result/construct.go b/src/search/result/construct.go new file mode 100644 index 00000000..f7b1f5c9 --- /dev/null +++ b/src/search/result/construct.go @@ -0,0 +1,91 @@ +package result + +import ( + "fmt" + + "github.com/hearchco/agent/src/search/engines" +) + +func ConstructResult(seName engines.Name, urll string, title string, description string, page int, onPageRank int) (GeneralScraped, error) { + res := GeneralScraped{ + url: urll, + title: title, + description: description, + rank: RankScraped{ + searchEngine: seName, + rank: 0, // This gets calculated when ranking the results. + page: page, + onPageRank: onPageRank, + }, + } + + if urll == "" { + return res, fmt.Errorf("invalid URL: empty") + } + + if title == "" { + return res, fmt.Errorf("invalid title: empty") + } + + if page <= 0 { + return res, fmt.Errorf("invalid page: %d", page) + } + + if onPageRank <= 0 { + return res, fmt.Errorf("invalid onPageRank: %d", onPageRank) + } + + return res, nil +} + +func ConstructImagesResult( + seName engines.Name, urll string, title string, description string, page int, onPageRank int, + originalHeight int, originalWidth int, thumbnailHeight int, thumbnailWidth int, + thumbnailUrl string, sourceName string, sourceUrl string, +) (ImagesScraped, error) { + res, err := ConstructResult(seName, urll, title, description, page, onPageRank) + imgres := ImagesScraped{ + GeneralScraped: res, + + originalSize: scrapedImageFormat{ + height: originalHeight, + width: originalWidth, + }, + thumbnailSize: scrapedImageFormat{ + height: thumbnailHeight, + width: thumbnailWidth, + }, + thumbnailURL: thumbnailUrl, + sourceName: sourceName, + sourceURL: sourceUrl, + } + if err != nil { + return imgres, err + } + + if originalHeight <= 0 { + return imgres, fmt.Errorf("invalid originalHeight: %d", originalHeight) + } + + if originalWidth <= 0 { + return imgres, fmt.Errorf("invalid originalWidth: %d", originalWidth) + } + + if thumbnailHeight <= 0 { + return imgres, fmt.Errorf("invalid thumbnailHeight: %d", thumbnailHeight) + } + + if thumbnailWidth <= 0 { + return imgres, fmt.Errorf("invalid thumbnailWidth: %d", thumbnailWidth) + } + + if thumbnailUrl == "" { + return imgres, fmt.Errorf("invalid thumbnailUrl: empty") + } + + if sourceUrl == "" { + return imgres, fmt.Errorf("invalid sourceUrl: empty") + } + + return imgres, nil +} diff --git a/src/search/result/general.go b/src/search/result/general.go new file mode 100644 index 00000000..538385b8 --- /dev/null +++ b/src/search/result/general.go @@ -0,0 +1,67 @@ +package result + +type General struct { + generalJSON +} + +type generalJSON struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + Rank int `json:"rank"` + Score float64 `json:"score"` + EngineRanks []Rank `json:"engine_ranks"` +} + +func (r General) URL() string { + return r.generalJSON.URL +} + +func (r General) Title() string { + return r.generalJSON.Title +} + +func (r General) Description() string { + return r.generalJSON.Description +} + +func (r *General) SetDescription(desc string) { + r.generalJSON.Description = desc +} + +func (r General) Rank() int { + return r.generalJSON.Rank +} + +func (r *General) SetRank(rank int) { + r.generalJSON.Rank = rank +} + +func (r General) Score() float64 { + return r.generalJSON.Score +} + +func (r *General) SetScore(score float64) { + r.generalJSON.Score = score +} + +func (r General) EngineRanks() []Rank { + return r.generalJSON.EngineRanks +} + +func (r *General) ShrinkEngineRanks() { + ranksLen := len(r.generalJSON.EngineRanks) + r.generalJSON.EngineRanks = r.generalJSON.EngineRanks[:ranksLen:ranksLen] +} + +func (r *General) AppendEngineRanks(rank Rank) { + if r.generalJSON.EngineRanks == nil { + r.generalJSON.EngineRanks = make([]Rank, 0) + } + + r.generalJSON.EngineRanks = append(r.generalJSON.EngineRanks, rank) +} + +func (r General) ConvertToOutput(salt string) ResultOutput { + return r +} diff --git a/src/search/result/general_scraped.go b/src/search/result/general_scraped.go new file mode 100644 index 00000000..13364384 --- /dev/null +++ b/src/search/result/general_scraped.go @@ -0,0 +1,37 @@ +package result + +type GeneralScraped struct { + url string + title string + description string + rank RankScraped +} + +func (r GeneralScraped) URL() string { + return r.url +} + +func (r GeneralScraped) Title() string { + return r.title +} + +func (r GeneralScraped) Description() string { + return r.description +} + +func (r GeneralScraped) Rank() RankScraped { + return r.rank +} + +func (r GeneralScraped) Convert(erCap int) Result { + engineRanks := make([]Rank, 0, erCap) + engineRanks = append(engineRanks, r.Rank().Convert()) + return &General{ + generalJSON{ + URL: r.URL(), + Title: r.Title(), + Description: r.Description(), + EngineRanks: engineRanks, + }, + } +} diff --git a/src/search/result/images.go b/src/search/result/images.go new file mode 100644 index 00000000..e04f864c --- /dev/null +++ b/src/search/result/images.go @@ -0,0 +1,54 @@ +package result + +import ( + "github.com/hearchco/agent/src/utils/anonymize" +) + +type Images struct { + imagesJSON +} + +type imagesJSON struct { + General + + OriginalSize ImageFormat `json:"original"` + ThumbnailSize ImageFormat `json:"thumbnail"` + ThumbnailURL string `json:"thumbnail_url"` + SourceName string `json:"source"` + SourceURL string `json:"source_url"` +} + +type ImageFormat struct { + Height int `json:"height"` + Width int `json:"width"` +} + +func (r Images) OriginalSize() ImageFormat { + return r.imagesJSON.OriginalSize +} + +func (r Images) ThumbnailSize() ImageFormat { + return r.imagesJSON.ThumbnailSize +} + +func (r Images) ThumbnailURL() string { + return r.imagesJSON.ThumbnailURL +} + +func (r Images) SourceName() string { + return r.imagesJSON.SourceName +} + +func (r Images) SourceURL() string { + return r.imagesJSON.SourceURL +} + +func (r Images) ConvertToOutput(salt string) ResultOutput { + return ImagesOutput{ + imagesOutputJSON{ + r, + anonymize.HashToSHA256B64Salted(r.URL(), salt), + anonymize.HashToSHA256B64Salted(r.ThumbnailURL(), salt), + }, + } +} diff --git a/src/search/result/images_output.go b/src/search/result/images_output.go new file mode 100644 index 00000000..b5b4aede --- /dev/null +++ b/src/search/result/images_output.go @@ -0,0 +1,12 @@ +package result + +type ImagesOutput struct { + imagesOutputJSON +} + +type imagesOutputJSON struct { + Images + + URLHash string `json:"url_hash,omitempty"` + ThumbnailURLHash string `json:"thumbnail_url_hash,omitempty"` +} diff --git a/src/search/result/images_scraped.go b/src/search/result/images_scraped.go new file mode 100644 index 00000000..473c26c8 --- /dev/null +++ b/src/search/result/images_scraped.go @@ -0,0 +1,73 @@ +package result + +type ImagesScraped struct { + GeneralScraped + + originalSize scrapedImageFormat + thumbnailSize scrapedImageFormat + thumbnailURL string + sourceName string + sourceURL string +} + +func (r ImagesScraped) OriginalSize() scrapedImageFormat { + return r.originalSize +} + +func (r ImagesScraped) ThumbnailSize() scrapedImageFormat { + return r.thumbnailSize +} + +func (r ImagesScraped) ThumbnailURL() string { + return r.thumbnailURL +} + +func (r ImagesScraped) SourceName() string { + return r.sourceName +} + +func (r ImagesScraped) SourceURL() string { + return r.sourceURL +} + +func (r ImagesScraped) Convert(erCap int) Result { + engineRanks := make([]Rank, 0, erCap) + engineRanks = append(engineRanks, r.Rank().Convert()) + return &Images{ + imagesJSON{ + General{ + generalJSON{ + URL: r.URL(), + Title: r.Title(), + Description: r.Description(), + EngineRanks: engineRanks, + }, + }, + r.OriginalSize().Convert(), + r.ThumbnailSize().Convert(), + r.ThumbnailURL(), + r.SourceName(), + r.SourceURL(), + }, + } +} + +type scrapedImageFormat struct { + height int + width int +} + +func (i scrapedImageFormat) GetHeight() int { + return i.height +} + +func (i scrapedImageFormat) GetWidth() int { + return i.width +} + +func (i scrapedImageFormat) Convert() ImageFormat { + return ImageFormat{ + Height: i.height, + Width: i.width, + } +} diff --git a/src/search/result/interface.go b/src/search/result/interface.go new file mode 100644 index 00000000..a6abf4fb --- /dev/null +++ b/src/search/result/interface.go @@ -0,0 +1,35 @@ +package result + +type Result interface { + URL() string + Title() string + Description() string + SetDescription(string) + Rank() int + SetRank(int) + Score() float64 + SetScore(float64) + EngineRanks() []Rank + ShrinkEngineRanks() + AppendEngineRanks(Rank) + ConvertToOutput(string) ResultOutput + Shorten(int, int) Result +} + +type ResultScraped interface { + URL() string + Title() string + Description() string + Rank() RankScraped + Convert(int) Result +} + +type ResultOutput interface{} + +func ConvertToOutput(results []Result, salt string) []ResultOutput { + var output = make([]ResultOutput, 0, len(results)) + for _, r := range results { + output = append(output, r.ConvertToOutput(salt)) + } + return output +} diff --git a/src/search/result/map.go b/src/search/result/map.go new file mode 100644 index 00000000..c8336c99 --- /dev/null +++ b/src/search/result/map.go @@ -0,0 +1,41 @@ +package result + +import ( + "slices" + "sync" + + "github.com/hearchco/agent/src/search/engines" +) + +type ConcurrentMap struct { + Mutex sync.RWMutex + Map map[string]Result +} + +func Map() ConcurrentMap { + return ConcurrentMap{ + Map: make(map[string]Result), + } +} + +func (r *ConcurrentMap) ExtractResultsAndResponders(enabledEnginesLen, titleLen, descLen int) ([]Result, []engines.Name) { + r.Mutex.RLock() + + results := make([]Result, 0, len(r.Map)) + responders := make([]engines.Name, 0, enabledEnginesLen) + + for _, res := range r.Map { + newRes := res.Shorten(titleLen, descLen) + newRes.ShrinkEngineRanks() + results = append(results, newRes) + for _, rank := range res.EngineRanks() { + if !slices.Contains(responders, rank.SearchEngine()) { + responders = append(responders, rank.SearchEngine()) + } + } + } + + r.Mutex.RUnlock() + + return results, responders +} diff --git a/src/search/result/output.go b/src/search/result/output.go deleted file mode 100644 index 0b04f89d..00000000 --- a/src/search/result/output.go +++ /dev/null @@ -1,55 +0,0 @@ -package result - -type GeneralResultOutput struct { - URL string `json:"url"` - URLHash string `json:"url_hash,omitempty"` - Rank uint `json:"rank"` - Score float64 `json:"score"` - Title string `json:"title"` - Description string `json:"description"` - EngineRanks []RetrievedRank `json:"engine_ranks"` -} - -func ConvertToGeneralOutput(results []Result) []GeneralResultOutput { - resultsOutput := make([]GeneralResultOutput, 0, len(results)) - for _, r := range results { - resultsOutput = append(resultsOutput, GeneralResultOutput{ - URL: r.URL, - URLHash: r.URLHash, - Rank: r.Rank, - Score: r.Score, - Title: r.Title, - Description: r.Description, - EngineRanks: r.EngineRanks, - }) - } - return resultsOutput -} - -type ImageResultOutput struct { - URL string `json:"url"` - URLHash string `json:"url_hash,omitempty"` - Rank uint `json:"rank"` - Score float64 `json:"score"` - Title string `json:"title"` - Description string `json:"description"` - EngineRanks []RetrievedRank `json:"engine_ranks"` - ImageResult ImageResult `json:"image_result"` -} - -func ConvertToImageOutput(results []Result) []ImageResultOutput { - resultsOutput := make([]ImageResultOutput, 0, len(results)) - for _, r := range results { - resultsOutput = append(resultsOutput, ImageResultOutput{ - URL: r.URL, - URLHash: r.URLHash, - Rank: r.Rank, - Score: r.Score, - Title: r.Title, - Description: r.Description, - EngineRanks: r.EngineRanks, - ImageResult: r.ImageResult, - }) - } - return resultsOutput -} diff --git a/src/search/result/rank.go b/src/search/result/rank.go new file mode 100644 index 00000000..784cc242 --- /dev/null +++ b/src/search/result/rank.go @@ -0,0 +1,56 @@ +package result + +import ( + "github.com/hearchco/agent/src/search/engines" +) + +type Rank struct { + rankJSON +} + +type rankJSON struct { + SearchEngine engines.Name `json:"search_engine"` + Rank int `json:"rank"` + Page int `json:"page"` + OnPageRank int `json:"on_page_rank"` +} + +func (r Rank) SearchEngine() engines.Name { + return r.rankJSON.SearchEngine +} + +func (r Rank) Rank() int { + return r.rankJSON.Rank +} + +func (r *Rank) SetRank(rank int) { + r.rankJSON.Rank = rank +} + +func (r Rank) Page() int { + return r.rankJSON.Page +} + +func (r *Rank) SetPage(page, onPageRank int) { + r.rankJSON.Page = page + r.rankJSON.OnPageRank = onPageRank +} + +func (r Rank) OnPageRank() int { + return r.rankJSON.OnPageRank +} + +func (r *Rank) SetOnPageRank(onPageRank int) { + r.rankJSON.OnPageRank = onPageRank +} + +func NewRank(searchEngine engines.Name, rank, page, onPageRank int) Rank { + return Rank{ + rankJSON{ + SearchEngine: searchEngine, + Rank: rank, + Page: page, + OnPageRank: onPageRank, + }, + } +} diff --git a/src/search/result/rank/filler.go b/src/search/result/rank/filler.go new file mode 100644 index 00000000..6a81e10a --- /dev/null +++ b/src/search/result/rank/filler.go @@ -0,0 +1,27 @@ +package rank + +import ( + "sort" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/result" +) + +// calculates Rank value of every EngineRank for each Search Engine individually by using Page and OnPageRank to sort +func (res Results) fillEngineRankRank() { + seEngineRanks := make([][]*result.Rank, len(engines.NameValues())) + + for _, r := range res { + for i := range r.EngineRanks() { + er := &r.EngineRanks()[i] + seEngineRanks[er.SearchEngine()] = append(seEngineRanks[er.SearchEngine()], er) + } + } + + for _, seer := range seEngineRanks { + sort.Sort(ByPageAndOnPageRank(seer)) + for i, er := range seer { + er.SetRank(i + 1) + } + } +} diff --git a/src/search/result/rank/filler_test.go b/src/search/result/rank/filler_test.go new file mode 100644 index 00000000..d42f4a95 --- /dev/null +++ b/src/search/result/rank/filler_test.go @@ -0,0 +1,113 @@ +package rank + +import ( + "testing" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/result" +) + +type ranksPair struct { + orig []result.RankScraped + expected []result.RankScraped +} + +func TestFillEngineRankRank(t *testing.T) { + // Each elements represents a pair of original and expected engine ranks. + // The number of elements represents the number of results. + ranksTests := [...]ranksPair{ + { + []result.RankScraped{ + result.NewRankScraped(engines.GOOGLE, 0, 1, 1), + result.NewRankScraped(engines.BING, 0, 1, 1), + result.NewRankScraped(engines.MOJEEK, 0, 1, 3), + }, + []result.RankScraped{ + result.NewRankScraped(engines.GOOGLE, 1, 1, 1), + result.NewRankScraped(engines.BING, 1, 1, 1), + result.NewRankScraped(engines.MOJEEK, 2, 1, 3), + }, + }, + { + []result.RankScraped{ + result.NewRankScraped(engines.MOJEEK, 0, 1, 1), + }, + []result.RankScraped{ + result.NewRankScraped(engines.MOJEEK, 1, 1, 1), + }, + }, + { + []result.RankScraped{ + result.NewRankScraped(engines.GOOGLE, 0, 2, 1), + result.NewRankScraped(engines.BING, 0, 3, 5), + }, + []result.RankScraped{ + result.NewRankScraped(engines.GOOGLE, 2, 2, 1), + result.NewRankScraped(engines.BING, 2, 3, 5), + }, + }, + } + + // Adding the ranks to the results, afterwards adding the results into the slice of results. + resultsOrig := make(Results, 0, len(ranksTests)) + resultsExpected := make(Results, 0, len(ranksTests)) + for _, rankPair := range ranksTests { + var resOrig result.Result = &result.General{} + var resExpected result.Result = &result.General{} + + for _, rank := range rankPair.orig { + resOrig.AppendEngineRanks(rank.Convert()) + } + for _, rank := range rankPair.expected { + resExpected.AppendEngineRanks(rank.Convert()) + } + + resultsOrig = append(resultsOrig, resOrig) + resultsExpected = append(resultsExpected, resExpected) + } + + // Creating the tests. + tests := [...]testPair{ + { + resultsOrig, + resultsExpected, + }, + } + + // Making sure that the tests exist. + if len(tests) == 0 { + t.Errorf("Bad tests made: len(tests) == 0") + } + + // Making sure that the tests are made correctly. + for _, test := range tests { + if len(test.orig) != len(test.expected) { + t.Errorf("Bad tests made: len(tests.orig) != len(tests.expected)") + } + + for i := range test.orig { + if len(test.orig[i].EngineRanks()) != len(test.expected[i].EngineRanks()) { + t.Errorf("Bad tests made: len(tests.orig[%v].EngineRanks) != len(tests.expected[%v].EngineRanks)", i, i) + } + + for j := range test.orig[i].EngineRanks() { + if test.orig[i].EngineRanks()[j].SearchEngine() != test.expected[i].EngineRanks()[j].SearchEngine() { + t.Errorf("Bad tests made: test.orig[%v].EngineRanks[%v].SearchEngine != test.expected[%v].EngineRanks[%v].SearchEngine", i, j, i, j) + } + } + } + } + + // Running the tests. + for _, test := range tests { + test.orig.fillEngineRankRank() + + for i := range test.orig { + for j := range test.orig[i].EngineRanks() { + if test.orig[i].EngineRanks()[j].Rank() != test.expected[i].EngineRanks()[j].Rank() { + t.Errorf("fillEngineRankRank() = %v, want %v", test.orig[i].EngineRanks()[j].Rank(), test.expected[i].EngineRanks()[j].Rank()) + } + } + } + } +} diff --git a/src/search/result/rank/rank.go b/src/search/result/rank/rank.go new file mode 100644 index 00000000..f21a304b --- /dev/null +++ b/src/search/result/rank/rank.go @@ -0,0 +1,48 @@ +package rank + +import ( + "sort" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search/result" +) + +type Results []result.Result + +// Cast []result.Result to Results, call rank() and return []result.Result slice. +func Rank(results []result.Result, rconf config.CategoryRanking) []result.Result { + resType := make(Results, 0, len(results)) + for _, res := range results { + resType = append(resType, res) + } + + // Rank the results. + resType.rank(rconf) + + rankedRes := make([]result.Result, 0, len(resType)) + for _, res := range resType { + rankedRes = append(rankedRes, res) + } + return rankedRes +} + +// Calculates the Score, sorts by it and then populates the Rank field of every result. +func (r Results) rank(rconf config.CategoryRanking) { + // Fill Rank field for every EngineRank. + r.fillEngineRankRank() + + // Calculate and set scores. + r.calculateScores(rconf) + + // Sort slice by score. + sort.Sort(ByScore(r)) + + // Set correct ranks, by iterating over the sorted slice. + r.correctRanks() +} + +func (r Results) correctRanks() { + for i, res := range r { + res.SetRank(i + 1) + } +} diff --git a/src/search/result/rank/score.go b/src/search/result/rank/score.go new file mode 100644 index 00000000..1799e5bb --- /dev/null +++ b/src/search/result/rank/score.go @@ -0,0 +1,31 @@ +package rank + +import ( + "math" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search/result" +) + +// Calculates and sets scores for all results. +func (r Results) calculateScores(rconf config.CategoryRanking) { + for _, res := range r { + res.SetScore(calculateScore(res, rconf)) + } +} + +// Only calculates the score for one result. +func calculateScore(res result.Result, rconf config.CategoryRanking) float64 { + retRankScore := float64(0) + for _, er := range res.EngineRanks() { + seMul := rconf.Engines[er.SearchEngine().ToLower()].Mul + seConst := rconf.Engines[er.SearchEngine().ToLower()].Const //these 2 could be preproced into array + retRankScore += (100.0/math.Pow(float64(er.Rank())*rconf.A+rconf.B, rconf.REXP)*rconf.C+rconf.D)*seMul + seConst + } + retRankScore /= float64(len(res.EngineRanks())) + + timesReturnedScore := math.Log(float64(len(res.EngineRanks()))*rconf.TRA+rconf.TRB)*10*rconf.TRC + rconf.TRD + + score := retRankScore + timesReturnedScore + return score +} diff --git a/src/search/result/rank/sorting.go b/src/search/result/rank/sorting.go new file mode 100644 index 00000000..9809803f --- /dev/null +++ b/src/search/result/rank/sorting.go @@ -0,0 +1,38 @@ +package rank + +import ( + "fmt" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/result" +) + +type ByScore []result.Result + +func (r ByScore) Len() int { return len(r) } +func (r ByScore) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r ByScore) Less(i, j int) bool { return r[i].Score() > r[j].Score() } + +type ByPageAndOnPageRank []*result.Rank + +func (r ByPageAndOnPageRank) Len() int { return len(r) } +func (r ByPageAndOnPageRank) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r ByPageAndOnPageRank) Less(i, j int) bool { + if r[i].Page() != r[j].Page() { + return r[i].Page() < r[j].Page() + } + + if r[i].OnPageRank() != r[j].OnPageRank() { + return r[i].OnPageRank() < r[j].OnPageRank() + } + + log.Panic(). + Caller(). + Str("comparableA", fmt.Sprintf("%v", r[i])). + Str("comparableB", fmt.Sprintf("%v", r[j])). + Msg("Failed at ranking: same page and onpagerank") + // ^PANIC + + panic("Failed at ranking: same page and onpagerank") +} diff --git a/src/search/result/rank/structs_test.go b/src/search/result/rank/structs_test.go new file mode 100644 index 00000000..ab57ae59 --- /dev/null +++ b/src/search/result/rank/structs_test.go @@ -0,0 +1,6 @@ +package rank + +type testPair struct { + orig Results + expected Results +} diff --git a/src/search/result/rank_scraped.go b/src/search/result/rank_scraped.go new file mode 100644 index 00000000..fc5908ef --- /dev/null +++ b/src/search/result/rank_scraped.go @@ -0,0 +1,48 @@ +package result + +import ( + "github.com/hearchco/agent/src/search/engines" +) + +type RankScraped struct { + searchEngine engines.Name + rank int + page int + onPageRank int +} + +func (r RankScraped) SearchEngine() engines.Name { + return r.searchEngine +} + +func (r RankScraped) Rank() int { + return r.rank +} + +func (r RankScraped) Page() int { + return r.page +} + +func (r RankScraped) OnPageRank() int { + return r.onPageRank +} + +func (r RankScraped) Convert() Rank { + return Rank{ + rankJSON: rankJSON{ + SearchEngine: r.searchEngine, + Rank: r.rank, + Page: r.page, + OnPageRank: r.onPageRank, + }, + } +} + +func NewRankScraped(searchEngine engines.Name, rank, page, onPageRank int) RankScraped { + return RankScraped{ + searchEngine: searchEngine, + rank: rank, + page: page, + onPageRank: onPageRank, + } +} diff --git a/src/search/result/result.go b/src/search/result/result.go deleted file mode 100644 index f3f7923f..00000000 --- a/src/search/result/result.go +++ /dev/null @@ -1,33 +0,0 @@ -package result - -import ( - "github.com/gocolly/colly/v2" -) - -type ImageFormat struct { - Height uint `json:"height"` - Width uint `json:"width"` -} - -type ImageResult struct { - Original ImageFormat `json:"original"` - Thumbnail ImageFormat `json:"thumbnail"` - ThumbnailURL string `json:"thumbnail_url"` - ThumbnailURLHash string `json:"thumbnail_url_hash,omitempty"` - Source string `json:"source"` - SourceURL string `json:"source_url"` -} - -// Everything about some Result, calculated and compiled from multiple search engines -// The URL is the primary key -type Result struct { - URL string `json:"url"` - URLHash string `json:"url_hash,omitempty"` - Rank uint `json:"rank"` - Score float64 `json:"score"` - Title string `json:"title"` - Description string `json:"description"` - EngineRanks []RetrievedRank `json:"engine_ranks"` - ImageResult ImageResult `json:"image_result"` - Response *colly.Response `json:"-"` -} diff --git a/src/search/result/retrieved.go b/src/search/result/retrieved.go deleted file mode 100644 index 52f2b252..00000000 --- a/src/search/result/retrieved.go +++ /dev/null @@ -1,22 +0,0 @@ -package result - -import "github.com/hearchco/hearchco/src/search/engines" - -// variables are 1-indexed -// Information about what Rank a result was on some Search Engine -type RetrievedRank struct { - SearchEngine engines.Name `json:"search_engine"` - Rank uint `json:"rank"` - Page uint `json:"page"` - OnPageRank uint `json:"on_page_rank"` -} - -// The info a Search Engine returned about some Result -type RetrievedResult struct { - URL string `json:"url"` - URLHash string `json:"url_hash,omitempty"` - Title string `json:"title"` - Description string `json:"description"` - ImageResult ImageResult `json:"image_result"` - Rank RetrievedRank `json:"rank"` -} diff --git a/src/search/result/shorten.go b/src/search/result/shorten.go index c0238508..6f1a8525 100644 --- a/src/search/result/shorten.go +++ b/src/search/result/shorten.go @@ -1,29 +1,63 @@ package result -func FirstNchars(str string, n int) string { - v := []rune(str) - if n < 0 || n >= len(v) { - return str +// Changes the title and description of the result to be at most N and M characters long respectively. +func (r General) Shorten(maxTitleLength int, maxDescriptionLength int) Result { + return &General{ + generalJSON{ + URL: r.URL(), + Title: shortString(r.Title(), maxTitleLength), + Description: shortString(r.Description(), maxDescriptionLength), + Rank: r.Rank(), + Score: r.Score(), + EngineRanks: r.EngineRanks(), + }, } - return string(v[:n]) } -// modifies the passed slice of results, -// changes the description of the results to be at most N characters long -func Shorten(results []Result, n int) { - suffix := "..." +func (r Images) Shorten(maxTitleLength int, maxDescriptionLength int) Result { + return &Images{ + imagesJSON{ + General{ + generalJSON{ + URL: r.URL(), + Title: shortString(r.Title(), maxTitleLength), + Description: shortString(r.Description(), maxDescriptionLength), + Rank: r.Rank(), + Score: r.Score(), + EngineRanks: r.EngineRanks(), + }, + }, + r.OriginalSize(), + r.ThumbnailSize(), + r.ThumbnailURL(), + r.SourceName(), + r.SourceURL(), + }, + } +} + +func shortString(s string, n int) string { if n < 0 { - return - } else if n-len(suffix) <= 0 { - suffix = "" // no room for suffix + return s } - // can't use _, result := range short because we need to modify the elements in slice - for i := range results { - result := &results[i] - if len(result.Description) > n { - descShort := FirstNchars(result.Description, n-len(suffix)) - result.Description = descShort + suffix - } + suffix := "..." + if n-len(suffix) <= 0 { + suffix = "" // No room for suffix. } + + if len(s) > n { + short := firstNchars(s, n-len(suffix)) + return short + suffix + } + + return s +} + +func firstNchars(str string, n int) string { + v := []rune(str) + if n < 0 || n >= len(v) { + return str + } + return string(v[:n]) } diff --git a/src/search/result/shorten_test.go b/src/search/result/shorten_test.go index b0972143..7af873b5 100644 --- a/src/search/result/shorten_test.go +++ b/src/search/result/shorten_test.go @@ -1,9 +1,7 @@ -package result_test +package result import ( "testing" - - "github.com/hearchco/hearchco/src/search/result" ) type testPair struct { @@ -12,7 +10,6 @@ type testPair struct { } func TestFirstNcharsNegative(t *testing.T) { - // original string, expected string tests := []testPair{ {"", ""}, {"banana death", "banana death"}, @@ -21,7 +18,7 @@ func TestFirstNcharsNegative(t *testing.T) { } for _, test := range tests { - v := result.FirstNchars(test.orig, -1) + v := firstNchars(test.orig, -1) if v != test.expected { t.Errorf("FirstNChars(%q) = %q, want %q", test.orig, v, test.expected) } @@ -29,7 +26,6 @@ func TestFirstNcharsNegative(t *testing.T) { } func TestFirstNcharsZero(t *testing.T) { - // original string, expected string tests := []testPair{ {"", ""}, {"banana death", ""}, @@ -38,7 +34,7 @@ func TestFirstNcharsZero(t *testing.T) { } for _, test := range tests { - v := result.FirstNchars(test.orig, 0) + v := firstNchars(test.orig, 0) if v != test.expected { t.Errorf("FirstNChars(%q) = %q, want %q", test.orig, v, test.expected) } @@ -46,7 +42,6 @@ func TestFirstNcharsZero(t *testing.T) { } func TestFirstNchars1(t *testing.T) { - // original string, expected string tests := []testPair{ {"", ""}, {"banana death", "b"}, @@ -55,7 +50,7 @@ func TestFirstNchars1(t *testing.T) { } for _, test := range tests { - v := result.FirstNchars(test.orig, 1) + v := firstNchars(test.orig, 1) if v != test.expected { t.Errorf("FirstNChars(%q) = %q, want %q", test.orig, v, test.expected) } @@ -63,7 +58,6 @@ func TestFirstNchars1(t *testing.T) { } func TestFirstNchars10(t *testing.T) { - // original string, expected string tests := []testPair{ {"", ""}, {"banana death", "banana dea"}, @@ -72,7 +66,7 @@ func TestFirstNchars10(t *testing.T) { } for _, test := range tests { - v := result.FirstNchars(test.orig, 10) + v := firstNchars(test.orig, 10) if v != test.expected { t.Errorf("FirstNChars(%q) = %q, want %q", test.orig, v, test.expected) } @@ -80,7 +74,6 @@ func TestFirstNchars10(t *testing.T) { } func TestShortenNegative(t *testing.T) { - // original string, expected string tests := []testPair{ // 0 characters -> 0 characters (nothing changes) {"", ""}, @@ -94,29 +87,36 @@ func TestShortenNegative(t *testing.T) { {"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."}, } - // create test results - var results = make([]result.Result, 0, len(tests)) + // Create test results. + var results = make([]General, 0, len(tests)) for _, test := range tests { - v := result.Result{ - Description: test.orig, + v := General{ + generalJSON: generalJSON{ + Title: test.orig, + Description: test.orig, + }, } results = append(results, v) } - // shorten the descriptions - result.Shorten(results, -1) + // Shorten the results. + for i := range results { + results[i] = *results[i].Shorten(-1, -1).(*General) + } - // check if the descriptions are shortened as expected + // Check if the results are shortened as expected. for i, test := range tests { - v := results[i].Description - if v != test.expected { - t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v, len(v), test.expected, len(test.expected)) + v := results[i] + if v.Title() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Title(), len(v.Title()), test.expected, len(test.expected)) + } + if v.Description() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Description(), len(v.Description()), test.expected, len(test.expected)) } } } func TestShortenZero(t *testing.T) { - // original string, expected string tests := []testPair{ // 0 characters -> 0 characters {"", ""}, @@ -130,29 +130,36 @@ func TestShortenZero(t *testing.T) { {"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", ""}, } - // create test results - var results = make([]result.Result, 0, len(tests)) + // Create test results. + var results = make([]General, 0, len(tests)) for _, test := range tests { - v := result.Result{ - Description: test.orig, + v := General{ + generalJSON: generalJSON{ + Title: test.orig, + Description: test.orig, + }, } results = append(results, v) } - // shorten the descriptions - result.Shorten(results, 0) + // Shorten the results. + for i := range results { + results[i] = *results[i].Shorten(0, 0).(*General) + } - // check if the descriptions are shortened as expected + // Check if the results are shortened as expected. for i, test := range tests { - v := results[i].Description - if v != test.expected { - t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v, len(v), test.expected, len(test.expected)) + v := results[i] + if v.Title() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Title(), len(v.Title()), test.expected, len(test.expected)) + } + if v.Description() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Description(), len(v.Description()), test.expected, len(test.expected)) } } } func TestShorten1(t *testing.T) { - // original string, expected string tests := []testPair{ // 0 characters -> 0 characters {"", ""}, @@ -166,29 +173,36 @@ func TestShorten1(t *testing.T) { {"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "L"}, } - // create test results - var results = make([]result.Result, 0, len(tests)) + // Create test results. + var results = make([]General, 0, len(tests)) for _, test := range tests { - v := result.Result{ - Description: test.orig, + v := General{ + generalJSON: generalJSON{ + Title: test.orig, + Description: test.orig, + }, } results = append(results, v) } - // shorten the descriptions - result.Shorten(results, 1) + // Shorten the results. + for i := range results { + results[i] = *results[i].Shorten(1, 1).(*General) + } - // check if the descriptions are shortened as expected + // Check if the results are shortened as expected. for i, test := range tests { - v := results[i].Description - if v != test.expected { - t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v, len(v), test.expected, len(test.expected)) + v := results[i] + if v.Title() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Title(), len(v.Title()), test.expected, len(test.expected)) + } + if v.Description() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Description(), len(v.Description()), test.expected, len(test.expected)) } } } func TestShorten2(t *testing.T) { - // original string, expected string tests := []testPair{ // 0 characters -> 0 characters {"", ""}, @@ -202,29 +216,36 @@ func TestShorten2(t *testing.T) { {"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "Lo"}, } - // create test results - var results = make([]result.Result, 0, len(tests)) + // Create test results. + var results = make([]General, 0, len(tests)) for _, test := range tests { - v := result.Result{ - Description: test.orig, + v := General{ + generalJSON: generalJSON{ + Title: test.orig, + Description: test.orig, + }, } results = append(results, v) } - // shorten the descriptions - result.Shorten(results, 2) + // Shorten the results. + for i := range results { + results[i] = *results[i].Shorten(2, 2).(*General) + } - // check if the descriptions are shortened as expected + // Check if the results are shortened as expected. for i, test := range tests { - v := results[i].Description - if v != test.expected { - t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v, len(v), test.expected, len(test.expected)) + v := results[i] + if v.Title() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Title(), len(v.Title()), test.expected, len(test.expected)) + } + if v.Description() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Description(), len(v.Description()), test.expected, len(test.expected)) } } } func TestShorten3(t *testing.T) { - // original string, expected string tests := []testPair{ // 0 characters -> 0 characters {"", ""}, @@ -238,29 +259,36 @@ func TestShorten3(t *testing.T) { {"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "Lor"}, } - // create test results - var results = make([]result.Result, 0, len(tests)) + // Create test results. + var results = make([]General, 0, len(tests)) for _, test := range tests { - v := result.Result{ - Description: test.orig, + v := General{ + generalJSON: generalJSON{ + Title: test.orig, + Description: test.orig, + }, } results = append(results, v) } - // shorten the descriptions - result.Shorten(results, 3) + // Shorten the results. + for i := range results { + results[i] = *results[i].Shorten(3, 3).(*General) + } - // check if the descriptions are shortened as expected + // Check if the results are shortened as expected. for i, test := range tests { - v := results[i].Description - if v != test.expected { - t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v, len(v), test.expected, len(test.expected)) + v := results[i] + if v.Title() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Title(), len(v.Title()), test.expected, len(test.expected)) + } + if v.Description() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Description(), len(v.Description()), test.expected, len(test.expected)) } } } func TestShorten4(t *testing.T) { - // original string, expected string tests := []testPair{ // 0 characters -> 0 characters {"", ""}, @@ -274,29 +302,36 @@ func TestShorten4(t *testing.T) { {"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "L..."}, } - // create test results - var results = make([]result.Result, 0, len(tests)) + // Create test results. + var results = make([]General, 0, len(tests)) for _, test := range tests { - v := result.Result{ - Description: test.orig, + v := General{ + generalJSON: generalJSON{ + Title: test.orig, + Description: test.orig, + }, } results = append(results, v) } - // shorten the descriptions - result.Shorten(results, 4) + // Shorten the results. + for i := range results { + results[i] = *results[i].Shorten(4, 4).(*General) + } - // check if the descriptions are shortened as expected + // Check if the results are shortened as expected. for i, test := range tests { - v := results[i].Description - if v != test.expected { - t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v, len(v), test.expected, len(test.expected)) + v := results[i] + if v.Title() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Title(), len(v.Title()), test.expected, len(test.expected)) + } + if v.Description() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Description(), len(v.Description()), test.expected, len(test.expected)) } } } func TestShorten400(t *testing.T) { - // original string, expected string tests := []testPair{ // 0 characters -> 0 characters {"", ""}, @@ -310,23 +345,31 @@ func TestShorten400(t *testing.T) { {"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa ..."}, } - // create test results - var results = make([]result.Result, 0, len(tests)) + // Create test results. + var results = make([]General, 0, len(tests)) for _, test := range tests { - v := result.Result{ - Description: test.orig, + v := General{ + generalJSON: generalJSON{ + Title: test.orig, + Description: test.orig, + }, } results = append(results, v) } - // shorten the descriptions - result.Shorten(results, 400) + // Shorten the results. + for i := range results { + results[i] = *results[i].Shorten(400, 400).(*General) + } - // check if the descriptions are shortened as expected + // Check if the results are shortened as expected. for i, test := range tests { - v := results[i].Description - if v != test.expected { - t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v, len(v), test.expected, len(test.expected)) + v := results[i] + if v.Title() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Title(), len(v.Title()), test.expected, len(test.expected)) + } + if v.Description() != test.expected { + t.Errorf("\n\tShorten(%q)\n\tlen = %v\n\n\tGot: %q\n\tlen = %v\n\n\tWant: %q\n\tlen = %v", test.orig, len(test.orig), v.Description(), len(v.Description()), test.expected, len(test.expected)) } } } diff --git a/src/search/run_preferred_engines.go b/src/search/run_preferred_engines.go new file mode 100644 index 00000000..e688f3dc --- /dev/null +++ b/src/search/run_preferred_engines.go @@ -0,0 +1,55 @@ +package search + +import ( + "sync" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/utils/anonymize" +) + +func runPreferredEngines(enginers []scraper.Enginer, wgPreferredEngines *sync.WaitGroup, query string, opts options.Options, preferredEngines []engines.Name, engChan chan chan result.ResultScraped, searchOnce map[engines.Name]*onceWrapper) { + wgPreferredEngines.Add(len(preferredEngines)) + for _, engName := range preferredEngines { + enginer := enginers[engName] + resChan := make(chan result.ResultScraped, 100) + engChan <- resChan + go func() { + defer wgPreferredEngines.Done() + searchOnce[engName].Do(func() { + log.Trace(). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "preferred"). + Msg("Started") + + // Run the engine. + errs, scraped := enginer.Search(query, opts, resChan) + + if len(errs) > 0 { + searchOnce[engName].Errored() + log.Error(). + Errs("errors", errs). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "preferred"). + Msg("Error searching") + } + + if !scraped { + log.Debug(). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "preferred"). + Msg("Failed to scrape any results (probably timed out)") + } else { + searchOnce[engName].Scraped() + } + }) + }() + } +} diff --git a/src/search/run_preferred_origins.go b/src/search/run_preferred_origins.go new file mode 100644 index 00000000..3b7a7bc1 --- /dev/null +++ b/src/search/run_preferred_origins.go @@ -0,0 +1,91 @@ +package search + +import ( + "slices" + "sync" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/utils/anonymize" +) + +func runPreferredByOriginEngines(enginers []scraper.Enginer, wgPreferredByOriginEngines *sync.WaitGroup, query string, opts options.Options, preferredByOriginEngines []engines.Name, enabledEngines []engines.Name, engChan chan chan result.ResultScraped, searchOnce map[engines.Name]*onceWrapper) { + // Create a map of slices of all the engines that contain origins from the preferred engines by origin. + preferredByOriginEnginesMap := make(map[engines.Name][]engines.Name, len(preferredByOriginEngines)) + for _, originName := range preferredByOriginEngines { + for _, engName := range enabledEngines { + origins := enginers[engName].GetOrigins() + if slices.Contains(origins, originName) { + workers, ok := preferredByOriginEnginesMap[originName] + if !ok { + workers = make([]engines.Name, 0, len(enabledEngines)) + } + preferredByOriginEnginesMap[originName] = append(workers, engName) + } + } + } + + // Run all preferred by origin engines. Cond should be awaited unless the preferred timeout is reached. + wgPreferredByOriginEngines.Add(len(preferredByOriginEnginesMap)) + for _, workers := range preferredByOriginEnginesMap { + if len(workers) == 0 { + wgPreferredByOriginEngines.Done() + continue + } + + c := sync.Cond{L: &sync.Mutex{}} + go func() { + c.L.Lock() + c.Wait() + c.L.Unlock() + wgPreferredByOriginEngines.Done() + }() + for _, engName := range workers { + enginer := enginers[engName] + resChan := make(chan result.ResultScraped, 100) + engChan <- resChan + go func() { + searchOnce[engName].Do(func() { + log.Trace(). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "preferred by origin"). + Msg("Started") + + // Run the engine. + errs, scraped := enginer.Search(query, opts, resChan) + + if len(errs) > 0 { + searchOnce[engName].Errored() + log.Error(). + Errs("errors", errs). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "preferred by origin"). + Msg("Error searching") + } + + if !scraped { + log.Debug(). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "preferred by origin"). + Msg("Failed to scrape any results (probably timed out)") + } else { + searchOnce[engName].Scraped() + } + }) + + if searchOnce[engName].Success() { + c.L.Lock() + c.Signal() + c.L.Unlock() + } + }() + } + } +} diff --git a/src/search/run_required_engines.go b/src/search/run_required_engines.go new file mode 100644 index 00000000..b0bb2cf0 --- /dev/null +++ b/src/search/run_required_engines.go @@ -0,0 +1,55 @@ +package search + +import ( + "sync" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/utils/anonymize" +) + +func runRequiredEngines(enginers []scraper.Enginer, wgRequiredEngines *sync.WaitGroup, query string, opts options.Options, requiredEngines []engines.Name, engChan chan chan result.ResultScraped, searchOnce map[engines.Name]*onceWrapper) { + wgRequiredEngines.Add(len(requiredEngines)) + for _, engName := range requiredEngines { + enginer := enginers[engName] + resChan := make(chan result.ResultScraped, 100) + engChan <- resChan + go func() { + defer wgRequiredEngines.Done() + searchOnce[engName].Do(func() { + log.Trace(). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "required"). + Msg("Started") + + // Run the engine. + errs, scraped := enginer.Search(query, opts, resChan) + + if len(errs) > 0 { + searchOnce[engName].Errored() + log.Error(). + Errs("errors", errs). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "required"). + Msg("Error searching") + } + + if !scraped { + log.Debug(). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "required"). + Msg("Failed to scrape any results (probably timed out)") + } else { + searchOnce[engName].Scraped() + } + }) + }() + } +} diff --git a/src/search/run_required_origins.go b/src/search/run_required_origins.go new file mode 100644 index 00000000..faa756f6 --- /dev/null +++ b/src/search/run_required_origins.go @@ -0,0 +1,90 @@ +package search + +import ( + "slices" + "sync" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/search/scraper" + "github.com/hearchco/agent/src/utils/anonymize" +) + +func runRequiredByOriginEngines(enginers []scraper.Enginer, wgRequiredByOriginEngines *sync.WaitGroup, query string, opts options.Options, requiredByOriginEngines []engines.Name, enabledEngines []engines.Name, engChan chan chan result.ResultScraped, searchOnce map[engines.Name]*onceWrapper) { + // Create a map of slices of all the engines that contain origins from the required engines by origin. + requiredByOriginEnginesMap := make(map[engines.Name][]engines.Name, len(requiredByOriginEngines)) + for _, originName := range requiredByOriginEngines { + for _, engName := range enabledEngines { + origins := enginers[engName].GetOrigins() + if slices.Contains(origins, originName) { + workers, ok := requiredByOriginEnginesMap[originName] + if !ok { + workers = make([]engines.Name, 0, len(enabledEngines)) + } + requiredByOriginEnginesMap[originName] = append(workers, engName) + } + } + } + + // Run all required by origin engines. Cond should be awaited unless the hard timeout is reached. + wgRequiredByOriginEngines.Add(len(requiredByOriginEnginesMap)) + for _, workers := range requiredByOriginEnginesMap { + if len(workers) == 0 { + wgRequiredByOriginEngines.Done() + continue + } + + c := sync.Cond{L: &sync.Mutex{}} + go func() { + c.L.Lock() + c.Wait() + c.L.Unlock() + wgRequiredByOriginEngines.Done() + }() + for _, engName := range workers { + enginer := enginers[engName] + resChan := make(chan result.ResultScraped, 100) + engChan <- resChan + go func() { + searchOnce[engName].Do(func() { + log.Trace(). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "required by origin"). + Msg("Started") + + // Run the engine. + errs, scraped := enginer.Search(query, opts, resChan) + + if len(errs) > 0 { + searchOnce[engName].Errored() + log.Error(). + Errs("errors", errs). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "required by origin"). + Msg("Error searching") + } + + if !scraped { + log.Debug(). + Str("engine", engName.String()). + Str("query", anonymize.String(query)). + Str("group", "required by origin"). + Msg("Failed to scrape any results (probably timed out)") + } else { + searchOnce[engName].Scraped() + } + }) + if searchOnce[engName].Success() { + c.L.Lock() + c.Signal() + c.L.Unlock() + } + }() + } + } +} diff --git a/src/search/scraper/collector.go b/src/search/scraper/collector.go new file mode 100644 index 00000000..82ac73df --- /dev/null +++ b/src/search/scraper/collector.go @@ -0,0 +1,121 @@ +package scraper + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + + "github.com/andybalholm/brotli" + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search/useragent" +) + +func (e *EngineBase) initCollector(ctx context.Context) { + // Get a random user agent with it's Sec-CH-UA headers. + ua := useragent.RandomUserAgentWithHeaders() + + // Initialize the collector. + e.collector = colly.NewCollector( + colly.StdlibContext(ctx), + colly.Async(), + colly.MaxDepth(1), + colly.IgnoreRobotsTxt(), + colly.UserAgent(ua.UserAgent), + colly.Headers(map[string]string{ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Ch-Ua": ua.SecCHUA, + "Sec-Ch-Ua-Mobile": ua.SecCHUAMobile, + "Sec-Ch-Ua-Platform": ua.SecCHUAPlatform, + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + }), + ) +} + +func (e *EngineBase) initLimitRule(timings config.CategoryTimings) { + limitRule := colly.LimitRule{ + DomainGlob: "*", + Delay: timings.Delay, + RandomDelay: timings.RandomDelay, + Parallelism: timings.Parallelism, + } + if err := e.collector.Limit(&limitRule); err != nil { + log.Panic(). + Caller(). + Err(err). + Str("limitRule", fmt.Sprintf("%v", limitRule)). + Msg("Failed adding new limit rule") + // ^PANIC + } +} + +func (e *EngineBase) initCollectorOnRequest(ctx context.Context) { + e.collector.OnRequest(func(r *colly.Request) { + if err := ctx.Err(); err != nil { + if IsTimeoutError(err) { + log.Trace(). + Caller(). + Err(err). + Str("engine", e.Name.String()). + Msg("Context timeout error") + } else { + log.Error(). + Caller(). + Err(err). + Str("engine", e.Name.String()). + Msg("Context error") + } + r.Abort() + return + } + }) +} + +func (e *EngineBase) initCollectorOnResponse() { + e.collector.OnResponse(func(r *colly.Response) { + if strings.Contains(r.Headers.Get("Content-Encoding"), "br") { + reader := brotli.NewReader(bytes.NewReader(r.Body)) + + body, err := io.ReadAll(reader) + if err != nil { + log.Error(). + Caller(). + Err(err). + Str("engine", e.Name.String()). + Msg("Failed to decode brotli response") + return + } + + r.Body = body + } + }) +} + +func (e *EngineBase) initCollectorOnError() { + e.collector.OnError(func(r *colly.Response, err error) { + if IsTimeoutError(err) { + log.Trace(). + Caller(). + // Err(err). // Timeout error produces Get "url" error with the query. + Str("engine", e.Name.String()). + // Str("url", urll). // Can't reliably anonymize it (because it's engine dependent). + Msg("Request timeout error for url") + } else { + log.Error(). + Caller(). + Err(err). + Str("engine", e.Name.String()). + // Str("url", urll). // Can't reliably anonymize it (because it's engine dependent). + Bytes("response", r.Body). // WARN: Query can be present, depending on the response from the engine. + Msg("Request error for url") + } + }) +} diff --git a/src/search/scraper/dompaths.go b/src/search/scraper/dompaths.go new file mode 100644 index 00000000..94df5c29 --- /dev/null +++ b/src/search/scraper/dompaths.go @@ -0,0 +1,25 @@ +package scraper + +type DOMPaths struct { + ResultsContainer string + Result string + URL string + Title string + Description string +} + +type DOMPathsImages struct { + DOMPaths + + OriginalSize struct { + Height string + Width string + } + ThumbnailSize struct { + Height string + Width string + } + ThumbnailURL string + SourceName string + SourceURL string +} diff --git a/src/search/scraper/enginer.go b/src/search/scraper/enginer.go new file mode 100644 index 00000000..067b7efa --- /dev/null +++ b/src/search/scraper/enginer.go @@ -0,0 +1,54 @@ +package scraper + +import ( + "context" + + "github.com/gocolly/colly/v2" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" +) + +// Base interface used by each category specific interface. +type Enginer interface { + GetName() engines.Name + GetOrigins() []engines.Name + Init(context.Context, config.CategoryTimings) + ReInit(context.Context) + Search(string, options.Options, chan result.ResultScraped) ([]error, bool) +} + +// Base struct for every search engine. +type EngineBase struct { + Name engines.Name + Origins []engines.Name + collector *colly.Collector + timings config.CategoryTimings +} + +// Used to get the name of the search engine. +func (e EngineBase) GetName() engines.Name { + return e.Name +} + +// Used to get the origins of the search engine. +func (e EngineBase) GetOrigins() []engines.Name { + return e.Origins +} + +// Used to initialize the EngineBase collector. +func (e *EngineBase) Init(ctx context.Context, timings config.CategoryTimings) { + e.timings = timings + e.initCollector(ctx) + e.initLimitRule(timings) + e.initCollectorOnRequest(ctx) + e.initCollectorOnResponse() + e.initCollectorOnError() +} + +// Used to allow re-running the Search method. +func (e *EngineBase) ReInit(ctx context.Context) { + e.Init(ctx, e.timings) +} diff --git a/src/search/scraper/infoparams.go b/src/search/scraper/infoparams.go new file mode 100644 index 00000000..3f221ff7 --- /dev/null +++ b/src/search/scraper/infoparams.go @@ -0,0 +1,17 @@ +package scraper + +import "github.com/hearchco/agent/src/search/engines" + +type Info struct { + Name engines.Name + Domain string + URL string + Origins []engines.Name +} + +type Params struct { + Page string + Locale string + LocaleSec string + SafeSearch string +} diff --git a/src/search/engines/_sedefaults/pagecontext.go b/src/search/scraper/pagecontext.go similarity index 50% rename from src/search/engines/_sedefaults/pagecontext.go rename to src/search/scraper/pagecontext.go index 8be1e9c6..1a8dbbd6 100644 --- a/src/search/engines/_sedefaults/pagecontext.go +++ b/src/search/scraper/pagecontext.go @@ -1,21 +1,20 @@ -package _sedefaults +package scraper import ( "strconv" "github.com/gocolly/colly/v2" - "github.com/hearchco/hearchco/src/search/engines" "github.com/rs/zerolog/log" ) -func PageFromContext(ctx *colly.Context, seName engines.Name) int { +func (e EngineBase) PageFromContext(ctx *colly.Context) int { var pageStr string = ctx.Get("page") - page, converr := strconv.Atoi(pageStr) - if converr != nil { + page, err := strconv.Atoi(pageStr) + if err != nil { log.Panic(). Caller(). - Err(converr). - Str("engine", seName.String()). + Err(err). + Str("engine", e.Name.String()). Str("page", pageStr). Msg("Failed to convert page number to int") // ^PANIC diff --git a/src/search/scraper/pagerankcounter.go b/src/search/scraper/pagerankcounter.go new file mode 100644 index 00000000..40e4a44f --- /dev/null +++ b/src/search/scraper/pagerankcounter.go @@ -0,0 +1,25 @@ +package scraper + +import ( + "sync/atomic" +) + +// A goroutine-safe counter for PageRank. +type PageRankCounter struct { + counts []atomic.Int32 +} + +// Create a new PageRankCounter. +func NewPageRankCounter(pages int) PageRankCounter { + return PageRankCounter{counts: make([]atomic.Int32, pages)} +} + +// Increment the count for a page. +func (prc *PageRankCounter) Increment(page int) { + prc.counts[page].Add(1) +} + +// Get the count for a page + 1. +func (prc *PageRankCounter) GetPlusOne(page int) int { + return int(prc.counts[page].Load() + 1) +} diff --git a/src/search/engines/_sedefaults/fields.go b/src/search/scraper/parse/fields.go similarity index 61% rename from src/search/engines/_sedefaults/fields.go rename to src/search/scraper/parse/fields.go index 57b16f90..35718ce9 100644 --- a/src/search/engines/_sedefaults/fields.go +++ b/src/search/scraper/parse/fields.go @@ -1,30 +1,30 @@ -package _sedefaults +package parse import ( "strings" "github.com/PuerkitoBio/goquery" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/parse" "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/search/engines" + "github.com/hearchco/agent/src/search/scraper" ) -// Fetches from DOM via dompaths. Returns (url, title, description). -func RawFieldsFromDOM(dom *goquery.Selection, dompaths engines.DOMPaths, seName engines.Name) (string, string, string) { +// Fetches from DOM via dompaths. Returns url, title and description. +func RawFieldsFromDOM(dom *goquery.Selection, dompaths scraper.DOMPaths, seName engines.Name) (string, string, string) { descText := dom.Find(dompaths.Description).Text() titleDom := dom.Find(dompaths.Title) titleText := titleDom.Text() - // Title and Link selector are often the same, utilize this + // Title and URL selector are often the same. var linkDom *goquery.Selection - if dompaths.Link == dompaths.Result { + if dompaths.URL == dompaths.Result { linkDom = titleDom } else { - linkDom = dom.Find(dompaths.Link) + linkDom = dom.Find(dompaths.URL) } linkText, hrefExists := linkDom.Attr("href") - if !hrefExists { log.Error(). Caller(). @@ -32,7 +32,7 @@ func RawFieldsFromDOM(dom *goquery.Selection, dompaths engines.DOMPaths, seName Str("url", linkText). Str("title", titleText). Str("description", descText). - Msgf("Href attribute doesn't exist on matched URL element (%v)", dompaths.Link) + Msgf("Href attribute doesn't exist on matched URL element (%v)", dompaths.URL) return "", "", "" } @@ -40,21 +40,21 @@ func RawFieldsFromDOM(dom *goquery.Selection, dompaths engines.DOMPaths, seName return linkText, titleText, descText } -// Fetches from DOM via dompaths and sanitizes. Returns (url, title, description). -func FieldsFromDOM(dom *goquery.Selection, dompaths engines.DOMPaths, seName engines.Name) (string, string, string) { +// Fetches from DOM via dompaths and sanitizes. Returns url, title and description. +func FieldsFromDOM(dom *goquery.Selection, dompaths scraper.DOMPaths, seName engines.Name) (string, string, string) { return SanitizeFields(RawFieldsFromDOM(dom, dompaths, seName)) } func SanitizeURL(urlText string) string { - return parse.ParseURL(urlText) + return ParseURL(urlText) } func SanitizeTitle(titleText string) string { - return parse.ParseTextWithHTML(strings.TrimSpace(titleText)) + return ParseTextWithHTML(strings.TrimSpace(titleText)) } func SanitizeDescription(descText string) string { - return parse.ParseTextWithHTML(strings.TrimSpace(descText)) + return ParseTextWithHTML(strings.TrimSpace(descText)) } func SanitizeFields(linkText string, titleText string, descText string) (string, string, string) { diff --git a/src/search/parse/parse.go b/src/search/scraper/parse/parse.go similarity index 77% rename from src/search/parse/parse.go rename to src/search/scraper/parse/parse.go index 66b010c9..1e17b667 100644 --- a/src/search/parse/parse.go +++ b/src/search/scraper/parse/parse.go @@ -24,16 +24,15 @@ func ParseURL(rawURL string) string { } func parseURL(rawURL string) (string, error) { - // rawURL may be empty string, function should return empty string then. - rawURL = strings.TrimSpace(rawURL) - parsedURL, parseErr := url.Parse(rawURL) - if parseErr != nil { - return "", fmt.Errorf("parse.parseURL(): failed url.Parse() on url(%v). error: %w", rawURL, parseErr) + trimmedRawURL := strings.TrimSpace(rawURL) + parsedURL, err := url.Parse(trimmedRawURL) + if err != nil { + return "", fmt.Errorf("parse.parseURL(): failed url.Parse() on url(%v). error: %w", rawURL, err) } urlString := parsedURL.String() - if len(urlString) != 0 && len(parsedURL.Path) == 0 { // https://example.org -> https://example.org/ - urlString += "/" + if len(urlString) > 0 && urlString[len(urlString)-1] == '/' { + urlString = urlString[:len(urlString)-1] } return urlString, nil diff --git a/src/search/scraper/requests.go b/src/search/scraper/requests.go new file mode 100644 index 00000000..831c6c5f --- /dev/null +++ b/src/search/scraper/requests.go @@ -0,0 +1,39 @@ +package scraper + +import ( + "fmt" + "io" + "net/http" + + "github.com/gocolly/colly/v2" + "github.com/rs/zerolog/log" +) + +func (e EngineBase) Get(ctx *colly.Context, urll string, anonurll string) error { + log.Trace(). + Str("engine", e.Name.String()). + Str("url", anonurll). + Str("method", http.MethodGet). + Msg("Making a new request") + + if err := e.collector.Request(http.MethodGet, urll, nil, ctx, nil); err != nil { + return fmt.Errorf("%v: failed GET request to %v with %w", e.Name.String(), anonurll, err) + } + + return nil +} + +func (e EngineBase) Post(ctx *colly.Context, urll string, body io.Reader, anonBody string) error { + log.Trace(). + Str("engine", e.Name.String()). + Str("url", urll). + Str("body", anonBody). + Str("method", http.MethodPost). + Msg("Making a new request") + + if err := e.collector.Request(http.MethodPost, urll, body, ctx, nil); err != nil { + return fmt.Errorf("%v: failed POST request to %v with %w", e.Name.String(), urll, err) + } + + return nil +} diff --git a/src/search/scraper/scrape.go b/src/search/scraper/scrape.go new file mode 100644 index 00000000..cbb4cded --- /dev/null +++ b/src/search/scraper/scrape.go @@ -0,0 +1,28 @@ +package scraper + +import ( + "github.com/gocolly/colly/v2" +) + +// OnHTML registers a function. Function will be executed on every HTML +// element matched by the GoQuery Selector parameter. +// GoQuery Selector is a selector used by https://github.com/PuerkitoBio/goquery. +func (e *EngineBase) OnHTML(goquerySelector string, f colly.HTMLCallback) { + e.collector.OnHTML(goquerySelector, f) +} + +// OnResponse registers a function. Function will be executed on every response. +func (e *EngineBase) OnResponse(f colly.ResponseCallback) { + e.collector.OnResponse(f) +} + +// OnRequest registers a function. Function will be executed on every +// request made by the Collector. +func (e *EngineBase) OnRequest(f colly.RequestCallback) { + e.collector.OnRequest(f) +} + +// Wait returns when the collector jobs are finished. +func (e EngineBase) Wait() { + e.collector.Wait() +} diff --git a/src/search/scraper/timeout.go b/src/search/scraper/timeout.go new file mode 100644 index 00000000..946c681d --- /dev/null +++ b/src/search/scraper/timeout.go @@ -0,0 +1,21 @@ +package scraper + +import ( + "context" + "net" + "strings" +) + +func IsTimeoutError(err error) bool { + // Check if the error is a cancelled context error. + if strings.HasSuffix(err.Error(), context.Canceled.Error()) { + return true + } + + // Check if the error is a timeout error. + if perr, ok := err.(net.Error); ok && perr.Timeout() { + return true + } + + return false +} diff --git a/src/search/search.go b/src/search/search.go index 94553292..61f3998b 100644 --- a/src/search/search.go +++ b/src/search/search.go @@ -1,41 +1,112 @@ package search import ( - "github.com/hearchco/hearchco/src/anonymize" - "github.com/hearchco/hearchco/src/cache" - "github.com/hearchco/hearchco/src/config" - "github.com/hearchco/hearchco/src/search/engines" - "github.com/hearchco/hearchco/src/search/result" + "context" + "fmt" + "sync" + "time" + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/search/engines/options" + "github.com/hearchco/agent/src/search/result" + "github.com/hearchco/agent/src/utils/anonymize" ) -func Search(query string, options engines.Options, db cache.DB, categoryConf config.Category, settings map[engines.Name]config.Settings, salt string) ([]result.Result, bool) { - if results, err := db.GetResults(query, options); err != nil { - // Error in reading cache is not returned, just logged - log.Error(). - Caller(). - Err(err). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Failed accessing cache") - } else if results != nil { - log.Debug(). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Found results in cache") - - return results, true +func Search(query string, category category.Name, opts options.Options, catConf config.Category) ([]result.Result, error) { + if err := validateParams(query, opts); err != nil { + return nil, err } - // if the cache is inaccesible or the query+category is not in the cache log.Debug(). - Str("queryAnon", anonymize.String(query)). - Str("queryHash", anonymize.HashToSHA256B64(query)). - Msg("Nothing found in cache, doing a clean search") + Str("category", category.String()). + Str("query", anonymize.String(query)). + Int("pages_start", opts.Pages.Start). + Int("pages_max", opts.Pages.Max). + Str("locale", opts.Locale.String()). + Bool("safesearch", opts.SafeSearch). + Str("engines", fmt.Sprintf("%v", catConf.Engines)). + Str("required_engines", fmt.Sprintf("%v", catConf.RequiredEngines)). + Str("required_by_origin_engines", fmt.Sprintf("%v", catConf.RequiredByOriginEngines)). + Str("preferred_engines", fmt.Sprintf("%v", catConf.PreferredEngines)). + Str("preferred_by_origin_engines", fmt.Sprintf("%v", catConf.PreferredByOriginEngines)). + Dur("preferred_timeout", catConf.Timings.PreferredTimeout). + Dur("hard_timeout", catConf.Timings.HardTimeout). + Msg("Searching") + + // Capture start time. + startTime := time.Now() + + // Create contexts with timeout for HardTimeout and PreferredTimeout. + ctxHardTimeout, cancelHardTimeoutFunc := context.WithTimeout(context.Background(), catConf.Timings.HardTimeout) + defer cancelHardTimeoutFunc() + ctxPreferredTimeout, cancelPreferredTimeoutFunc := context.WithTimeout(context.Background(), catConf.Timings.PreferredTimeout) + defer cancelPreferredTimeoutFunc() + + // Create a context that cancels when both HardTimeout and PreferredTimeout are done. + searchCtx, cancelSearch := context.WithCancel(context.Background()) + defer cancelSearch() + go func() { + <-ctxHardTimeout.Done() + <-ctxPreferredTimeout.Done() + cancelSearch() + }() + + // Initialize each engine. + enginers := initializeEnginers(searchCtx, catConf.Engines, catConf.Timings) + + // Create a channel of channels to receive the results from each engine. + engChan := make(chan chan result.ResultScraped, len(catConf.Engines)) + + // Create a map for the results with RWMutex. + resMap := result.Map() + + // Start a goroutine to receive the results from each engine and add them to results map. + go createReceiver(engChan, &resMap, len(catConf.Engines)) + + // Create a sync.Once wrapper for each enginer.Search() to ensure that the engine is only run once. + searchOnce := initOnceWrapper(catConf.Engines) - // the main line - results := PerformSearch(query, options, categoryConf, settings, salt) - result.Shorten(results, 2500) + // Run all required engines. WaitGroup should be awaited unless the hard timeout is reached. + var wgRequiredEngines sync.WaitGroup + runRequiredEngines(enginers, &wgRequiredEngines, query, opts, catConf.RequiredEngines, engChan, searchOnce) + + // Run all required by origin engines. Cond should be awaited unless the hard timeout is reached. + var wgRequiredByOriginEngines sync.WaitGroup + runRequiredByOriginEngines(enginers, &wgRequiredByOriginEngines, query, opts, catConf.RequiredByOriginEngines, catConf.Engines, engChan, searchOnce) + + // Run all preferred engines. WaitGroup should be awaited unless the preferred timeout is reached. + var wgPreferredEngines sync.WaitGroup + runPreferredEngines(enginers, &wgPreferredEngines, query, opts, catConf.PreferredEngines, engChan, searchOnce) + + // Run all preferred by origin engines. Cond should be awaited unless the preferred timeout is reached. + var wgPreferredByOriginEngines sync.WaitGroup + runPreferredByOriginEngines(enginers, &wgPreferredByOriginEngines, query, opts, catConf.PreferredByOriginEngines, catConf.Engines, engChan, searchOnce) + + // Close the channel of channels (it's safe because each sending already happened sequentially). + close(engChan) + + // Cancel the hard timeout after all required engines have finished and all required by origin engines have finished. + go cancelHardTimeout(startTime, cancelHardTimeoutFunc, query, &wgRequiredEngines, catConf.RequiredEngines, &wgRequiredByOriginEngines, catConf.RequiredByOriginEngines) + + // Cancel the preferred timeout after all preferred engines have finished and all preferred by origin engines have finished. + go cancelPreferredTimeout(startTime, cancelPreferredTimeoutFunc, query, &wgPreferredEngines, catConf.PreferredEngines, &wgPreferredByOriginEngines, catConf.PreferredByOriginEngines) + + // Wait for both hard timeout and preferred timeout to finish. + <-searchCtx.Done() + + // Extract the results and responders from the map. + // TODO: Make title and desc length configurable. + results, responders := resMap.ExtractResultsAndResponders(len(catConf.Engines), 100, 1000) + + log.Debug(). + Int("results", len(results)). + Str("query", anonymize.String(query)). + Str("responders", fmt.Sprintf("%v", responders)). + Msg("Scraping finished") - return results, false + // Return the results. + return results, nil } diff --git a/src/search/useragent/useragent.go b/src/search/useragent/useragent.go index ac219ea5..23221d40 100644 --- a/src/search/useragent/useragent.go +++ b/src/search/useragent/useragent.go @@ -5,32 +5,41 @@ import ( "time" ) -type userAgentWithHeader struct { - UserAgent string - SecCHUA string +type userAgentWithHeaders struct { + UserAgent string + SecCHUA string + SecCHUAMobile string + SecCHUAPlatform string } -// user agents used when making requests and their corresponding Sec-Ch-Ua headers -var userAgentArray = [...]userAgentWithHeader{ +// UserAgents used when making requests and their corresponding Sec-Ch-Ua headers. +var userAgentArray = [...]userAgentWithHeaders{ // Chrome 119.0.0, Windows { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", `"Google Chrome";v="119", "Chromium";v="119", "Not=A?Brand";v="24"`, + "?0", + "\"Windows\"", }, // Chrome 118.0.0, Windows { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", `"Google Chrome";v="118", "Chromium";v="118", "Not=A?Brand";v="24"`, + "?0", + "\"Windows\"", }, // Chrome 117.0.0, Windows { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36", `"Google Chrome";v="117", "Chromium";v="117", "Not=A?Brand";v="24"`, + "?0", + "\"Windows\"", }, } -func randomUserAgentStruct() userAgentWithHeader { - randSrc := rand.NewSource(time.Now().UnixNano()) // WARN: will work until year 2262 +func randomUserAgentStruct() userAgentWithHeaders { + // WARNING: Will stop working after year 2262. + randSrc := rand.NewSource(time.Now().UnixNano()) randGen := rand.New(randSrc) return userAgentArray[randGen.Intn(len(userAgentArray))] } @@ -40,7 +49,6 @@ func RandomUserAgent() string { return randomUA.UserAgent } -func RandomUserAgentWithHeader() (string, string) { - randomUA := randomUserAgentStruct() - return randomUA.UserAgent, randomUA.SecCHUA +func RandomUserAgentWithHeaders() userAgentWithHeaders { + return randomUserAgentStruct() } diff --git a/src/anonymize/hash.go b/src/utils/anonymize/hash.go similarity index 74% rename from src/anonymize/hash.go rename to src/utils/anonymize/hash.go index 03e448a7..3d3bab10 100644 --- a/src/anonymize/hash.go +++ b/src/utils/anonymize/hash.go @@ -6,14 +6,10 @@ import ( ) func HashToSHA256B64(orig string) string { - // hash string with sha256 which returns binary hasher := sha256.New() hasher.Write([]byte(orig)) hashedBinary := hasher.Sum(nil) - - // encode binary hash to base64 string hashedString := base64.URLEncoding.EncodeToString(hashedBinary) - return hashedString } @@ -21,6 +17,6 @@ func HashToSHA256B64Salted(orig string, salt string) string { return HashToSHA256B64(orig + salt) } -func CheckHash(hash string, orig string, salt string) bool { +func VerifyHash(hash string, orig string, salt string) bool { return hash == HashToSHA256B64Salted(orig, salt) } diff --git a/src/anonymize/hash_test.go b/src/utils/anonymize/hash_test.go similarity index 89% rename from src/anonymize/hash_test.go rename to src/utils/anonymize/hash_test.go index 3e87fb0a..bac5c590 100644 --- a/src/anonymize/hash_test.go +++ b/src/utils/anonymize/hash_test.go @@ -1,9 +1,7 @@ -package anonymize_test +package anonymize import ( "testing" - - "github.com/hearchco/hearchco/src/anonymize" ) func TestHashToSHA256B64(t *testing.T) { @@ -16,7 +14,7 @@ func TestHashToSHA256B64(t *testing.T) { } for _, test := range tests { - hash := anonymize.HashToSHA256B64(test.orig) + hash := HashToSHA256B64(test.orig) if hash != test.expected { t.Errorf("HashToSHA256B64(%q) = %q, want %q", test.orig, hash, test.expected) } diff --git a/src/anonymize/string.go b/src/utils/anonymize/string.go similarity index 58% rename from src/anonymize/string.go rename to src/utils/anonymize/string.go index 1f2d6b6d..732fa7f8 100644 --- a/src/anonymize/string.go +++ b/src/utils/anonymize/string.go @@ -7,8 +7,18 @@ import ( "time" ) -// remove duplicate characters from string -func Deduplicate(orig string) string { +// Anonymize string +func String(orig string) string { + return shuffle(deduplicate(orig)) +} + +// Anonymize substring of a string +func Substring(orig string, ssToAnon string) string { + return strings.ReplaceAll(orig, ssToAnon, String(ssToAnon)) +} + +// Remove duplicate characters from string. +func deduplicate(orig string) string { dedupStr := "" encountered := make(map[rune]bool) @@ -22,23 +32,11 @@ func Deduplicate(orig string) string { return dedupStr } -// sort string characters lexicographically -func SortString(orig string) string { - // Convert the string to a slice of characters - characters := strings.Split(orig, "") - - // Sort the slice - sort.Strings(characters) - - // Join the sorted slice back into a string - return strings.Join(characters, "") -} - -// shuffle string because deduplicate retains the order of letters -func Shuffle(orig string) string { +// Shuffle string because deduplicate retains the order of letters. +func shuffle(orig string) string { inRune := []rune(orig) - // WARNING: in year 2262, this will break + // WARNING: In year 2262, this will break. rng := rand.New(rand.NewSource(time.Now().UnixNano())) rng.Shuffle(len(inRune), func(i, j int) { inRune[i], inRune[j] = inRune[j], inRune[i] @@ -47,12 +45,10 @@ func Shuffle(orig string) string { return string(inRune) } -// anonymize string -func String(orig string) string { - return Shuffle(Deduplicate(orig)) -} - -// anonymize substring of string -func Substring(orig string, ssToAnon string) string { - return strings.ReplaceAll(orig, ssToAnon, String(ssToAnon)) +// Sort string characters lexicographically. +func sortString(orig string) string { + // Convert the string to a slice of characters. + characters := strings.Split(orig, "") + sort.Strings(characters) + return strings.Join(characters, "") } diff --git a/src/anonymize/string_test.go b/src/utils/anonymize/string_test.go similarity index 88% rename from src/anonymize/string_test.go rename to src/utils/anonymize/string_test.go index 10e8df93..953b89b1 100644 --- a/src/anonymize/string_test.go +++ b/src/utils/anonymize/string_test.go @@ -1,9 +1,7 @@ -package anonymize_test +package anonymize import ( "testing" - - "github.com/hearchco/hearchco/src/anonymize" ) func TestDeduplicate(t *testing.T) { @@ -16,7 +14,7 @@ func TestDeduplicate(t *testing.T) { } for _, test := range tests { - deduplicated := anonymize.Deduplicate(test.orig) + deduplicated := deduplicate(test.orig) if deduplicated != test.expected { t.Errorf("deduplicate(%q) = %q, want %q", test.orig, deduplicated, test.expected) } @@ -36,7 +34,7 @@ func TestSortString(t *testing.T) { } for _, test := range tests { - sorted := anonymize.SortString(test.orig) + sorted := sortString(test.orig) if sorted != test.expected { t.Errorf("SortString(%q) = %q, want %q", test.orig, sorted, test.expected) @@ -57,8 +55,8 @@ func TestShuffle(t *testing.T) { } for _, test := range tests { - shuffled := anonymize.Shuffle(test.orig) - shuffledSorted := anonymize.SortString(shuffled) + shuffled := shuffle(test.orig) + shuffledSorted := sortString(shuffled) if shuffledSorted != test.expected { t.Errorf("SortString(Shuffle(%q)) = %q, want %q", test.orig, shuffledSorted, test.expected) diff --git a/src/anonymize/structs_test.go b/src/utils/anonymize/structs_test.go similarity index 72% rename from src/anonymize/structs_test.go rename to src/utils/anonymize/structs_test.go index 97b20919..727bb272 100644 --- a/src/anonymize/structs_test.go +++ b/src/utils/anonymize/structs_test.go @@ -1,4 +1,4 @@ -package anonymize_test +package anonymize type testPair struct { orig string diff --git a/src/gotypelimits/ints.go b/src/utils/gotypelimits/ints.go similarity index 100% rename from src/gotypelimits/ints.go rename to src/utils/gotypelimits/ints.go diff --git a/src/gotypelimits/uints.go b/src/utils/gotypelimits/uints.go similarity index 100% rename from src/gotypelimits/uints.go rename to src/utils/gotypelimits/uints.go diff --git a/src/utils/morestrings/join.go b/src/utils/morestrings/join.go new file mode 100644 index 00000000..1729e7cd --- /dev/null +++ b/src/utils/morestrings/join.go @@ -0,0 +1,24 @@ +package morestrings + +import ( + "strings" +) + +// JoinNonEmpty concatenates the non empty elements of its first argument to create a single string. The separator +// string sep is placed between elements in the resulting string. +func JoinNonEmpty(elems []string, beg string, sep string) string { + nonEmptyElems := []string{} + for _, elem := range elems { + if elem != "" { + nonEmptyElems = append(nonEmptyElems, elem) + } + } + + if len(nonEmptyElems) == 0 { + return "" + } else if len(nonEmptyElems) == 1 { + return beg + nonEmptyElems[0] + } else { + return beg + strings.Join(nonEmptyElems, sep) + } +} diff --git a/src/moretime/fancy.go b/src/utils/moretime/convert.go similarity index 82% rename from src/moretime/fancy.go rename to src/utils/moretime/convert.go index f7db38c5..b6773c8f 100644 --- a/src/moretime/fancy.go +++ b/src/utils/moretime/convert.go @@ -23,14 +23,18 @@ func convertToDurationWithoutLastChar(s string) time.Duration { return time.Duration(handleAtoi(s[:len(s)-1])) } -// converts 1y to 1 year -// converts 2M to 2 month -// converts 3w to 3 week -// converts 4d to 4 day -// converts 5h to 5 hour -// converts 6m to 6 minute -// converts 7s to 7 second -// converts 8 to 8 millisecond +/* +Converts the following to time.Duration: + + "1y" -> 1 year, + "2M" -> 2 months, + "3w" -> 3 weeks, + "4d" -> 4 days, + "5h" -> 5 hours, + "6m" -> 6 minutes, + "7s" -> 7 seconds, + "8"-> 8 milliseconds +*/ func ConvertFromFancyTime(fancy string) time.Duration { switch fancy[len(fancy)-1] { case 'y': @@ -52,7 +56,7 @@ func ConvertFromFancyTime(fancy string) time.Duration { } } -// converts to milliseconds +// Converts to milliseconds. func ConvertToFancyTime(d time.Duration) string { return strconv.Itoa(int(d.Milliseconds())) } diff --git a/src/moretime/time.go b/src/utils/moretime/types.go similarity index 89% rename from src/moretime/time.go rename to src/utils/moretime/types.go index 8b0ffd5c..31d34a2a 100644 --- a/src/moretime/time.go +++ b/src/utils/moretime/types.go @@ -1,6 +1,8 @@ package moretime -import "time" +import ( + "time" +) const Day = 24 * time.Hour const Week = 7 * Day