Просмотр исходного кода

feat: pluggable radar via RadarSource interface

Extract FanqieRadarSource, QidianRadarSource, TextRadarSource into
radar-source.ts. RadarAgent now accepts RadarSource[] via constructor
instead of hardcoded platform list.
majx_mac 8 месяцев назад
Родитель
Сommit
190f1aeec3
3 измененных файлов с 173 добавлено и 11 удалено
  1. 123 0
      packages/core/src/agents/radar-source.ts
  2. 47 10
      packages/core/src/agents/radar.ts
  3. 3 1
      packages/core/src/index.ts

+ 123 - 0
packages/core/src/agents/radar-source.ts

@@ -0,0 +1,123 @@
+export interface RankingEntry {
+  readonly title: string;
+  readonly author: string;
+  readonly category: string;
+  readonly extra: string;
+}
+
+export interface PlatformRankings {
+  readonly platform: string;
+  readonly entries: ReadonlyArray<RankingEntry>;
+}
+
+/**
+ * Pluggable data source for the Radar agent.
+ * Implement this interface to feed custom ranking/trend data
+ * (e.g. from OpenClaw, custom scrapers, paid APIs).
+ */
+export interface RadarSource {
+  readonly name: string;
+  fetch(): Promise<PlatformRankings>;
+}
+
+/**
+ * Wraps raw natural language text as a radar source.
+ * Use this to inject external analysis (e.g. from OpenClaw) into the radar pipeline.
+ */
+export class TextRadarSource implements RadarSource {
+  readonly name: string;
+  private readonly text: string;
+
+  constructor(text: string, name = "external") {
+    this.name = name;
+    this.text = text;
+  }
+
+  async fetch(): Promise<PlatformRankings> {
+    return {
+      platform: this.name,
+      entries: [{ title: this.text, author: "", category: "", extra: "[外部分析]" }],
+    };
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Built-in sources
+// ---------------------------------------------------------------------------
+
+const FANQIE_RANK_TYPES = [
+  { sideType: 10, label: "热门榜" },
+  { sideType: 13, label: "黑马榜" },
+] as const;
+
+export class FanqieRadarSource implements RadarSource {
+  readonly name = "fanqie";
+
+  async fetch(): Promise<PlatformRankings> {
+    const entries: RankingEntry[] = [];
+
+    for (const { sideType, label } of FANQIE_RANK_TYPES) {
+      try {
+        const url = `https://api-lf.fanqiesdk.com/api/novel/channel/homepage/rank/rank_list/v2/?aid=13&limit=15&offset=0&side_type=${sideType}`;
+        const res = await globalThis.fetch(url, {
+          headers: { "User-Agent": "Mozilla/5.0 (compatible; InkOS/0.1)" },
+        });
+        if (!res.ok) continue;
+        const data = (await res.json()) as Record<string, unknown>;
+        const list = (data as { data?: { result?: unknown[] } }).data?.result;
+        if (!Array.isArray(list)) continue;
+
+        for (const item of list) {
+          const rec = item as Record<string, unknown>;
+          entries.push({
+            title: String(rec.book_name ?? ""),
+            author: String(rec.author ?? ""),
+            category: String(rec.category ?? ""),
+            extra: `[${label}]`,
+          });
+        }
+      } catch {
+        // skip on network error
+      }
+    }
+
+    return { platform: "番茄小说", entries };
+  }
+}
+
+export class QidianRadarSource implements RadarSource {
+  readonly name = "qidian";
+
+  async fetch(): Promise<PlatformRankings> {
+    const entries: RankingEntry[] = [];
+
+    try {
+      const url = "https://www.qidian.com/rank/";
+      const res = await globalThis.fetch(url, {
+        headers: {
+          "User-Agent":
+            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+        },
+      });
+      if (!res.ok) return { platform: "起点中文网", entries };
+      const html = await res.text();
+
+      const bookPattern =
+        /<a[^>]*href="\/\/book\.qidian\.com\/info\/(\d+)"[^>]*>([^<]+)<\/a>/g;
+      let match: RegExpExecArray | null;
+      const seen = new Set<string>();
+      while ((match = bookPattern.exec(html)) !== null) {
+        const title = match[2].trim();
+        if (title && !seen.has(title) && title.length > 1 && title.length < 30) {
+          seen.add(title);
+          entries.push({ title, author: "", category: "", extra: "[起点热榜]" });
+        }
+        if (entries.length >= 20) break;
+      }
+    } catch {
+      // skip on network error
+    }
+
+    return { platform: "起点中文网", entries };
+  }
+}

+ 47 - 10
packages/core/src/agents/radar.ts

@@ -1,5 +1,7 @@
 import { BaseAgent } from "./base.js";
 import type { Platform, Genre } from "../models/book.js";
+import type { RadarSource, PlatformRankings } from "./radar-source.js";
+import { FanqieRadarSource, QidianRadarSource } from "./radar-source.js";
 
 export interface RadarResult {
   readonly recommendations: ReadonlyArray<RadarRecommendation>;
@@ -16,21 +18,56 @@ export interface RadarRecommendation {
   readonly benchmarkTitles: ReadonlyArray<string>;
 }
 
+const DEFAULT_SOURCES: ReadonlyArray<RadarSource> = [
+  new FanqieRadarSource(),
+  new QidianRadarSource(),
+];
+
+function formatRankingsForPrompt(rankings: ReadonlyArray<PlatformRankings>): string {
+  const sections = rankings
+    .filter((r) => r.entries.length > 0)
+    .map((r) => {
+      const lines = r.entries.map(
+        (e) => `- ${e.title}${e.author ? ` (${e.author})` : ""}${e.category ? ` [${e.category}]` : ""} ${e.extra}`,
+      );
+      return `### ${r.platform}\n${lines.join("\n")}`;
+    });
+
+  return sections.length > 0
+    ? sections.join("\n\n")
+    : "(未能获取到实时排行数据,请基于你的知识分析)";
+}
+
 export class RadarAgent extends BaseAgent {
+  private readonly sources: ReadonlyArray<RadarSource>;
+
+  constructor(
+    ctx: ConstructorParameters<typeof BaseAgent>[0],
+    sources?: ReadonlyArray<RadarSource>,
+  ) {
+    super(ctx);
+    this.sources = sources ?? DEFAULT_SOURCES;
+  }
+
   get name(): string {
     return "radar";
   }
 
-  async scan(platforms: ReadonlyArray<Platform>): Promise<RadarResult> {
-    const systemPrompt = `你是一个专业的网络小说市场分析师。你的任务是分析当前网文市场热度,推荐有潜力的题材和概念。
+  async scan(): Promise<RadarResult> {
+    const rankings = await Promise.all(this.sources.map((s) => s.fetch()));
+    const rankingsText = formatRankingsForPrompt(rankings);
+
+    const systemPrompt = `你是一个专业的网络小说市场分析师。下面是从各平台实时抓取的排行榜数据,请基于这些真实数据分析市场趋势。
+
+## 实时排行榜数据
 
-你需要基于以下平台的当前趋势进行分析:${platforms.join("、")}
+${rankingsText}
 
 分析维度:
-1. 当前热门题材和标签
-2. 高追读/收藏的作品类型
-3. 市场空白和机会点
-4. 风险提示(过度饱和的题材)
+1. 从排行榜数据中识别当前热门题材和标签
+2. 分析哪些类型的作品占据榜单高位
+3. 发现市场空白和机会点(榜单上缺少但有潜力的方向)
+4. 风险提示(榜单上过度扎堆的题材)
 
 输出格式必须为 JSON:
 {
@@ -40,11 +77,11 @@ export class RadarAgent extends BaseAgent {
       "genre": "题材类型",
       "concept": "一句话概念描述",
       "confidence": 0.0-1.0,
-      "reasoning": "推荐理由",
+      "reasoning": "推荐理由(引用具体榜单数据)",
       "benchmarkTitles": ["对标书1", "对标书2"]
     }
   ],
-  "marketSummary": "整体市场概述"
+  "marketSummary": "整体市场概述(基于真实榜单数据)"
 }
 
 推荐数量:3-5个,按 confidence 降序排列。`;
@@ -54,7 +91,7 @@ export class RadarAgent extends BaseAgent {
         { role: "system", content: systemPrompt },
         {
           role: "user",
-          content: `请分析当前${platforms.join("、")}平台的网文市场热度,给出开书建议。`,
+          content: `请基于上面的实时排行榜数据,分析当前网文市场热度,给出开书建议。`,
         },
       ],
       { temperature: 0.6, maxTokens: 4096 },

+ 3 - 1
packages/core/src/index.ts

@@ -14,10 +14,12 @@ export { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "./
 export { ContinuityAuditor, type AuditResult, type AuditIssue } from "./agents/continuity.js";
 export { ReviserAgent, type ReviseOutput } from "./agents/reviser.js";
 export { RadarAgent, type RadarResult, type RadarRecommendation } from "./agents/radar.js";
+export { FanqieRadarSource, QidianRadarSource, TextRadarSource, type RadarSource, type PlatformRankings, type RankingEntry } from "./agents/radar-source.js";
 
 // Pipeline
-export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult } from "./pipeline/runner.js";
+export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";
 export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
+export { runAgentLoop, AGENT_TOOLS, type AgentLoopOptions } from "./pipeline/agent.js";
 
 // State
 export { StateManager } from "./state/manager.js";