diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs
index 4112d32..2856ab3 100644
--- a/apps/desktop/src/main.rs
+++ b/apps/desktop/src/main.rs
@@ -20,6 +20,7 @@ const HEALTHCHECK_INTERVAL: Duration = Duration::from_millis(250);
fn main() {
tauri::Builder::default()
.manage(BackendState(Mutex::new(None)))
+ .invoke_handler(tauri::generate_handler![open_external_url])
.setup(|app| {
// Anything that can fail is handled here so we never let an error
// propagate out of the setup hook (that would abort() the process
@@ -44,6 +45,31 @@ fn main() {
});
}
+#[tauri::command]
+fn open_external_url(url: String) -> Result<(), String> {
+ let trimmed = url.trim();
+ if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
+ return Err("only HTTP(S) URLs can be opened externally".to_string());
+ }
+
+ let status = if cfg!(target_os = "windows") {
+ Command::new("rundll32")
+ .args(["url.dll,FileProtocolHandler", trimmed])
+ .status()
+ } else if cfg!(target_os = "macos") {
+ Command::new("open").arg(trimmed).status()
+ } else {
+ Command::new("xdg-open").arg(trimmed).status()
+ }
+ .map_err(|err| err.to_string())?;
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(format!("failed to open URL externally: {status}"))
+ }
+}
+
fn start_backend_and_window(app: &tauri::App) -> Result<(), String> {
let port = reserve_port()?;
let mut child = spawn_backend(app.handle(), port)?;
diff --git a/backend/content_cleaner/router.py b/backend/content_cleaner/router.py
index 7dee2cf..087b3df 100644
--- a/backend/content_cleaner/router.py
+++ b/backend/content_cleaner/router.py
@@ -1,7 +1,7 @@
from fastapi import APIRouter
-from .schemas import CleanContentRequest, CleanContentResponse
-from .service import clean_html, clean_stored_article
+from .schemas import CleanContentRequest, CleanContentResponse, WebPageResponse
+from .service import clean_html, clean_stored_article, fetch_entry_web_page
router = APIRouter(prefix="/content", tags=["content"])
@@ -14,3 +14,8 @@ async def clean_content(request: CleanContentRequest) -> CleanContentResponse:
@router.get("/entries/{article_id}/clean", response_model=CleanContentResponse)
async def clean_stored_content(article_id: str) -> CleanContentResponse:
return clean_stored_article(article_id)
+
+
+@router.get("/entries/{article_id}/web", response_model=WebPageResponse)
+async def fetch_stored_web_page(article_id: str) -> WebPageResponse:
+ return fetch_entry_web_page(article_id)
diff --git a/backend/content_cleaner/schemas.py b/backend/content_cleaner/schemas.py
index 218e499..217ceeb 100644
--- a/backend/content_cleaner/schemas.py
+++ b/backend/content_cleaner/schemas.py
@@ -16,3 +16,11 @@ class CleanContentResponse(BaseModel):
content_hash: str | None = None
word_count: int
reading_time_minutes: int
+
+
+class WebPageResponse(BaseModel):
+ article_id: str
+ url: str
+ final_url: str
+ content_type: str | None = None
+ html: str
diff --git a/backend/content_cleaner/service.py b/backend/content_cleaner/service.py
index d7a3c8b..ceb4255 100644
--- a/backend/content_cleaner/service.py
+++ b/backend/content_cleaner/service.py
@@ -3,7 +3,9 @@
import math
import sqlite3
from pathlib import Path
-from urllib.parse import urljoin
+from urllib.error import HTTPError, URLError
+from urllib.parse import urljoin, urlsplit
+from urllib.request import Request, urlopen
from bleach import clean as bleach_clean
from bs4 import BeautifulSoup
@@ -13,7 +15,7 @@
from app.schemas.content import ArticleContent
from db import get_article, get_article_content, save_article_content
-from .schemas import CleanContentRequest, CleanContentResponse
+from .schemas import CleanContentRequest, CleanContentResponse, WebPageResponse
logger = logging.getLogger(__name__)
@@ -102,8 +104,9 @@ def clean_html(request: CleanContentRequest) -> CleanContentResponse:
"""
raw_html = request.raw_html
- # 1. Parse
+ # 1. Parse and prefer the actual article container when the source is a full web page.
soup = BeautifulSoup(raw_html, "lxml")
+ soup = _extract_reader_candidate(soup)
# 2. Sanitize — strip scripts, event handlers, dangerous markup
sanitized = bleach_clean(
@@ -152,7 +155,7 @@ def clean_html(request: CleanContentRequest) -> CleanContentResponse:
def clean_stored_article(article_id: str) -> CleanContentResponse:
- """Read raw HTML from storage, clean it, and persist the result."""
+ """Read article HTML, preferring the canonical URL when feed HTML is only metadata."""
try:
content = get_article_content(article_id)
except sqlite3.OperationalError as err:
@@ -169,10 +172,16 @@ def clean_stored_article(article_id: str) -> CleanContentResponse:
entry = get_article(article_id)
source_url = entry.url if entry else ""
+ raw_html = content.raw_html
+ if entry is not None and _should_fetch_reader_source(content.raw_html):
+ fetched = _try_fetch_web_page(entry.url)
+ if fetched is not None:
+ raw_html = fetched.html
+ source_url = fetched.final_url
result = clean_html(
CleanContentRequest(
- raw_html=content.raw_html,
+ raw_html=raw_html,
source_url=source_url,
article_id=article_id,
)
@@ -180,7 +189,7 @@ def clean_stored_article(article_id: str) -> CleanContentResponse:
save_article_content(
article_id=article_id,
- raw_html=content.raw_html,
+ raw_html=raw_html,
cleaned_html=result.cleaned_html,
cleaned_markdown=result.cleaned_markdown,
plain_text=result.plain_text,
@@ -195,6 +204,69 @@ def clean_stored_article(article_id: str) -> CleanContentResponse:
return result
+def fetch_entry_web_page(article_id: str, timeout_seconds: float = 15.0) -> WebPageResponse:
+ """Fetch the article's canonical URL and return HTML suitable for iframe srcDoc."""
+ try:
+ entry = get_article(article_id)
+ except sqlite3.OperationalError as err:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Article '{article_id}' not found",
+ ) from err
+
+ if entry is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Article '{article_id}' not found",
+ )
+
+ fetched = _try_fetch_web_page(entry.url, timeout_seconds=timeout_seconds)
+ if fetched is None:
+ raise HTTPException(status_code=502, detail="Web page request failed")
+
+ html = _inject_base_url(fetched.html, fetched.final_url)
+ return WebPageResponse(
+ article_id=article_id,
+ url=fetched.url,
+ final_url=fetched.final_url,
+ content_type=fetched.content_type,
+ html=html,
+ )
+
+
+def _try_fetch_web_page(url: str, timeout_seconds: float = 15.0) -> WebPageResponse | None:
+ url = _validate_web_url(url)
+ headers = {
+ "User-Agent": "Lumen/0.1 web-preview",
+ "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ }
+ request = Request(url, headers=headers)
+
+ try:
+ with urlopen(request, timeout=timeout_seconds) as response:
+ body = response.read()
+ content_type = response.headers.get("Content-Type")
+ final_url = response.url
+ except HTTPError as exc:
+ logger.info("Web page server returned HTTP %s for %s", exc.code, url)
+ return None
+ except TimeoutError:
+ logger.info("Web page request timed out for %s", url)
+ return None
+ except (URLError, OSError) as exc:
+ logger.info("Web page request failed for %s: %s", url, exc)
+ return None
+
+ html = _decode_response_body(body, content_type)
+ return WebPageResponse(
+ article_id="",
+ url=url,
+ final_url=final_url,
+ content_type=content_type,
+ html=html,
+ )
+
+
def load_article_content(
article_id: str,
db_path: Path | str | None = None,
@@ -224,6 +296,74 @@ def save_cleaned_article_content(
# ── Private helpers ──────────────────────────────────────────────
+def _should_fetch_reader_source(raw_html: str) -> bool:
+ text = BeautifulSoup(raw_html or "", "lxml").get_text(separator="\n", strip=True)
+ if not text:
+ return True
+
+ lower = text.lower()
+ hnrss_markers = [
+ "article url:",
+ "comments url:",
+ "points:",
+ "# comments:",
+ ]
+ if sum(1 for marker in hnrss_markers if marker in lower) >= 2:
+ return True
+
+ return False
+
+def _validate_web_url(url: str) -> str:
+ value = url.strip()
+ parts = urlsplit(value)
+ if parts.scheme not in {"http", "https"} or not parts.netloc:
+ raise HTTPException(status_code=409, detail="Article URL is not an absolute HTTP(S) URL")
+ return value
+
+
+def _decode_response_body(body: bytes, content_type: str | None) -> str:
+ encoding = "utf-8"
+ if content_type:
+ for part in content_type.split(";"):
+ key, _, value = part.strip().partition("=")
+ if key.lower() == "charset" and value.strip():
+ encoding = value.strip().strip('"')
+ break
+ try:
+ return body.decode(encoding, errors="replace")
+ except LookupError:
+ return body.decode("utf-8", errors="replace")
+
+
+def _inject_base_url(html: str, final_url: str) -> str:
+ soup = BeautifulSoup(html, "lxml")
+ base = soup.new_tag("base", href=final_url)
+ if soup.head:
+ existing = soup.head.find("base")
+ if existing:
+ existing["href"] = final_url
+ else:
+ soup.head.insert(0, base)
+ elif soup.html:
+ head = soup.new_tag("head")
+ head.append(base)
+ soup.html.insert(0, head)
+ else:
+ return f'\n{html}'
+ return str(soup)
+
+
+def _extract_reader_candidate(soup: BeautifulSoup) -> BeautifulSoup:
+ for selector in ("article", "main", "[role='main']"):
+ candidate = soup.select_one(selector)
+ if candidate is not None and candidate.get_text(strip=True):
+ return BeautifulSoup(str(candidate), "lxml")
+
+ for tag in soup.select("script, style, noscript, nav, header, footer, aside"):
+ tag.decompose()
+ return soup
+
+
def _count_words(text: str) -> int:
"""Count words in plain text by splitting on whitespace."""
return len(text.split()) if text.strip() else 0
diff --git a/backend/db/repositories/article_repo.py b/backend/db/repositories/article_repo.py
index 59daa24..83d92bd 100644
--- a/backend/db/repositories/article_repo.py
+++ b/backend/db/repositories/article_repo.py
@@ -151,7 +151,10 @@ def list_articles(
if where_clauses:
sql += " WHERE " + " AND ".join(where_clauses)
- sql += " ORDER BY articles.published_at DESC, articles.created_at DESC LIMIT ? OFFSET ?"
+ sql += """
+ ORDER BY articles.published_at DESC, articles.created_at DESC, articles.id ASC
+ LIMIT ? OFFSET ?
+ """
params.extend([limit, offset])
with connection(db_path) as conn:
@@ -351,7 +354,7 @@ def search_articles(
LIMIT 1
)
WHERE article_fts MATCH ?
- ORDER BY articles.published_at DESC, articles.created_at DESC
+ ORDER BY articles.published_at DESC, articles.created_at DESC, articles.id ASC
LIMIT ? OFFSET ?
"""
with connection(db_path) as conn:
@@ -406,7 +409,7 @@ def search_articles(
WHERE articles.title LIKE ?
OR articles.summary LIKE ?
OR article_search.plain_text LIKE ?
- ORDER BY articles.published_at DESC, articles.created_at DESC
+ ORDER BY articles.published_at DESC, articles.created_at DESC, articles.id ASC
LIMIT ? OFFSET ?
""",
(like_keyword, like_keyword, like_keyword, limit, offset),
diff --git a/backend/tests/test_content_cleaner.py b/backend/tests/test_content_cleaner.py
index 1d68161..f3fcd09 100644
--- a/backend/tests/test_content_cleaner.py
+++ b/backend/tests/test_content_cleaner.py
@@ -497,3 +497,186 @@ def test_clean_stored_article_empty_body(
assert data["word_count"] == 0
assert data["reading_time_minutes"] == 1 # floor
assert data["plain_text"] == ""
+
+ def test_clean_stored_article_fetches_full_page_for_hnrss_metadata(
+ self, client: TestClient, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ import content_cleaner.service as cleaner_service
+ from app.schemas.content import ArticleContent
+ from app.schemas.entry import Entry
+
+ fake_content = ArticleContent(
+ article_id="art-hn",
+ raw_html="""
+
Article URL: https://example.com/full
+ Comments URL: HN
+ Points: 74
+ # Comments: 17
+ """,
+ cleaned_html="",
+ cleaned_markdown="",
+ plain_text="",
+ )
+ fake_entry = Entry(
+ id="art-hn",
+ feed_id="feed-001",
+ title="HN Article",
+ summary="",
+ author="",
+ url="https://example.com/full",
+ published_at="2026-01-01T00:00:00Z",
+ is_read=False,
+ is_starred=False,
+ tag_ids=[],
+ reader_html="",
+ web_preview="",
+ related_entry_ids=[],
+ note="",
+ summary_text="",
+ translation_html=None,
+ translation_status="idle",
+ )
+
+ class FakeHeaders:
+ def get(self, name: str) -> str | None:
+ return "text/html; charset=utf-8" if name == "Content-Type" else None
+
+ class FakeResponse:
+ url = "https://example.com/full"
+ headers = FakeHeaders()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args: object) -> None:
+ return None
+
+ def read(self) -> bytes:
+ return (
+ b""
+ b"Real article title
Real article body text.
"
+ b""
+ )
+
+ saved: dict = {}
+ monkeypatch.setattr(
+ cleaner_service,
+ "get_article_content",
+ lambda aid: fake_content if aid == "art-hn" else None,
+ )
+ monkeypatch.setattr(
+ cleaner_service,
+ "get_article",
+ lambda aid: fake_entry if aid == "art-hn" else None,
+ )
+ monkeypatch.setattr(cleaner_service, "urlopen", lambda *args, **kwargs: FakeResponse())
+ monkeypatch.setattr(cleaner_service, "save_article_content", lambda **kw: saved.update(kw))
+
+ response = client.get("/content/entries/art-hn/clean")
+ assert response.status_code == 200
+ data = response.json()
+ assert "Real article title" in data["cleaned_html"]
+ assert "Real article body text." in data["cleaned_markdown"]
+ assert "Article URL:" not in data["plain_text"]
+ assert "Real article title" in saved["raw_html"]
+
+
+class TestFetchStoredWebPageEndpoint:
+ def test_get_nonexistent_article_returns_404(self, client: TestClient) -> None:
+ response = client.get("/content/entries/nonexistent-id/web")
+ assert response.status_code == 404
+
+ def test_rejects_non_http_article_url(
+ self, client: TestClient, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ import content_cleaner.service as cleaner_service
+ from app.schemas.entry import Entry
+
+ fake_entry = Entry(
+ id="art-file",
+ feed_id="feed-001",
+ title="Local Article",
+ summary="",
+ author="",
+ url="file:///tmp/article.html",
+ published_at="2026-01-01T00:00:00Z",
+ is_read=False,
+ is_starred=False,
+ tag_ids=[],
+ reader_html="",
+ web_preview="",
+ related_entry_ids=[],
+ note="",
+ summary_text="",
+ translation_html=None,
+ translation_status="idle",
+ )
+
+ monkeypatch.setattr(
+ cleaner_service,
+ "get_article",
+ lambda aid: fake_entry if aid == "art-file" else None,
+ )
+
+ response = client.get("/content/entries/art-file/web")
+ assert response.status_code == 409
+
+ def test_fetches_html_and_injects_base_url(
+ self, client: TestClient, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ import content_cleaner.service as cleaner_service
+ from app.schemas.entry import Entry
+
+ fake_entry = Entry(
+ id="art-web",
+ feed_id="feed-001",
+ title="Web Article",
+ summary="",
+ author="",
+ url="https://example.com/articles/one",
+ published_at="2026-01-01T00:00:00Z",
+ is_read=False,
+ is_starred=False,
+ tag_ids=[],
+ reader_html="",
+ web_preview="",
+ related_entry_ids=[],
+ note="",
+ summary_text="",
+ translation_html=None,
+ translation_status="idle",
+ )
+
+ class FakeHeaders:
+ def get(self, name: str) -> str | None:
+ return "text/html; charset=utf-8" if name == "Content-Type" else None
+
+ class FakeResponse:
+ url = "https://example.com/articles/one?utm=no"
+ headers = FakeHeaders()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args: object) -> None:
+ return None
+
+ def read(self) -> bytes:
+ return (
+ b"Hi"
+ b"
"
+ )
+
+ monkeypatch.setattr(
+ cleaner_service,
+ "get_article",
+ lambda aid: fake_entry if aid == "art-web" else None,
+ )
+ monkeypatch.setattr(cleaner_service, "urlopen", lambda *args, **kwargs: FakeResponse())
+
+ response = client.get("/content/entries/art-web/web")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["article_id"] == "art-web"
+ assert data["final_url"] == "https://example.com/articles/one?utm=no"
+ assert '' in data["html"]
diff --git a/packages/ipc-client/src/content.ts b/packages/ipc-client/src/content.ts
index f722b17..d1d9235 100644
--- a/packages/ipc-client/src/content.ts
+++ b/packages/ipc-client/src/content.ts
@@ -5,3 +5,7 @@ import type { IpcClient } from "./index";
export function cleanStoredContent(client: IpcClient, articleId: string) {
return client.request("GET", `/content/entries/${articleId}/clean`);
}
+
+export function getEntryWebPage(client: IpcClient, articleId: string) {
+ return client.request("GET", `/content/entries/${articleId}/web`);
+}
diff --git a/packages/shared-types/src/generated.ts b/packages/shared-types/src/generated.ts
index 7b4e278..cbb2a95 100644
--- a/packages/shared-types/src/generated.ts
+++ b/packages/shared-types/src/generated.ts
@@ -158,37 +158,97 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/providers": {
+ "/providers/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- /** Get Providers */
- get: operations["get_providers_providers_get"];
+ /**
+ * Get Providers
+ * @description 获取所有提供商列表(不包含敏感信息)
+ */
+ get: operations["get_providers_providers__get"];
put?: never;
- /** Create Provider */
- post: operations["create_provider_providers_post"];
+ /**
+ * Create Provider
+ * @description 创建新的提供商
+ */
+ post: operations["create_provider_providers__post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/providers/{provider_name}": {
+ "/providers/{name}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- get?: never;
- /** Edit Provider */
- put: operations["edit_provider_providers__provider_name__put"];
+ /**
+ * Get Provider By Name
+ * @description 获取指定提供商
+ */
+ get: operations["get_provider_by_name_providers__name__get"];
+ /**
+ * Update Provider By Name
+ * @description 更新提供商配置
+ */
+ put: operations["update_provider_by_name_providers__name__put"];
post?: never;
- /** Delete Provider */
- delete: operations["delete_provider_providers__provider_name__delete"];
+ /**
+ * Delete Provider
+ * @description 删除提供商
+ */
+ delete: operations["delete_provider_providers__name__delete"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/providers/default": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Default Provider
+ * @description 获取默认提供商
+ */
+ get: operations["get_default_provider_providers_default_get"];
+ put?: never;
+ /**
+ * Set Default Provider
+ * @description 设置默认提供商
+ */
+ post: operations["set_default_provider_providers_default_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/providers/test/{name}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Test Provider
+ * @description 测试提供商连接
+ */
+ post: operations["test_provider_providers_test__name__post"];
+ delete?: never;
options?: never;
head?: never;
patch?: never;
@@ -297,6 +357,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/content/entries/{article_id}/web": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Fetch Stored Web Page */
+ get: operations["fetch_stored_web_page_content_entries__article_id__web_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/agents/summary/generate": {
parameters: {
query?: never;
@@ -428,6 +505,32 @@ export interface components {
/** Reading Time Minutes */
reading_time_minutes: number;
};
+ /** CreateProviderRequest */
+ CreateProviderRequest: {
+ /** Name */
+ name: string;
+ /**
+ * Kind
+ * @default openai_compatible
+ */
+ kind: string;
+ /**
+ * Model
+ * @default
+ */
+ model: string;
+ /** Base Url */
+ base_url?: string | null;
+ /** Api Key */
+ api_key?: string | null;
+ /** Api Key Header */
+ api_key_header?: string | null;
+ /**
+ * Is Default
+ * @default false
+ */
+ is_default: boolean;
+ };
/** Entry */
Entry: {
/** Id */
@@ -600,8 +703,8 @@ export interface components {
* @enum {string}
*/
ProviderKind: "openai_compatible" | "anthropic" | "ollama";
- /** ProviderSummaryResponse */
- ProviderSummaryResponse: {
+ /** ProviderSummary */
+ ProviderSummary: {
/** Name */
name: string;
kind: components["schemas"]["ProviderKind"];
@@ -622,29 +725,10 @@ export interface components {
*/
has_api_key: boolean;
};
- /** ProviderUpsertRequest */
- ProviderUpsertRequest: {
+ /** SetDefaultRequest */
+ SetDefaultRequest: {
/** Name */
name: string;
- kind: components["schemas"]["ProviderKind"];
- /** Model */
- model: string;
- /** Base Url */
- base_url?: string | null;
- /** Api Key */
- api_key?: string | null;
- /** Api Key Header */
- api_key_header?: string | null;
- /**
- * Is Default
- * @default false
- */
- is_default: boolean;
- /**
- * Clear Api Key
- * @default false
- */
- clear_api_key: boolean;
};
/** SubscribeFeedRequest */
SubscribeFeedRequest: {
@@ -681,6 +765,40 @@ export interface components {
/** Model */
model: string;
};
+ /** SyncResult */
+ SyncResult: {
+ /** Feed Id */
+ feed_id: string;
+ /** Status */
+ status: string;
+ /** Fetched */
+ fetched: number;
+ /** Saved */
+ saved: number;
+ /**
+ * Skipped
+ * @default 0
+ */
+ skipped: number;
+ /**
+ * Not Modified
+ * @default false
+ */
+ not_modified: boolean;
+ };
+ /** Tag */
+ Tag: {
+ /** Id */
+ id: string;
+ /** Name */
+ name: string;
+ /** Aliases */
+ aliases: string[];
+ /** Usage Count */
+ usage_count: number;
+ /** Unread Count */
+ unread_count: number;
+ };
/** TranslationRequest */
TranslationRequest: {
/** Entry Id */
@@ -710,59 +828,22 @@ export interface components {
/** Model */
model: string;
};
- /** ProviderSummary */
- ProviderSummary: {
+ /** UpdateProviderRequest */
+ UpdateProviderRequest: {
/** Name */
name: string;
- /**
- * Kind
- * @enum {string}
- */
- kind: "openai_compatible" | "anthropic" | "ollama";
+ /** Kind */
+ kind?: string | null;
/** Model */
- model: string;
+ model?: string | null;
/** Base Url */
base_url?: string | null;
+ /** Api Key */
+ api_key?: string | null;
/** Api Key Header */
api_key_header?: string | null;
/** Is Default */
- is_default: boolean;
- /** Has Api Key */
- has_api_key: boolean;
- };
- /** SyncResult */
- SyncResult: {
- /** Feed Id */
- feed_id: string;
- /** Status */
- status: string;
- /** Fetched */
- fetched: number;
- /** Saved */
- saved: number;
- /**
- * Skipped
- * @default 0
- */
- skipped: number;
- /**
- * Not Modified
- * @default false
- */
- not_modified: boolean;
- };
- /** Tag */
- Tag: {
- /** Id */
- id: string;
- /** Name */
- name: string;
- /** Aliases */
- aliases: string[];
- /** Usage Count */
- usage_count: number;
- /** Unread Count */
- unread_count: number;
+ is_default?: boolean | null;
};
/** ValidationError */
ValidationError: {
@@ -777,6 +858,19 @@ export interface components {
/** Context */
ctx?: Record;
};
+ /** WebPageResponse */
+ WebPageResponse: {
+ /** Article Id */
+ article_id: string;
+ /** Url */
+ url: string;
+ /** Final Url */
+ final_url: string;
+ /** Content Type */
+ content_type?: string | null;
+ /** Html */
+ html: string;
+ };
};
responses: never;
parameters: never;
@@ -1058,7 +1152,7 @@ export interface operations {
};
};
};
- get_providers_providers_get: {
+ get_providers_providers__get: {
parameters: {
query?: never;
header?: never;
@@ -1073,12 +1167,12 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ProviderSummaryResponse"][];
+ "application/json": components["schemas"]["ProviderSummary"][];
};
};
};
};
- create_provider_providers_post: {
+ create_provider_providers__post: {
parameters: {
query?: never;
header?: never;
@@ -1087,7 +1181,7 @@ export interface operations {
};
requestBody: {
content: {
- "application/json": components["schemas"]["ProviderUpsertRequest"];
+ "application/json": components["schemas"]["CreateProviderRequest"];
};
};
responses: {
@@ -1097,7 +1191,9 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ProviderSummaryResponse"];
+ "application/json": {
+ [key: string]: unknown;
+ };
};
};
/** @description Validation Error */
@@ -1111,18 +1207,49 @@ export interface operations {
};
};
};
- edit_provider_providers__provider_name__put: {
+ get_provider_by_name_providers__name__get: {
parameters: {
query?: never;
header?: never;
path: {
- provider_name: string;
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ProviderSummary"] | null;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ update_provider_by_name_providers__name__put: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
};
cookie?: never;
};
requestBody: {
content: {
- "application/json": components["schemas"]["ProviderUpsertRequest"];
+ "application/json": components["schemas"]["UpdateProviderRequest"];
};
};
responses: {
@@ -1132,7 +1259,9 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ProviderSummaryResponse"];
+ "application/json": {
+ [key: string]: unknown;
+ };
};
};
/** @description Validation Error */
@@ -1146,12 +1275,12 @@ export interface operations {
};
};
};
- delete_provider_providers__provider_name__delete: {
+ delete_provider_providers__name__delete: {
parameters: {
query?: never;
header?: never;
path: {
- provider_name: string;
+ name: string;
};
cookie?: never;
};
@@ -1164,7 +1293,95 @@ export interface operations {
};
content: {
"application/json": {
- [key: string]: boolean;
+ [key: string]: unknown;
+ };
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_default_provider_providers_default_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ProviderSummary"] | null;
+ };
+ };
+ };
+ };
+ set_default_provider_providers_default_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["SetDefaultRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ [key: string]: unknown;
+ };
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ test_provider_providers_test__name__post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ [key: string]: unknown;
};
};
};
@@ -1409,6 +1626,37 @@ export interface operations {
};
};
};
+ fetch_stored_web_page_content_entries__article_id__web_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ article_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["WebPageResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
generate_summary_agents_summary_generate_post: {
parameters: {
query?: never;
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index afbefa5..348f3b8 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -57,6 +57,7 @@ import {
getApiErrorMessage,
requestTranslation
} from "./services/api";
+import { openExternalUrl } from "./services/desktop";
import { readStoredBoolean, readStoredNumber, writeStoredBoolean, writeStoredNumber } from "./services/storage";
const maxSidebarTags = 5;
@@ -1002,7 +1003,45 @@ function ReaderDetail(props: {
const articleHtml = translationMode === "translation" && entry.translationHtml
? renderBilingualMarkdown(entry.translationHtml)
: entry.readerHtml;
- const webHtml = entry.webPreview.trim().startsWith("<") ? entry.webPreview : articleHtml;
+ const webUrl = normalizeWebUrl(entry.url);
+
+ function openEntryExternally() {
+ if (!webUrl) {
+ props.onNotice(t("webPageUnavailable"));
+ return;
+ }
+ void openExternalUrl(webUrl).catch((error) => {
+ props.onNotice(error instanceof Error ? error.message : t("requestFailed"));
+ });
+ }
+
+ function handleReaderArticleClick(event: React.MouseEvent) {
+ const target = event.target;
+ if (!(target instanceof Element)) {
+ return;
+ }
+
+ const link = target.closest("a[href]");
+ if (!(link instanceof HTMLAnchorElement) || !event.currentTarget.contains(link)) {
+ return;
+ }
+
+ const href = link.getAttribute("href")?.trim();
+ if (!href || href.startsWith("#")) {
+ return;
+ }
+
+ event.preventDefault();
+ const resolvedUrl = resolveExternalUrl(href, webUrl ?? "");
+ if (!resolvedUrl) {
+ props.onNotice(t("webPageUnavailable"));
+ return;
+ }
+
+ void openExternalUrl(resolvedUrl).catch((error) => {
+ props.onNotice(error instanceof Error ? error.message : t("requestFailed"));
+ });
+ }
async function toggleTranslation() {
if (!entry) {
@@ -1137,7 +1176,7 @@ function ReaderDetail(props: {
icon={}
items={[
{ label: t("copyLink"), action: () => void navigator.clipboard?.writeText(entry.url) },
- { label: t("openBrowser"), action: () => window.open(entry.url, "_blank", "noopener") },
+ { label: t("openBrowser"), action: openEntryExternally },
{ label: t("shareDigest"), action: () => props.onModal({ type: "shareDigest", entryId: entry.id, exportMode: "share" }) },
{ label: t("exportDigest"), action: () => props.onModal({ type: "shareDigest", entryId: entry.id, exportMode: "single" }) }
]}
@@ -1187,6 +1226,7 @@ function ReaderDetail(props: {
{showReader && (
void navigator.clipboard?.writeText(entry.url)} title={t("copyUrl")}>
- {entry.url}
+ {webUrl}
+
-
+ {webUrl ? (
+
+ ) : (
+
+
+
{t("webPageUnavailable")}
+
+
+ )}
)}
@@ -2326,6 +2377,28 @@ function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
+function normalizeWebUrl(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+ try {
+ const url = new URL(trimmed);
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
+ } catch {
+ return null;
+ }
+}
+
+function resolveExternalUrl(href: string, baseUrl: string): string | null {
+ try {
+ const url = new URL(href, baseUrl);
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
+ } catch {
+ return null;
+ }
+}
+
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
diff --git a/packages/ui/src/i18n/messages.ts b/packages/ui/src/i18n/messages.ts
index f8fa05f..ab3c4a0 100644
--- a/packages/ui/src/i18n/messages.ts
+++ b/packages/ui/src/i18n/messages.ts
@@ -53,6 +53,9 @@ const en: Messages = {
web: "Web",
dual: "Dual",
copyUrl: "Copy URL",
+ loadingWebPage: "Loading original web page...",
+ webPageFailed: "Original web page failed to load.",
+ webPageUnavailable: "No valid article URL.",
relatedContent: "Related",
hideRelated: "Hide related content",
showRelated: "Show related content",
@@ -223,6 +226,9 @@ const zhHans: Messages = {
web: "网页",
dual: "双栏",
copyUrl: "复制 URL",
+ loadingWebPage: "正在加载原始网页...",
+ webPageFailed: "原始网页加载失败。",
+ webPageUnavailable: "没有可用的文章 URL。",
relatedContent: "相关内容",
hideRelated: "隐藏相关内容",
showRelated: "显示相关内容",
diff --git a/packages/ui/src/services/api/index.ts b/packages/ui/src/services/api/index.ts
index f467c5b..c94e24c 100644
--- a/packages/ui/src/services/api/index.ts
+++ b/packages/ui/src/services/api/index.ts
@@ -12,6 +12,7 @@ import {
getTags,
importOpml,
IpcError,
+ getEntryWebPage,
setDefaultProvider as setDefaultProviderClient,
setEntryReadState,
setEntryStarState,
@@ -92,6 +93,10 @@ export async function ensureEntryContent(entryId: string): Promise {
+ return getEntryWebPage(mercuryClient, entryId);
+}
+
export async function getProviders() {
return getProvidersClient(mercuryClient);
}
diff --git a/packages/ui/src/services/desktop.ts b/packages/ui/src/services/desktop.ts
new file mode 100644
index 0000000..47d7ae8
--- /dev/null
+++ b/packages/ui/src/services/desktop.ts
@@ -0,0 +1,30 @@
+type TauriInternals = {
+ invoke?: (command: string, args?: Record) => Promise;
+};
+
+type TauriWindow = Window & {
+ __TAURI_INTERNALS__?: TauriInternals;
+ __TAURI__?: {
+ core?: {
+ invoke?: (command: string, args?: Record) => Promise;
+ };
+ };
+};
+
+export async function openExternalUrl(url: string): Promise {
+ const invoke = getTauriInvoke();
+ if (invoke) {
+ await invoke("open_external_url", { url });
+ return;
+ }
+
+ const opened = window.open(url, "_blank", "noopener,noreferrer");
+ if (!opened) {
+ window.location.assign(url);
+ }
+}
+
+function getTauriInvoke(): ((command: string, args?: Record) => Promise) | null {
+ const tauriWindow = window as TauriWindow;
+ return tauriWindow.__TAURI__?.core?.invoke ?? tauriWindow.__TAURI_INTERNALS__?.invoke ?? null;
+}
diff --git a/packages/ui/src/styles/app.css b/packages/ui/src/styles/app.css
index 6139f4f..3b42182 100644
--- a/packages/ui/src/styles/app.css
+++ b/packages/ui/src/styles/app.css
@@ -653,6 +653,8 @@ textarea {
}
.web-url-bar span {
+ flex: 1;
+ min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -682,6 +684,19 @@ textarea {
margin: 0;
}
+.web-preview-state {
+ flex: 1;
+ height: auto;
+}
+
+.web-preview-frame {
+ flex: 1;
+ width: 100%;
+ min-height: 0;
+ border: 0;
+ background: #ffffff;
+}
+
.web-preview-article {
flex: 1;
min-width: 0;