File size: 6,253 Bytes
a96145c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac179d1
 
a96145c
ac179d1
 
 
 
 
 
 
 
 
 
623f05d
 
 
 
a96145c
 
 
 
 
 
 
 
 
 
 
623f05d
a96145c
 
 
 
 
623f05d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a96145c
 
 
 
 
 
 
 
623f05d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a96145c
 
ca12da8
a96145c
 
 
ca12da8
 
 
a96145c
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
import logging
from typing import Optional
from urllib.parse import urlparse

from .models import Article

logger = logging.getLogger(__name__)


class ArticleExtractor:
    def __init__(self):
        self._trafilatura = None
        self._init_backend()

    def _init_backend(self):
        try:
            import trafilatura
            self._trafilatura = trafilatura
        except ImportError:
            logger.info("trafilatura not installed — using fallback extractor")

    def extract(self, url: str) -> Optional[Article]:
        domain = urlparse(url).netloc
        try:
            if self._trafilatura:
                return self._extract_trafilatura(url, domain)
            return self._extract_fallback(url, domain)
        except Exception as exc:
            logger.debug("Extraction failed for %s: %s", url, exc)
            return None

    def _extract_trafilatura(self, url: str, domain: str) -> Optional[Article]:
        downloaded = self._trafilatura.fetch_url(url)
        if not downloaded:
            return None
        text = self._trafilatura.extract(downloaded)
        if not text:
            return None
        doc = self._trafilatura.bare_extraction(downloaded, with_metadata=True)
        title = (doc.title if doc else None) or self._extract_title_meta(downloaded) or ""
        image = ""
        published, published_iso = ("", "")
        if doc:
            image = doc.image or ""
            published, published_iso = self._parse_trafilatura_date(doc.date)
        if not image:
            soup = BeautifulSoup(downloaded, "html.parser")
            image = self._extract_og_image_soup(soup)
        return Article(
            url=url, title=title, text=text, source_domain=domain,
            image_url=image, published=published, published_iso=published_iso,
        )

    def _extract_fallback(self, url: str, domain: str) -> Optional[Article]:
        resp = requests.get(url, headers={"User-Agent": "newsapp/1.0"}, timeout=15)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.text, "html.parser")

        title = ""
        if soup.title:
            title = soup.title.get_text(strip=True)

        image = self._extract_og_image_soup(soup)
        published, published_iso = self._extract_fallback_date(soup)

        paragraphs = soup.find_all("p")
        text = "\n\n".join(p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True))
        if not text:
            return None
        return Article(
            url=url, title=title, text=text, source_domain=domain,
            image_url=image, published=published, published_iso=published_iso,
        )

    @staticmethod
    def _parse_trafilatura_date(raw_date: str | None) -> tuple[str, str]:
        if not raw_date:
            return ("", "")
        from datetime import datetime
        for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
            try:
                dt = datetime.strptime(raw_date.strip(), fmt)
                return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
            except ValueError:
                continue
        try:
            dt = datetime.fromisoformat(raw_date.strip())
            return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
        except (ValueError, TypeError):
            pass
        if len(raw_date) >= 10:
            try:
                dt = datetime.strptime(raw_date[:10], "%Y-%m-%d")
                return (dt.strftime("%d %b %Y"), raw_date[:10])
            except ValueError:
                pass
        return ("", "")

    @staticmethod
    def _extract_title_meta(html: str) -> Optional[str]:
        import html as html_mod
        import re
        m = re.search(r'<title[^>]*>(.*?)</title>', html, re.IGNORECASE | re.DOTALL)
        return html_mod.unescape(m.group(1).strip()) if m else None

    @staticmethod
    def _extract_fallback_date(soup) -> tuple[str, str]:
        from datetime import datetime
        time_tag = soup.find("time")
        if time_tag:
            raw = time_tag.get("datetime") or time_tag.get_text(strip=True)
            if raw:
                for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
                    try:
                        dt = datetime.strptime(raw.strip(), fmt)
                        return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
                    except ValueError:
                        continue
                try:
                    dt = datetime.fromisoformat(raw.strip())
                    return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
                except (ValueError, TypeError):
                    pass
        for prop in ("article:published_time", "date"):
            tag = soup.find("meta", property=prop) or soup.find("meta", attrs={"name": prop})
            if tag and tag.get("content"):
                raw = tag["content"].strip()
                for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
                    try:
                        dt = datetime.strptime(raw, fmt)
                        return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
                    except ValueError:
                        continue
                try:
                    dt = datetime.fromisoformat(raw)
                    return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
                except (ValueError, TypeError):
                    pass
                if len(raw) >= 10:
                    try:
                        dt = datetime.strptime(raw[:10], "%Y-%m-%d")
                        return (dt.strftime("%d %b %Y"), raw[:10])
                    except ValueError:
                        pass
        return ("", "")

    @staticmethod
    def _extract_og_image_soup(soup) -> str:
        for prop in ("og:image", "twitter:image", "article:image"):
            tag = soup.find("meta", property=prop) or soup.find("meta", attrs={"name": prop})
            if tag and tag.get("content"):
                return tag["content"]
        link = soup.find("link", rel="image_src")
        if link and link.get("href"):
            return link["href"]
        return ""


import requests
from bs4 import BeautifulSoup