Skip to content

feat: 支持小说页面#541

Open
Hoshino-Yumetsuki wants to merge 1 commit into
FreeNowOrg:masterfrom
Hoshino-Yumetsuki:master
Open

feat: 支持小说页面#541
Hoshino-Yumetsuki wants to merge 1 commit into
FreeNowOrg:masterfrom
Hoshino-Yumetsuki:master

Conversation

@Hoshino-Yumetsuki
Copy link
Copy Markdown
Contributor

@Hoshino-Yumetsuki Hoshino-Yumetsuki commented May 15, 2026

Summary by Sourcery

为 Pixiv 小说提供一等公民级别的支持,包括查看单篇小说和小说系列,以及列出用户的小说。

新功能:

  • 在现有插画 API 之外,开放 Pixiv 小说与小说系列的 API 和类型。
  • 引入小说详情页和小说系列页面,提供丰富的元数据、内容渲染和导航功能。
  • 新增可复用的小说阅读器、卡片和列表组件,用于在应用内展示和浏览小说。
  • 在用户个人主页新增“小说”标签页,用于展示创作者的小说作品并链接到小说详情页。

增强内容:

  • 更新插画卡片和用户类型,使其能够正确链接至小说路由,并使用小说特有的元数据。
  • 添加 HTML 转文本解析和小说内容解析工具,以处理 Pixiv 小说标记和内嵌图片。
  • 引入兼容路由,将旧版的 /novel/show.php 链接重定向到新的 /novels/:id 页面。
  • 调整代理中间件,使原生的 /novel/show.php 请求可以绕过 Pixiv 的 AJAX 代理。
Original summary in English

Summary by Sourcery

Add first-class support for Pixiv novels including viewing individual novels and series, and listing user novels.

New Features:

  • Expose Pixiv novel and novel series APIs and types alongside existing artwork APIs.
  • Introduce novel detail and novel series pages with rich metadata, content rendering, and navigation.
  • Add reusable novel reader, card, and list components to display and browse novels across the app.
  • Surface a new novels tab on user profiles to show a creator's novel works and link into novel detail pages.

Enhancements:

  • Update artwork cards and user types to correctly link to novel routes and use novel-specific metadata.
  • Add HTML-to-text parsing and novel content parsing utilities to handle Pixiv novel markup and embedded images.
  • Introduce a compatibility route to redirect legacy /novel/show.php links to the new /novels/:id page.
  • Adjust proxy middleware to let native /novel/show.php requests bypass the Pixiv AJAX proxy.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented May 15, 2026

Reviewer's Guide

为 Pixiv 小说提供完整支持:客户端 API 方法、小说/系列类型和存储、小说阅读器解析工具、小说与系列页面/组件,并将小说集成进现有的作品/用户流中,同时保留对旧版小说 URL 的代理行为。

加载 Pixiv 小说页面的时序图

sequenceDiagram
  actor User
  participant Route as NuxtRoute
  participant NovelPage as NovelsIdPage
  participant NovelStore as useNovelStore
  participant PixivClient as PixivWebClient
  participant PixivAPI as PixivAjaxAPI
  participant UserProfileStore as useUserProfileStore

  User->>Route: navigate /novels/:id
  Route->>NovelPage: mount
  NovelPage->>NovelPage: init(id)
  NovelPage->>NovelStore: fetchNovel(id)
  alt novel in cache
    NovelStore-->>NovelPage: Novel (from novelCache)
  else novel not cached
    NovelStore->>PixivClient: getNovel(id)
    PixivClient->>PixivAPI: GET /ajax/novel/:id
    PixivAPI-->>PixivClient: PixivResponse<Novel>
    PixivClient-->>NovelStore: Novel
    NovelStore-->>NovelPage: Novel
  end
  NovelPage->>UserProfileStore: fetchUser(novel.userId)
  UserProfileStore-->>NovelPage: User
  NovelPage->>NovelPage: parseNovelContent(content, textEmbeddedImages)
  NovelPage-->>User: Render NovelReader and metadata
Loading

小说内容解析与渲染流程图

flowchart LR
  A[Novel.content\nNovel.textEmbeddedImages] --> B[parseNovelContent]
  B --> C["NovelContentBlock[]"]
  C --> D[NovelReader.vue]
  D --> E[Rendered paragraphs, chapters, images, links]

  subgraph Utils
    B
  end

  subgraph Components
    D
  end
Loading

文件级变更

Change Details Files
扩展 Pixiv web client 和共享类型,以支持小说与小说系列 API 以及用户小说元数据。
  • 在全新的 Novels 类型模块中新增 NovelNovelInfoNovelContentTitleNovelSeriesNovelSeriesContentResult 及相关接口,并在 types index 中重新导出。
  • 扩展 PixivWebClient,新增 getNovelgetNovelSeriesgetNovelSeriesContentgetNovelSeriesContentTitles 方法,包括查询参数映射和响应解包。
  • 更新与用户相关的类型以及 PixivWebClient.getUserProfileTop,使其在小说合集和用户列表项中使用 NovelInfo,而不是 ArtworkInfo
app/api/pixiv-client.ts
app/types/Novels.ts
app/types/Users.ts
app/types/index.ts
引入 Pinia store,用于获取和缓存小说实体,并将系列内容规范化为 NovelInfo 条目。
  • 创建 useNovelStore,为小说、系列、系列内容以及系列标题内容创建以 id 为键的缓存。
  • 实现 fetchNovelfetchNovelSeries,只调用一次 Pixiv client 并缓存结果。
  • 实现 fetchNovelSeriesContentfetchNovelSeriesContentTitles,在可用时优先使用缩略图数据,并使用一个辅助函数将 NovelSeriesContentItem 映射为 NovelInfo,以作为系列内容的回退规范化逻辑。
