Coverage for src/crawler/utils.py: 40%
144 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-29 13:39 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-29 13:39 +0000
1import base64
2import hashlib
3import json
4import logging
5import os
6import pickle
7import re
8import unicodedata
9from collections.abc import Callable
10from datetime import UTC, datetime
11from functools import cache, wraps
12from typing import Literal, TypeVar
14import regex
15from bs4 import BeautifulSoup
16from django.conf import settings
17from django.contrib.auth.models import User
18from history.model_data import HistoryEventDict, HistoryEventType
19from history.models import HistoryEventStatus
21# from ptf.models import ResourceId
22from history.utils import insert_history_event
23from ptf import model_helpers
24from ptf.cmds import ptf_cmds
25from ptf.cmds.xml.xml_utils import escape
26from ptf.exceptions import ResourceDoesNotExist
27from ptf.model_data import ResourceData, create_extlink, create_publicationdata
28from ptf.models import Collection
29from pymongo import MongoClient
30from pymongo.synchronous.database import Database
32from crawler.types import JSONCol
34logger = logging.getLogger(__name__)
37def insert_crawl_event_in_history(
38 colid: str,
39 source_domain: str,
40 username: str,
41 status: HistoryEventStatus,
42 tasks_count,
43 message: str,
44 event_type: HistoryEventType = "import-collection",
45 title=None,
46):
47 collection = model_helpers.get_collection(colid, sites=False)
48 user = User.objects.get(username=username)
50 event_data: HistoryEventDict = {
51 "type": event_type,
52 "pid": f"{colid}-{source_domain}",
53 "col": collection,
54 "source": source_domain,
55 "status": status,
56 "title": collection.title_html if collection else (title or colid),
57 "userid": user.pk,
58 "type_error": "",
59 "message": message,
60 }
62 insert_history_event(event_data)
65def col_has_source(col: JSONCol, filter: str):
66 return any(source for source in col["sources"] if source == filter)
69def get_cols_by_source(source: str) -> list[JSONCol]:
70 """
71 Get all cols by source
72 @param source: str
73 @return: list of collections
74 """
75 data = get_all_cols()
77 return [col for col in data.values() if col_has_source(col, source)]
80def get_all_cols_by_source():
81 """
82 Get all cols by source
83 @return: dict of collections by source
84 """
85 data = get_all_cols()
87 sources: dict[str, list] = {}
88 for col in data.values():
89 for source in col["sources"]:
90 if source not in sources:
91 sources[source] = []
92 sources[source].append(col)
94 return sources
97@cache
98def get_all_cols() -> dict[str, JSONCol]:
99 with open(
100 os.path.dirname(os.path.abspath(__file__)) + "/data/all_cols.json", encoding="utf8"
101 ) as data_collections:
102 return json.load(data_collections)
105def get_or_create_collection(pid: str):
106 """
107 Creates a Collection based on its pid.
108 The pid has to be in the list of collections given by the Documentation team (CSV then transformed in JSON)
109 """
111 all_collections = get_all_cols()
113 if pid not in all_collections:
114 raise ValueError(f"{pid} is not listed in all_cols.csv")
116 col_data = next(item for item in all_collections.items() if item[0] == pid)[1]
118 collection: Collection | None = model_helpers.get_collection(pid, sites=False)
120 if not collection:
121 p = model_helpers.get_provider("mathdoc-id")
123 xcol = create_publicationdata()
124 xcol.coltype = col_data["type"]
125 xcol.pid = pid
126 xcol.title_tex = col_data["title"]
127 # Mis en commentaire car trop tôt, Taban n'a pas encore validé les ISSNs
128 # xcol.e_issn = col_data["ISSN_électronique"]
129 # xcol.issn = col_data["ISSN_papier"]
130 xcol.title_html = col_data["title"]
131 xcol.title_xml = f"<title-group><title>{col_data['title']}</title></title-group>"
132 xcol.lang = "en"
134 cmd = ptf_cmds.addCollectionPtfCmd({"xobj": xcol})
135 cmd.set_provider(p)
136 collection = cmd.do()
138 # Mis en commentaire car trop tôt, Taban n'a pas encore validé les ISSNs
139 # if col_data["ISSN_électronique"] != "":
140 # e_issn = {
141 # "resource_id": collection.resource_ptr_id,
142 # "id_type": "e_issn",
143 # "id_value": col_data["ISSN_électronique"],
144 # }
145 # ResourceId.objects.create(**e_issn)
146 #
147 # if col_data["ISSN_papier"] != "":
148 # issn = {
149 # "resource_id": collection.resource_ptr_id,
150 # "id_type": "issn",
151 # "id_value": col_data["ISSN_papier"],
152 # }
153 # ResourceId.objects.create(**issn)
155 if not collection:
156 raise ResourceDoesNotExist(f"Resource {pid} does not exist")
158 return collection
161def cleanup_str(input: str, unsafe=False):
162 # some white spaces aren't actual space characters, like \xa0
163 input = unicodedata.normalize("NFKC", input)
164 input = re.sub(r"[\x7f]+", "", input)
165 # remove useless continuous \n and spaces from the string
166 input = re.sub(r"[\n\t\r ]+", " ", input).strip()
167 if unsafe: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 return input
169 return escape(input)
172def add_pdf_link_to_xarticle(
173 xarticle: ResourceData,
174 pdf_url: str,
175 mimetype: Literal["application/pdf", "application/x-tex", "text/html"] = "application/pdf",
176):
177 xarticle.streams.append(
178 {
179 "rel": "full-text",
180 "mimetype": mimetype,
181 "location": pdf_url,
182 "base": "",
183 "text": "Full Text",
184 }
185 )
187 # The pdf url is already added as a stream (just above) but might be replaced by a file later on.
188 # Keep the pdf url as an Extlink if we want to propose both option:
189 # - direct download of a local PDF
190 # - URL to the remote PDF
191 if mimetype == "application/pdf": 191 ↛ 193line 191 didn't jump to line 193 because the condition on line 191 was always true
192 rel = "article-pdf"
193 elif mimetype == "text/html":
194 rel = "article-html"
195 else:
196 rel = "article-tex"
197 ext_link = create_extlink(rel=rel, location=pdf_url)
198 xarticle.ext_links.append(ext_link)
201def add_source_link_to_xarticle(xarticle, url: str, domain: str):
202 ext_link = create_extlink()
203 ext_link["rel"] = "source"
204 ext_link["location"] = url
205 ext_link["metadata"] = domain
206 xarticle.ext_links.append(ext_link)
209def regex_to_dict(pattern: str, value: str, *, error_msg="Regex failed to parse"):
210 issue_search = regex.search(pattern, value)
211 if not issue_search: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
212 raise ValueError(error_msg)
214 return issue_search.groupdict()
217def get_base(soup: BeautifulSoup, default: str):
218 base_tag = soup.select_one("head base")
219 if not base_tag:
220 return default
221 base = base_tag.get("href")
222 if not isinstance(base, str):
223 raise TypeError("Cannot parse base href")
224 return base
227try:
228 from crawler.tests.data_generation.decorators import skip_generation
229except ImportError:
231 def skip_generation(func):
232 def wrapper(*args, **kwargs):
233 return func(*args, **kwargs)
235 return wrapper
238class PickleSerializer:
239 def serialize(self, obj):
240 return base64.b64encode(pickle.dumps(obj, protocol=-1))
242 def deserialize(self, serialized):
243 return pickle.loads(base64.b64decode(serialized))
246RT = TypeVar("RT")
249def mongo_cache(
250 db_conn: Database = MongoClient(host=getattr(settings, "MONGO_HOSTNAME", "localhost"))[ # noqa: B008
251 "crawler_func_cache"
252 ],
253 prefix="cache_",
254 capped=True,
255 capped_size=1000000000,
256 hash_keys=True,
257 serializer=PickleSerializer,
258) -> Callable[[Callable[..., RT]], Callable[..., RT]]:
259 """Helper decorator to speedup local development
260 ```py
263 @mongo_cache(
264 db_conn=MongoClient(host=getattr(settings, "MONGO_HOSTNAME", "localhost"))[
265 "crawler_func_cache"
266 ]
267 )
268 def parse_collection_content(self, content):
269 ...
270 ```
271 """
273 def decorator(func: Callable[..., RT]) -> Callable[..., RT]:
274 serializer_ins = serializer()
275 col_name = f"{prefix}{func.__name__}"
276 if capped:
277 db_conn.create_collection(col_name, capped=capped, size=capped_size)
279 cache_col = db_conn[col_name]
280 cache_col.create_index("key", unique=True)
281 cache_col.create_index("date", expireAfterSeconds=86400)
283 @wraps(func)
284 def wrapped_func(*args, **kwargs) -> RT:
285 cache_key = pickle.dumps((args[1:], kwargs), protocol=-1)
286 if hash_keys:
287 cache_key = hashlib.md5(cache_key).hexdigest()
288 else:
289 cache_key = base64.b64encode(cache_key)
291 cached_obj = cache_col.find_one({"key": cache_key})
292 if cached_obj:
293 return serializer_ins.deserialize(cached_obj["result"])
295 ret = func(*args, **kwargs)
296 cache_col.update_one(
297 {"key": cache_key},
298 {
299 "$set": {
300 "result": serializer_ins.serialize(ret),
301 "date": datetime.now(UTC),
302 }
303 },
304 upsert=True,
305 )
307 return ret
309 return wrapped_func
311 return decorator