]> git.mxchange.org Git - fba.git/blob - fba/fba.py
95c916c6d250443a6d8e3e8cf246673bb6b66251
[fba.git] / fba / fba.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 bs4
17 import hashlib
18 import re
19 import requests
20 import json
21 import sqlite3
22 import sys
23 import time
24 import validators
25
26 from urllib.parse import urlparse
27
28 from fba import blacklist
29 from fba import cache
30 from fba import config
31 from fba import instances
32 from fba import network
33
34 from fba.federation import lemmy
35 from fba.federation import misskey
36 from fba.federation import peertube
37
38 # Array with pending errors needed to be written to database
39 pending_errors = {
40 }
41
42 # "rel" identifiers (no real URLs)
43 nodeinfo_identifier = [
44     "https://nodeinfo.diaspora.software/ns/schema/2.1",
45     "https://nodeinfo.diaspora.software/ns/schema/2.0",
46     "https://nodeinfo.diaspora.software/ns/schema/1.1",
47     "https://nodeinfo.diaspora.software/ns/schema/1.0",
48     "http://nodeinfo.diaspora.software/ns/schema/2.1",
49     "http://nodeinfo.diaspora.software/ns/schema/2.0",
50     "http://nodeinfo.diaspora.software/ns/schema/1.1",
51     "http://nodeinfo.diaspora.software/ns/schema/1.0",
52 ]
53
54 # Connect to database
55 connection = sqlite3.connect("blocks.db")
56 cursor = connection.cursor()
57
58 # Pattern instance for version numbers
59 patterns = [
60     # semantic version number (with v|V) prefix)
61     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)?$"),
62     # non-sematic, e.g. 1.2.3.4
63     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(\.(?P<subpatch>0|[1-9]\d*))?)$"),
64     # non-sematic, e.g. 2023-05[-dev]
65     re.compile("^(?P<year>[1-9]{1}[0-9]{3})\.(?P<month>[0-9]{2})(-dev){0,1}$"),
66     # non-semantic, e.g. abcdef0
67     re.compile("^[a-f0-9]{7}$"),
68 ]
69
70 ##### Other functions #####
71
72 def is_primitive(var: any) -> bool:
73     # DEBUG: print(f"DEBUG: var[]='{type(var)}' - CALLED!")
74     return type(var) in {int, str, float, bool} or var is None
75
76 def fetch_instances(domain: str, origin: str, software: str, script: str, path: str = None):
77     # DEBUG: print(f"DEBUG: domain='{domain}',origin='{origin}',software='{software}',path='{path}' - CALLED!")
78     if not isinstance(domain, str):
79         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
80     elif domain == "":
81         raise ValueError("Parameter 'domain' is empty")
82     elif not isinstance(origin, str) and origin is not None:
83         raise ValueError(f"Parameter origin[]={type(origin)} is not 'str'")
84     elif software is None:
85         print(f"DEBUG: software for domain='{domain}' is not set, determining ...")
86         software = determine_software(domain, path)
87         print(f"DEBUG: Determined software='{software}' for domain='{domain}'")
88     elif not isinstance(software, str):
89         raise ValueError(f"Parameter software[]={type(software)} is not 'str'")
90     elif not isinstance(script, str):
91         raise ValueError(f"Parameter script[]={type(script)} is not 'str'")
92     elif domain == "":
93         raise ValueError("Parameter 'domain' is empty")
94
95     if not instances.is_registered(domain):
96         # DEBUG: print("DEBUG: Adding new domain:", domain, origin)
97         instances.add(domain, origin, script, path)
98
99     # DEBUG: print("DEBUG: Fetching instances for domain:", domain, software)
100     peerlist = fetch_peers(domain, software)
101
102     if (peerlist is None):
103         print("ERROR: Cannot fetch peers:", domain)
104         return
105     elif instances.has_pending_instance_data(domain):
106         # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo data, flushing ...")
107         instances.update_data(domain)
108
109     print(f"INFO: Checking {len(peerlist)} instances from {domain} ...")
110     for instance in peerlist:
111         if instance is None:
112             # Skip "None" types as tidup() cannot parse them
113             continue
114
115         # DEBUG: print(f"DEBUG: instance='{instance}' - BEFORE")
116         instance = tidyup_domain(instance)
117         # DEBUG: print(f"DEBUG: instance='{instance}' - AFTER")
118
119         if instance == "":
120             print("WARNING: Empty instance after tidyup_domain(), domain:", domain)
121             continue
122         elif not validators.domain(instance.split("/")[0]):
123             print(f"WARNING: Bad instance='{instance}' from domain='{domain}',origin='{origin}',software='{software}'")
124             continue
125         elif blacklist.is_blacklisted(instance):
126             # DEBUG: print("DEBUG: instance is blacklisted:", instance)
127             continue
128
129         # DEBUG: print("DEBUG: Handling instance:", instance)
130         try:
131             if not instances.is_registered(instance):
132                 # DEBUG: print("DEBUG: Adding new instance:", instance, domain)
133                 instances.add(instance, domain, script)
134         except BaseException as exception:
135             print(f"ERROR: instance='{instance}',exception[{type(exception)}]:'{str(exception)}'")
136             continue
137
138     # DEBUG: print("DEBUG: EXIT!")
139
140 def add_peers(rows: dict) -> list:
141     # DEBUG: print(f"DEBUG: rows()={len(rows)} - CALLED!")
142     peers = list()
143     for key in ["linked", "allowed", "blocked"]:
144         # DEBUG: print(f"DEBUG: Checking key='{key}'")
145         if key in rows and rows[key] is not None:
146             # DEBUG: print(f"DEBUG: Adding {len(rows[key])} peer(s) to peers list ...")
147             for peer in rows[key]:
148                 # DEBUG: print(f"DEBUG: peer='{peer}' - BEFORE!")
149                 peer = tidyup_domain(peer)
150
151                 # DEBUG: print(f"DEBUG: peer='{peer}' - AFTER!")
152                 if blacklist.is_blacklisted(peer):
153                     # DEBUG: print(f"DEBUG: peer='{peer}' is blacklisted, skipped!")
154                     continue
155
156                 # DEBUG: print(f"DEBUG: Adding peer='{peer}' ...")
157                 peers.append(peer)
158
159     # DEBUG: print(f"DEBUG: peers()={len(peers)} - EXIT!")
160     return peers
161
162 def remove_version(software: str) -> str:
163     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
164     if not "." in software and " " not in software:
165         print(f"WARNING: software='{software}' does not contain a version number.")
166         return software
167
168     temp = software
169     if ";" in software:
170         temp = software.split(";")[0]
171     elif "," in software:
172         temp = software.split(",")[0]
173     elif " - " in software:
174         temp = software.split(" - ")[0]
175
176     # DEBUG: print(f"DEBUG: software='{software}'")
177     version = None
178     if " " in software:
179         version = temp.split(" ")[-1]
180     elif "/" in software:
181         version = temp.split("/")[-1]
182     elif "-" in software:
183         version = temp.split("-")[-1]
184     else:
185         # DEBUG: print(f"DEBUG: Was not able to find common seperator, returning untouched software='{software}'")
186         return software
187
188     match = None
189     # DEBUG: print(f"DEBUG: Checking {len(patterns)} patterns ...")
190     for pattern in patterns:
191         # Run match()
192         match = pattern.match(version)
193
194         # DEBUG: print(f"DEBUG: match[]={type(match)}")
195         if isinstance(match, re.Match):
196             # DEBUG: print(f"DEBUG: version='{version}' is matching pattern='{pattern}'")
197             break
198
199     # DEBUG: print(f"DEBUG: version[{type(version)}]='{version}',match='{match}'")
200     if not isinstance(match, re.Match):
201         print(f"WARNING: version='{version}' does not match regex, leaving software='{software}' untouched.")
202         return software
203
204     # DEBUG: print(f"DEBUG: Found valid version number: '{version}', removing it ...")
205     end = len(temp) - len(version) - 1
206
207     # DEBUG: print(f"DEBUG: end[{type(end)}]={end}")
208     software = temp[0:end].strip()
209     if " version" in software:
210         # DEBUG: print(f"DEBUG: software='{software}' contains word ' version'")
211         software = strip_until(software, " version")
212
213     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
214     return software
215
216 def strip_powered_by(software: str) -> str:
217     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
218     if not isinstance(software, str):
219         raise ValueError(f"Parameter software[]='{type(software)}' is not 'str'")
220     elif software == "":
221         raise ValueError("Parameter 'software' is empty")
222     elif not "powered by" in software:
223         print(f"WARNING: Cannot find 'powered by' in software='{software}'!")
224         return software
225
226     start = software.find("powered by ")
227     # DEBUG: print(f"DEBUG: start[{type(start)}]='{start}'")
228
229     software = software[start + 11:].strip()
230     # DEBUG: print(f"DEBUG: software='{software}'")
231
232     software = strip_until(software, " - ")
233
234     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
235     return software
236
237 def strip_hosted_on(software: str) -> str:
238     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
239     if not isinstance(software, str):
240         raise ValueError(f"Parameter software[]='{type(software)}' is not 'str'")
241     elif software == "":
242         raise ValueError("Parameter 'software' is empty")
243     elif not "hosted on" in software:
244         print(f"WARNING: Cannot find 'hosted on' in '{software}'!")
245         return software
246
247     end = software.find("hosted on ")
248     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
249
250     software = software[0, end].strip()
251     # DEBUG: print(f"DEBUG: software='{software}'")
252
253     software = strip_until(software, " - ")
254
255     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
256     return software
257
258 def strip_until(software: str, until: str) -> str:
259     # DEBUG: print(f"DEBUG: software='{software}',until='{until}' - CALLED!")
260     if not isinstance(software, str):
261         raise ValueError(f"Parameter software[]='{type(software)}' is not 'str'")
262     elif software == "":
263         raise ValueError("Parameter 'software' is empty")
264     elif not isinstance(until, str):
265         raise ValueError(f"Parameter until[]='{type(until)}' is not 'str'")
266     elif until == "":
267         raise ValueError("Parameter 'until' is empty")
268     elif not until in software:
269         print(f"WARNING: Cannot find '{until}' in '{software}'!")
270         return software
271
272     # Next, strip until part
273     end = software.find(until)
274
275     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
276     if end > 0:
277         software = software[0:end].strip()
278
279     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
280     return software
281
282 def remove_pending_error(domain: str):
283     if not isinstance(domain, str):
284         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
285     elif domain == "":
286         raise ValueError("Parameter 'domain' is empty")
287
288     try:
289         # Prevent updating any pending errors, nodeinfo was found
290         del pending_errors[domain]
291
292     except:
293         pass
294
295     # DEBUG: print("DEBUG: EXIT!")
296
297 def get_hash(domain: str) -> str:
298     if not isinstance(domain, str):
299         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
300     elif domain == "":
301         raise ValueError("Parameter 'domain' is empty")
302
303     return hashlib.sha256(domain.encode("utf-8")).hexdigest()
304
305 def log_error(domain: str, response: requests.models.Response):
306     # DEBUG: print("DEBUG: domain,response[]:", domain, type(response))
307     if not isinstance(domain, str):
308         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
309     elif domain == "":
310         raise ValueError("Parameter 'domain' is empty")
311
312     try:
313         # DEBUG: print("DEBUG: BEFORE response[]:", type(response))
314         if isinstance(response, BaseException) or isinstance(response, json.decoder.JSONDecodeError):
315             response = str(response)
316
317         # DEBUG: print("DEBUG: AFTER response[]:", type(response))
318         if isinstance(response, str):
319             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, 999, ?, ?)",[
320                 domain,
321                 response,
322                 time.time()
323             ])
324         else:
325             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, ?, ?, ?)",[
326                 domain,
327                 response.status_code,
328                 response.reason,
329                 time.time()
330             ])
331
332         # Cleanup old entries
333         # DEBUG: print(f"DEBUG: Purging old records (distance: {config.get('error_log_cleanup')})")
334         cursor.execute("DELETE FROM error_log WHERE created < ?", [time.time() - config.get("error_log_cleanup")])
335     except BaseException as exception:
336         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(exception)}]:'{str(exception)}'")
337         sys.exit(255)
338
339     # DEBUG: print("DEBUG: EXIT!")
340
341 def fetch_peers(domain: str, software: str) -> list:
342     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},software={software} - CALLED!")
343     if not isinstance(domain, str):
344         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
345     elif domain == "":
346         raise ValueError("Parameter 'domain' is empty")
347     elif not isinstance(software, str) and software is not None:
348         raise ValueError(f"software[]={type(software)} is not 'str'")
349
350     if software == "misskey":
351         # DEBUG: print(f"DEBUG: Invoking misskey.fetch_peers({domain}) ...")
352         return misskey.fetch_peers(domain)
353     elif software == "lemmy":
354         # DEBUG: print(f"DEBUG: Invoking lemmy.fetch_peers({domain}) ...")
355         return lemmy.fetch_peers(domain)
356     elif software == "peertube":
357         # DEBUG: print(f"DEBUG: Invoking peertube.fetch_peers({domain}) ...")
358         return peertube.fetch_peers(domain)
359
360     # DEBUG: print(f"DEBUG: Fetching peers from '{domain}',software='{software}' ...")
361     peers = list()
362     try:
363         response = network.fetch_response(domain, "/api/v1/instance/peers", network.api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
364
365         data = json_from_response(response)
366
367         # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
368         if not response.ok or response.status_code >= 400:
369             # DEBUG: print(f"DEBUG: Was not able to fetch peers, trying alternative ...")
370             response = network.fetch_response(domain, "/api/v3/site", network.api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
371
372             data = json_from_response(response)
373             # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
374             if not response.ok or response.status_code >= 400:
375                 print("WARNING: Could not reach any JSON API:", domain)
376                 instances.update_last_error(domain, response)
377             elif response.ok and isinstance(data, list):
378                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
379                 sys.exit(255)
380             elif "federated_instances" in data:
381                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
382                 peers = peers + add_peers(data["federated_instances"])
383                 # DEBUG: print("DEBUG: Added instance(s) to peers")
384             else:
385                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
386                 instances.update_last_error(domain, response)
387         else:
388             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(data))
389             peers = data
390
391     except BaseException as exception:
392         print("WARNING: Some error during get():", domain, exception)
393         instances.update_last_error(domain, exception)
394
395     # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
396     instances.set("total_peers", domain, len(peers))
397
398     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
399     instances.update_last_instance_fetch(domain)
400
401     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
402     return peers
403
404 def fetch_nodeinfo(domain: str, path: str = None) -> list:
405     # DEBUG: print(f"DEBUG: domain='{domain}',path={path} - CALLED!")
406     if not isinstance(domain, str):
407         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
408     elif domain == "":
409         raise ValueError("Parameter 'domain' is empty")
410     elif not isinstance(path, str) and path is not None:
411         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
412
413     # DEBUG: print(f"DEBUG: Fetching nodeinfo from domain='{domain}' ...")
414     nodeinfo = fetch_wellknown_nodeinfo(domain)
415
416     # DEBUG: print(f"DEBUG: nodeinfo({len(nodeinfo)})={nodeinfo}")
417     if len(nodeinfo) > 0:
418         # DEBUG: print("DEBUG: nodeinfo()={len(nodeinfo))} - EXIT!")
419         return nodeinfo
420
421     request_paths = [
422        "/nodeinfo/2.1.json",
423        "/nodeinfo/2.1",
424        "/nodeinfo/2.0.json",
425        "/nodeinfo/2.0",
426        "/nodeinfo/1.0",
427        "/api/v1/instance"
428     ]
429
430     data = {}
431     for request in request_paths:
432         if path is not None and path != "" and path != f"https://{domain}{path}":
433             # DEBUG: print(f"DEBUG: path='{path}' does not match request='{request}' - SKIPPED!")
434             continue
435
436         try:
437             # DEBUG: print(f"DEBUG: Fetching request='{request}' from domain='{domain}' ...")
438             response = network.fetch_response(domain, request, network.api_headers, (config.get("nodeinfo_connection_timeout"), config.get("nodeinfo_read_timeout")))
439
440             data = json_from_response(response)
441             # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
442             if response.ok and isinstance(data, dict):
443                 # DEBUG: print("DEBUG: Success:", request)
444                 instances.set("detection_mode", domain, "STATIC_CHECK")
445                 instances.set("nodeinfo_url"  , domain, request)
446                 break
447             elif response.ok and isinstance(data, list):
448                 print(f"UNSUPPORTED: domain='{domain}' returned a list: '{data}'")
449                 sys.exit(255)
450             elif not response.ok or response.status_code >= 400:
451                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
452                 instances.update_last_error(domain, response)
453                 continue
454
455         except BaseException as exception:
456             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
457             instances.update_last_error(domain, exception)
458             pass
459
460     # DEBUG: print(f"DEBUG: data()={len(data)} - EXIT!")
461     return data
462
463 def fetch_wellknown_nodeinfo(domain: str) -> list:
464     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
465     if not isinstance(domain, str):
466         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
467     elif domain == "":
468         raise ValueError("Parameter 'domain' is empty")
469
470     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
471     data = {}
472
473     try:
474         response = network.fetch_response(domain, "/.well-known/nodeinfo", network.api_headers, (config.get("nodeinfo_connection_timeout"), config.get("nodeinfo_read_timeout")))
475
476         data = json_from_response(response)
477         # DEBUG: print("DEBUG: domain,response.ok,data[]:", domain, response.ok, type(data))
478         if response.ok and isinstance(data, dict):
479             nodeinfo = data
480             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
481             if "links" in nodeinfo:
482                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
483                 for link in nodeinfo["links"]:
484                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
485                     if link["rel"] in nodeinfo_identifier:
486                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
487                         response = fetch_url(link["href"], network.api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
488
489                         data = json_from_response(response)
490                         # DEBUG: print("DEBUG: href,response.ok,response.status_code:", link["href"], response.ok, response.status_code)
491                         if response.ok and isinstance(data, dict):
492                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(data))
493                             instances.set("detection_mode", domain, "AUTO_DISCOVERY")
494                             instances.set("nodeinfo_url"  , domain, link["href"])
495                             break
496                     else:
497                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
498             else:
499                 print("WARNING: nodeinfo does not contain 'links':", domain)
500
501     except BaseException as exception:
502         print("WARNING: Failed fetching .well-known info:", domain)
503         instances.update_last_error(domain, exception)
504         pass
505
506     # DEBUG: print("DEBUG: Returning data[]:", type(data))
507     return data
508
509 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
510     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
511     if not isinstance(domain, str):
512         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
513     elif domain == "":
514         raise ValueError("Parameter 'domain' is empty")
515     elif not isinstance(path, str):
516         raise ValueError(f"path[]={type(path)} is not 'str'")
517     elif path == "":
518         raise ValueError("Parameter 'path' is empty")
519
520     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
521     software = None
522
523     try:
524         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
525         response = network.fetch_response(domain, path, network.headers, (config.get("connection_timeout"), config.get("read_timeout")))
526
527         # DEBUG: print("DEBUG: domain,response.ok,response.status_code,response.text[]:", domain, response.ok, response.status_code, type(response.text))
528         if response.ok and response.status_code < 300 and len(response.text) > 0:
529             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
530             doc = bs4.BeautifulSoup(response.text, "html.parser")
531
532             # DEBUG: print("DEBUG: doc[]:", type(doc))
533             generator = doc.find("meta", {"name": "generator"})
534             site_name = doc.find("meta", {"property": "og:site_name"})
535
536             # DEBUG: print(f"DEBUG: generator='{generator}',site_name='{site_name}'")
537             if isinstance(generator, bs4.element.Tag):
538                 # DEBUG: print("DEBUG: Found generator meta tag:", domain)
539                 software = tidyup_domain(generator.get("content"))
540                 print(f"INFO: domain='{domain}' is generated by '{software}'")
541                 instances.set("detection_mode", domain, "GENERATOR")
542                 remove_pending_error(domain)
543             elif isinstance(site_name, bs4.element.Tag):
544                 # DEBUG: print("DEBUG: Found property=og:site_name:", domain)
545                 sofware = tidyup_domain(site_name.get("content"))
546                 print(f"INFO: domain='{domain}' has og:site_name='{software}'")
547                 instances.set("detection_mode", domain, "SITE_NAME")
548                 remove_pending_error(domain)
549
550     except BaseException as exception:
551         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", exception)
552         instances.update_last_error(domain, exception)
553         pass
554
555     # DEBUG: print(f"DEBUG: software[]={type(software)}")
556     if isinstance(software, str) and software == "":
557         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
558         software = None
559     elif isinstance(software, str) and ("." in software or " " in software):
560         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
561         software = remove_version(software)
562
563     # DEBUG: print(f"DEBUG: software[]={type(software)}")
564     if isinstance(software, str) and " powered by " in software:
565         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
566         software = remove_version(strip_powered_by(software))
567     elif isinstance(software, str) and " hosted on " in software:
568         # DEBUG: print(f"DEBUG: software='{software}' has 'hosted on' in it")
569         software = remove_version(strip_hosted_on(software))
570     elif isinstance(software, str) and " by " in software:
571         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
572         software = strip_until(software, " by ")
573     elif isinstance(software, str) and " see " in software:
574         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
575         software = strip_until(software, " see ")
576
577     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
578     return software
579
580 def determine_software(domain: str, path: str = None) -> str:
581     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
582     if not isinstance(domain, str):
583         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
584     elif domain == "":
585         raise ValueError("Parameter 'domain' is empty")
586     elif not isinstance(path, str) and path is not None:
587         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
588
589     # DEBUG: print("DEBUG: Determining software for domain,path:", domain, path)
590     software = None
591
592     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
593     data = fetch_nodeinfo(domain, path)
594
595     # DEBUG: print("DEBUG: data[]:", type(data))
596     if not isinstance(data, dict) or len(data) == 0:
597         # DEBUG: print("DEBUG: Could not determine software type:", domain)
598         return fetch_generator_from_path(domain)
599
600     # DEBUG: print("DEBUG: data():", len(data), data)
601     if "status" in data and data["status"] == "error" and "message" in data:
602         print("WARNING: JSON response is an error:", data["message"])
603         instances.update_last_error(domain, data["message"])
604         return fetch_generator_from_path(domain)
605     elif "message" in data:
606         print("WARNING: JSON response contains only a message:", data["message"])
607         instances.update_last_error(domain, data["message"])
608         return fetch_generator_from_path(domain)
609     elif "software" not in data or "name" not in data["software"]:
610         # DEBUG: print(f"DEBUG: JSON response from domain='{domain}' does not include [software][name], fetching / ...")
611         software = fetch_generator_from_path(domain)
612
613         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
614         return software
615
616     software = tidyup_domain(data["software"]["name"])
617
618     # DEBUG: print("DEBUG: sofware after tidyup_domain():", software)
619     if software in ["akkoma", "rebased"]:
620         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
621         software = "pleroma"
622     elif software in ["hometown", "ecko"]:
623         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
624         software = "mastodon"
625     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
626         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
627         software = "misskey"
628     elif software.find("/") > 0:
629         print("WARNING: Spliting of slash:", software)
630         software = tidup_domain(software.split("/")[-1]);
631     elif software.find("|") > 0:
632         print("WARNING: Spliting of pipe:", software)
633         software = tidyup_domain(software.split("|")[0]);
634     elif "powered by" in software:
635         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
636         software = strip_powered_by(software)
637     elif isinstance(software, str) and " by " in software:
638         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
639         software = strip_until(software, " by ")
640     elif isinstance(software, str) and " see " in software:
641         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
642         software = strip_until(software, " see ")
643
644     # DEBUG: print(f"DEBUG: software[]={type(software)}")
645     if software == "":
646         print("WARNING: tidyup_domain() left no software name behind:", domain)
647         software = None
648
649     # DEBUG: print(f"DEBUG: software[]={type(software)}")
650     if str(software) == "":
651         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
652         software = fetch_generator_from_path(domain)
653     elif len(str(software)) > 0 and ("." in software or " " in software):
654         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
655         software = remove_version(software)
656
657     # DEBUG: print(f"DEBUG: software[]={type(software)}")
658     if isinstance(software, str) and "powered by" in software:
659         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
660         software = remove_version(strip_powered_by(software))
661
662     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
663     return software
664
665 def tidyup_reason(reason: str) -> str:
666     # DEBUG: print(f"DEBUG: reason='{reason}' - CALLED!")
667     if not isinstance(reason, str):
668         raise ValueError(f"Parameter reason[]={type(reason)} is not 'str'")
669
670     # Strip string
671     reason = reason.strip()
672
673     # Replace â with "
674     reason = re.sub("â", "\"", reason)
675
676     # DEBUG: print(f"DEBUG: reason='{reason}' - EXIT!")
677     return reason
678
679 def tidyup_domain(domain: str) -> str:
680     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
681     if not isinstance(domain, str):
682         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
683
684     # All lower-case and strip spaces out + last dot
685     domain = domain.lower().strip().rstrip(".")
686
687     # No port number
688     domain = re.sub("\:\d+$", "", domain)
689
690     # No protocol, sometimes without the slashes
691     domain = re.sub("^https?\:(\/*)", "", domain)
692
693     # No trailing slash
694     domain = re.sub("\/$", "", domain)
695
696     # No @ sign
697     domain = re.sub("^\@", "", domain)
698
699     # No individual users in block lists
700     domain = re.sub("(.+)\@", "", domain)
701     if domain.find("/profile/"):
702         domain = domain.split("/profile/")[0]
703     elif domain.find("/users/"):
704         domain = domain.split("/users/")[0]
705
706     # DEBUG: print(f"DEBUG: domain='{domain}' - EXIT!")
707     return domain
708
709 def json_from_response(response: requests.models.Response) -> list:
710     # DEBUG: print(f"DEBUG: response[]={type(response)} - CALLED!")
711     if not isinstance(response, requests.models.Response):
712         raise ValueError(f"Parameter response[]='{type(response)}' is not type of 'Response'")
713
714     data = list()
715     if response.text.strip() != "":
716         # DEBUG: print(f"DEBUG: response.text()={len(response.text)} is not empty, invoking response.json() ...")
717         try:
718             data = response.json()
719         except json.decoder.JSONDecodeError:
720             pass
721
722     # DEBUG: print(f"DEBUG: data[]={type(data)} - EXIT!")
723     return data
724
725 def has_key(lists: list, key: str, value: any) -> bool:
726     # DEBUG: print(f"DEBUG: lists()={len(lists)},key='{key}',value[]='{type(value)}' - CALLED!")
727     if not isinstance(lists, list):
728         raise ValueError(f"Parameter lists[]='{type(lists)}' is not 'list'")
729     elif not isinstance(key, str):
730         raise ValueError(f"Parameter key[]='{type(key)}' is not 'str'")
731     elif key == "":
732         raise ValueError("Parameter 'key' is empty")
733
734     has = False
735     # DEBUG: print(f"DEBUG: Checking lists()={len(lists)} ...")
736     for row in lists:
737         # DEBUG: print(f"DEBUG: row['{type(row)}']={row}")
738         if not isinstance(row, dict):
739             raise ValueError(f"row[]='{type(row)}' is not 'dict'")
740         elif not key in row:
741             raise KeyError(f"Cannot find key='{key}'")
742         elif row[key] == value:
743             has = True
744             break
745
746     # DEBUG: print(f"DEBUG: has={has} - EXIT!")
747     return has
748
749 def find_domains(tag: bs4.element.Tag) -> list:
750     # DEBUG: print(f"DEBUG: tag[]={type(tag)} - CALLED!")
751     if not isinstance(tag, bs4.element.Tag):
752         raise ValueError(f"Parameter tag[]={type(tag)} is not type of bs4.element.Tag")
753     elif len(tag.select("tr")) == 0:
754         raise KeyError("No table rows found in table!")
755
756     domains = list()
757     for element in tag.select("tr"):
758         # DEBUG: print(f"DEBUG: element[]={type(element)}")
759         if not element.find("td"):
760             # DEBUG: print("DEBUG: Skipping element, no <td> found")
761             continue
762
763         domain = tidyup_domain(element.find("td").text)
764         reason = tidyup_reason(element.findAll("td")[1].text)
765
766         # DEBUG: print(f"DEBUG: domain='{domain}',reason='{reason}'")
767
768         if blacklist.is_blacklisted(domain):
769             print(f"WARNING: domain='{domain}' is blacklisted - skipped!")
770             continue
771         elif domain == "gab.com/.ai, develop.gab.com":
772             # DEBUG: print(f"DEBUG: Multiple domains detected in one row")
773             domains.append({
774                 "domain": "gab.com",
775                 "reason": reason,
776             })
777             domains.append({
778                 "domain": "gab.ai",
779                 "reason": reason,
780             })
781             domains.append({
782                 "domain": "develop.gab.com",
783                 "reason": reason,
784             })
785             continue
786         elif not validators.domain(domain):
787             print(f"WARNING: domain='{domain}' is not a valid domain - skipped!")
788             continue
789
790         # DEBUG: print(f"DEBUG: Adding domain='{domain}' ...")
791         domains.append({
792             "domain": domain,
793             "reason": reason,
794         })
795
796     # DEBUG: print(f"DEBUG: domains()={len(domains)} - EXIT!")
797     return domains
798
799 def fetch_url(url: str, headers: dict, timeout: tuple) -> requests.models.Response:
800     # DEBUG: print(f"DEBUG: url='{url}',headers()={len(headers)},timeout={timeout} - CALLED!")
801     if not isinstance(url, str):
802         raise ValueError(f"Parameter url[]='{type(url)}' is not 'str'")
803     elif url == "":
804         raise ValueError("Parameter 'url' is empty")
805     elif not isinstance(headers, dict):
806         raise ValueError(f"Parameter headers[]='{type(headers)}' is not 'dict'")
807     elif not isinstance(timeout, tuple):
808         raise ValueError(f"Parameter timeout[]='{type(timeout)}' is not 'tuple'")
809
810     # DEBUG: print(f"DEBUG: Parsing url='{url}'")
811     components = urlparse(url)
812
813     # Invoke other function, avoid trailing ?
814     # DEBUG: print(f"DEBUG: components[{type(components)}]={components}")
815     if components.query != "":
816         response = network.fetch_response(components.hostname, f"{components.path}?{components.query}", headers, timeout)
817     else:
818         response = network.fetch_response(components.hostname, f"{components.path}", headers, timeout)
819
820     # DEBUG: print(f"DEBUG: response[]='{type(response)}' - EXXIT!")
821     return response