app/stores/novel.ts
实现小说内容解析和阅读器组件,将 Pixiv 小说标记解析为结构化块和行内 token。
  • 新增 parseNovelContent,基于标记语法和可选的嵌入图片元数据,将原始小说内容拆分为章节/分隔符/段落/图片块。
  • 新增 parseInline,将 Pixiv 行内标记(注音标注 ruby、外部链接、着重号、页面跳转、粗体/斜体)转换为带类型的 token 流,并进行 URL 安全性检查。
  • 新增 stripHtmlText 辅助函数,将 HTML 标题/描述转换为纯文本并保留换行,并在全局使用 NovelContentBlock/NovelInlineToken 联合类型。
  • 创建 NovelReader 组件,将块/token 渲染为语义化 HTML(ruby、链接、着重、页面分隔、嵌入图片和 Pixiv 链接图片),并附带基础样式。
app/utils/novel-content.ts
app/components/Novel/NovelReader.vue
新增小说详情页和小说系列页,消费 novel store、渲染内容,并提供导航和相关列表。
  • 创建 /novels/[id].vue 页面:获取小说及其作者,通过 parseNovelContent 构建 contentBlocks,推导描述和字符数/阅读时长标签,并使用 NovelListNovelReader 展示数据统计、标签、系列导航以及相关用户小说。
  • 创建 /novel/series/[id].vue 页面:并行获取系列、其内容列表和标题列表,计算 coverUrl/captionText,并以目录视图形式渲染,包含作者信息以及指向首/最新话的快速链接。
  • 将两个页面接入路由,使用命名路由和别名,通过 setTitle 设置文档标题,并在路由更新和错误状态下使用骨架占位和 ErrorPage 进行处理。
app/pages/novels/[id].vue
app/pages/novel/series/[id].vue
新增可复用的小说列表和卡片组件,用于用户页面以及小说/系列页面。
  • 创建 NovelCard,展示小说封面(或 SVG 回退)、R-18 标记、标题、作者以及文本/阅读时长元数据,并链接到小说和用户路由。
  • 创建 NovelList,以响应式网格渲染多个 NovelCard 组件,并通过数值或布尔的 loading prop 支持骨架加载模式。
  • 使用共享的 DeferLoad 和 naive-ui 的 NSkeleton 组件,实现图片懒加载和一致的占位样式。
app/components/Novel/NovelCard.vue
app/components/Novel/NovelList.vue
将小说集成到现有作品和用户界面中,并添加旧版 URL 兼容处理。
  • 更新 ArtworkCard,计算 workLink,使小说路由到 /novels/:id 而非 artworks,对非小说条目限制显示 ugoira 图标,并使用 ref 在响应式条目上安全地修改 bookmarkData
  • 扩展用户主页,增加“小说”标签页,当 user.novels 非空时显示 NovelList,并通过与其他作品网格共享的 CSS 隐藏作者元数据。
  • 新增重定向页面 /novel/show.php.vue,从 query 中读取 id,将其验证为数字,并在客户端重定向到 /novels/:id(或 404),同时显示骨架占位。
  • 调整 pixiv-proxy 中间件,使其跳过对 /novel/show.php 的代理,以便由 Nuxt 路由在本地处理。
app/components/Artwork/ArtworkCard.vue
app/pages/users/[id]/index.vue
app/pages/novel/show.php.vue
server/middleware/pixiv-proxy.ts

可能关联的 Issue

  • #[FEAT] 小说页面:该 PR 完整实现了所需的小说页面,包括小说详情页、系列页面、列表以及相关 API

Tips and commands

Interacting with Sourcery

  • 触发新的 Review: 在 pull request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的 review 评论。
  • 从 review 评论生成 GitHub issue: 在某条 review 评论下回复,要求 Sourcery 从该评论创建 issue。也可以直接回复 @sourcery-ai issue 从该评论创建 issue。
  • 生成 pull request 标题: 在 pull request 标题中任意位置写上 @sourcery-ai,即可随时生成标题。你也可以在 pull request 中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成 pull request 总结: 在 pull request body 中任意位置写上 @sourcery-ai summary,可在该位置随时生成 PR 总结。你也可以在 pull request 中评论 @sourcery-ai summary 来(重新)生成总结。
  • 生成 reviewer's guide: 在 pull request 中评论 @sourcery-ai guide,可随时(重新)生成 reviewer's guide。
  • 一次性解决所有 Sourcery 评论: 在 pull request 中评论 @sourcery-ai resolve,将所有 Sourcery 评论标记为已解决。适用于你已经处理完所有评论且不希望再看到它们的情况。
  • 忽略所有 Sourcery reviews: 在 pull request 中评论 @sourcery-ai dismiss,忽略所有现有的 Sourcery reviews。特别适用于你想用一次新的 review 重新开始的场景——别忘了再评论 @sourcery-ai review 触发新的 review!

Customizing Your Experience

访问你的 dashboard 来:

  • 启用或禁用某些 review 功能,例如 Sourcery 自动生成的 pull request 总结、reviewer's guide 等。
  • 更改 review 语言。
  • 添加、删除或编辑自定义 review 说明。
  • 调整其他 review 设置。

Getting Help

Original review guide in English

