- content.js : écrit la sélection dans chrome.storage.session/local en plus du message runtime - sidepanel.js : lit le storage en fallback, loggue chaque étape, affiche un diagnostic panel - sidepanel.js : avertissement explicite si le content script n'est pas détecté - sidepanel.js : sépare le rendu Markdown du résumé et l'affichage texte brut de l'extrait - sidepanel.css : agrandit la zone de prévisualisation - i18n : nouvelles clés contentScriptMissing, diagnosticsTitle, copyDiagnostics - locales : correction FR (Retour, Enregistrement, Note, etc.) + version 0.4.4 partout - extract-article.ts : nettoie textContent des métadonnées (dates, temps de lecture, sources) - analyze/route.ts : excerpt propre via makeExcerpt - builds Chrome Store et Firefox regénérés en 0.4.4
117 lines
4.1 KiB
TypeScript
117 lines
4.1 KiB
TypeScript
import { Readability } from '@mozilla/readability'
|
||
import { JSDOM } from 'jsdom'
|
||
import DOMPurify from 'isomorphic-dompurify'
|
||
import {
|
||
applyRtlToHtmlBlocks,
|
||
readPageLocaleFromHtml,
|
||
resolveClipLocale,
|
||
wrapClipArticleHtml,
|
||
type ClipLocaleHint,
|
||
} from '@/lib/clip/rtl-content'
|
||
|
||
export interface ExtractedArticle {
|
||
title: string
|
||
content: string
|
||
textContent: string
|
||
excerpt: string
|
||
locale: ClipLocaleHint
|
||
}
|
||
|
||
const META_PATTERNS = [
|
||
// Dates/heures avec "mis à jour / updated / actualizado / aktualisiert"
|
||
/\b(mis à jour|updated at|actualizado|aktualisiert am|aggiornato alle)\b[\s:|-]*.*/gi,
|
||
// Temps de lecture
|
||
/\b(temps de lecture|reading time|lesedauer|tiempo de lectura|tempo di lettura|czas czytania|время чтения|阅读时间|読了時間)\b[\s:|-]*.*/gi,
|
||
// Lignes de type "Source - Aujourd'hui à 09:15 | mis à jour à 11:07"
|
||
/^(.*?\s[-–|]\s)(aujourd['’]?hui|today|hoy|heute|oggi|vandaag|dzisiaj|сегодня|今日)\b.*$/gim,
|
||
// Heures seules type "09:15" ou "09:15 | mis à jour..."
|
||
/^\s*\d{1,2}:\d{2}(\s*[-|]\s*.*)?$/gm,
|
||
]
|
||
|
||
export function cleanClipText(text: string): string {
|
||
let cleaned = text.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n')
|
||
for (const pattern of META_PATTERNS) {
|
||
cleaned = cleaned.replace(pattern, '')
|
||
}
|
||
return cleaned
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter((line) => line.length > 0)
|
||
.join('\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim()
|
||
}
|
||
|
||
export function makeExcerpt(text: string, maxLen = 500): string {
|
||
if (text.length <= maxLen) return text
|
||
// Préférer couper à la fin d'une phrase.
|
||
const sentenceEnd = text.lastIndexOf('.', maxLen)
|
||
if (sentenceEnd > maxLen * 0.6) return text.slice(0, sentenceEnd + 1).trim()
|
||
// Sinon couper au dernier espace.
|
||
const space = text.lastIndexOf(' ', maxLen)
|
||
if (space > maxLen * 0.6) return text.slice(0, space).trim() + '…'
|
||
return text.slice(0, maxLen).trim() + '…'
|
||
}
|
||
|
||
export function extractArticleFromHtml(html: string, pageUrl: string): ExtractedArticle | null {
|
||
const dom = new JSDOM(html, { url: pageUrl })
|
||
const reader = new Readability(dom.window.document)
|
||
const article = reader.parse()
|
||
if (!article) return null
|
||
|
||
const pageLocale = readPageLocaleFromHtml(html)
|
||
const readabilityDir = article.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
|
||
const readabilityLang = article.lang?.split('-')[0]?.toLowerCase()
|
||
const locale = resolveClipLocale(
|
||
pageUrl,
|
||
article.title || '',
|
||
article.textContent || '',
|
||
)
|
||
const mergedLocale: ClipLocaleHint = {
|
||
direction:
|
||
readabilityDir === 'rtl' || pageLocale.direction === 'rtl' || locale.direction === 'rtl'
|
||
? 'rtl'
|
||
: 'ltr',
|
||
lang:
|
||
(readabilityLang === 'fa' || readabilityLang === 'ar' || readabilityLang === 'he'
|
||
? readabilityLang
|
||
: undefined) ||
|
||
locale.lang ||
|
||
pageLocale.lang,
|
||
}
|
||
|
||
const sanitized = DOMPurify.sanitize(article.content || '')
|
||
const rtlBlocks = applyRtlToHtmlBlocks(sanitized, mergedLocale)
|
||
const content = wrapClipArticleHtml(rtlBlocks, mergedLocale)
|
||
|
||
const cleanText = cleanClipText(article.textContent || '')
|
||
|
||
return {
|
||
title: (article.title || '').trim(),
|
||
content,
|
||
textContent: cleanText,
|
||
excerpt: makeExcerpt(cleanText, 500),
|
||
locale: mergedLocale,
|
||
}
|
||
}
|
||
|
||
export function clipFooterLocaleTag(lang?: string): string {
|
||
if (lang === 'fa') return 'fa-IR'
|
||
if (lang === 'ar') return 'ar'
|
||
if (lang === 'he') return 'he-IL'
|
||
return 'fr-FR'
|
||
}
|
||
|
||
export function buildClipSourceFooter(domain: string, date: Date, localeTag = 'fr-FR'): string {
|
||
const formatted = date.toLocaleDateString(localeTag, { day: 'numeric', month: 'long', year: 'numeric' })
|
||
const isRtl = localeTag.startsWith('fa') || localeTag.startsWith('ar') || localeTag.startsWith('he')
|
||
const label =
|
||
localeTag.startsWith('fa')
|
||
? `برگرفته از ${domain} — ${formatted}`
|
||
: localeTag.startsWith('ar')
|
||
? `مقتبس من ${domain} — ${formatted}`
|
||
: `Extrait de ${domain} le ${formatted}`
|
||
const dirAttr = isRtl ? ' dir="rtl"' : ''
|
||
return `<hr/><p${dirAttr}><small>${DOMPurify.sanitize(label)}</small></p>`
|
||
}
|