/** * FreshnessEvaluator - コンテンツ鮮度評価 * * REQ-FRESH-001: 情報鮮度自動評価 * DES-SHIKIGAMI-014 Section 3.6 * TSK-TS-006 * * @version 1.14.0 */ /** * 鮮度レベル */ export type FreshnessLevel = 'fresh' | 'recent' | 'stale' | 'outdated' | 'unknown'; /** * 鮮度評価結果 */ export interface FreshnessResult { /** 鮮度レベル */ level: FreshnessLevel; /** 公開日(検出できた場合) */ publishDate?: Date; /** 更新日(検出できた場合) */ updateDate?: Date; /** 信頼度(0-1) */ confidence: number; /** 経過日数 */ daysOld?: number; /** 鮮度スコア(0-100) */ score: number; /** 検出方法 */ detectionMethod?: string; /** 警告メッセージ */ warnings?: string[]; /** メタデータ */ metadata?: { source: string; rawDate?: string; }; } /** * 鮮度評価オプション */ export interface FreshnessOptions { /** 「fresh」とみなす日数閾値 */ freshThresholdDays?: number; /** 「recent」とみなす日数閾値 */ recentThresholdDays?: number; /** 「stale」とみなす日数閾値 */ staleThresholdDays?: number; /** 基準日(デフォルト: 現在) */ referenceDate?: Date; /** トピック別の閾値を使用するか */ topicAware?: boolean; } /** * デフォルトオプション */ export const DEFAULT_FRESHNESS_OPTIONS: Required = { freshThresholdDays: 30, recentThresholdDays: 90, staleThresholdDays: 365, referenceDate: new Date(), topicAware: false, }; /** * トピック別の鮮度閾値 */ export const TOPIC_FRESHNESS_THRESHOLDS: Record< string, { fresh: number; recent: number; stale: number } > = { // 急速に変化するトピック news: { fresh: 1, recent: 7, stale: 30 }, cryptocurrency: { fresh: 1, recent: 7, stale: 30 }, stock_market: { fresh: 1, recent: 7, stale: 30 }, ai_ml: { fresh: 7, recent: 30, stale: 90 }, technology: { fresh: 14, recent: 60, stale: 180 }, startup: { fresh: 14, recent: 60, stale: 180 }, // 中程度の変化 business: { fresh: 30, recent: 90, stale: 365 }, market_research: { fresh: 30, recent: 90, stale: 365 }, regulation: { fresh: 30, recent: 90, stale: 365 }, // 安定したトピック academic: { fresh: 365, recent: 730, stale: 1825 }, medical: { fresh: 180, recent: 365, stale: 730 }, legal: { fresh: 180, recent: 365, stale: 730 }, history: { fresh: 730, recent: 1825, stale: 3650 }, }; /** * 日付検出パターン */ const DATE_PATTERNS = { // メタタグ meta: { // 各種メタタグのname/property属性 names: [ 'article:published_time', 'article:modified_time', 'datePublished', 'dateModified', 'date', 'pubdate', 'publish_date', 'DC.date.issued', 'og:article:published_time', 'og:updated_time', ], // JSON-LD内のキー jsonLd: ['datePublished', 'dateModified', 'dateCreated', 'uploadDate'], }, // テキスト内の日付パターン text: { // 日本語 jpDate: /(\d{4})年(\d{1,2})月(\d{1,2})日/g, jpDateShort: /(\d{2,4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/g, // 英語 enDateLong: /(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})/gi, enDateShort: /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4})/gi, // ISO形式 isoDate: /(\d{4})-(\d{2})-(\d{2})(?:T[\d:]+)?/g, // 更新表記 updated: /(?:更新日?|Updated|Modified|Revised)[:\s]*(\d{4})[\/\-\.]?(\d{1,2})[\/\-\.]?(\d{1,2})?/gi, published: /(?:公開日?|Published|Posted|Created)[:\s]*(\d{4})[\/\-\.]?(\d{1,2})[\/\-\.]?(\d{1,2})?/gi, }, }; /** * 月名のマッピング */ const MONTH_MAP: Record = { january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7, august: 8, september: 9, october: 10, november: 11, december: 12, jan: 1, feb: 2, mar: 3, apr: 4, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, }; /** * HTMLから日付を抽出 */ export function extractDateFromHTML(html: string): { publishDate?: Date; updateDate?: Date; confidence: number; method: string; } { let publishDate: Date | undefined; let updateDate: Date | undefined; let confidence = 0; let method = 'none'; // 1. JSON-LD から抽出(最も信頼性が高い) const jsonLdMatch = html.match( /]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi ); if (jsonLdMatch) { for (const match of jsonLdMatch) { try { const jsonContent = match.replace(/<\/?script[^>]*>/gi, ''); const data = JSON.parse(jsonContent); for (const key of DATE_PATTERNS.meta.jsonLd) { const dateStr = findNestedValue(data, key); if (dateStr) { const parsed = new Date(dateStr); if (!isNaN(parsed.getTime())) { if (key.includes('Modified') || key.includes('updated')) { updateDate = parsed; } else { publishDate = parsed; } confidence = Math.max(confidence, 0.95); method = 'json-ld'; } } } } catch { // JSON解析エラーは無視 } } } // 2. メタタグから抽出 if (!publishDate) { for (const name of DATE_PATTERNS.meta.names) { const metaMatch = html.match( new RegExp( `]+(?:name|property)=["']${name}["'][^>]+content=["']([^"']+)["']`, 'i' ) ); if (metaMatch) { const parsed = new Date(metaMatch[1]); if (!isNaN(parsed.getTime())) { if (name.includes('modified') || name.includes('updated')) { updateDate = parsed; } else { publishDate = parsed; } confidence = Math.max(confidence, 0.9); method = method === 'none' ? 'meta-tag' : method; } } } } // 3.