Reviewer's Guide

Adds full Pixiv novel support: client API methods, novel/series types and stores, novel reader parsing utilities, novel and series pages/components, and integrates novels into existing artwork/user flows while preserving proxy behavior for legacy novel URLs.

Sequence diagram for loading a Pixiv novel page

sequenceDiagram
  actor User
  participant Route as NuxtRoute
  participant NovelPage as NovelsIdPage
  participant NovelStore as useNovelStore
  participant PixivClient as PixivWebClient
  participant PixivAPI as PixivAjaxAPI
  participant UserProfileStore as useUserProfileStore

  User->>Route: navigate /novels/:id
  Route->>NovelPage: mount
  NovelPage->>NovelPage: init(id)
  NovelPage->>NovelStore: fetchNovel(id)
  alt novel in cache
    NovelStore-->>NovelPage: Novel (from novelCache)
  else novel not cached
    NovelStore->>PixivClient: getNovel(id)
    PixivClient->>PixivAPI: GET /ajax/novel/:id
    PixivAPI-->>PixivClient: PixivResponse<Novel>
    PixivClient-->>NovelStore: Novel
    NovelStore-->>NovelPage: Novel
  end
  NovelPage->>UserProfileStore: fetchUser(novel.userId)
  UserProfileStore-->>NovelPage: User
  NovelPage->>NovelPage: parseNovelContent(content, textEmbeddedImages)
  NovelPage-->>User: Render NovelReader and metadata
Loading

Flow diagram for novel content parsing and rendering

flowchart LR
  A[Novel.content\nNovel.textEmbeddedImages] --> B[parseNovelContent]
  B --> C["NovelContentBlock[]"]
  C --> D[NovelReader.vue]
  D --> E[Rendered paragraphs, chapters, images, links]

  subgraph Utils
    B
  end

  subgraph Components
    D
  end
Loading

File-Level Changes

Change Details Files
Extend Pixiv web client and shared types to support novel and novel series APIs and user novel metadata.
  • Add Novel, NovelInfo, NovelContentTitle, NovelSeries, NovelSeriesContentResult and related interfaces in a new Novels types module and re-export them from the types index.
  • Augment PixivWebClient with getNovel, getNovelSeries, getNovelSeriesContent and getNovelSeriesContentTitles methods, including query parameter mapping and response unwrapping.
  • Update user-related types and PixivWebClient.getUserProfileTop to use NovelInfo instead of ArtworkInfo for novel collections and user list items.
app/api/pixiv-client.ts
app/types/Novels.ts
app/types/Users.ts
app/types/index.ts
Introduce a Pinia store for fetching and caching novel entities and normalizing series content into NovelInfo items.
  • Create useNovelStore with caches for novels, series, series content and series content titles keyed by id.
  • Implement fetchNovel and fetchNovelSeries that hit the Pixiv client once and cache results.
  • Implement fetchNovelSeriesContent and fetchNovelSeriesContentTitles that prefer thumbnail data when available and normalize series content fallback using a helper that maps NovelSeriesContentItem to NovelInfo.
app/stores/novel.ts
Implement novel content parsing and a reader component to render Pixiv novel markup into structured blocks and inline tokens.
  • Add parseNovelContent to split raw novel content into chapter/divider/paragraph/image blocks based on marker syntax and optional embedded image metadata.
  • Add parseInline to convert inline Pixiv markup (ruby, external links, emphasis marks, page jumps, bold/italic) into a typed token stream with URL safety checks.
  • Add stripHtmlText helper to convert HTML captions/descriptions to plain text while preserving line breaks, and use NovelContentBlock/NovelInlineToken union types throughout.
  • Create NovelReader component that renders blocks/tokens into semantic HTML (ruby, links, emphasis, page dividers, embedded and Pixiv-linked images) with basic styling.
app/utils/novel-content.ts
app/components/Novel/NovelReader.vue
Add novel detail and novel series pages that consume the novel store, render content, and expose navigation and related lists.
  • Create /novels/[id].vue page that fetches a novel and its author, builds contentBlocks via parseNovelContent, derives description and character/reading-time labels, and shows stats, tags, series navigation and related user novels using NovelList and NovelReader.
  • Create /novel/series/[id].vue page that fetches a series, its content list and title list in parallel, computes coverUrl/captionText, and renders a directory view with author info and quick links to first/latest episodes.
  • Wire both pages into routing with named routes and aliases, set document titles via setTitle, and handle route updates and error states with skeleton placeholders and ErrorPage.
app/pages/novels/[id].vue
app/pages/novel/series/[id].vue
Add reusable novel list and card components used on user pages and novel/series pages.
  • Create NovelCard to show a novel’s cover (or SVG fallback), R-18 badge, title, author, and text/reading-time metadata, linking to novel and user routes.
  • Create NovelList to render a responsive grid of NovelCard components and support skeleton loading mode via a numeric or boolean loading prop.
  • Use shared DeferLoad and naive-ui NSkeleton components for lazy image loading and consistent placeholders.
app/components/Novel/NovelCard.vue
app/components/Novel/NovelList.vue
Integrate novels into existing artwork and user interfaces and add legacy URL compatibility handling.
  • Update ArtworkCard to compute a workLink that routes novels to /novels/:id instead of artworks, guard ugoira icon display for non-novel items, and use refs to mutate bookmarkData on the reactive item safely.
  • Extend the user profile page to add a "小说" tab that displays a NovelList when user.novels is non-empty and hides author metadata via shared CSS with other work grids.
  • Add a redirect page /novel/show.php.vue that reads id from query, validates it as numeric, and client-side redirects to /novels/:id (or 404) while showing a skeleton placeholder.
  • Adjust pixiv-proxy middleware to skip proxying /novel/show.php so that the Nuxt route can handle it locally.
