Add plugin RSS 阅读器 v1.0.0 - #318
Conversation
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
在处理 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
}| 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 | ||
| } |
There was a problem hiding this comment.
同样地,在下载图片处理重定向时,建议使用 new URL(loc, u.href).href 来健壮地解析重定向地址,避免相对路径解析失败。
| 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 | |
| } |
| 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 (_) { | ||
| // 静默失败,不打断阅读 | ||
| } | ||
| } |
There was a problem hiding this comment.
当单篇文章被标记为已读时,当前代码仅递减了 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 (_) {
// 静默失败,不打断阅读
}
}| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
在执行“全部标记为已读”操作时,当前代码仅将当前选中节点的未读计数设为 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)
}
}| const chunks = [] | ||
| res.on('data', (c) => chunks.push(c)) | ||
| res.on('end', () => { | ||
| const b64 = Buffer.concat(chunks).toString('base64') | ||
| resolve('data:' + ct + ';base64,' + b64) | ||
| }) |
There was a problem hiding this comment.
在通过 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)
})| 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 } | ||
| } |
There was a problem hiding this comment.
为了防止插件在非 ZTools 客户端环境(例如本地浏览器开发调试环境)下运行时因为 window.ztools 未定义而直接崩溃,建议在 loadConfig 和 saveConfig 中对 window.ztools 进行可选链(Optional Chaining)保护。
| 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 } | |
| } |
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
当对一篇文章进行加星或取消加星操作时,如果该文章处于“未读”状态,那么“星标文章”特殊节点的未读计数也应当同步增加或减少。当前代码忽略了这一同步,导致“星标文章”的未读计数不准确。建议在本地状态变更时同步更新 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
}
}
}
}
}| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
在文章视图中,点击链接或原文按钮时会调用 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)
}
}
| 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') | ||
| }) | ||
| } |
There was a problem hiding this comment.
在图片加载失败尝试通过 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')
}
}
|
图标需要更换一下,可以使用ai生成svg转png |
插件信息
本次变更
截图 / 演示
自检清单
plugins/ztools-rss/目录此 PR 由 ztools-plugin-cli 自动管理:每次
ztools publish在分支上追加一个 commit,PR 链接保持不变。