Skip to content

Commit 87ca627

Browse files
committed
feat: default browserless onboarding and request strategies
1 parent 4a047cc commit 87ca627

14 files changed

Lines changed: 355 additions & 49 deletions

File tree

app/web/api/v1/root_metadata.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ module V1
77
##
88
# Builds the public metadata payload for the API root endpoint.
99
module RootMetadata
10+
FEATURED_FEEDS = [
11+
{
12+
path: '/microsoft.com/azure-products.rss',
13+
title: 'Azure product updates',
14+
description: 'Follow Microsoft Azure product announcements from your own instance.'
15+
},
16+
{
17+
path: '/phys.org/weekly.rss',
18+
title: 'Top science news of the week',
19+
description: 'Try a high-signal feed with stable weekly headlines from the built-in config set.'
20+
},
21+
{
22+
path: '/softwareleadweekly.com/issues.rss',
23+
title: 'Software Lead Weekly issues',
24+
description: 'Follow a long-running newsletter archive from the embedded config catalog.'
25+
}
26+
].freeze
27+
1028
class << self
1129
# @param router [Roda::RodaRequest]
1230
# @return [Hash{Symbol=>Object}]
@@ -30,7 +48,8 @@ def instance_payload(_router)
3048
feed_creation: {
3149
enabled: AutoSource.enabled?,
3250
access_token_required: AutoSource.enabled?
33-
}
51+
},
52+
featured_feeds: FEATURED_FEEDS
3453
}
3554
end
3655
end

app/web/config/local_config.rb

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# frozen_string_literal: true
22

33
require 'yaml'
4+
begin
5+
require 'html2rss/configs'
6+
rescue LoadError
7+
nil
8+
end
49

510
module Html2rss
611
module Web
@@ -17,6 +22,7 @@ class NotFound < RuntimeError; end
1722
# raised when the local config shape is invalid
1823
class InvalidConfig < RuntimeError; end
1924
FEED_EXTENSION_PATTERN = /\.(json|rss|xml)\z/
25+
EMBEDDED_FEED_NAME_PATTERN = %r{\A[^/]+/.+\z}
2026

2127
# Path to local feed configuration file.
2228
CONFIG_FILE = 'config/feeds.yml'
@@ -27,10 +33,8 @@ class << self
2733
# @return [Hash<Symbol, Any>]
2834
def find(name)
2935
normalized_name = normalize_name(name)
30-
config = snapshot.feeds.fetch(normalized_name.to_sym) do
31-
raise NotFound, "Did not find local feed config at '#{normalized_name}'"
32-
end
33-
config_hash = deep_dup(config.raw)
36+
config_hash = local_feed_config(normalized_name) || embedded_feed_config(normalized_name)
37+
raise NotFound, "Did not find local feed config at '#{normalized_name}'" unless config_hash
3438

3539
apply_global_defaults(config_hash)
3640
end
@@ -76,6 +80,26 @@ def reload!(reason: 'manual')
7680

7781
private
7882

83+
# @param normalized_name [String]
84+
# @return [Hash{Symbol=>Object}, nil]
85+
def local_feed_config(normalized_name)
86+
config = snapshot.feeds[normalized_name.to_sym]
87+
return nil unless config
88+
89+
deep_dup(config.raw)
90+
end
91+
92+
# @param normalized_name [String]
93+
# @return [Hash{Symbol=>Object}, nil]
94+
def embedded_feed_config(normalized_name)
95+
return nil unless defined?(Html2rss::Configs)
96+
return nil unless normalized_name.match?(EMBEDDED_FEED_NAME_PATTERN)
97+
98+
deep_dup(Html2rss::Configs.find_by_name(normalized_name))
99+
rescue Html2rss::Configs::ConfigNotFound
100+
nil
101+
end
102+
79103
# Applies global defaults only when feed-level keys are absent.
80104
#
81105
# @param config [Hash{Symbol=>Object}]
@@ -90,9 +114,9 @@ def apply_global_defaults(config)
90114
end
91115

92116
# @param name [String, Symbol, #to_s]
93-
# @return [String] basename without extension for feed lookup.
117+
# @return [String] path without feed extension for feed lookup.
94118
def normalize_name(name)
95-
File.basename(name.to_s).sub(FEED_EXTENSION_PATTERN, '')
119+
name.to_s.delete_prefix('/').sub(FEED_EXTENSION_PATTERN, '')
96120
end
97121

98122
# Deep-duplicates nested config structures to avoid mutating shared data.

app/web/domain/auto_source.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def enabled?
2121
# @param token_data [Hash{Symbol=>Object}] authenticated account data.
2222
# @param strategy [String]
2323
# @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata, nil]
24-
def create_stable_feed(name, url, token_data, strategy = 'faraday')
24+
def create_stable_feed(name, url, token_data, strategy = Html2rss::RequestService.default_strategy_name.to_s)
2525
return nil unless token_data && FeedAccess.url_allowed_for_username?(token_data[:username], url)
2626

2727
feed_token = Auth.generate_feed_token(token_data[:username], url, strategy: strategy)