app/components/Artwork/ArtworkCard.vue
app/pages/users/[id]/index.vue
app/pages/novel/show.php.vue
server/middleware/pixiv-proxy.ts

Possibly linked issues

  • #[FEAT] 小说页面: PR fully implements the requested novel page, including novel detail, series pages, lists, and supporting APIs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - 我发现了 3 个问题,并给出了一些总体反馈:

  • NovelReader.vue 中,内联 token 的渲染逻辑在普通流程、加粗和斜体分支中被重复实现;建议提取一个用于内联 token 的小型辅助组件或渲染函数,以减少重复,并让后续修改更不容易出错。
  • 在小说系列页面中,coverUrl 回退到 series.firstEpisode?.url,这个字段很可能是 Pixiv 页面 URL 而不是图片 URL;可能更安全的做法是只使用已知的图片字段(如 cover.urls[...]),当没有可用的图片 URL 时,让 UI 回退到占位图。
  • normalizeSeriesContent 中对系列内容做归一化时,你将 restrict 硬编码为 0;如果 API 可能为系列章节返回不同的限制等级,建议像 xRestrict 一样从源数据派生 restrict,以保留访问控制语义。
提供给 AI Agent 的提示
Please address the comments from this code review:

## Overall Comments
- In `NovelReader.vue` the rendering logic for inline tokens is duplicated for normal flow, bold, and italic branches; consider extracting a small helper component or render function for inline tokens to reduce repetition and make future changes less error‑prone.
- In the novel series page, `coverUrl` falls back to `series.firstEpisode?.url`, which is likely a Pixiv page URL rather than an image URL; it may be safer to only use known image fields (`cover.urls[...]`) and let the UI fall back to the placeholder when no image URL is available.
- When normalizing series content in `normalizeSeriesContent`, you hardcode `restrict: 0`; if the API can return different restrict levels for series episodes, consider deriving `restrict` from the source data (similar to `xRestrict`) to preserve access control semantics.

## Individual Comments

### Comment 1
<location path="app/pages/novels/[id].vue" line_range="91-100" />
<code_context>
+import NovelList from '~/components/Novel/NovelList.vue'
+import IFasArrowRight from '~icons/fa-solid/arrow-right'
+import { NButton, NEmpty, NSkeleton } from 'naive-ui'
+import { effect } from 'vue'
+import { useNovelStore } from '~/stores/novel'
+import type { NovelContentTitle, NovelInfo, NovelSeries } from '~/types'
</code_context>
<issue_to_address>
**issue (bug_risk):** Using `effect` from `vue` is likely incorrect; consider `watchEffect` or `watch` instead.

`effect` is an internal API from `@vue/reactivity` and isn’t exported by `vue`, so `import { effect } from 'vue'` will fail. For updating the document title when `novel.value?.title` changes, use the public APIs: e.g. `watchEffect(() => setTitle(...))` or a `watch` on `novel`. Swapping `effect` for `watchEffect` here should resolve the issue without changing behavior.
</issue_to_address>

### Comment 2
<location path="app/pages/novel/series/[id].vue" line_range="73-82" />
<code_context>
+import { effect } from 'vue'
</code_context>
<issue_to_address>
**issue (bug_risk):** Same as novels page: `effect` is not a public Vue API; use `watchEffect` or `watch` instead.

Importing `effect` from `vue` will fail since it’s not part of the public runtime API. Use `watchEffect(() => setTitle(series.value?.title, 'Novel Series'))` instead and update the import accordingly to preserve the same behavior with a supported API.
</issue_to_address>

### Comment 3
<location path="app/utils/novel-content.ts" line_range="8" />
<code_context>
+} from '~/types'
+
+const blockMarkerPattern = /^\[(chapter|newpage|uploadedimage|pixivimage)(?::([^\]]*))?\]$/
+const inlinePattern =
+  /\[\[rb:([^>\]]+)\s*>\s*([^\]]+)\]\]|\[\[jumpuri:([^>\]]+)\s*>\s*([^\]]+)\]\]|\[\[emphasismark:([^>\]]+)\s*>\s*([^\]]+)\]\]|\[jump:(\d+)\]|\[b:([^\]]+)\]|\[i:([^\]]+)\]/g
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the regex handling and image block creation to use named groups and a helper function so the parsing logic is easier to follow and maintain.

You can reduce complexity without changing behavior by:

1. **Replacing positional regex groups with named groups** to avoid brittle `match[1]`/`match[2]` logic.
2. **Extracting image block creation into a helper** so `parseNovelContent`’s main loop is more about control flow.

### 1. Use named capture groups in `inlinePattern`

This keeps the single regex but makes the dispatch logic much easier to read and maintain:

```ts
const inlinePattern =
  /\[\[rb:(?<rubyBase>[^>\]]+)\s*>\s*(?<rubyRuby>[^\]]+)\]\]|
    \[\[jumpuri:(?<linkText>[^>\]]+)\s*>\s*(?<linkHref>[^\]]+)\]\]|
    \[\[emphasismark:(?<emphasisText>[^>\]]+)\s*>\s*(?<emphasisMark>[^\]]+)\]\]|
    \[jump:(?<jumpPage>\d+)\]|
    \[b:(?<boldText>[^\]]+)\]|
    \[i:(?<italicText>[^\]]+)\]/g;
```

