dsh-rss 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.6.0 (2026-10-04)
8
+
9
+ - 新增推荐订阅目录:16 个网站原生 RSS/Atom,提供五类主题、关键词与内容语言筛选、近期文章预览、可用性检查和「已订阅」状态。浏览目录不会自动订阅或抓取全部来源。
10
+ - 保留「输入地址」和 OPML 导入入口;分类可选择或自定义,已有名称和分类原样保留。预览与订阅共用原来的网络保护与订阅存储。
11
+ - 本次真实网络读取通过 16 个来源;可用性仍取决于各用户网络。有效但暂时空的来源会明确解释;失败时保留输入。
12
+ - 验收范围见 [0.6.0 推荐源检查](docs/validation-0.6.0.md)。
13
+
7
14
  ## 0.5.2 (2026-10-03)
8
15
 
9
16
  - 修复语言切换时菜单滞后一轮;订阅检查、OPML 跳过原因与后台错误引导支持英文,订阅名称和文章保留原文。
package/README.en.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [中文](README.md)
4
4
 
5
+ ![dsh-rss whale girl plugin cover](https://raw.githubusercontent.com/STARDUSTLC666/dsh-rss/master/assets/cover-whale-girl.png)
6
+
5
7
  Subscribe to and read RSS or Atom feeds, and manage updates from DSH.
6
8
 
7
9
  [![npm](https://img.shields.io/npm/v/dsh-rss)](https://www.npmjs.com/package/dsh-rss) [![downloads](https://img.shields.io/npm/dm/dsh-rss)](https://www.npmjs.com/package/dsh-rss)
@@ -9,6 +11,7 @@ Subscribe to and read RSS or Atom feeds, and manage updates from DSH.
9
11
  ## What it does
10
12
 
11
13
  - Add feeds, read articles and fetch updates.
14
+ - Choose from five catalog categories, preview recent articles and subscribe, or enter a URL.
12
15
  - Import or export OPML and retain article content.
13
16
  - Use a feed panel and optional plugin-level proxy configuration.
14
17
 
@@ -24,7 +27,7 @@ For the web version, replace `desktop` with `web`. Restart DSH after installatio
24
27
 
25
28
  ## Start using it
26
29
 
27
- Add a feed in Settings → RSS subscriptions, or ask to subscribe to a feed URL and summarize recent articles.
30
+ Open Settings → RSS subscriptions → Recommended feeds, choose a topic, preview articles and subscribe. You can also enter your own feed URL or ask to subscribe and summarize recent articles.
28
31
 
29
32
  ## Requirements and configuration
30
33
 
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [English](README.en.md)
4
4
 
5
+ ![dsh-rss 鲸鱼娘插件封面](https://raw.githubusercontent.com/STARDUSTLC666/dsh-rss/master/assets/cover-whale-girl.png)
6
+
5
7
  订阅和阅读 RSS / Atom,在 DSH 中查看与管理更新。
6
8
 
7
9
  [![npm](https://img.shields.io/npm/v/dsh-rss)](https://www.npmjs.com/package/dsh-rss) [![downloads](https://img.shields.io/npm/dm/dsh-rss)](https://www.npmjs.com/package/dsh-rss)
@@ -9,6 +11,7 @@
9
11
  ## 功能
10
12
 
11
13
  - 添加订阅、查看文章和抓取更新。
14
+ - 从五类推荐目录选择来源,预览近期文章后订阅;也可输入地址或导入 OPML。
12
15
  - 导入、导出 OPML,保留文章正文。
13
16
  - 可视化订阅面板,支持插件级网络代理。
14
17
 
@@ -24,7 +27,7 @@ dsh plugin --profile desktop add dsh-rss
24
27
 
25
28
  ## 开始使用
26
29
 
27
- 打开「设置 → RSS 订阅」添加订阅源,或说:“订阅这个 RSS 地址并总结最新文章。”
30
+ 打开「设置 → RSS 订阅 → 推荐订阅源」,选择感兴趣的分类,预览后点击订阅;也可以输入自己的地址,或说:“订阅这个 RSS 地址并总结最新文章。”
28
31
 
29
32
  ## 依赖与配置
30
33
 
package/docs/USAGE.en.md CHANGED
@@ -14,12 +14,18 @@ For the desktop profile, use `dsh plugin --profile desktop add dsh-rss` and reop
14
14
 
15
15
  ## Subscription panel
16
16
 
17
- Open **Settings → RSS subscriptions**. New URLs are fetched and checked before saving; the name and category are optional. Renaming or recategorizing an unchanged URL works without the remote feed being online.
17
+ Open **Settings → RSS subscriptions** and choose Recommended feeds, Enter a URL, or Import OPML.
18
+
19
+ The catalog lists 16 publisher-hosted RSS/Atom feeds in Technology news, AI and research, Software updates, Reading and blogs, and Life and interests. Filter by source, keywords and content language. Cards link to the publisher and show the feed URL. Preview and check fetches only the selected source and shows up to five recent articles. Subscribe checks the feed before saving, and existing URLs show Subscribed. Browsing the catalog does not subscribe, fetch every site, or rename or recategorize existing subscriptions. Feeds such as arXiv can be valid but empty on non-publication days.
20
+
21
+ Enter a URL accepts your own RSS/Atom address; the name and category are optional. Pick an existing or suggested category, or type your own. New URLs are fetched and checked before saving. Renaming or recategorizing an unchanged URL works without the remote feed being online.
18
22
 
19
23
  Choose an OPML file (up to 1 MiB) or paste its text, review the preview, then confirm. Rejected URLs include a reason. If another page or tool changed the list, preview again without retyping. Export downloads OPML without changing subscriptions.
20
24
 
21
25
  Cards show the most recent check/fetch time, article count or error during this run. Read-only configurations allow viewing, checking and exporting.
22
26
 
27
+ Feeds come from publishers or an instance you choose, rather than being produced by the plugin. For sites without a native feed, paste a complete URL generated by your chosen [RSSHub instance](https://github.com/DIYgod/RSSHub). Catalog entries do not need RSSHub. An ordinary webpage URL is not a feed URL. Reachability depends on the publisher and your current network; the plugin does not require a VPN. Retry an unavailable source or choose another one. Failed saves preserve your input.
28
+
23
29
  ## Uninstall
24
30
 
25
31
  ```bash
package/docs/USAGE.md CHANGED
@@ -14,12 +14,18 @@ dsh plugin --profile web add dsh-rss
14
14
 
15
15
  ## 订阅面板
16
16
 
17
- 打开 **设置 → RSS 订阅**。添加时填写 RSS/Atom 地址,名称可留空,分类可选。新地址通过抓取检查后才会保存;编辑原地址的名称或分类不需要源网站在线。
17
+ 打开 **设置 → RSS 订阅**,提供「推荐订阅源」「输入地址」「导入 OPML」三个入口。
18
+
19
+ 推荐目录收录 16 个网站原生 RSS/Atom,按科技资讯、AI 与科研、软件更新、阅读与博客、生活与兴趣分类。可按名称、关键词和内容语言筛选;每个来源可查看原网站与订阅地址。「预览并检查」只抓取所选来源,显示最多 5 篇近期文章;「订阅」通过抓取验证后才保存,已添加的地址显示「已订阅」。目录不会自动订阅、批量抓取或改动原有名称和分类。arXiv 等按发布日更新的源可能有效但没有当前条目。
20
+
21
+ 选择「输入地址」填写自己的 RSS/Atom 地址,名称可留空。分类可选择目录提供的分类、现有分类,也可直接输入自定义分类。新地址通过抓取检查后才会保存;编辑原地址的名称或分类不需要源网站在线。
18
22
 
19
23
  导入可选择不超过 1 MiB 的 OPML 文件或粘贴文本。先检查预览,再点击确认;跳过的地址会说明原因。已有订阅发生变化时重新预览即可,原输入仍保留。导出通过浏览器下载 OPML,不会修改订阅。
20
24
 
21
25
  列表卡片显示最近一次手动检查或工具抓取的时间和文章数,失败时显示具体原因。只读配置仍可查看、检查和导出。
22
26
 
27
+ RSS 来自网站或用户选择的实例,不由插件自己生产。网站没有原生源时,可粘贴你选择的 [RSSHub 实例](https://github.com/DIYgod/RSSHub) 生成的完整地址;目录中的来源不依赖 RSSHub。不要把普通网页地址当作 RSS 地址。可用性取决于来源网站与用户当前网络,插件不要求使用 VPN;当前网络无法访问某个网站时,可重试或改选其他源。保存失败时不会清空输入。
28
+
23
29
  ## 卸载
24
30
 
25
31
  ```bash
@@ -1,5 +1,7 @@
1
1
  # dsh-rss 验证记录
2
2
 
3
+ [0.6.0 推荐订阅源验收](validation-0.6.0.md)。
4
+
3
5
  [0.5.2 中英文界面验收](validation-language-2026-10-03.md)。
4
6
 
5
7
  本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
@@ -0,0 +1,11 @@
1
+ # 0.6.0 推荐订阅源验收 · 2026-10-04
2
+
3
+ Windows / Node 24.16.0:110 项测试通过。新增回归验证浏览目录不联网或改写订阅、按规范化 URL 标记已订阅、推荐预览不写入订阅、请求地址锁定到选定的推荐源、最多返回五篇且仅允许无凭据的 HTTP(S) 文章链接,以及离线预览/订阅失败不写入数据。原有 OPML、取消、并发保存和网络保护回归均通过。
4
+
5
+ 使用插件真实抓取与解析函数、原生 DNS、默认公网保护,读取下列 16 个发布方提供的 RSS/Atom 地址成功:IT之家、少数派、爱范儿、Hacker News、arXiv AI、Hugging Face、Google DeepMind、小众软件、DeepSeek Harness releases、Python Insider、Rust、Node.js、月光博客、Simon Willison、NASA、xkcd。arXiv 本次有效但条目数为零。另一个候选阮一峰返回 HTTP 403,未放入推荐目录。
6
+
7
+ 网络验收保留用户当前 VPN/系统代理,未注入测试 DNS、网络响应或解析结果。成功表示本次环境可读取,不保证其他网络均可访问;插件无需强制使用 VPN。推荐源直接来自发布方,不依赖 RSSHub 实例。
8
+
9
+ 官方源码构建的 DSH 0.2.0-rc.2(639ed01539)独立 profile 中实际操作了推荐入口、分类、IT之家真实文章预览、订阅与已订阅状态、无效网页地址失败后保留所有输入、自定义分类、英文切换及 arXiv 空源提示。390px 布局测量显示目录单列,文档宽度 390px,订阅对话框没有水平溢出。文章和用户分类仍为原文。
10
+
11
+ 本轮未进行原生桌面窗口操作;网页和自动回归不表示每个原生桌面交互已验收。测试 profile 不使用或修改个人订阅与密钥。
@@ -0,0 +1,29 @@
1
+ /** Publisher-hosted feeds; browsing this list never contacts their websites. */
2
+ export declare const sourceCategories: readonly [{
3
+ readonly id: "technology";
4
+ readonly name: readonly ["科技资讯", "Technology news"];
5
+ }, {
6
+ readonly id: "research";
7
+ readonly name: readonly ["AI 与科研", "AI and research"];
8
+ }, {
9
+ readonly id: "software";
10
+ readonly name: readonly ["软件更新", "Software updates"];
11
+ }, {
12
+ readonly id: "reading";
13
+ readonly name: readonly ["阅读与博客", "Reading and blogs"];
14
+ }, {
15
+ readonly id: "interests";
16
+ readonly name: readonly ["生活与兴趣", "Life and interests"];
17
+ }];
18
+ export interface RecommendedSource {
19
+ id: string;
20
+ name: string;
21
+ url: string;
22
+ website: string;
23
+ category: typeof sourceCategories[number]['id'];
24
+ language: 'zh' | 'en';
25
+ description: readonly [string, string];
26
+ }
27
+ export declare const recommendedSources: readonly RecommendedSource[];
28
+ /** Feed-provided links stay text-only or become ordinary HTTP(S) links. */
29
+ export declare function articleLink(value: string, base: string): string;
package/lib/catalog.js ADDED
@@ -0,0 +1,40 @@
1
+ /** Publisher-hosted feeds; browsing this list never contacts their websites. */
2
+ export const sourceCategories = [
3
+ { id: 'technology', name: ['科技资讯', 'Technology news'] },
4
+ { id: 'research', name: ['AI 与科研', 'AI and research'] },
5
+ { id: 'software', name: ['软件更新', 'Software updates'] },
6
+ { id: 'reading', name: ['阅读与博客', 'Reading and blogs'] },
7
+ { id: 'interests', name: ['生活与兴趣', 'Life and interests'] },
8
+ ];
9
+ // Descriptions are original catalog copy. Article content belongs to publishers.
10
+ // These are native RSS/Atom endpoints, without an RSSHub instance dependency.
11
+ export const recommendedSources = [
12
+ { id: 'ithome', name: 'IT之家', url: 'https://www.ithome.com/rss/', website: 'https://www.ithome.com/', category: 'technology', language: 'zh', description: ['数码产品、科技行业与软件资讯。', 'Consumer devices, technology and software news.'] },
13
+ { id: 'sspai', name: '少数派', url: 'https://sspai.com/feed', website: 'https://sspai.com/', category: 'technology', language: 'zh', description: ['数字生活、效率工具与使用经验。', 'Digital life, productivity tools and practical guides.'] },
14
+ { id: 'ifanr', name: '爱范儿', url: 'https://www.ifanr.com/feed', website: 'https://www.ifanr.com/', category: 'technology', language: 'zh', description: ['科技产品、设计与数字生活观察。', 'Technology products, design and digital life.'] },
15
+ { id: 'hacker-news', name: 'Hacker News', url: 'https://news.ycombinator.com/rss', website: 'https://news.ycombinator.com/', category: 'technology', language: 'en', description: ['技术社区的热门文章与讨论链接。', 'Popular articles and discussions from the technology community.'] },
16
+ { id: 'arxiv-ai', name: 'arXiv · Artificial Intelligence', url: 'https://rss.arxiv.org/rss/cs.AI', website: 'https://arxiv.org/list/cs.AI/recent', category: 'research', language: 'en', description: ['人工智能论文更新;非发布日可能没有条目。', 'AI paper announcements; non-publication days can have no entries.'] },
17
+ { id: 'hugging-face', name: 'Hugging Face Blog', url: 'https://huggingface.co/blog/feed.xml', website: 'https://huggingface.co/blog', category: 'research', language: 'en', description: ['开放模型、数据集与机器学习实践。', 'Open models, datasets and machine-learning practice.'] },
18
+ { id: 'deepmind', name: 'Google DeepMind', url: 'https://deepmind.google/blog/rss.xml', website: 'https://deepmind.google/blog/', category: 'research', language: 'en', description: ['研究团队发布的 AI 研究和产品进展。', 'AI research and product updates from the research team.'] },
19
+ { id: 'appinn', name: '小众软件', url: 'https://www.appinn.com/feed/', website: 'https://www.appinn.com/', category: 'software', language: 'zh', description: ['实用软件、工具推荐和更新介绍。', 'Useful applications, tools and software updates.'] },
20
+ { id: 'harness', name: 'DeepSeek Harness releases', url: 'https://github.com/deepseek-ai/DeepSeek-Harness/releases.atom', website: 'https://github.com/deepseek-ai/DeepSeek-Harness/releases', category: 'software', language: 'en', description: ['DSH 官方 GitHub 版本发布记录。', 'Official DSH releases published on GitHub.'] },
21
+ { id: 'python', name: 'Python Insider', url: 'https://blog.python.org/feeds/posts/default', website: 'https://blog.python.org/', category: 'software', language: 'en', description: ['Python 官方版本发布与项目消息。', 'Official Python releases and project news.'] },
22
+ { id: 'rust', name: 'Rust Blog', url: 'https://blog.rust-lang.org/feed.xml', website: 'https://blog.rust-lang.org/', category: 'software', language: 'en', description: ['Rust 官方版本与语言生态进展。', 'Official Rust releases and ecosystem updates.'] },
23
+ { id: 'nodejs', name: 'Node.js Blog', url: 'https://nodejs.org/en/feed/blog.xml', website: 'https://nodejs.org/en/blog', category: 'software', language: 'en', description: ['Node.js 官方版本、安全更新与项目消息。', 'Official Node.js releases, security updates and project news.'] },
24
+ { id: 'moonlight', name: '月光博客', url: 'https://www.williamlong.info/rss.xml', website: 'https://www.williamlong.info/', category: 'reading', language: 'zh', description: ['互联网、技术应用与个人博客文章。', 'Personal writing about the internet and technology.'] },
25
+ { id: 'simon-willison', name: 'Simon Willison', url: 'https://simonwillison.net/atom/everything/', website: 'https://simonwillison.net/', category: 'reading', language: 'en', description: ['开发实践、数据工具与 AI 使用笔记。', 'Development practice, data tools and AI notes.'] },
26
+ { id: 'nasa', name: 'NASA', url: 'https://www.nasa.gov/feed/', website: 'https://www.nasa.gov/', category: 'interests', language: 'en', description: ['航天任务、科学发现与宇宙探索。', 'Space missions, science and exploration.'] },
27
+ { id: 'xkcd', name: 'xkcd', url: 'https://xkcd.com/rss.xml', website: 'https://xkcd.com/', category: 'interests', language: 'en', description: ['关于科学、技术和日常生活的漫画更新。', 'Comics about science, technology and everyday life.'] },
28
+ ];
29
+ /** Feed-provided links stay text-only or become ordinary HTTP(S) links. */
30
+ export function articleLink(value, base) {
31
+ if (!value || value.length > 4096)
32
+ return '';
33
+ try {
34
+ const url = new URL(value, base);
35
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : '';
36
+ }
37
+ catch {
38
+ return '';
39
+ }
40
+ }
package/lib/client.js CHANGED
@@ -8,11 +8,19 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
8
8
  const listeners = new Set();
9
9
  const words = {
10
10
  title: ['RSS 订阅', 'RSS subscriptions'], intro: ['在这里管理订阅,也可以在对话里让 AI 读取和搜索文章。', 'Manage feeds here, then ask AI to read or search articles in a conversation.'],
11
+ recommended: ['推荐订阅源', 'Recommended feeds'], manual: ['输入地址', 'Enter a URL'],
12
+ catalogHint: ['先选感兴趣的分类,再预览或订阅。不会默认添加,也不会自动访问所有网站。', 'Choose a topic, then preview or subscribe. Feeds are not added by default and websites are not contacted automatically.'],
13
+ networkHint: ['可用性取决于你当前的网络,国内外网站可能不同。预览失败可以重试,或改选其他来源。', 'Availability depends on your current network and can differ by website. Retry a failed preview or choose another source.'],
14
+ sourceSearch: ['搜索来源或关键词', 'Search sources or keywords'], sourceLanguage: ['内容语言', 'Content language'], anyLanguage: ['全部语言', 'All languages'], chinese: ['中文', 'Chinese'], english: ['英文', 'English'],
15
+ native: ['网站原生 RSS / Atom', 'Publisher RSS / Atom'], website: ['来源网站', 'Source website'], previewArticles: ['预览并检查', 'Preview and check'], subscribe: ['订阅', 'Subscribe'], subscribed: ['已订阅', 'Subscribed'],
16
+ recentArticles: ['近期文章(最多 5 篇)', 'Recent articles (up to 5)'], noRecentArticles: ['订阅格式有效,但此时没有文章。有些来源只在发布日提供条目。', 'The feed is valid but currently empty. Some sources only include entries on publication days.'], noTitle: ['无标题', 'Untitled'],
17
+ customCategory: ['可选现有分类,也可填写自定义分类。已有分类原样保留。', 'Choose an existing category or type your own. Existing categories are preserved.'],
18
+ rsshubHint: ['其他网站:可在“输入地址”添加原生订阅,或粘贴你选择的 RSSHub 实例生成的地址。', 'For other sites, use “Enter a URL” with a native feed or a URL generated by your chosen RSSHub instance.'],
11
19
  add: ['添加订阅', 'Add feed'], import: ['导入 OPML', 'Import OPML'], export: ['导出 OPML', 'Export OPML'], refresh: ['刷新列表', 'Refresh list'],
12
20
  search: ['搜索名称或地址', 'Search name or URL'], category: ['分类', 'Category'], all: ['全部分类', 'All categories'], uncategorized: ['未分类', 'Uncategorized'],
13
21
  name: ['显示名称', 'Display name'], nameHint: ['留空时使用订阅源的标题', 'Leave blank to use the feed title'], url: ['订阅源地址', 'Feed URL'], categoryHint: ['例如:科技、阅读', 'For example: Tech, Reading'],
14
22
  save: ['保存订阅', 'Save feed'], saving: ['正在处理…', 'Working…'], cancel: ['取消', 'Cancel'], close: ['关闭', 'Close'], edit: ['编辑', 'Edit'], remove: ['移除', 'Remove'], check: ['检查可用性', 'Check feed'],
15
- empty: ['还没有订阅', 'No subscriptions yet'], emptyHint: ['添加 RSS/Atom 地址,或从阅读器导入 OPML 文件。', 'Add an RSS/Atom URL or import an OPML file from your reader.'], noMatch: ['没有匹配的订阅,试试清空筛选。', 'No matches. Try clearing the filters.'], clear: ['清空筛选', 'Clear filters'],
23
+ empty: ['还没有订阅', 'No subscriptions yet'], emptyHint: ['从推荐目录选几个感兴趣的来源,也可以输入 RSS/Atom 地址或导入 OPML。', 'Choose sources from the recommended catalog, enter an RSS/Atom URL, or import OPML.'], noMatch: ['没有匹配项,试试清空筛选。', 'No matches. Try clearing the filters.'], clear: ['清空筛选', 'Clear filters'],
16
24
  readonly: ['当前配置为只读。你可以查看、检查和导出订阅。', 'These settings are read-only. You can view, check and export feeds.'],
17
25
  pending: ['本次运行尚未检查', 'Not checked during this run'], entries: ['篇文章', 'articles'], checked: ['检查通过', 'Check passed'], failed: ['检查失败', 'Check failed'],
18
26
  statusHint: ['检查记录仅保留在本次 DSH 运行中;导入不会自动抓取所有订阅。', 'Check history lasts for this DSH run. Import does not fetch every feed.'],
@@ -27,6 +35,7 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
27
35
  bodyLarge: ['文件超过 1 MiB,请拆分后导入。', 'File exceeds 1 MiB. Split it before importing.'], session: ['DSH 会话已失效,请重新打开 DSH 页面。', 'The DSH session has expired. Reopen the DSH page.'], fileError: ['无法读取文件,请重新选择或粘贴 OPML 文本。', 'Cannot read this file. Choose it again or paste OPML text.'],
28
36
  };
29
37
  const t = key => (words[key] || [key, key])[language === 'en' ? 1 : 0];
38
+ const localized = value => value?.[language === 'en' ? 1 : 0] || '';
30
39
 
31
40
  function feedError(message, code) {
32
41
  if (language !== 'en' || !/[\u3400-\u9fff]/.test(message || '')) return message;
@@ -58,6 +67,10 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
58
67
  .dshr-dialog{font:inherit;line-height:1.55;color:var(--dsw-alias-label-primary,#202737);background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:14px;padding:22px;max-width:620px;width:calc(100% - 28px);max-height:calc(100dvh - 28px);overflow:auto;box-shadow:0 18px 70px #0003}
59
68
  .dshr-dialog::backdrop{background:#14203366}.dshr-dialog h3{font-size:19px;margin:0 0 12px}.dshr-dialog form{display:grid;gap:14px}.dshr-dialog textarea{min-height:140px;resize:vertical}.dshr-dialog p{margin:0}.dshr-dialog .dshr-buttons{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:14px}
60
69
  .dshr-dialog label.dshr-checkbox{display:flex;flex-direction:row;align-items:flex-start;gap:8px}.dshr-checkbox input{width:18px;height:18px;min-height:18px;margin-top:3px;flex-shrink:0}.dshr-preview{border-top:1px solid var(--dsw-alias-border-l2,#d9dee7);padding-top:12px;overflow-wrap:anywhere}.dshr-preview ul{margin:8px 0;padding-left:20px}.dshr-preview li{margin-bottom:5px}
70
+ .dshr-dialog.catalog{max-width:900px}.dshr-dialog select{font:inherit;min-height:40px;padding:8px;border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fff);color:inherit;width:100%}
71
+ .dshr-dialog select:focus-visible,.dshr-dialog a:focus-visible{outline:2px solid #5685d4;outline-offset:2px}.dshr-dialog a{color:var(--dsw-alias-accent-primary,#245cb5);overflow-wrap:anywhere}.dshr-dialog .dshr-filters{margin-top:14px}.dshr-dialog .dshr-filters label{flex:1 1 140px}
72
+ .dshr-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;list-style:none;padding:0;margin:14px 0}.dshr-source{border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:12px;padding:14px;min-width:0;align-self:start}.dshr-source h4{font-size:16px;margin:0 0 6px;overflow-wrap:anywhere}.dshr-source p{margin:8px 0}.dshr-source .dshr-actions{margin-top:12px}.dshr-source.is-selected{border-color:#5685d4}.dshr-source details{margin-top:8px;font-size:12px}.dshr-source summary{cursor:pointer}.dshr-source .dshr-preview{margin-top:12px;font-size:13px}
73
+ @media(max-width:640px){.dshr-catalog{grid-template-columns:1fr}.dshr-dialog .dshr-filters label{flex-basis:100%}}
61
74
  @media(max-width:520px){.dshr-dialog{padding:16px}.dshr-actions{width:100%}.dshr-filters label{width:100%}.dshr-toolbar button{flex:1 1 auto}.dshr-card{padding:12px}}
62
75
  `;
63
76
  async function request(action, args, signal) {
@@ -73,17 +86,17 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
73
86
  }
74
87
  return value;
75
88
  }
76
- function Dialog({ title, busy, onClose, children }) {
89
+ function Dialog({ title, busy, onClose, children, wide }) {
77
90
  const ref = useRef(null);
78
91
  const id = useId();
79
92
  useEffect(() => { ref.current.showModal(); return () => ref.current?.close(); }, []);
80
- return h('dialog', { ref, className: 'dshr-dialog', 'aria-labelledby': id,
93
+ return h('dialog', { ref, className: 'dshr-dialog' + (wide ? ' catalog' : ''), 'aria-labelledby': id,
81
94
  onKeyDownCapture: event => {
82
95
  if (event.nativeEvent.isComposing) return;
83
96
  if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); }
84
97
  if (event.key === 'Tab') {
85
98
  event.stopPropagation();
86
- const targets = [...event.currentTarget.querySelectorAll('input:not([disabled]),textarea:not([disabled]),select:not([disabled]),button:not([disabled])')];
99
+ const targets = [...event.currentTarget.querySelectorAll('input:not([disabled]),textarea:not([disabled]),select:not([disabled]),button:not([disabled]),a[href],summary')].filter(node => node.getClientRects().length);
87
100
  const first = targets[0], last = targets[targets.length - 1];
88
101
  if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
89
102
  else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
@@ -98,6 +111,9 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
98
111
  const [error, setError] = useState(''), [notice, setNotice] = useState(''), [query, setQuery] = useState(''), [category, setCategory] = useState('*');
99
112
  const [modal, setModal] = useState(null), [draft, setDraft] = useState({ url: '', name: '', category: '' });
100
113
  const [opml, setOpml] = useState(''), [updateExisting, setUpdateExisting] = useState(false), [preview, setPreview] = useState(null);
114
+ const [catalogQuery, setCatalogQuery] = useState(''), [catalogCategory, setCatalogCategory] = useState('*'), [catalogLanguage, setCatalogLanguage] = useState('*');
115
+ const [sourcePreviews, setSourcePreviews] = useState({}), [selectedSource, setSelectedSource] = useState('');
116
+ const categoryListId = useId();
101
117
  const controller = useRef(null), trigger = useRef(null);
102
118
  useEffect(() => { listeners.add(setLanguage); return () => listeners.delete(setLanguage); }, []);
103
119
  useEffect(() => { void run(null); return () => controller.current?.abort(); }, []);
@@ -119,6 +135,7 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
119
135
  trigger.current = event.currentTarget; setError(''); setNotice(''); setPreview(null);
120
136
  if (type === 'feed') setDraft(feed ? { url: feed.url, name: feed.name, category: feed.category } : { url: '', name: '', category: '' });
121
137
  if (type === 'import') { setOpml(''); setUpdateExisting(false); }
138
+ if (type === 'catalog') { setCatalogQuery(''); setCatalogCategory('*'); setCatalogLanguage('*'); setSelectedSource(''); }
122
139
  setModal({ type, feed });
123
140
  }
124
141
  async function chooseFile(event) {
@@ -130,6 +147,7 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
130
147
  }
131
148
  const field = (key, hint) => h('label', { key }, t(key), h('input', {
132
149
  name: key, type: key === 'url' ? 'url' : 'text', value: draft[key], placeholder: hint, required: key === 'url', disabled: !!busy,
150
+ list: key === 'category' ? categoryListId : undefined,
133
151
  autoFocus: key === 'url', onChange: event => setDraft({ ...draft, [key]: event.target.value }),
134
152
  }));
135
153
  const buttons = submit => h('div', { className: 'dshr-buttons' },
@@ -137,12 +155,18 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
137
155
  const alert = error ? h('div', { role: 'alert', className: 'dshr-alert' }, error) : null;
138
156
  const feeds = state?.feeds || [], writable = state?.writable === true;
139
157
  const categories = [...new Set(feeds.map(feed => feed.category))].sort();
158
+ const catalog = state?.catalog || { categories: [], sources: [] };
159
+ const sourceCategory = source => localized(catalog.categories.find(category => category.id === source.category)?.name);
160
+ const sourceOptions = catalog.sources.filter(source => (catalogCategory === '*' || source.category === catalogCategory) &&
161
+ (catalogLanguage === '*' || source.language === catalogLanguage) &&
162
+ (source.name + ' ' + source.url + ' ' + (source.description || []).join(' ')).toLowerCase().includes(catalogQuery.trim().toLowerCase()));
140
163
  const filtered = feeds.filter(feed => (category === '*' || feed.category === category) && (feed.name + ' ' + feed.url).toLowerCase().includes(query.trim().toLowerCase()));
141
164
  return h('section', { className: 'dshr', 'aria-label': t('title') },
142
165
  h('h2', null, t('title')), h('p', { className: 'dshr-muted' }, t('intro')),
143
166
  state && !writable ? h('p', { className: 'dshr-notice' }, t('readonly')) : null,
144
167
  h('div', { className: 'dshr-toolbar' },
145
- h('button', { className: 'primary', disabled: !writable || !!busy, onClick: event => open('feed', null, event) }, t('add')),
168
+ h('button', { className: 'primary', disabled: !state || !!busy, onClick: event => open('catalog', null, event) }, t('recommended')),
169
+ h('button', { disabled: !writable || !!busy, onClick: event => open('feed', null, event) }, t('manual')),
146
170
  h('button', { disabled: !writable || !!busy, onClick: event => open('import', null, event) }, t('import')),
147
171
  h('button', { disabled: !state || !!busy, onClick: () => run('export', {}, value => {
148
172
  const url = URL.createObjectURL(new Blob([value.opml], { type: 'text/x-opml;charset=utf-8' }));
@@ -171,10 +195,37 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
171
195
  h('button', { disabled: !writable || !!busy, onClick: event => open('feed', feed, event) }, t('edit')),
172
196
  h('button', { disabled: !writable || !!busy, onClick: event => open('remove', feed, event) }, t('remove')))))),
173
197
  feeds.length ? h('p', { className: 'dshr-muted', style: { marginTop: 12 } }, t('statusHint')) : null),
174
- modal ? h(Dialog, { title: modal.type === 'import' ? t('import') : modal.type === 'remove' ? t('deleteTitle') : modal.feed ? t('edit') : t('add'), busy: !!busy, onClose: close },
198
+ modal ? h(Dialog, { title: modal.type === 'catalog' ? t('recommended') : modal.type === 'import' ? t('import') : modal.type === 'remove' ? t('deleteTitle') : modal.feed ? t('edit') : t('add'), wide: modal.type === 'catalog', busy: !!busy, onClose: close },
175
199
  alert,
200
+ modal.type === 'catalog' ? h('div', null,
201
+ h('p', { className: 'dshr-muted' }, t('catalogHint')), h('p', { className: 'dshr-muted' }, t('networkHint')),
202
+ notice ? h('p', { role: 'status', className: 'dshr-notice' }, notice) : null,
203
+ h('div', { className: 'dshr-filters' },
204
+ h('label', null, t('sourceSearch'), h('input', { type: 'search', autoFocus: true, value: catalogQuery, disabled: !!busy, onChange: event => setCatalogQuery(event.target.value) })),
205
+ h('label', null, t('category'), h('select', { value: catalogCategory, disabled: !!busy, onChange: event => setCatalogCategory(event.target.value) }, h('option', { value: '*' }, t('all')), catalog.categories.map(category => h('option', { key: category.id, value: category.id }, localized(category.name))))),
206
+ h('label', null, t('sourceLanguage'), h('select', { value: catalogLanguage, disabled: !!busy, onChange: event => setCatalogLanguage(event.target.value) }, h('option', { value: '*' }, t('anyLanguage')), h('option', { value: 'zh' }, t('chinese')), h('option', { value: 'en' }, t('english'))))),
207
+ !sourceOptions.length ? h('p', { role: 'status' }, t('noMatch')) : null,
208
+ h('ul', { className: 'dshr-catalog' }, sourceOptions.map(source => {
209
+ const recent = sourcePreviews[source.id];
210
+ return h('li', { key: source.id, className: 'dshr-source' + (selectedSource === source.id ? ' is-selected' : ''), 'aria-label': source.name },
211
+ h('h4', null, source.name), h('span', { className: 'dshr-tag' }, sourceCategory(source)), ' ', h('span', { className: 'dshr-tag' }, t(source.language === 'zh' ? 'chinese' : 'english')),
212
+ h('p', null, localized(source.description)), h('a', { href: source.website, target: '_blank', rel: 'noopener noreferrer' }, t('website')),
213
+ h('details', null, h('summary', null, t('url')), h('p', { className: 'dshr-url' }, source.url), h('p', { className: 'dshr-muted' }, t('native'))),
214
+ source.check ? h('p', { className: 'dshr-status' }, source.check.ok ? t('checked') + ' · ' + source.check.entryCount + ' ' + t('entries') : t('failed') + ' · ' + feedError(source.check.error)) : null,
215
+ h('div', { className: 'dshr-actions' },
216
+ h('button', { type: 'button', disabled: !!busy, onClick: () => { setSelectedSource(source.id); void run('previewSource', { sourceId: source.id }, value => setSourcePreviews(old => ({ ...old, [source.id]: value.sourcePreview }))); } }, busy === 'previewSource' && selectedSource === source.id ? t('saving') : t('previewArticles')),
217
+ h('button', { type: 'button', className: source.subscribed ? '' : 'primary', disabled: !writable || !!busy || source.subscribed, onClick: () => { setSelectedSource(source.id); void run('add', { url: source.url, name: source.name, category: sourceCategory(source), revision: state.revision }, () => setNotice(t('saved') + ' · ' + source.name)); } }, source.subscribed ? t('subscribed') : t('subscribe'))),
218
+ recent ? h('div', { className: 'dshr-preview', role: 'status' }, h('strong', null, t('recentArticles')), h('p', { className: 'dshr-muted' }, new Date(recent.checkedAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN')),
219
+ !recent.entries.length ? h('p', null, t('noRecentArticles')) : h('ul', null, recent.entries.map((entry, index) => h('li', { key: index },
220
+ entry.link ? h('a', { href: entry.link, target: '_blank', rel: 'noopener noreferrer' }, entry.title || t('noTitle')) : entry.title || t('noTitle'),
221
+ entry.pubDate ? h('div', { className: 'dshr-muted' }, new Date(entry.pubDate).toLocaleDateString(language === 'en' ? 'en-US' : 'zh-CN')) : null)))) : null);
222
+ })),
223
+ h('p', { className: 'dshr-muted' }, t('rsshubHint')),
224
+ h('div', { className: 'dshr-buttons' }, h('button', { type: 'button', disabled: !!busy, onClick: close }, t('close')))) :
176
225
  modal.type === 'feed' ? h('form', { onSubmit: event => { event.preventDefault(); void run(modal.feed ? 'edit' : 'add', { ...draft, id: modal.feed?.id, revision: state.revision }, () => { close(); setNotice(t('saved')); }); } },
177
226
  field('url', 'https://example.com/feed.xml'), field('name', t('nameHint')), field('category', t('categoryHint')),
227
+ h('datalist', { id: categoryListId }, [...new Set([...categories, ...catalog.categories.map(category => localized(category.name))])].filter(Boolean).map(value => h('option', { key: value, value }))),
228
+ h('p', { className: 'dshr-muted' }, t('customCategory')),
178
229
  error ? h('button', { type: 'button', disabled: !!busy, onClick: () => run(null) }, t('refresh')) : null,
179
230
  buttons(h('button', { type: 'submit', className: 'primary', disabled: !!busy }, t(busy ? 'saving' : 'save')))) :
180
231
  modal.type === 'remove' ? h('div', null, h('strong', null, modal.feed.name || modal.feed.url), h('p', { className: 'dshr-url' }, modal.feed.url), h('p', null, t('deleteHint')),
package/lib/web.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import type { RssSettingsScope } from './tools.js';
4
4
  import type { ResolvedRssConfig } from './config.js';
5
5
  import { type FetchLike, type DnsLookupLike } from './network.js';
6
+ import { sourceCategories } from './catalog.js';
6
7
  export declare const RSS_SETTINGS_ROUTE = "/_dsh/dsh-rss/subscriptions";
7
8
  export declare function createRssSettingsBackend(config: ResolvedRssConfig, scope: RssSettingsScope, options?: {
8
9
  writable?: () => boolean;
@@ -19,6 +20,35 @@ export declare function createRssSettingsBackend(config: ResolvedRssConfig, scop
19
20
  name: string;
20
21
  category: string;
21
22
  }[];
23
+ catalog: {
24
+ categories: readonly [{
25
+ readonly id: "technology";
26
+ readonly name: readonly ["科技资讯", "Technology news"];
27
+ }, {
28
+ readonly id: "research";
29
+ readonly name: readonly ["AI 与科研", "AI and research"];
30
+ }, {
31
+ readonly id: "software";
32
+ readonly name: readonly ["软件更新", "Software updates"];
33
+ }, {
34
+ readonly id: "reading";
35
+ readonly name: readonly ["阅读与博客", "Reading and blogs"];
36
+ }, {
37
+ readonly id: "interests";
38
+ readonly name: readonly ["生活与兴趣", "Life and interests"];
39
+ }];
40
+ sources: {
41
+ subscribed: boolean;
42
+ check: import("./subscriptions.js").FeedCheck | null;
43
+ id: string;
44
+ name: string;
45
+ url: string;
46
+ website: string;
47
+ category: (typeof sourceCategories)[number]["id"];
48
+ language: "zh" | "en";
49
+ description: readonly [string, string];
50
+ }[];
51
+ };
22
52
  };
23
53
  handle(req: IncomingMessage, res: ServerResponse): Promise<void>;
24
54
  };
package/lib/web.js CHANGED
@@ -2,6 +2,7 @@ import { sameFeedUrl } from './feeds.js';
2
2
  import { assertHttpUrl } from './network.js';
3
3
  import { buildOpml, parseOpml, importOpmlFeeds } from './opml.js';
4
4
  import { feedId, subscriptionSnapshot, mutateSubscriptions, recentFeedChecks, readRssFeed, SubscriptionError } from './subscriptions.js';
5
+ import { recommendedSources, sourceCategories, articleLink } from './catalog.js';
5
6
  export const RSS_SETTINGS_ROUTE = '/_dsh/dsh-rss/subscriptions';
6
7
  // JSON quoting can double the OPML file size; limit the actual text separately.
7
8
  const MAX_OPML = 1024 * 1024;
@@ -78,7 +79,9 @@ export function createRssSettingsBackend(config, scope, options = {}) {
78
79
  const snapshot = () => {
79
80
  const state = subscriptionSnapshot(scope);
80
81
  const checks = recentFeedChecks(scope);
81
- return { revision: state.revision, writable: writable(), feeds: state.feeds.map(feed => ({ ...feed, id: feedId(feed.url), check: checks.get(feed.url) ?? null })) };
82
+ return { revision: state.revision, writable: writable(), feeds: state.feeds.map(feed => ({ ...feed, id: feedId(feed.url), check: checks.get(feed.url) ?? null })),
83
+ catalog: { categories: sourceCategories, sources: recommendedSources.map(source => ({ ...source,
84
+ subscribed: state.feeds.some(feed => sameFeedUrl(feed.url, source.url)), check: checks.get(source.url) ?? null })) } };
82
85
  };
83
86
  const find = (feeds, id) => {
84
87
  const feed = feeds.find(feed => feedId(feed.url) === id);
@@ -108,6 +111,16 @@ export function createRssSettingsBackend(config, scope, options = {}) {
108
111
  return { opml: buildOpml(subscriptionSnapshot(scope).feeds) };
109
112
  if (action === 'preview')
110
113
  return { preview: preview(args) };
114
+ if (action === 'previewSource') {
115
+ const source = recommendedSources.find(source => source.id === text(args, 'sourceId'));
116
+ if (!source)
117
+ throw new SubscriptionError('这个推荐源不存在,请刷新目录。');
118
+ const { parsed, finalUrl } = await check(source.url, signal);
119
+ return { sourcePreview: { sourceId: source.id, checkedAt: new Date().toISOString(), title: parsed.feed.title,
120
+ entryCount: parsed.entries.length, entries: parsed.entries.slice(0, 5).map(entry => ({
121
+ title: entry.title.slice(0, 400), link: articleLink(entry.link, finalUrl), pubDate: entry.pubDate.slice(0, 64),
122
+ })) } };
123
+ }
111
124
  if (action === 'check') {
112
125
  const url = text(args, 'id') ? find(subscriptionSnapshot(scope).feeds, text(args, 'id')).url : text(args, 'url');
113
126
  const { parsed } = await check(url, signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rss",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "订阅和阅读 RSS / Atom,在 DSH 中查看与管理更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",