app/web/feeds/source_resolver.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def static_cache_identity(feed_name, params)
6969
def static_generator_input(config, params)
7070
generator_input = config.dup
7171
generator_input[:params] = merged_static_params(config, params)
72-
generator_input[:strategy] ||= :faraday
72+
generator_input[:strategy] ||= Html2rss::RequestService.default_strategy_name.to_sym
7373
generator_input
7474
end
7575

docker-compose.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@ services:
77
restart: unless-stopped
88
ports:
99
- "127.0.0.1:4000:4000"
10-
volumes:
11-
- type: bind
12-
source: ./config/feeds.yml
13-
target: /app/config/feeds.yml
14-
read_only: true
10+
env_file:
11+
- path: .env
12+
required: false
1513
environment:
1614
RACK_ENV: production
1715
PORT: 4000
1816
HTML2RSS_SECRET_KEY: ${HTML2RSS_SECRET_KEY:?set HTML2RSS_SECRET_KEY}
1917
HEALTH_CHECK_TOKEN: ${HEALTH_CHECK_TOKEN:?set HEALTH_CHECK_TOKEN}
2018
BROWSERLESS_IO_WEBSOCKET_URL: ws://browserless:4002
2119
BROWSERLESS_IO_API_TOKEN: ${BROWSERLESS_IO_API_TOKEN:?set BROWSERLESS_IO_API_TOKEN}
20+
# Trial runs use the image's bundled config/feeds.yml.
21+
# Uncomment the block below when you want to replace it with your own file.
22+
# volumes:
23+
# - type: bind
24+
# source: ./config/feeds.yml
25+
# target: /app/config/feeds.yml
26+
# read_only: true
2227

2328
watchtower:
2429
image: containrrr/watchtower

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe('App contract', () => {
1818
http.post('/api/v1/feeds', async ({ request }) => {
1919
const body = (await request.json()) as { url: string; strategy: string };
2020

21-
expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'ssrf_filter' });
21+
expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'browserless' });
2222
expect(request.headers.get('authorization')).toBe(`Bearer ${token}`);
2323

