feat: 支持小说页面#541
Conversation
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
小说内容解析与渲染流程图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
文件级变更
可能关联的 Issue
Tips and commandsInteracting with Sourcery
Customizing Your Experience访问你的 dashboard 来:
Getting HelpOriginal review guide in EnglishReviewer's GuideAdds 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 pagesequenceDiagram
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
Flow diagram for novel content parsing and renderingflowchart 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
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
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>帮我变得更有用!请在每条评论上点击 👍 或 👎,我会根据你的反馈改进后续的 review。
Original comment in English
Hey - I've found 3 issues, and left some high level feedback:
- In
NovelReader.vuethe 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,
coverUrlfalls back toseries.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 hardcoderestrict: 0; if the API can return different restrict levels for series episodes, consider derivingrestrictfrom the source data (similar toxRestrict) 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| 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() |
There was a problem hiding this comment.
issue (bug_risk): 使用从 vue 导入的 effect 很可能是不正确的;建议改用 watchEffect 或 watch。
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.
| 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('') |
There was a problem hiding this comment.
issue (bug_risk): 与小说页面相同:effect 不是 Vue 的公开 API;请使用 watchEffect 或 watch 替代。
从 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 = |
There was a problem hiding this comment.
issue (complexity): 建议重构正则处理和图片块创建逻辑,使用命名分组和辅助函数,以便让解析逻辑更容易理解和维护。
你可以在不改变行为的前提下降低复杂度,具体可以:
- 用命名分组替代基于位置的正则分组,避免脆弱的
match[1]/match[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:
- Replacing positional regex groups with named groups to avoid brittle
match[1]/match[2]logic. - 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.
Summary by Sourcery
为 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:
Enhancements: