diff --git a/docs/project-structure.md b/docs/project-structure.md index 5cf1e56071..7cb3eeb2ab 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -114,6 +114,52 @@ combines credential access, model selection, and those semantics before each adapter projects the result into one target CLI process. Follow [[docs/model-semantics-and-runtime-injection.md]] for that boundary. +## Optional RSSHub News Sources + +Alice owns RSS collection and the JSONL archive in `src/domain/news/`. +Settings → News Sources (`/settings/news-collector`) offers opt-in presets +for 财联社 (CLS), 格隆汇 (Gelonghui), and 金十数据 (Jin10). Enter an RSSHub +instance URL and add each wanted source. Existing subscriptions are preserved. +A source already configured, even if disabled, cannot be added again by a preset. + +RSSHub is an independently operated service, not an Alice-managed process or a +mandatory Docker dependency. For a native Alice backend on the same machine, +this verified, pinned RSSHub image can run on loopback: + +```bash +docker run -d --name openalice-news-rsshub --restart unless-stopped -p 127.0.0.1:1200:1200 -e CACHE_TYPE=memory -e CACHE_EXPIRE=600 diygod/rsshub@sha256:0c36f939df98144fc2cbba1c5d7feefbc1675cc6ad5e99544fa1c77ac84a4e0d +``` + +Use `http://127.0.0.1:1200` in the preset form. Docker must remain running; +`docker stop openalice-news-rsshub` stops the service, and +`docker start openalice-news-rsshub` starts it again. Operators own RSSHub +updates and should recheck routes before replacing the pinned image. + +The address is resolved by the **Alice backend**, not the browser. With a remote +backend, a browser-local RSSHub is not sufficient. For separate Alice and RSSHub +containers, use a private shared Docker network and RSSHub's service name +(for example `http://rsshub:1200`), not loopback. Do not expose an unauthenticated +RSSHub publicly merely to make it reachable. + +HTTP(S) base URLs may include a reverse-proxy prefix, such as +`https://news.example.com/rsshub/`. Presets preserve the prefix and append +their route. Credentials, query strings, and fragments are rejected; do not put +secrets in feed URLs, which can appear in collection errors. Public instances +may rate-limit or return challenge pages. Adding a preset does not test connectivity. + +The form writes ordinary `news.feeds` entries through the existing config API. +No new persisted shape or migration is needed. Existing installations see the +presets without resetting config. The instance field is an add-time input, not a +global setting: changing it does not rewrite saved feeds. Remove and re-add a +preset to change its instance. Restart Alice after saving, when no Workspace +session needs to remain running: the collector reads its feed list at startup. +Then open News and filter by the saved source tag to verify reception. + +RSSHub owns provider adaptation; Alice owns parsing, deduplication, storage, and +queries. Entries may lack an article link or body. Preserve available fields +without inventing URLs or substituting headlines as full articles. Feed access +does not grant redistribution rights or access to paid content. + ## Workspace Architecture A Workspace is the primary capability boundary. It is a persistent directory diff --git a/ui/src/pages/NewsCollectorPage.spec.tsx b/ui/src/pages/NewsCollectorPage.spec.tsx index 9781ecee25..1f9029da39 100644 --- a/ui/src/pages/NewsCollectorPage.spec.tsx +++ b/ui/src/pages/NewsCollectorPage.spec.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom +import { useState } from 'react' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' - +import type { NewsCollectorFeed } from '../api/types' import { FeedsSection } from './NewsCollectorPage' afterEach(cleanup) @@ -95,3 +96,59 @@ describe('NewsCollectorPage feed editor', () => { }]) }) }) + +describe('RSSHub news presets', () => { + it('adds all sources under a reverse-proxy prefix without replacing existing feeds', () => { + function Editor() { + const [feeds, setFeeds] = useState([{ + name: 'Existing feed', source: 'existing', url: 'https://example.com/rss', enabled: false, + }]) + return + } + render() + fireEvent.change(screen.getByLabelText('RSSHub instance URL'), { + target: { value: ' https://news.example.com/rsshub/// ' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Add 财联社 · 电报' })) + fireEvent.click(screen.getByRole('button', { name: 'Add 格隆汇 · 实时快讯' })) + fireEvent.click(screen.getByRole('button', { name: 'Add 金十数据 · 市场快讯' })) + expect(screen.getByText('https://news.example.com/rsshub/jin10')).toBeTruthy() + expect(screen.getByText('https://news.example.com/rsshub/cls/telegraph')).toBeTruthy() + expect(screen.getByText('https://news.example.com/rsshub/gelonghui/live')).toBeTruthy() + expect(screen.getByText('https://example.com/rss')).toBeTruthy() + expect(screen.getByRole('switch', { name: 'Existing feed' }).getAttribute('aria-checked')).toBe('false') + expect(screen.getByRole('button', { name: 'Added 财联社 · 电报' }).hasAttribute('disabled')).toBe(true) + fireEvent.change(screen.getByLabelText('RSSHub instance URL'), { target: { value: 'http://localhost:1200' } }) + expect(screen.getByText('https://news.example.com/rsshub/cls/telegraph')).toBeTruthy() + }) + + it('requires an HTTP instance URL without embedded secrets or discarded URL components', () => { + const onChange = vi.fn() + render() + const input = screen.getByLabelText('RSSHub instance URL') + const add = screen.getByRole('button', { name: 'Add 财联社 · 电报' }) + expect(add.hasAttribute('disabled')).toBe(true) + for (const value of ['file:///tmp/rss', 'https://user:secret@example.com', 'https://example.com?key=secret', 'https://example.com#feed']) { + fireEvent.change(input, { target: { value } }) + expect(input.getAttribute('aria-invalid')).toBe('true') + fireEvent.click(add) + } + expect(onChange).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: 'http://localhost:1200' } }) + expect(add.hasAttribute('disabled')).toBe(false) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('does not duplicate or enable a previously configured source on another instance', () => { + const onChange = vi.fn() + render() + fireEvent.change(screen.getByLabelText('RSSHub instance URL'), { target: { value: 'http://localhost:1200' } }) + const add = screen.getByRole('button', { name: 'Added 财联社 · 电报' }) + expect(add.hasAttribute('disabled')).toBe(true) + fireEvent.click(add) + expect(onChange).not.toHaveBeenCalled() + expect(screen.getByRole('switch', { name: 'My CLS' }).getAttribute('aria-checked')).toBe('false') + }) +}) diff --git a/ui/src/pages/NewsCollectorPage.tsx b/ui/src/pages/NewsCollectorPage.tsx index 86d4213124..e9062755b3 100644 --- a/ui/src/pages/NewsCollectorPage.tsx +++ b/ui/src/pages/NewsCollectorPage.tsx @@ -105,6 +105,88 @@ export function isValidFeedUrl(value: string): boolean { } } +const RSSHUB_PRESETS = [ + { name: '财联社 · 电报', source: 'cls', route: 'cls/telegraph', description: 'CLS telegraph news via your RSSHub instance.' }, + { name: '格隆汇 · 实时快讯', source: 'gelonghui', route: 'gelonghui/live', description: 'Gelonghui live news via your RSSHub instance.' }, + { name: '金十数据 · 市场快讯', source: 'jin10', route: 'jin10', description: 'Jin10 market news via your RSSHub instance.' }, +] + +function rssHubBaseUrl(value: string): string | null { + try { + const url = new URL(value.trim()) + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null + return url.href.replace(/\/+$/, '') + '/' + } catch { + return null + } +} + +function RssHubPresets({ feeds, onChange }: { + feeds: NewsCollectorFeed[] + onChange: (feeds: NewsCollectorFeed[]) => void +}) { + const [instance, setInstance] = useState('') + const baseUrl = rssHubBaseUrl(instance) + const invalid = instance.trim().length > 0 && !baseUrl + + return ( +
+

Chinese news via RSSHub

+

+ Use an RSSHub instance reachable from the OpenAlice backend, not just this browser. + OpenAlice does not install RSSHub. Public instances may block requests. + Restart Alice after saving to start collecting the new feeds. +

+ + setInstance(event.target.value)} + placeholder="http://localhost:1200" + aria-invalid={invalid} + aria-describedby={invalid ? 'rsshub-help rsshub-error' : 'rsshub-help'} + /> + {invalid && ( + + )} + +
+ {RSSHUB_PRESETS.map((preset) => { + const added = feeds.some((feed) => feed.source.trim().toLowerCase() === preset.source) + return ( + + ) + })} +
+

+ Each preset saves a normal feed URL. Changing this address does not modify existing feeds; + remove and re-add a preset to move it to another instance. +

+
+ ) +} + export function FeedsSection({ feeds, onChange, @@ -156,6 +238,7 @@ export function FeedsSection({ : 'Add a feed to start collecting articles.' } > + {/* Existing feeds */} {feeds.length > 0 && (