Skip to content

Add plugin RSS 阅读器 v1.0.0 - #318

Draft
Kitsune2077 wants to merge 1 commit into
ZToolsCenter:mainfrom
Kitsune2077:plugin/ztools-rss
Draft

Add plugin RSS 阅读器 v1.0.0#318
Kitsune2077 wants to merge 1 commit into
ZToolsCenter:mainfrom
Kitsune2077:plugin/ztools-rss

Conversation

@Kitsune2077

@Kitsune2077 Kitsune2077 commented Jul 17, 2026

Copy link
Copy Markdown

插件信息

  • 名称: RSS 阅读器
  • 插件ID: ztools-rss
  • 版本: 1.0.0
  • 描述: 在 ZTools 内阅读 RSS,支持 FreshRSS / Google Reader API
  • 作者: Kitsune
  • 类型: 新增

本次变更

  • Initial commit: ztools-rss RSS reader plugin

截图 / 演示

自检清单

  • plugin.json 的 name / title / version / description / author 字段均已检查
  • 已移除调试日志、未使用文件、敏感信息(.env、token、密钥等)
  • 本次 PR 的 diff 仅涉及 plugins/ztools-rss/ 目录
  • 已在本地 ZTools 客户端实际加载并测试过此插件,主要功能正常
  • 同意以仓库声明的开源协议发布此插件

此 PR 由 ztools-plugin-cli 自动管理:每次 ztools publish 在分支上追加一个 commit,PR 链接保持不变。

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the ztools-rss plugin, a Vue 3 and TypeScript-based RSS reader for ZTools that integrates with FreshRSS and Google Reader APIs. It features a double-column layout, feed categorization, article sanitization via DOMPurify, and a Node.js preload layer to bypass CORS and image hotlinking. The review feedback highlights several opportunities to improve robustness and UI consistency. Key suggestions include handling relative redirects more robustly in HTTP requests, implementing size limits on image downloads to prevent memory issues, adding defensive checks for window.ztools and window.services to support non-ZTools environments, and ensuring unread counts are synchronized across all relevant tree nodes (folders, special categories, and feed items) when articles are marked as read, starred, or batch-updated.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +35 to +41
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
let loc = res.headers.location
if (loc.startsWith('/')) loc = u.protocol + '//' + u.host + loc
res.resume()
httpRequest(loc, { method, headers, body, timeout }).then(resolve, reject)
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在处理 HTTP 重定向时,当前代码仅简单地通过判断 loc.startsWith('/') 来拼接相对路径。如果重定向的 Location 是一个不带前缀 / 的相对路径(例如 greader.php?foo=bar),这种拼接方式会失效,导致 new URL(loc) 抛出异常。建议使用标准的 new URL(loc, u.href).href 来健壮地解析所有类型的重定向 URL(包括绝对路径、相对路径和完整 URL)。

      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        let loc = res.headers.location
        try {
          loc = new URL(loc, u.href).href
        } catch (e) {
          reject(new Error('重定向解析失败:' + loc))
          return
        }
        res.resume()
        httpRequest(loc, { method, headers, body, timeout }).then(resolve, reject)
        return
      }

Comment on lines +100 to +106
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
let loc = res.headers.location
if (loc.startsWith('/')) loc = u.protocol + '//' + u.host + loc
res.resume()
fetchImageAsDataUrl(loc, referer).then(resolve, reject)
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

同样地,在下载图片处理重定向时,建议使用 new URL(loc, u.href).href 来健壮地解析重定向地址,避免相对路径解析失败。

Suggested change
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
let loc = res.headers.location
if (loc.startsWith('/')) loc = u.protocol + '//' + u.host + loc
res.resume()
fetchImageAsDataUrl(loc, referer).then(resolve, reject)
return
}
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
let loc = res.headers.location
try {
loc = new URL(loc, u.href).href
} catch (e) {
reject(new Error('图片重定向解析失败:' + loc))
return
}
res.resume()
fetchImageAsDataUrl(loc, referer).then(resolve, reject)
return
}

