diff --git a/electron/main/ai/tasks/qimao-ranking.test.mjs b/electron/main/ai/tasks/qimao-ranking.test.mjs new file mode 100644 index 00000000..ac118772 --- /dev/null +++ b/electron/main/ai/tasks/qimao-ranking.test.mjs @@ -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, /点击“开始扫榜”后/) +}) diff --git a/electron/main/qimao-trends.ts b/electron/main/qimao-trends.ts new file mode 100644 index 00000000..08cfd6eb --- /dev/null +++ b/electron/main/qimao-trends.ts @@ -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 { + 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 + }) +} diff --git a/electron/main/register-main-ipc.ts b/electron/main/register-main-ipc.ts index d0d44c5d..b6a3bc18 100644 --- a/electron/main/register-main-ipc.ts +++ b/electron/main/register-main-ipc.ts @@ -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 { @@ -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, diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6cb88a0e..74dad239 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -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 }), diff --git a/package.json b/package.json index ee6fd8ca..3b7f5601 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/renderer/src/env.d.ts b/renderer/src/env.d.ts index c94c61f7..7ed5b9f2 100644 --- a/renderer/src/env.d.ts +++ b/renderer/src/env.d.ts @@ -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 diff --git a/renderer/src/features/ranking/qimaoRanking.ts b/renderer/src/features/ranking/qimaoRanking.ts new file mode 100644 index 00000000..b5d7a1e9 --- /dev/null +++ b/renderer/src/features/ranking/qimaoRanking.ts @@ -0,0 +1,169 @@ +export interface QimaoBoardMeta { + slug: string + name: string + channel: string + categories: string[] + latestDate?: string + bookCount?: number +} + +type AnyRecord = Record + +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() + 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 + ] + } + } +} diff --git a/renderer/src/pages/FanqieTrendsPage.vue b/renderer/src/pages/FanqieTrendsPage.vue index 1f284372..062ca87c 100644 --- a/renderer/src/pages/FanqieTrendsPage.vue +++ b/renderer/src/pages/FanqieTrendsPage.vue @@ -5,11 +5,16 @@ import { NButton, NCard, NModal, NSelect, useMessage } from 'naive-ui' import { useAppStore } from '@/stores/app' import { renderMarkdown } from '@/composables/useGlobalAssistant' import { toIpcPayload } from '@/utils/ipcPayload' +import { + buildQimaoViewData, + parseQimaoBoardCatalog, + type QimaoBoardMeta +} from '@/features/ranking/qimaoRanking' const appStore = useAppStore() const message = useMessage() -type Platform = 'fanqie' | 'qidian' +type Platform = 'fanqie' | 'qidian' | 'qimao' type ScanStage = 'setup' | 'running' | 'error' | 'report' | 'ideating' | 'idea-error' | 'ideas' | 'cancelled' type RankingScanStatus = 'draft' | 'running' | 'success' | 'failed' | 'cancelled' @@ -115,6 +120,8 @@ const scanSampleLimit = ref(40) const scanCancelRequested = ref(false) const ideaCancelRequested = ref(false) const scanBusy = computed(() => scanLoading.value || scanStage.value === 'ideating') +const scanCatalogLoading = ref(false) +const qimaoBoards = ref([]) function openBookUrl(url: unknown): void { const target = typeof url === 'string' ? url.trim() : '' @@ -172,7 +179,7 @@ const QIDIAN_CATEGORIES = [ { label: '悬疑灵异', value: '10' }, { label: '轻小说', value: '12' } ] -const SCAN_BOARD_CATALOG: Record> = { +const SCAN_BOARD_CATALOG: Record, Array<{ label: string; value: string }>> = { fanqie: [ { label: '女频新书榜', value: 'female-new' }, { label: '男频新书榜', value: 'male-new' } @@ -209,22 +216,47 @@ const boardEmptyName = ref('') const boardEmpty = ref(false) let boardRequestId = 0 -const pageTitle = computed(() => platform.value === 'qidian' ? '起点风向标' : '番茄风向标') -const pageSubtitle = computed(() => platform.value === 'qidian' - ? '起点中文网榜单 · 支持榜单与作品分类组合筛选' - : '番茄小说榜单 · 每日趋势与题材风向') -const pageSource = computed(() => platform.value === 'qidian' - ? '抓取逻辑参考 QiuNova/Qbook(MIT)' - : '数据来源于 https://github.com/uu201/FanqieRankTracker') +const pageTitle = computed(() => ({ + fanqie: '番茄风向标', + qidian: '起点风向标', + qimao: '七猫风向标' +})[platform.value]) +const pageSubtitle = computed(() => ({ + fanqie: '番茄小说榜单 · 每日趋势与题材风向', + qidian: '起点中文网榜单 · 支持榜单与作品分类组合筛选', + qimao: '七猫中文网 · 男女频 16 个榜单与动态作品分类' +})[platform.value]) +const pageSource = computed(() => ({ + fanqie: '数据来源于 https://github.com/uu201/FanqieRankTracker', + qidian: '抓取逻辑参考 QiuNova/Qbook(MIT)', + qimao: '公开榜单数据来源于 siweimidu/QiMaoRankTracker2' +})[platform.value]) const scanReportHtml = computed(() => renderMarkdown(scanReport.value)) -const scanBoardOptions = computed(() => SCAN_BOARD_CATALOG[scanSelectedPlatform.value] || []) -const scanSelectedPlatformLabel = computed(() => scanSelectedPlatform.value === 'qidian' ? '起点中文网' : '番茄小说') +const scanBoardOptions = computed(() => scanSelectedPlatform.value === 'qimao' + ? qimaoBoards.value.map((board) => ({ label: board.name, value: board.slug })) + : SCAN_BOARD_CATALOG[scanSelectedPlatform.value] || []) +const scanSelectedPlatformLabel = computed(() => ({ + fanqie: '番茄小说', + qidian: '起点中文网', + qimao: '七猫中文网' +})[scanSelectedPlatform.value]) const scanSelectedBoardLabel = computed(() => scanBoardOptions.value.find((option) => option.value === scanSelectedBoard.value)?.label || '当前榜单' ) +const scanCategoryOptions = computed(() => { + if (scanSelectedPlatform.value === 'qidian') return QIDIAN_CATEGORIES + if (scanSelectedPlatform.value !== 'qimao') return [] + const board = qimaoBoards.value.find((item) => item.slug === scanSelectedBoard.value) + return [ + { label: '全部分类', value: '__all__' }, + ...(board?.categories || []).map((category) => ({ label: category, value: category })) + ] +}) const scanSelectedCategoryLabel = computed(() => - QIDIAN_CATEGORIES.find((option) => option.value === scanSelectedCategory.value)?.label || '全部分类' + scanCategoryOptions.value.find((option) => option.value === scanSelectedCategory.value)?.label || '全部分类' ) +const scanHasCategory = computed(() => scanSelectedPlatform.value === 'qidian' || scanSelectedPlatform.value === 'qimao') +const scanCategorySuffix = computed(() => scanHasCategory.value ? ` · ${scanSelectedCategoryLabel.value}` : '') const selectedDirection = computed(() => ideaDirections.value.find((direction) => direction.id === selectedDirectionId.value) || null ) @@ -236,7 +268,7 @@ const scanModalTitle = computed(() => { if (['ideating', 'ideas', 'idea-error'].includes(scanStage.value)) { return `${scanSelectedPlatformLabel.value} · 从榜单到新书` } - return `${scanSelectedPlatformLabel.value} · ${scanSelectedBoardLabel.value}${scanSelectedPlatform.value === 'qidian' ? ` · ${scanSelectedCategoryLabel.value}` : ''}风格报告` + return `${scanSelectedPlatformLabel.value} · ${scanSelectedBoardLabel.value}${scanCategorySuffix.value}风格报告` }) const scanCurrentStageLabel = computed(() => RANKING_SCAN_STAGES[Math.min(scanCurrentStage.value, RANKING_SCAN_STAGES.length - 1)] || '准备分析参数') const filteredScanHistory = computed(() => { @@ -266,7 +298,11 @@ function normalizeRankingScanRecord(input: unknown): RankingScanHistoryRecord | return { id: String(value.id || ''), projectId: String(value.projectId || ''), - platform: value.platform === 'qidian' ? 'qidian' : 'fanqie', + platform: value.platform === 'qidian' + ? 'qidian' + : value.platform === 'qimao' + ? 'qimao' + : 'fanqie', board: String(value.board || ''), category: String(value.category || ''), sampleCount: Number(value.sampleCount || 0), @@ -320,6 +356,26 @@ async function fetchJson(path: string, force = false): Promise { return res.data as AnyRecord } +async function fetchQimaoJson(path: string, force = false): Promise { + const res = await window.characterArc.fetchQimaoTrends(path, force) + if (!res.success || res.data == null) throw new Error(res.error || '七猫榜单加载失败') + if (res.mirror) srcNote.value = `${res.fromCache ? '缓存 · ' : '镜像 · '}${res.mirror}` + return res.data as AnyRecord +} + +async function ensureQimaoBoardCatalog(force = false): Promise { + if (!force && qimaoBoards.value.length) return qimaoBoards.value + scanCatalogLoading.value = true + try { + const catalog = parseQimaoBoardCatalog(await fetchQimaoJson('api/boards.json', force)) + if (!catalog.length) throw new Error('七猫榜单目录为空') + qimaoBoards.value = catalog + return catalog + } finally { + scanCatalogLoading.value = false + } +} + // ===== 切换榜单 ===== async function switchBoard(slug: string, force = false): Promise { const board = boardsList.value.find((b) => b.slug === slug) @@ -369,6 +425,24 @@ async function switchBoard(slug: string, force = false): Promise { return } + if (platform.value === 'qimao') { + const [boardData, marketSummary] = await Promise.all([ + fetchQimaoJson(`api/${slug}/latest/all.json`, force), + fetchQimaoJson(`api/${slug}/market_summary.json`, force).catch(() => ({})) + ]) + if (requestId !== boardRequestId || platform.value !== requestedPlatform) return + const adapted = buildQimaoViewData(boardData, marketSummary) + summaryData.value = adapted.summaryData + allData.value = adapted.allData + curPeriod.value = 'all' + dataDate.value = '数据日期 ' + (adapted.allData.date || '—') + dataPrev.value = adapted.allData.prev_date ? `对比 ${adapted.allData.prev_date}` : '' + curCat.value = adapted.allData.categories[0]?.name || null + loading.value = false + switching.value = false + return + } + const [summary, all] = await Promise.all([ fetchJson(`data/${slug}/market_summary.json`, force), fetchJson(`api/${slug}/lastest/all.json`, force) @@ -402,6 +476,24 @@ async function loadAll(force = false): Promise { return } + + if (platform.value === 'qimao') { + const catalog = await ensureQimaoBoardCatalog(force) + boardsList.value = catalog.map((board) => ({ + slug: board.slug, + name: board.name, + channel: board.channel, + has_genres: board.categories.length > 0, + _empty: board.bookCount === 0 + })) + const firstReady = boardsList.value.find((board) => board.slug === curBoard.value && !board._empty) + || boardsList.value.find((board) => !board._empty) + || boardsList.value[0] + if (firstReady) await switchBoard(firstReady.slug, force) + else loading.value = false + return + } + let known: BoardItem[] = [] try { known = ((await fetchJson('api/boards.json', force)).boards || []) as BoardItem[] @@ -453,11 +545,25 @@ function bookMeta(book: AnyRecord): string { if (platform.value === 'qidian') { return book.qidianMeta || [book.author, book.category, book.word_display].filter(Boolean).join(' · ') } + if (platform.value === 'qimao') { + return [ + book.author || '未知作者', + book.category || '未分类', + book.word_display, + Number(book.heat || book.reads) > 0 ? `热度 ${fmtScore(Number(book.heat || book.reads))}` : '' + ].filter(Boolean).join(' · ') + } return `${book.author || '未知作者'} · ${book.reads || 0} 在读` } function collectRankingScanBooks(limit = scanSampleLimit.value): AnyRecord[] { - const groups = categories.value.map((category) => ({ + const qimaoCategory = platform.value === 'qimao' && scanSelectedCategory.value !== '__all__' + ? scanSelectedCategory.value + : '' + const sourceCategories = qimaoCategory + ? categories.value.filter((category) => category.name === qimaoCategory) + : categories.value.filter((category) => category.name !== '全部' || categories.value.length === 1) + const groups = sourceCategories.map((category) => ({ name: category.name, books: Array.isArray(category.books) ? category.books : [] })) @@ -502,7 +608,7 @@ function currentScanRecord(status: RankingScanStatus = scanStage.value === 'runn projectId: appStore.selectedProjectId || '', platform: scanSelectedPlatform.value, board: scanSelectedBoardLabel.value, - category: scanSelectedPlatform.value === 'qidian' ? scanSelectedCategoryLabel.value : '全部分类', + category: scanHasCategory.value ? scanSelectedCategoryLabel.value : '全部分类', sampleCount: scanSampleCount.value || existing?.sampleCount || 0, analysisModes: scanAnalysisModes.value, model: appStore.appSettings.model || '当前模型', @@ -576,19 +682,33 @@ function updateScanProgress(progress: number, stage: number, message?: string, l else persistScanHistory() } -function openRankingHistory(record: RankingScanHistoryRecord): void { +async function openRankingHistory(record: RankingScanHistoryRecord): Promise { if (scanBusy.value) { message.warning('当前任务仍在运行,请先取消或等待完成') return } scanActiveId.value = record.id scanSelectedPlatform.value = record.platform - scanSelectedBoard.value = SCAN_BOARD_CATALOG[record.platform].some((option) => option.label === record.board || option.value === record.board) - ? (SCAN_BOARD_CATALOG[record.platform].find((option) => option.label === record.board || option.value === record.board)?.value || SCAN_BOARD_CATALOG[record.platform][0]?.value || '') - : SCAN_BOARD_CATALOG[record.platform][0]?.value || '' - scanSelectedCategory.value = record.platform === 'qidian' - ? (QIDIAN_CATEGORIES.find((option) => option.label === record.category || option.value === record.category)?.value || '-1') - : '-1' + if (record.platform === 'qimao') { + try { + const catalog = await ensureQimaoBoardCatalog() + const board = catalog.find((option) => option.name === record.board || option.slug === record.board) || catalog[0] + scanSelectedBoard.value = board?.slug || '' + scanSelectedCategory.value = board?.categories.includes(record.category) ? record.category : '__all__' + } catch (error) { + message.error(error instanceof Error ? error.message : '加载七猫榜单目录失败') + scanSelectedBoard.value = '' + scanSelectedCategory.value = '__all__' + } + } else { + const options = SCAN_BOARD_CATALOG[record.platform] + scanSelectedBoard.value = options.find((option) => option.label === record.board || option.value === record.board)?.value + || options[0]?.value + || '' + scanSelectedCategory.value = record.platform === 'qidian' + ? (QIDIAN_CATEGORIES.find((option) => option.label === record.category || option.value === record.category)?.value || '-1') + : '-1' + } scanProgress.value = record.progress scanCurrentStage.value = record.currentStage scanLogs.value = record.logs @@ -662,17 +782,35 @@ async function cancelIdeaGeneration(): Promise { function openRankingStyleScan(): void { scanSelectedPlatform.value = platform.value - const availableBoards = SCAN_BOARD_CATALOG[platform.value] || [] + const availableBoards = platform.value === 'qimao' + ? qimaoBoards.value.map((board) => ({ label: board.name, value: board.slug })) + : SCAN_BOARD_CATALOG[platform.value] scanSelectedBoard.value = availableBoards.some((option) => option.value === curBoard.value) ? String(curBoard.value) : availableBoards[0]?.value || '' - scanSelectedCategory.value = platform.value === 'qidian' ? qidianCategory.value : '-1' + scanSelectedCategory.value = platform.value === 'qidian' + ? qidianCategory.value + : platform.value === 'qimao' && curCat.value && curCat.value !== '全部' + ? curCat.value + : platform.value === 'qimao' ? '__all__' : '-1' createNewRankingScan() } -function updateScanPlatform(value: Platform): void { - if (value !== 'fanqie' && value !== 'qidian') return +async function updateScanPlatform(value: Platform): Promise { + if (value !== 'fanqie' && value !== 'qidian' && value !== 'qimao') return scanSelectedPlatform.value = value + if (value === 'qimao') { + try { + const catalog = await ensureQimaoBoardCatalog() + if (scanSelectedPlatform.value !== value) return + scanSelectedBoard.value = catalog[0]?.slug || '' + scanSelectedCategory.value = '__all__' + } catch (error) { + message.error(error instanceof Error ? error.message : '加载七猫榜单目录失败') + scanSelectedBoard.value = '' + } + return + } scanSelectedBoard.value = SCAN_BOARD_CATALOG[value]?.[0]?.value || '' scanSelectedCategory.value = value === 'qidian' ? qidianCategory.value : '-1' } @@ -683,6 +821,13 @@ function toggleScanAnalysisMode(mode: string): void { : [...scanAnalysisModes.value, mode] } +function updateScanBoard(value: string): void { + scanSelectedBoard.value = value + scanSelectedCategory.value = scanSelectedPlatform.value === 'qidian' + ? '-1' + : scanSelectedPlatform.value === 'qimao' ? '__all__' : '-1' +} + function returnToScanSetup(): void { scanStage.value = 'setup' scanLoading.value = false @@ -692,7 +837,9 @@ function returnToScanSetup(): void { async function prepareSelectedRankingForScan(): Promise { const targetPlatform = scanSelectedPlatform.value const targetBoard = scanSelectedBoard.value - const targetCategory = targetPlatform === 'qidian' ? scanSelectedCategory.value : '-1' + const targetCategory = targetPlatform === 'qidian' || targetPlatform === 'qimao' + ? scanSelectedCategory.value + : '-1' if (!targetBoard) throw new Error('请先选择要分析的榜单') if (platform.value !== targetPlatform) { @@ -717,6 +864,26 @@ async function prepareSelectedRankingForScan(): Promise { if (curBoard.value !== targetBoard || !allData.value) { throw new Error(errorMsg.value || '所选榜单数据加载失败') } + if (targetPlatform === 'qimao' && targetCategory !== '__all__') { + const selectedCategory = categories.value.find((category) => category.name === targetCategory) + if (!selectedCategory) throw new Error(`所选分类“${targetCategory}”在当前榜单中不可用`) + curCat.value = targetCategory + } +} + +function currentScanCategoryKey(): string { + if (platform.value === 'qidian') return qidianCategory.value + if (platform.value === 'qimao') return scanSelectedCategory.value || '__all__' + return 'all' +} + +function currentPlatformName(): string { + return ({ fanqie: '番茄小说', qidian: '起点中文网', qimao: '七猫中文网' })[platform.value] +} + +function currentScanBoardName(): string { + const board = curBoardItem.value?.name || '当前榜单' + return `${board}${platform.value === 'qidian' || platform.value === 'qimao' ? ` · ${scanSelectedCategoryLabel.value}` : ''}` } async function runRankingStyleScan(force = false): Promise { @@ -756,7 +923,7 @@ async function runRankingStyleScan(force = false): Promise { updateScanProgress(44, 2, `已筛选 ${books.length} 条有效样本`) persistScanHistory('running') - const cacheKey = `${platform.value}:${curBoard.value || 'default'}:${platform.value === 'qidian' ? qidianCategory.value : 'all'}:${dataDate.value}` + const cacheKey = `${platform.value}:${curBoard.value || 'default'}:${currentScanCategoryKey()}:${dataDate.value}` const cachedReport = scanCache.get(cacheKey) if (!force && cachedReport) { scanReport.value = cachedReport.content @@ -773,14 +940,13 @@ async function runRankingStyleScan(force = false): Promise { scanReport.value = '' scanGeneratedAt.value = '' updateScanProgress(58, 3, '正在生成市场风向报告') - const board = curBoardItem.value const response = await window.characterArc.generateAi(toIpcPayload({ clientTaskId: scanClientTaskId.value, task: 'ranking-style-analysis', settings: appStore.appSettings, context: { - platformName: platform.value === 'qidian' ? '起点中文网' : '番茄小说', - boardName: `${board?.name || '当前榜单'}${platform.value === 'qidian' ? ` · ${QIDIAN_CATEGORIES.find((option) => option.value === qidianCategory.value)?.label || '全部分类'}` : ''}`, + platformName: currentPlatformName(), + boardName: currentScanBoardName(), dataDate: dataDate.value.replace(/^数据日期\s*/, ''), analysisModes: scanAnalysisModes.value, books @@ -839,9 +1005,8 @@ async function generateIdeaDirections(force = false): Promise { await prepareSelectedRankingForScan() if (ideaCancelRequested.value) return const books = collectRankingScanBooks() - const board = curBoardItem.value - const boardName = `${board?.name || '当前榜单'}${platform.value === 'qidian' ? ` · ${QIDIAN_CATEGORIES.find((option) => option.value === qidianCategory.value)?.label || '全部分类'}` : ''}` - const ideaCacheKey = `${platform.value}:${curBoard.value || 'default'}:${platform.value === 'qidian' ? qidianCategory.value : 'all'}:${dataDate.value}:${scanReport.value.slice(0, 120)}` + const boardName = currentScanBoardName() + const ideaCacheKey = `${platform.value}:${curBoard.value || 'default'}:${currentScanCategoryKey()}:${dataDate.value}:${scanReport.value.slice(0, 120)}` const cachedIdeas = ideaCache.get(ideaCacheKey) if (!force && cachedIdeas) { ideaDirections.value = cachedIdeas @@ -857,7 +1022,7 @@ async function generateIdeaDirections(force = false): Promise { task: 'ranking-idea-combinations', settings: appStore.appSettings, context: { - platformName: platform.value === 'qidian' ? '起点中文网' : '番茄小说', + platformName: currentPlatformName(), boardName, scanReport: scanReport.value, books @@ -1001,6 +1166,11 @@ const curCatTrend = computed(() => curCatData.value?.trend || {}) const curCatSummaryHtml = computed(() => String(curCatTrend.value.summary || '暂无该分类速评') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') .replace(/\n/g, '
') .replace(/\*\*(.+?)\*\*/g, '$1') ) @@ -1042,6 +1212,7 @@ onBeforeUnmount(() => {
+
@@ -1106,7 +1277,7 @@ onBeforeUnmount(() => {