]> git.mxchange.org Git - fba.git/blob - fba/http/federation.py
Continued:
[fba.git] / fba / http / federation.py
1 # Copyright (C) 2023 Free Software Foundation
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License as published
5 # by the Free Software Foundation, either version 3 of the License, or
6 # (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU Affero General Public License for more details.
12 #
13 # You should have received a copy of the GNU Affero General Public License
14 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
15
16 import logging
17
18 from urllib.parse import urlparse
19
20 import bs4
21 import validators
22
23 from fba import csrf
24 from fba import utils
25
26 from fba.helpers import config
27 from fba.helpers import cookies
28 from fba.helpers import domain as domain_helper
29 from fba.helpers import software as software_helper
30 from fba.helpers import tidyup
31 from fba.helpers import version
32
33 from fba.http import network
34
35 from fba.models import instances
36
37 from fba.networks import lemmy
38 from fba.networks import misskey
39 from fba.networks import peertube
40
41 logging.basicConfig(level=logging.INFO)
42 logger = logging.getLogger(__name__)
43
44 def fetch_instances(domain: str, origin: str, software: str, command: str, path: str = None):
45     logger.debug("domain='%s',origin='%s',software='%s',command='%s',path='%s' - CALLED!", domain, origin, software, command, path)
46     domain_helper.raise_on(domain)
47
48     if not isinstance(origin, str) and origin is not None:
49         raise ValueError(f"Parameter origin[]='{type(origin)}' is not of type 'str'")
50     elif not isinstance(command, str):
51         raise ValueError(f"Parameter command[]='{type(command)}' is not of type 'str'")
52     elif command == "":
53         raise ValueError("Parameter 'command' is empty")
54     elif software is None:
55         try:
56             logger.debug("Software for domain='%s' is not set, determining ...", domain)
57             software = determine_software(domain, path)
58         except network.exceptions as exception:
59             logger.warning("Exception '%s' during determining software type", type(exception))
60             instances.set_last_error(domain, exception)
61
62         logger.debug("Determined software='%s' for domain='%s'", software, domain)
63     elif not isinstance(software, str):
64         raise ValueError(f"Parameter software[]='{type(software)}' is not of type 'str'")
65
66     logger.debug("Checking if domain='%s' is registered ...", domain)
67     if not instances.is_registered(domain):
68         logger.debug("Adding new domain='%s',origin='%s',command='%s',path='%s',software='%s'", domain, origin, command, path, software)
69         instances.add(domain, origin, command, path, software)
70
71     logger.debug("Updating last_instance_fetch for domain='%s' ...", domain)
72     instances.set_last_instance_fetch(domain)
73
74     peerlist = list()
75     try:
76         logger.debug("Fetching instances for domain='%s',software='%s',origin='%s'", domain, software, origin)
77         peerlist = fetch_peers(domain, software, origin)
78     except network.exceptions as exception:
79         logger.warning("Cannot fetch peers from domain='%s': '%s'", domain, type(exception))
80
81     logger.debug("peerlist[]='%s'", type(peerlist))
82     if isinstance(peerlist, list):
83         logger.debug("Invoking instances.set_total_peerlist(%s,%d) ...", domain, len(peerlist))
84         instances.set_total_peers(domain, peerlist)
85
86     logger.debug("peerlist[]='%s'", type(peerlist))
87     if peerlist is None or len(peerlist) == 0:
88         logger.warning("Cannot fetch peers: domain='%s'", domain)
89
90         if instances.has_pending(domain):
91             logger.debug("Flushing updates for domain='%s' ...", domain)
92             instances.update_data(domain)
93
94         logger.debug("Invoking cookies.clear(%s) ...", domain)
95         cookies.clear(domain)
96
97         logger.debug("EXIT!")
98         return
99
100     logger.info("Checking %d instance(s) from domain='%s',software='%s' ...", len(peerlist), domain, software)
101     for instance in peerlist:
102         logger.debug("instance='%s'", instance)
103         if instance is None:
104             # Skip "None" types as tidup.domain() cannot parse them
105             continue
106
107         logger.debug("instance='%s' - BEFORE!", instance)
108         instance = tidyup.domain(instance)
109         logger.debug("instance='%s' - AFTER!", instance)
110
111         if instance == "":
112             logger.warning("Empty instance after tidyup.domain(), domain='%s'", domain)
113             continue
114         elif not utils.is_domain_wanted(instance):
115             logger.debug("instance='%s' is not wanted - SKIPPED!", instance)
116             continue
117         elif instance.find("/profile/") > 0 or instance.find("/users/") > 0 or (instances.is_registered(instance.split("/")[0]) and instance.find("/c/") > 0):
118             logger.debug("instance='%s' is a link to a single user profile - SKIPPED!", instance)
119             continue
120         elif instance.find("/tag/") > 0:
121             logger.debug("instance='%s' is a link to a tag - SKIPPED!", instance)
122             continue
123         elif not instances.is_registered(instance):
124             logger.debug("Adding new instance='%s',domain='%s',command='%s'", instance, domain, command)
125             instances.add(instance, domain, command)
126
127     logger.debug("Invoking cookies.clear(%s) ...", domain)
128     cookies.clear(domain)
129
130     logger.debug("Checking if domain='%s' has pending updates ...", domain)
131     if instances.has_pending(domain):
132         logger.debug("Flushing updates for domain='%s' ...", domain)
133         instances.update_data(domain)
134
135     logger.debug("EXIT!")
136
137 def fetch_peers(domain: str, software: str, origin: str) -> list:
138     logger.debug("domain='%s',software='%s',origin='%s' - CALLED!", domain, software, origin)
139     domain_helper.raise_on(domain)
140
141     if not isinstance(software, str) and software is not None:
142         raise ValueError(f"software[]='{type(software)}' is not of type 'str'")
143
144     if software == "misskey":
145         logger.debug("Invoking misskey.fetch_peers(%s) ...", domain)
146         return misskey.fetch_peers(domain)
147     elif software == "lemmy":
148         logger.debug("Invoking lemmy.fetch_peers(%s,%s) ...", domain, origin)
149         return lemmy.fetch_peers(domain, origin)
150     elif software == "peertube":
151         logger.debug("Invoking peertube.fetch_peers(%s) ...", domain)
152         return peertube.fetch_peers(domain)
153
154     # No CSRF by default, you don't have to add network.api_headers by yourself here
155     headers = tuple()
156
157     try:
158         logger.debug("Checking CSRF for domain='%s'", domain)
159         headers = csrf.determine(domain, dict())
160     except network.exceptions as exception:
161         logger.warning("Exception '%s' during checking CSRF (fetch_peers,%s) - EXIT!", type(exception), __name__)
162         instances.set_last_error(domain, exception)
163         return list()
164
165     paths = [
166         "/api/v1/instance/peers",
167         "/api/v3/site",
168     ]
169
170     # Init peers variable
171     peers = list()
172
173     logger.debug("Checking %d paths ...", len(paths))
174     for path in paths:
175         logger.debug("Fetching path='%s' from domain='%s',software='%s' ...", path, domain, software)
176         data = network.get_json_api(
177             domain,
178             path,
179             headers,
180             (config.get("connection_timeout"), config.get("read_timeout"))
181         )
182
183         logger.debug("data[]='%s'", type(data))
184         if "error_message" in data:
185             logger.debug("Was not able to fetch peers from path='%s',domain='%s' ...", path, domain)
186             instances.set_last_error(domain, data)
187         elif "json" in data and len(data["json"]) > 0:
188             logger.debug("Querying API path='%s' was successful: domain='%s',data[json][%s]()=%d", path, domain, type(data['json']), len(data['json']))
189             peers = data["json"]
190
191             logger.debug("Marking domain='%s' as successfully handled ...", domain)
192             instances.set_success(domain)
193             break
194
195     if not isinstance(peers, list):
196         logger.warning("peers[]='%s' is not of type 'list', maybe bad API response?", type(peers))
197         peers = list()
198
199     logger.debug("Invoking instances.set_total_peers(%s,%d) ...", domain, len(peers))
200     instances.set_total_peers(domain, peers)
201
202     logger.debug("peers()=%d - EXIT!", len(peers))
203     return peers
204
205 def fetch_nodeinfo(domain: str, path: str = None) -> dict:
206     logger.debug("domain='%s',path='%s' - CALLED!", domain, path)
207     domain_helper.raise_on(domain)
208
209     if not isinstance(path, str) and path is not None:
210         raise ValueError(f"Parameter path[]='{type(path)}' is not of type 'str'")
211
212     logger.debug("Fetching nodeinfo from domain='%s' ...", domain)
213     nodeinfo = fetch_wellknown_nodeinfo(domain)
214
215     logger.debug("nodeinfo[%s](%d='%s'", type(nodeinfo), len(nodeinfo), nodeinfo)
216     if "error_message" not in nodeinfo and "json" in nodeinfo and len(nodeinfo["json"]) > 0:
217         logger.debug("Invoking instances.set_last_nodeinfo(%s) ...", domain)
218         instances.set_last_nodeinfo(domain)
219
220         logger.debug("Found nodeinfo[json]()=%d - EXIT!", len(nodeinfo['json']))
221         return nodeinfo
222
223     # No CSRF by default, you don't have to add network.api_headers by yourself here
224     headers = tuple()
225     data = dict()
226
227     try:
228         logger.debug("Checking CSRF for domain='%s'", domain)
229         headers = csrf.determine(domain, dict())
230     except network.exceptions as exception:
231         logger.warning("Exception '%s' during checking CSRF (nodeinfo,%s) - EXIT!", type(exception), __name__)
232         instances.set_last_error(domain, exception)
233         instances.set_software(domain, None)
234         instances.set_detection_mode(domain, None)
235         instances.set_nodeinfo_url(domain, None)
236         return {
237             "status_code"  : 500,
238             "error_message": f"exception[{type(exception)}]='{str(exception)}'",
239             "exception"    : exception,
240         }
241
242     request_paths = [
243        "/nodeinfo/2.1.json",
244        "/nodeinfo/2.1",
245        "/nodeinfo/2.0.json",
246        "/nodeinfo/2.0",
247        "/nodeinfo/1.0.json",
248        "/nodeinfo/1.0",
249        "/api/v1/instance",
250     ]
251
252     for request in request_paths:
253         logger.debug("request='%s'", request)
254         http_url  = f"http://{domain}{path}"
255         https_url = f"https://{domain}{path}"
256
257         logger.debug("path[%s]='%s',request='%s',http_url='%s',https_url='%s'", type(path), path, request, http_url, https_url)
258         if path is None or path in [request, http_url, https_url]:
259             logger.debug("path='%s',http_url='%s',https_url='%s'", path, http_url, https_url)
260             if path in [http_url, https_url]:
261                 logger.debug("domain='%s',path='%s' has protocol in path, splitting ...", domain, path)
262                 components = urlparse(path)
263                 path = components.path
264
265             logger.debug("Fetching request='%s' from domain='%s' ...", request, domain)
266             data = network.get_json_api(
267                 domain,
268                 request,
269                 headers,
270                 (config.get("nodeinfo_connection_timeout"), config.get("nodeinfo_read_timeout"))
271             )
272
273             logger.debug("data[]='%s'", type(data))
274             if "error_message" not in data and "json" in data:
275                 logger.debug("Success: request='%s' - Setting detection_mode=STATIC_CHECK ...", request)
276                 instances.set_last_nodeinfo(domain)
277                 instances.set_detection_mode(domain, "STATIC_CHECK")
278                 instances.set_nodeinfo_url(domain, request)
279                 break
280
281             logger.warning("Failed fetching nodeinfo from domain='%s',status_code='%s',error_message='%s'", domain, data['status_code'], data['error_message'])
282
283     logger.debug("data()=%d - EXIT!", len(data))
284     return data
285
286 def fetch_wellknown_nodeinfo(domain: str) -> dict:
287     logger.debug("domain='%s' - CALLED!", domain)
288     domain_helper.raise_on(domain)
289
290     # "rel" identifiers (no real URLs)
291     nodeinfo_identifier = [
292         "https://nodeinfo.diaspora.software/ns/schema/2.1",
293         "http://nodeinfo.diaspora.software/ns/schema/2.1",
294         "https://nodeinfo.diaspora.software/ns/schema/2.0",
295         "http://nodeinfo.diaspora.software/ns/schema/2.0",
296         "https://nodeinfo.diaspora.software/ns/schema/1.1",
297         "http://nodeinfo.diaspora.software/ns/schema/1.1",
298         "https://nodeinfo.diaspora.software/ns/schema/1.0",
299         "http://nodeinfo.diaspora.software/ns/schema/1.0",
300     ]
301
302     # No CSRF by default, you don't have to add network.api_headers by yourself here
303     headers = tuple()
304
305     try:
306         logger.debug("Checking CSRF for domain='%s'", domain)
307         headers = csrf.determine(domain, dict())
308     except network.exceptions as exception:
309         logger.warning("Exception '%s' during checking CSRF (fetch_wellknown_nodeinfo,%s) - EXIT!", type(exception), __name__)
310         instances.set_last_error(domain, exception)
311         return {
312             "status_code"  : 500,
313             "error_message": type(exception),
314             "exception"    : exception,
315         }
316
317     logger.debug("Fetching .well-known info for domain='%s'", domain)
318     data = network.get_json_api(
319         domain,
320         "/.well-known/nodeinfo",
321         headers,
322         (config.get("nodeinfo_connection_timeout"), config.get("nodeinfo_read_timeout"))
323     )
324
325     logger.debug("data[]='%s'", type(data))
326     if "error_message" not in data:
327         nodeinfo = data["json"]
328
329         logger.debug("Marking domain='%s' as successfully handled ...", domain)
330         instances.set_success(domain)
331
332         logger.debug("Found entries: nodeinfo()=%d,domain='%s'", len(nodeinfo), domain)
333         if "links" in nodeinfo:
334             logger.debug("Found nodeinfo[links]()=%d record(s),", len(nodeinfo["links"]))
335             for niid in nodeinfo_identifier:
336                 data = dict()
337
338                 logger.debug("Checking niid='%s' ...", niid)
339                 for link in nodeinfo["links"]:
340                     logger.debug("link[%s]='%s'", type(link), link)
341                     if not isinstance(link, dict) or not "rel" in link:
342                         logger.debug("link[]='%s' is not of type 'dict' or no element 'rel' found - SKIPPED!", type(link))
343                         continue
344                     elif link["rel"] != niid:
345                         logger.debug("link[re]='%s' does not matched niid='%s' - SKIPPED!", link["rel"], niid)
346                         continue
347                     elif "href" not in link:
348                         logger.warning("link[rel]='%s' has no element 'href' - SKIPPED!", link["rel"])
349                         continue
350                     elif link["href"] is None:
351                         logger.debug("link[href] is None, link[rel]='%s' - SKIPPED!", link["rel"])
352                         continue
353
354                     # Default is that 'href' has a complete URL, but some hosts don't send that
355                     logger.debug("link[rel]='%s' matches niid='%s'", link["rel"], niid)
356                     url = link["href"]
357                     components = urlparse(url)
358
359                     logger.debug("components[%s]='%s'", type(components), components)
360                     if components.scheme == "" and components.netloc == "":
361                         logger.warning("link[href]='%s' has no scheme and host name in it, prepending from domain='%s'", link['href'], domain)
362                         url = f"https://{domain}{url}"
363                         components = urlparse(url)
364                     elif components.netloc == "":
365                         logger.warning("link[href]='%s' has no netloc set, setting domain='%s'", link["href"], domain)
366                         url = f"{components.scheme}://{domain}{components.path}"
367                         components = urlparse(url)
368
369                     logger.debug("components.netloc[]='%s'", type(components.netloc))
370                     if not utils.is_domain_wanted(components.netloc):
371                         logger.debug("components.netloc='%s' is not wanted - SKIPPED!", components.netloc)
372                         continue
373
374                     logger.debug("Fetching nodeinfo from url='%s' ...", url)
375                     data = network.fetch_api_url(
376                         url,
377                         (config.get("connection_timeout"), config.get("read_timeout"))
378                      )
379
380                     logger.debug("link[href]='%s',data[]='%s'", link["href"], type(data))
381                     if "error_message" not in data and "json" in data:
382                         logger.debug("Found JSON data()=%d,link[href]='%s' - Setting detection_mode=AUTO_DISCOVERY ...", len(data), link["href"])
383                         instances.set_detection_mode(domain, "AUTO_DISCOVERY")
384                         instances.set_nodeinfo_url(domain, link["href"])
385
386                         logger.debug("Marking domain='%s' as successfully handled ...", domain)
387                         instances.set_success(domain)
388                         break
389                     else:
390                         logger.debug("Setting last error for domain='%s',data[]='%s'", domain, type(data))
391                         instances.set_last_error(domain, data)
392
393                 logger.debug("data()=%d", len(data))
394                 if "error_message" not in data and "json" in data:
395                     logger.debug("Auto-discovery successful: domain='%s'", domain)
396                     break
397         else:
398             logger.warning("nodeinfo does not contain 'links': domain='%s'", domain)
399
400     logger.debug("Returning data[]='%s' - EXIT!", type(data))
401     return data
402
403 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
404     logger.debug("domain='%s',path='%s' - CALLED!", domain, path)
405     domain_helper.raise_on(domain)
406
407     if not isinstance(path, str):
408         raise ValueError(f"path[]='{type(path)}' is not of type 'str'")
409     elif path == "":
410         raise ValueError("Parameter 'path' is empty")
411
412     logger.debug("domain='%s',path='%s' - CALLED!", domain, path)
413     software = None
414
415     logger.debug("Fetching path='%s' from domain='%s' ...", path, domain)
416     response = network.fetch_response(
417         domain, path,
418         network.web_headers,
419         (config.get("connection_timeout"), config.get("read_timeout")),
420         allow_redirects=True
421     )
422
423     logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
424     if response.ok and response.status_code < 300 and response.text.find("<html") > 0 and domain_helper.is_in_url(domain, response.url):
425         logger.debug("Parsing response.text()=%d Bytes ...", len(response.text))
426         doc = bs4.BeautifulSoup(response.text, "html.parser")
427
428         logger.debug("doc[]='%s'", type(doc))
429         generator = doc.find("meta", {"name"    : "generator"})
430         site_name = doc.find("meta", {"property": "og:site_name"})
431         platform = doc.find("meta", {"property": "og:platform"})
432
433         logger.debug("generator[]='%s',site_name[]='%s',platform[]='%s'", type(generator), type(site_name), type(platform))
434         if isinstance(generator, bs4.element.Tag) and isinstance(generator.get("content"), str):
435             logger.debug("Found generator meta tag: domain='%s'", domain)
436             software = tidyup.domain(generator.get("content"))
437
438             logger.debug("software[%s]='%s'", type(software), software)
439             if software is not None and software != "":
440                 logger.info("domain='%s' is generated by software='%s' - Setting detection_mode=GENERATOR ...", domain, software)
441                 instances.set_detection_mode(domain, "GENERATOR")
442         elif isinstance(site_name, bs4.element.Tag) and isinstance(site_name.get("content"), str):
443             logger.debug("Found property=og:site_name, domain='%s'", domain)
444             software = tidyup.domain(site_name.get("content"))
445
446             logger.debug("software[%s]='%s'", type(software), software)
447             if software is not None and software != "":
448                 logger.debug("domain='%s' has og:site_name='%s' - Setting detection_mode=SITE_NAME ...", domain, software)
449                 instances.set_detection_mode(domain, "SITE_NAME")
450         elif isinstance(platform, bs4.element.Tag) and isinstance(platform.get("content"), str):
451             logger.debug("Found property=og:platform, domain='%s'", domain)
452             software = tidyup.domain(platform.get("content"))
453
454             logger.debug("software[%s]='%s'", type(software), software)
455             if software is not None and software != "":
456                 logger.debug("domain='%s' has og:platform='%s' - Setting detection_mode=PLATFORM ...", domain, software)
457                 instances.set_detection_mode(domain, "PLATFORM")
458     elif not domain_helper.is_in_url(domain, response.url):
459         logger.warning("domain='%s' doesn't match response.url='%s', maybe redirect to other domain?", domain, response.url)
460
461         components = urlparse(response.url)
462
463         logger.debug("components[]='%s'", type(components))
464         if not instances.is_registered(components.netloc):
465             logger.info("components.netloc='%s' is not registered, adding ...", components.netloc)
466             fetch_instances(components.netloc, domain, None, "fetch_generator")
467
468         message = f"Redirect from domain='{domain}' to response.url='{response.url}'"
469         instances.set_last_error(domain, message)
470         instances.set_software(domain, None)
471         instances.set_detection_mode(domain, None)
472         instances.set_nodeinfo_url(domain, None)
473
474         raise requests.exceptions.TooManyRedirects(message)
475
476     logger.debug("software[]='%s'", type(software))
477     if isinstance(software, str) and software == "":
478         logger.debug("Corrected empty string to None for software of domain='%s'", domain)
479         software = None
480     elif isinstance(software, str) and ("." in software or " " in software):
481         logger.debug("software='%s' may contain a version number, domain='%s', removing it ...", software, domain)
482         software = version.remove(software)
483
484     logger.debug("software[]='%s'", type(software))
485     if isinstance(software, str) and "powered by " in software:
486         logger.debug("software='%s' has 'powered by' in it", software)
487         software = version.remove(version.strip_powered_by(software))
488     elif isinstance(software, str) and " hosted on " in software:
489         logger.debug("software='%s' has 'hosted on' in it", software)
490         software = version.remove(version.strip_hosted_on(software))
491     elif isinstance(software, str) and " by " in software:
492         logger.debug("software='%s' has ' by ' in it", software)
493         software = version.strip_until(software, " by ")
494     elif isinstance(software, str) and " see " in software:
495         logger.debug("software='%s' has ' see ' in it", software)
496         software = version.strip_until(software, " see ")
497
498     logger.debug("software='%s' - EXIT!", software)
499     return software
500
501 def determine_software(domain: str, path: str = None) -> str:
502     logger.debug("domain='%s',path='%s' - CALLED!", domain, path)
503     domain_helper.raise_on(domain)
504
505     if not isinstance(path, str) and path is not None:
506         raise ValueError(f"Parameter path[]='{type(path)}' is not of type 'str'")
507
508     logger.debug("Determining software for domain='%s',path='%s'", domain, path)
509     software = None
510
511     logger.debug("Fetching nodeinfo from domain='%s' ...", domain)
512     data = fetch_nodeinfo(domain, path)
513
514     logger.debug("data[%s]='%s'", type(data), data)
515     if "exception" in data:
516         # Continue raising it
517         logger.debug("data()=%d contains exception='%s' - raising ...", len(data), type(data["exception"]))
518         raise data["exception"]
519     elif "error_message" in data:
520         logger.debug("Returned error_message during fetching nodeinfo: '%s',status_code=%d", data['error_message'], data['status_code'])
521         software = fetch_generator_from_path(domain)
522         logger.debug("Generator for domain='%s' is: '%s'", domain, software)
523     elif "json" in data:
524         logger.debug("domain='%s',path='%s',data[json] found ...", domain, path)
525         data = data["json"]
526     else:
527         logger.debug("JSON response from domain='%s' does not include [software][name], fetching / ...", domain)
528         software = fetch_generator_from_path(domain)
529         logger.debug("Generator for domain='%s' is: '%s'", domain, software)
530
531     if "status" in data and data["status"] == "error" and "message" in data:
532         logger.warning("JSON response is an error: '%s' - Resetting detection_mode,nodeinfo_url ...", data["message"])
533         instances.set_last_error(domain, data["message"])
534         instances.set_detection_mode(domain, None)
535         instances.set_nodeinfo_url(domain, None)
536         software = fetch_generator_from_path(domain)
537         logger.debug("Generator for domain='%s' is: '%s'", domain, software)
538     elif "software" in data and "name" in data["software"]:
539         logger.debug("Found data[json][software][name] in JSON response")
540         software = data["software"]["name"]
541         logger.debug("software[%s]='%s' - FOUND!", type(software), software)
542     elif "message" in data:
543         logger.warning("JSON response contains only a message: '%s' - Resetting detection_mode,nodeinfo_url ...", data["message"])
544         instances.set_last_error(domain, data["message"])
545         instances.set_detection_mode(domain, None)
546         instances.set_nodeinfo_url(domain, None)
547
548         logger.debug("Invoking fetch_generator_from_path(%s) ...", domain)
549         software = fetch_generator_from_path(domain)
550         logger.debug("Generator for domain='%s' is: '%s'", domain, software)
551     elif "software" not in data or "name" not in data["software"]:
552         logger.debug("JSON response from domain='%s' does not include [software][name] - Resetting detection_mode,nodeinfo_url ...", domain)
553         instances.set_detection_mode(domain, None)
554         instances.set_nodeinfo_url(domain, None)
555
556         logger.debug("Invoking fetch_generator_from_path(%s) ...", domain)
557         software = fetch_generator_from_path(domain)
558         logger.debug("Generator for domain='%s' is: '%s'", domain, software)
559
560     logger.debug("software[%s]='%s'", type(software), software)
561     if software is None:
562         logger.debug("Returning None - EXIT!")
563         return None
564
565     logger.debug("software='%s'- BEFORE!", software)
566     software = software_helper.alias(software)
567     logger.debug("software['%s']='%s' - AFTER!", type(software), software)
568
569     if str(software) == "":
570         logger.debug("software for domain='%s' was not detected, trying generator ...", domain)
571         software = fetch_generator_from_path(domain)
572     elif len(str(software)) > 0 and ("." in software or " " in software):
573         logger.debug("software='%s' may contain a version number, domain='%s', removing it ...", software, domain)
574         software = version.remove(software)
575
576     logger.debug("software[]='%s'", type(software))
577     if isinstance(software, str) and "powered by" in software:
578         logger.debug("software='%s' has 'powered by' in it", software)
579         software = version.remove(version.strip_powered_by(software))
580
581     logger.debug("software='%s' - EXIT!", software)
582     return software
583
584 def find_domains(tag: bs4.element.Tag) -> list:
585     logger.debug("tag[]='%s' - CALLED!", type(tag))
586     if not isinstance(tag, bs4.element.Tag):
587         raise ValueError(f"Parameter tag[]='{type(tag)}' is not type of bs4.element.Tag")
588     elif len(tag.select("tr")) == 0:
589         raise KeyError("No table rows found in table!")
590
591     domains = list()
592     for element in tag.select("tr"):
593         logger.debug("element[]='%s'", type(element))
594         if not element.find("td"):
595             logger.debug("Skipping element, no <td> found")
596             continue
597
598         domain = tidyup.domain(element.find("td").text)
599         reason = tidyup.reason(element.findAll("td")[1].text)
600
601         logger.debug("domain='%s',reason='%s'", domain, reason)
602
603         if not utils.is_domain_wanted(domain):
604             logger.debug("domain='%s' is blacklisted - SKIPPED!", domain)
605             continue
606         elif domain == "gab.com/.ai, develop.gab.com":
607             logger.debug("Multiple domains detected in one row")
608             domains.append({
609                 "domain": "gab.com",
610                 "reason": reason,
611             })
612             domains.append({
613                 "domain": "gab.ai",
614                 "reason": reason,
615             })
616             domains.append({
617                 "domain": "develop.gab.com",
618                 "reason": reason,
619             })
620             continue
621         elif not validators.domain(domain.split("/")[0]):
622             logger.warning("domain='%s' is not a valid domain - SKIPPED!", domain)
623             continue
624
625         logger.debug("Adding domain='%s',reason='%s' ...", domain, reason)
626         domains.append({
627             "domain": domain,
628             "reason": reason,
629         })
630
631     logger.debug("domains()=%d - EXIT!", len(domains))
632     return domains
633
634 def add_peers(rows: dict) -> list:
635     logger.debug("rows[]='%s' - CALLED!", type(rows))
636     if not isinstance(rows, dict):
637         raise ValueError(f"Parameter rows[]='{type(rows)}' is not of type 'dict'")
638
639     peers = list()
640     for key in ["linked", "allowed", "blocked"]:
641         logger.debug("Checking key='%s'", key)
642         if key not in rows or rows[key] is None:
643             logger.debug("Cannot find key='%s' or it is NoneType - SKIPPED!", key)
644             continue
645
646         logger.debug("Adding %d peer(s) to peers list ...", len(rows[key]))
647         for peer in rows[key]:
648             logger.debug("peer[%s]='%s' - BEFORE!", type(peer), peer)
649             if peer is None or peer == "":
650                 logger.debug("peer is empty - SKIPPED")
651                 continue
652             elif isinstance(peer, dict) and "domain" in peer:
653                 logger.debug("peer[domain]='%s'", peer["domain"])
654                 peer = tidyup.domain(peer["domain"])
655             elif isinstance(peer, str):
656                 logger.debug("peer='%s'", peer)
657                 peer = tidyup.domain(peer)
658             else:
659                 raise ValueError(f"peer[]='{type(peer)}' is not supported,key='{key}'")
660
661             logger.debug("peer[%s]='%s' - AFTER!", type(peer), peer)
662             if not utils.is_domain_wanted(peer):
663                 logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
664                 continue
665
666             logger.debug("Appending peer='%s' ...", peer)
667             peers.append(peer)
668
669     logger.debug("peers()=%d - EXIT!", len(peers))
670     return peers