Skip to content

Commit 3dcd2de

Browse files
committed
Suggest article selectors with OpenAI on request
When a page is parsed wrong, an administrator can ask OpenAI for a CSS selector of the article. The model sees an outline of the page body instead of raw HTML, and a suggestion is accepted only after it is applied to the page and matches a plausible amount of text that is neither nearly the whole page nor what is extracted now. The selector is stored as a disabled rule with an insert-only write, so an existing rule of the domain is never replaced and nothing changes for readers until the rule is enabled. OpenAI is never asked for a selector during normal extraction. The request shares the per-request deadline used for summaries.
1 parent 78ebaf8 commit 3dcd2de

13 files changed

Lines changed: 1055 additions & 45 deletions

File tree

‎CLAUDE.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ web/ → Go HTML templates (HTMX v2), static assets
3939
**Key interfaces:**
4040
- `extractor.Rules` (defined consumer-side in `extractor/readability.go`), implemented by `datastore.RulesDAO`. Mock generated with `//go:generate moq` in extractor package.
4141
- `extractor.Retriever` (defined in `extractor/retriever.go`) — abstracts URL content fetching. Two implementations: `HTTPRetriever` (default, standard HTTP GET with Safari user-agent) and `CloudflareRetriever` (Cloudflare Browser Rendering API for JS-rendered pages). When `UReadability.Retriever` is nil, defaults to `HTTPRetriever`.
42-
- `extractor.OpenAIClient` and `extractor.Summaries` (defined in `extractor/summary.go`): the go-openai client and the summary cache (`datastore.SummariesDAO`, TTL index on `expires_at`). `UReadability.OpenAI` is set only when `--openai.api-key` is given; nil means summaries are off. Mocks in `extractor/mocks/`; moq cannot read Go 1.27 export data, so regenerate them with a newer moq or edit by hand.
42+
- `extractor.SuggestRule` (`extractor/improve.go`) backs `POST /api/content-parsed-wrong`: the model suggests a selector from a page outline (`extractor/outline.go`), the selector is checked against the page, and it is stored as a disabled rule when the domain has none. It never runs during normal extraction.
43+
- `extractor.OpenAIClient` and `extractor.Summaries` (defined in `extractor/summary.go`): the go-openai client and the summary cache (`datastore.SummariesDAO`, TTL index on `expires_at`). `UReadability.OpenAI` is set only when `--openai.api-key` is given; nil means OpenAI features (summaries, `SuggestRule`) are off. Mocks in `extractor/mocks/`; moq cannot read Go 1.27 export data, so regenerate them with a newer moq or edit by hand.
4344

44-
Optional OpenAI flags, all in the `--openai.*` group: `api-key` / `OPENAI_API_KEY` (enables summaries), `model` / `OPENAI_MODEL` (default `gpt-6-luna`), `summary-prompt`, `max-content-length` (characters, default 10000).
45+
Optional OpenAI flags, all in the `--openai.*` group: `api-key` / `OPENAI_API_KEY` (enables summaries and selector suggestions), `model` / `OPENAI_MODEL` (default `gpt-6-luna`), `summary-prompt`, `max-content-length` (characters, default 10000).
4546

4647
## Content Extraction Flow
4748

‎README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@
2121
| cf-route-all | CF_ROUTE_ALL | `false` | route every request through Cloudflare Browser Rendering |
2222
| dbg | DEBUG | `false` | debug mode |
2323

24-
#### OpenAI summaries (optional)
24+
#### OpenAI (optional)
2525

26-
Setting `--openai.api-key` enables article summaries; without it no OpenAI calls are made.
26+
Setting `--openai.api-key` enables article summaries and selector suggestions for pages that are parsed wrong; without it no OpenAI calls are made.
2727