And then update `parseInline` to use the named groups instead of numeric indices:

```ts
function parseInline(text: string): NovelInlineToken[] {
  const tokens: NovelInlineToken[] = [];
  let lastIndex = 0;

  for (const match of text.matchAll(inlinePattern)) {
    if (match.index == null) continue;
    if (match.index > lastIndex) {
      tokens.push({ type: 'text', text: text.slice(lastIndex, match.index) });
    }

    const groups = match.groups ?? {};

    if (groups.rubyBase && groups.rubyRuby) {
      tokens.push({
        type: 'ruby',
        base: groups.rubyBase.trim(),
        ruby: groups.rubyRuby.trim(),
      });
    } else if (groups.linkText && groups.linkHref) {
      const href = safeHref(groups.linkHref.trim());
      if (href) {
        tokens.push({
          type: 'link',
          text: groups.linkText.trim(),
          href,
        });
      } else {
        tokens.push({ type: 'text', text: groups.linkText.trim() });
      }
    } else if (groups.emphasisText && groups.emphasisMark) {
      tokens.push({
        type: 'emphasis',
        text: groups.emphasisText.trim(),
        mark: groups.emphasisMark.trim().slice(0, 1),
      });
    } else if (groups.jumpPage) {
      tokens.push({ type: 'jump', page: Number(groups.jumpPage) });
    } else if (groups.boldText) {
      tokens.push({
        type: 'bold',
        inlines: parseInline(groups.boldText.trim()),
      });
    } else if (groups.italicText) {
      tokens.push({
        type: 'italic',
        inlines: parseInline(groups.italicText.trim()),
      });
    }

    lastIndex = match.index + match[0].length;
  }

  if (lastIndex < text.length) {
    tokens.push({ type: 'text', text: text.slice(lastIndex) });
  }

  return tokens.length ? tokens : [{ type: 'text', text }];
}
```

This keeps the current behavior but removes the need to mentally track which index corresponds to which token type.

### 2. Extract image block creation in `parseNovelContent`

You can move the embedded image logic out of the main loop into a small helper:

```ts
function makeImageBlock(
  id: string,
  markerType: 'uploadedimage' | 'pixivimage',
  embeddedImages?: Record<string, NovelTextEmbeddedImage> | null
): NovelContentBlock {
  if (markerType === 'pixivimage') {
    return { type: 'pixivImage', id };
  }

  const embedded = embeddedImages?.[id];
  const src = getEmbeddedImageUrl(embedded);

  if (src) {
    return {
      type: 'uploadedImage',
      id,
      src,
      alt: embedded?.alt || `uploaded image ${id}`,
    };
  }

  return { type: 'pixivImage', id };
}
```

Then the relevant part of the loop simplifies to:

```ts
} else if (markerType === 'uploadedimage' || markerType === 'pixivimage') {
  blocks.push(makeImageBlock(markerValue, markerType, embeddedImages));
}
```

This keeps functionality intact while making the main parsing loop shorter and easier to scan.
</issue_to_address>

Sourcery 对开源项目是免费的——如果你觉得这些 review 有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点击 👍 或 👎,我会根据你的反馈改进后续的 review。
Original comment in English

Hey - I've found 3 issues, and left some high level feedback:

  • In NovelReader.vue the rendering logic for inline tokens is duplicated for normal flow, bold, and italic branches; consider extracting a small helper component or render function for inline tokens to reduce repetition and make future changes less error‑prone.
  • In the novel series page, coverUrl falls back to series.firstEpisode?.url, which is likely a Pixiv page URL rather than an image URL; it may be safer to only use known image fields (cover.urls[...]) and let the UI fall back to the placeholder when no image URL is available.
  • When normalizing series content in normalizeSeriesContent, you hardcode restrict: 0; if the API can return different restrict levels for series episodes, consider deriving restrict from the source data (similar to xRestrict) to preserve access control semantics.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `NovelReader.vue` the rendering logic for inline tokens is duplicated for normal flow, bold, and italic branches; consider extracting a small helper component or render function for inline tokens to reduce repetition and make future changes less error‑prone.
- In the novel series page, `coverUrl` falls back to `series.firstEpisode?.url`, which is likely a Pixiv page URL rather than an image URL; it may be safer to only use known image fields (`cover.urls[...]`) and let the UI fall back to the placeholder when no image URL is available.
- When normalizing series content in `normalizeSeriesContent`, you hardcode `restrict: 0`; if the API can return different restrict levels for series episodes, consider deriving `restrict` from the source data (similar to `xRestrict`) to preserve access control semantics.

## Individual Comments

### Comment 1
<location path="app/pages/novels/[id].vue" line_range="91-100" />
<code_context>
+import NovelList from '~/components/Novel/NovelList.vue'
+import IFasArrowRight from '~icons/fa-solid/arrow-right'
+import { NButton, NEmpty, NSkeleton } from 'naive-ui'
+import { effect } from 'vue'
+import { useNovelStore } from '~/stores/novel'
+import type { NovelContentTitle, NovelInfo, NovelSeries } from '~/types'
</code_context>
<issue_to_address>
**issue (bug_risk):** Using `effect` from `vue` is likely incorrect; consider `watchEffect` or `watch` instead.

