diff --git a/newspaper/article.py b/newspaper/article.py
index df0d9c43..4a1b6ad1 100644
--- a/newspaper/article.py
+++ b/newspaper/article.py
@@ -153,6 +153,9 @@ def __init__(self, url, title='', source_url='', config=None, **kwargs):
# A property dict for users to store custom data.
self.additional_data = {}
+ # The final URL after redirects and meta refresh
+ self.final_url = None
+
def build(self):
"""Build a lone article from a URL independent of the source (newspaper).
Don't normally call this method b/c it's good to multithread articles
@@ -173,7 +176,9 @@ def _parse_scheme_file(self, path):
def _parse_scheme_http(self):
try:
- return network.get_html_2XX_only(self.url, self.config)
+ html, final_url = network.get_html_2XX_only(self.url, self.config, return_final_url=True)
+ self.final_url = final_url
+ return html
except requests.exceptions.RequestException as e:
self.download_state = ArticleDownloadState.FAILED_RESPONSE
self.download_exception_msg = str(e)
@@ -190,18 +195,27 @@ def download(self, input_html=None, title=None, recursion_counter=0):
parsed_url = urlparse(self.url)
if parsed_url.scheme == "file":
html = self._parse_scheme_file(parsed_url.path)
+ # For file scheme, the final URL is the same as the initial URL
+ if self.final_url is None:
+ self.final_url = self.url
else:
html = self._parse_scheme_http()
+ # final_url is already set in _parse_scheme_http
if html is None:
log.debug('Download failed on URL %s because of %s' %
(self.url, self.download_exception_msg))
return
else:
html = input_html
+ # If HTML is provided directly and final_url not set, use the current URL
+ if self.final_url is None:
+ self.final_url = self.url
if self.config.follow_meta_refresh:
meta_refresh_url = extract_meta_refresh(html)
if meta_refresh_url and recursion_counter < 1:
+ # Update final_url to the meta refresh URL
+ self.final_url = meta_refresh_url
return self.download(
input_html=network.get_html(meta_refresh_url),
recursion_counter=recursion_counter + 1)
@@ -213,19 +227,22 @@ def parse(self):
self.throw_if_not_downloaded_verbose()
self.doc = self.config.get_parser().fromstring(self.html)
- self.clean_doc = copy.deepcopy(self.doc)
if self.doc is None:
# `parse` call failed, return nothing
return
+ document_cleaner = DocumentCleaner(self.config)
+ output_formatter = OutputFormatter(self.config)
+
+ self.clean_doc = copy.deepcopy(self.doc)
+ # Before any computations on the body, clean DOM object
+ self.clean_doc = document_cleaner.clean(self.clean_doc)
+
# TODO: Fix this, sync in our fix_url() method
parse_candidate = self.get_parse_candidate()
self.link_hash = parse_candidate.link_hash # MD5
- document_cleaner = DocumentCleaner(self.config)
- output_formatter = OutputFormatter(self.config)
-
title = self.extractor.get_title(self.clean_doc)
self.set_title(title)
@@ -267,16 +284,23 @@ def parse(self):
self.url,
self.clean_doc)
- # Before any computations on the body, clean DOM object
- self.doc = document_cleaner.clean(self.doc)
-
self.top_node = self.extractor.calculate_best_node(self.doc)
+ if self.top_node is None:
+ self.top_node = self.extractor.calculate_best_node(self.clean_doc)
+ if self.top_node is None:
+ self.top_node = self.extractor.parser.getElementById(self.doc, 'content')
+ if self.top_node is None:
+ for tag in ['article', 'main']:
+ nodes = self.extractor.parser.getElementsByTag(self.doc, tag=tag)
+ if len(nodes) > 0:
+ self.top_node = nodes[0]
+ break
if self.top_node is not None:
video_extractor = VideoExtractor(self.config, self.top_node)
self.set_movies(video_extractor.get_videos())
- self.top_node = self.extractor.post_cleanup(self.top_node)
self.clean_top_node = copy.deepcopy(self.top_node)
+ self.clean_top_node = self.extractor.post_cleanup(self.clean_top_node)
text, article_html = output_formatter.get_formatted(
self.top_node)
diff --git a/newspaper/configuration.py b/newspaper/configuration.py
index 94688e70..d5c6d998 100644
--- a/newspaper/configuration.py
+++ b/newspaper/configuration.py
@@ -57,6 +57,10 @@ def __init__(self):
# Fail for error responses (e.g. 404 page)
self.http_success_only = True
+ # Allow redirects (enabled by default)
+ self.allow_redirects = True
+
+ self.ignored_images_suffix_list = []
# English is the fallback
self._language = 'en'
@@ -68,6 +72,7 @@ def __init__(self):
self.request_timeout = 7
self.proxies = {}
self.number_threads = 10
+ self.verify_ssl_cert = True
self.verbose = False # for debugging
diff --git a/newspaper/extractors.py b/newspaper/extractors.py
index 96255401..9fd6cee9 100644
--- a/newspaper/extractors.py
+++ b/newspaper/extractors.py
@@ -13,6 +13,7 @@
import copy
import logging
+import os.path
import re
import re
from collections import defaultdict
@@ -234,6 +235,24 @@ def parse_date_str(date_str):
return None
+ def _choose_title_candidate(self, candidates, og_title):
+ """Pick the best
when a document has more than one.
+
+ Prefer a candidate whose filtered text contains the og:title (the
+ descriptive article title), otherwise fall back to the longest
+ candidate. This avoids picking bare site names (e.g. Medium's second
+ Medium).
+ """
+ if len(candidates) == 1:
+ return candidates[0]
+ filter_regex = re.compile(r'[^\u4e00-\u9fa5a-zA-Z0-9\ ]')
+ og_filtered = filter_regex.sub('', og_title or '').lower().strip()
+ if og_filtered:
+ for candidate in candidates:
+ if og_filtered in filter_regex.sub('', candidate).lower():
+ return candidate
+ return max(candidates, key=len)
+
def get_title(self, doc):
"""Fetch the article title and analyze it
@@ -253,12 +272,31 @@ def get_title(self, doc):
"""
title = ''
title_element = self.parser.getElementsByTag(doc, tag='title')
- # no title found
- if title_element is None or len(title_element) == 0:
- return title
+ title_text_fb = (
+ self.get_meta_content(doc, 'meta[property="og:title"]') or
+ self.get_meta_content(doc, 'meta[name="og:title"]') or ''
+ )
- # title elem found
- title_text = self.parser.getText(title_element[0])
+ # no title found, fallback to og:title
+ if title_element is None or len(title_element) == 0:
+ title_text = title_text_fb
+ if not title_text:
+ return title
+ else:
+ # some sites (e.g. Medium) emit multiple tags, one of which
+ # is a bare site name ("Medium"). Blindly taking the first element
+ # can yield the useless site name, so pick the best candidate:
+ # prefer the one matching og:title, otherwise the longest text.
+ title_candidates = [self.parser.getText(el).strip()
+ for el in title_element]
+ title_candidates = [c for c in title_candidates if c]
+ if not title_candidates:
+ title_text = title_text_fb
+ if not title_text:
+ return title
+ else:
+ title_text = self._choose_title_candidate(title_candidates,
+ title_text_fb)
used_delimeter = False
# title from h1
@@ -280,11 +318,6 @@ def get_title(self, doc):
# clean double spaces
title_text_h1 = ' '.join([x for x in title_text_h1.split() if x])
- # title from og:title
- title_text_fb = (
- self.get_meta_content(doc, 'meta[property="og:title"]') or
- self.get_meta_content(doc, 'meta[name="og:title"]') or '')
-
# create filtered versions of title_text, title_text_h1, title_text_fb
# for finer comparison
filter_regex = re.compile(r'[^\u4e00-\u9fa5a-zA-Z0-9\ ]')
@@ -449,19 +482,21 @@ def get_meta_img_url(self, article_url, doc):
"""
top_meta_image, try_one, try_two, try_three, try_four = [None] * 5
try_one = self.get_meta_content(doc, 'meta[property="og:image"]')
+ try_one = None if self.image_is_ignored(try_one) else try_one
if not try_one:
link_img_src_kwargs = \
{'tag': 'link', 'attr': 'rel', 'value': 'img_src|image_src'}
elems = self.parser.getElementsByTag(doc, use_regex=True, **link_img_src_kwargs)
try_two = elems[0].get('href') if elems else None
-
+ try_two = None if self.image_is_ignored(try_two) else try_two
if not try_two:
try_three = self.get_meta_content(doc, 'meta[name="og:image"]')
-
+ try_three = None if self.image_is_ignored(try_three) else try_three
if not try_three:
link_icon_kwargs = {'tag': 'link', 'attr': 'rel', 'value': 'icon'}
elems = self.parser.getElementsByTag(doc, **link_icon_kwargs)
try_four = elems[0].get('href') if elems else None
+ try_four = None if self.image_is_ignored(try_four) else try_four
top_meta_image = try_one or try_two or try_three or try_four
@@ -469,6 +504,12 @@ def get_meta_img_url(self, article_url, doc):
return urljoin(article_url, top_meta_image)
return ''
+ def image_is_ignored(self, image):
+ return any([True for x in self.config.ignored_images_suffix_list if image and image != '' and self.match_image(x, os.path.basename(image))])
+
+ def match_image(self, pattern, image):
+ return re.search(pattern, image) is not None
+
def get_meta_type(self, doc):
"""Returns meta type of article, open graph protocol
"""
@@ -575,6 +616,7 @@ def get_img_urls(self, article_url, doc):
for img_tag in img_tags if img_tag.get('src')]
img_links = set([urljoin(article_url, url)
for url in urls])
+ img_links = set([x for x in img_links if not self.image_is_ignored(x)])
return img_links
def get_first_img_url(self, article_url, top_node):
@@ -1014,9 +1056,16 @@ def nodes_to_check(self, doc):
on like paragraphs and tables
"""
nodes_to_check = []
- for tag in ['p', 'pre', 'td']:
- items = self.parser.getElementsByTag(doc, tag=tag)
- nodes_to_check += items
+ articles = self.parser.getElementsByTag(doc, tag='article')
+ if len(articles) > 0 and self.get_meta_site_name(doc) == 'Medium':
+ # Specific heuristic for Medium articles
+ sections = self.parser.getElementsByTag(articles[0], tag='section')
+ if len(sections) > 1:
+ nodes_to_check = sections
+ if len(nodes_to_check) == 0:
+ for tag in ['p', 'pre', 'td', 'ol', 'ul']:
+ items = self.parser.getElementsByTag(doc, tag=tag)
+ nodes_to_check += items
return nodes_to_check
def is_table_and_no_para_exist(self, e):
diff --git a/newspaper/images.py b/newspaper/images.py
index 77476625..bead0cc3 100644
--- a/newspaper/images.py
+++ b/newspaper/images.py
@@ -83,6 +83,37 @@ def clean_url(url):
return url
+def get_full_image_dimensions(image_url):
+ """Fallback in case PIL can't open the streamed image
+ """
+ try:
+ response = requests.get(image_url) # No stream=True needed
+ response.raise_for_status() # Raise an exception for bad status codes
+
+ # Use io.BytesIO to treat the response content (bytes) as a file
+ image_bytes = io.BytesIO(response.content)
+
+ # Open the image directly from the bytes stream
+ img = Image.open(image_bytes)
+
+ sz = img.size
+
+ # It's good practice to close the image when done
+ img.close()
+
+ return sz
+
+ except requests.exceptions.RequestException as e:
+ log.warning(f"Method 2 (Direct): Error fetching the image via requests: {e}")
+ return None
+ except FileNotFoundError:
+ log.warning("Method 2 (Direct): Error: io.BytesIO did not behave as expected (treated as file not found).")
+ return None
+ except Exception as e:
+ log.warning(f"Method 2 (Direct): An unexpected error occurred while opening image: {e}")
+ return None
+
+
def fetch_url(url, useragent, referer=None, retries=1, dimension=False):
cur_try = 0
nothing = None if dimension else (None, None)
@@ -143,7 +174,9 @@ def fetch_url(url, useragent, referer=None, retries=1, dimension=False):
if dimension and p.image:
return p.image.size
elif dimension:
- return nothing
+ # we did read the image, but it failed to parse for some reason
+ # try to download it in one go
+ return get_full_image_dimensions(url)
elif dimension:
# expected an image, but didn't get one
return nothing
diff --git a/newspaper/network.py b/newspaper/network.py
index 29f0e699..52b80748 100644
--- a/newspaper/network.py
+++ b/newspaper/network.py
@@ -21,7 +21,7 @@
FAIL_ENCODING = 'ISO-8859-1'
-def get_request_kwargs(timeout, useragent, proxies, headers):
+def get_request_kwargs(timeout, useragent, proxies, headers, allow_redirects, verify_ssl_cert):
"""This Wrapper method exists b/c some values in req_kwargs dict
are methods which need to be called every time we make a request
"""
@@ -29,8 +29,9 @@ def get_request_kwargs(timeout, useragent, proxies, headers):
'headers': headers if headers else {'User-Agent': useragent},
'cookies': cj(),
'timeout': timeout,
- 'allow_redirects': True,
- 'proxies': proxies
+ 'allow_redirects': allow_redirects,
+ 'proxies': proxies,
+ 'verify': verify_ssl_cert,
}
@@ -44,7 +45,7 @@ def get_html(url, config=None, response=None):
return ''
-def get_html_2XX_only(url, config=None, response=None):
+def get_html_2XX_only(url, config=None, response=None, return_final_url=False):
"""Consolidated logic for http requests from newspaper. We handle error cases:
- Attempt to find encoding of the html by using HTTP header. Fallback to
'ISO-8859-1' if not provided.
@@ -55,19 +56,27 @@ def get_html_2XX_only(url, config=None, response=None):
timeout = config.request_timeout
proxies = config.proxies
headers = config.headers
+ verify_ssl_cert = config.verify_ssl_cert
+ allow_redirects = config.allow_redirects
if response is not None:
- return _get_html_from_response(response, config)
+ html = _get_html_from_response(response, config)
+ if return_final_url:
+ return html, getattr(response, 'url', url)
+ return html
response = requests.get(
- url=url, **get_request_kwargs(timeout, useragent, proxies, headers))
+ url=url, **get_request_kwargs(timeout, useragent, proxies, headers, allow_redirects, verify_ssl_cert))
html = _get_html_from_response(response, config)
+ final_url = response.url
if config.http_success_only:
# fail if HTTP sends a non 2XX response
response.raise_for_status()
+ if return_final_url:
+ return html, final_url
return html
@@ -107,7 +116,7 @@ def __init__(self, url, config=None):
def send(self):
try:
self.resp = requests.get(self.url, **get_request_kwargs(
- self.timeout, self.useragent, self.proxies, self.headers))
+ self.timeout, self.useragent, self.proxies, self.headers, self.config.allow_redirects))
if self.config.http_success_only:
self.resp.raise_for_status()
except requests.exceptions.RequestException as e:
diff --git a/newspaper/outputformatters.py b/newspaper/outputformatters.py
index 47a76467..620dce63 100644
--- a/newspaper/outputformatters.py
+++ b/newspaper/outputformatters.py
@@ -9,6 +9,7 @@
from html import unescape
import logging
+import copy
from .text import innerTrim
@@ -42,7 +43,7 @@ def get_formatted(self, top_node):
"""Returns the body text of an article, and also the body article
html if specified. Returns in (text, html) form
"""
- self.top_node = top_node
+ self.top_node = copy.deepcopy(top_node)
html, text = '', ''
self.remove_negativescores_nodes()
diff --git a/requirements.txt b/requirements.txt
index 61974601..0ea8c095 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,7 +3,7 @@ cssselect>=0.9.2
feedfinder2>=0.0.4
feedparser>=5.2.1
jieba3k>=0.35.1
-lxml>=3.6.0
+lxml==5.1.0 # https://lxml.de/5.2/changes-5.2.0.html
nltk>=3.2.1
Pillow>=3.3.0
pythainlp>=1.7.2
diff --git a/tests/unit_tests.py b/tests/unit_tests.py
index 69c05adf..56583171 100644
--- a/tests/unit_tests.py
+++ b/tests/unit_tests.py
@@ -24,7 +24,7 @@
URLS_FILE = os.path.join(TEST_DIR, 'data', 'fulltext_url_list.txt')
import newspaper
-from newspaper import Article, fulltext, Source, ArticleException, news_pool
+from newspaper import Article, Config, fulltext, Source, ArticleException, news_pool, images
from newspaper.article import ArticleDownloadState
from newspaper.configuration import Configuration
from newspaper.urls import get_domain
@@ -375,6 +375,10 @@ def test_get_title_quotes(self):
html = '{}'.format(title)
self.assertEqual(self._get_title(html), title)
+ def test_get_title_fallback_to_og_title_when_title_missing(self):
+ html = ''
+ self.assertEqual(self._get_title(html), 'Fallback title from og')
+
def _get_canonical_link(self, article_url, html):
doc = self.parser.fromstring(html)
return self.extractor.get_canonical_link(article_url, doc)
@@ -406,9 +410,9 @@ def test_get_top_image_from_meta(self):
html = '' \
''
html_empty_og_content = '' \
- ''
+ ''
html_empty_all = '' \
- ''
+ ''
html_rel_img_src = html_empty_all + ''
html_rel_img_src2 = html_empty_all + ''
html_rel_icon = html_empty_all + ''
@@ -544,7 +548,6 @@ def test_valid_urls(self):
print('\t\turl: %s is supposed to be %s' % (url, truth_val))
raise
-
@print_test
def test_pubdate(self):
"""Checks that irrelevant data in url isn't considered as publishing date"""
@@ -568,7 +571,6 @@ def test_pubdate(self):
print('\t\tpublishing date in %s should not be present' % (url))
raise
-
@unittest.skip("Need to write an actual test")
@print_test
def test_prepare_url(self):
@@ -635,9 +637,9 @@ class ConfigBuildTestCase(unittest.TestCase):
NOTE: No need to mock responses as we are just initializing the
objects, not actually calling download(..)
"""
+
@print_test
def test_article_default_params(self):
-
a = Article(url='http://www.cnn.com/2013/11/27/'
'travel/weather-thanksgiving/index.html')
self.assertEqual('en', a.config.language)
@@ -767,6 +769,31 @@ def test_article_pdf_fetching(self):
a.download()
self.assertNotEqual('%PDF-', a.html)
+
+class TestIgnoreImages(unittest.TestCase):
+
+ @print_test
+ def test_config_ignore_images(self):
+ config = Config()
+ config.ignored_images_suffix_list = ['think.png', '(.*)\.ico']
+ a = Article('https://www.reillywood.com/blog/why-nu/', config=config)
+ a.download()
+ a.parse()
+ self.assertEqual('https://d33wubrfki0l68.cloudfront.net/77d3013f91800257b3ca2adfb995ae24e49fff4e/b3086/img/main/headshot.jpg', a.top_img)
+
+
+class TestGetImageDimensionFallback(unittest.TestCase):
+
+ @print_test
+ def test_get_image_dimension_fallback(self):
+ config = Config()
+ config.image_dimension_ration = 32 / 9
+ a = Article('https://appwrite.io/blog/post/add-figma-oauth2-appwrite', config=config)
+ s = images.Scraper(a)
+ sr = s.satisfies_requirements('https://appwrite.io/images/blog/add-figma-oauth2-appwrite/cover.png')
+ self.assertTrue(sr)
+
+
if __name__ == '__main__':
argv = list(sys.argv)
if 'fulltext' in argv: