// main.go
package main
import (
"context"
"encoding/base64"
"encoding/json"
"html"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// ------------------- Config & globals -------------------
var httpClient = &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 8 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 10, // small pool to reduce memory
MaxIdleConnsPerHost: 6,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 8 * time.Second,
},
}
const pinterestSearchURL = "https://www.pinterest.com/resource/BaseSearchResource/get/"
// ------------------- Static CSS & HTML header/footer -------------------
const cssContent = `
:root{--bg:#0b0f17;--muted:#94a3b8;--text:#e6e6ff;--accent:#7c3aed;--card-shadow:rgba(0,0,0,0.6)}
*{box-sizing:border-box}body{margin:0;padding:20px;background:linear-gradient(180deg,#071020 0%,#0b0f17 100%);color:var(--text);font-family:ui-monospace,Menlo,Monaco,monospace}
.header{display:flex;gap:12px;align-items:center;margin-bottom:18px}.brand{font-size:20px;font-weight:700;color:var(--accent);text-decoration:none}.search-box{margin-left:auto;display:flex;gap:8px;align-items:center}input[type="text"]{background:transparent;border:1px solid rgba(255,255,255,0.06);padding:8px 12px;color:var(--text);min-width:240px;border-radius:8px;outline:none}button[type="submit"]{background:linear-gradient(90deg,var(--accent),#5b21b6);color:white;border:none;padding:8px 12px;border-radius:8px;cursor:pointer}
.img-container{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;align-items:start;margin-top:18px}
.card{position:relative;border-radius:10px;overflow:hidden;background:linear-gradient(180deg,rgba(255,255,255,0.01),rgba(255,255,255,0.02));box-shadow:0 6px 18px rgba(3,7,18,0.6);border:1px solid rgba(124,58,237,0.06)}
.card img{display:block;width:100%;height:auto;object-fit:cover;background:#08101a}
.magnifier{position:absolute;top:8px;right:8px;background:rgba(0,0,0,0.45);border:1px solid rgba(255,255,255,0.06);color:var(--text);padding:6px;border-radius:999px;font-size:14px;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;text-decoration:none;transition:transform .12s ease,background .12s ease}
.magnifier:hover{transform:translateY(-2px);background:linear-gradient(180deg,rgba(124,58,237,0.14),rgba(124,58,237,0.08));color:white;cursor:pointer}
.pagination{text-align:center;margin:26px 0}.pagination a{color:var(--accent);text-decoration:none;padding:8px 12px;border-radius:8px;border:1px solid rgba(124,58,237,0.12);background:rgba(124,58,237,0.02)}
.footer-note{color:var(--muted);font-size:12px;margin-top:22px}
`
const indexHTML = `
Pinata - Search
Search images from Pinterest — click an image to open, or use the magnifier to search Tineye.
`
// header for streamed results; we write query directly escaped
func resultsHeader(query string) string {
return `
` + html.EscapeString(query) + ` - Pinata
Results for "` + html.EscapeString(query) + `"
`
}
const resultsFooterPrefix = `
` // close img-container; will append pagination and footer after streaming
const footerHTML = `
{{PAGINATION}}
`
// ------------------- Handlers -------------------
func styleHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = w.Write([]byte(cssContent))
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(indexHTML))
}
// searchHandler streams Pinterest response and writes each card as it's decoded.
// It captures csrftoken cookie and bookmark; bookmark is used to render the "Next" link at the end.
func searchHandler(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if len(q) < 1 || len(q) > 64 {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
bookmark := r.URL.Query().Get("bookmark")
csrftoken := r.URL.Query().Get("csrftoken")
// Build data param JSON (same structure as PHP)
dataObj := map[string]any{"options": map[string]any{"query": q}}
if bookmark != "" {
dataObj["options"].(map[string]any)["bookmarks"] = []string{bookmark}
}
jb, err := json.Marshal(dataObj)
if err != nil {
http.Error(w, "internal", http.StatusInternalServerError)
return
}
dataParam := url.QueryEscape(string(jb))
var req *http.Request
if bookmark == "" {
u := pinterestSearchURL + "?data=" + dataParam
req, err = http.NewRequestWithContext(r.Context(), "GET", u, nil)
} else {
body := "data=" + dataParam
req, err = http.NewRequestWithContext(r.Context(), "POST", pinterestSearchURL, strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if err != nil {
http.Error(w, "failed to build request", http.StatusInternalServerError)
return
}
req.Header.Set("x-pinterest-pws-handler", "www/search/[scope].js")
if csrftoken != "" {
req.Header.Set("x-csrftoken", csrftoken)
req.Header.Set("Cookie", "csrftoken="+csrftoken)
}
resp, err := httpClient.Do(req)
if err != nil {
http.Error(w, "failed to fetch", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// capture csrftoken if set by server (we return it via pagination link)
var newCsrf string
for _, c := range resp.Cookies() {
if strings.EqualFold(c.Name, "csrftoken") {
newCsrf = c.Value
break
}
}
// Start streaming HTML
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(resultsHeader(q)))
dec := json.NewDecoder(resp.Body)
// We'll find "results" array and iterate it; also capture "bookmark" value
var nextBookmark string
// Use token streaming to find the "results" key and the "bookmark" key.
for {
tk, err := dec.Token()
if err != nil {
if err == io.EOF {
break
}
// parsing error: finish gracefully
log.Printf("json token error: %v", err)
break
}
// We're looking for string keys
key, ok := tk.(string)
if !ok {
continue
}
switch key {
case "results":
// next token should be '['
t, err := dec.Token()
if err != nil {
log.Printf("unexpected json after results: %v", err)
continue
}
if delim, ok := t.(json.Delim); !ok || delim != '[' {
// not an array, continue
continue
}
// iterate results array: decode each result object into a small struct
var rObj struct {
Images struct {
Orig struct {
URL string `json:"url"`
} `json:"orig"`
} `json:"images"`
}
for dec.More() {
// decode one result; this allocates a very small struct each time
if err := dec.Decode(&rObj); err != nil {
log.Printf("error decoding result item: %v", err)
break
}
u := strings.TrimSpace(rObj.Images.Orig.URL)
if u == "" {
continue
}
// write card HTML for this image (escape values)
esc := url.QueryEscape(u)
b64 := base64.StdEncoding.EncodeToString([]byte(u))
// card html
_, _ = io.WriteString(w, ``)
_, _ = io.WriteString(w, `
`)
_, _ = io.WriteString(w, ` `)
_, _ = io.WriteString(w, ` `)
_, _ = io.WriteString(w, `
🔍 `)
_, _ = io.WriteString(w, `
`)
// flush to client if possible (net/http does buffering internally)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// consume closing ']' token
_, _ = dec.Token()
case "bookmark":
// next token should be the bookmark string (or null)
t, err := dec.Token()
if err == nil {
if s, ok := t.(string); ok {
nextBookmark = s
}
}
default:
// ignore other keys
continue
}
}
// Close the img-container
_, _ = io.WriteString(w, resultsFooterPrefix)
// Render pagination if we have a bookmark
if nextBookmark != "" {
// Build next link with csrftoken if present
qenc := url.QueryEscape(q)
benc := url.QueryEscape(nextBookmark)
cenc := ""
if newCsrf != "" {
cenc = "&csrftoken=" + url.QueryEscape(newCsrf)
} else if csrftoken != "" {
cenc = "&csrftoken=" + url.QueryEscape(csrftoken)
}
next := "/search?q=" + qenc + "&bookmark=" + benc + cenc
_, _ = io.WriteString(w, ``)
}
// Footer
_, _ = io.WriteString(w, ``)
_, _ = io.WriteString(w, `