nice clean recipes pear.dunkirk.sh
recipes
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: init

+1882
+43
.air.toml
··· 1 + root = "." 2 + testdata_dir = "testdata" 3 + tmp_dir = "tmp" 4 + 5 + [build] 6 + args_bin = [] 7 + bin = "./tmp/main" 8 + cmd = "go build -ldflags \"-X main.gitHash=$(git rev-parse --short HEAD)\" -o ./tmp/main ." 9 + delay = 1000 10 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 + exclude_regex = ["_test.go"] 12 + exclude_unchanged = true 13 + follow_symlink = true 14 + full_bin = "" 15 + include_dir = [] 16 + include_ext = ["go", "html", "js", "css"] 17 + include_file = [] 18 + kill_delay = "0s" 19 + log = "build-errors.log" 20 + poll = false 21 + poll_interval = 0 22 + rerun = false 23 + rerun_delay = 500 24 + send_interrupt = false 25 + stop_on_error = false 26 + 27 + [color] 28 + app = "" 29 + build = "yellow" 30 + main = "magenta" 31 + runner = "green" 32 + watcher = "cyan" 33 + 34 + [log] 35 + main_only = false 36 + time = false 37 + 38 + [misc] 39 + clean_on_exit = false 40 + 41 + [screen] 42 + clear_on_rebuild = false 43 + keep_scroll = true
+9
.gitignore
··· 1 + tmp/ 2 + .env 3 + *.exe 4 + *.test 5 + *.out 6 + pare 7 + .DS_Store 8 + build-errors.log 9 + pare.db*
+9
go.mod
··· 1 + module tangled.org/dunkirk.sh/pare 2 + 3 + go 1.26.2 4 + 5 + require ( 6 + github.com/go-chi/chi/v5 v5.2.5 7 + github.com/mattn/go-sqlite3 v1.14.42 8 + golang.org/x/net v0.53.0 9 + )
+6
go.sum
··· 1 + github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= 2 + github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 3 + github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= 4 + github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 5 + golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= 6 + golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+89
internal/cache/cache.go
··· 1 + package cache 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "time" 7 + 8 + "tangled.org/dunkirk.sh/pare/internal/models" 9 + 10 + _ "github.com/mattn/go-sqlite3" 11 + ) 12 + 13 + type Cache struct { 14 + db *sql.DB 15 + } 16 + 17 + func New(dbPath string) (*Cache, error) { 18 + db, err := sql.Open("sqlite3", dbPath) 19 + if err != nil { 20 + return nil, err 21 + } 22 + 23 + if err := migrate(db); err != nil { 24 + db.Close() 25 + return nil, err 26 + } 27 + 28 + return &Cache{db: db}, nil 29 + } 30 + 31 + func migrate(db *sql.DB) error { 32 + _, err := db.Exec(` 33 + CREATE TABLE IF NOT EXISTS recipes ( 34 + url TEXT PRIMARY KEY, 35 + recipe JSON NOT NULL, 36 + extraction_method TEXT NOT NULL, 37 + fetched_at DATETIME NOT NULL 38 + ) 39 + `) 40 + return err 41 + } 42 + 43 + func (c *Cache) Get(url string) (*models.Recipe, error) { 44 + var recipeJSON []byte 45 + var method string 46 + var fetchedAt time.Time 47 + 48 + err := c.db.QueryRow( 49 + "SELECT recipe, extraction_method, fetched_at FROM recipes WHERE url = ?", 50 + url, 51 + ).Scan(&recipeJSON, &method, &fetchedAt) 52 + 53 + if err == sql.ErrNoRows { 54 + return nil, nil 55 + } 56 + if err != nil { 57 + return nil, err 58 + } 59 + 60 + var recipe models.Recipe 61 + if err := json.Unmarshal(recipeJSON, &recipe); err != nil { 62 + return nil, err 63 + } 64 + 65 + if time.Since(fetchedAt) > 24*time.Hour { 66 + return nil, nil 67 + } 68 + 69 + return &recipe, nil 70 + } 71 + 72 + func (c *Cache) Set(url string, recipe *models.Recipe) error { 73 + recipeJSON, err := json.Marshal(recipe) 74 + if err != nil { 75 + return err 76 + } 77 + 78 + _, err = c.db.Exec( 79 + `INSERT INTO recipes (url, recipe, extraction_method, fetched_at) 80 + VALUES (?, ?, ?, ?) 81 + ON CONFLICT(url) DO UPDATE SET recipe=excluded.recipe, extraction_method=excluded.extraction_method, fetched_at=excluded.fetched_at`, 82 + url, recipeJSON, recipe.ExtractionMethod, time.Now(), 83 + ) 84 + return err 85 + } 86 + 87 + func (c *Cache) Close() error { 88 + return c.db.Close() 89 + }
+278
internal/cooklang/export.go
··· 1 + package cooklang 2 + 3 + import ( 4 + "fmt" 5 + "regexp" 6 + "sort" 7 + "strings" 8 + 9 + "tangled.org/dunkirk.sh/pare/internal/models" 10 + ) 11 + 12 + func Export(recipe *models.Recipe) string { 13 + var sb strings.Builder 14 + 15 + sb.WriteString("---\n") 16 + sb.WriteString(fmt.Sprintf("source: %s\n", recipe.SourceURL)) 17 + if recipe.Yield != "" { 18 + sb.WriteString(fmt.Sprintf("servings: %s\n", recipe.Yield)) 19 + } 20 + if recipe.PrepTime != "" { 21 + sb.WriteString(fmt.Sprintf("prepTime: %s\n", formatDuration(recipe.PrepTime))) 22 + } 23 + if recipe.CookTime != "" { 24 + sb.WriteString(fmt.Sprintf("cookTime: %s\n", formatDuration(recipe.CookTime))) 25 + } 26 + sb.WriteString("---\n\n") 27 + 28 + ingredientIndex := buildIngredientIndex(recipe.Ingredients) 29 + matched := make(map[string]bool) 30 + 31 + if len(recipe.Instructions) > 0 { 32 + for i, step := range recipe.Instructions { 33 + annotated, stepMatched := annotateStep(step.Text, ingredientIndex) 34 + for k := range stepMatched { 35 + matched[k] = true 36 + } 37 + sb.WriteString(annotated) 38 + if i < len(recipe.Instructions)-1 { 39 + sb.WriteString("\n\n") 40 + } 41 + } 42 + } 43 + 44 + var unmatched []models.Ingredient 45 + for _, ing := range recipe.Ingredients { 46 + key := ingredientKey(ing) 47 + if !matched[key] { 48 + unmatched = append(unmatched, ing) 49 + } 50 + } 51 + 52 + if len(unmatched) > 0 { 53 + sb.WriteString("\n\n== Ingredients ==\n") 54 + for _, ing := range unmatched { 55 + ref := ingredientCookRef(ing) 56 + sb.WriteString(fmt.Sprintf("- %s\n", ref)) 57 + } 58 + } 59 + 60 + return sb.String() 61 + } 62 + 63 + var timeRe = regexp.MustCompile(`(?i)\b(\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?|h)\b`) 64 + var cookwareRe = regexp.MustCompile(`(?i)\b(a|the)\s+(large|small|medium|big|heavy|deep|shallow|cast-iron|nonstick)\s+([\w-]+(?:\s+[\w-]+)?)\b`) 65 + var bareCookwareRe = regexp.MustCompile(`(?i)\b(saucepan|skillet|frying pan|baking sheet|baking dish|roasting pan|stockpot|dutch oven|slow cooker|instant pot|air fryer|pressure cooker|food processor|stand mixer|hand mixer|blender|grill|oven|stove|microwave|pot|pan|wok|bowl|whisk|spatula|tongs|colander|strainer|sieve|rolling pin|cutting board|knife|peeler|grater|mandoline|thermometer)\b`) 66 + 67 + func annotateStep(text string, ingredients map[string]models.Ingredient) (string, map[string]bool) { 68 + annotated := text 69 + matched := make(map[string]bool) 70 + 71 + type match struct { 72 + start int 73 + end int 74 + repl string 75 + key string 76 + } 77 + 78 + var matches []match 79 + 80 + for key, ing := range ingredients { 81 + searchNames := searchNamesFor(key) 82 + for _, searchName := range searchNames { 83 + lowerText := strings.ToLower(annotated) 84 + lowerSearch := strings.ToLower(searchName) 85 + 86 + idx := 0 87 + for { 88 + pos := strings.Index(lowerText[idx:], lowerSearch) 89 + if pos < 0 { 90 + break 91 + } 92 + pos += idx 93 + 94 + if isWordBoundary(annotated, pos, pos+len(searchName)) { 95 + cookRef := ingredientCookRefInStep(ing, key) 96 + matches = append(matches, match{start: pos, end: pos + len(searchName), repl: cookRef, key: key}) 97 + matched[key] = true 98 + break 99 + } 100 + idx = pos + 1 101 + } 102 + if matched[key] { 103 + break 104 + } 105 + } 106 + } 107 + 108 + sort.Slice(matches, func(i, j int) bool { 109 + return matches[i].start > matches[j].start 110 + }) 111 + 112 + for _, m := range matches { 113 + annotated = annotated[:m.start] + m.repl + annotated[m.end:] 114 + } 115 + 116 + annotated = timeRe.ReplaceAllStringFunc(annotated, func(matchStr string) string { 117 + parts := timeRe.FindStringSubmatch(matchStr) 118 + if len(parts) >= 3 { 119 + qty := parts[1] 120 + unit := parts[2] 121 + unit = normalizeTimeUnit(unit) 122 + return fmt.Sprintf("~{%s%%%s}", qty, unit) 123 + } 124 + return matchStr 125 + }) 126 + 127 + annotated = cookwareRe.ReplaceAllStringFunc(annotated, func(matchStr string) string { 128 + parts := cookwareRe.FindStringSubmatch(matchStr) 129 + if len(parts) >= 4 { 130 + ware := parts[2] + " " + parts[3] 131 + return fmt.Sprintf("#%s{}", ware) 132 + } 133 + return matchStr 134 + }) 135 + 136 + annotated = bareCookwareRe.ReplaceAllStringFunc(annotated, func(matchStr string) string { 137 + ware := strings.ToLower(matchStr) 138 + if strings.Contains(ware, " ") { 139 + return fmt.Sprintf("#%s{}", ware) 140 + } 141 + return fmt.Sprintf("#%s", ware) 142 + }) 143 + 144 + return annotated, matched 145 + } 146 + 147 + func normalizeTimeUnit(unit string) string { 148 + unit = strings.ToLower(unit) 149 + switch unit { 150 + case "sec", "secs": 151 + return "second" 152 + case "min", "mins": 153 + return "minute" 154 + case "hr", "hrs", "h": 155 + return "hour" 156 + default: 157 + return unit 158 + } 159 + } 160 + 161 + func isWordBoundary(s string, start, end int) bool { 162 + if start > 0 { 163 + c := s[start-1] 164 + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { 165 + return false 166 + } 167 + } 168 + if end < len(s) { 169 + c := s[end] 170 + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { 171 + return false 172 + } 173 + } 174 + return true 175 + } 176 + 177 + func searchNamesFor(key string) []string { 178 + names := []string{key} 179 + 180 + stripPrefixes := []string{"ground ", "dried ", "fresh ", "frozen ", "canned ", "cooked ", "raw ", "minced ", "chopped ", "crushed ", "grated ", "sliced ", "diced ", "powdered ", "granulated "} 181 + lower := strings.ToLower(key) 182 + for _, prefix := range stripPrefixes { 183 + if strings.HasPrefix(lower, prefix) { 184 + short := key[len(prefix):] 185 + names = append(names, short) 186 + } 187 + } 188 + 189 + return names 190 + } 191 + 192 + func ingredientKey(ing models.Ingredient) string { 193 + if ing.Name != "" { 194 + return ing.Name 195 + } 196 + return extractIngredientName(ing.RawText) 197 + } 198 + 199 + func ingredientCookRef(ing models.Ingredient) string { 200 + name := ingredientKey(ing) 201 + needsBraces := strings.Contains(name, " ") 202 + 203 + if ing.Quantity != "" && ing.Unit != "" { 204 + if needsBraces { 205 + return fmt.Sprintf("@%s{%s%%%s}", name, ing.Quantity, ing.Unit) 206 + } 207 + return fmt.Sprintf("@%s{%s%%%s}", name, ing.Quantity, ing.Unit) 208 + } 209 + if ing.Quantity != "" { 210 + if needsBraces { 211 + return fmt.Sprintf("@%s{%s}", name, ing.Quantity) 212 + } 213 + return fmt.Sprintf("@%s{%s}", name, ing.Quantity) 214 + } 215 + if needsBraces { 216 + return fmt.Sprintf("@%s{}", name) 217 + } 218 + return fmt.Sprintf("@%s", name) 219 + } 220 + 221 + func ingredientCookRefInStep(ing models.Ingredient, key string) string { 222 + needsBraces := strings.Contains(key, " ") 223 + 224 + if ing.Quantity != "" && ing.Unit != "" { 225 + if needsBraces { 226 + return fmt.Sprintf("@%s{%s%%%s}", key, ing.Quantity, ing.Unit) 227 + } 228 + return fmt.Sprintf("@%s{%s%%%s}", key, ing.Quantity, ing.Unit) 229 + } 230 + if ing.Quantity != "" { 231 + if needsBraces { 232 + return fmt.Sprintf("@%s{%s}", key, ing.Quantity) 233 + } 234 + return fmt.Sprintf("@%s{%s}", key, ing.Quantity) 235 + } 236 + if needsBraces { 237 + return fmt.Sprintf("@%s{}", key) 238 + } 239 + return fmt.Sprintf("@%s", key) 240 + } 241 + 242 + func buildIngredientIndex(ingredients []models.Ingredient) map[string]models.Ingredient { 243 + index := make(map[string]models.Ingredient) 244 + for _, ing := range ingredients { 245 + key := ingredientKey(ing) 246 + if key != "" { 247 + index[key] = ing 248 + } 249 + } 250 + return index 251 + } 252 + 253 + var ingredientPrefixRe = regexp.MustCompile(`^(?i)(\d+\s*\S*\s+)?(?:of\s+)?(.+)$`) 254 + 255 + func extractIngredientName(raw string) string { 256 + raw = strings.TrimSpace(raw) 257 + parts := ingredientPrefixRe.FindStringSubmatch(raw) 258 + if len(parts) >= 3 && parts[2] != "" { 259 + name := parts[2] 260 + name = strings.TrimPrefix(name, "of ") 261 + name = strings.TrimSpace(name) 262 + name = strings.TrimSuffix(name, ",") 263 + name = strings.TrimSpace(name) 264 + return name 265 + } 266 + return raw 267 + } 268 + 269 + func formatDuration(iso string) string { 270 + if strings.HasPrefix(iso, "PT") { 271 + d := strings.TrimPrefix(iso, "PT") 272 + d = strings.Replace(d, "H", " hours", 1) 273 + d = strings.Replace(d, "M", " minutes", 1) 274 + d = strings.Replace(d, "S", " seconds", 1) 275 + return strings.TrimSpace(d) 276 + } 277 + return iso 278 + }
+168
internal/extract/ai/ai.go
··· 1 + package ai 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + 11 + "tangled.org/dunkirk.sh/pare/internal/models" 12 + ) 13 + 14 + type Extractor struct { 15 + apiKey string 16 + model string 17 + baseURL string 18 + } 19 + 20 + func NewExtractor(apiKey, model, baseURL string) *Extractor { 21 + if model == "" { 22 + model = "claude-sonnet-4-20250514" 23 + } 24 + if baseURL == "" { 25 + baseURL = "https://api.anthropic.com/v1/messages" 26 + } 27 + return &Extractor{ 28 + apiKey: apiKey, 29 + model: model, 30 + baseURL: baseURL, 31 + } 32 + } 33 + 34 + func (e *Extractor) Extract(pageText, sourceURL string) (*models.Recipe, error) { 35 + if e.apiKey == "" { 36 + return nil, fmt.Errorf("AI extraction not configured: no API key set") 37 + } 38 + 39 + prompt := buildPrompt(pageText, sourceURL) 40 + 41 + reqBody := map[string]interface{}{ 42 + "model": e.model, 43 + "max_tokens": 4096, 44 + "messages": []map[string]string{ 45 + {"role": "user", "content": prompt}, 46 + }, 47 + } 48 + 49 + body, err := json.Marshal(reqBody) 50 + if err != nil { 51 + return nil, fmt.Errorf("marshaling request: %w", err) 52 + } 53 + 54 + req, err := http.NewRequest("POST", e.baseURL, bytes.NewReader(body)) 55 + if err != nil { 56 + return nil, fmt.Errorf("creating request: %w", err) 57 + } 58 + 59 + req.Header.Set("Content-Type", "application/json") 60 + req.Header.Set("x-api-key", e.apiKey) 61 + req.Header.Set("anthropic-version", "2023-06-01") 62 + 63 + resp, err := http.DefaultClient.Do(req) 64 + if err != nil { 65 + return nil, fmt.Errorf("making request: %w", err) 66 + } 67 + defer resp.Body.Close() 68 + 69 + if resp.StatusCode != http.StatusOK { 70 + respBody, _ := io.ReadAll(resp.Body) 71 + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) 72 + } 73 + 74 + var result struct { 75 + Content []struct { 76 + Text string `json:"text"` 77 + } `json:"content"` 78 + } 79 + 80 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 81 + return nil, fmt.Errorf("decoding response: %w", err) 82 + } 83 + 84 + if len(result.Content) == 0 { 85 + return nil, fmt.Errorf("empty response from AI") 86 + } 87 + 88 + return parseAIResponse(result.Content[0].Text, sourceURL) 89 + } 90 + 91 + func buildPrompt(pageText, sourceURL string) string { 92 + return fmt.Sprintf(`Extract the recipe from the following page text. Return ONLY valid JSON matching this schema, no markdown fences: 93 + 94 + { 95 + "name": "string", 96 + "description": "string", 97 + "image_url": "string", 98 + "prep_time": "string (ISO 8601 duration like PT20M)", 99 + "cook_time": "string (ISO 8601 duration like PT40M)", 100 + "total_time": "string (ISO 8601 duration)", 101 + "yield": "string", 102 + "servings": 4, 103 + "ingredients": ["2 cups flour", "1 tsp salt"], 104 + "instructions": ["Step one text", "Step two text"] 105 + } 106 + 107 + Page text: 108 + %s`, truncate(pageText, 8000)) 109 + } 110 + 111 + func parseAIResponse(text, sourceURL string) (*models.Recipe, error) { 112 + cleaned := strings.TrimSpace(text) 113 + cleaned = strings.TrimPrefix(cleaned, "```json") 114 + cleaned = strings.TrimPrefix(cleaned, "```") 115 + cleaned = strings.TrimSuffix(cleaned, "```") 116 + cleaned = strings.TrimSpace(cleaned) 117 + 118 + var raw struct { 119 + Name string `json:"name"` 120 + Description string `json:"description"` 121 + ImageURL string `json:"image_url"` 122 + PrepTime string `json:"prep_time"` 123 + CookTime string `json:"cook_time"` 124 + TotalTime string `json:"total_time"` 125 + Yield string `json:"yield"` 126 + Servings int `json:"servings"` 127 + Ingredients []string `json:"ingredients"` 128 + Instructions []string `json:"instructions"` 129 + } 130 + 131 + if err := json.Unmarshal([]byte(cleaned), &raw); err != nil { 132 + return nil, fmt.Errorf("parsing AI JSON response: %w", err) 133 + } 134 + 135 + recipe := &models.Recipe{ 136 + Name: raw.Name, 137 + Description: raw.Description, 138 + ImageURL: raw.ImageURL, 139 + SourceURL: sourceURL, 140 + PrepTime: raw.PrepTime, 141 + CookTime: raw.CookTime, 142 + TotalTime: raw.TotalTime, 143 + Yield: raw.Yield, 144 + Servings: raw.Servings, 145 + ExtractionMethod: "ai", 146 + } 147 + 148 + for _, ing := range raw.Ingredients { 149 + recipe.Ingredients = append(recipe.Ingredients, models.Ingredient{RawText: ing}) 150 + } 151 + 152 + for _, step := range raw.Instructions { 153 + recipe.Instructions = append(recipe.Instructions, models.Instruction{Text: step}) 154 + } 155 + 156 + if recipe.Name == "" { 157 + return nil, fmt.Errorf("AI extraction returned empty recipe name") 158 + } 159 + 160 + return recipe, nil 161 + } 162 + 163 + func truncate(s string, maxLen int) string { 164 + if len(s) <= maxLen { 165 + return s 166 + } 167 + return s[:maxLen] + "..." 168 + }
+167
internal/extract/hrecipe/hrecipe.go
··· 1 + package hrecipe 2 + 3 + import ( 4 + "strings" 5 + 6 + "tangled.org/dunkirk.sh/pare/internal/models" 7 + 8 + "golang.org/x/net/html" 9 + ) 10 + 11 + func Extract(body string) (*models.Recipe, bool) { 12 + doc, err := html.Parse(strings.NewReader(body)) 13 + if err != nil { 14 + return nil, false 15 + } 16 + 17 + recipeNode := findByClass(doc, "h-recipe") 18 + if recipeNode == nil { 19 + return nil, false 20 + } 21 + 22 + recipe := &models.Recipe{} 23 + recipe.ExtractionMethod = "h-recipe" 24 + 25 + if name := findTextByClass(recipeNode, "p-name"); name != "" { 26 + recipe.Name = name 27 + } 28 + if summary := findTextByClass(recipeNode, "p-summary"); summary != "" { 29 + recipe.Description = summary 30 + } 31 + if yield := findTextByClass(recipeNode, "p-yield"); yield != "" { 32 + recipe.Yield = yield 33 + } 34 + if duration := findTextByClass(recipeNode, "dt-duration"); duration != "" { 35 + recipe.TotalTime = duration 36 + } 37 + if photo := findAttrByClass(recipeNode, "u-photo", "src"); photo != "" { 38 + recipe.ImageURL = photo 39 + } 40 + 41 + ingredients := findAllTextByClass(recipeNode, "p-ingredient") 42 + for _, ing := range ingredients { 43 + recipe.Ingredients = append(recipe.Ingredients, models.Ingredient{RawText: ing}) 44 + } 45 + 46 + instructions := findInnerHTMLByClass(recipeNode, "e-instructions") 47 + if instructions != "" { 48 + for _, line := range strings.Split(instructions, "\n") { 49 + line = strings.TrimSpace(line) 50 + if line != "" { 51 + recipe.Instructions = append(recipe.Instructions, models.Instruction{Text: line}) 52 + } 53 + } 54 + } 55 + 56 + if recipe.Name == "" { 57 + return nil, false 58 + } 59 + 60 + return recipe, true 61 + } 62 + 63 + func findByClass(n *html.Node, class string) *html.Node { 64 + if hasClass(n, class) { 65 + return n 66 + } 67 + for c := n.FirstChild; c != nil; c = c.NextSibling { 68 + if found := findByClass(c, class); found != nil { 69 + return found 70 + } 71 + } 72 + return nil 73 + } 74 + 75 + func hasClass(n *html.Node, class string) bool { 76 + for _, attr := range n.Attr { 77 + if attr.Key == "class" { 78 + for _, c := range strings.Fields(attr.Val) { 79 + if c == class { 80 + return true 81 + } 82 + } 83 + } 84 + } 85 + return false 86 + } 87 + 88 + func findTextByClass(n *html.Node, class string) string { 89 + found := findByClass(n, class) 90 + if found == nil { 91 + return "" 92 + } 93 + return textContent(found) 94 + } 95 + 96 + func findAllTextByClass(n *html.Node, class string) []string { 97 + var results []string 98 + var f func(*html.Node) 99 + f = func(n *html.Node) { 100 + if hasClass(n, class) { 101 + results = append(results, textContent(n)) 102 + return 103 + } 104 + for c := n.FirstChild; c != nil; c = c.NextSibling { 105 + f(c) 106 + } 107 + } 108 + f(n) 109 + return results 110 + } 111 + 112 + func findAttrByClass(n *html.Node, class, attr string) string { 113 + found := findByClass(n, class) 114 + if found == nil { 115 + return "" 116 + } 117 + for _, a := range found.Attr { 118 + if a.Key == attr { 119 + return a.Val 120 + } 121 + } 122 + return "" 123 + } 124 + 125 + func findInnerHTMLByClass(n *html.Node, class string) string { 126 + found := findByClass(n, class) 127 + if found == nil { 128 + return "" 129 + } 130 + return innerText(found) 131 + } 132 + 133 + func textContent(n *html.Node) string { 134 + if n.Type == html.TextNode { 135 + return n.Data 136 + } 137 + var sb strings.Builder 138 + for c := n.FirstChild; c != nil; c = c.NextSibling { 139 + sb.WriteString(textContent(c)) 140 + } 141 + return strings.TrimSpace(sb.String()) 142 + } 143 + 144 + func innerText(n *html.Node) string { 145 + var sb strings.Builder 146 + var f func(*html.Node) 147 + f = func(n *html.Node) { 148 + if n.Type == html.TextNode { 149 + sb.WriteString(n.Data) 150 + } else if n.Type == html.ElementNode && n.Data == "br" { 151 + sb.WriteString("\n") 152 + } else if n.Type == html.ElementNode && (n.Data == "li" || n.Data == "p") { 153 + sb.WriteString("\n") 154 + for c := n.FirstChild; c != nil; c = c.NextSibling { 155 + f(c) 156 + } 157 + } else { 158 + for c := n.FirstChild; c != nil; c = c.NextSibling { 159 + f(c) 160 + } 161 + } 162 + } 163 + for c := n.FirstChild; c != nil; c = c.NextSibling { 164 + f(c) 165 + } 166 + return strings.TrimSpace(sb.String()) 167 + }
+120
internal/extract/pipeline.go
··· 1 + package extract 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "net/http" 7 + "strings" 8 + "time" 9 + 10 + "tangled.org/dunkirk.sh/pare/internal/extract/ai" 11 + "tangled.org/dunkirk.sh/pare/internal/extract/hrecipe" 12 + "tangled.org/dunkirk.sh/pare/internal/extract/schema" 13 + "tangled.org/dunkirk.sh/pare/internal/models" 14 + ) 15 + 16 + type Pipeline struct { 17 + aiExtractor *ai.Extractor 18 + client *http.Client 19 + } 20 + 21 + func NewPipeline(aiExtractor *ai.Extractor) *Pipeline { 22 + return &Pipeline{ 23 + aiExtractor: aiExtractor, 24 + client: &http.Client{ 25 + Timeout: 15 * time.Second, 26 + }, 27 + } 28 + } 29 + 30 + type Result struct { 31 + Recipe *models.Recipe 32 + Error error 33 + } 34 + 35 + func (p *Pipeline) Extract(targetURL string) *Result { 36 + body, err := p.fetch(targetURL) 37 + if err != nil { 38 + return &Result{Error: fmt.Errorf("fetching page: %w", err)} 39 + } 40 + 41 + if recipe, ok := schema.Extract(body); ok { 42 + recipe.SourceURL = targetURL 43 + recipe.SourceDomain = domainOf(targetURL) 44 + return &Result{Recipe: recipe} 45 + } 46 + 47 + if recipe, ok := hrecipe.Extract(body); ok { 48 + recipe.SourceURL = targetURL 49 + recipe.SourceDomain = domainOf(targetURL) 50 + return &Result{Recipe: recipe} 51 + } 52 + 53 + if p.aiExtractor != nil { 54 + pageText := stripHTML(body) 55 + recipe, err := p.aiExtractor.Extract(pageText, targetURL) 56 + if err == nil && recipe != nil { 57 + recipe.SourceURL = targetURL 58 + recipe.SourceDomain = domainOf(targetURL) 59 + return &Result{Recipe: recipe} 60 + } 61 + } 62 + 63 + return &Result{Error: fmt.Errorf("no recipe found on page — tried JSON-LD, h-recipe, and AI extraction")} 64 + } 65 + 66 + func (p *Pipeline) fetch(url string) (string, error) { 67 + req, err := http.NewRequest("GET", url, nil) 68 + if err != nil { 69 + return "", err 70 + } 71 + 72 + req.Header.Set("User-Agent", "Pare/1.0 (recipe extractor; like a read-it-later service)") 73 + req.Header.Set("Accept", "text/html,application/xhtml+xml") 74 + 75 + resp, err := p.client.Do(req) 76 + if err != nil { 77 + return "", err 78 + } 79 + defer resp.Body.Close() 80 + 81 + if resp.StatusCode != http.StatusOK { 82 + return "", fmt.Errorf("HTTP %d", resp.StatusCode) 83 + } 84 + 85 + body, err := io.ReadAll(resp.Body) 86 + if err != nil { 87 + return "", err 88 + } 89 + 90 + return string(body), nil 91 + } 92 + 93 + func domainOf(url string) string { 94 + parts := strings.SplitAfter(url, "://") 95 + if len(parts) < 2 { 96 + return url 97 + } 98 + host := strings.Split(parts[1], "/")[0] 99 + return host 100 + } 101 + 102 + func stripHTML(body string) string { 103 + var sb strings.Builder 104 + inTag := false 105 + for _, r := range body { 106 + if r == '<' { 107 + inTag = true 108 + continue 109 + } 110 + if r == '>' { 111 + inTag = false 112 + sb.WriteRune(' ') 113 + continue 114 + } 115 + if !inTag { 116 + sb.WriteRune(r) 117 + } 118 + } 119 + return sb.String() 120 + }
+267
internal/extract/schema/jsonld.go
··· 1 + package schema 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "regexp" 7 + "strings" 8 + 9 + "tangled.org/dunkirk.sh/pare/internal/models" 10 + 11 + "golang.org/x/net/html" 12 + ) 13 + 14 + func Extract(body string) (*models.Recipe, bool) { 15 + doc, err := html.Parse(strings.NewReader(body)) 16 + if err != nil { 17 + return nil, false 18 + } 19 + 20 + ldNodes := findJSONLDScripts(doc) 21 + for _, node := range ldNodes { 22 + recipe := parseJSONLD(node) 23 + if recipe != nil { 24 + recipe.ExtractionMethod = "schema.org" 25 + return recipe, true 26 + } 27 + } 28 + 29 + return nil, false 30 + } 31 + 32 + func findJSONLDScripts(n *html.Node) []string { 33 + var results []string 34 + var f func(*html.Node) 35 + f = func(n *html.Node) { 36 + if n.Type == html.ElementNode && n.Data == "script" { 37 + for _, attr := range n.Attr { 38 + if attr.Key == "type" && attr.Val == "application/ld+json" { 39 + text := collectText(n) 40 + results = append(results, text) 41 + } 42 + } 43 + } 44 + for c := n.FirstChild; c != nil; c = c.NextSibling { 45 + f(c) 46 + } 47 + } 48 + f(n) 49 + return results 50 + } 51 + 52 + func collectText(n *html.Node) string { 53 + if n.Type == html.TextNode { 54 + return n.Data 55 + } 56 + var sb strings.Builder 57 + for c := n.FirstChild; c != nil; c = c.NextSibling { 58 + sb.WriteString(collectText(c)) 59 + } 60 + return sb.String() 61 + } 62 + 63 + func parseJSONLD(content string) *models.Recipe { 64 + var raw interface{} 65 + if err := json.Unmarshal([]byte(content), &raw); err != nil { 66 + return nil 67 + } 68 + 69 + obj := findRecipeObject(raw) 70 + if obj == nil { 71 + return nil 72 + } 73 + 74 + m, ok := obj.(map[string]interface{}) 75 + if !ok { 76 + return nil 77 + } 78 + 79 + recipe := &models.Recipe{} 80 + recipe.Name = strVal(m, "name") 81 + recipe.Description = strVal(m, "description") 82 + recipe.PrepTime = strVal(m, "prepTime") 83 + recipe.CookTime = strVal(m, "cookTime") 84 + recipe.TotalTime = strVal(m, "totalTime") 85 + recipe.Yield = cleanYield(strVal(m, "recipeYield")) 86 + 87 + if img := extractImage(m); img != "" { 88 + recipe.ImageURL = img 89 + } 90 + 91 + recipe.Ingredients = extractIngredients(m) 92 + recipe.Instructions = extractInstructions(m) 93 + 94 + if recipe.Yield != "" { 95 + fmt.Sscanf(recipe.Yield, "%d", &recipe.Servings) 96 + } 97 + 98 + if recipe.Name == "" { 99 + return nil 100 + } 101 + 102 + return recipe 103 + } 104 + 105 + func findRecipeObject(v interface{}) interface{} { 106 + switch val := v.(type) { 107 + case map[string]interface{}: 108 + if typ, ok := val["@type"]; ok { 109 + typeStr := fmt.Sprintf("%v", typ) 110 + if typeStr == "Recipe" { 111 + return val 112 + } 113 + } 114 + if graph, ok := val["@graph"]; ok { 115 + if arr, ok := graph.([]interface{}); ok { 116 + for _, item := range arr { 117 + if r := findRecipeObject(item); r != nil { 118 + return r 119 + } 120 + } 121 + } 122 + } 123 + case []interface{}: 124 + for _, item := range val { 125 + if r := findRecipeObject(item); r != nil { 126 + return r 127 + } 128 + } 129 + } 130 + return nil 131 + } 132 + 133 + func extractImage(m map[string]interface{}) string { 134 + img := m["image"] 135 + switch v := img.(type) { 136 + case string: 137 + return v 138 + case map[string]interface{}: 139 + return strVal(v, "url") 140 + case []interface{}: 141 + if len(v) > 0 { 142 + switch first := v[0].(type) { 143 + case string: 144 + return first 145 + case map[string]interface{}: 146 + return strVal(first, "url") 147 + } 148 + } 149 + } 150 + return "" 151 + } 152 + 153 + func extractIngredients(m map[string]interface{}) []models.Ingredient { 154 + raw, ok := m["recipeIngredient"] 155 + if !ok { 156 + raw, ok = m["ingredients"] 157 + } 158 + if !ok { 159 + return nil 160 + } 161 + 162 + var items []interface{} 163 + switch v := raw.(type) { 164 + case []interface{}: 165 + items = v 166 + case string: 167 + items = []interface{}{v} 168 + default: 169 + return nil 170 + } 171 + 172 + var ingredients []models.Ingredient 173 + for _, item := range items { 174 + text := fmt.Sprintf("%v", item) 175 + ingredients = append(ingredients, parseIngredient(text)) 176 + } 177 + return ingredients 178 + } 179 + 180 + var ingredientRe = regexp.MustCompile(`^(\d+\s*\d*/\d*|\d+\.?\d*)\s+(cups?|tablespoons?|teaspoons?|tbsp|tsp|c|oz|lbs?|pounds?|grams?|g|kg|ml|liters?|l|pinch|dash|cloves?|slices?|pieces?|heads?|sprigs?|bunches?|cans?|bottles?|packages?|sticks?|quarts?|pints?|gallons?)\s+(.+)$`) 181 + 182 + var ingredientFracRe = regexp.MustCompile(`^(\d+\s+\d/\d+)\s+(cups?|tablespoons?|teaspoons?|tbsp|tsp|c|oz|lbs?|pounds?|grams?|g|kg|ml|liters?|l|pinch|dash|cloves?|slices?|pieces?|heads?|sprigs?|bunches?|cans?|bottles?|packages?|sticks?|quarts?|pints?|gallons?)\s+(.+)$`) 183 + 184 + func parseIngredient(text string) models.Ingredient { 185 + text = strings.TrimSpace(text) 186 + 187 + if m := ingredientFracRe.FindStringSubmatch(text); len(m) == 4 { 188 + return models.Ingredient{RawText: text, Quantity: m[1], Unit: m[2], Name: m[3]} 189 + } 190 + if m := ingredientRe.FindStringSubmatch(text); len(m) == 4 { 191 + return models.Ingredient{RawText: text, Quantity: m[1], Unit: m[2], Name: m[3]} 192 + } 193 + return models.Ingredient{RawText: text} 194 + } 195 + 196 + func extractInstructions(m map[string]interface{}) []models.Instruction { 197 + raw, ok := m["recipeInstructions"] 198 + if !ok { 199 + return nil 200 + } 201 + 202 + var steps []models.Instruction 203 + walkInstructions(raw, &steps) 204 + return steps 205 + } 206 + 207 + func walkInstructions(v interface{}, steps *[]models.Instruction) { 208 + switch val := v.(type) { 209 + case []interface{}: 210 + for _, item := range val { 211 + walkInstructions(item, steps) 212 + } 213 + case map[string]interface{}: 214 + typ := fmt.Sprintf("%v", val["@type"]) 215 + switch typ { 216 + case "HowToStep": 217 + text := strVal(val, "text") 218 + if text != "" { 219 + *steps = append(*steps, models.Instruction{Text: text}) 220 + } 221 + case "HowToSection": 222 + if items, ok := val["itemListElement"].([]interface{}); ok { 223 + for _, item := range items { 224 + walkInstructions(item, steps) 225 + } 226 + } 227 + default: 228 + text := strVal(val, "text") 229 + if text != "" { 230 + *steps = append(*steps, models.Instruction{Text: text}) 231 + } 232 + } 233 + case string: 234 + lines := strings.Split(val, "\n") 235 + for _, line := range lines { 236 + line = strings.TrimSpace(line) 237 + if line != "" { 238 + *steps = append(*steps, models.Instruction{Text: line}) 239 + } 240 + } 241 + } 242 + } 243 + 244 + func cleanYield(yield string) string { 245 + yield = strings.TrimSpace(yield) 246 + yield = strings.TrimSuffix(yield, " servings") 247 + yield = strings.TrimSuffix(yield, " serving") 248 + return yield 249 + } 250 + 251 + func strVal(m map[string]interface{}, key string) string { 252 + v, ok := m[key] 253 + if !ok { 254 + return "" 255 + } 256 + switch val := v.(type) { 257 + case string: 258 + return val 259 + case []interface{}: 260 + if len(val) > 0 { 261 + return fmt.Sprintf("%v", val[0]) 262 + } 263 + return "" 264 + default: 265 + return fmt.Sprintf("%v", val) 266 + } 267 + }
+37
internal/models/recipe.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Recipe struct { 6 + Name string 7 + Description string 8 + ImageURL string 9 + SourceURL string 10 + SourceDomain string 11 + PrepTime string 12 + CookTime string 13 + TotalTime string 14 + Yield string 15 + Servings int 16 + Ingredients []Ingredient 17 + Instructions []Instruction 18 + ExtractionMethod string 19 + } 20 + 21 + type Ingredient struct { 22 + RawText string 23 + Quantity string 24 + Unit string 25 + Name string 26 + } 27 + 28 + type Instruction struct { 29 + Text string 30 + } 31 + 32 + type CachedRecipe struct { 33 + URL string 34 + Recipe []byte 35 + ExtractionMethod string 36 + FetchedAt time.Time 37 + }
+263
main.go
··· 1 + package main 2 + 3 + import ( 4 + "flag" 5 + "fmt" 6 + "html/template" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "os" 11 + "strings" 12 + "io/fs" 13 + 14 + "github.com/go-chi/chi/v5" 15 + "github.com/go-chi/chi/v5/middleware" 16 + 17 + "tangled.org/dunkirk.sh/pare/internal/cache" 18 + "tangled.org/dunkirk.sh/pare/internal/cooklang" 19 + "tangled.org/dunkirk.sh/pare/internal/extract" 20 + "tangled.org/dunkirk.sh/pare/internal/extract/ai" 21 + "tangled.org/dunkirk.sh/pare/internal/models" 22 + "tangled.org/dunkirk.sh/pare/ui" 23 + ) 24 + 25 + var gitHash = "dev" 26 + 27 + func main() { 28 + port := flag.Int("port", 3000, "port to listen on") 29 + dbPath := flag.String("db", "pare.db", "path to SQLite database") 30 + apiKey := flag.String("ai-key", os.Getenv("ANTHROPIC_API_KEY"), "Anthropic API key for AI extraction") 31 + baseURL := flag.String("base-url", "", "base URL of this service") 32 + flag.Parse() 33 + 34 + if *baseURL == "" { 35 + *baseURL = fmt.Sprintf("http://localhost:%d", *port) 36 + } 37 + 38 + c, err := cache.New(*dbPath) 39 + if err != nil { 40 + log.Fatalf("opening cache: %v", err) 41 + } 42 + defer c.Close() 43 + 44 + var aiExtractor *ai.Extractor 45 + if *apiKey != "" { 46 + aiExtractor = ai.NewExtractor(*apiKey, "", "") 47 + } 48 + 49 + tmpl, err := template.New("").Funcs(template.FuncMap{ 50 + "fmtDuration": fmtDuration, 51 + }).ParseFS(ui.Templates, "templates/*.html") 52 + if err != nil { 53 + log.Fatalf("parsing templates: %v", err) 54 + } 55 + 56 + srv := &Server{ 57 + pipeline: extract.NewPipeline(aiExtractor), 58 + cache: c, 59 + templates: tmpl, 60 + baseURL: *baseURL, 61 + gitHash: gitHash, 62 + } 63 + 64 + r := chi.NewRouter() 65 + r.Use(middleware.Logger) 66 + r.Use(middleware.Recoverer) 67 + r.Use(middleware.CleanPath) 68 + 69 + staticContent, err := fs.Sub(ui.Static, "static") 70 + if err != nil { 71 + log.Fatalf("failed to get static fs: %v", err) 72 + } 73 + 74 + r.Get("/", srv.handleIndex) 75 + r.Get("/recipe", srv.handleRecipe) 76 + r.Get("/export.cook", srv.handleCookExport) 77 + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) 78 + 79 + addr := fmt.Sprintf(":%d", *port) 80 + log.Printf("http://localhost:%d", *port) 81 + if err := http.ListenAndServe(addr, r); err != nil { 82 + log.Fatal(err) 83 + } 84 + } 85 + 86 + type Server struct { 87 + pipeline *extract.Pipeline 88 + cache *cache.Cache 89 + templates *template.Template 90 + baseURL string 91 + gitHash string 92 + } 93 + 94 + func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { 95 + targetURL := r.URL.Query().Get("url") 96 + if targetURL != "" { 97 + http.Redirect(w, r, "/recipe?url="+url.QueryEscape(targetURL), http.StatusFound) 98 + return 99 + } 100 + s.templates.ExecuteTemplate(w, "index_page", map[string]string{"GitHash": s.gitHash}) 101 + } 102 + 103 + func (s *Server) handleRecipe(w http.ResponseWriter, r *http.Request) { 104 + targetURL := r.URL.Query().Get("url") 105 + if targetURL == "" { 106 + http.Redirect(w, r, "/", http.StatusFound) 107 + return 108 + } 109 + 110 + if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { 111 + targetURL = "https://" + targetURL 112 + } 113 + 114 + recipe, err := s.cache.Get(targetURL) 115 + if err != nil { 116 + log.Printf("cache read error: %v", err) 117 + } 118 + if recipe == nil { 119 + result := s.pipeline.Extract(targetURL) 120 + if result.Error != nil { 121 + s.renderError(w, result.Error.Error(), targetURL) 122 + return 123 + } 124 + recipe = result.Recipe 125 + if err := s.cache.Set(targetURL, recipe); err != nil { 126 + log.Printf("cache write error: %v", err) 127 + } 128 + } 129 + 130 + data := map[string]interface{}{ 131 + "Recipe": recipe, 132 + "TargetURL": targetURL, 133 + "GitHash": s.gitHash, 134 + } 135 + 136 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 137 + s.templates.ExecuteTemplate(w, "recipe_page", data) 138 + } 139 + 140 + func (s *Server) handleCookExport(w http.ResponseWriter, r *http.Request) { 141 + targetURL := r.URL.Query().Get("url") 142 + if targetURL == "" { 143 + http.Error(w, "missing url parameter", http.StatusBadRequest) 144 + return 145 + } 146 + 147 + recipe, err := s.cache.Get(targetURL) 148 + if err != nil { 149 + log.Printf("cache read error: %v", err) 150 + } 151 + if recipe == nil { 152 + result := s.pipeline.Extract(targetURL) 153 + if result.Error != nil { 154 + http.Error(w, result.Error.Error(), http.StatusBadGateway) 155 + return 156 + } 157 + recipe = result.Recipe 158 + } 159 + 160 + cook := cooklang.Export(recipe) 161 + filename := url.PathEscape(recipe.Name) + ".cook" 162 + 163 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 164 + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) 165 + w.Write([]byte(cook)) 166 + } 167 + 168 + func (s *Server) renderError(w http.ResponseWriter, errMsg, sourceURL string) { 169 + data := map[string]interface{}{ 170 + "Error": errMsg, 171 + "SourceURL": sourceURL, 172 + "BaseURL": s.baseURL, 173 + "GitHash": s.gitHash, 174 + } 175 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 176 + w.WriteHeader(http.StatusBadGateway) 177 + s.templates.ExecuteTemplate(w, "error_page", data) 178 + } 179 + 180 + func fmtDuration(iso string) string { 181 + if !strings.HasPrefix(iso, "PT") { 182 + return iso 183 + } 184 + d := strings.TrimPrefix(iso, "PT") 185 + var parts []string 186 + if h := before(d, "H"); h != "" { 187 + parts = append(parts, h+"h") 188 + d = after(d, "H") 189 + } 190 + if m := before(d, "M"); m != "" { 191 + parts = append(parts, m+"m") 192 + d = after(d, "M") 193 + } 194 + if s := before(d, "S"); s != "" { 195 + parts = append(parts, s+"s") 196 + } 197 + if len(parts) == 0 { 198 + return iso 199 + } 200 + return strings.Join(parts, " ") 201 + } 202 + 203 + func before(s, sep string) string { 204 + i := strings.Index(s, sep) 205 + if i < 0 { 206 + return "" 207 + } 208 + return s[:i] 209 + } 210 + 211 + func after(s, sep string) string { 212 + i := strings.Index(s, sep) 213 + if i < 0 { 214 + return s 215 + } 216 + return s[i+len(sep):] 217 + } 218 + 219 + func recipeToJSONLD(r *models.Recipe) map[string]interface{} { 220 + ld := map[string]interface{}{ 221 + "@context": "https://schema.org", 222 + "@type": "Recipe", 223 + "name": r.Name, 224 + "description": r.Description, 225 + "recipeIngredient": ingredientStrings(r.Ingredients), 226 + "recipeInstructions": instructionStrings(r.Instructions), 227 + } 228 + if r.ImageURL != "" { 229 + ld["image"] = r.ImageURL 230 + } 231 + if r.PrepTime != "" { 232 + ld["prepTime"] = r.PrepTime 233 + } 234 + if r.CookTime != "" { 235 + ld["cookTime"] = r.CookTime 236 + } 237 + if r.TotalTime != "" { 238 + ld["totalTime"] = r.TotalTime 239 + } 240 + if r.Yield != "" { 241 + ld["recipeYield"] = r.Yield 242 + } 243 + return ld 244 + } 245 + 246 + func ingredientStrings(ings []models.Ingredient) []string { 247 + out := make([]string, len(ings)) 248 + for i, ing := range ings { 249 + out[i] = ing.RawText 250 + } 251 + return out 252 + } 253 + 254 + func instructionStrings(steps []models.Instruction) []map[string]string { 255 + out := make([]map[string]string, len(steps)) 256 + for i, step := range steps { 257 + out[i] = map[string]string{ 258 + "@type": "HowToStep", 259 + "text": step.Text, 260 + } 261 + } 262 + return out 263 + }
+9
ui/embed.go
··· 1 + package ui 2 + 3 + import "embed" 4 + 5 + //go:embed templates 6 + var Templates embed.FS 7 + 8 + //go:embed static 9 + var Static embed.FS
+242
ui/static/style.css
··· 1 + *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} 2 + 3 + :root{ 4 + --bg:#faf9f7; 5 + --surface:#fff; 6 + --text:#1a1a1a; 7 + --text-muted:#6b6b6b; 8 + --accent:#e85d04; 9 + --accent-hover:#c44e03; 10 + --border:#e0ddd8; 11 + --check-bg:#f0ede8; 12 + --error-bg:#fef2f2; 13 + --error-text:#991b1b; 14 + --ai-badge:#7c3aed; 15 + --radius:8px; 16 + --max-w:680px; 17 + } 18 + 19 + html{font-size:16px;-webkit-font-smoothing:antialiased} 20 + 21 + body{ 22 + font-family:Georgia,"Times New Roman",serif; 23 + background:var(--bg); 24 + color:var(--text); 25 + line-height:1.7; 26 + min-height:100vh; 27 + display:flex; 28 + flex-direction:column; 29 + } 30 + 31 + a{color:var(--accent);text-decoration:none} 32 + a:hover{text-decoration:underline} 33 + 34 + nav{ 35 + max-width:var(--max-w); 36 + margin:0 auto; 37 + padding:1.25rem 1.5rem; 38 + width:100%; 39 + } 40 + nav a.wordmark{ 41 + font-family:system-ui,sans-serif; 42 + font-size:1.6rem; 43 + font-weight:800; 44 + color:var(--accent); 45 + letter-spacing:-0.03em; 46 + text-decoration:none; 47 + } 48 + nav a.wordmark:hover{text-decoration:none} 49 + 50 + .page{ 51 + max-width:var(--max-w); 52 + margin:0 auto; 53 + padding:0 1.5rem 2rem; 54 + flex:1; 55 + } 56 + 57 + footer{ 58 + display:flex; 59 + justify-content:space-between; 60 + align-items:center; 61 + padding:1.25rem 2rem; 62 + color:var(--text-muted); 63 + font-size:0.8rem; 64 + font-family:system-ui,sans-serif; 65 + border-top:1px solid var(--border); 66 + margin-top:auto; 67 + } 68 + footer a{color:var(--text-muted)} 69 + footer a:hover{color:var(--accent)} 70 + .commit-link{font-family:monospace;font-size:0.75rem} 71 + 72 + .hero{text-align:center;padding:4rem 0 2.5rem} 73 + .hero p{color:var(--text-muted);font-size:1.05rem;margin-bottom:2rem} 74 + 75 + .search-form{} 76 + .search-form form{display:flex;gap:0.5rem} 77 + .search-form input[type="text"]{ 78 + flex:1; 79 + padding:0.85rem 1.1rem; 80 + border:2px solid var(--border); 81 + border-radius:var(--radius); 82 + font-size:1.05rem; 83 + font-family:inherit; 84 + background:var(--surface); 85 + outline:none; 86 + transition:border-color 0.2s,box-shadow 0.2s; 87 + } 88 + .search-form input[type="text"]:focus{ 89 + border-color:var(--accent); 90 + box-shadow:0 0 0 3px rgba(232,93,4,0.1); 91 + } 92 + .search-form button{ 93 + padding:0.85rem 1.6rem; 94 + background:var(--accent); 95 + color:#fff; 96 + border:none; 97 + border-radius:var(--radius); 98 + font-size:1.05rem; 99 + font-family:system-ui,sans-serif; 100 + font-weight:600; 101 + cursor:pointer; 102 + transition:background 0.2s; 103 + } 104 + .search-form button:hover{background:var(--accent-hover)} 105 + 106 + .recipe-image{ 107 + width:100%; 108 + max-height:400px; 109 + object-fit:cover; 110 + border-radius:var(--radius); 111 + margin-bottom:1.5rem; 112 + } 113 + 114 + .recipe-header{margin-bottom:1.5rem} 115 + .recipe-header h2{font-size:1.6rem;letter-spacing:-0.01em;margin-bottom:0.35rem} 116 + .recipe-meta{color:var(--text-muted);font-size:0.9rem} 117 + .recipe-meta span{margin-right:1rem} 118 + .description{margin-top:0.5rem;color:var(--text-muted);font-size:0.95rem} 119 + 120 + .extraction-badge{ 121 + display:inline-block; 122 + padding:0.15rem 0.5rem; 123 + border-radius:4px; 124 + font-size:0.75rem; 125 + font-family:system-ui,sans-serif; 126 + vertical-align:middle; 127 + } 128 + .extraction-badge.schema{background:#dbeafe;color:#1e40af} 129 + .extraction-badge.hrecipe{background:#d1fae5;color:#065f46} 130 + .extraction-badge.ai{background:#ede9fe;color:var(--ai-badge)} 131 + 132 + .section{margin-bottom:2rem} 133 + .section h3{ 134 + font-size:0.85rem; 135 + text-transform:uppercase; 136 + letter-spacing:0.08em; 137 + color:var(--text-muted); 138 + border-bottom:2px solid var(--border); 139 + padding-bottom:0.35rem; 140 + margin-bottom:0.75rem; 141 + font-family:system-ui,sans-serif; 142 + } 143 + 144 + .ingredient-list{list-style:none} 145 + .ingredient-list li{ 146 + display:flex; 147 + align-items:flex-start; 148 + gap:0.6rem; 149 + padding:0.35rem 0; 150 + cursor:pointer; 151 + transition:opacity 0.2s; 152 + } 153 + .ingredient-list li.checked{opacity:0.45;text-decoration:line-through} 154 + .ingredient-list li input[type="checkbox"]{ 155 + width:1.1rem;height:1.1rem; 156 + accent-color:var(--accent); 157 + margin-top:0.3rem; 158 + flex-shrink:0; 159 + } 160 + .ingredient-list li label{cursor:pointer;flex:1} 161 + 162 + .instruction-list{counter-reset:step;list-style:none} 163 + .instruction-list li{ 164 + counter-increment:step; 165 + position:relative; 166 + padding-left:2.2rem; 167 + margin-bottom:0.75rem; 168 + } 169 + .instruction-list li::before{ 170 + content:counter(step); 171 + position:absolute; 172 + left:0;top:0.15rem; 173 + width:1.6rem;height:1.6rem; 174 + background:var(--accent); 175 + color:#fff; 176 + border-radius:50%; 177 + font-size:0.8rem; 178 + font-family:system-ui,sans-serif; 179 + display:flex;align-items:center;justify-content:center; 180 + } 181 + 182 + .actions{ 183 + display:flex; 184 + gap:0.75rem; 185 + flex-wrap:wrap; 186 + margin-top:2rem; 187 + } 188 + .actions a,.actions button{ 189 + padding:0.6rem 1.2rem; 190 + border-radius:var(--radius); 191 + font-size:0.9rem; 192 + font-family:system-ui,sans-serif; 193 + cursor:pointer; 194 + text-decoration:none; 195 + transition:all 0.2s; 196 + display:inline-flex; 197 + align-items:center; 198 + gap:0.4rem; 199 + } 200 + .btn-primary{background:var(--accent);color:#fff;border:none} 201 + .btn-primary:hover{background:var(--accent-hover);text-decoration:none} 202 + .btn-secondary{background:var(--surface);color:var(--text);border:1px solid var(--border)} 203 + .btn-secondary:hover{border-color:var(--accent);text-decoration:none} 204 + 205 + .error-box{ 206 + background:var(--error-bg); 207 + color:var(--error-text); 208 + padding:1.5rem; 209 + border-radius:var(--radius); 210 + text-align:center; 211 + } 212 + .error-box h3{margin-bottom:0.5rem} 213 + .error-box p{font-size:0.9rem;margin-bottom:1rem} 214 + 215 + .bookmarklet{ 216 + margin-top:2rem; 217 + padding:1rem; 218 + background:var(--surface); 219 + border:1px solid var(--border); 220 + border-radius:var(--radius); 221 + } 222 + .bookmarklet h4{font-size:0.9rem;margin-bottom:0.5rem;font-family:system-ui,sans-serif} 223 + .bookmarklet p{font-size:0.85rem;color:var(--text-muted);margin-bottom:0.5rem} 224 + .bookmarklet code{ 225 + display:block; 226 + padding:0.5rem; 227 + background:var(--check-bg); 228 + border-radius:4px; 229 + font-size:0.75rem; 230 + word-break:break-all; 231 + } 232 + 233 + @media(max-width:600px){ 234 + .page{padding:0 1rem 2rem} 235 + nav{padding:1rem} 236 + footer{padding:1rem} 237 + .hero h1{font-size:1.8rem} 238 + .hero{padding:2.5rem 0 2rem} 239 + .search-form form{flex-direction:column} 240 + .actions{flex-direction:column} 241 + .actions a,.actions button{width:100%;justify-content:center} 242 + }
+20
ui/templates/base.html
··· 1 + {{define "base"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width,initial-scale=1"> 7 + <title>{{block "title" .}}pare{{end}}</title> 8 + <link rel="stylesheet" href="/static/style.css"> 9 + </head> 10 + <body> 11 + <nav> 12 + <a href="/" class="wordmark">pare</a> 13 + </nav> 14 + <div class="page"> 15 + {{block "content" .}}{{end}} 16 + </div> 17 + <footer>making recipe websites palatable again</footer> 18 + </body> 19 + </html> 20 + {{end}}
+27
ui/templates/error.html
··· 1 + {{define "error_page"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width,initial-scale=1"> 7 + <title>pare - error</title> 8 + <link rel="stylesheet" href="/static/style.css"> 9 + </head> 10 + <body> 11 + <nav> 12 + <a href="/" class="wordmark">pare</a> 13 + </nav> 14 + <div class="page"> 15 + <div class="error-box"> 16 + <h3>Could not extract recipe</h3> 17 + <p>{{.Error}}</p> 18 + <a class="btn-secondary" href="{{.SourceURL}}" target="_blank" rel="noopener">Open original page &#8599;</a> 19 + </div> 20 + </div> 21 + <footer> 22 + <span>made by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 23 + <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" class="commit-link">{{.GitHash}}</a> 24 + </footer> 25 + </body> 26 + </html> 27 + {{end}}
+31
ui/templates/index.html
··· 1 + {{define "index_page"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width,initial-scale=1"> 7 + <title>pare</title> 8 + <link rel="stylesheet" href="/static/style.css"> 9 + </head> 10 + <body> 11 + <nav> 12 + <a href="/" class="wordmark">pare</a> 13 + </nav> 14 + <div class="page"> 15 + <div class="hero"> 16 + <p>paste any recipe URL below to strip it down to what matters</p> 17 + <div class="search-form"> 18 + <form method="GET" action="/"> 19 + <input type="text" name="url" placeholder="https://www.seriouseats.com/..." value="" autofocus> 20 + <button type="submit">pare it</button> 21 + </form> 22 + </div> 23 + </div> 24 + </div> 25 + <footer> 26 + <span>made by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 27 + <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" class="commit-link">{{.GitHash}}</a> 28 + </footer> 29 + </body> 30 + </html> 31 + {{end}}
+97
ui/templates/recipe.html
··· 1 + {{define "recipe_page"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width,initial-scale=1"> 7 + <title>pare - {{.Recipe.Name}}</title> 8 + <link rel="stylesheet" href="/static/style.css"> 9 + </head> 10 + <body> 11 + <nav> 12 + <a href="/" class="wordmark">pare</a> 13 + </nav> 14 + <div class="page"> 15 + {{if .Recipe.ImageURL}} 16 + <img class="recipe-image" src="{{.Recipe.ImageURL}}" alt="{{.Recipe.Name}}" referrerpolicy="no-referrer"> 17 + {{end}} 18 + 19 + <div class="recipe-header"> 20 + <h2>{{.Recipe.Name}}</h2> 21 + <div class="recipe-meta"> 22 + {{if .Recipe.PrepTime}}<span>&#9201; {{fmtDuration .Recipe.PrepTime}} prep</span>{{end}} 23 + {{if .Recipe.CookTime}}<span>&#9201; {{fmtDuration .Recipe.CookTime}} cook</span>{{end}} 24 + {{if .Recipe.Yield}}<span>Serves {{.Recipe.Yield}}</span>{{end}} 25 + </div> 26 + {{if .Recipe.Description}}<p class="description">{{.Recipe.Description}}</p>{{end}} 27 + </div> 28 + 29 + {{if .Recipe.Ingredients}} 30 + <div class="section"> 31 + <h3>Ingredients</h3> 32 + <ul class="ingredient-list"> 33 + {{range $i, $ing := .Recipe.Ingredients}} 34 + <li id="ing-{{$i}}" onclick="toggleCheck(this)"> 35 + <input type="checkbox" id="cb-{{$i}}"> 36 + <label for="cb-{{$i}}">{{$ing.RawText}}</label> 37 + </li> 38 + {{end}} 39 + </ul> 40 + </div> 41 + {{end}} 42 + 43 + {{if .Recipe.Instructions}} 44 + <div class="section"> 45 + <h3>Instructions</h3> 46 + <ol class="instruction-list"> 47 + {{range .Recipe.Instructions}} 48 + <li>{{.Text}}</li> 49 + {{end}} 50 + </ol> 51 + </div> 52 + {{end}} 53 + 54 + <div class="actions"> 55 + <a class="btn-primary" href="/export.cook?url={{.TargetURL | urlquery}}">Download .cook</a> 56 + <a class="btn-secondary" href="{{.Recipe.SourceURL}}" target="_blank" rel="noopener">{{.Recipe.SourceDomain}} &#8599;</a> 57 + </div> 58 + 59 + <script> 60 + function toggleCheck(li) { 61 + const cb = li.querySelector('input[type="checkbox"]'); 62 + cb.checked = !cb.checked; 63 + li.classList.toggle('checked', cb.checked); 64 + saveChecks(); 65 + } 66 + 67 + function saveChecks() { 68 + const state = {}; 69 + document.querySelectorAll('.ingredient-list li').forEach(li => { 70 + state[li.id] = li.classList.contains('checked'); 71 + }); 72 + localStorage.setItem(location.pathname, JSON.stringify(state)); 73 + } 74 + 75 + function loadChecks() { 76 + try { 77 + const state = JSON.parse(localStorage.getItem(location.pathname) || '{}'); 78 + Object.entries(state).forEach(([id, checked]) => { 79 + const li = document.getElementById(id); 80 + if (li && checked) { 81 + li.classList.add('checked'); 82 + li.querySelector('input[type="checkbox"]').checked = true; 83 + } 84 + }); 85 + } catch(e) {} 86 + } 87 + 88 + loadChecks(); 89 + </script> 90 + </div> 91 + <footer> 92 + <span>made by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 93 + <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" class="commit-link">{{.GitHash}}</a> 94 + </footer> 95 + </body> 96 + </html> 97 + {{end}}