├── .gitignore ├── .tool-versions ├── LICENSE ├── Makefile ├── dist └── screen.png ├── go.mod ├── main.go ├── main_test.go └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | build/ 3 | go.sum 4 | .DS_Store -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.21.3 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alasdair Monk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME=ogpk 2 | 3 | VERSION=0.1.3 4 | 5 | BUILD_DIR=build 6 | 7 | GOFLAGS := -ldflags="-X main.version=$(VERSION)" 8 | 9 | all: darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 10 | 11 | darwin-amd64: 12 | GOOS=darwin GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-$(VERSION)-darwin-amd64 13 | 14 | darwin-arm64: 15 | GOOS=darwin GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-$(VERSION)-darwin-arm64 16 | 17 | linux-amd64: 18 | GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-$(VERSION)-linux-amd64 19 | 20 | linux-arm64: 21 | GOOS=linux GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-$(VERSION)-linux-arm64 22 | 23 | install: 24 | cp $(BUILD_DIR)/$(BINARY_NAME)-$(VERSION)-`uname -s | tr A-Z a-z`-`uname -m` /usr/local/bin/$(BINARY_NAME) 25 | 26 | clean: 27 | rm -rf $(BUILD_DIR) 28 | 29 | sha: 30 | @sha256sum $(BUILD_DIR)/* | sed 's/build\///g' | sed 's/ / /g' 31 | 32 | formula: 33 | @CPU_ARCHS="amd64 arm64"; \ 34 | echo "class Ogpk < Formula"; \ 35 | echo " desc \"CLI tool to fetch OpenGraph data from a URL\""; \ 36 | echo " homepage \"https://github.com/almonk/$(BINARY_NAME)\""; \ 37 | echo " version \"$(VERSION)\""; \ 38 | for ARCH in $$CPU_ARCHS; do \ 39 | FILENAME=$(BINARY_NAME)-$(VERSION)-darwin-$$ARCH; \ 40 | LOCAL_PATH=$(BUILD_DIR)/$$FILENAME; \ 41 | if [ ! -f $$LOCAL_PATH ]; then \ 42 | echo "Error: Binary not found at $$LOCAL_PATH. Ensure you have built it."; \ 43 | continue; \ 44 | fi; \ 45 | SHA256=$$(shasum -a 256 $$LOCAL_PATH | awk '{print $$1}'); \ 46 | echo " if Hardware::CPU.arm? && \"$$ARCH\" == \"arm64\""; \ 47 | echo " url \"https://github.com/almonk/$(BINARY_NAME)/releases/download/$(VERSION)/$$FILENAME\""; \ 48 | echo " sha256 \"$$SHA256\""; \ 49 | echo " elsif \"$$ARCH\" == \"amd64\""; \ 50 | echo " url \"https://github.com/almonk/$(BINARY_NAME)/releases/download/$(VERSION)/$$FILENAME\""; \ 51 | echo " sha256 \"$$SHA256\""; \ 52 | echo " end"; \ 53 | done; \ 54 | echo " def install"; \ 55 | echo " if Hardware::CPU.arm?"; \ 56 | echo " bin.install \"$(BINARY_NAME)-$(VERSION)-darwin-arm64\" => \"$(BINARY_NAME)\""; \ 57 | echo " else"; \ 58 | echo " bin.install \"$(BINARY_NAME)-$(VERSION)-darwin-amd64\" => \"$(BINARY_NAME)\""; \ 59 | echo " end"; \ 60 | echo " end"; \ 61 | echo "end"; 62 | 63 | 64 | 65 | .PHONY: all darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 install clean 66 | -------------------------------------------------------------------------------- /dist/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/almonk/ogpk/00c46c8f865f02eb09b83ea4d1458d84b93b505b/dist/screen.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module ogpk 2 | 3 | go 1.19 4 | 5 | require golang.org/x/net v0.16.0 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "os" 11 | "os/exec" 12 | "sort" 13 | "strings" 14 | 15 | "golang.org/x/net/html" 16 | ) 17 | 18 | var version = "dev" 19 | 20 | const ( 21 | Green = "\033[32m" 22 | Reset = "\033[0m" 23 | Ghostty = "ghostty" 24 | XtemKitty = "xtem-kitty" 25 | ITermApp = "iTerm.app" 26 | OpenGraphPre = "og:" 27 | ) 28 | 29 | func main() { 30 | pFlag := flag.Bool("p", false, "Show og:image") 31 | jsonFlag := flag.Bool("json", false, "Output as JSON") 32 | 33 | // Parse the flags 34 | flag.Parse() 35 | 36 | // Get the non-flag arguments 37 | args := flag.Args() 38 | 39 | // Check if we have a URL 40 | if len(args) != 1 { 41 | fmt.Println("Usage: ogpk [options] ") 42 | flag.PrintDefaults() 43 | fmt.Printf("\nVersion %s\n", version) 44 | return 45 | } 46 | 47 | url := parseURL(args[0]) 48 | ogData, err := getOpenGraphData(url) 49 | if err != nil { 50 | log.Fatalf("Error: %v", err) 51 | } 52 | 53 | if *jsonFlag { 54 | displayAsJSON(ogData) 55 | } else { 56 | displayInTerminal(ogData) 57 | } 58 | 59 | if imageURL, ok := ogData["og:image"]; ok && *pFlag { 60 | displayImage(imageURL) 61 | } 62 | } 63 | 64 | // parseURL parses a URL and adds the protocol prefix if it's missing. 65 | func parseURL(input string) string { 66 | if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { 67 | return "http://" + input 68 | } 69 | return input 70 | } 71 | 72 | // getOpenGraphData fetches OpenGraph data from a given URL. 73 | func getOpenGraphData(url string) (map[string]string, error) { 74 | doc, err := fetchHTML(url) 75 | if err != nil { 76 | return nil, fmt.Errorf("fetching URL: %w", err) 77 | } 78 | return extractOpenGraphData(doc), nil 79 | } 80 | 81 | // displayAsJSON displays OpenGraph data as JSON. 82 | func displayAsJSON(ogData map[string]string) { 83 | jsonData, err := json.MarshalIndent(ogData, "", " ") 84 | if err != nil { 85 | log.Fatalf("Error converting data to JSON: %v", err) 86 | } 87 | fmt.Println(string(jsonData)) 88 | } 89 | 90 | // displayInTerminal displays OpenGraph data in the terminal. 91 | func displayInTerminal(ogData map[string]string) { 92 | var keys []string 93 | for k := range ogData { 94 | keys = append(keys, k) 95 | } 96 | sort.Strings(keys) 97 | 98 | for _, k := range keys { 99 | fmt.Printf("%s%s%s: %s\n", Green, k, Reset, ogData[k]) 100 | } 101 | } 102 | 103 | // displayImage downloads and displays an image from a given URL. 104 | func displayImage(imageURL string) { 105 | imgData, err := downloadImage(imageURL) 106 | if err != nil { 107 | log.Fatalf("Error downloading image: %v", err) 108 | } 109 | 110 | filePath, err := saveImage(imgData) 111 | if err != nil { 112 | log.Fatalf("Error saving image: %v", err) 113 | } 114 | 115 | _, err = exec.LookPath("timg") 116 | if err == nil { 117 | if err := displayImageWithTimg(filePath); err != nil { 118 | log.Fatalf("Error displaying image with timg: %v", err) 119 | } 120 | } else { 121 | fmt.Println("timg not found, image saved to:", filePath) 122 | } 123 | } 124 | 125 | // fetchHTML fetches and parses HTML from a given URL. 126 | func fetchHTML(url string) (*html.Node, error) { 127 | resp, err := http.Get(url) 128 | if err != nil { 129 | return nil, err 130 | } 131 | defer resp.Body.Close() 132 | 133 | return html.Parse(resp.Body) 134 | } 135 | 136 | // extractOpenGraphData extracts OpenGraph data from parsed HTML and returns it as a map. 137 | func extractOpenGraphData(doc *html.Node) map[string]string { 138 | data := make(map[string]string) 139 | 140 | var f func(*html.Node) 141 | f = func(n *html.Node) { 142 | if n.Type == html.ElementNode && n.Data == "meta" { 143 | var property, content string 144 | for _, a := range n.Attr { 145 | if a.Key == "property" && a.Val[:3] == "og:" { 146 | property = a.Val 147 | } 148 | if a.Key == "content" { 149 | content = a.Val 150 | } 151 | } 152 | if property != "" && content != "" { 153 | data[property] = content 154 | } 155 | } 156 | for c := n.FirstChild; c != nil; c = c.NextSibling { 157 | f(c) 158 | } 159 | } 160 | f(doc) 161 | 162 | return data 163 | } 164 | 165 | func displayImageWithTimg(path string) error { 166 | fmt.Println() 167 | cmdArgs := []string{path} 168 | 169 | switch terminalName() { 170 | case Ghostty: 171 | cmdArgs = append(cmdArgs, "-pk") 172 | case XtemKitty: 173 | cmdArgs = append(cmdArgs, "-pk") 174 | case ITermApp: 175 | cmdArgs = append(cmdArgs, "-pi") 176 | } 177 | 178 | // Set the height to 12 grid units 179 | cmdArgs = append(cmdArgs, "-gx12") 180 | 181 | cmd := exec.Command("timg", cmdArgs...) 182 | cmd.Stdout = os.Stdout 183 | cmd.Stderr = os.Stderr 184 | return cmd.Run() 185 | } 186 | 187 | // terminalName returns the name of the terminal running the application. 188 | func terminalName() string { 189 | term := os.Getenv("TERM_PROGRAM") 190 | if term == "" { 191 | // Fetch the name of the terminal from the $TERM environment variable 192 | term = os.Getenv("TERM") 193 | 194 | if term == "" { 195 | return "unknown" 196 | } 197 | } 198 | return term 199 | } 200 | 201 | // downloadImage downloads an image from a given URL. 202 | func downloadImage(url string) ([]byte, error) { 203 | resp, err := http.Get(url) 204 | if err != nil { 205 | return nil, err 206 | } 207 | defer resp.Body.Close() 208 | 209 | return ioutil.ReadAll(resp.Body) 210 | } 211 | 212 | // saveImage saves image data to a temporary file and returns the file path. 213 | func saveImage(data []byte) (string, error) { 214 | tmpFile, err := ioutil.TempFile("", "ogpk-*.jpg") 215 | if err != nil { 216 | return "", err 217 | } 218 | 219 | if _, err := tmpFile.Write(data); err != nil { 220 | tmpFile.Close() 221 | return "", err 222 | } 223 | tmpFile.Close() 224 | 225 | return tmpFile.Name(), nil 226 | } 227 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func TestIntegration_OpenGraphExtraction(t *testing.T) { 10 | // Mock an HTTP server 11 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | w.WriteHeader(http.StatusOK) 13 | w.Header().Set("Content-Type", "text/html") 14 | w.Write([]byte(` 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Hello, World! 23 | 24 | 25 | `)) 26 | })) 27 | defer ts.Close() 28 | 29 | ogData, err := getOpenGraphData(ts.URL) 30 | if err != nil { 31 | t.Fatalf("Unexpected error: %v", err) 32 | } 33 | 34 | expectedTitle := "Test Title" 35 | if ogData["og:title"] != expectedTitle { 36 | t.Errorf("Expected og:title to be %s, but got %s", expectedTitle, ogData["og:title"]) 37 | } 38 | 39 | expectedDescription := "Test Description" 40 | if ogData["og:description"] != expectedDescription { 41 | t.Errorf("Expected og:description to be %s, but got %s", expectedDescription, ogData["og:description"]) 42 | } 43 | } 44 | 45 | func TestIntegration_OpenGraphExtraction_RealURL(t *testing.T) { 46 | url := "https://replay.software" 47 | 48 | ogData, err := getOpenGraphData(url) 49 | if err != nil { 50 | t.Fatalf("Unexpected error: %v", err) 51 | } 52 | 53 | expectedData := map[string]string{ 54 | "og:description": "A tiny studio making delightful apps for your Mac.", 55 | "og:image": "https://replay.software/replay/opengraph-23.png", 56 | "og:image:height": "315", 57 | "og:image:width": "600", 58 | "og:locale": "en_GB", 59 | "og:site_name": "Replay Software", 60 | "og:title": "Replay Software", 61 | "og:type": "website", 62 | "og:url": "https://replay.software/", 63 | } 64 | 65 | // Check if the OpenGraph data matches the expecte values 66 | for key, expectedValue := range expectedData { 67 | if ogData[key] != expectedValue { 68 | t.Errorf("For key %s, expected value to be %s, but got %s", key, expectedValue, ogData[key]) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ogpk (opengraph peek) 2 | 3 | `ogpk` is a simple CLI tool written in Go that fetches OpenGraph data from a given URL. If the optional dependency `timg` is installed, `ogpk` can also display the `og:image` directly in the terminal. 4 | 5 | 6 | 7 | ### Installation 8 | 9 | On macOS: 10 | 11 | ```bash 12 | brew tap almonk/ogpk 13 | brew install ogpk 14 | ``` 15 | 16 | On linux: 17 | 18 | * Go to the [releases page](https://github.com/almonk/ogpk/releases) and download the latest release for your platform. 19 | * Extract the archive and move the executable to a directory in your `PATH` (e.g. `/usr/local/bin`) 20 | * Make the executable executable, e.g.: 21 | 22 | ```bash 23 | chmod +x /usr/local/bin/ogpk 24 | ``` 25 | 26 | ### Usage 27 | 28 | To fetch OpenGraph data from a website: 29 | ```bash 30 | ogpk [URL] 31 | ``` 32 | 33 | For example: 34 | ```bash 35 | ogpk https://example.com 36 | ``` 37 | 38 | To display the `og:image` in the terminal (requires `timg`): 39 | ```bash 40 | ogpk [URL] --p 41 | ``` 42 | 43 | Output data as JSON: 44 | ```bash 45 | ogpk [URL] --json 46 | ``` 47 | 48 | ### Building from source 49 | 50 | Clone this repository: 51 | ```bash 52 | git clone https://github.com/almonk/ogpk.git 53 | ``` 54 | Navigate to the cloned directory: 55 | ```bash 56 | cd ogpk 57 | ``` 58 | 59 | Build the tool: 60 | ```bash 61 | go build -o ogpk 62 | ``` 63 | 64 | This will produce an executable named `ogpk` in the current directory. 65 | 66 | 67 | ### Optional Dependency on `timg` 68 | 69 | ogpk has an optional dependency on `timg`, a terminal image viewer. If `timg` is installed and available in the `PATH`, ogpk can display the `og:image` directly in the terminal when the `--p` flag is used. 70 | 71 | To install `timg`, refer to its [official documentation](https://github.com/hzeller/timg). 72 | --------------------------------------------------------------------------------