2424
return HttpResponse.json(
@@ -35,7 +35,14 @@ describe('App contract', () => {
3535

3636
return HttpResponse.json(
3737
{
38-
items: [{ title: 'Contract Item' }],
38+
items: [
39+
{
40+
title: 'Contract Item',
41+
content_text: 'Contract preview excerpt.',
42+
url: 'https://example.com/contract-item',
43+
date_published: '2024-01-01T00:00:00Z',
44+
},
45+
],
3946
},
4047
{
4148
headers: { 'content-type': 'application/feed+json' },
@@ -47,23 +54,28 @@ describe('App contract', () => {
4754
render(<App />);
4855

4956
await screen.findByLabelText('Page URL');
57+
await waitFor(() => {
58+
expect(screen.getByRole('combobox')).toHaveValue('browserless');
59+
});
5060

5161
const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement;
5262
fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } });
5363

5464
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
5565

5666
await waitFor(() => {
67+
expect(screen.getByText('Your feed is ready')).toBeInTheDocument();
5768
expect(screen.getByText('Example Feed')).toBeInTheDocument();
5869
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
5970
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
6071
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
61-
expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute(
72+
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
6273
'href',
6374
'http://localhost:3000/api/v1/feeds/generated-token.json'
6475
);
6576
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
66-
expect(screen.getByText('Feed preview')).toBeInTheDocument();
77+
expect(screen.getByText('Preview')).toBeInTheDocument();
78+
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
6779
expect(screen.getByText('Contract Item')).toBeInTheDocument();
6880
});
6981
});
@@ -89,6 +101,7 @@ describe('App contract', () => {
89101
enabled: true,
90102
access_token_required: true,
91103
},
104+
featured_feeds: [],
92105
},
93106
},
94107
});
@@ -135,6 +148,9 @@ describe('App contract', () => {
135148
render(<App />);
136149

137150
await screen.findByLabelText('Page URL');
151+
await waitFor(() => {
152+
expect(screen.getByRole('combobox')).toHaveValue('browserless');
153+
});
138154

139155
fireEvent.input(screen.getByLabelText('Page URL'), {
140156
target: { value: 'https://example.com/articles' },

frontend/src/__tests__/App.test.tsx

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('App', () => {
3838

3939
beforeEach(() => {
4040
vi.clearAllMocks();
41+
window.history.replaceState({}, '', 'http://localhost:3000/');
4142

4243
mockUseAccessToken.mockReturnValue({
4344
token: null,
@@ -60,6 +61,7 @@ describe('App', () => {
6061
enabled: true,
6162
access_token_required: true,
6263
},
64+
featured_feeds: [],
6365
},
6466
},
6567
isLoading: false,
@@ -77,8 +79,8 @@ describe('App', () => {
7779

7880
mockUseStrategies.mockReturnValue({
7981
strategies: [
80-
{ id: 'ssrf_filter', name: 'ssrf_filter', display_name: 'Standard (recommended)' },
81-
{ id: 'browserless', name: 'browserless', display_name: 'JavaScript pages' },
82+
{ id: 'faraday', name: 'faraday', display_name: 'Default' },
83+
{ id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' },
8284
],
8385
isLoading: false,
8486
error: null,
@@ -102,6 +104,50 @@ describe('App', () => {
102104
});
103105
});
104106

107+
it('prefers browserless as the default strategy when available', () => {
108+
render(<App />);
109+
110+
return waitFor(() => {
111+
expect(screen.getByRole('combobox')).toHaveValue('browserless');
112+
});
113+
});
114+
115+
it('falls back to the first available strategy when browserless is unavailable', () => {
116+
mockUseStrategies.mockReturnValue({
117+
strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Default' }],
118+
isLoading: false,
119+
error: null,
120+
});
121+
122+
render(<App />);
123+
124+
return waitFor(() => {
125+
expect(screen.getByRole('combobox')).toHaveValue('faraday');
126+
});
127+
});
128+
129+
it('auto-submits a prefilled url using the resolved default strategy', async () => {
130+
mockUseAccessToken.mockReturnValue({
131+
token: 'saved-token',
132+
hasToken: true,
133+
saveToken: mockSaveToken,
134+
clearToken: mockClearToken,
135+
isLoading: false,
136+
error: null,
137+
});
138+
window.history.replaceState({}, '', 'http://localhost:3000/?url=https%3A%2F%2Fexample.com%2Farticles');
139+
140+
render(<App />);
141+
142+
await waitFor(() => {
143+
expect(mockConvertFeed).toHaveBeenCalledWith(
144+
'https://example.com/articles',
145+
'browserless',
146+
'saved-token'
147+
);
148+
});
149+
});
150+
105151
it('shows inline token prompt when submitting without a token', async () => {
106152
render(<App />);
107153

@@ -123,14 +169,55 @@ describe('App', () => {
123169
expect(mockConvertFeed).not.toHaveBeenCalled();
124170
});
125171

172+
it('promotes included feeds when feed creation is disabled', () => {
173+
mockUseApiMetadata.mockReturnValue({
174+
metadata: {
175+
api: {
176+
name: 'html2rss-web API',
177+
description: 'RESTful API for converting websites to RSS feeds',
178+
openapi_url: 'http://example.test/openapi.yaml',
179+
},
180+
instance: {
181+
feed_creation: {
182+
enabled: false,
183+
access_token_required: false,
184+
},
185+
featured_feeds: [
186+
{
187+
path: '/microsoft.com/azure-products.rss',
188+
title: 'Azure product updates',
189+
description: 'Follow Microsoft Azure product announcements from your own instance.',
190+
},
191+
],
192+
},
193+
},
194+
isLoading: false,
195+
error: null,
196+
});
197+
198+
render(<App />);
199+
200+
expect(screen.getByText('Try a working included feed')).toBeInTheDocument();
201+
expect(screen.getByRole('link', { name: 'Azure product updates' })).toHaveAttribute(
202+
'href',
203+
'/microsoft.com/azure-products.rss'
204+
);
205+
expect(screen.getByText('Custom feed generation is disabled for this instance.')).toBeInTheDocument();
206+
});
207+
126208
it('renders the result panel when a feed is available', async () => {
209+
vi.spyOn(window, 'fetch').mockResolvedValue({
210+
ok: true,
211+
json: async () => ({ items: [] }),
212+
} as Response);
213+
127214
mockUseFeedConversion.mockReturnValue({
128215
isConverting: false,
129216
result: {
130217
id: 'feed-123',
131218
name: 'Example Feed',
132219
url: 'https://example.com/articles',
133-
strategy: 'ssrf_filter',
220+
strategy: 'faraday',
134221
feed_token: 'example-token',
135222
public_url: '/api/v1/feeds/example-token',
136223
json_public_url: '/api/v1/feeds/example-token.json',
@@ -196,7 +283,7 @@ describe('App', () => {
196283
expect(mockSaveToken).toHaveBeenCalledWith('token-123');
197284
expect(mockConvertFeed).toHaveBeenCalledWith(
198285
'https://example.com/articles',
199-
'ssrf_filter',
286+
'browserless',
200287
'token-123'
201288
);
202289
});
@@ -281,4 +368,22 @@ describe('App', () => {
281368
expect(bookmarklet.getAttribute('href')).toContain('/?url=');
282369
expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent');
283370
});
371+
372+
it('shows the utility links in a user-focused order', () => {
373+
render(<App />);
374+
375+
fireEvent.click(screen.getByRole('button', { name: 'More' }));
376+
377+
const utilityLinks = screen.getAllByRole('link').map((link) => link.textContent);
378+
expect(utilityLinks).toEqual(['Try included feeds', 'Bookmarklet', 'OpenAPI spec', 'Source code']);
379+
380+
expect(screen.getByRole('link', { name: 'OpenAPI spec' })).toHaveAttribute(
381+
'href',
382+
'http://example.test/openapi.yaml'
383+
);
384+
expect(screen.getByRole('link', { name: 'Try included feeds' })).toHaveAttribute(
385+
'href',
386+
'https://html2rss.github.io/web-application/how-to/use-included-configs/'
387+
);
388+
});
284389
});

0 commit comments

Comments
 (0)