generate_datasets.js 6.44 KB
const fs = require('fs');

const seedData = JSON.parse(fs.readFileSync('../hunt/seed_data.json', 'utf8'));

// Sample 50 records evenly across platforms
const platforms = ['bilibili', 'baidu', 'toutiao', 'zhihu', 'douban'];
const byPlatform = {};
platforms.forEach(p => byPlatform[p] = seedData.filter(r => r.platform === p));

const selected = [];
// Select: 20 bilibili, 12 baidu, 8 toutiao, 5 zhihu, 5 douban = 50
const quotas = { bilibili: 20, baidu: 12, toutiao: 8, zhihu: 5, douban: 5 };
for (const [p, q] of Object.entries(quotas)) {
  selected.push(...byPlatform[p].slice(0, q));
}

function buildInput(record) {
  switch (record.platform) {
    case 'bilibili':
      return {
        platform: 'bilibili',
        endpoint: 'https://api.bilibili.com/x/web-interface/popular',
        raw_item: {
          title: record.topic,
          owner: { name: record.author || '未知UP主' },
          stat: { view: record.hot_value, like: record.likes || 0, coin: 0, danmaku: 0 },
          tnamev2: record.category,
          bvid: record.source_url.replace('https://b23.tv/', '')
        },
        config: { required_fields: ['platform','rank','topic','category','source_url','timestamp'] }
      };
    case 'baidu':
      return {
        platform: 'baidu',
        endpoint: 'https://top.baidu.com/board?tab=realtime',
        raw_html_fragment: `<div class="content_1YWB0"><div class="c-single-text-ellipsis">${record.topic}</div></div>`,
        matched_query: record.topic,
        config: { required_fields: ['platform','rank','topic','category','source_url','timestamp'] }
      };
    case 'toutiao':
      return {
        platform: 'toutiao',
        endpoint: 'https://www.toutiao.com/hot-event/hot-board/',
        raw_json_fragment: { Title: record.topic, Url: '/hot-event/hot-board/' },
        config: { required_fields: ['platform','rank','topic','category','source_url','timestamp'] }
      };
    case 'zhihu':
      return {
        platform: 'zhihu',
        endpoint: 'https://www.zhihu.com/api/v4/search/top_search',
        raw_item: {
          display_query: record.topic,
          query: record.topic
        },
        config: { required_fields: ['platform','rank','topic','category','source_url','timestamp'] }
      };
    case 'douban':
      return {
        platform: 'douban',
        endpoint: 'https://movie.douban.com/chart',
        raw_html_fragment: `<td class="title"><a href="/subject/12345/" title="${record.topic}" class="nbg"></a></td>`,
        matched_title: record.topic,
        config: { required_fields: ['platform','rank','topic','category','source_url','timestamp'] }
      };
    default:
      return { platform: record.platform, raw: record.topic };
  }
}

const samples = selected.map((record, idx) => ({
  id: `tscn-${String(idx+1).padStart(3,'0')}`,
  input: buildInput(record),
  expected_output: {
    platform: record.platform,
    rank: record.rank,
    topic: record.topic,
    hot_value: record.hot_value,
    category: record.category,
    trend_direction: record.trend_direction,
    trend_change: record.trend_change || '0',
    source_url: record.source_url,
    timestamp: record.timestamp,
    crawl_time: record.crawl_time,
    author: record.author || null,
    likes: record.likes || null
  }
}));

// Add edge-case samples
const edgeCases = [
  {
    id: 'tscn-edge-01',
    input: {
      platform: 'bilibili',
      endpoint: 'api.bilibili.com/x/web-interface/popular',
      raw_item: { title: '', owner: {}, stat: {}, tnamev2: '', bvid: '' },
      config: { required_fields: ['platform','rank','topic','category','source_url','timestamp'] }
    },
    expected_output: {
      error: 'EMPTY_RECORD',
      message: '跳过缺失必填字段的记录',
      action: 'continue'
    },
    note: '边界:B站返回空字段记录时的处理'
  },
  {
    id: 'tscn-edge-02',
    input: {
      platform: 'baidu',
      endpoint: 'top.baidu.com/board?tab=realtime',
      raw_html_fragment: '<html>Forbidden</html>',
      http_status: 403,
      config: { fallback: null }
    },
    expected_output: {
      error: 'FORBIDDEN',
      message: '百度热榜返回 403,尝试增加延迟或切换 UA',
      action: 'retry_with_backoff',
      retry_delay_sec: 10
    },
    note: '边界:反爬触发 403 时的异常处理'
  },
  {
    id: 'tscn-edge-03',
    input: {
      platform: 'weibo',
      endpoint: 'weibo.com/ajax/side/hotSearch',
      http_status: 418,
      config: {
        fallback_chain: ['weibo_hot_search_mobile', 'weibo_hot_search_html'],
        current_attempt: 1
      }
    },
    expected_output: {
      error: 'BLOCKED_418',
      message: '主端点 418,触发降级到移动端 API',
      action: 'trigger_fallback',
      next_source: 'weibo_hot_search_mobile'
    },
    note: '边界:微博主 API 失败时的降级逻辑'
  },
  {
    id: 'tscn-edge-04',
    input: {
      platform: 'zhihu',
      endpoint: 'zhihu.com/api/v3/feed/topstory/hot-lists/total',
      http_status: 401,
      config: { fallback: 'zhihu_hot_list_html' }
    },
    expected_output: {
      error: 'AUTHENTICATION_ERROR',
      message: '知乎热榜需登录态,降级到 HTML 页面解析',
      action: 'trigger_fallback',
      next_source: 'zhihu_hot_list_html'
    },
    note: '边界:需登录 API 失败时的降级'
  },
  {
    id: 'tscn-edge-05',
    input: {
      platform: 'mixed',
      records: [
        { platform: 'bilibili', topic: '重复话题A', timestamp: '2026-04-13T10:00:00Z' },
        { platform: 'bilibili', topic: '重复话题A', timestamp: '2026-04-13T10:00:00Z' },
        { platform: 'baidu', topic: '重复话题A', timestamp: '2026-04-13T10:00:00Z' }
      ],
      dedup_key: 'md5(platform + topic + timestamp)'
    },
    expected_output: {
      kept: 2,
      removed: 1,
      reason: 'bilibili 记录因 (platform,topic,timestamp) 完全重复被去重',
      remaining_platforms: ['bilibili', 'baidu']
    },
    note: '边界:跨平台重复数据的去重逻辑'
  }
];

const allSamples = [...samples, ...edgeCases];

// Shuffle deterministically and split 80/20
allSamples.sort((a, b) => a.id.localeCompare(b.id));
const trainCount = Math.floor(allSamples.length * 0.8);
const train = allSamples.slice(0, trainCount);
const test = allSamples.slice(trainCount);

fs.writeFileSync('train.jsonl', train.map(s => JSON.stringify(s)).join('\n') + '\n');
fs.writeFileSync('test.jsonl', test.map(s => JSON.stringify(s)).join('\n') + '\n');

console.log(`Total: ${allSamples.length} | Train: ${train.length} | Test: ${test.length}`);