2828
| Command line | Environment | Default | Description |
2929
|---------------------------|---------------------------|--------------|--------------------------------------------------------------------|
30-
| openai.api-key | OPENAI_API_KEY | none | OpenAI API key, enables summaries |
30+
| openai.api-key | OPENAI_API_KEY | none | OpenAI API key, enables summaries and selector suggestions |
3131
| openai.model | OPENAI_MODEL | `gpt-6-luna` | OpenAI model |
3232
| openai.summary-prompt | OPENAI_SUMMARY_PROMPT | built-in | custom system prompt for summaries |
3333
| openai.max-content-length | OPENAI_MAX_CONTENT_LENGTH | `10000` | max characters of article text sent for summarizing (0 for no limit) |
@@ -46,9 +46,12 @@ When Cloudflare credentials are not set, the service uses a standard HTTP client
4646
GET /api/content/v1/parser?token=secret&url=http://aa.com/blah - extract content (emulate Readability API parse call)
4747
GET /api/content/v1/parser?token=secret&url=http://aa.com/blah&summary=true - extract content with AI-generated summary
4848
POST /api/extract {url: http://aa.com/blah} - extract content
49+
POST /api/content-parsed-wrong?url=http://aa.com/blah - ask OpenAI for a rule for a page that is parsed wrong (credentials required)
4950

5051
Summary generation requires the server token to be set and an OpenAI API key. Summaries are cached in MongoDB for 30 days, keyed by model, prompt and article text; a TTL index removes expired ones. When the key is set, rule previews in the web UI also show a summary.
5152

53+
`POST /api/content-parsed-wrong` uses the same credentials as the rule management routes and calls OpenAI only when it is requested, never during normal extraction. The model gets an outline of the page body and suggests a CSS selector for the article. The selector is applied to the page and accepted only when it matches a plausible amount of text, not nearly the whole page and not what is extracted now; up to three suggestions are tried. The response holds the selector, the page extracted with it and `saved`. When the domain has no rule yet, the selector is stored as a **disabled** rule, so nothing changes for readers until it is reviewed and enabled in the web UI. When the domain already has a rule, enabled or not, that rule is left as it is and only the suggestion is returned.
54+
5255
## Development
5356

5457
### Running tests

‎datastore/rules.go‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
fieldID = "_id"
1919
fieldExpiresAt = "expires_at"
2020
opSet = "$set"
21+
opSetOnInsert = "$setOnInsert"
2122
)
2223

