Skip to content

Commit 6c87bbb

Browse files
author
root
committed
add feeds settings
1 parent 676f37d commit 6c87bbb

53 files changed

Lines changed: 6713 additions & 876 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/docs/openapi/api.yaml

Lines changed: 436 additions & 0 deletions
Large diffs are not rendered by default.

backend/docs/openapi/ext-api.yaml

Lines changed: 427 additions & 0 deletions
Large diffs are not rendered by default.

backend/docs/openapi/group-matrix.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,19 @@ groups:
146146
description: Feed item preference updates, bookmark creation, and manual polling endpoints.
147147
apiType: Ext
148148
extSurface:
149+
- GET /api/feeds/favicon
150+
- GET /api/feeds/bookmarks
151+
- GET /api/feeds/items
152+
- GET /api/feeds/summary
149153
- POST /api/feeds/bookmarks
154+
- POST /api/feeds/bookmarks/analyze
155+
- PATCH /api/feeds/bookmarks/{id}
150156
- DELETE /api/feeds/bookmarks/{id}
157+
- POST /api/feeds/items/{id}/bookmark
151158
- PATCH /api/feeds/items/{id}/state
152159
- POST /api/feeds/analyze
160+
- POST /api/feeds/cleanup/preview
161+
- POST /api/feeds/cleanup
153162
- POST /api/feeds/poll
154163
- POST /api/feeds/sources/{id}/poll
155164
nativeSurface: []

backend/domain/config/sysconfig/schema/schema.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,22 @@ var entryCatalog = []EntrySchema{
317317
{ID: "flushJitterSeconds", Label: "Flush Jitter Seconds", Type: "integer", HelpText: "Randomized flush delay used to smooth write bursts toward AppOS."},
318318
},
319319
},
320+
{
321+
ID: "feeds-policy",
322+
Title: "Feeds Policy",
323+
Section: SectionSystem,
324+
Source: SourceCustom,
325+
Module: "feeds",
326+
Key: "policy",
327+
Fields: []FieldSchema{
328+
{ID: "pollIntervalMinutes", Label: "Poll Interval Minutes", Type: "integer", HelpText: "Base cadence for polling active feed sources."},
329+
{ID: "failureBackoffOneHours", Label: "Failure Backoff One Hours", Type: "integer", HelpText: "Delay after the first consecutive polling failure."},
330+
{ID: "failureBackoffTwoHours", Label: "Failure Backoff Two Hours", Type: "integer", HelpText: "Delay after the second consecutive polling failure."},
331+
{ID: "failureBackoffMaxHours", Label: "Failure Backoff Max Hours", Type: "integer", HelpText: "Delay after the third and later consecutive polling failures."},
332+
{ID: "perSourceRetentionCap", Label: "Per Source Retention Cap", Type: "integer", HelpText: "Maximum stored feed articles per source before cleanup trims older items."},
333+
{ID: "globalRetentionCap", Label: "Global Retention Cap", Type: "integer", HelpText: "Maximum stored feed articles across all sources before global cleanup trims older items."},
334+
},
335+
},
320336
}
321337

322338
var customSettingDefaults = map[string]map[string]any{
@@ -386,6 +402,14 @@ var customSettingDefaults = map[string]map[string]any{
386402
"collectionJitterSeconds": 1,
387403
"flushJitterSeconds": 1,
388404
},
405+
"feeds/policy": {
406+
"pollIntervalMinutes": 60,
407+
"failureBackoffOneHours": 2,
408+
"failureBackoffTwoHours": 6,
409+
"failureBackoffMaxHours": 24,
410+
"perSourceRetentionCap": 1000,
411+
"globalRetentionCap": 30000,
412+
},
389413
}
390414

391415
func Actions() []ActionSchema {

backend/domain/feeds/bookmark.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package feeds
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
11+
"golang.org/x/net/html"
12+
13+
"github.com/websoft9/appos/backend/infra/safefetch"
14+
)
15+
16+
const maxBookmarkFetchBytes int64 = 1 * 1024 * 1024
17+
18+
type BookmarkAnalysis struct {
19+
Title string `json:"title"`
20+
Description string `json:"description"`
21+
FaviconURL string `json:"favicon_url"`
22+
ResolvedURL string `json:"resolved_url"`
23+
}
24+
25+
func AnalyzeBookmark(ctx context.Context, rawURL string, client HTTPDoer) (BookmarkAnalysis, error) {
26+
data, resolvedURL, err := fetchBookmarkBytes(ctx, rawURL, client)
27+
if err != nil {
28+
return BookmarkAnalysis{}, err
29+
}
30+
31+
analysis, err := AnalyzeBookmarkBytes(resolvedURL, data)
32+
if err != nil {
33+
return BookmarkAnalysis{}, err
34+
}
35+
analysis.ResolvedURL = resolvedURL
36+
return analysis, nil
37+
}
38+
39+
func fetchBookmarkBytes(ctx context.Context, rawURL string, client HTTPDoer) ([]byte, string, error) {
40+
if strings.TrimSpace(rawURL) == "" {
41+
return nil, "", fmt.Errorf("bookmark url is required")
42+
}
43+
if _, err := safefetch.ValidateURL(rawURL); err != nil {
44+
return nil, "", err
45+
}
46+
if client == nil {
47+
client = safefetch.NewClient()
48+
}
49+
if ctx == nil {
50+
ctx = context.Background()
51+
}
52+
53+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
54+
if err != nil {
55+
return nil, "", fmt.Errorf("build bookmark request: %w", err)
56+
}
57+
resp, err := client.Do(req)
58+
if err != nil {
59+
return nil, "", fmt.Errorf("fetch bookmark url: %w", err)
60+
}
61+
defer resp.Body.Close()
62+
63+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
64+
return nil, "", fmt.Errorf("bookmark url returned HTTP %d", resp.StatusCode)
65+
}
66+
67+
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBookmarkFetchBytes+1))
68+
if err != nil {
69+
return nil, "", fmt.Errorf("read bookmark page: %w", err)
70+
}
71+
if int64(len(data)) > maxBookmarkFetchBytes {
72+
return nil, "", fmt.Errorf("bookmark page exceeded %d byte limit", maxBookmarkFetchBytes)
73+
}
74+
75+
resolvedURL := strings.TrimSpace(rawURL)
76+
if resp.Request != nil && resp.Request.URL != nil {
77+
resolvedURL = resp.Request.URL.String()
78+
}
79+
80+
return data, resolvedURL, nil
81+
}
82+
83+
func AnalyzeBookmarkBytes(pageURL string, data []byte) (BookmarkAnalysis, error) {
84+
base, err := safefetch.ValidateURL(pageURL)
85+
if err != nil {
86+
return BookmarkAnalysis{}, err
87+
}
88+
89+
tokenizer := html.NewTokenizer(strings.NewReader(string(data)))
90+
analysis := BookmarkAnalysis{}
91+
var inTitle bool
92+
93+
for {
94+
tt := tokenizer.Next()
95+
switch tt {
96+
case html.ErrorToken:
97+
if tokenizer.Err() == io.EOF {
98+
if analysis.FaviconURL == "" {
99+
analysis.FaviconURL = defaultFaviconURL(base)
100+
}
101+
return analysis, nil
102+
}
103+
return BookmarkAnalysis{}, fmt.Errorf("parse bookmark html: %w", tokenizer.Err())
104+
case html.StartTagToken, html.SelfClosingTagToken:
105+
token := tokenizer.Token()
106+
switch strings.ToLower(token.Data) {
107+
case "title":
108+
inTitle = true
109+
case "meta":
110+
name := strings.ToLower(strings.TrimSpace(getHTMLAttr(token, "name")))
111+
property := strings.ToLower(strings.TrimSpace(getHTMLAttr(token, "property")))
112+
content := strings.TrimSpace(html.UnescapeString(getHTMLAttr(token, "content")))
113+
if content == "" {
114+
continue
115+
}
116+
if property == "og:title" {
117+
analysis.Title = normalizeMetadataText(content)
118+
}
119+
if analysis.Description == "" && (name == "description" || property == "og:description") {
120+
analysis.Description = normalizeMetadataText(content)
121+
}
122+
case "link":
123+
if analysis.FaviconURL != "" {
124+
continue
125+
}
126+
rel := strings.ToLower(strings.TrimSpace(getHTMLAttr(token, "rel")))
127+
href := strings.TrimSpace(getHTMLAttr(token, "href"))
128+
if href == "" || !looksLikeFaviconRel(rel) {
129+
continue
130+
}
131+
analysis.FaviconURL = resolveRelativeURL(base, href)
132+
}
133+
case html.TextToken:
134+
if !inTitle || analysis.Title != "" {
135+
continue
136+
}
137+
text := normalizeMetadataText(string(tokenizer.Text()))
138+
if text != "" {
139+
analysis.Title = text
140+
}
141+
case html.EndTagToken:
142+
token := tokenizer.Token()
143+
if strings.EqualFold(token.Data, "title") {
144+
inTitle = false
145+
}
146+
}
147+
}
148+
}
149+
150+
func getHTMLAttr(token html.Token, name string) string {
151+
for _, attr := range token.Attr {
152+
if strings.EqualFold(attr.Key, name) {
153+
return attr.Val
154+
}
155+
}
156+
return ""
157+
}
158+
159+
func looksLikeFaviconRel(rel string) bool {
160+
if rel == "" {
161+
return false
162+
}
163+
return strings.Contains(rel, "icon")
164+
}
165+
166+
func resolveRelativeURL(base *url.URL, raw string) string {
167+
trimmed := strings.TrimSpace(raw)
168+
if trimmed == "" {
169+
return ""
170+
}
171+
parsed, err := url.Parse(trimmed)
172+
if err != nil {
173+
return ""
174+
}
175+
return base.ResolveReference(parsed).String()
176+
}
177+
178+
func defaultFaviconURL(base *url.URL) string {
179+
if base == nil {
180+
return ""
181+
}
182+
return base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
183+
}
184+
185+
func normalizeMetadataText(value string) string {
186+
return strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
187+
}

0 commit comments

Comments
 (0)