`effect` is an internal API from `@vue/reactivity` and isn’t exported by `vue`, so `import { effect } from 'vue'` will fail. For updating the document title when `novel.value?.title` changes, use the public APIs: e.g. `watchEffect(() => setTitle(...))` or a `watch` on `novel`. Swapping `effect` for `watchEffect` here should resolve the issue without changing behavior.
</issue_to_address>

### Comment 2
<location path="app/pages/novel/series/[id].vue" line_range="73-82" />
<code_context>
+import { effect } from 'vue'
</code_context>
<issue_to_address>
**issue (bug_risk):** Same as novels page: `effect` is not a public Vue API; use `watchEffect` or `watch` instead.

Importing `effect` from `vue` will fail since it’s not part of the public runtime API. Use `watchEffect(() => setTitle(series.value?.title, 'Novel Series'))` instead and update the import accordingly to preserve the same behavior with a supported API.
</issue_to_address>

### Comment 3
<location path="app/utils/novel-content.ts" line_range="8" />
<code_context>
+} from '~/types'
+
+const blockMarkerPattern = /^\[(chapter|newpage|uploadedimage|pixivimage)(?::([^\]]*))?\]$/
+const inlinePattern =
+  /\[\[rb:([^>\]]+)\s*>\s*([^\]]+)\]\]|\[\[jumpuri:([^>\]]+)\s*>\s*([^\]]+)\]\]|\[\[emphasismark:([^>\]]+)\s*>\s*([^\]]+)\]\]|\[jump:(\d+)\]|\[b:([^\]]+)\]|\[i:([^\]]+)\]/g
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the regex handling and image block creation to use named groups and a helper function so the parsing logic is easier to follow and maintain.

You can reduce complexity without changing behavior by:

1. **Replacing positional regex groups with named groups** to avoid brittle `match[1]`/`match[2]` logic.
2. **Extracting image block creation into a helper** so `parseNovelContent`’s main loop is more about control flow.

### 1. Use named capture groups in `inlinePattern`

This keeps the single regex but makes the dispatch logic much easier to read and maintain:

```ts
const inlinePattern =
  /\[\[rb:(?<rubyBase>[^>\]]+)\s*>\s*(?<rubyRuby>[^\]]+)\]\]|
    \[\[jumpuri:(?<linkText>[^>\]]+)\s*>\s*(?<linkHref>[^\]]+)\]\]|
    \[\[emphasismark:(?<emphasisText>[^>\]]+)\s*>\s*(?<emphasisMark>[^\]]+)\]\]|
    \[jump:(?<jumpPage>\d+)\]|
    \[b:(?<boldText>[^\]]+)\]|
    \[i:(?<italicText>[^\]]+)\]/g;
```

And then update `parseInline` to use the named groups instead of numeric indices:

```ts
function parseInline(text: string): NovelInlineToken[] {
  const tokens: NovelInlineToken[] = [];
  let lastIndex = 0;

  for (const match of text.matchAll(inlinePattern)) {
    if (match.index == null) continue;
    if (match.index > lastIndex) {
      tokens.push({ type: 'text', text: text.slice(lastIndex, match.index) });
    }

    const groups = match.groups ?? {};

    if (groups.rubyBase && groups.rubyRuby) {
      tokens.push({
        type: 'ruby',
        base: groups.rubyBase.trim(),
        ruby: groups.rubyRuby.trim(),
      });
    } else if (groups.linkText && groups.linkHref) {
      const href = safeHref(groups.linkHref.trim());
      if (href) {
        tokens.push({
          type: 'link',
          text: groups.linkText.trim(),
          href,
        });
      } else {
        tokens.push({ type: 'text', text: groups.linkText.trim() });
      }
    } else if (groups.emphasisText && groups.emphasisMark) {
      tokens.push({
        type: 'emphasis',
        text: groups.emphasisText.trim(),
        mark: groups.emphasisMark.trim().slice(0, 1),
      });
    } else if (groups.jumpPage) {
      tokens.push({ type: 'jump', page: Number(groups.jumpPage) });
    } else if (groups.boldText) {
      tokens.push({
        type: 'bold',
        inlines: parseInline(groups.boldText.trim()),
      });
    } else if (groups.italicText) {
      tokens.push({
        type: 'italic',
        inlines: parseInline(groups.italicText.trim()),
      });
    }

    lastIndex = match.index + match[0].length;
  }

  if (lastIndex < text.length) {
    tokens.push({ type: 'text', text: text.slice(lastIndex) });
  }

  return tokens.length ? tokens : [{ type: 'text', text }];
}
```

This keeps the current behavior but removes the need to mentally track which index corresponds to which token type.

### 2. Extract image block creation in `parseNovelContent`

You can move the embedded image logic out of the main loop into a small helper:

```ts
function makeImageBlock(
  id: string,
  markerType: 'uploadedimage' | 'pixivimage',
  embeddedImages?: Record<string, NovelTextEmbeddedImage> | null
): NovelContentBlock {
  if (markerType === 'pixivimage') {
    return { type: 'pixivImage', id };
  }

  const embedded = embeddedImages?.[id];
  const src = getEmbeddedImageUrl(embedded);

  if (src) {
    return {
      type: 'uploadedImage',
      id,
      src,
      alt: embedded?.alt || `uploaded image ${id}`,
    };
  }

  return { type: 'pixivImage', id };
}
```

Then the relevant part of the loop simplifies to:

```ts
} else if (markerType === 'uploadedimage' || markerType === 'pixivimage') {
  blocks.push(makeImageBlock(markerValue, markerType, embeddedImages));
}
```

