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