tools.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. import datetime
  2. import json
  3. import logging
  4. import os
  5. import re
  6. import shutil
  7. import sys
  8. from collections import defaultdict
  9. from logging.handlers import RotatingFileHandler
  10. from time import time
  11. from urllib.parse import urlparse, urlunparse
  12. import pytz
  13. import requests
  14. from bs4 import BeautifulSoup
  15. from flask import send_file, make_response
  16. from opencc import OpenCC
  17. import utils.constants as constants
  18. from utils.config import config, resource_path
  19. from utils.types import ChannelData
  20. opencc_t2s = OpenCC("t2s")
  21. def get_logger(path, level=logging.ERROR, init=False):
  22. """
  23. get the logger
  24. """
  25. os.makedirs(os.path.dirname(path), exist_ok=True)
  26. os.makedirs(constants.output_dir, exist_ok=True)
  27. if init and os.path.exists(path):
  28. os.remove(path)
  29. handler = RotatingFileHandler(path, encoding="utf-8")
  30. logger = logging.getLogger(path)
  31. logger.addHandler(handler)
  32. logger.setLevel(level)
  33. return logger
  34. def format_interval(t):
  35. """
  36. Formats a number of seconds as a clock time, [H:]MM:SS
  37. Parameters
  38. ----------
  39. t : int or float
  40. Number of seconds.
  41. Returns
  42. -------
  43. out : str
  44. [H:]MM:SS
  45. """
  46. mins, s = divmod(int(t), 60)
  47. h, m = divmod(mins, 60)
  48. if h:
  49. return "{0:d}:{1:02d}:{2:02d}".format(h, m, s)
  50. else:
  51. return "{0:02d}:{1:02d}".format(m, s)
  52. def get_pbar_remaining(n=0, total=0, start_time=None):
  53. """
  54. Get the remaining time of the progress bar
  55. """
  56. try:
  57. elapsed = time() - start_time
  58. completed_tasks = n
  59. if completed_tasks > 0:
  60. avg_time_per_task = elapsed / completed_tasks
  61. remaining_tasks = total - completed_tasks
  62. remaining_time = format_interval(avg_time_per_task * remaining_tasks)
  63. else:
  64. remaining_time = "未知"
  65. return remaining_time
  66. except Exception as e:
  67. print(f"Error: {e}")
  68. def update_file(final_file, old_file, copy=False):
  69. """
  70. Update the file
  71. """
  72. old_file_path = resource_path(old_file, persistent=True)
  73. final_file_path = resource_path(final_file, persistent=True)
  74. if os.path.exists(old_file_path):
  75. if copy:
  76. shutil.copyfile(old_file_path, final_file_path)
  77. else:
  78. os.replace(old_file_path, final_file_path)
  79. def filter_by_date(data):
  80. """
  81. Filter by date and limit
  82. """
  83. default_recent_days = 30
  84. use_recent_days = config.recent_days
  85. if not isinstance(use_recent_days, int) or use_recent_days <= 0:
  86. use_recent_days = default_recent_days
  87. start_date = datetime.datetime.now() - datetime.timedelta(days=use_recent_days)
  88. recent_data = []
  89. unrecent_data = []
  90. for info, response_time in data:
  91. item = (info, response_time)
  92. date = info["date"]
  93. if date:
  94. date = datetime.datetime.strptime(date, "%m-%d-%Y")
  95. if date >= start_date:
  96. recent_data.append(item)
  97. else:
  98. unrecent_data.append(item)
  99. else:
  100. unrecent_data.append(item)
  101. recent_data_len = len(recent_data)
  102. if recent_data_len == 0:
  103. recent_data = unrecent_data
  104. elif recent_data_len < config.urls_limit:
  105. recent_data.extend(unrecent_data[: config.urls_limit - len(recent_data)])
  106. return recent_data
  107. def get_soup(source):
  108. """
  109. Get soup from source
  110. """
  111. source = re.sub(
  112. r"<!--.*?-->",
  113. "",
  114. source,
  115. flags=re.DOTALL,
  116. )
  117. soup = BeautifulSoup(source, "html.parser")
  118. return soup
  119. def get_resolution_value(resolution_str):
  120. """
  121. Get resolution value from string
  122. """
  123. try:
  124. if resolution_str:
  125. pattern = r"(\d+)[xX*](\d+)"
  126. match = re.search(pattern, resolution_str)
  127. if match:
  128. width, height = map(int, match.groups())
  129. return width * height
  130. except:
  131. pass
  132. return 0
  133. def get_total_urls(info_list: list[ChannelData], ipv_type_prefer, origin_type_prefer, rtmp_type=None) -> list:
  134. """
  135. Get the total urls from info list
  136. """
  137. ipv_prefer_bool = bool(ipv_type_prefer)
  138. origin_prefer_bool = bool(origin_type_prefer)
  139. if not ipv_prefer_bool:
  140. ipv_type_prefer = ["all"]
  141. if not origin_prefer_bool:
  142. origin_type_prefer = ["all"]
  143. categorized_urls = {origin: {ipv_type: [] for ipv_type in ipv_type_prefer} for origin in origin_type_prefer}
  144. total_urls = []
  145. for info in info_list:
  146. channel_id, url, origin, resolution, url_ipv_type, extra_info = (
  147. info["id"],
  148. info["url"],
  149. info["origin"],
  150. info["resolution"],
  151. info["ipv_type"],
  152. info.get("extra_info", ""),
  153. )
  154. if not origin:
  155. continue
  156. if origin in ["live", "hls"]:
  157. if not rtmp_type or (rtmp_type and origin in rtmp_type):
  158. total_urls.append(info)
  159. continue
  160. else:
  161. continue
  162. if origin == "whitelist":
  163. total_urls.append(info)
  164. continue
  165. if origin_prefer_bool and (origin not in origin_type_prefer):
  166. continue
  167. if not extra_info:
  168. info["extra_info"] = constants.origin_map[origin]
  169. if not origin_prefer_bool:
  170. origin = "all"
  171. if ipv_prefer_bool:
  172. if url_ipv_type in ipv_type_prefer:
  173. categorized_urls[origin][url_ipv_type].append(info)
  174. else:
  175. categorized_urls[origin]["all"].append(info)
  176. ipv_num = {ipv_type: 0 for ipv_type in ipv_type_prefer}
  177. urls_limit = config.urls_limit
  178. for origin in origin_type_prefer:
  179. if len(total_urls) >= urls_limit:
  180. break
  181. for ipv_type in ipv_type_prefer:
  182. if len(total_urls) >= urls_limit:
  183. break
  184. ipv_type_num = ipv_num[ipv_type]
  185. ipv_type_limit = config.ipv_limit[ipv_type] or urls_limit
  186. if ipv_type_num < ipv_type_limit:
  187. urls = categorized_urls[origin][ipv_type]
  188. if not urls:
  189. continue
  190. limit = min(
  191. max(config.source_limits.get(origin, urls_limit) - ipv_type_num, 0),
  192. max(ipv_type_limit - ipv_type_num, 0),
  193. )
  194. limit_urls = urls[:limit]
  195. total_urls.extend(limit_urls)
  196. ipv_num[ipv_type] += len(limit_urls)
  197. else:
  198. continue
  199. total_urls = total_urls[:urls_limit]
  200. return total_urls
  201. def get_total_urls_from_sorted_data(data):
  202. """
  203. Get the total urls with filter by date and duplicate from sorted data
  204. """
  205. if len(data) > config.urls_limit:
  206. total_urls = [channel_data["url"] for channel_data, _ in filter_by_date(data)]
  207. else:
  208. total_urls = [channel_data["url"] for channel_data, _ in data]
  209. return list(dict.fromkeys(total_urls))[: config.urls_limit]
  210. def check_ipv6_support():
  211. """
  212. Check if the system network supports ipv6
  213. """
  214. if os.getenv("GITHUB_ACTIONS"):
  215. return False
  216. url = "https://ipv6.tokyo.test-ipv6.com/ip/?callback=?&testdomain=test-ipv6.com&testname=test_aaaa"
  217. try:
  218. print("Checking if your network supports IPv6...")
  219. response = requests.get(url, timeout=10)
  220. if response.status_code == 200:
  221. print("Your network supports IPv6")
  222. return True
  223. except Exception:
  224. pass
  225. print("Your network does not support IPv6, don't worry, the IPv6 results will be saved")
  226. return False
  227. def check_ipv_type_match(ipv_type: str) -> bool:
  228. """
  229. Check if the ipv type matches
  230. """
  231. config_ipv_type = config.ipv_type
  232. return (
  233. config_ipv_type == ipv_type
  234. or config_ipv_type == "全部"
  235. or config_ipv_type == "all"
  236. )
  237. def check_url_by_keywords(url, keywords=None):
  238. """
  239. Check by URL keywords
  240. """
  241. if not keywords:
  242. return True
  243. else:
  244. return any(keyword in url for keyword in keywords)
  245. def merge_objects(*objects, match_key=None):
  246. """
  247. Merge objects
  248. Args:
  249. *objects: Dictionaries to merge
  250. match_key: If dict1[key] is a list of dicts, this key will be used to match and merge dicts
  251. """
  252. def merge_dicts(dict1, dict2):
  253. for key, value in dict2.items():
  254. if key in dict1:
  255. if isinstance(dict1[key], dict) and isinstance(value, dict):
  256. merge_dicts(dict1[key], value)
  257. elif isinstance(dict1[key], set):
  258. dict1[key].update(value)
  259. elif isinstance(dict1[key], list) and isinstance(value, list):
  260. if match_key and all(isinstance(x, dict) for x in dict1[key] + value):
  261. existing_items = {item[match_key]: item for item in dict1[key]}
  262. for new_item in value:
  263. if match_key in new_item and new_item[match_key] in existing_items:
  264. merge_dicts(existing_items[new_item[match_key]], new_item)
  265. else:
  266. dict1[key].append(new_item)
  267. else:
  268. dict1[key].extend(x for x in value if x not in dict1[key])
  269. elif value != dict1[key]:
  270. dict1[key] = value
  271. else:
  272. dict1[key] = value
  273. merged_dict = {}
  274. for obj in objects:
  275. if not isinstance(obj, dict):
  276. raise TypeError("All input objects must be dictionaries")
  277. merge_dicts(merged_dict, obj)
  278. return merged_dict
  279. def get_ip_address():
  280. """
  281. Get the IP address
  282. """
  283. host = os.getenv("APP_HOST", config.app_host)
  284. port = os.getenv("APP_PORT", config.app_port)
  285. return f"{host}:{port}"
  286. def get_epg_url():
  287. """
  288. Get the epg result url
  289. """
  290. if os.getenv("GITHUB_ACTIONS"):
  291. repository = os.getenv("GITHUB_REPOSITORY", "Guovin/iptv-api")
  292. ref = os.getenv("GITHUB_REF", "gd")
  293. return join_url(config.cdn_url, f"https://raw.githubusercontent.com/{repository}/{ref}/output/epg/epg.gz")
  294. else:
  295. return f"{get_ip_address()}/epg/epg.gz"
  296. def convert_to_m3u(path=None, first_channel_name=None, data=None):
  297. """
  298. Convert result txt to m3u format
  299. """
  300. if os.path.exists(path):
  301. with open(path, "r", encoding="utf-8") as file:
  302. m3u_output = f'#EXTM3U x-tvg-url="{get_epg_url()}"\n'
  303. current_group = None
  304. for line in file:
  305. trimmed_line = line.strip()
  306. if trimmed_line != "":
  307. if "#genre#" in trimmed_line:
  308. current_group = trimmed_line.replace(",#genre#", "").strip()
  309. else:
  310. try:
  311. original_channel_name, _, channel_link = map(
  312. str.strip, trimmed_line.partition(",")
  313. )
  314. except:
  315. continue
  316. processed_channel_name = re.sub(
  317. r"(CCTV|CETV)-(\d+)(\+.*)?",
  318. lambda m: f"{m.group(1)}{m.group(2)}"
  319. + ("+" if m.group(3) else ""),
  320. first_channel_name if current_group == "🕘️更新时间" else original_channel_name,
  321. )
  322. m3u_output += f'#EXTINF:-1 tvg-name="{processed_channel_name}" tvg-logo="{join_url(config.cdn_url, f'https://raw.githubusercontent.com/fanmingming/live/main/tv/{processed_channel_name}.png')}"'
  323. if current_group:
  324. m3u_output += f' group-title="{current_group}"'
  325. item_data = {}
  326. if data:
  327. item_list = data.get(original_channel_name, [])
  328. for item in item_list:
  329. if item["url"] == channel_link:
  330. item_data = item
  331. break
  332. if item_data:
  333. catchup = item_data.get("catchup")
  334. if catchup:
  335. for key, value in catchup.items():
  336. m3u_output += f' {key}="{value}"'
  337. m3u_output += f",{original_channel_name}\n"
  338. if item_data and config.open_headers:
  339. headers = item_data.get("headers")
  340. if headers:
  341. for key, value in headers.items():
  342. m3u_output += f"#EXTVLCOPT:http-{key.lower()}={value}\n"
  343. m3u_output += f"{channel_link}\n"
  344. m3u_file_path = os.path.splitext(path)[0] + ".m3u"
  345. with open(m3u_file_path, "w", encoding="utf-8") as m3u_file:
  346. m3u_file.write(m3u_output)
  347. # print(f"✅ M3U result file generated at: {m3u_file_path}")
  348. def get_result_file_content(path=None, show_content=False, file_type=None):
  349. """
  350. Get the content of the result file
  351. """
  352. result_file = (
  353. os.path.splitext(path)[0] + f".{file_type}"
  354. if file_type
  355. else path
  356. )
  357. if os.path.exists(result_file):
  358. if config.open_m3u_result:
  359. if file_type == "m3u" or not file_type:
  360. result_file = os.path.splitext(path)[0] + ".m3u"
  361. if file_type != "txt" and show_content == False:
  362. return send_file(resource_path(result_file), as_attachment=True)
  363. with open(result_file, "r", encoding="utf-8") as file:
  364. content = file.read()
  365. else:
  366. content = constants.waiting_tip
  367. response = make_response(content)
  368. response.mimetype = 'text/plain'
  369. return response
  370. def remove_duplicates_from_list(data_list, seen, filter_host=False, ipv6_support=True):
  371. """
  372. Remove duplicates from data list
  373. """
  374. unique_list = []
  375. for item in data_list:
  376. if item["origin"] in ["whitelist", "live", "hls"]:
  377. continue
  378. if not ipv6_support and item["ipv_type"] == "ipv6":
  379. continue
  380. part = item["host"] if filter_host else item["url"]
  381. if part not in seen:
  382. seen.add(part)
  383. unique_list.append(item)
  384. return unique_list
  385. def process_nested_dict(data, seen, filter_host=False, ipv6_support=True):
  386. """
  387. Process nested dict
  388. """
  389. for key, value in data.items():
  390. if isinstance(value, dict):
  391. process_nested_dict(value, seen, filter_host, ipv6_support)
  392. elif isinstance(value, list):
  393. data[key] = remove_duplicates_from_list(value, seen, filter_host, ipv6_support)
  394. def get_url_host(url):
  395. """
  396. Get the url host
  397. """
  398. matcher = constants.url_host_pattern.search(url)
  399. if matcher:
  400. return matcher.group()
  401. return None
  402. def add_url_info(url, info):
  403. """
  404. Add url info to the URL
  405. """
  406. if info:
  407. separator = "-" if "$" in url else "$"
  408. url += f"{separator}{info}"
  409. return url
  410. def format_url_with_cache(url, cache=None):
  411. """
  412. Format the URL with cache
  413. """
  414. cache = cache or get_url_host(url) or ""
  415. return add_url_info(url, f"cache:{cache}") if cache else url
  416. def remove_cache_info(string):
  417. """
  418. Remove the cache info from the string
  419. """
  420. return re.sub(r"[.*]?\$?-?cache:.*", "", string)
  421. def resource_path(relative_path, persistent=False):
  422. """
  423. Get the resource path
  424. """
  425. base_path = os.path.abspath(".")
  426. total_path = os.path.join(base_path, relative_path)
  427. if persistent or os.path.exists(total_path):
  428. return total_path
  429. else:
  430. try:
  431. base_path = sys._MEIPASS
  432. return os.path.join(base_path, relative_path)
  433. except Exception:
  434. return total_path
  435. def write_content_into_txt(content, path=None, position=None, callback=None):
  436. """
  437. Write content into txt file
  438. """
  439. if not path:
  440. return
  441. mode = "r+" if position == "top" else "a"
  442. with open(path, mode, encoding="utf-8") as f:
  443. if position == "top":
  444. existing_content = f.read()
  445. f.seek(0, 0)
  446. f.write(f"{content}\n{existing_content}")
  447. else:
  448. f.write(content)
  449. if callback:
  450. callback()
  451. def format_name(name: str) -> str:
  452. """
  453. Format the name with sub and replace and lower
  454. """
  455. name = opencc_t2s.convert(name)
  456. for region in constants.region_list:
  457. name = name.replace(f"{region}|", "")
  458. name = constants.sub_pattern.sub("", name)
  459. for old, new in constants.replace_dict.items():
  460. name = name.replace(old, new)
  461. return name.lower()
  462. def get_headers_key_value(content: str) -> dict:
  463. """
  464. Get the headers key value from content
  465. """
  466. key_value = {}
  467. for match in constants.key_value_pattern.finditer(content):
  468. key = match.group("key").strip().replace("http-", "").replace("-", "").lower()
  469. if "refer" in key:
  470. key = "referer"
  471. value = match.group("value").replace('"', "").strip()
  472. if key and value:
  473. key_value[key] = value
  474. return key_value
  475. def get_name_url(content, pattern, open_headers=False, check_url=True):
  476. """
  477. Extract name and URL from content using a regex pattern.
  478. :param content: str, the input content to search.
  479. :param pattern: re.Pattern, the compiled regex pattern to match.
  480. :param open_headers: bool, whether to extract headers.
  481. :param check_url: bool, whether to validate the presence of a URL.
  482. """
  483. result = []
  484. for match in pattern.finditer(content):
  485. group_dict = match.groupdict()
  486. name = (group_dict.get("name", "") or "").strip()
  487. url = (group_dict.get("url", "") or "").strip()
  488. if not name or (check_url and not url):
  489. continue
  490. data = {"name": name, "url": url}
  491. attributes = {**get_headers_key_value(group_dict.get("attributes", "")),
  492. **get_headers_key_value(group_dict.get("options", ""))}
  493. headers = {
  494. "User-Agent": attributes.get("useragent", ""),
  495. "Referer": attributes.get("referer", ""),
  496. "Origin": attributes.get("origin", "")
  497. }
  498. catchup = {
  499. "catchup": attributes.get("catchup", ""),
  500. "catchup-source": attributes.get("catchupsource", ""),
  501. }
  502. headers = {k: v for k, v in headers.items() if v}
  503. catchup = {k: v for k, v in catchup.items() if v}
  504. if not open_headers and headers:
  505. continue
  506. if open_headers:
  507. data["headers"] = headers
  508. data["catchup"] = catchup
  509. result.append(data)
  510. return result
  511. def get_real_path(path) -> str:
  512. """
  513. Get the real path
  514. """
  515. dir_path, file = os.path.split(path)
  516. user_real_path = os.path.join(dir_path, 'user_' + file)
  517. real_path = user_real_path if os.path.exists(user_real_path) else path
  518. return real_path
  519. def get_urls_from_file(path: str, pattern_search: bool = True) -> list:
  520. """
  521. Get the urls from file
  522. """
  523. real_path = get_real_path(resource_path(path))
  524. urls = []
  525. if os.path.exists(real_path):
  526. with open(real_path, "r", encoding="utf-8") as f:
  527. for line in f:
  528. line = line.strip()
  529. if not line or line.startswith("#"):
  530. continue
  531. if pattern_search:
  532. match = constants.url_pattern.search(line)
  533. if match:
  534. urls.append(match.group().strip())
  535. else:
  536. urls.append(line)
  537. return urls
  538. def get_name_urls_from_file(path: str, format_name_flag: bool = False) -> dict[str, list]:
  539. """
  540. Get the name and urls from file
  541. """
  542. real_path = get_real_path(resource_path(path))
  543. name_urls = defaultdict(list)
  544. if os.path.exists(real_path):
  545. with open(real_path, "r", encoding="utf-8") as f:
  546. for line in f:
  547. line = line.strip()
  548. if line.startswith("#"):
  549. continue
  550. name_url = get_name_url(line, pattern=constants.txt_pattern)
  551. if name_url and name_url[0]:
  552. name = format_name(name_url[0]["name"]) if format_name_flag else name_url[0]["name"]
  553. url = name_url[0]["url"]
  554. if url not in name_urls[name]:
  555. name_urls[name].append(url)
  556. return name_urls
  557. def get_name_uri_from_dir(path: str) -> dict:
  558. """
  559. Get the name and uri from dir, only from file name
  560. """
  561. real_path = get_real_path(resource_path(path))
  562. name_urls = defaultdict(list)
  563. if os.path.exists(real_path):
  564. for file in os.listdir(real_path):
  565. filename = file.rsplit(".", 1)[0]
  566. name_urls[filename].append(f"{real_path}/{file}")
  567. return name_urls
  568. def get_datetime_now():
  569. """
  570. Get the datetime now
  571. """
  572. now = datetime.datetime.now()
  573. time_zone = pytz.timezone(config.time_zone)
  574. return now.astimezone(time_zone).strftime("%Y-%m-%d %H:%M:%S")
  575. def get_version_info():
  576. """
  577. Get the version info
  578. """
  579. with open(resource_path("version.json"), "r", encoding="utf-8") as f:
  580. return json.load(f)
  581. def join_url(url1: str, url2: str) -> str:
  582. """
  583. Get the join url
  584. :param url1: The first url
  585. :param url2: The second url
  586. :return: The join url
  587. """
  588. if not url1:
  589. return url2
  590. if not url2:
  591. return url1
  592. if not url1.endswith("/"):
  593. url1 += "/"
  594. return url1 + url2
  595. def add_port_to_url(url: str, port: int) -> str:
  596. """
  597. Add port to the url
  598. """
  599. parsed = urlparse(url)
  600. netloc = parsed.netloc
  601. if parsed.username and parsed.password:
  602. netloc = f"{parsed.username}:{parsed.password}@{netloc}"
  603. if port:
  604. netloc = f"{netloc}:{port}"
  605. new_url = urlunparse((
  606. parsed.scheme,
  607. netloc,
  608. parsed.path,
  609. parsed.params,
  610. parsed.query,
  611. parsed.fragment
  612. ))
  613. return new_url
  614. def get_url_without_scheme(url: str) -> str:
  615. """
  616. Get the url without scheme
  617. """
  618. parsed = urlparse(url)
  619. return parsed.netloc + parsed.path
  620. def find_by_id(data: dict, id: int) -> dict:
  621. """
  622. Find the nested dict by id
  623. :param data: target data
  624. :param id: target id
  625. :return: target dict
  626. """
  627. if isinstance(data, dict) and 'id' in data and data['id'] == id:
  628. return data
  629. for key, value in data.items():
  630. if isinstance(value, dict):
  631. result = find_by_id(value, id)
  632. if result is not None:
  633. return result
  634. elif isinstance(value, list):
  635. for item in value:
  636. if isinstance(item, dict):
  637. result = find_by_id(item, id)
  638. if result is not None:
  639. return result
  640. return {}
  641. def custom_print(*args, **kwargs):
  642. """
  643. Custom print
  644. """
  645. if not custom_print.disable:
  646. print(*args, **kwargs)
  647. def get_urls_len(data) -> int:
  648. """
  649. Get the dict urls length
  650. """
  651. urls = set(
  652. url_info["url"]
  653. for value in data.values()
  654. for url_info_list in value.values()
  655. for url_info in url_info_list
  656. )
  657. return len(urls)