Comment on lines +308 to +321
async function markCurrentRead(): Promise<void> {
if (!state.config || !state.selectedArticleId) return
const article = currentArticle.value
if (!article || article.read) return
article.read = true
// 更新未读计数
const node = findNode(state.tree, state.selectedNodeId)
if (node && node.unread > 0) node.unread -= 1
try {
await api.markItemRead(state.config, article.id, true)
} catch (_) {
// 静默失败,不打断阅读
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

当单篇文章被标记为已读时,当前代码仅递减了 state.selectedNodeId 对应节点的未读计数。然而,如果该订阅源属于某个分类(文件夹),或者在“全部文章”/“星标文章”等特殊节点中,这些父级或特殊节点的未读计数并不会同步更新,导致侧边栏的未读计数出现不一致。建议实现一个统一的 decrementUnread 函数,同步更新该文章所属订阅源、父文件夹以及特殊节点的未读计数。

function decrementUnread(article: Article): void {
  const allNode = state.tree.find((n) => n.id === SPECIAL_ALL)
  if (allNode && allNode.unread > 0) allNode.unread -= 1

  if (article.starred) {
    const starNode = state.tree.find((n) => n.id === SPECIAL_STARRED)
    if (starNode && starNode.unread > 0) starNode.unread -= 1
  }

  const feedId = article.feedId
  if (feedId) {
    for (const node of state.tree) {
      if (node.type === 'feed' && node.streamId === feedId) {
        if (node.unread > 0) node.unread -= 1
      } else if (node.type === 'folder' && node.children) {
        const child = node.children.find((c) => c.streamId === feedId)
        if (child) {
          if (child.unread > 0) {
            child.unread -= 1
            if (node.unread > 0) node.unread -= 1
          }
        }
      }
    }
  }
}

async function markCurrentRead(): Promise<void> {
  if (!state.config || !state.selectedArticleId) return
  const article = currentArticle.value
  if (!article || article.read) return
  article.read = true
  decrementUnread(article)
  try {
    await api.markItemRead(state.config, article.id, true)
  } catch (_) {
    // 静默失败,不打断阅读
  }
}

Comment on lines +334 to +344
export async function markAllReadInCurrent(): Promise<void> {
if (!state.config || !state.selectedStreamId) return
try {
await api.markStreamRead(state.config, state.selectedStreamId)
for (const a of state.articles) a.read = true
const node = findNode(state.tree, state.selectedNodeId)
if (node) node.unread = 0
} catch (e: any) {
state.error = e.message || String(e)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在执行“全部标记为已读”操作时,当前代码仅将当前选中节点的未读计数设为 0。这会导致其他相关节点(如“全部文章”特殊节点、父文件夹节点或其他订阅源节点)的未读计数滞后,产生严重的 UI 数据不一致。由于这是一个批量操作,最稳妥且简单的方法是在操作成功后调用 refreshTree() 重新从服务端拉取最新的未读计数,确保整个订阅树的计数完全准确。

export async function markAllReadInCurrent(): Promise<void> {
  if (!state.config || !state.selectedStreamId) return
  try {
    await api.markStreamRead(state.config, state.selectedStreamId)
    for (const a of state.articles) a.read = true
    await refreshTree()
  } catch (e: any) {
    state.error = e.message || String(e)
  }
}

Comment on lines +113 to +118
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
const b64 = Buffer.concat(chunks).toString('base64')
resolve('data:' + ct + ';base64,' + b64)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在通过 Node.js 下载图片并转换为 Base64 时,如果目标图片非常大(例如恶意服务返回的超大文件或无限流),会消耗大量内存甚至导致 Node.js 进程崩溃(OOM)。建议对下载的图片大小进行限制(例如最大 10MB),以提高插件的健壮性和安全性。

        const chunks = []
        let size = 0
        const maxImgSize = 10 * 1024 * 1024 // 10MB 限制
        res.on('data', (c) => {
          size += c.length
          if (size > maxImgSize) {
            req.destroy(new Error('图片文件过大'))
            return
          }
          chunks.push(c)
        })
        res.on('end', () => {
          const b64 = Buffer.concat(chunks).toString('base64')
          resolve('data:' + ct + ';base64,' + b64)
        })

Comment on lines +46 to +59
function loadConfig(): GReaderConfig | null {
try {
const raw = window.ztools.dbStorage.getItem<GReaderConfig>(CONFIG_KEY)
if (raw && raw.baseUrl && raw.username) return raw
} catch (_) {
// ignore
}
return null
}

export function saveConfig(config: GReaderConfig): void {
window.ztools.dbStorage.setItem(CONFIG_KEY, config)
state.config = { ...config }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了防止插件在非 ZTools 客户端环境(例如本地浏览器开发调试环境)下运行时因为 window.ztools 未定义而直接崩溃,建议在 loadConfigsaveConfig 中对 window.ztools 进行可选链(Optional Chaining)保护。

Suggested change
function loadConfig(): GReaderConfig | null {
try {
const raw = window.ztools.dbStorage.getItem<GReaderConfig>(CONFIG_KEY)
if (raw && raw.baseUrl && raw.username) return raw
} catch (_) {
// ignore
}
return null
}
export function saveConfig(config: GReaderConfig): void {
window.ztools.dbStorage.setItem(CONFIG_KEY, config)
state.config = { ...config }
}
function loadConfig(): GReaderConfig | null {
try {
if (window.ztools?.dbStorage) {
const raw = window.ztools.dbStorage.getItem<GReaderConfig>(CONFIG_KEY)
if (raw && raw.baseUrl && raw.username) return raw
}
} catch (_) {
// ignore
}
return null
}
export function saveConfig(config: GReaderConfig): void {
if (window.ztools?.dbStorage) {
window.ztools.dbStorage.setItem(CONFIG_KEY, config)
}
state.config = { ...config }
}

Comment on lines +323 to +332
export async function toggleStar(article: Article): Promise<void> {
if (!state.config) return
const next = !article.starred
article.starred = next
try {
await api.markItemStarred(state.config, article.id, next)
} catch (_) {
article.starred = !next
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

当对一篇文章进行加星或取消加星操作时,如果该文章处于“未读”状态,那么“星标文章”特殊节点的未读计数也应当同步增加或减少。当前代码忽略了这一同步,导致“星标文章”的未读计数不准确。建议在本地状态变更时同步更新 SPECIAL_STARRED 节点的未读计数,并在请求失败回滚时进行逆向恢复。

export async function toggleStar(article: Article): Promise<void> {
  if (!state.config) return
  const next = !article.starred
  article.starred = next

  if (!article.read) {
    const starNode = state.tree.find((n) => n.id === SPECIAL_STARRED)
    if (starNode) {
      if (next) {
        starNode.unread += 1
      } else if (starNode.unread > 0) {
        starNode.unread -= 1
      }
    }
  }

  try {
    await api.markItemStarred(state.config, article.id, next)
  } catch (_) {
    article.starred = !next
    if (!article.read) {
      const starNode = state.tree.find((n) => n.id === SPECIAL_STARRED)
      if (starNode) {
        if (next && starNode.unread > 0) {
          starNode.unread -= 1
        } else {
          starNode.unread += 1
        }
      }
    }
  }
}

Comment on lines +27 to +38
function openLink(url: string) {
if (url) window.ztools.shellOpenExternal(url)
}

function onContentClick(e: MouseEvent) {
const target = e.target as HTMLElement
const a = target.closest('a')
if (a && a.href) {
e.preventDefault()
window.ztools.shellOpenExternal(a.href)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在文章视图中,点击链接或原文按钮时会调用 window.ztools.shellOpenExternal。如果该插件在非 ZTools 客户端环境(如浏览器)中运行,会导致 TypeError 崩溃。建议对 window.ztools 进行安全保护。

function openLink(url: string) {
  if (url && window.ztools) window.ztools.shellOpenExternal(url)
}

function onContentClick(e: MouseEvent) {
  const target = e.target as HTMLElement
  const a = target.closest('a')
  if (a && a.href && window.ztools) {
    e.preventDefault()
    window.ztools.shellOpenExternal(a.href)
  }
}

Comment on lines +50 to +64
function onImgError(e: Event) {
const img = e.target as HTMLImageElement
if (!img || img.tagName !== 'IMG' || img.dataset.proxied) return
const src = img.currentSrc || img.src
if (!src || !/^https?:/i.test(src)) return
img.dataset.proxied = '1'
window.services
.fetchImageAsDataUrl(src, refererHint())
.then((dataUrl) => {
img.src = dataUrl
})
.catch(() => {
img.classList.add('img-failed')
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在图片加载失败尝试通过 Node.js 代理下载时,如果 window.services 未定义(例如在浏览器开发环境下),会直接抛出未定义错误。建议添加安全守卫,在 window.services 不存在时直接添加失败样式,避免控制台报错。

function onImgError(e: Event) {
  const img = e.target as HTMLImageElement
  if (!img || img.tagName !== 'IMG' || img.dataset.proxied) return
  const src = img.currentSrc || img.src
  if (!src || !/^https?:/i.test(src)) return
  img.dataset.proxied = '1'
  if (window.services) {
    window.services
      .fetchImageAsDataUrl(src, refererHint())
      .then((dataUrl) => {
        img.src = dataUrl
      })
      .catch(() => {
        img.classList.add('img-failed')
      })
  } else {
    img.classList.add('img-failed')
  }
}

@lzx8589561

Copy link
Copy Markdown
Contributor

图标需要更换一下,可以使用ai生成svg转png

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants