/** * Paywall Detector * REQ-PAY-001: ペイウォール検知機能 * * @remarks * - ペイウォールの種類を検出 * - 代替アクセス方法を提案 * - 部分コンテンツの割合を推定 * - TSK-013 実装 */ import type { PaywallDetectionResult, PaywallType } from './types.js'; /** * ペイウォール検知クラス */ export class PaywallDetector { // ペイウォールを示すキーワードパターン private readonly paywallIndicators = { hard: [ 'subscribe to continue', 'subscription required', 'premium content', 'members only', 'subscribers only', 'buy this article', 'purchase access', '購読が必要', '有料会員限定', '会員限定記事', ], soft: [ 'free articles remaining', 'articles left this month', 'reached your limit', 'metered paywall', '無料記事の上限', '今月の閲覧上限', ], freemium: [ 'read more with subscription', 'continue reading', 'unlock full article', 'see full content', '続きを読む', '全文を読む', ], registration: [ 'sign up to read', 'create account', 'register to continue', 'log in to read', '無料会員登録', 'ログインして続きを読む', ], subscription: [ 'digital subscription', 'monthly subscription', 'annual subscription', '月額プラン', '年間購読', ], institutional: [ 'institutional access', 'campus access', 'library access', 'university access', '機関購読', '大学アクセス', ], }; // ペイウォール関連のCSSクラス private readonly paywallClasses = [ 'paywall', 'premium-wall', 'subscription-wall', 'member-wall', 'restricted-content', 'locked-content', 'blur-content', 'fade-content', ]; // ペイウォールを示すDOM構造 private readonly paywallElements = [ 'paywall', 'subscription-prompt', 'premium-prompt', 'login-wall', 'registration-wall', ]; /** * ペイウォールを検知 */ detect(html: string, url: string): PaywallDetectionResult { const evidence: string[] = []; let paywallType: PaywallType | undefined; let confidence = 0; let hasPartialContent = false; let accessiblePercentage = 100; const htmlLower = html.toLowerCase(); // 1. キーワードベースの検出 for (const [type, indicators] of Object.entries(this.paywallIndicators)) { for (const indicator of indicators) { if (htmlLower.includes(indicator.toLowerCase())) { paywallType = type as PaywallType; confidence = Math.max(confidence, 0.7); evidence.push(`Keyword detected: "${indicator}"`); } } } // 2. CSSクラスベースの検出 for (const className of this.paywallClasses) { if (htmlLower.includes(`class="${className}"`) || htmlLower.includes(`class='${className}'`) || htmlLower.includes(`class="${className} `) || htmlLower.includes(`class='${className} `)) { confidence = Math.max(confidence, 0.8); evidence.push(`CSS class detected: "${className}"`); } } // 3. DOM要素ベースの検出 for (const element of this.paywallElements) { if (htmlLower.includes(`id="${element}"`) || htmlLower.includes(`id='${element}'`) || htmlLower.includes(`data-${element}`)) { confidence = Math.max(confidence, 0.85); evidence.push(`DOM element detected: "${element}"`); } } // 4. コンテンツの切り詰め検出 const truncationPatterns = [ /\.{3}\s*<\/p>\s*]*class=["'][^"']*paywall/i, /続きを読む|read more|continue reading/i, /]*style=["'][^"']*overflow:\s*hidden/i, /]*style=["'][^"']*max-height:\s*\d+px/i, ]; for (const pattern of truncationPatterns) { if (pattern.test(html)) { hasPartialContent = true; confidence = Math.max(confidence, 0.6); evidence.push('Content truncation detected'); break; } } // 5. ブラー/フェード効果の検出 if ( htmlLower.includes('blur(') || htmlLower.includes('opacity: 0') || htmlLower.includes('gradient-mask') || htmlLower.includes('fade-out') ) { hasPartialContent = true; confidence = Math.max(confidence, 0.65); evidence.push('Visual obscuring effect detected'); } // 6. URL/ドメインベースの検出 const paywallDomains = [ 'nytimes.com', 'wsj.com', 'ft.com', 'economist.com', 'washingtonpost.com', 'bloomberg.com', 'hbr.org', 'nikkei.com', 'asahi.com', 'mainichi.jp', ]; const urlLower = url.toLowerCase(); for (const domain of paywallDomains) { if (urlLower.includes(domain)) { confidence = Math.max(confidence, 0.5); evidence.push(`Known paywall domain: ${domain}`); if (!paywallType) paywallType = 'subscription'; break; } } // 7. アクセス可能コンテンツの割合を推定 if (hasPartialContent || confidence > 0.5) { accessiblePercentage = this.estimateAccessibleContent(html); } // 8. 提案を生成 const suggestions = this.generateSuggestions(paywallType, url, evidence); const isPaywalled = confidence >= 0.5; return { isPaywalled, paywallType: isPaywalled ? paywallType : undefined, confidence, evidence, suggestions, hasPartialContent, accessiblePercentage, }; } /** * アクセス可能なコンテンツの割合を推定 */ private estimateAccessibleContent(html: string): number { // 全体のテキスト長 const fullText = html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); const fullLength = fullText.length; if (fullLength === 0) return 0; // ペイウォール要素より前のコンテンツを探す const paywallPatterns = [ /]*class=["'][^"']*paywall[^"']*["']/i, /]*id=["']paywall["']/i, //i, ]; for (const pattern of paywallPatterns) { const match = html.search(pattern); if (match !== -1) { const beforePaywall = html.substring(0, match).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); return Math.round((beforePaywall.length / fullLength) * 100); } } // 切り詰めパターンを探す const truncationMarkers = ['...', '続きを読む', 'Read more', 'Continue reading']; for (const marker of truncationMarkers) { const idx = fullText.indexOf(marker); if (idx !== -1 && idx < fullLength * 0.5) { return Math.round((idx / fullLength) * 100); } } // デフォルト推定 return 30; } /** * 代替アクセス方法の提案を生成 */ private generateSuggestions( paywallType: PaywallType | undefined, url: string, _evidence: string[] ): string[] { const suggestions: string[] = []; // 共通の提案 suggestions.push(`Archive版を確認: https://web.archive.org/web/*/${url}`); suggestions.push(`Googleキャッシュを確認: https://webcache.googleusercontent.com/search?q=cache:${encodeURIComponent(url)}`); // ペイウォールタイプ別の提案 switch (paywallType) { case 'soft': suggestions.push('シークレットモードまたはCookieクリアで閲覧可能な場合があります'); suggestions.push('別のブラウザを試してください'); break; case 'registration': suggestions.push('無料会員登録で全文閲覧可能です'); suggestions.push('一時メールサービスでの登録を検討してください'); break; case 'institutional': suggestions.push('大学・研究機関のVPNまたはプロキシ経由でアクセスしてください'); suggestions.push('図書館のデータベースアクセスを確認してください'); break; case 'hard': case 'subscription': suggestions.push('著者のプレプリント版がarXivやResearchGateで公開されている可能性があります'); suggestions.push('著者に直接連絡して論文を請求することも一般的です'); break; } // 学術コンテンツの場合 if (url.includes('doi.org') || url.includes('journal') || url.includes('paper')) { suggestions.push('Unpaywall (ブラウザ拡張) でOA版を自動検索できます'); suggestions.push('Semantic Scholarで関連論文を検索: https://www.semanticscholar.org/'); } return suggestions; } } // シングルトンインスタンス let paywallDetectorInstance: PaywallDetector | null = null; /** * PaywallDetectorのシングルトンインスタンスを取得 */ export function getPaywallDetector(): PaywallDetector { if (!paywallDetectorInstance) { paywallDetectorInstance = new PaywallDetector(); } return paywallDetectorInstance; }