This keeps functionality intact while making the main parsing loop shorter and easier to scan.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread app/pages/novels/[id].vue
Comment on lines +91 to +100
import { effect } from 'vue'
import { useNovelStore } from '~/stores/novel'
import { useUserProfileStore } from '~/stores/user-profile'
import type { Novel, NovelInfo, User } from '~/types'
import { parseNovelContent, stripHtmlText } from '~/utils/novel-content'
import { setTitle } from '~/utils/setTitle'

const route = useRoute()
const novelStore = useNovelStore()
const userProfileStore = useUserProfileStore()
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.

issue (bug_risk): 使用从 vue 导入的 effect 很可能是不正确的;建议改用 watchEffectwatch

effect@vue/reactivity 中的内部 API,并不会由 vue 导出,所以 import { effect } from 'vue' 会失败。对于在 novel.value?.title 变化时更新文档标题的需求,请使用公开 API:例如 watchEffect(() => setTitle(...)),或者对 novel 使用 watch。在这里将 effect 替换为 watchEffect 即可在不改变行为的前提下解决问题。

Original comment in English

issue (bug_risk): Using effect from vue is likely incorrect; consider watchEffect or watch instead.

effect is an internal API from @vue/reactivity and isn’t exported by vue, so import { effect } from 'vue' will fail. For updating the document title when novel.value?.title changes, use the public APIs: e.g. watchEffect(() => setTitle(...)) or a watch on novel. Swapping effect for watchEffect here should resolve the issue without changing behavior.

Comment on lines +73 to +82
import { effect } from 'vue'
import { useNovelStore } from '~/stores/novel'
import type { NovelContentTitle, NovelInfo, NovelSeries } from '~/types'
import { stripHtmlText } from '~/utils/novel-content'
import { setTitle } from '~/utils/setTitle'

const route = useRoute()
const novelStore = useNovelStore()
const loading = ref(true)
const error = ref('')
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.

issue (bug_risk): 与小说页面相同:effect 不是 Vue 的公开 API;请使用 watchEffectwatch 替代。

vue 导入 effect 会失败,因为它不是运行时公共 API 的一部分。请使用 watchEffect(() => setTitle(series.value?.title, 'Novel Series')) 并相应更新 import,这样可以在使用受支持 API 的同时保持当前行为。

Original comment in English

issue (bug_risk): Same as novels page: effect is not a public Vue API; use watchEffect or watch instead.

Importing effect from vue will fail since it’s not part of the public runtime API. Use watchEffect(() => setTitle(series.value?.title, 'Novel Series')) instead and update the import accordingly to preserve the same behavior with a supported API.

} from '~/types'

const blockMarkerPattern = /^\[(chapter|newpage|uploadedimage|pixivimage)(?::([^\]]*))?\]$/
const inlinePattern =
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.

issue (complexity): 建议重构正则处理和图片块创建逻辑,使用命名分组和辅助函数,以便让解析逻辑更容易理解和维护。

你可以在不改变行为的前提下降低复杂度,具体可以:

  1. 用命名分组替代基于位置的正则分组,避免脆弱的 match[1]/match[2] 之类的访问方式。
  2. 把图片块创建逻辑提取到一个辅助函数中,让 parseNovelContent 的主循环更多地关注控制流。

1. 在 inlinePattern 中使用命名捕获组

这样可以保留单个正则表达式,同时让分派逻辑更易读、更易维护:

const inlinePattern =
  /\[\[rb:(?<rubyBase>[^>\]]+)\s*>\s*(?<rubyRuby>[^\]]+)\]\]|
    \[\[jumpuri:(?<linkText>[^>\]]+)\s*>\s*(?<linkHref>[^\]]+)\]\]|
    \[\[emphasismark:(?<emphasisText>[^>\]]+)\s*>\s*(?<emphasisMark>[^\]]+)\]\]|
    \[jump:(?<jumpPage>\d+)\]|
    \[b:(?<boldText>[^\]]+)\]|
    \[i:(?<italicText>[^\]]+)\]/g;

然后在 parseInline 中使用命名分组而不是数字索引:

function parseInline(text: string): NovelInlineToken[] {
  const tokens: NovelInlineToken[] = [];
  let lastIndex = 0;

  for (const match of text.matchAll(inlinePattern)) {
    if (match.index == null) continue;
    if (match.index > lastIndex) {
      tokens.push({ type: 'text', text: text.slice(lastIndex, match.index) });
    }

    const groups = match.groups ?? {};

    if (groups.rubyBase && groups.rubyRuby) {
      tokens.push({
        type: 'ruby',
        base: groups.rubyBase.trim(),
        ruby: groups.rubyRuby.trim(),
      });
    } else if (groups.linkText && groups.linkHref) {
      const href = safeHref(groups.linkHref.trim());
      if (href) {
        tokens.push({
          type: 'link',
          text: groups.linkText.trim(),
          href,
        });
      } else {
        tokens.push({ type: 'text', text: groups.linkText.trim() });
      }
    } else if (groups.emphasisText && groups.emphasisMark) {
      tokens.push({
        type: 'emphasis',
        text: groups.emphasisText.trim(),
        mark: groups.emphasisMark.trim().slice(0, 1),
      });
    } else if (groups.jumpPage) {
      tokens.push({ type: 'jump', page: Number(groups.jumpPage) });
    } else if (groups.boldText) {
      tokens.push({
        type: 'bold',
        inlines: parseInline(groups.boldText.trim()),
      });
    } else if (groups.italicText) {
      tokens.push({
        type: 'italic',
        inlines: parseInline(groups.italicText.trim()),
      });
    }

    lastIndex = match.index + match[0].length;
  }

  if (lastIndex < text.length) {
    tokens.push({ type: 'text', text: text.slice(lastIndex) });
  }

  return tokens.length ? tokens : [{ type: 'text', text }];
}

