Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions electron/main/ai/tasks/qimao-ranking.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'

import {
buildQimaoViewData,
parseQimaoBoardCatalog
} from '../../../../renderer/src/features/ranking/qimaoRanking.ts'

const rootDir = fileURLToPath(new URL('../../../../', import.meta.url))

function readSource(relativePath) {
return readFileSync(new URL(relativePath, `file:///${rootDir.replaceAll('\\', '/')}/`), 'utf8')
}

test('七猫榜单目录过滤非法和重复条目并保留动态分类', () => {
const boards = parseQimaoBoardCatalog({
boards: [
{
slug: 'boy-hot-date',
name: '男生大热日榜',
channel: 'male',
categories: ['都市高武', '都市高武', '东方玄幻'],
book_count: 20
},
{ slug: '../escape', name: '非法路径' },
{ slug: 'boy-hot-date', name: '重复榜单' }
]
})

assert.equal(boards.length, 1)
assert.equal(boards[0].name, '男生大热日榜')
assert.deepEqual(boards[0].categories, ['都市高武', '东方玄幻'])
})

test('七猫数据适配为风向标现有的摘要、分类和作品结构', () => {
const adapted = buildQimaoViewData({
board: { slug: 'girl-new-date', name: '女生新书日榜', period_name: '日榜' },
date: '2026-08-18',
books: [{ book_id: 1, title: '测试新书', author: '作者甲', minor: '年代重生', heat: 12345, is_new: true }],
categories: [{
name: '年代重生',
total_heat: 12345,
books: [{ book_id: 1, title: '测试新书', author: '作者甲', minor: '年代重生', heat: 12345 }],
trend: { summary: '新书势头明显' }
}]
}, {
overview: { text: '七猫女频新书风向', ai_source: 'rule' },
genre_groups: [{ name: '现代言情', total_heat: 12345 }],
keywords: [{ word: '重生', count: 3 }]
})

assert.equal(adapted.summaryData.periods.all.summary, '七猫女频新书风向')
assert.equal(adapted.summaryData.periods.all.hot_themes[0].name, '重生')
assert.equal(adapted.allData.categories[0].name, '全部')
assert.equal(adapted.allData.categories[1].books[0].id, '1')
assert.deepEqual(adapted.allData.categories[0].trend.new_books, ['测试新书'])
})

test('七猫 IPC 只开放榜单目录、最新榜单和市场摘要路径', () => {
const backend = readSource('electron/main/qimao-trends.ts')
const page = readSource('renderer/src/pages/FanqieTrendsPage.vue')

assert.match(backend, /siweimidu\/QiMaoRankTracker2/)
assert.match(backend, /ALLOWED_PATH/)
assert.match(backend, /latest\\\/all\\\.json\|market_summary\\\.json/)
assert.match(page, /updateScanPlatform\('qimao'\)/)
assert.match(page, /scanSelectedPlatform === 'qimao'/)
assert.match(page, /点击“开始扫榜”后/)
})
43 changes: 43 additions & 0 deletions electron/main/qimao-trends.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { fetchWithCache, type FetchWithCacheResult } from './github-mirror'

/**
* 七猫风向标公开静态 API 适配层。
*
* 只允许读取榜单目录、单榜最新数据和单榜市场摘要,避免渲染层把该 IPC
* 当成任意 GitHub 文件代理。数据通过公共镜像回退并缓存在本机。
*/

const REPO = 'siweimidu/QiMaoRankTracker2'
const BRANCH = 'main'
const CACHE_DIR = 'qimao-trends-cache'
const CACHE_TTL_MS = 2 * 60 * 60 * 1000
const ALLOWED_PATH = /^api\/(?:boards\.json|[a-z0-9-]+\/(?:latest\/all\.json|market_summary\.json))$/

export type QimaoTrendsFetchResult = FetchWithCacheResult

export function normalizeQimaoApiPath(remotePath: string): string {
const path = String(remotePath || '').replace(/^\/+/, '').trim()
if (!ALLOWED_PATH.test(path)) throw new Error('不支持的七猫榜单数据路径。')
return path
}

export async function fetchQimaoTrends(
remotePath: string,
force = false
): Promise<QimaoTrendsFetchResult> {
let filePath: string
try {
filePath = normalizeQimaoApiPath(remotePath)
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : '七猫榜单路径无效' }
}

