Repository: parzival-space/badley
Pull request #35: Add Tool: Brave Search
Author: login: parzival-space
Labels: name: enhancement
description: New feature or request
Created: 2025-04-25T15:10:44Z | Merged: 2025-04-25T15:20:01Z

Completes #32 


Diff:
diff --git a/src/main/java/space/parzival/discord/badley/ai/search/BraveTools.java b/src/main/java/space/parzival/discord/badley/ai/search/BraveTools.java
new file mode 100644
index 0000000..be426f1
--- /dev/null
+++ b/src/main/java/space/parzival/discord/badley/ai/search/BraveTools.java
@@ -0,0 +1,46 @@
+package space.parzival.discord.badley.ai.search;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+import space.parzival.discord.badley.ai.AiToolsService;
+import space.parzival.discord.badley.service.brave.BraveService;
+import space.parzival.discord.badley.service.brave.model.BraveQueryResponse;
+
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+@ConditionalOnProperty(value = "badley.ai.tools.brave.token")
+@AllArgsConstructor
+public class BraveTools implements AiToolsService {
+    private final BraveService braveService;
+
+    private static final String WEB_SEARCH_RESULT = """
+            %s:
+            - Title: %s
+            - Description: %s
+            - Family Friendly: %b
+            - Language: %s
+            """;
+
+    @Tool(description = "Search the web for information.")
+    public String search(String query) {
+        log.debug("AI is requesting web search results from Brave for query: {}", query);
+
+        try {
+            BraveQueryResponse response = braveService.query(query);
+
+            return response.getWeb().getResults().stream().map(result ->
+                    String.format(WEB_SEARCH_RESULT,
+                            result.getUrl(), result.getTitle(), result.getDescription(),
+                            result.isFamilyFriendly(), result.getLanguage()))
+                    .collect(Collectors.joining("\n"));
+        } catch (Exception e) {
+            log.error("Error fetching web search results: {}", e.getMessage());
+            return "Error fetching web search results: " + e.getMessage();
+        }
+    }
+}
diff --git a/src/main/java/space/parzival/discord/badley/configuration/properties/BraveProperties.java b/src/main/java/space/parzival/discord/badley/configuration/properties/BraveProperties.java
new file mode 100644
index 0000000..54dcd7b
--- /dev/null
+++ b/src/main/java/space/parzival/discord/badley/configuration/properties/BraveProperties.java
@@ -0,0 +1,12 @@
+package space.parzival.discord.badley.configuration.properties;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "badley.ai.tools.brave")
+@AllArgsConstructor
+public class BraveProperties {
+    private String token;
+}
diff --git a/src/main/java/space/parzival/discord/badley/service/brave/BraveService.java b/src/main/java/space/parzival/discord/badley/service/brave/BraveService.java
new file mode 100644
index 0000000..b8ce224
--- /dev/null
+++ b/src/main/java/space/parzival/discord/badley/service/brave/BraveService.java
@@ -0,0 +1,74 @@
+package space.parzival.discord.badley.service.brave;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+import space.parzival.discord.badley.configuration.properties.BraveProperties;
+import space.parzival.discord.badley.service.brave.model.BraveQueryResponse;
+
+import java.util.List;
+
+@Service
+@ConditionalOnProperty(value = "badley.ai.tools.brave.token")
+public class BraveService {
+    private final RestTemplate apiRestTemplate;
+
+    public BraveService(RestTemplateBuilder restTemplateBuilder, BraveProperties braveProperties) {
+        this.apiRestTemplate = restTemplateBuilder
+                .rootUri("https://api.search.brave.com")
+                .defaultHeader("x-subscription-token", braveProperties.getToken())
+                .build();
+    }
+
+    /**
+     * Queries the Brave Search API for a web search with the given parameters.
+     * @apiNote This API has a rate limit of 2000 requests per month on the free plan.
+     * @param query The search query to perform.
+     * @param country The country code to use for the search. (Two uppercase-letter ISO 3166-1 alpha-2 code)
+     * @param language The language code to use for the search. (Two-letter ISO 639-1 code)
+     * @param count The number of results to return. (Less or equal to 50)
+     * @param offset The number of results to skip. (Less or equal to 9)
+     * @return The response containing the search results.
+     */
+    public BraveQueryResponse query(String query, String country, String language, int count, int offset) {
+        UriComponents apiUri = UriComponentsBuilder.newInstance()
+                .path("/res/v1/web/search")
+                .queryParam("q", query)
+                .queryParam("country", country)
+                .queryParam("search_lang", language)
+                .queryParam("count", count)
+                .queryParam("offset", offset)
+                //.queryParam("freshness", "pw") // pd, pw, pm
+                .queryParam("summary", false)
+                .queryParam("safesearch", "off")
+                .queryParam("result_filter", "web")
+                .build();
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setAccept(List.of(MediaType.APPLICATION_JSON));
+
+        return apiRestTemplate.exchange(
+                apiUri.toUriString(),
+                HttpMethod.GET,
+                new HttpEntity<>(headers),
+                BraveQueryResponse.class
+        ).getBody();
+    }
+
+    /**
+     * Queries the Brave Search API for a web search with the given parameters.
+     * @apiNote This API has a rate limit of 2000 requests per month on the free plan.
+     * @param query The search query to perform.
+     * @return The response containing the search results.
+     */
+    public BraveQueryResponse query(String query) {
+        return query(query, "US", "en", 10, 0);
+    }
+}
diff --git a/src/main/java/space/parzival/discord/badley/service/brave/model/BraveQueryResponse.java b/src/main/java/space/parzival/discord/badley/service/brave/model/BraveQueryResponse.java
new file mode 100644
index 0000000..d6690fd
--- /dev/null
+++ b/src/main/java/space/parzival/discord/badley/service/brave/model/BraveQueryResponse.java
@@ -0,0 +1,30 @@
+package space.parzival.discord.badley.service.brave.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.*;
+import space.parzival.discord.badley.service.brave.model.query.BraveQueryInfo;
+import space.parzival.discord.badley.service.brave.model.query.BraveQueryWebResults;
+
+@Value
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+@With
+@JsonDeserialize(builder = BraveQueryResponse.BraveQueryResponseBuilder.class)
+public class BraveQueryResponse {
+    @JsonProperty("query")
+    BraveQueryInfo query;
+
+    /**
+     * @apiNote DO NOT USE THIS FIELD!
+     * @implNote This field will not be parsed as it contains redundant information.
+     */
+    @JsonProperty("mixed")
+    Object mixed;
+
+    @JsonProperty("type")
+    String type;
+
+    @JsonProperty("web")
+    BraveQueryWebResults web;
+}
diff --git a/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryInfo.java b/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryInfo.java
new file mode 100644
index 0000000..77eb890
--- /dev/null
+++ b/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryInfo.java
@@ -0,0 +1,51 @@
+package space.parzival.discord.badley.service.brave.model.query;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.*;
+
+@Value
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+@With
+@JsonDeserialize(builder = BraveQueryInfo.BraveQueryInfoBuilder.class)
+public class BraveQueryInfo {
+    @JsonProperty("original")
+    String originalQuery;
+
+    @JsonProperty("show_strict_warning")
+    boolean showStrictWarning;
+
+    @JsonProperty("is_navigational")
+    boolean navigational;
+
+    @JsonProperty("is_news_breaking")
+    boolean newsBreaking;
+
+    @JsonProperty("spellcheck_off")
+    boolean spellcheckOff;
+
+    @JsonProperty("country")
+    String country;
+
+    @JsonProperty("bad_results")
+    boolean badResults;
+
+    @JsonProperty("should_fallback")
+    boolean shouldFallback;
+
+    @JsonProperty("postal_code")
+    String postalCode;
+
+    @JsonProperty("city")
+    String city;
+
+    @JsonProperty("header_country")
+    String headerCountry;
+
+    @JsonProperty("more_results_available")
+    boolean moreResultsAvailable;
+
+    @JsonProperty("state")
+    String state;
+}
diff --git a/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResult.java b/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResult.java
new file mode 100644
index 0000000..9f221f6
--- /dev/null
+++ b/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResult.java
@@ -0,0 +1,59 @@
+package space.parzival.discord.badley.service.brave.model.query;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.*;
+
+@Value
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+@With
+@JsonDeserialize(builder = BraveQueryWebResult.BraveQueryWebResultBuilder.class)
+public class BraveQueryWebResult {
+    @JsonProperty("title")
+    String title;
+
+    @JsonProperty("url")
+    String url;
+
+    @JsonProperty("is_source_local")
+    boolean isSourceLocal;
+
+    @JsonProperty("is_source_both")
+    boolean isSourceBoth;
+
+    @JsonProperty("description")
+    String description;
+
+    @JsonProperty("profile")
+    BraveQueryWebResultProfile profile;
+
+    @JsonProperty("language")
+    String language;
+
+    @JsonProperty("family_friendly")
+    boolean familyFriendly;
+
+    @JsonProperty("type")
+    String resultType;
+
+    @JsonProperty("subtype")
+    String resultSubtype;
+
+    @JsonProperty("is_live")
+    boolean isLive;
+
+    /**
+     * @apiNote DO NOT USE THIS FIELD!
+     * @implNote This field will not be parsed as it contains redundant information.
+     */
+    @JsonProperty("meta_url")
+    Object metaUrl;
+
+    /**
+     * @apiNote DO NOT USE THIS FIELD!
+     * @implNote This field will not be parsed as it contains redundant information.
+     */
+    @JsonProperty("thumbnail")
+    Object thumbnail;
+}
diff --git a/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResultProfile.java b/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResultProfile.java
new file mode 100644
index 0000000..f3e225b
--- /dev/null
+++ b/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResultProfile.java
@@ -0,0 +1,24 @@
+package space.parzival.discord.badley.service.brave.model.query;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.*;
+
+@Value
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+@With
+@JsonDeserialize(builder = BraveQueryWebResultProfile.BraveQueryWebResultProfileBuilder.class)
+public class BraveQueryWebResultProfile {
+    @JsonProperty("name")
+    String name;
+
+    @JsonProperty("url")
+    String url;
+
+    @JsonProperty("long_name")
+    String domain;
+
+    @JsonProperty("img")
+    String imgUrl;
+}
diff --git a/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResults.java b/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResults.java
new file mode 100644
index 0000000..f9156cd
--- /dev/null
+++ b/src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResults.java
@@ -0,0 +1,23 @@
+package space.parzival.discord.badley.service.brave.model.query;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.*;
+
+import java.util.List;
+
+@Value
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+@With
+@JsonDeserialize(builder = BraveQueryWebResults.BraveQueryWebResultsBuilder.class)
+public class BraveQueryWebResults {
+    @JsonProperty("type")
+    String resultType;
+
+    @JsonProperty("results")
+    List<BraveQueryWebResult> results;
+
+    @JsonProperty("family_friendly")
+    boolean familyFriendly;
+}
diff --git a/src/test/java/space/parzival/discord/badley/ai/search/BraveToolsTest.java b/src/test/java/space/parzival/discord/badley/ai/search/BraveToolsTest.java
new file mode 100644
index 0000000..6d33f68
--- /dev/null
+++ b/src/test/java/space/parzival/discord/badley/ai/search/BraveToolsTest.java
@@ -0,0 +1,74 @@
+package space.parzival.discord.badley.ai.search;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import space.parzival.discord.badley.service.brave.BraveService;
+import space.parzival.discord.badley.service.brave.model.BraveQueryResponse;
+import space.parzival.discord.badley.service.brave.model.query.BraveQueryWebResult;
+import space.parzival.discord.badley.service.brave.model.query.BraveQueryWebResults;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class BraveToolsTest {
+    private BraveService searchService;
+
+    @BeforeEach
+    void setUp() {
+        searchService = mock(BraveService.class);
+    }
+
+    @Test
+    void search_returns_validData() {
+        when(searchService.query(anyString())).thenReturn(createBraveQueryResponse());
+
+        BraveTools braveTools = new BraveTools(searchService);
+        String result = braveTools.search("test query");
+
+        assertNotNull(result);
+        assertTrue(result.contains("https://example.com"));
+        assertTrue(result.contains("Title: Example Title"));
+        assertTrue(result.contains("Description: Example Description"));
+        assertTrue(result.contains("Family Friendly: true"));
+        assertTrue(result.contains("Language: en"));
+    }
+
+    @Test
+    void search_returns_errorMessage() {
+        when(searchService.query(anyString())).thenThrow(new RuntimeException("Test exception"));
+
+        BraveTools braveTools = new BraveTools(searchService);
+        String result = braveTools.search("test query");
+
+        assertNotNull(result);
+        assertTrue(result.contains("Error fetching web search results: Test exception"));
+    }
+
+    private BraveQueryResponse createBraveQueryResponse() {
+        return BraveQueryResponse.builder()
+                .web(createBraveQueryWebResults())
+                .build();
+    }
+
+    private BraveQueryWebResults createBraveQueryWebResults() {
+        return BraveQueryWebResults.builder()
+                .resultType("web")
+                .results(List.of(createBraveQueryWebResult()))
+                .familyFriendly(true)
+                .build();
+    }
+
+    private BraveQueryWebResult createBraveQueryWebResult() {
+        return BraveQueryWebResult.builder()
+                .url("https://example.com")
+                .title("Example Title")
+                .description("Example Description")
+                .familyFriendly(true)
+                .language("en")
+                .build();
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/space/parzival/discord/badley/service/brave/BraveServiceIT.java b/src/test/java/space/parzival/discord/badley/service/brave/BraveServiceIT.java
new file mode 100644
index 0000000..cd6e295
--- /dev/null
+++ b/src/test/java/space/parzival/discord/badley/service/brave/BraveServiceIT.java
@@ -0,0 +1,63 @@
+package space.parzival.discord.badley.service.brave;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+import org.springframework.test.web.client.MockRestServiceServer;
+import space.parzival.discord.badley.configuration.properties.BraveProperties;
+import space.parzival.discord.badley.service.brave.model.BraveQueryResponse;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+@EnableConfigurationProperties(BraveProperties.class)
+@RestClientTest(value = BraveService.class, properties = "badley.ai.tools.brave.token=token")
+class BraveServiceIT {
+    @Autowired
+    private BraveService service;
+
+    @MockitoSpyBean
+    private BraveProperties properties;
+
+    @Autowired
+    private MockRestServiceServer server;
+
+    @Autowired
+    private ResourceLoader resourceLoader;
+
+    @Test
+    void query_returns_validData() {
+        server.expect(request -> request.getURI().getPath().equals("/res/v1/web/search"))
+                .andRespond(withSuccess(
+                        resourceLoader.getResource("classpath:mock/brave/valid-response.json"),
+                        MediaType.APPLICATION_JSON
+                ));
+
+        BraveQueryResponse result = service.query("test");
+
+        assertNotNull(result);
+        assertEquals("search", result.getType());
+        assertEquals(10, result.getWeb().getResults().size());
+        assertEquals("Parzival", result.getWeb().getResults().getFirst().getTitle());
+        assertEquals("https://parzival.space/", result.getWeb().getResults().getFirst().getUrl());
+    }
+
+    @Test
+    void query_returns_errorResponse() {
+        server.expect(request -> request.getURI().getPath().equals("/res/v1/web/search"))
+                .andRespond(withSuccess(
+                        resourceLoader.getResource("classpath:mock/brave/error-response.json"),
+                        MediaType.APPLICATION_JSON
+                ));
+
+        BraveQueryResponse result = service.query("test");
+
+        assertNotNull(result);
+        assertEquals("ErrorResponse", result.getType());
+        assertNull(result.getWeb());
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/mock/brave/error-response.json b/src/test/resources/mock/brave/error-response.json
new file mode 100644
index 0000000..2cd9184
--- /dev/null
+++ b/src/test/resources/mock/brave/error-response.json
@@ -0,0 +1,23 @@
+{
+  "type": "ErrorResponse",
+  "error": {
+    "id": "5c31e409-f9e2-4f9e-a722-0e5309e23c01",
+    "status": 422,
+    "code": "VALIDATION",
+    "detail": "Unable to validate request parameter(s)",
+    "meta": {
+      "errors": [
+        {
+          "type": "int_parsing",
+          "loc": [
+            "query",
+            "count"
+          ],
+          "msg": "Input should be a valid integer, unable to parse string as an integer",
+          "input": "10sd"
+        }
+      ]
+    }
+  },
+  "time": 1745593592
+}
\ No newline at end of file
diff --git a/src/test/resources/mock/brave/valid-response.json b/src/test/resources/mock/brave/valid-response.json
new file mode 100644
index 0000000..a7d15ce
--- /dev/null
+++ b/src/test/resources/mock/brave/valid-response.json
@@ -0,0 +1,443 @@
+{
+  "query": {
+    "original": "parzival-space",
+    "show_strict_warning": false,
+    "is_navigational": true,
+    "is_news_breaking": false,
+    "spellcheck_off": true,
+    "country": "de",
+    "bad_results": false,
+    "should_fallback": false,
+    "postal_code": "",
+    "city": "",
+    "header_country": "",
+    "more_results_available": true,
+    "state": ""
+  },
+  "mixed": {
+    "type": "mixed",
+    "main": [
+      {
+        "type": "web",
+        "index": 0,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 1,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 2,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 3,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 4,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 5,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 6,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 7,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 8,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 9,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 10,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 11,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 12,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 13,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 14,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 15,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 16,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 17,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 18,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 19,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 20,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 21,
+        "all": false
+      },
+      {
+        "type": "web",
+        "index": 22,
+        "all": false
+      }
+    ],
+    "top": [],
+    "side": []
+  },
+  "type": "search",
+  "web": {
+    "type": "search",
+    "results": [
+      {
+        "title": "Parzival",
+        "url": "https://parzival.space/",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "<strong>parzival</strong>-<strong>space</strong> · developer audiophile gamer nerd ·",
+        "profile": {
+          "name": "Parzival",
+          "url": "https://parzival.space/",
+          "long_name": "parzival.space",
+          "img": "https://imgs.search.brave.com/VqOFX6vQI_xGHWDHpK_-aBM63csintTJjBmslkwJ0Gc/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOGQ3NWNlN2Qw/ZmZiMThjMTRkODI1/ODNlYzQxOTYyNzZl/ZTU2MjBlNTFjZTEy/YWNmMzZlNTdhODc2/NzU3MzAyYS9wYXJ6/aXZhbC5zcGFjZS8"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "generic",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "parzival.space",
+          "hostname": "parzival.space",
+          "favicon": "https://imgs.search.brave.com/VqOFX6vQI_xGHWDHpK_-aBM63csintTJjBmslkwJ0Gc/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOGQ3NWNlN2Qw/ZmZiMThjMTRkODI1/ODNlYzQxOTYyNzZl/ZTU2MjBlNTFjZTEy/YWNmMzZlNTdhODc2/NzU3MzAyYS9wYXJ6/aXZhbC5zcGFjZS8",
+          "path": ""
+        }
+      },
+      {
+        "title": "User Parzival - Space Exploration Stack Exchange",
+        "url": "https://space.stackexchange.com/users/39775/parzival",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "Q&amp;A for spacecraft operators, scientists, engineers, and enthusiasts",
+        "profile": {
+          "name": "Stack Exchange",
+          "url": "https://space.stackexchange.com/users/39775/parzival",
+          "long_name": "space.stackexchange.com",
+          "img": "https://imgs.search.brave.com/aLh80EzPa0qw5yu5O79baOMzQOpZZMKbk5RxGh1C5wQ/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMThjYWNkOWNi/MzZlOTFkYTYwZjJj/YmE5YzM2ZTQ4ZDgw/N2I3MDcyMTg2OGMx/MDQwYjQ3MjdiODg3/ZjJlYzlhMi9zcGFj/ZS5zdGFja2V4Y2hh/bmdlLmNvbS8"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "generic",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "space.stackexchange.com",
+          "hostname": "space.stackexchange.com",
+          "favicon": "https://imgs.search.brave.com/aLh80EzPa0qw5yu5O79baOMzQOpZZMKbk5RxGh1C5wQ/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMThjYWNkOWNi/MzZlOTFkYTYwZjJj/YmE5YzM2ZTQ4ZDgw/N2I3MDcyMTg2OGMx/MDQwYjQ3MjdiODg3/ZjJlYzlhMi9zcGFj/ZS5zdGFja2V4Y2hh/bmdlLmNvbS8",
+          "path": "› users  › 39775  › parzival"
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/Qp2hbpZk7_DpiWzr9nsp89qnuGCQzk89mSD7NUSN6I8/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9jZG4u/c3N0YXRpYy5uZXQv/U2l0ZXMvc3BhY2Uv/SW1nL2FwcGxlLXRv/dWNoLWljb25AMi5w/bmc_dj1iYjM5YzAw/ZGJhMzQ",
+          "original": "https://cdn.sstatic.net/Sites/space/Img/apple-touch-icon@2.png?v=bb39c00dba34",
+          "logo": false
+        }
+      },
+      {
+        "title": "Sponsor @parzival-space on GitHub Sponsors · GitHub",
+        "url": "https://github.com/sponsors/parzival-space",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "A developer from Germany who spends much of his time trying to get his computer to do the things he wants it to do.",
+        "profile": {
+          "name": "GitHub",
+          "url": "https://github.com/sponsors/parzival-space",
+          "long_name": "github.com",
+          "img": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "generic",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "github.com",
+          "hostname": "github.com",
+          "favicon": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw",
+          "path": "› sponsors  › parzival-space"
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/IQWDFFo8x5ox2iCflMCUgUQmul5KA7d9hErQCVTh-H8/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9hdmF0/YXJzLmdpdGh1YnVz/ZXJjb250ZW50LmNv/bS91LzM4Mjg2NDI0/P3M9NDAwJmFtcDt2/PTQ",
+          "original": "https://avatars.githubusercontent.com/u/38286424?s=400&amp;v=4",
+          "logo": false
+        }
+      },
+      {
+        "title": "Parzival – Wikipedia",
+        "url": "https://de.wikipedia.org/wiki/Parzival",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "In kunstvoll verzahnten Handlungssträngen einer Doppelromanstruktur werden die Aventiuren erzählt, die abenteuerlichen Geschicke zweier ritterlicher Hauptfiguren – einerseits die Entwicklung des Titelhelden <strong>Parzival</strong> (von altfranzösisch Perceval) vom Unwissenden im Narrenkleid zum Gralskönig, ...",
+        "page_age": "2004-02-25T13:32:23",
+        "profile": {
+          "name": "Wikipedia",
+          "url": "https://de.wikipedia.org/wiki/Parzival",
+          "long_name": "de.wikipedia.org",
+          "img": "https://imgs.search.brave.com/isGN_dS_WoFpc8EQKa1Iw4s3pCrXpLAE_Wn_ILqXW10/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZTY0NDY0YmVk/MjZlYjZhMjMwMmI3/OTZmNzM3N2JiOTAy/ODFjYzdiODA1NmE1/Mjk0ZTk1ZDkwYTA4/ZTFmOTMxZS9kZS53/aWtpcGVkaWEub3Jn/Lw"
+        },
+        "language": "de",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "generic",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "de.wikipedia.org",
+          "hostname": "de.wikipedia.org",
+          "favicon": "https://imgs.search.brave.com/isGN_dS_WoFpc8EQKa1Iw4s3pCrXpLAE_Wn_ILqXW10/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZTY0NDY0YmVk/MjZlYjZhMjMwMmI3/OTZmNzM3N2JiOTAy/ODFjYzdiODA1NmE1/Mjk0ZTk1ZDkwYTA4/ZTFmOTMxZS9kZS53/aWtpcGVkaWEub3Jn/Lw",
+          "path": "› wiki  › Parzival"
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/_O82xlRIzpjlbfmW9PSdX8K5zzvWXsRG7hHnksXfeFE/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly91cGxv/YWQud2lraW1lZGlh/Lm9yZy93aWtpcGVk/aWEvY29tbW9ucy9l/L2UyL1dvbGZyYW1f/UGFyeml2YWxfUHJv/bG9nX2NwZzMzOS5q/cGc",
+          "original": "https://upload.wikimedia.org/wikipedia/commons/e/e2/Wolfram_Parzival_Prolog_cpg339.jpg",
+          "logo": false
+        },
+        "age": "February 25, 2004"
+      },
+      {
+        "title": "Parzival-117 | Parzival-117 - Roberts Space Industries | Follow the development of Star Citizen and Squadron 42",
+        "url": "https://robertsspaceindustries.com/citizens/Parzival-117",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "Roberts <strong>Space</strong> Industries is the official go-to website for all news about Star Citizen and Squadron 42. It also hosts the online store for game items and merch, as well as all the community tools used by our fans.",
+        "profile": {
+          "name": "Parzival-117",
+          "url": "https://robertsspaceindustries.com/citizens/Parzival-117",
+          "long_name": "robertsspaceindustries.com",
+          "img": "https://imgs.search.brave.com/IJYMMPFF8fcM7sk7leDoS4gyYFpXk6Qer6dmWhcLPeU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvN2RlOGJlOGY3/MzlhZTlhMzY3ZjYz/ZWJlNGQ3YmVmNDI3/OTA1NjY1NmRlZTAw/YmU0N2FhNGJlMzg5/MWViYTU3ZC9yb2Jl/cnRzc3BhY2VpbmR1/c3RyaWVzLmNvbS8"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "generic",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "robertsspaceindustries.com",
+          "hostname": "robertsspaceindustries.com",
+          "favicon": "https://imgs.search.brave.com/IJYMMPFF8fcM7sk7leDoS4gyYFpXk6Qer6dmWhcLPeU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvN2RlOGJlOGY3/MzlhZTlhMzY3ZjYz/ZWJlNGQ3YmVmNDI3/OTA1NjY1NmRlZTAw/YmU0N2FhNGJlMzg5/MWViYTU3ZC9yb2Jl/cnRzc3BhY2VpbmR1/c3RyaWVzLmNvbS8",
+          "path": "› citizens  › Parzival-117"
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/tVdFAtCKaPGzhYo9VVbv9nHfP675TF5KORM6x1XCCo4/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9jZG4u/cm9iZXJ0c3NwYWNl/aW5kdXN0cmllcy5j/b20vc3RhdGljL2lt/YWdlcy9SU0ktZmIu/anBn",
+          "original": "https://cdn.robertsspaceindustries.com/static/images/RSI-fb.jpg",
+          "logo": false
+        }
+      },
+      {
+        "title": "All Products – Parzival Co.",
+        "url": "https://parzivalco.com/collections/all-products",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "<strong>Parzival</strong> Co. ... Choosing a selection results in a full page refresh. Press the <strong>space</strong> key then arrow keys to make a selection.",
+        "profile": {
+          "name": "Parzivalco",
+          "url": "https://parzivalco.com/collections/all-products",
+          "long_name": "parzivalco.com",
+          "img": "https://imgs.search.brave.com/fEltStqJmRzhxHhdwEBVLYwJ_BpHwZ3pBKbraKYrZ84/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMTgxMzE4NWE0/NzFjMWY1ODcwMWMz/NDZlZWM1NTMyNGNm/NmQzN2E4YTdkOTcx/MGNjNzc3NzQ4MTA4/NGJmNmZhMC9wYXJ6/aXZhbGNvLmNvbS8"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "generic",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "parzivalco.com",
+          "hostname": "parzivalco.com",
+          "favicon": "https://imgs.search.brave.com/fEltStqJmRzhxHhdwEBVLYwJ_BpHwZ3pBKbraKYrZ84/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMTgxMzE4NWE0/NzFjMWY1ODcwMWMz/NDZlZWM1NTMyNGNm/NmQzN2E4YTdkOTcx/MGNjNzc3NzQ4MTA4/NGJmNmZhMC9wYXJ6/aXZhbGNvLmNvbS8",
+          "path": "› collections  › all-products"
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/4kvUStp4ELprPKBwqKGW-KDoGPUPOr-8gfvzHk9jQjY/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9jZG4u/c2hvcGlmeS5jb20v/cy9maWxlcy8xLzAz/MDUvMzQyNy8xMTA5/L2ZpbGVzLzREMTIw/QzcyLUI5MDctNDA0/MC1BMTRBLTUzNEEz/RjEzN0FFMV9kZmYz/NGVkNC01NjA5LTQ3/YjItYjFhMC1jNGIy/ZmU3NmMwZjEucG5n/P3Y9MTU4OTU0NTMy/Mw",
+          "original": "https://cdn.shopify.com/s/files/1/0305/3427/1109/files/4D120C72-B907-4040-A14A-534A3F137AE1_dff34ed4-5609-47b2-b1a0-c4b2fe76c0f1.png?v=1589545323",
+          "logo": false
+        }
+      },
+      {
+        "title": "GitHub - parzival-space/PSpectrum: Allows programs to access the audio loop-back of your system.",
+        "url": "https://github.com/parzival-space/PSpectrum",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "Allows programs to access the audio loop-back of your system. - GitHub - <strong>parzival</strong>-<strong>space</strong>/PSpectrum: Allows programs to access the audio loop-back of your system.",
+        "profile": {
+          "name": "GitHub",
+          "url": "https://github.com/parzival-space/PSpectrum",
+          "long_name": "github.com",
+          "img": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "software",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "github.com",
+          "hostname": "github.com",
+          "favicon": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw",
+          "path": "› parzival-space  › PSpectrum"
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/0kYHZZ5RYBLn70v-HvlY0_O3dtMvfXPBRucUuymM0k8/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9vcGVu/Z3JhcGguZ2l0aHVi/YXNzZXRzLmNvbS9m/ZjU0YWZkOGU1NGFi/MzkzYTU4ZjRiOWRh/MTYxZDA1ODZiMWQ0/MDI2ZjYxYmYzNzM5/NzEwOTJmNWI0ZDc0/ZTE2L3BhcnppdmFs/LXNwYWNlL1BTcGVj/dHJ1bQ",
+          "original": "https://opengraph.githubassets.com/ff54afd8e54ab393a58f4b9da161d0586b1d4026f61bf373971092f5b4d74e16/parzival-space/PSpectrum",
+          "logo": false
+        }
+      },
+      {
+        "title": "GitHub - parzival-space/Linux-Guides: Just some notes so I dont have to re-read the docs every time I want to do stuff.",
+        "url": "https://github.com/parzival-space/Linux-Guides",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "Just some notes so I dont have to re-read the docs every time I want to do stuff. - GitHub - <strong>parzival</strong>-<strong>space</strong>/Linux-Guides: Just some notes so I dont have to re-read the docs every time I want to do ...",
+        "profile": {
+          "name": "GitHub",
+          "url": "https://github.com/parzival-space/Linux-Guides",
+          "long_name": "github.com",
+          "img": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "software",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "github.com",
+          "hostname": "github.com",
+          "favicon": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw",
+          "path": "› parzival-space  › Linux-Guides"
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/anG7ggBs9pQo_T4iczJiCnLqTh2MUUthNQvOZh3SWEg/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9vcGVu/Z3JhcGguZ2l0aHVi/YXNzZXRzLmNvbS84/YjEzYzdlYWUyN2Zk/Yzk3NmZkMWU0YzNi/MWFkYWY1NGNjNjA4/NjM1YjJkMGUzMjNj/NGFkYjI0NTk1YWQ0/ZjNiL3BhcnppdmFs/LXNwYWNlL0xpbnV4/LUd1aWRlcw",
+          "original": "https://opengraph.githubassets.com/8b13c7eae27fdc976fd1e4c3b1adaf54cc608635b2d0e323c4adb24595ad4f3b/parzival-space/Linux-Guides",
+          "logo": false
+        }
+      },
+      {
+        "title": "GitHub - parzival-space/audiovizbd: Adds an audio visualizer behind the user modal.",
+        "url": "https://github.com/parzival-space/audiovizbd",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "Adds an audio visualizer behind the user modal. Contribute to <strong>parzival</strong>-<strong>space</strong>/audiovizbd development by creating an account on GitHub.",
+        "profile": {
+          "name": "GitHub",
+          "url": "https://github.com/parzival-space/audiovizbd",
+          "long_name": "github.com",
+          "img": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "software",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "github.com",
+          "hostname": "github.com",
+          "favicon": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw",
+          "path": "› parzival-space  › audiovizbd"
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/_YtAnTkMeIO2WdBb-9FtZOurwZvbl0ZsKVuTP4ssUHY/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9vcGVu/Z3JhcGguZ2l0aHVi/YXNzZXRzLmNvbS9k/ZjQyMGE2YjdjMjk2/OGM4YmJmMWNhMzM5/MDA4ZmI5OWJiMjYz/Y2ZiZTRlNTcxZTFh/MmQ0MTNhMTE2ODNi/NmNmL3BhcnppdmFs/LXNwYWNlL2F1ZGlv/dml6YmQ",
+          "original": "https://opengraph.githubassets.com/df420a6b7c2968c8bbf1ca339008fb99bb263cfbe4e571e1a2d413a11683b6cf/parzival-space/audiovizbd",
+          "logo": false
+        }
+      },
+      {
+        "title": "Parzival Co. | Designer Clothing Lifestyle Brand Made For The Bold",
+        "url": "https://parzivalco.com/",
+        "is_source_local": false,
+        "is_source_both": false,
+        "description": "Press the <strong>space</strong> key then arrow keys to make a selection.",
+        "profile": {
+          "name": "Parzivalco",
+          "url": "https://parzivalco.com/",
+          "long_name": "parzivalco.com",
+          "img": "https://imgs.search.brave.com/fEltStqJmRzhxHhdwEBVLYwJ_BpHwZ3pBKbraKYrZ84/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMTgxMzE4NWE0/NzFjMWY1ODcwMWMz/NDZlZWM1NTMyNGNm/NmQzN2E4YTdkOTcx/MGNjNzc3NzQ4MTA4/NGJmNmZhMC9wYXJ6/aXZhbGNvLmNvbS8"
+        },
+        "language": "en",
+        "family_friendly": true,
+        "type": "search_result",
+        "subtype": "generic",
+        "is_live": false,
+        "meta_url": {
+          "scheme": "https",
+          "netloc": "parzivalco.com",
+          "hostname": "parzivalco.com",
+          "favicon": "https://imgs.search.brave.com/fEltStqJmRzhxHhdwEBVLYwJ_BpHwZ3pBKbraKYrZ84/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMTgxMzE4NWE0/NzFjMWY1ODcwMWMz/NDZlZWM1NTMyNGNm/NmQzN2E4YTdkOTcx/MGNjNzc3NzQ4MTA4/NGJmNmZhMC9wYXJ6/aXZhbGNvLmNvbS8",
+          "path": ""
+        },
+        "thumbnail": {
+          "src": "https://imgs.search.brave.com/4kvUStp4ELprPKBwqKGW-KDoGPUPOr-8gfvzHk9jQjY/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9jZG4u/c2hvcGlmeS5jb20v/cy9maWxlcy8xLzAz/MDUvMzQyNy8xMTA5/L2ZpbGVzLzREMTIw/QzcyLUI5MDctNDA0/MC1BMTRBLTUzNEEz/RjEzN0FFMV9kZmYz/NGVkNC01NjA5LTQ3/YjItYjFhMC1jNGIy/ZmU3NmMwZjEucG5n/P3Y9MTU4OTU0NTMy/Mw",
+          "original": "https://cdn.shopify.com/s/files/1/0305/3427/1109/files/4D120C72-B907-4040-A14A-534A3F137AE1_dff34ed4-5609-47b2-b1a0-c4b2fe76c0f1.png?v=1589545323",
+          "logo": false
+        }
+      }
+    ],
+    "family_friendly": true
+  }
+}
\ No newline at end of file


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds the Brave Search tool integration to complete #32 by introducing a new service that queries the Brave Search API, new response model classes, and associated integration and unit tests.  
- Added BraveService with two query methods that wrap the Brave Search API.  
- Included new domain models to deserialize API responses.  
- Introduced BraveTools to expose the tool functionality for AI web searches.

### Reviewed Changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| src/test/java/space/parzival/discord/badley/service/brave/BraveServiceIT.java | Integration tests for BraveService validating both valid and error response handling. |
| src/test/java/space/parzival/discord/badley/ai/search/BraveToolsTest.java | Unit tests for BraveTools ensuring proper processing of search results and error messages. |
| src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResults.java | Model class for deserializing web search results. |
| src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResultProfile.java | Model class for deserializing profile information in web query responses. |
| src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryWebResult.java | Model class for individual web result items with additional metadata. |
| src/main/java/space/parzival/discord/badley/service/brave/model/query/BraveQueryInfo.java | Model class for additional query information from the API response. |
| src/main/java/space/parzival/discord/badley/service/brave/model/BraveQueryResponse.java | Aggregates the overall API response including query info and web results. |
| src/main/java/space/parzival/discord/badley/service/brave/BraveService.java | The service class responsible for making HTTP requests to the Brave Search API. |
| src/main/java/space/parzival/discord/badley/configuration/properties/BraveProperties.java | Configuration properties for injecting the Brave API token. |
| src/main/java/space/parzival/discord/badley/ai/search/BraveTools.java | The tool exposing Brave Search functionality for AI, formatting and handling results. |
</details>


<details>
<summary>Files not reviewed (1)</summary>

* **src/test/resources/mock/brave/error-response.json**: Language not supported
</details>



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:11:45Z
user: login: parzival-space
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-25T15:14:12Z
user: login: parzival-space
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-25T15:17:11Z

Review comments:
diff_hunk: @@ -0,0 +1,12 @@
+package space.parzival.discord.badley.configuration.properties;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "badley.ai.tools.brave")
+@AllArgsConstructor
path: src/main/java/space/parzival/discord/badley/configuration/properties/BraveProperties.java
user: login: Copilot
body: As a @ConfigurationProperties bean, BraveProperties might fail property binding without a default constructor or explicit @ConstructorBinding. Consider adding a no-args constructor or annotating with @ConstructorBinding if using constructor injection.
```suggestion
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;

@Data
@ConfigurationProperties(prefix = "badley.ai.tools.brave")
@AllArgsConstructor
@ConstructorBinding
```
created_at: 2025-04-25T15:11:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+package space.parzival.discord.badley.ai.search;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+import space.parzival.discord.badley.ai.AiToolsService;
+import space.parzival.discord.badley.service.brave.BraveService;
+import space.parzival.discord.badley.service.brave.model.BraveQueryResponse;
+
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+@ConditionalOnProperty(value = "badley.ai.tools.brave.token")
+@AllArgsConstructor
+public class BraveTools implements AiToolsService {
+    private final BraveService braveService;
+
+    private static final String WEB_SEARCH_RESULT = """
path: src/main/java/space/parzival/discord/badley/ai/search/BraveTools.java
user: login: Copilot
body: [nitpick] The text block used for WEB_SEARCH_RESULT may include unintended leading whitespace, potentially affecting the formatting of the output. Consider using stripIndent() or adjusting the text block formatting to ensure the result is displayed as intended.
created_at: 2025-04-25T15:11:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,12 @@
+package space.parzival.discord.badley.configuration.properties;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "badley.ai.tools.brave")
+@AllArgsConstructor
path: src/main/java/space/parzival/discord/badley/configuration/properties/BraveProperties.java
user: login: parzival-space
body: Copilot stupid.
created_at: 2025-04-25T15:14:11Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,46 @@
+package space.parzival.discord.badley.ai.search;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+import space.parzival.discord.badley.ai.AiToolsService;
+import space.parzival.discord.badley.service.brave.BraveService;
+import space.parzival.discord.badley.service.brave.model.BraveQueryResponse;
+
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+@ConditionalOnProperty(value = "badley.ai.tools.brave.token")
+@AllArgsConstructor
+public class BraveTools implements AiToolsService {
+    private final BraveService braveService;
+
+    private static final String WEB_SEARCH_RESULT = """
path: src/main/java/space/parzival/discord/badley/ai/search/BraveTools.java
user: login: parzival-space
body: Created a issue for now #36 
created_at: 2025-04-25T15:17:11Z
author_association: OWNER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-25T15:19:22Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=parzival-space_badley&pullRequest=35) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=parzival-space_badley&pullRequest=35&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=parzival-space_badley&pullRequest=35&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=parzival-space_badley&pullRequest=35&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=parzival-space_badley&pullRequest=35&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=parzival-space_badley&pullRequest=35&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=parzival-space_badley&pullRequest=35)


author_association: NONE