- build-chrome-store.mjs / build-firefox.mjs : host_permissions et content_scripts matches passent à http://*/* + https://*/* au lieu de memento-note.com uniquement. Un Web Clipper a besoin d'accéder à tous les sites ; le manifest Store bloquait l'injection du content script sur n'importe quel article. - extract-article.ts : refonte de cleanClipText en filtrage par ligne avec patterns élargis (mis à jour, temps de lecture, aujourd'hui à HH:MM, source - date, publié le, etc.). - Bump version 0.4.5 dans manifest, locales, README et builds.
126 lines
4.6 KiB
TypeScript
126 lines
4.6 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
|
||
}
|
||
|
||
// Lignes de métadonnées qu'on veut retirer du texte extrait.
|
||
// On teste ligne par ligne pour ne pas tronquer du vrai contenu en plein milieu.
|
||
const META_LINE_PATTERNS = [
|
||
// "mis à jour", "updated at", "actualizado", "aktualisiert", "aggiornato"
|
||
/\b(mis à jour|updated at|updated|actualizado|actualizada|aktualisiert|aktualisiert am|aggiornato alle|aggiornato)\b/i,
|
||
// "Temps de lecture", "Reading time", etc.
|
||
/\b(temps de lecture|reading time|lesedauer|tiempo de lectura|tempo di lettura|czas czytania|время чтения|阅读时间|読了時間)\b/i,
|
||
// "Aujourd'hui à 09:15", "Today at 09:15", etc.
|
||
/\b(aujourd['’]?hui|today|hoy|heute|oggi|vandaag|dzisiaj|сегодня|今日)\b.*\d{1,2}:\d{2}/i,
|
||
// "Le Progrès - Aujourd'hui à 09:15 | mis à jour..."
|
||
/^.*\s[-–|]\s.*\d{1,2}:\d{2}/i,
|
||
// Heure seule "09:15" ou "09:15 | ..."
|
||
/^\s*\d{1,2}:\d{2}\s*([-|].*)?$/i,
|
||
// "Publié le ...", "Published on ..."
|
||
/\b(publié le|publié|published on|published|veröffentlicht am|publicado el|publicado)\b/i,
|
||
// "Source : ...", "Par ..." en début de ligne
|
||
/^\s*(source\s*:|par\s+|by\s+|auteur\s*:|author\s*:)/i,
|
||
]
|
||
|
||
function isMetaLine(line: string): boolean {
|
||
return META_LINE_PATTERNS.some((pattern) => pattern.test(line))
|
||
}
|
||
|
||
export function cleanClipText(text: string): string {
|
||
return text
|
||
.replace(/\r\n/g, '\n')
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter((line) => line.length > 0 && !isMetaLine(line))
|
||
.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>`
|
||
}
|