这样既保持了当前行为,又不再需要在脑中记住每个索引对应哪种 token 类型。

2. 在 parseNovelContent 中提取图片块创建逻辑

你可以把嵌入图片的逻辑从主循环中拆出来,放到一个小的辅助函数里:

function makeImageBlock(
  id: string,
  markerType: 'uploadedimage' | 'pixivimage',
  embeddedImages?: Record<string, NovelTextEmbeddedImage> | null
): NovelContentBlock {
  if (markerType === 'pixivimage') {
    return { type: 'pixivImage', id };
  }

  const embedded = embeddedImages?.[id];
  const src = getEmbeddedImageUrl(embedded);

  if (src) {
    return {
      type: 'uploadedImage',
      id,
      src,
      alt: embedded?.alt || `uploaded image ${id}`,
    };
  }

  return { type: 'pixivImage', id };
}

然后循环中相关的部分可以简化为:

} else if (markerType === 'uploadedimage' || markerType === 'pixivimage') {
  blocks.push(makeImageBlock(markerValue, markerType, embeddedImages));
}

这样在保持功能不变的同时,使主解析循环更短、更易于浏览。

Original comment in English

issue (complexity): Consider refactoring the regex handling and image block creation to use named groups and a helper function so the parsing logic is easier to follow and maintain.

You can reduce complexity without changing behavior by:

  1. Replacing positional regex groups with named groups to avoid brittle match[1]/match[2] logic.
  2. Extracting image block creation into a helper so parseNovelContent’s main loop is more about control flow.

1. Use named capture groups in inlinePattern

This keeps the single regex but makes the dispatch logic much easier to read and maintain:

const inlinePattern =
  /\[\[rb:(?<rubyBase>[^>\]]+)\s*>\s*(?<rubyRuby>[^\]]+)\]\]|
    \[\[jumpuri:(?<linkText>[^>\]]+)\s*>\s*(?<linkHref>[^\]]+)\]\]|
    \[\[emphasismark:(?<emphasisText>[^>\]]+)\s*>\s*(?<emphasisMark>[^\]]+)\]\]|
    \[jump:(?<jumpPage>\d+)\]|
    \[b:(?<boldText>[^\]]+)\]|
    \[i:(?<italicText>[^\]]+)\]/g;

And then update parseInline to use the named groups instead of numeric indices:

function parseInline(text: string): NovelInlineToken[] {
  const tokens: NovelInlineToken[] = [];
  let lastIndex = 0;

  for (const match of text.matchAll(inlinePattern)) {
    if (match.index == null) continue;
    if (match.index > lastIndex) {
      tokens.push({ type: 'text', text: text.slice(lastIndex, match.index) });
    }

    const groups = match.groups ?? {};

    if (groups.rubyBase && groups.rubyRuby) {
      tokens.push({
        type: 'ruby',
        base: groups.rubyBase.trim(),
        ruby: groups.rubyRuby.trim(),
      });
    } else if (groups.linkText && groups.linkHref) {
      const href = safeHref(groups.linkHref.trim());
      if (href) {
        tokens.push({
          type: 'link',
          text: groups.linkText.trim(),
          href,
        });
      } else {
        tokens.push({ type: 'text', text: groups.linkText.trim() });
      }
    } else if (groups.emphasisText && groups.emphasisMark) {
      tokens.push({
        type: 'emphasis',
        text: groups.emphasisText.trim(),
        mark: groups.emphasisMark.trim().slice(0, 1),
      });
    } else if (groups.jumpPage) {
      tokens.push({ type: 'jump', page: Number(groups.jumpPage) });
    } else if (groups.boldText) {
      tokens.push({
        type: 'bold',
        inlines: parseInline(groups.boldText.trim()),
      });
    } else if (groups.italicText) {
      tokens.push({
        type: 'italic',
        inlines: parseInline(groups.italicText.trim()),
      });
    }

    lastIndex = match.index + match[0].length;
  }

  if (lastIndex < text.length) {
    tokens.push({ type: 'text', text: text.slice(lastIndex) });
  }

  return tokens.length ? tokens : [{ type: 'text', text }];
}

This keeps the current behavior but removes the need to mentally track which index corresponds to which token type.

2. Extract image block creation in parseNovelContent

You can move the embedded image logic out of the main loop into a small helper:

function makeImageBlock(
  id: string,
  markerType: 'uploadedimage' | 'pixivimage',
  embeddedImages?: Record<string, NovelTextEmbeddedImage> | null
): NovelContentBlock {
  if (markerType === 'pixivimage') {
    return { type: 'pixivImage', id };
  }

  const embedded = embeddedImages?.[id];
  const src = getEmbeddedImageUrl(embedded);

  if (src) {
    return {
      type: 'uploadedImage',
      id,
      src,
      alt: embedded?.alt || `uploaded image ${id}`,
    };
  }

  return { type: 'pixivImage', id };
}

Then the relevant part of the loop simplifies to:

} else if (markerType === 'uploadedimage' || markerType === 'pixivimage') {
  blocks.push(makeImageBlock(markerValue, markerType, embeddedImages));
}

This keeps functionality intact while making the main parsing loop shorter and easier to scan.

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.

1 participant