Coverage for src / crawler / abstract_crawlers / base_crawler.py: 64%
615 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-15 13:33 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-15 13:33 +0000
1import logging
2import time
3from collections.abc import Iterable
4from datetime import datetime, timedelta
5from email.policy import EmailPolicy
6from typing import TYPE_CHECKING, Literal
8import aiohttp
9import regex
10import requests
11from bs4 import BeautifulSoup
12from django.conf import settings
13from django.contrib.auth.models import User
14from django.db.utils import IntegrityError
15from django.utils import timezone
16from langcodes import standardize_tag
17from lingua import LanguageDetector, LanguageDetectorBuilder
18from opentelemetry import trace
19from ptf.cmds.xml.ckeditor.utils import (
20 build_jats_data_from_html_field,
21)
22from ptf.cmds.xml.jats.builder.references import (
23 get_article_title_xml,
24 get_author_xml,
25 get_fpage_xml,
26 get_lpage_xml,
27 get_source_xml,
28 get_year_xml,
29)
30from ptf.cmds.xml.jats.jats_parser import JatsBase
31from ptf.model_data import (
32 ArticleData,
33 ContributorDict,
34 IssueData,
35 ResourceData,
36 TitleDict,
37 create_abstract,
38 create_contributor,
39 create_extlink,
40 create_issuedata,
41 create_publisherdata,
42 create_subj,
43 create_titledata,
44)
45from ptf.model_data_converter import update_data_for_jats
46from ptf.models import ExtLink
47from pylatexenc.latex2text import LatexNodes2Text
48from pysolr import SolrError
49from requests.adapters import HTTPAdapter
50from requests_cache import CachedSession
51from urllib3 import Retry
53from crawler.cmds.xml_cmds import addOrUpdateGDMLIssueXmlCmd
54from crawler.models import Source
55from crawler.models.extlink_checked import ExtlinkChecked
56from crawler.types import CitationLiteral
57from crawler.utils import (
58 add_pdf_link_to_xarticle,
59 cleanup_str,
60 get_all_cols,
61 get_or_create_collection,
62 get_session,
63)
65if TYPE_CHECKING:
66 from typing import Callable
68 from bs4 import Tag
71class CrawlerTitleDict(TitleDict):
72 title_tex: str | None
75class BaseCollectionCrawler:
76 """
77 Base collection for the crawlers.
78 To create a crawler:
79 1) derive a class from BaseCollectionCrawler and name it XXXCrawler
80 2) override the functions parse_collection_content, parse_issue_content and parse_article_content
81 3) update factory.py so that crawler_factory can return your new crawler
82 """
84 logger = logging.getLogger(__name__)
85 tracer = trace.get_tracer(__name__)
87 source_name = ""
88 source_domain = ""
89 source_website = ""
91 issue_href = ""
93 collection = None
94 source = None
95 user = None
96 session: requests.Session | CachedSession
97 async_session: aiohttp.ClientSession
98 is_checkable = True
99 verify = True
100 headers = {
101 "accept_encoding": "utf-8",
102 "User-Agent": getattr(settings, "REQUESTS_USER_AGENT", "Mathdoc/1.0.0"),
103 "From": getattr(settings, "REQUESTS_EMAIL", "accueil@listes.mathdoc.fr"),
104 }
106 requests_interval = getattr(settings, "REQUESTS_INTERVAL", 90)
107 "seconds to wait between two http requests"
108 requests_timeout = 60
109 "seconds to wait before aborting the connection (if no bytes are recieved)"
111 latext_parser = LatexNodes2Text()
113 # Override the values in your concrete crawler if the formulas in text (titles, abstracts)
114 # do not use the "$" to surround tex formulas
115 delimiter_inline_formula = "$"
116 delimiter_disp_formula = "$"
118 # HACK : Workaround for tests (monkeypatching)
119 # We store the class here, so we can monkeypatch it when running tests
120 # subCrawlers = {
121 # LofplCrawler: None
122 # }
123 subCrawlers: dict[type["BaseCollectionCrawler"], "BaseCollectionCrawler | None"] = {}
125 _language_detector: LanguageDetector | None = None
126 _language_detector_builder = LanguageDetectorBuilder.from_all_languages()
128 force_refresh = False
130 match_headers = False
131 "Whereas to include headers in requests cache key"
132 orcid_re = r"https\:\/\/orcid\.org\/(?P<orcid>\d{4}-\d{4}-\d{4}-\d{4})"
134 ignore_missing_pdf = True
135 "Set this to False on a Crawler-basis to allow inserting articles without PDFs"
136 pid_year_restrictions: dict[str, int] = {}
137 "pid -> excluded years count"
139 pause_function: "Callable[[int], None]"
140 "Overridable the pause function (used in celery tasks to speedup aborting)"
142 @classmethod
143 def get_view_id(cls):
144 return cls.source_domain
146 @property
147 def language_detector(self):
148 """Crawler Instance singleton for language builder.
149 Late init of LanguageDetector to save on memory"""
150 if not self._language_detector:
151 self._language_detector = self._language_detector_builder.build()
152 return self._language_detector
154 def __init__(
155 self,
156 *args,
157 username: str,
158 collection_id: str,
159 dry: bool = False,
160 publisher: str = "",
161 force_refresh=False,
162 collection_url: str | None = None,
163 backend=None,
164 pause_function=staticmethod(time.sleep),
165 ):
166 if not collection_url: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 all_cols = get_all_cols()
168 col = all_cols[collection_id]
170 collection_url = col["sources"].get(self.source_domain, None)
171 if collection_url is None:
172 raise ValueError(
173 f"Source {self.source_domain} not found for collection {collection_id}"
174 )
175 self.collection_url = collection_url
176 for CrawlerClass in self.subCrawlers: 176 ↛ 177line 176 didn't jump to line 177 because the loop on line 176 never started
177 self.subCrawlers[CrawlerClass] = CrawlerClass(
178 *args,
179 username=username,
180 collection_id=collection_id,
181 dry=dry,
182 publisher=publisher,
183 collection_url=collection_url,
184 )
185 self.logger = logging.getLogger(__name__ + "." + self.source_domain)
186 # self.logger = logging.getLogger(__name__)
188 self.username = username
190 self.collection_id = collection_id
192 self.dry = dry
193 self.publisher = publisher
195 # Classproperty : We sometimes want to use the session without initializing the class (rot monitoring)
196 BaseCollectionCrawler.session = requests.Session()
198 self.pause_function = pause_function
200 # Skipped when running tests
201 self.initialize()
203 self.force_refresh = force_refresh
204 self.backend = backend
206 def initialize(self):
207 """
208 Acts as a "second" init function to skip model accesses during test data generation
209 """
210 self.collection = get_or_create_collection(self.collection_id)
211 self.source = self.get_or_create_source()
212 self.user = User.objects.get(username=self.username)
213 BaseCollectionCrawler.session = get_session()
214 BaseCollectionCrawler.session.verify = self.verify
215 self.session.pause_function = self.pause_function
216 self.session.delay = self.requests_interval
217 retries = Retry(
218 total=0,
219 )
220 self.session.mount("https://", HTTPAdapter(max_retries=retries))
221 self.session.mount("http://", HTTPAdapter(max_retries=retries))
223 @classmethod
224 def can_crawl(cls, pid: str) -> bool:
225 return True
227 def parse_collection_content(self, content: str) -> list[IssueData]:
228 """
229 Parse the HTML content with BeautifulSoup
230 returns a list of xissue.
231 Override this function in a derived class
232 """
233 return []
235 def parse_issue_content(self, content: str, xissue: IssueData):
236 """
237 Parse the HTML content with BeautifulSoup
238 Fills the xissue.articles
239 Override this function in a derived class.
241 CAV : You are supposed to create articles there. Please assign a PID to each article.
242 The PID can be `a + article_index`, like this : `a0` `a21`
243 """
245 def parse_article_content(
246 self, content: str, xissue: IssueData, xarticle: ArticleData, url: str
247 ) -> ArticleData | None:
248 """
249 Parse the HTML content with BeautifulSoup
250 returns the xarticle.
251 Override this function in a derived class.
252 The xissue is passed to the function in case the article page has issue information (ex: publisher)
253 The article url is also passed as a parameter
255 CAV : You are supposed to assign articles pid again here
256 """
257 return xarticle
259 @tracer.start_as_current_span("crawl_collection")
260 def crawl_collection(self):
261 # TODO: Comments, filter
262 """
263 Crawl an entire collection. ptf.models.Container objects are created.
264 - get the HTML content of the collection_url
265 - parse the HTML content with beautifulsoup to extract the list of issues
266 - merge the xissues (some Source can have multiple pages for 1 volume/issue. We create only 1 container)
267 - crawl each issue if col_only is False
268 - Returns the list of merged issues.
269 It is an OrderedDict {pid: {"issues": xissues}}
270 The key is the pid of the merged issues.
271 Ex: The source may have Ex: Volume 6 (2000) and Volume 6 (1999)
272 the pid is then made with 1999-2000__6_
273 """
275 if self.source is None:
276 raise RuntimeError("ERROR: the source is not set")
278 content = self.download_file(self.collection_url)
279 if content:
280 xissues = self.parse_collection_content(content)
281 else:
282 # download_file returns None (404)
283 return None
285 """
286 Some collections split the same volumes in different pages
287 Ex: Volume 6 (2000) and Volume 6 (1999)
288 We merge the 2 xissues with the same volume number => Volume 6 (1999-2000)
289 """
290 # merged_xissues = self.merge_xissues(xissues)
292 xissues_dict = {str(i.pid): i for i in xissues}
294 return xissues_dict
296 def start_process_issue(self, xissue: IssueData):
297 # Some source, like EuDML do not have a separate HTML pages for an issue's table of content.
298 # The list of articles directly come from the collection HTML page: the xissue has no url attribute
299 issue_url = xissue.url
300 if issue_url is not None:
301 if issue_url.endswith(".pdf"):
302 add_pdf_link_to_xarticle(xissue, issue_url)
303 xissue.url = None
304 else:
305 content = self.download_file(issue_url)
306 with self.tracer.start_as_current_span("parse_issue_content"):
307 self.parse_issue_content(content, xissue)
309 @tracer.start_as_current_span("crawl_issue")
310 def crawl_issue(self, xissue: IssueData):
311 """
312 Crawl 1 wag page of an issue.
313 - get the HTML content of the issue
314 - parse the HTML content with beautifulsoup to extract the list of articles and/or the issue metadata
315 - crawl each article
316 """
318 self.start_process_issue(xissue)
320 xarticles = xissue.articles
322 parsed_xarticles = []
324 for xarticle in xarticles:
325 parsed_xarticle = self.crawl_article(xarticle, xissue)
326 if parsed_xarticle is not None:
327 parsed_xarticles.append(parsed_xarticle)
329 xissue.articles = parsed_xarticles
331 issue_has_pdf = self.article_has_pdf(xissue)
333 if self.ignore_missing_pdf:
334 xissue.articles = [a for a in xissue.articles if self.article_has_pdf(a)]
335 if self.dry:
336 return
337 if len(xissue.articles) == 0 and not issue_has_pdf:
338 return
339 self.process_resource_metadata(xissue, resource_type="issue")
341 self.add_xissue_into_database(xissue)
343 @staticmethod
344 def article_has_source(art: ArticleData | IssueData):
345 return (
346 next(
347 (e_link for e_link in art.ext_links if e_link["rel"] == "source"),
348 None,
349 )
350 is not None
351 )
353 @staticmethod
354 def article_has_pdf(art: ArticleData | IssueData):
355 return (
356 next(
357 (link for link in art.ext_links if link["rel"] in ["article-pdf", "article-html"]),
358 None,
359 )
360 is not None
361 )
363 def crawl_article(self, xarticle: ArticleData, xissue: IssueData):
364 # ARTICLE URL as en ExtLink (to display the link in the article page)
365 if xarticle.url is None:
366 if not self.article_has_source(xarticle): 366 ↛ 376line 366 didn't jump to line 376 because the condition on line 366 was always true
367 if xissue.url:
368 article_source = xissue.url
369 else:
370 article_source = self.collection_url
371 ext_link = create_extlink()
372 ext_link["rel"] = "source"
373 ext_link["location"] = article_source
374 ext_link["metadata"] = self.source_domain
375 xarticle.ext_links.append(ext_link)
376 return self.process_article_metadata(xarticle)
378 parsed_xarticle = xarticle
379 if self.parse_article_content.__func__ != BaseCollectionCrawler.parse_article_content:
380 content = self.download_file(xarticle.url)
381 xarticle.pid = f"{xissue.pid}_{xarticle.pid}"
383 try:
384 with self.tracer.start_as_current_span("parse_article_content"):
385 parsed_xarticle = self.parse_article_content(
386 content, xissue, xarticle, xarticle.url
387 )
388 except ValueError as e:
389 self.logger.warning(e)
390 self.logger.warning("Retrying in 5 mins while invalidating cache")
391 self.pause_function(5 * 60)
392 content = self.download_file(xarticle.url, force_refresh=True)
393 with self.tracer.start_as_current_span("parse_article_content"):
394 parsed_xarticle = self.parse_article_content(
395 content, xissue, xarticle, xarticle.url
396 )
398 if parsed_xarticle is None: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true
399 return None
401 if parsed_xarticle.doi:
402 parsed_xarticle.pid = (
403 parsed_xarticle.doi.replace("/", "_").replace(".", "_").replace("-", "_")
404 )
406 if not self.article_has_source(parsed_xarticle) and parsed_xarticle.url:
407 ext_link = create_extlink()
408 ext_link["rel"] = "source"
409 ext_link["location"] = parsed_xarticle.url
410 ext_link["metadata"] = self.source_domain
411 parsed_xarticle.ext_links.append(ext_link)
413 # The article title may have formulas surrounded with '$'
414 return self.process_article_metadata(parsed_xarticle)
416 def process_resource_metadata(self, xresource: ResourceData, resource_type="article"):
417 tag = "article-title" if resource_type == "article" else "issue-title"
419 # Process title tex
420 ckeditor_data = build_jats_data_from_html_field(
421 xresource.title_tex,
422 tag=tag,
423 text_lang=xresource.lang,
424 delimiter_inline=self.delimiter_inline_formula,
425 delimiter_disp=self.delimiter_disp_formula,
426 )
428 xresource.title_html = ckeditor_data["value_html"]
429 # xresource.title_tex = ckeditor_data["value_tex"]
430 xresource.title_xml = ckeditor_data["value_xml"]
432 abstracts_to_parse = [
433 xabstract for xabstract in xresource.abstracts if xabstract["tag"] == "abstract"
434 ]
435 # abstract may have formulas surrounded with '$'
436 if len(abstracts_to_parse) > 0:
437 for xabstract in abstracts_to_parse:
438 ckeditor_data = build_jats_data_from_html_field(
439 xabstract["value_tex"],
440 tag="abstract",
441 text_lang=xabstract["lang"],
442 resource_lang=xresource.lang,
443 field_type="abstract",
444 delimiter_inline=self.delimiter_inline_formula,
445 delimiter_disp=self.delimiter_disp_formula,
446 )
448 xabstract["value_html"] = ckeditor_data["value_html"]
449 # xabstract["value_tex"] = ckeditor_data["value_tex"]
450 xabstract["value_xml"] = ckeditor_data["value_xml"]
452 return xresource
454 def process_article_metadata(self, xarticle: ArticleData):
455 self.process_resource_metadata(xarticle)
456 for bibitem in xarticle.bibitems:
457 bibitem.type = "unknown"
458 update_data_for_jats(xarticle, with_label=False)
460 return xarticle
462 def download_file(self, url: str, force_refresh=False, headers={}):
463 """
464 Downloads a page and returns its content (decoded string).
465 """
467 for attempt in range(3):
468 response = self.get(
469 url,
470 force_refresh=force_refresh,
471 headers=headers,
472 pause_function=self.pause_function,
473 )
475 content = self.decode_response(response)
476 if content == "" or not content:
477 self.logger.debug("Got empty content while fetching ! ")
478 # 15 mins, 30 mins, 45 mins
479 delay_minutes = attempt * 15
480 self.logger.debug(
481 f"Retrying in {delay_minutes}mins ({(datetime.now() + timedelta(minutes=delay_minutes)).time()})",
482 extra={"url": url},
483 )
484 self.pause_function(delay_minutes * 60)
485 continue
486 return content
487 raise ValueError(f"Could not decode content at {url}")
489 @classmethod
490 def get(cls, url, *args, headers={}, force_refresh=False, pause_function=time.sleep, **kwargs):
491 current_exception = Exception(f"Could fetch url {url}")
492 for attempt in range(3):
493 try:
494 kwargs = {
495 "url": url,
496 "headers": {**cls.headers, **headers},
497 "timeout": cls.requests_timeout,
498 **kwargs,
499 }
500 if isinstance(cls.session, CachedSession):
501 kwargs["force_refresh"] = force_refresh
502 if attempt > 0:
503 kwargs["force_refresh"] = True
504 response = cls.session.get(*args, **kwargs)
505 return response
506 except (
507 requests.ConnectionError,
508 requests.ConnectTimeout,
509 requests.exceptions.HTTPError,
510 ) as e:
511 current_exception = e
512 cls.logger.debug(f"Caught error : {e}", extra={"url": url})
513 # 15 mins, 30 mins, 45 mins
514 delay_minutes = attempt * 15
515 cls.logger.debug(
516 f"Retrying in {delay_minutes}mins ({(datetime.now() + timedelta(minutes=delay_minutes)).time()})",
517 extra={"url": url},
518 )
519 pause_function(delay_minutes * 60)
521 raise current_exception
523 def decode_response(self, response: requests.Response, encoding: str | None = None):
524 """Override this if the content-type headers from the sources are advertising something else than the actual content
525 SASA needs this"""
526 # Force
527 if encoding:
528 response.encoding = encoding
529 return response.text
531 # Attempt to get encoding using HTTP headers
532 content_type_tag = response.headers.get("Content-Type", None)
534 if content_type_tag: 534 ↛ 541line 534 didn't jump to line 541 because the condition on line 534 was always true
535 charset = self.parse_content_type_charset(content_type_tag)
536 if charset: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 response.encoding = charset
538 return response.text
540 # Attempt to get encoding using HTML meta charset tag
541 soup = BeautifulSoup(response.text, "html5lib")
542 charset = soup.select_one("meta[charset]")
543 if charset:
544 htmlencoding = charset.get("charset")
545 if isinstance(htmlencoding, str): 545 ↛ 550line 545 didn't jump to line 550 because the condition on line 545 was always true
546 response.encoding = htmlencoding
547 return response.text
549 # Attempt to get encoding using HTML meta content type tag
550 content_type_tag = soup.select_one(
551 'meta[http-equiv="Content-Type"],meta[http-equiv="content-type"]'
552 )
553 if content_type_tag:
554 content_type = content_type_tag.get("content")
555 if isinstance(content_type, str): 555 ↛ 561line 555 didn't jump to line 561 because the condition on line 555 was always true
556 charset = self.parse_content_type_charset(content_type)
557 if charset: 557 ↛ 561line 557 didn't jump to line 561 because the condition on line 557 was always true
558 response.encoding = charset
559 return response.text
561 return response.text
563 @staticmethod
564 def parse_content_type_charset(content_type: str):
565 header = EmailPolicy.header_factory("content-type", content_type)
566 if "charset" in header.params:
567 return header.params.get("charset")
569 @tracer.start_as_current_span("add_xissue_to_database")
570 def add_xissue_into_database(self, xissue: IssueData) -> IssueData:
571 xissue.journal = self.collection
572 xissue.source = self.source_domain
574 if xissue.fyear == 0:
575 raise ValueError("Failsafe : Cannot insert issue without a year")
577 xpub = create_publisherdata()
578 xpub.name = self.publisher
579 xissue.publisher = xpub
580 xissue.last_modified_iso_8601_date_str = timezone.now().isoformat()
582 attempt = 1
583 success = False
585 while not success and attempt < 4:
586 try:
587 params = {"xissue": xissue, "use_body": False}
588 cmd = addOrUpdateGDMLIssueXmlCmd(params)
589 cmd.do()
590 success = True
591 self.logger.debug(f"Issue {xissue.pid} inserted in database")
592 return xissue
593 except SolrError:
594 self.logger.warning(
595 f"Encoutered SolrError while inserting issue {xissue.pid} in database"
596 )
597 attempt += 1
598 self.logger.debug(f"Attempt {attempt}. sleeping 10 seconds.")
599 self.pause_function(10)
600 except Exception as e:
601 self.logger.error(
602 f"Got exception while attempting to insert {xissue.pid} in database : {e}"
603 )
604 raise e
606 if success is False:
607 raise ConnectionRefusedError("Cannot connect to SolR")
609 assert False, "Unreachable"
611 def get_metadata_using_citation_meta(
612 self,
613 xarticle: ArticleData,
614 xissue: IssueData,
615 soup: BeautifulSoup,
616 what: list[CitationLiteral] = [],
617 ):
618 """
619 :param xarticle: the xarticle that will collect the metadata
620 :param xissue: the xissue that will collect the publisher
621 :param soup: the BeautifulSoup object of tha article page
622 :param what: list of citation_ items to collect.
623 :return: None. The given article is modified
624 """
626 if "title" in what:
627 # TITLE
628 citation_title_node = soup.select_one("meta[name='citation_title']")
629 if citation_title_node: 629 ↛ 634line 629 didn't jump to line 634 because the condition on line 629 was always true
630 title = citation_title_node.get("content")
631 if isinstance(title, str): 631 ↛ 634line 631 didn't jump to line 634 because the condition on line 631 was always true
632 xarticle.title_tex = title
634 if "author" in what: 634 ↛ 663line 634 didn't jump to line 663 because the condition on line 634 was always true
635 # AUTHORS
636 citation_author_nodes = soup.select("meta[name^='citation_author']")
637 current_author: ContributorDict | None = None
638 for citation_author_node in citation_author_nodes:
639 if citation_author_node.get("name") == "citation_author":
640 text_author = citation_author_node.get("content")
641 if not isinstance(text_author, str): 641 ↛ 642line 641 didn't jump to line 642 because the condition on line 641 was never true
642 raise ValueError("Cannot parse author")
643 if text_author == "": 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true
644 current_author = None
645 continue
646 current_author = create_contributor(role="author", string_name=text_author)
647 xarticle.contributors.append(current_author)
648 continue
649 if current_author is None: 649 ↛ 650line 649 didn't jump to line 650 because the condition on line 649 was never true
650 self.logger.warning("Couldn't parse citation author")
651 continue
652 if citation_author_node.get("name") == "citation_author_institution":
653 text_institution = citation_author_node.get("content")
654 if not isinstance(text_institution, str): 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true
655 continue
656 current_author["addresses"].append(text_institution)
657 if citation_author_node.get("name") == "citation_author_ocrid": 657 ↛ 658line 657 didn't jump to line 658 because the condition on line 657 was never true
658 text_orcid = citation_author_node.get("content")
659 if not isinstance(text_orcid, str):
660 continue
661 current_author["orcid"] = text_orcid
663 if "pdf" in what:
664 # PDF
665 citation_pdf_node = soup.select_one('meta[name="citation_pdf_url"]')
666 if citation_pdf_node:
667 pdf_url = citation_pdf_node.get("content")
668 if isinstance(pdf_url, str): 668 ↛ 671line 668 didn't jump to line 671 because the condition on line 668 was always true
669 add_pdf_link_to_xarticle(xarticle, pdf_url)
671 if "lang" in what:
672 # LANG
673 citation_lang_node = soup.select_one("meta[name='citation_language']")
674 if citation_lang_node: 674 ↛ 680line 674 didn't jump to line 680 because the condition on line 674 was always true
675 # TODO: check other language code
676 content_text = citation_lang_node.get("content")
677 if isinstance(content_text, str): 677 ↛ 680line 677 didn't jump to line 680 because the condition on line 677 was always true
678 xarticle.lang = standardize_tag(content_text)
680 if "abstract" in what:
681 # ABSTRACT
682 abstract_node = soup.select_one("meta[name='citation_abstract']")
683 if abstract_node is not None:
684 abstract = abstract_node.get("content")
685 if not isinstance(abstract, str): 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true
686 raise ValueError("Couldn't parse abstract from meta")
687 abstract = BeautifulSoup(abstract, "html.parser").text
688 lang = abstract_node.get("lang")
689 if not isinstance(lang, str):
690 lang = self.detect_language(abstract, xarticle)
691 xarticle.abstracts.append(create_abstract(lang=lang, value_tex=abstract))
693 if "page" in what:
694 # PAGES
695 citation_fpage_node = soup.select_one("meta[name='citation_firstpage']")
696 if citation_fpage_node:
697 page = citation_fpage_node.get("content")
698 if isinstance(page, str): 698 ↛ 703line 698 didn't jump to line 703 because the condition on line 698 was always true
699 page = page.split("(")[0]
700 if len(page) < 32: 700 ↛ 703line 700 didn't jump to line 703 because the condition on line 700 was always true
701 xarticle.fpage = page
703 citation_lpage_node = soup.select_one("meta[name='citation_lastpage']")
704 if citation_lpage_node:
705 page = citation_lpage_node.get("content")
706 if isinstance(page, str): 706 ↛ 711line 706 didn't jump to line 711 because the condition on line 706 was always true
707 page = page.split("(")[0]
708 if len(page) < 32: 708 ↛ 711line 708 didn't jump to line 711 because the condition on line 708 was always true
709 xarticle.lpage = page
711 if "doi" in what:
712 # DOI
713 citation_doi_node = soup.select_one("meta[name='citation_doi']")
714 if citation_doi_node: 714 ↛ 723line 714 didn't jump to line 723 because the condition on line 714 was always true
715 doi = citation_doi_node.get("content")
716 if isinstance(doi, str): 716 ↛ 723line 716 didn't jump to line 723 because the condition on line 716 was always true
717 doi = doi.strip()
718 pos = doi.find("10.")
719 if pos > 0: 719 ↛ 720line 719 didn't jump to line 720 because the condition on line 719 was never true
720 doi = doi[pos:]
721 xarticle.doi = doi
723 if "mr" in what:
724 # MR
725 citation_mr_node = soup.select_one("meta[name='citation_mr']")
726 if citation_mr_node: 726 ↛ 734line 726 didn't jump to line 734 because the condition on line 726 was always true
727 mr = citation_mr_node.get("content")
728 if isinstance(mr, str): 728 ↛ 734line 728 didn't jump to line 734 because the condition on line 728 was always true
729 mr = mr.strip()
730 if mr.find("MR") == 0: 730 ↛ 734line 730 didn't jump to line 734 because the condition on line 730 was always true
731 mr = mr[2:]
732 xarticle.extids.append(("mr-item-id", mr))
734 if "zbl" in what:
735 # ZBL
736 citation_zbl_node = soup.select_one("meta[name='citation_zbl']")
737 if citation_zbl_node: 737 ↛ 745line 737 didn't jump to line 745 because the condition on line 737 was always true
738 zbl = citation_zbl_node.get("content")
739 if isinstance(zbl, str): 739 ↛ 745line 739 didn't jump to line 745 because the condition on line 739 was always true
740 zbl = zbl.strip()
741 if zbl.find("Zbl") == 0: 741 ↛ 745line 741 didn't jump to line 745 because the condition on line 741 was always true
742 zbl = zbl[3:].strip()
743 xarticle.extids.append(("zbl-item-id", zbl))
745 if "publisher" in what:
746 # PUBLISHER
747 citation_publisher_node = soup.select_one("meta[name='citation_publisher']")
748 if citation_publisher_node: 748 ↛ 757line 748 didn't jump to line 757 because the condition on line 748 was always true
749 pub = citation_publisher_node.get("content")
750 if isinstance(pub, str): 750 ↛ 757line 750 didn't jump to line 757 because the condition on line 750 was always true
751 pub = pub.strip()
752 if pub != "": 752 ↛ 757line 752 didn't jump to line 757 because the condition on line 752 was always true
753 xpub = create_publisherdata()
754 xpub.name = pub
755 xissue.publisher = xpub
757 if "keywords" in what:
758 # KEYWORDS
759 citation_kwd_nodes = soup.select("meta[name='citation_keywords']")
760 for kwd_node in citation_kwd_nodes:
761 kwds = kwd_node.get("content")
762 if isinstance(kwds, str): 762 ↛ 760line 762 didn't jump to line 760 because the condition on line 762 was always true
763 kwds = kwds.split(",")
764 for kwd in kwds:
765 if kwd == "":
766 continue
767 kwd = kwd.strip()
768 xarticle.kwds.append({"type": "", "lang": xarticle.lang, "value": kwd})
770 if "references" in what:
771 citation_references = soup.select("meta[name='citation_reference']")
772 for index, tag in enumerate(citation_references):
773 content = tag.get("content")
774 if not isinstance(content, str): 774 ↛ 775line 774 didn't jump to line 775 because the condition on line 774 was never true
775 raise ValueError("Cannot parse citation_reference meta")
776 label = str(index + 1)
777 if regex.match(r"^\[\d+\].*", content): 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true
778 label = None
779 xarticle.bibitems.append(self.__parse_meta_citation_reference(content, label))
781 def get_metadata_using_dcterms(
782 self,
783 xarticle: ArticleData,
784 soup: "Tag",
785 what: "Iterable[Literal['abstract', 'keywords', 'date_published', 'article_type']]",
786 ):
787 if "abstract" in what: 787 ↛ 795line 787 didn't jump to line 795 because the condition on line 787 was always true
788 abstract_tag = soup.select_one("meta[name='DCTERMS.abstract']")
789 if abstract_tag: 789 ↛ 795line 789 didn't jump to line 795 because the condition on line 789 was always true
790 abstract_text = self.get_str_attr(abstract_tag, "content")
791 xarticle.abstracts.append(
792 create_abstract(lang="en", value_tex=cleanup_str(abstract_text))
793 )
795 if "keywords" in what: 795 ↛ 804line 795 didn't jump to line 804 because the condition on line 795 was always true
796 keyword_tags = soup.select("meta[name='DC.subject']")
797 for tag in keyword_tags:
798 kwd_text = tag.get("content")
799 if not isinstance(kwd_text, str) or len(kwd_text) == 0: 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true
800 continue
801 kwd = create_subj(value=kwd_text)
802 xarticle.kwds.append(kwd)
804 if "date_published" in what: 804 ↛ 805line 804 didn't jump to line 805 because the condition on line 804 was never true
805 published_tag = soup.select_one("meta[name='DC.Date.created']")
806 if published_tag:
807 published_text = self.get_str_attr(published_tag, "content")
808 xarticle.date_published = published_text
810 if "article_type" in what: 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true
811 type_tag = soup.select_one("meta[name='DC.Type.articleType']")
812 if type_tag:
813 type_text = self.get_str_attr(type_tag, "content")
814 xarticle.atype = type_text
816 def create_xissue(
817 self,
818 url: str | None,
819 year: int,
820 volume_number: str | None,
821 issue_number: str | None = None,
822 vseries: str | None = None,
823 ):
824 if url is not None and url.endswith("/"): 824 ↛ 825line 824 didn't jump to line 825 because the condition on line 824 was never true
825 url = url[:-1]
826 xissue = create_issuedata()
827 xissue.url = url
829 xissue.pid = self.get_issue_pid(
830 self.collection_id, year, volume_number, issue_number, vseries
831 )
833 xissue.fyear = year
835 if volume_number is not None:
836 xissue.volume = regex.sub(r"[^\w-]+", "_", volume_number)
838 if issue_number is not None:
839 xissue.number = issue_number.replace(",", "-")
841 if vseries is not None: 841 ↛ 842line 841 didn't jump to line 842 because the condition on line 841 was never true
842 xissue.vseries = vseries
843 return xissue
845 def detect_language(self, text: str, article: ArticleData | None = None):
846 if article and article.lang is not None and article.lang != "und":
847 return article.lang
849 language = self.language_detector.detect_language_of(text)
851 if not language: 851 ↛ 852line 851 didn't jump to line 852 because the condition on line 851 was never true
852 return "und"
853 return language.iso_code_639_1.name.lower()
855 def get_str_attr(self, tag: "Tag", attr: str):
856 """Equivalent of `tag.get(attr)`, but ensures the return value is a string"""
857 node_attr = tag.get(attr)
858 if isinstance(node_attr, list): 858 ↛ 859line 858 didn't jump to line 859 because the condition on line 858 was never true
859 raise ValueError(
860 f"[{self.source_domain}] {self.collection_id} : html tag has multiple {attr} attributes."
861 )
862 if node_attr is None: 862 ↛ 863line 862 didn't jump to line 863 because the condition on line 862 was never true
863 raise ValueError(
864 f"[{self.source_domain}] {self.collection_id} : html tag doesn't have any {attr} attributes"
865 )
866 return node_attr
868 def create_trans_title(
869 self,
870 resource_type: str,
871 title_str: str,
872 lang: str,
873 xresource_lang: str,
874 title_type: str = "main",
875 ):
876 tag = "trans-title" if resource_type == "article" else "issue-title"
878 ckeditor_data = build_jats_data_from_html_field(
879 title_str,
880 tag=tag,
881 text_lang=lang,
882 resource_lang=xresource_lang,
883 delimiter_inline=self.delimiter_inline_formula,
884 delimiter_disp=self.delimiter_disp_formula,
885 )
887 titledata = create_titledata(
888 lang=lang,
889 type="main",
890 title_html=ckeditor_data["value_html"],
891 title_xml=ckeditor_data["value_xml"],
892 )
894 return titledata
896 references_mapping = {
897 "citation_title": get_article_title_xml,
898 "citation_journal_title": get_source_xml,
899 "citation_publication_date": get_year_xml,
900 "citation_firstpage": get_fpage_xml,
901 "citation_lastpage": get_lpage_xml,
902 }
904 @classmethod
905 def __parse_meta_citation_reference(cls, content: str, label=None):
906 categories = content.split(";")
908 if len(categories) == 1:
909 return JatsBase.bake_ref(content, label=label)
911 citation_data = [c.split("=") for c in categories if "=" in c]
912 del categories
914 xml_string = ""
915 authors_parsed = False
916 authors_strings = []
917 for data in citation_data:
918 key = data[0].strip()
919 citation_content = data[1]
920 if key == "citation_author":
921 authors_strings.append(get_author_xml(template_str=citation_content))
922 continue
923 elif not authors_parsed:
924 xml_string += ", ".join(authors_strings)
925 authors_parsed = True
927 if key in cls.references_mapping:
928 xml_string += " " + cls.references_mapping[key](citation_content)
930 return JatsBase.bake_ref(xml_string, label=label)
932 @classmethod
933 def get_or_create_source(cls):
934 source, created = Source.objects.get_or_create(
935 domain=cls.source_domain,
936 defaults={
937 "name": cls.source_name,
938 "website": cls.source_website,
939 "view_id": cls.get_view_id(),
940 },
941 )
942 if created: 942 ↛ 943line 942 didn't jump to line 943 because the condition on line 942 was never true
943 source.save()
944 return source
946 @staticmethod
947 def get_issue_pid(
948 collection_id: str,
949 year: int,
950 volume_number: str | None = None,
951 issue_number: str | None = None,
952 series: str | None = None,
953 ):
954 # Replace any non-word character with an underscore
955 pid = f"{collection_id}_{year}"
956 if series is not None: 956 ↛ 957line 956 didn't jump to line 957 because the condition on line 956 was never true
957 pid += f"_{series}"
958 if volume_number is not None:
959 pid += f"_{volume_number}"
960 if issue_number is not None:
961 pid += f"_{issue_number}"
962 pid = regex.sub(r"[^\w-]+", "_", cleanup_str(pid))
963 return pid
965 @staticmethod
966 def set_pages(article: ArticleData, pages: str, separator: str = "-"):
967 pages_split = pages.split(separator)
968 if len(pages_split) == 0: 968 ↛ 969line 968 didn't jump to line 969 because the condition on line 968 was never true
969 article.page_range = pages
970 if len(pages_split) > 0: 970 ↛ exitline 970 didn't return from function 'set_pages' because the condition on line 970 was always true
971 if pages[0].isnumeric(): 971 ↛ exitline 971 didn't return from function 'set_pages' because the condition on line 971 was always true
972 article.fpage = pages_split[0]
973 if ( 973 ↛ 978line 973 didn't jump to line 978 because the condition on line 973 was never true
974 len(pages_split) > 1
975 and pages_split[0] != pages_split[1]
976 and pages_split[1].isnumeric()
977 ):
978 article.lpage = pages_split[1]
980 @staticmethod
981 def _process_pdf_header(chunk: str, response: requests.Response | aiohttp.ClientResponse):
982 content_type = response.headers.get("Content-Type")
983 if regex.match(rb"^%PDF-\d\.\d", chunk):
984 if content_type and "application/pdf" in content_type:
985 # The file is unmistakably a pdf
986 return [
987 True,
988 response,
989 {
990 "status": ExtlinkChecked.Status.OK,
991 "message": "",
992 },
993 ]
994 # The file is a pdf, but the content type advertised by the server is wrong
995 return [
996 True,
997 response,
998 {
999 "status": ExtlinkChecked.Status.WARNING,
1000 "message": f"Content-Type header: {content_type}",
1001 },
1002 ]
1004 # Reaching here means we couldn't find the pdf.
1005 if not content_type or "application/pdf" not in content_type:
1006 return [
1007 False,
1008 response,
1009 {
1010 "status": ExtlinkChecked.Status.ERROR,
1011 "message": f"Content-Type header: {content_type}; PDF Header not found: got {chunk}",
1012 },
1013 ]
1015 return [
1016 False,
1017 response,
1018 {
1019 "status": ExtlinkChecked.Status.ERROR,
1020 "message": f"PDF Header not found: got {chunk}",
1021 },
1022 ]
1024 @classmethod
1025 async def a_check_pdf_link_validity(
1026 cls, url: str, verify=True
1027 ) -> list[bool | aiohttp.ClientResponse | dict]:
1028 """
1029 Check the validity of the PDF links.
1030 """
1031 CHUNK_SIZE = 10 # Nombre de caractères à récupérer
1032 header = {
1033 "Range": f"bytes=0-{CHUNK_SIZE}",
1034 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
1035 }
1036 async with cls.async_session.get(
1037 url, headers=header, allow_redirects=True, ssl=verify
1038 ) as response:
1039 try:
1040 chunk = await response.content.read(CHUNK_SIZE)
1041 return BaseCollectionCrawler._process_pdf_header(chunk, response)
1042 except StopIteration:
1043 return [
1044 False,
1045 response,
1046 {
1047 "status": ExtlinkChecked.Status.ERROR,
1048 "message": "Error reading PDF header",
1049 },
1050 ]
1052 @classmethod
1053 def check_pdf_link_validity(
1054 cls, url: str, verify=True
1055 ) -> list[bool | requests.Response | None | dict]:
1056 """
1057 Check the validity of the PDF links.
1058 """
1059 CHUNK_SIZE = 10 # Nombre de caractères à récupérer
1060 header = {
1061 "Range": f"bytes=0-{CHUNK_SIZE}",
1062 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
1063 }
1064 with cls.get(
1065 url, headers=header, allow_redirects=True, verify=verify, stream=True
1066 ) as response:
1067 try:
1068 chunk = next(response.iter_content(CHUNK_SIZE))
1069 return BaseCollectionCrawler._process_pdf_header(chunk, response)
1070 except StopIteration:
1071 return [
1072 False,
1073 response,
1074 {
1075 "status": ExtlinkChecked.Status.ERROR,
1076 "message": "Error reading PDF header",
1077 },
1078 ]
1080 @classmethod
1081 async def check_extlink_validity(cls, extlink: "ExtLink"):
1082 """
1083 Method used by rot_monitoring to check if links have expired
1084 """
1085 defaults: dict = {"date": datetime.now(), "status": ExtlinkChecked.Status.OK}
1086 header = {
1087 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0"
1088 }
1089 verify = True
1090 if not cls.verify:
1091 verify = False
1092 try:
1093 # For the GDZ links, we just check if the http response is 200 or 206
1094 if (
1095 extlink.rel == "article-pdf"
1096 and "gdz.sub.uni-goettingen.de" not in extlink.location
1097 ):
1098 isok, response, message = await cls.a_check_pdf_link_validity(
1099 extlink.location, verify
1100 )
1101 defaults.update(message)
1102 defaults["http_status"] = response.status
1103 else:
1104 async with cls.async_session.get(
1105 url=extlink.location,
1106 headers=header,
1107 allow_redirects=True,
1108 ssl=verify,
1109 ) as response:
1110 defaults["http_status"] = response.status
1111 if response.status not in (200, 206):
1112 defaults["status"] = ExtlinkChecked.Status.ERROR
1114 except aiohttp.ClientSSLError:
1115 cls.logger.error("SSL error for the url: %s", extlink.location)
1116 defaults["status"] = ExtlinkChecked.Status.ERROR
1117 defaults["message"] = "SSL error"
1118 except aiohttp.ClientConnectionError:
1119 cls.logger.error("Connection error for the url: %s", extlink.location)
1120 defaults["status"] = ExtlinkChecked.Status.ERROR
1121 defaults["message"] = "Connection error"
1122 except TimeoutError:
1123 cls.logger.error("Timeout error for the url: %s", extlink.location)
1124 defaults["status"] = ExtlinkChecked.Status.ERROR
1125 defaults["message"] = "Timeout error"
1126 finally:
1127 try:
1128 await ExtlinkChecked.objects.aupdate_or_create(extlink=extlink, defaults=defaults)
1129 cls.logger.info(
1130 "DB Update, source: %s, url: %s", cls.source_domain, extlink.location
1131 )
1132 except IntegrityError:
1133 cls.logger.error(
1134 "Extlink was deleted, source: %s, url: %s", cls.source_domain, extlink.location
1135 )
1137 def resolve_year_end(self, pid: str, default: int) -> int:
1138 if pid in self.pid_year_restrictions:
1139 return datetime.now().year - self.pid_year_restrictions[pid]
1140 return default