2324
// RulesDAO data-access obj for custom parsing rules, implements Rules
@@ -77,17 +78,7 @@ func (r RulesDAO) GetByID(ctx context.Context, id bson.ObjectID) (Rule, bool) {
7778

7879
// Save upsert rule
7980
func (r RulesDAO) Save(ctx context.Context, rule Rule) (Rule, error) {
80-
// normalise nil slices to empty ones so cleared multi-value fields are stored as BSON arrays
81-
// ([]) rather than null (the driver encodes nil slices as null), keeping the stored type stable.
82-
if rule.MatchURLs == nil {
83-
rule.MatchURLs = []string{}
84-
}
85-
if rule.Excludes == nil {
86-
rule.Excludes = []string{}
87-
}
88-
if rule.TestURLs == nil {
89-
rule.TestURLs = []string{}
90-
}
81+
rule = withEmptySlices(rule)
9182
ch, err := r.UpdateOne(ctx, bson.M{fieldDomain: rule.Domain}, bson.M{opSet: rule}, options.UpdateOne().SetUpsert(true))
9283
if err != nil {
9384
log.Printf("[WARN] failed to save, error=%v, article=%v", err, rule)
@@ -109,6 +100,33 @@ func (r RulesDAO) Save(ctx context.Context, rule Rule) (Rule, error) {
109100
return rule, err
110101
}
111102

103+
// SaveIfAbsent stores the rule only when the domain has no rule yet, enabled or not, and reports
104+
// whether it did. An existing rule is never touched, unlike Save which replaces it.
105+
func (r RulesDAO) SaveIfAbsent(ctx context.Context, rule Rule) (bool, error) {
106+
rule = withEmptySlices(rule)
107+
ch, err := r.UpdateOne(ctx, bson.M{fieldDomain: rule.Domain}, bson.M{opSetOnInsert: rule}, options.UpdateOne().SetUpsert(true))
108+
if err != nil {
109+
log.Printf("[WARN] failed to save, error=%v, article=%v", err, rule)
110+
return false, err
111+
}
112+
return ch.UpsertedID != nil, nil
113+
}
114+
115+
// withEmptySlices normalises nil slices to empty ones so cleared multi-value fields are stored as BSON
116+
// arrays ([]) rather than null (the driver encodes nil slices as null), keeping the stored type stable.
117+
func withEmptySlices(rule Rule) Rule {
118+
if rule.MatchURLs == nil {
119+
rule.MatchURLs = []string{}
120+
}
121+
if rule.Excludes == nil {
122+
rule.Excludes = []string{}
123+
}
124+
if rule.TestURLs == nil {
125+
rule.TestURLs = []string{}
126+
}
127+
return rule
128+
}
129+
112130
// Disable marks enabled=false, by id
113131
func (r RulesDAO) Disable(ctx context.Context, id bson.ObjectID) error {
114132
_, err := r.UpdateOne(ctx, bson.M{fieldID: id}, bson.M{opSet: bson.M{fieldEnabled: false}})

‎datastore/rules_test.go‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package datastore
22

33
import (
44
"context"
5+
"fmt"
56
"math/rand/v2"
67
"testing"
78

@@ -92,6 +93,48 @@ func TestRulesSave(t *testing.T) {
9293
})
9394
}
9495

96+
func TestRulesSaveIfAbsent(t *testing.T) {
97+
rules := setupRules(t)
98+
ctx := context.Background()
99+
100+
t.Run("new domain is stored", func(t *testing.T) {
101+
rule := Rule{Domain: randDomain(), Content: "div.copy", User: "openai", TestURLs: []string{"https://example.com/a"}}
102+
saved, err := rules.SaveIfAbsent(ctx, rule)
103+
require.NoError(t, err)
104+
assert.True(t, saved)
105+
106+
var stored Rule
107+
require.NoError(t, rules.FindOne(ctx, bson.M{fieldDomain: rule.Domain}).Decode(&stored))
108+
assert.Equal(t, "div.copy", stored.Content)
109+
assert.Equal(t, "openai", stored.User)
110+
assert.False(t, stored.Enabled)
111+
assert.Equal(t, []string{"https://example.com/a"}, stored.TestURLs)
112+
assert.Equal(t, []string{}, stored.MatchURLs, "nil slices are stored as arrays")
113+
})
114+
115+
for _, enabled := range []bool{true, false} {
116+
t.Run(fmt.Sprintf("existing rule is kept, enabled=%v", enabled), func(t *testing.T) {
117+
domain := randDomain()
118+
orig, err := rules.Save(ctx, Rule{Domain: domain, Content: "original", Author: "me", Enabled: enabled})
119+
require.NoError(t, err)
120+
121+
saved, err := rules.SaveIfAbsent(ctx, Rule{Domain: domain, Content: "suggested", User: "openai"})
122+
require.NoError(t, err)
123+
assert.False(t, saved)
124+
125+
var stored []Rule
126+
cursor, err := rules.Find(ctx, bson.M{fieldDomain: domain})
127+
require.NoError(t, err)
128+
require.NoError(t, cursor.All(ctx, &stored))
129+
require.Len(t, stored, 1)
130+
assert.Equal(t, orig.ID, stored[0].ID)
131+
assert.Equal(t, "original", stored[0].Content)
132+
assert.Equal(t, "me", stored[0].Author)
133+
assert.Equal(t, enabled, stored[0].Enabled)
134+
})
135+
}
136+
}
137+
95138
func TestRulesGet(t *testing.T) {
96139
rules := setupRules(t)
97140

‎extractor/improve.go‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package extractor
2+
3+
import (
4+
"cmp"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"net/url"
10+
"strings"
11+
"time"
12+
13+
"github.com/PuerkitoBio/goquery"
14+
log "github.com/go-pkgz/lgr"
15+
"github.com/sashabaranov/go-openai"
16+
17+
"github.com/ukeeper/ukeeper-readability/datastore"
18+
)
19+
20+
// ErrOpenAIDisabled is returned by SuggestRule when no OpenAI client is configured
21+
var ErrOpenAIDisabled = errors.New("OpenAI is not configured")
22+
23+
// ErrNoSelector is returned by SuggestRule when no attempt produced a selector that passed the checks
24+
var ErrNoSelector = errors.New("no usable selector was suggested")
25+
26+
// errInvalidAnswer marks a model reply that is not the requested JSON
27+
var errInvalidAnswer = errors.New("reply is not the requested JSON")
28+
29+
const (
30+
suggestAttempts = 3
31+
suggestCallTimeout = 60 * time.Second // one model call
32+
suggestedRuleUser = "openai"
33+
maxSelectorLen = 200
34+
minSuggestedLen = 200 // characters of text a selector has to match
35+
maxWholePagePercent = 90 // a selector matching more of the page text than this is the page, not the article
36+
currentTextLen = 500 // characters of the current extraction shown to the model
37+
)
38+
39+
const suggestPrompt = `You find the main article on a web page. You get an outline of the page body: one line per element ` +
40+
`with its tag, id, classes, the number of characters of visible text and the number of paragraphs. ` +
41+
`Reply with JSON only: {"selector": "<CSS selector>"}. ` +
42+
`The selector must match the element that holds the whole article text and nothing else: ` +
43+
`no navigation, comments, related links, share buttons or footer. ` +
44+
`Prefer an id or a stable class over positional selectors.`
45+
46+
// Suggestion is what SuggestRule found for a URL
47+
type Suggestion struct {
48+
Selector string `json:"selector"`
49+
Saved bool `json:"saved"` // true when the selector was stored as a disabled rule for review
50+
Response *Response `json:"response"` // the page extracted with the selector
51+
}
52+
53+
// attempt is a rejected selector together with the reason, passed back to the model
54+
type attempt struct {
55+
selector string
56+
reason string
57+
}
58+
59+
// SuggestRule asks the model for a CSS selector of the article on the page, for a page the general
60+
// parser got wrong. The selector is applied to the page and has to match a plausible amount of text
61+
// before it is accepted. When the domain has no rule yet, it is stored as a disabled rule, so
62+
// nothing changes for readers until someone reviews and enables it. When the domain already has a
63+
// rule, the existing one is left alone and the suggestion is only returned.
64+
func (f *UReadability) SuggestRule(ctx context.Context, reqURL string) (*Suggestion, error) {
65+
if f.OpenAI == nil {
66+
return nil, ErrOpenAIDisabled
67+
}
68+
u, err := url.Parse(reqURL)
69+
if err != nil {
70+
return nil, fmt.Errorf("parse url %q: %w", reqURL, err)
71+
}
72+
73+
var rule *datastore.Rule // the enabled rule, as extraction would use it
74+
if f.Rules != nil {
75+
if r, found := f.Rules.Get(ctx, reqURL); found {
76+
rule = &r
77+
}
78+
}
79+
80+
rb, body, err := f.fetch(ctx, reqURL, rule)
81+
if err != nil {
82+
return nil, err
83+
}
84+
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
85+
if err != nil {
86+
return nil, err
87+
}
88+
89+
current, _, _ := f.getContent(ctx, body, reqURL, rule)
90+
current = f.getText(current, "")
91+
outline := pageOutline(doc.Nodes[0])
92+
pageLen := visibleTextLen(findBody(doc.Nodes[0]))
93+
94+
var rejected []attempt
95+
for range suggestAttempts {
96+
selector, err := f.askSelector(ctx, reqURL, current, outline, rejected)
97+
if errors.Is(err, errInvalidAnswer) {
98+
rejected = append(rejected, attempt{reason: "the reply was not the requested JSON"})
99+
continue
100+
}
101+
if err != nil {
102+
return nil, err
103+
}
104+
if reason := f.checkSelector(body, selector, current, pageLen); reason != "" {
105+
log.Printf("[INFO] selector %q for %s rejected: %s", selector, reqURL, reason)
106+
rejected = append(rejected, attempt{selector: selector, reason: reason})
107+
continue
108+
}
109+
110+
resp, err := f.parse(ctx, reqURL, body, rb, &datastore.Rule{Content: selector, Enabled: true})
111+
if err != nil {
112+
return nil, err
113+
}
114+
return &Suggestion{Selector: selector, Saved: f.saveSuggested(ctx, u.Host, reqURL, selector), Response: resp}, nil
115+
}
116+
return nil, ErrNoSelector
117+
}
118+
119+
// askSelector makes one model call and returns the selector from the reply
120+
func (f *UReadability) askSelector(ctx context.Context, reqURL, current, outline string, rejected []attempt) (string, error) {
121+
var sb strings.Builder
122+
fmt.Fprintf(&sb, "URL: %s\n\n", reqURL)
123+
if current != "" {
124+
if r := []rune(current); len(r) > currentTextLen {
125+
current = string(r[:currentTextLen])
126+
}
127+
fmt.Fprintf(&sb, "The text extracted now is reported as wrong. It starts with:\n---\n%s\n---\n\n", current)
128+
}
129+
fmt.Fprintf(&sb, "Page body outline:\n---\n%s\n---\n", outline)
130+
for _, a := range rejected {
131+
if a.selector == "" {
132+
fmt.Fprintf(&sb, "\nAn earlier attempt failed: %s. Try again.", a.reason)
133+
continue
134+
}
135+
fmt.Fprintf(&sb, "\nThe selector %q was rejected: %s. Suggest a different one.", a.selector, a.reason)
136+
}
137+
138+
callCtx, cancel := context.WithTimeout(ctx, suggestCallTimeout)
139+
defer cancel()
140+
resp, err := f.OpenAI.CreateChatCompletion(callCtx, openai.ChatCompletionRequest{
141+
Model: cmp.Or(f.OpenAIModel, DefaultOpenAIModel),
142+
Messages: []openai.ChatCompletionMessage{
143+
{Role: openai.ChatMessageRoleSystem, Content: suggestPrompt},
144+
{Role: openai.ChatMessageRoleUser, Content: sb.String()},
145+
},
146+
ResponseFormat: &openai.ChatCompletionResponseFormat{Type: openai.ChatCompletionResponseFormatTypeJSONObject},
147+
})
148+
if err != nil {
149+
return "", fmt.Errorf("openai selector request: %w", err)
150+
}
151+
if len(resp.Choices) == 0 {
152+
return "", fmt.Errorf("openai returned no choices: %w", errInvalidAnswer)
153+
}
154+
155+
var answer struct {
156+
Selector string `json:"selector"`
157+
}
158+
if err := json.Unmarshal([]byte(resp.Choices[0].Message.Content), &answer); err != nil || strings.TrimSpace(answer.Selector) == "" {
159+
return "", errInvalidAnswer
160+
}
161+
return strings.TrimSpace(answer.Selector), nil
162+
}
163+
164+
// checkSelector applies the selector to the page and returns why it can't be the article, or "" when it can
165+
func (f *UReadability) checkSelector(body, selector, current string, pageLen int) string {
166+
if len(selector) > maxSelectorLen {
167+
return "it is too long"
168+
}
169+
res, err := extractWithSelector(body, selector)
170+
if err != nil || res == "" {
171+
return "it matches nothing on the page"
172+
}
173+
doc, err := goquery.NewDocumentFromReader(strings.NewReader(res))
174+
if err != nil {
175+
return "what it matches can't be parsed"
176+
}
177+
n := visibleTextLen(findBody(doc.Nodes[0]))
178+
switch {
179+
case n < minSuggestedLen:
180+
return fmt.Sprintf("it matches only %d characters of text", n)
181+
case n*100 > pageLen*maxWholePagePercent:
182+
return "it matches almost all the text of the page"
183+
case f.getText(res, "") == current:
184+
return "it extracts the same text as the current result"
185+
}
186+
return ""
187+
}
188+
189+
// saveSuggested stores the selector as a disabled rule when the domain has none. Both enabled and
190+
// disabled rules count, and an existing rule is never replaced.
191+
func (f *UReadability) saveSuggested(ctx context.Context, domain, reqURL, selector string) bool {
192+
if f.Rules == nil {
193+
return false
194+
}
195+
rule := datastore.Rule{Domain: domain, Content: selector, TestURLs: []string{reqURL}, User: suggestedRuleUser}
196+
saved, err := f.Rules.SaveIfAbsent(ctx, rule)
197+
switch {
198+
case err != nil:
199+
log.Printf("[WARN] failed to save suggested rule for %s: %v", domain, err)
200+
case !saved:
201+
log.Printf("[INFO] %s already has a rule, suggested selector %q is not saved", domain, selector)
202+
default:
203+
log.Printf("[INFO] saved disabled rule for %s with selector %q", domain, selector)
204+
}
205+
return err == nil && saved
206+
}

0 commit comments

Comments
 (0)