return fetchWithCache({
repo: REPO,
branch: BRANCH,
filePath,
cacheDir: CACHE_DIR,
ttlMs: CACHE_TTL_MS,
force
})
}
7 changes: 7 additions & 0 deletions electron/main/register-main-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { extractReferenceNovelContext, type ReferenceNovelLocalContext } from '.
import { fetchWithCache } from './github-mirror'
import { fetchFanqieTrends } from './fanqie-trends'
import { fetchQidianRank } from './qidian-rank'
import { fetchQimaoTrends } from './qimao-trends'
import { getWorkspaceDirPath } from './workspace-store'
import { inspectContinuationNovelFile } from './continuation-import'
import {
Expand Down Expand Up @@ -1583,6 +1584,12 @@ export function registerMainIpcHandlers(deps: RegisterMainIpcHandlersDeps): void
return fetchFanqieTrends(remotePath, force)
})

// ── 七猫风向标:公开静态榜单 API(主进程缓存 + 路径白名单) ──
ipcMain.handle('characterarc:qimao-trends-fetch', async (_event, payload: { path?: string; force?: boolean }) => {
const remotePath = typeof payload?.path === 'string' ? payload.path : ''
return fetchQimaoTrends(remotePath, payload?.force === true)
})

// ── 起点中文网:公开移动端榜单(按榜单与作品分类缓存) ──
ipcMain.handle('characterarc:qidian-rank-fetch', async (
_event,
Expand Down
3 changes: 3 additions & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ contextBridge.exposeInMainWorld('characterArc', {
/** 抓取番茄风向标榜单数据(主进程带本地缓存,force=true 时强制刷新) */
fetchFanqieTrends: (path: string, force = false) =>
ipcRenderer.invoke('characterarc:fanqie-trends-fetch', { path, force }),
/** 抓取七猫风向标公开静态榜单数据(主进程带本地缓存与路径白名单) */
fetchQimaoTrends: (path: string, force = false) =>
ipcRenderer.invoke('characterarc:qimao-trends-fetch', { path, force }),
/** 抓取起点中文网公开榜单数据(主进程带本地缓存) */
fetchQidianRank: (type: string, category = '-1', force = false) =>
ipcRenderer.invoke('characterarc:qidian-rank-fetch', { type, category, force }),
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"license": "MIT",
"scripts": {
"dev": "electron-vite dev",
"test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs",
"test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs",
"test:ranking": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/main/ai/tasks/ranking-analysis.test.mjs",
"typecheck": "vue-tsc --noEmit",
"build": "pnpm run typecheck && electron-vite build",
Expand Down
8 changes: 8 additions & 0 deletions renderer/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,14 @@ declare global {
mirror?: string
error?: string
}>
fetchQimaoTrends: (path: string, force?: boolean) => Promise<{
success: boolean
data?: unknown
fromCache?: boolean
fetchedAt?: number
mirror?: string
error?: string
}>
fetchQidianRank: (type: string, category?: string, force?: boolean) => Promise<{
success: boolean
data?: unknown
Expand Down
169 changes: 169 additions & 0 deletions renderer/src/features/ranking/qimaoRanking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
export interface QimaoBoardMeta {
slug: string
name: string
channel: string
categories: string[]
latestDate?: string
bookCount?: number
}

type AnyRecord = Record<string, any>

function asRecord(value: unknown): AnyRecord {
return value && typeof value === 'object' ? value as AnyRecord : {}
}

function safeText(value: unknown, max = 120): string {
return String(value ?? '').trim().slice(0, max)
}

function normalizeBook(value: unknown): AnyRecord {
const book = asRecord(value)
return {
...book,
id: safeText(book.book_id || book.id),
title: safeText(book.title, 200) || '未命名作品',
author: safeText(book.author, 120) || '未知作者',
category: safeText(book.category || book.minor, 120) || '未分类',
word_display: safeText(book.word_count_text || book.word_display, 80),
reads: Number(book.heat) || 0,
intro: safeText(book.intro, 3000),
url: safeText(book.url, 1000),
cover: safeText(book.cover, 1000)
}
}

function normalizeTrend(value: unknown, books: AnyRecord[], fallbackSummary: string): AnyRecord {
const trend = asRecord(value)
const newBooks = Array.isArray(trend.new_books)
? trend.new_books.map((item: unknown) => typeof item === 'string' ? item : safeText(asRecord(item).title)).filter(Boolean)
: books.filter((book) => book.is_new === true).map((book) => book.title)
const topRisers = Array.isArray(trend.top_risers) ? trend.top_risers : books
.filter((book) => Number(book.rank_change) > 0)
.sort((a, b) => Number(b.rank_change) - Number(a.rank_change))
.slice(0, 8)
.map((book) => ({ title: book.title, change: `+${Number(book.rank_change)}` }))
const topFallers = Array.isArray(trend.top_fallers) ? trend.top_fallers : books
.filter((book) => Number(book.rank_change) < 0)
.sort((a, b) => Number(a.rank_change) - Number(b.rank_change))
.slice(0, 8)
.map((book) => ({ title: book.title, change: String(Number(book.rank_change)) }))
const heatLeaders = Array.isArray(trend.heat_leaders) ? trend.heat_leaders : books
.filter((book) => Number(book.heat_change) > 0)
.sort((a, b) => Number(b.heat_change) - Number(a.heat_change))
.slice(0, 8)
const readsGrowth = heatLeaders.map((item: unknown) => {
const row = asRecord(item)
return {
title: safeText(row.title),
growth: Number(row.heat_change ?? row.growth) > 0
? `+${Number(row.heat_change ?? row.growth)}`
: String(row.heat_change ?? row.growth ?? '')
}
}).filter((item) => item.title)

return {
...trend,
summary: safeText(trend.summary, 3000) || fallbackSummary || '暂无该分类速评',
new_books: newBooks,
dropped_books: Array.isArray(trend.dropped_books) ? trend.dropped_books : [],
top_risers: topRisers,
top_fallers: topFallers,
reads_growth: readsGrowth
}
}

export function parseQimaoBoardCatalog(payload: unknown): QimaoBoardMeta[] {
const root = asRecord(payload)
if (!Array.isArray(root.boards)) return []
const seen = new Set<string>()
const result: QimaoBoardMeta[] = []
for (const value of root.boards) {
const board = asRecord(value)
const slug = safeText(board.slug, 80)
if (!/^[a-z0-9-]+$/.test(slug) || seen.has(slug)) continue
seen.add(slug)
result.push({
slug,
name: safeText(board.name, 120) || slug,
channel: board.channel === 'female' ? 'female' : 'male',
categories: Array.isArray(board.categories)
? Array.from(new Set(board.categories.map((item: unknown) => safeText(item)).filter(Boolean))).slice(0, 100)
: [],
latestDate: safeText(board.latest_date, 40) || undefined,
bookCount: Number(board.book_count) || 0
})
}
return result
}

export function buildQimaoViewData(boardPayload: unknown, summaryPayload: unknown): {
summaryData: AnyRecord
allData: AnyRecord
} {
const root = asRecord(boardPayload)
const summary = asRecord(summaryPayload)
const board = asRecord(root.board)
const overview = asRecord(summary.overview)
const allBooks = Array.isArray(root.books) ? root.books.map(normalizeBook) : []
const overviewText = safeText(overview.text, 5000) || '暂无七猫市场速评'
const sourceCategories = Array.isArray(root.categories) ? root.categories : []
const categories = sourceCategories.map((value: unknown) => {
const category = asRecord(value)
const books = Array.isArray(category.books) ? category.books.map(normalizeBook) : []
return {
...category,
name: safeText(category.name, 120) || '未分类',
books,
trend: normalizeTrend(category.trend, books, '')
}
})

const allTrend = normalizeTrend({}, allBooks, overviewText)
const genreGroups = Array.isArray(summary.genre_groups) ? summary.genre_groups : []
const keywords = Array.isArray(summary.keywords)
? summary.keywords
: Array.isArray(root.keywords) ? root.keywords : []

return {
summaryData: {
date: safeText(root.date || summary.date, 40),
periods: {
all: {
period: safeText(board.period_name, 40) || '最新一期',
summary: overviewText,
source: overview.ai_source === 'ai' ? 'ai' : 'rule',
hot_genres: genreGroups.map((value: unknown) => {
const group = asRecord(value)
return {
name: safeText(group.name),
score: Number(group.total_heat ?? group.count) || 0,
read_growth_total: Number(group.total_heat ?? group.count) || 0,
new_count: Number(group.change) || 0,
categories: []
}
}).filter((group: AnyRecord) => group.name),
hot_types: categories.map((category: AnyRecord) => ({
name: category.name,
score: Number(category.total_heat ?? category.count) || 0,
read_growth_total: Number(category.total_heat ?? category.count) || 0
})),
hot_themes: keywords.map((value: unknown) => {
const keyword = asRecord(value)
return { name: safeText(keyword.word || keyword.name), count: Number(keyword.count) || 0 }
}).filter((keyword: AnyRecord) => keyword.name)
}
}
},
allData: {
board,
date: safeText(root.date, 40),
prev_date: safeText(root.prev_date, 40),
stats: asRecord(root.stats),
categories: [
{ name: '全部', books: allBooks, trend: allTrend },
...categories
]
}
}
}
Loading
Loading