Initial Commit
This commit is contained in:
parent
4c0797a45d
commit
dce138339d
17 changed files with 577 additions and 374 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Build
|
||||
bin/
|
||||
search
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Go specific
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
go.work
|
||||
127
Makefile
Normal file
127
Makefile
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
.PHONY: build clean test test-coverage test-verbose install lint run setup help local-install
|
||||
|
||||
# Build variables
|
||||
BINARY_NAME=search
|
||||
BUILD_DIR=bin
|
||||
CMD_DIR=cmd/search
|
||||
COVERAGE_DIR=coverage
|
||||
GO_BIN=$(HOME)/go/bin
|
||||
|
||||
# Go commands
|
||||
GOCMD=go
|
||||
GOBUILD=$(GOCMD) build
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
GOTEST=$(GOCMD) test
|
||||
GOGET=$(GOCMD) get
|
||||
GOMOD=$(GOCMD) mod
|
||||
GOLINT=golangci-lint
|
||||
|
||||
# Build the project
|
||||
build:
|
||||
@echo "Building..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR)
|
||||
|
||||
# Clean build files
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@rm -rf $(BUILD_DIR) $(COVERAGE_DIR)
|
||||
$(GOCLEAN)
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
$(GOTEST) ./...
|
||||
|
||||
# Run tests with coverage
|
||||
test-coverage:
|
||||
@echo "Running tests with coverage..."
|
||||
@mkdir -p $(COVERAGE_DIR)
|
||||
$(GOTEST) -coverprofile=$(COVERAGE_DIR)/coverage.out ./...
|
||||
$(GOCMD) tool cover -html=$(COVERAGE_DIR)/coverage.out -o $(COVERAGE_DIR)/coverage.html
|
||||
@echo "Coverage report generated in $(COVERAGE_DIR)/coverage.html"
|
||||
|
||||
# Run tests with verbose output
|
||||
test-verbose:
|
||||
@echo "Running tests (verbose)..."
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
# Install dependencies
|
||||
deps:
|
||||
@echo "Downloading dependencies..."
|
||||
$(GOMOD) download
|
||||
$(GOMOD) tidy
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
@echo "Running linter..."
|
||||
$(GOLINT) run
|
||||
|
||||
# Install the binary
|
||||
install:
|
||||
@echo "Installing to $(GO_BIN)..."
|
||||
@mkdir -p $(GO_BIN)
|
||||
$(GOBUILD) -o $(GO_BIN)/$(BINARY_NAME) ./$(CMD_DIR)
|
||||
@if ! echo "$$PATH" | grep -q "$(GO_BIN)"; then \
|
||||
echo "Warning: $(GO_BIN) is not in your PATH"; \
|
||||
echo "Add this line to your ~/.bashrc or ~/.zshrc:"; \
|
||||
echo " export PATH=\$$PATH:$(GO_BIN)"; \
|
||||
fi
|
||||
|
||||
# Install to /usr/local/bin (requires sudo)
|
||||
local-install: build
|
||||
@echo "Installing to /usr/local/bin..."
|
||||
@sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/$(BINARY_NAME)
|
||||
|
||||
# Run the application (requires query argument)
|
||||
run:
|
||||
@if [ -z "$(query)" ]; then \
|
||||
echo "Usage: make run query='your search query'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@$(BUILD_DIR)/$(BINARY_NAME) $(query)
|
||||
|
||||
# Setup project structure
|
||||
setup:
|
||||
@echo "Setting up project structure..."
|
||||
@mkdir -p cmd/search internal/adapters internal/formatter
|
||||
@if [ -f main.go ]; then \
|
||||
echo "Moving main.go to cmd/search/..."; \
|
||||
rm cmd/search/main.go || true; \
|
||||
mv main.go cmd/search/; \
|
||||
fi
|
||||
@if [ -d adapters ]; then \
|
||||
echo "Moving adapter files to internal/adapters/..."; \
|
||||
mv adapters/* internal/adapters/ 2>/dev/null || true; \
|
||||
rmdir adapters; \
|
||||
fi
|
||||
@if [ -f main_test.go ]; then \
|
||||
echo "Moving main_test.go to internal/formatter/formatter_test.go..."; \
|
||||
mv main_test.go internal/formatter/formatter_test.go; \
|
||||
fi
|
||||
@if [ -f search.py ]; then \
|
||||
echo "Removing Python version..."; \
|
||||
rm search.py; \
|
||||
fi
|
||||
@echo "Updating go.mod..."
|
||||
@$(GOMOD) init github.com/regismesquita/search-cli || true
|
||||
@$(GOMOD) tidy
|
||||
@echo "Project structure setup complete"
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make build - Build the project"
|
||||
@echo " make clean - Clean build files"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make test-coverage - Run tests with coverage report"
|
||||
@echo " make test-verbose - Run tests with verbose output"
|
||||
@echo " make deps - Download dependencies"
|
||||
@echo " make lint - Run linter"
|
||||
@echo " make install - Install binary to ~/go/bin"
|
||||
@echo " make local-install - Install binary to /usr/local/bin"
|
||||
@echo " make run - Run the application (requires query='your search query')"
|
||||
@echo " make setup - Setup project structure"
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
131
README.md
Normal file
131
README.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Search CLI
|
||||
|
||||
A command-line interface for searching using Serper and Tavily APIs.
|
||||
|
||||
## Features
|
||||
|
||||
- Search using Google (via Serper API)
|
||||
- Search and extract content using Tavily API
|
||||
- JSON output support
|
||||
- Configurable search depth (for Tavily)
|
||||
|
||||
## Installation
|
||||
|
||||
### Using Go Install
|
||||
```bash
|
||||
go install github.com/regismesquita/search-cli/cmd/search@latest
|
||||
```
|
||||
|
||||
### Using mise (formerly rtx)
|
||||
```bash
|
||||
mise use -g go@latest
|
||||
mise install github.com/regismesquita/search-cli/cmd/search@latest
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/regismesquita/search-cli
|
||||
cd search-cli
|
||||
|
||||
# Build and install
|
||||
make build
|
||||
make install # Installs to ~/go/bin
|
||||
# or
|
||||
make local-install # Installs to /usr/local/bin (requires sudo)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set your API keys as environment variables:
|
||||
```bash
|
||||
# For Serper
|
||||
export SERPER_API_KEY=your_key_here
|
||||
|
||||
# For Tavily
|
||||
export TAVILY_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Search (using Serper)
|
||||
```bash
|
||||
# Default search using Serper
|
||||
search "your query"
|
||||
# or explicitly
|
||||
search -s "your query"
|
||||
```
|
||||
|
||||
### Using Tavily
|
||||
```bash
|
||||
# Basic search
|
||||
search -t "your query"
|
||||
|
||||
# Advanced search
|
||||
search -t -depth advanced "your query"
|
||||
|
||||
# Extract content from URLs
|
||||
search -t -e "https://example.com"
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
```bash
|
||||
search -json "your query"
|
||||
search -t -json "your query"
|
||||
search -t -e -json "https://example.com"
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `-s`: Use Serper (default)
|
||||
- `-t`: Use Tavily
|
||||
- `-e`: Extract content (Tavily only)
|
||||
- `-json`: Output in JSON format
|
||||
- `-depth`: Search depth for Tavily (basic or advanced)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run tests with coverage
|
||||
make test-coverage
|
||||
|
||||
# Run linter
|
||||
make lint
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/
|
||||
│ └── search/ # Main application
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── adapters/ # API providers
|
||||
│ │ ├── serper.go
|
||||
│ │ ├── tavily.go
|
||||
│ │ └── types.go
|
||||
│ └── formatter/ # Output formatting
|
||||
│ └── formatter.go
|
||||
├── go.mod
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package adapters
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSerperProvider_Search(t *testing.T) {
|
||||
// Mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("X-API-KEY") != "test-key" {
|
||||
t.Errorf("Expected API key header, got %s", r.Header.Get("X-API-KEY"))
|
||||
}
|
||||
|
||||
// Return mock response
|
||||
response := map[string]interface{}{
|
||||
"organic": []map[string]interface{}{
|
||||
{
|
||||
"link": "https://example.com",
|
||||
"snippet": "Example content",
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Set test environment
|
||||
os.Setenv("SERPER_API_KEY", "test-key")
|
||||
defer os.Unsetenv("SERPER_API_KEY")
|
||||
|
||||
provider, err := NewSerperProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
|
||||
// Override API URL for testing
|
||||
originalURL := "https://google.serper.dev/search"
|
||||
http.DefaultClient = server.Client()
|
||||
|
||||
results, err := provider.Search("test query", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results.Results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results.Results))
|
||||
}
|
||||
|
||||
if results.Results[0].URL != "https://example.com" {
|
||||
t.Errorf("Expected URL https://example.com, got %s", results.Results[0].URL)
|
||||
}
|
||||
|
||||
if results.Results[0].Content != "Example content" {
|
||||
t.Errorf("Expected content 'Example content', got %s", results.Results[0].Content)
|
||||
}
|
||||
|
||||
// Restore original URL
|
||||
_ = originalURL
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
package adapters
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTavilyProvider_Search(t *testing.T) {
|
||||
// Mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Verify request body contains API key
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
t.Fatalf("Failed to decode request body: %v", err)
|
||||
}
|
||||
if reqBody["api_key"] != "test-key" {
|
||||
t.Errorf("Expected API key in body, got %v", reqBody["api_key"])
|
||||
}
|
||||
|
||||
// Return mock response
|
||||
response := map[string]interface{}{
|
||||
"results": []map[string]interface{}{
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"content": "Example content",
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Set test environment
|
||||
os.Setenv("TAVILY_API_KEY", "test-key")
|
||||
defer os.Unsetenv("TAVILY_API_KEY")
|
||||
|
||||
provider, err := NewTavilyProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
|
||||
// Override API URL for testing
|
||||
http.DefaultClient = server.Client()
|
||||
|
||||
results, err := provider.Search("test query", map[string]string{"depth": "advanced"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results.Results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results.Results))
|
||||
}
|
||||
|
||||
if results.Results[0].URL != "https://example.com" {
|
||||
t.Errorf("Expected URL https://example.com, got %s", results.Results[0].URL)
|
||||
}
|
||||
|
||||
if results.Results[0].Content != "Example content" {
|
||||
t.Errorf("Expected content 'Example content', got %s", results.Results[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTavilyProvider_Extract(t *testing.T) {
|
||||
// Mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Return mock response
|
||||
response := ExtractResponse{
|
||||
Results: []ExtractResult{
|
||||
{
|
||||
URL: "https://example.com",
|
||||
RawContent: "Example content",
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Set test environment
|
||||
os.Setenv("TAVILY_API_KEY", "test-key")
|
||||
defer os.Unsetenv("TAVILY_API_KEY")
|
||||
|
||||
provider, err := NewTavilyProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
|
||||
// Override API URL for testing
|
||||
http.DefaultClient = server.Client()
|
||||
|
||||
results, err := provider.Extract([]string{"https://example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results.Results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results.Results))
|
||||
}
|
||||
|
||||
if results.Results[0].URL != "https://example.com" {
|
||||
t.Errorf("Expected URL https://example.com, got %s", results.Results[0].URL)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,15 +7,15 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"search-cli/internal/adapters"
|
||||
"search-cli/internal/formatter"
|
||||
"github.com/regismesquita/search-cli/internal/adapters"
|
||||
"github.com/regismesquita/search-cli/internal/formatter"
|
||||
)
|
||||
|
||||
func main() {
|
||||
jsonOutput := flag.Bool("json", false, "Output in JSON format")
|
||||
tavilyMode := flag.Bool("tavily", false, "Use Tavily as provider")
|
||||
serperMode := flag.Bool("serper", false, "Use Serper as provider (default)")
|
||||
extractMode := flag.Bool("extract", false, "Extract content from URLs (Tavily only)")
|
||||
tavilyMode := flag.Bool("t", false, "Use Tavily as provider")
|
||||
serperMode := flag.Bool("s", false, "Use Serper as provider (default)")
|
||||
extractMode := flag.Bool("e", false, "Extract content from URLs (Tavily only)")
|
||||
depth := flag.String("depth", "basic", "Search depth (basic or advanced) - only for Tavily")
|
||||
flag.Parse()
|
||||
|
||||
|
|
@ -26,13 +26,13 @@ func main() {
|
|||
|
||||
// Validate provider flags
|
||||
if *tavilyMode && *serperMode {
|
||||
fmt.Fprintln(os.Stderr, "Error: Cannot use both -tavily and -serper")
|
||||
fmt.Fprintln(os.Stderr, "Error: Cannot use both -t and -s")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate extract mode
|
||||
if *extractMode && *serperMode {
|
||||
fmt.Fprintln(os.Stderr, "Error: Extract mode is only available with Tavily")
|
||||
fmt.Fprintln(os.Stderr, "Error: Extract mode (-e) is only available with Tavily (-t)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -1,3 +1,3 @@
|
|||
module search-cli
|
||||
module github.com/regismesquita/search-cli
|
||||
|
||||
go 1.21
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
type SerperProvider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type serperRequest struct {
|
||||
|
|
@ -29,7 +30,10 @@ func NewSerperProvider() (*SerperProvider, error) {
|
|||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("SERPER_API_KEY environment variable is not set")
|
||||
}
|
||||
return &SerperProvider{apiKey: apiKey}, nil
|
||||
return &SerperProvider{
|
||||
apiKey: apiKey,
|
||||
baseURL: "https://google.serper.dev/search",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SerperProvider) Search(query string, options map[string]string) (*SearchResponse, error) {
|
||||
|
|
@ -39,7 +43,7 @@ func (s *SerperProvider) Search(query string, options map[string]string) (*Searc
|
|||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://google.serper.dev/search", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", s.baseURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
89
internal/adapters/serper_test.go
Normal file
89
internal/adapters/serper_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package adapters
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupMockServer(t *testing.T, expectedResponse interface{}) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("X-API-KEY") != "test-key" {
|
||||
t.Errorf("Expected API key header, got %s", r.Header.Get("X-API-KEY"))
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(expectedResponse)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestSerperProvider_Search(t *testing.T) {
|
||||
mockServer := NewTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("X-API-KEY") != "test-key" {
|
||||
t.Errorf("Expected API key header, got %s", r.Header.Get("X-API-KEY"))
|
||||
}
|
||||
|
||||
// Return mock response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"organic": [
|
||||
{
|
||||
"link": "https://example.com",
|
||||
"snippet": "Example content"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Set test environment
|
||||
oldKey := os.Getenv("SERPER_API_KEY")
|
||||
os.Setenv("SERPER_API_KEY", "test-key")
|
||||
defer os.Setenv("SERPER_API_KEY", oldKey)
|
||||
|
||||
provider, err := NewSerperProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
provider.baseURL = mockServer.URL
|
||||
|
||||
results, err := provider.Search("test query", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if len(results.Results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results.Results))
|
||||
}
|
||||
|
||||
expected := SearchResult{
|
||||
URL: "https://example.com",
|
||||
Content: "Example content",
|
||||
}
|
||||
|
||||
if results.Results[0] != expected {
|
||||
t.Errorf("Expected %+v, got %+v", expected, results.Results[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerperProvider_NoAPIKey(t *testing.T) {
|
||||
// Clear API key
|
||||
oldKey := os.Getenv("SERPER_API_KEY")
|
||||
os.Unsetenv("SERPER_API_KEY")
|
||||
defer os.Setenv("SERPER_API_KEY", oldKey)
|
||||
|
||||
_, err := NewSerperProvider()
|
||||
if err == nil {
|
||||
t.Error("Expected error when no API key is set")
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
type TavilyProvider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewTavilyProvider() (*TavilyProvider, error) {
|
||||
|
|
@ -18,7 +19,10 @@ func NewTavilyProvider() (*TavilyProvider, error) {
|
|||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("TAVILY_API_KEY environment variable is not set")
|
||||
}
|
||||
return &TavilyProvider{apiKey: apiKey}, nil
|
||||
return &TavilyProvider{
|
||||
apiKey: apiKey,
|
||||
baseURL: "https://api.tavily.com", // default URL
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *TavilyProvider) Extract(urls []string) (*ExtractResponse, error) {
|
||||
|
|
@ -35,7 +39,7 @@ func (t *TavilyProvider) Extract(urls []string) (*ExtractResponse, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.tavily.com/extract", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", t.baseURL+"/extract", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -87,7 +91,7 @@ func (t *TavilyProvider) Search(query string, options map[string]string) (*Searc
|
|||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.tavily.com/search", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", t.baseURL+"/search", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
143
internal/adapters/tavily_test.go
Normal file
143
internal/adapters/tavily_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package adapters
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupTavilyMockServer(t *testing.T, expectedResponse interface{}) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Verify request body contains API key
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
t.Fatalf("Failed to decode request body: %v", err)
|
||||
}
|
||||
if reqBody["api_key"] != "test-key" {
|
||||
t.Errorf("Expected API key in body, got %v", reqBody["api_key"])
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(expectedResponse)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestTavilyProvider_Search(t *testing.T) {
|
||||
mockServer := NewTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Return mock response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"results": [
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"content": "Example content"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Set test environment
|
||||
oldKey := os.Getenv("TAVILY_API_KEY")
|
||||
os.Setenv("TAVILY_API_KEY", "test-key")
|
||||
defer os.Setenv("TAVILY_API_KEY", oldKey)
|
||||
|
||||
provider, err := NewTavilyProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
provider.baseURL = mockServer.URL // Set mock server URL
|
||||
|
||||
results, err := provider.Search("test query", map[string]string{"depth": "advanced"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if len(results.Results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results.Results))
|
||||
}
|
||||
|
||||
expected := SearchResult{
|
||||
URL: "https://example.com",
|
||||
Content: "Example content",
|
||||
}
|
||||
|
||||
if results.Results[0] != expected {
|
||||
t.Errorf("Expected %+v, got %+v", expected, results.Results[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTavilyProvider_Extract(t *testing.T) {
|
||||
mockServer := NewTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Return mock response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"results": [
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"raw_content": "Example content"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Set test environment
|
||||
oldKey := os.Getenv("TAVILY_API_KEY")
|
||||
os.Setenv("TAVILY_API_KEY", "test-key")
|
||||
defer os.Setenv("TAVILY_API_KEY", oldKey)
|
||||
|
||||
provider, err := NewTavilyProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create provider: %v", err)
|
||||
}
|
||||
provider.baseURL = mockServer.URL // Set mock server URL
|
||||
|
||||
results, err := provider.Extract([]string{"https://example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if len(results.Results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results.Results))
|
||||
}
|
||||
|
||||
expected := ExtractResult{
|
||||
URL: "https://example.com",
|
||||
RawContent: "Example content",
|
||||
}
|
||||
|
||||
if results.Results[0] != expected {
|
||||
t.Errorf("Expected %+v, got %+v", expected, results.Results[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTavilyProvider_NoAPIKey(t *testing.T) {
|
||||
// Clear API key
|
||||
oldKey := os.Getenv("TAVILY_API_KEY")
|
||||
os.Unsetenv("TAVILY_API_KEY")
|
||||
defer os.Setenv("TAVILY_API_KEY", oldKey)
|
||||
|
||||
_, err := NewTavilyProvider()
|
||||
if err == nil {
|
||||
t.Error("Expected error when no API key is set")
|
||||
}
|
||||
}
|
||||
30
internal/adapters/testing_helpers.go
Normal file
30
internal/adapters/testing_helpers.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package adapters
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
// TestServer represents a mock server for testing
|
||||
type TestServer struct {
|
||||
*httptest.Server
|
||||
OriginalClient *http.Client
|
||||
}
|
||||
|
||||
// NewTestServer creates a new test server and patches the default HTTP client
|
||||
func NewTestServer(handler http.HandlerFunc) *TestServer {
|
||||
server := httptest.NewServer(handler)
|
||||
originalClient := http.DefaultClient
|
||||
http.DefaultClient = server.Client()
|
||||
|
||||
return &TestServer{
|
||||
Server: server,
|
||||
OriginalClient: originalClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the server and restores the original HTTP client
|
||||
func (s *TestServer) Close() {
|
||||
http.DefaultClient = s.OriginalClient
|
||||
s.Server.Close()
|
||||
}
|
||||
|
|
@ -34,3 +34,9 @@ type ExtractResponse struct {
|
|||
FailedResults []FailedResult `json:"failed_results"`
|
||||
ResponseTime float64 `json:"response_time"`
|
||||
}
|
||||
|
||||
// Add base URL configuration
|
||||
type Config struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"search-cli/internal/adapters"
|
||||
"github.com/regismesquita/search-cli/internal/adapters"
|
||||
)
|
||||
|
||||
func FormatResults(results *adapters.SearchResponse) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package formatter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"search-cli/adapters"
|
||||
"github.com/regismesquita/search-cli/internal/adapters"
|
||||
)
|
||||
|
||||
func TestFormatResults(t *testing.T) {
|
||||
|
|
@ -24,7 +24,7 @@ func TestFormatResults(t *testing.T) {
|
|||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
formatResults(results)
|
||||
FormatResults(results)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
|
@ -62,7 +62,7 @@ func TestFormatExtractResults(t *testing.T) {
|
|||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
formatExtractResults(results)
|
||||
FormatExtractResults(results)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
115
main.go
115
main.go
|
|
@ -1,115 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"search-cli/adapters"
|
||||
)
|
||||
|
||||
func formatResults(results *adapters.SearchResponse) {
|
||||
for _, result := range results.Results {
|
||||
fmt.Printf("\n🔗 %s\n", result.URL)
|
||||
fmt.Printf("📝 %s\n", result.Content)
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
}
|
||||
}
|
||||
|
||||
func formatExtractResults(results *adapters.ExtractResponse) {
|
||||
for _, result := range results.Results {
|
||||
fmt.Printf("\n🔗 %s\n", result.URL)
|
||||
fmt.Printf("📄 %s\n", result.RawContent)
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
}
|
||||
|
||||
if len(results.FailedResults) > 0 {
|
||||
fmt.Println("\n❌ Failed URLs:")
|
||||
for _, failed := range results.FailedResults {
|
||||
fmt.Printf("URL: %s\nError: %s\n", failed.URL, failed.Error)
|
||||
fmt.Println(strings.Repeat("-", 40))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
jsonOutput := flag.Bool("json", false, "Output in JSON format")
|
||||
tavilyMode := flag.Bool("tavily", false, "Use Tavily as provider")
|
||||
serperMode := flag.Bool("serper", false, "Use Serper as provider (default)")
|
||||
extractMode := flag.Bool("extract", false, "Extract content from URLs (Tavily only)")
|
||||
depth := flag.String("depth", "basic", "Search depth (basic or advanced) - only for Tavily")
|
||||
flag.Parse()
|
||||
|
||||
if flag.NArg() < 1 {
|
||||
fmt.Fprintln(os.Stderr, "Error: Query or URLs required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate provider flags
|
||||
if *tavilyMode && *serperMode {
|
||||
fmt.Fprintln(os.Stderr, "Error: Cannot use both -tavily and -serper")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate extract mode
|
||||
if *extractMode && *serperMode {
|
||||
fmt.Fprintln(os.Stderr, "Error: Extract mode is only available with Tavily")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *extractMode {
|
||||
provider, err := adapters.NewTavilyProvider()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
urls := strings.Fields(strings.Join(flag.Args(), " "))
|
||||
results, err := provider.Extract(urls)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *jsonOutput {
|
||||
json.NewEncoder(os.Stdout).Encode(results)
|
||||
} else {
|
||||
formatExtractResults(results)
|
||||
}
|
||||
} else {
|
||||
var searchProvider adapters.SearchProvider
|
||||
var err error
|
||||
|
||||
// Default to Serper unless Tavily is explicitly requested
|
||||
if *tavilyMode {
|
||||
searchProvider, err = adapters.NewTavilyProvider()
|
||||
} else {
|
||||
searchProvider, err = adapters.NewSerperProvider()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
options := map[string]string{}
|
||||
if *tavilyMode {
|
||||
options["depth"] = *depth
|
||||
}
|
||||
|
||||
query := flag.Arg(0)
|
||||
results, err := searchProvider.Search(query, options)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *jsonOutput {
|
||||
json.NewEncoder(os.Stdout).Encode(results)
|
||||
} else {
|
||||
formatResults(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
search.py
56
search.py
|
|
@ -1,56 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")
|
||||
if not TAVILY_API_KEY:
|
||||
print("Error: TAVILY_API_KEY environment variable is not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
API_URL = "https://api.tavily.com/search"
|
||||
|
||||
def search(query: str, search_depth: str = "basic") -> Dict[Any, Any]:
|
||||
headers = {"api-key": TAVILY_API_KEY}
|
||||
params = {
|
||||
"query": query,
|
||||
"search_depth": search_depth,
|
||||
"include_domains": [],
|
||||
"exclude_domains": []
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(API_URL, json=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def format_results(data: Dict[Any, Any]) -> None:
|
||||
for result in data['results']:
|
||||
print(f"\n🔗 {result['url']}")
|
||||
print(f"📝 {result['content']}\n")
|
||||
print("-" * 80)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Tavily Search CLI")
|
||||
parser.add_argument("query", help="Search query")
|
||||
parser.add_argument("--json", action="store_true", help="Output in JSON format")
|
||||
parser.add_argument("--depth", choices=["basic", "advanced"], default="basic",
|
||||
help="Search depth (basic or advanced)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
results = search(args.query, args.depth)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2))
|
||||
else:
|
||||
format_results(results)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue