├── .gitignore ├── .python-version ├── Imgs └── tiny-scraper.png ├── pyproject.toml ├── readme.md ├── release.sh ├── requirements.txt ├── tiny-scraper.sh ├── tiny_scraper ├── anbernic.py ├── app.py ├── config.json ├── graphic.py ├── input.py ├── lang │ ├── en_US.json │ ├── ja_JP.json │ └── zh_CN.json ├── language.py ├── main.py ├── scraper.py └── systems.py └── uv.lock /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .venv 3 | Roms 4 | __pycache__ 5 | pyrightconfig.json 6 | new_systems.xml 7 | tiny-scraper.zip 8 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /Imgs/tiny-scraper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Julioevm/tiny-scraper/b425bc22009071e9a7fa2a6978c37a1e411d46d6/Imgs/tiny-scraper.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tiny-scraper" 3 | version = "1.3.0" 4 | description = "A small utility to scrape game covers for yourAnbernic RGXX devices." 5 | readme = "readme.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "requests==2.32.3", 9 | ] 10 | 11 | [tool.uv] 12 | dev-dependencies = [ 13 | "ruff>=0.9.6", 14 | ] 15 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Tiny Scraper 2 | 3 | ![Platform](https://img.shields.io/badge/platform-Anbernic-orange.svg) 4 | 5 | A small utility to scrape game covers for your RGXX devices 6 | 7 | ## Features 8 | 9 | - **Easy Downloads:** Download cover media directly onto your Anbernic device. 10 | - **User-Friendly Interface:** Simple and intuitive interface designed specifically for Anbernic devices. 11 | - **Wide Compatibility:** Supports various ROM file types and multiple Anbernic models. 12 | 13 | ## Supported Devices 14 | 15 | I've personally only tested it on the RG35XX H 16 | RG40XXV, RGcubeXX and RG28xx should be supported. 17 | 18 | However, it could be compatible with any Anbernic handheld with a Python version >= 3.7. Please, open an issue to confirm the compatibility or to report any problems. 19 | 20 | ## Installation 21 | 22 | To install Tiny Scraper on your Anbernic device, follow these steps: 23 | 24 | 1. **Download the Latest Release:** 25 | - Navigate to the [Releases](https://github.com/Julioevm/tiny-scraper/releases) page and download the latest version of TinyScraper.zip. 26 | 27 | 2. **Transfer to Device:** 28 | - Extract and copy the content of the downloaded zip to the `APPS` directory of your Anbernic. You can copy it in `/mnt/sdcard/Roms/APPS` if you want the app on the SD2 or `/mnt/mmc/Roms/APPS` for the SD1. 29 | 30 | 3. **Setup config** 31 | - create a `config.json` file inside the `tiny_scraper` folder with a valid user and password from https://www.screenscraper.fr. Register if you haven't. 32 | ``` 33 | { 34 | "user": "your_user", 35 | "password": "your_password", 36 | "media_type": "sstitle", 37 | "region": "wor", 38 | "resize": false 39 | } 40 | ``` 41 | 42 | - Media type let's you select the type of media to download: The main options I suggest are `ss` for a game screenshot, `sstitle`, for the title screen or `box-2D` or `box-3D` (Keep the capital letters) for a box, `mixrbv1` or `mixrbv2` for a mix of screenshot, wheel and so on. For more options check the [screenscraper.fr documentation](https://api.screenscraper.fr/api2/jeuInfos.php?devid=xxx&devpassword=yyy&softname=zzz&output=xml&ssid=test&sspassword=test&crc=50ABC90A&systemeid=1&romtype=rom&romnom=Sonic%20The%20Hedgehog%202%20(World).zip&romtaille=749652)—search in the list for the media entries. Note that box and mix might be of bigger size than `ss` or `sstitle` In some cases it can cause the game list to load slower. 43 | 44 | - Region let's you prioritize the region of the media to download. Some games have different covers for Japan, some for Europe and some for the rest of the world. If the region is not specified it will prioritize the world covers, also if the media type is not available on the preferred region, we will get the first one available. Valid regions are `wor`, `jp`, `eu`, `asi`, `kr`, `ss`, `us`. 45 | 46 | - Resize: `true` or `false` — Will resize the downloaded media to 320 by 240, saving space and avoiding slowdowns when listing the roms. But it might make scraping in bulk a bit slower. 47 | 48 | 3. **Start Tiny Scraper:** 49 | - From the main menu, go to App Center, select Apps and launch Tiny Scraper. 50 | 51 | 52 | ## Troubleshooting 53 | 54 | Old version of stock OS might cause issues. V 1.0.3 (20240511) hs been reported to miss some necessary libraries: No module named 'PIL' try to update in this case. 55 | 56 | Any issue should be logged in the log.txt file inside the `tiny_scraper` folder. Open an issue and share its contents for help! 57 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | # This script will bundle the necessary files for a release and create a zip file 2 | 3 | ZIP_FILE="tiny-scraper.zip" 4 | 5 | # Remove the existing zip file if it exists 6 | if [ -f "$ZIP_FILE" ]; then 7 | rm "$ZIP_FILE" 8 | fi 9 | 10 | # Create the zip file with the necessary files and directories 11 | zip -r "$ZIP_FILE" tiny-scraper.sh tiny_scraper Imgs README.md 12 | 13 | echo "Release bundle created: $ZIP_FILE" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.3 -------------------------------------------------------------------------------- /tiny-scraper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . /mnt/mod/ctrl/configs/functions &>/dev/null 2>&1 4 | progdir=$(cd $(dirname "$0"); pwd) 5 | 6 | program="python3 ${progdir}/tiny_scraper/main.py ${progdir}/tiny_scraper/config.json" 7 | log_file="${progdir}/tiny_scraper/log.txt" 8 | 9 | $program > "$log_file" 2>&1 -------------------------------------------------------------------------------- /tiny_scraper/anbernic.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | 5 | class Anbernic: 6 | 7 | def __init__(self): 8 | self.__sd1_rom_storage_path = "/mnt/mmc/Roms" 9 | self.__sd2_rom_storage_path = "/mnt/sdcard/Roms" 10 | 11 | self.__rom_folder_mapping = { 12 | "PSP": "PSP", 13 | "PS": "PS", 14 | "GBA": "GBA", 15 | "GBC": "GBC", 16 | "GB": "GB", 17 | "NDS": "NDS", 18 | "N64": "N64", 19 | } 20 | self.__current_sd = 2 21 | 22 | 23 | def get_sd1_storage_path(self): 24 | return self.__sd1_rom_storage_path 25 | 26 | def get_sd2_storage_path(self): 27 | return self.__sd2_rom_storage_path 28 | 29 | def get_sd1_storage_console_path(self, console): 30 | return os.path.join(self.__sd1_rom_storage_path, self.__rom_folder_mapping[console]) 31 | 32 | def get_sd2_storage_console_path(self, console): 33 | return os.path.join(self.__sd2_rom_storage_path, self.__rom_folder_mapping[console]) 34 | 35 | def set_sd_storage(self, sd): 36 | if sd == 1 or sd == 2: 37 | self.__current_sd = sd 38 | 39 | def get_sd_storage(self): 40 | return self.__current_sd 41 | 42 | def switch_sd_storage(self): 43 | if self.__current_sd == 1: 44 | self.__current_sd = 2 45 | else: 46 | self.__current_sd = 1 47 | 48 | def get_sd_storage_path(self): 49 | if self.__current_sd == 1 or not any(Path("/mnt/sdcard").iterdir()): 50 | self.__current_sd = 1 51 | return self.get_sd1_storage_path() 52 | else: 53 | return self.get_sd2_storage_path() 54 | 55 | def get_sd_storage_console_path(self, console): 56 | if self.__current_sd == 1: 57 | return self.get_sd1_storage_console_path(console) 58 | else: 59 | return self.get_sd2_storage_console_path(console) 60 | 61 | -------------------------------------------------------------------------------- /tiny_scraper/app.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Optional 3 | from main import hw_info, system_lang 4 | from graphic import screen_resolutions 5 | from language import Translator 6 | import graphic as gr 7 | import input 8 | import sys 9 | import time 10 | import socket 11 | from anbernic import Anbernic 12 | from scraper import Scraper 13 | from systems import get_system_id 14 | from PIL import Image 15 | from io import BytesIO 16 | 17 | translator = Translator(system_lang) 18 | selected_position = 0 19 | roms_selected_position = 0 20 | selected_system = "" 21 | current_window = "console" 22 | an = Anbernic() 23 | scraper = Scraper() 24 | skip_input_check = False 25 | 26 | x_size, y_size, max_elem = screen_resolutions.get(hw_info, (640, 480, 11)) 27 | 28 | button_x = x_size - 110 29 | button_y = y_size - 30 30 | ratio = y_size / x_size 31 | 32 | 33 | def is_connected(): 34 | try: 35 | sock = socket.create_connection(("1.1.1.1", 53), timeout=3) 36 | sock.close() 37 | return True 38 | except (socket.timeout, socket.error): 39 | return False 40 | 41 | 42 | def start(config_path: str) -> None: 43 | print("Starting Tiny Scraper...") 44 | if not is_connected(): 45 | gr.draw_log( 46 | f"{translator.translate('No internet connection')}", fill=gr.colorBlue, outline=gr.colorBlueD1 47 | ) 48 | gr.draw_paint() 49 | time.sleep(3) 50 | sys.exit(1) 51 | scraper.load_config_from_json(config_path) 52 | load_console_menu() 53 | 54 | 55 | def update() -> None: 56 | global current_window, selected_position, skip_input_check 57 | 58 | if skip_input_check: 59 | input.reset_input() 60 | skip_input_check = False 61 | else: 62 | input.check() 63 | 64 | if input.key("MENUF"): 65 | gr.draw_end() 66 | print("Exiting Tiny Scraper...") 67 | sys.exit() 68 | 69 | if current_window == "console": 70 | load_console_menu() 71 | elif current_window == "roms": 72 | load_roms_menu() 73 | else: 74 | load_console_menu() 75 | 76 | 77 | def load_console_menu() -> None: 78 | global selected_position, selected_system, current_window, skip_input_check 79 | 80 | available_systems = scraper.get_available_systems(an.get_sd_storage_path()) 81 | 82 | if available_systems: 83 | if input.key("DY"): 84 | selected_position += input.value 85 | if selected_position < 0: 86 | selected_position = len(available_systems) - 1 87 | elif selected_position >= len(available_systems): 88 | selected_position = 0 89 | elif input.key("A"): 90 | selected_system = available_systems[selected_position] 91 | current_window = "roms" 92 | gr.draw_log( 93 | f"{translator.translate('Checking existing media...')}", fill=gr.colorBlue, outline=gr.colorBlueD1 94 | ) 95 | gr.draw_paint() 96 | skip_input_check = True 97 | return 98 | 99 | if input.key("Y"): 100 | an.switch_sd_storage() 101 | selected_position = 0 102 | available_systems = scraper.get_available_systems(an.get_sd_storage_path()) 103 | 104 | gr.draw_clear() 105 | 106 | gr.draw_rectangle_r([10, 40, x_size - 10, y_size - 40], 15, fill=gr.colorGrayD2, outline=None) 107 | gr.draw_text((x_size / 2, 20), f"{translator.translate('Tiny Scraper')}", font=17, anchor="mm") 108 | 109 | if len(available_systems) > 1: 110 | start_idx = int(selected_position / max_elem) * max_elem 111 | end_idx = start_idx + max_elem 112 | for i, system in enumerate(available_systems[start_idx:end_idx]): 113 | row_list( 114 | system, (20, 50 + (i * 35)), x_size - 40, i == (selected_position % max_elem) 115 | ) 116 | button_circle((30, button_y), "A", f"{translator.translate('Select')}") 117 | else: 118 | gr.draw_text( 119 | (x_size / 2, y_size / 2), f"{translator.translate('No roms found in TF')} {an.get_sd_storage()}", anchor="mm" 120 | ) 121 | 122 | button_circle((button_x-110, button_y), "Y", f"TF: {an.get_sd_storage()}") 123 | button_circle((button_x, button_y), "M", f"{translator.translate('Exit')}") 124 | 125 | gr.draw_paint() 126 | 127 | 128 | def load_roms_menu() -> None: 129 | global \ 130 | selected_position, \ 131 | current_window, \ 132 | roms_selected_position, \ 133 | skip_input_check, \ 134 | selected_system 135 | 136 | exit_menu = False 137 | roms_list = scraper.get_roms(an.get_sd_storage_path(), selected_system) 138 | system_path = Path(an.get_sd_storage_path()) / selected_system 139 | imgs_folder = Path(f"{an.get_sd_storage_path()}/{selected_system}/Imgs") 140 | 141 | if not imgs_folder.exists(): 142 | imgs_folder.mkdir() 143 | imgs_files: List[str] = [] 144 | else: 145 | imgs_files = scraper.get_image_files_without_extension(imgs_folder) 146 | 147 | roms_without_image = list(set([rom for rom in roms_list if rom.name not in imgs_files])) 148 | roms_without_image.sort(key=lambda x: x.name) 149 | system_id = get_system_id(selected_system) 150 | 151 | if len(roms_without_image) < 1: 152 | current_window = "console" 153 | selected_system = "" 154 | gr.draw_log( 155 | f"{translator.translate('No roms missing media found...')}", fill=gr.colorBlue, outline=gr.colorBlueD1 156 | ) 157 | gr.draw_paint() 158 | time.sleep(2) 159 | gr.draw_clear() 160 | exit_menu = True 161 | 162 | if input.key("B"): 163 | exit_menu = True 164 | elif input.key("A"): 165 | gr.draw_log(f"{translator.translate('Scraping...')}", fill=gr.colorBlue, outline=gr.colorBlueD1) 166 | gr.draw_paint() 167 | rom = roms_without_image[roms_selected_position] 168 | rom.set_crc(scraper.get_crc32_from_file(system_path / rom.filename)) 169 | screenshot = scraper.scrape_screenshot( 170 | game_name=rom.name, crc=rom.crc, system_id=system_id 171 | ) 172 | if screenshot: 173 | img_path: Path = imgs_folder / f"{rom.name}.png" 174 | save_screenshot(img_path, screenshot) 175 | gr.draw_log( 176 | f"{translator.translate('Scraping completed')}", fill=gr.colorBlue, outline=gr.colorBlueD1 177 | ) 178 | print(f"Done scraping {rom.name}. Saved file to {img_path}") 179 | else: 180 | gr.draw_log(f"{translator.translate('Scraping failed!')}", fill=gr.colorBlue, outline=gr.colorBlueD1) 181 | print(f"Failed to get screenshot for {rom.name}") 182 | gr.draw_paint() 183 | time.sleep(3) 184 | exit_menu = True 185 | elif input.key("START"): 186 | progress: int = 1 187 | success: int = 0 188 | failure: int = 0 189 | gr.draw_log( 190 | f"{translator.translate('Scraping')} {progress} {translator.translate('of')} {len(roms_without_image)}", 191 | fill=gr.colorBlue, 192 | outline=gr.colorBlueD1, 193 | ) 194 | gr.draw_paint() 195 | for rom in roms_without_image: 196 | if rom.name not in imgs_files: 197 | rom.set_crc(scraper.get_crc32_from_file(system_path / rom.filename)) 198 | screenshot: Optional[bytes] = scraper.scrape_screenshot( 199 | game_name=rom.name, crc=rom.crc, system_id=system_id 200 | ) 201 | if screenshot: 202 | img_path: Path = imgs_folder / f"{rom.name}.png" 203 | save_screenshot(img_path, screenshot) 204 | print(f"Done scraping {rom.name}. Saved file to {img_path}") 205 | success += 1 206 | else: 207 | print(f"Failed to get screenshot for {rom.name}") 208 | failure += 1 209 | progress += 1 210 | gr.draw_log( 211 | f"{translator.translate('Scraping')} {progress} {translator.translate('of')} {len(roms_without_image)}", 212 | fill=gr.colorBlue, 213 | outline=gr.colorBlueD1, 214 | ) 215 | gr.draw_paint() 216 | gr.draw_log( 217 | f"{translator.translate('Scraping completed! Success:')} {success} {translator.translate('Errors:')} {failure}", 218 | fill=gr.colorBlue, 219 | outline=gr.colorBlueD1, 220 | width=800, 221 | ) 222 | gr.draw_paint() 223 | time.sleep(4) 224 | exit_menu = True 225 | elif input.key("DY"): 226 | roms_selected_position += input.value 227 | if roms_selected_position < 0: 228 | roms_selected_position = len(roms_without_image) - 1 229 | elif roms_selected_position >= len(roms_without_image): 230 | roms_selected_position = 0 231 | elif input.key("L1"): 232 | if roms_selected_position > 0: 233 | roms_selected_position = max(0, roms_selected_position - max_elem) 234 | elif input.key("R1"): 235 | if roms_selected_position < len(roms_without_image) - 1: 236 | roms_selected_position = min( 237 | len(roms_without_image) - 1, roms_selected_position + max_elem 238 | ) 239 | elif input.key("L2"): 240 | if roms_selected_position > 0: 241 | roms_selected_position = max(0, roms_selected_position - 100) 242 | elif input.key("R2"): 243 | if roms_selected_position < len(roms_without_image) - 1: 244 | roms_selected_position = min( 245 | len(roms_without_image) - 1, roms_selected_position + 100 246 | ) 247 | 248 | if exit_menu: 249 | current_window = "console" 250 | selected_system = "" 251 | gr.draw_clear() 252 | roms_selected_position = 0 253 | skip_input_check = True 254 | return 255 | 256 | gr.draw_clear() 257 | 258 | gr.draw_rectangle_r([10, 40, x_size - 10, y_size - 40], 15, fill=gr.colorGrayD2, outline=None) 259 | gr.draw_text( 260 | (x_size / 2, 20), 261 | f"{selected_system} - {translator.translate('Roms:')} {len(roms_list)} {translator.translate('Missing media:')} {len(roms_without_image)}", 262 | anchor="mm", 263 | ) 264 | 265 | start_idx = int(roms_selected_position / max_elem) * max_elem 266 | end_idx = start_idx + max_elem 267 | for i, rom in enumerate(roms_without_image[start_idx:end_idx]): 268 | row_list( 269 | rom.name[:48] + "..." if len(rom.name) > 50 else rom.name, 270 | (20, 50 + (i * 35)), 271 | x_size -40, 272 | i == (roms_selected_position % max_elem), 273 | ) 274 | 275 | button_rectangle((20, button_y), "Start", f"{translator.translate('D. All')}") 276 | button_circle((190, button_y), "A", f"{translator.translate('Download')}") 277 | button_circle((320, button_y), "B", f"{translator.translate('Back')}") 278 | button_circle((button_x, button_y), "M", f"{translator.translate('Exit')}") 279 | 280 | gr.draw_paint() 281 | 282 | def save_screenshot(img_path: Path, screenshot: bytes) -> None: 283 | if scraper.resize: 284 | print("Resizing image...") 285 | img = Image.open(BytesIO(screenshot)) 286 | target_size = (320, 240) 287 | img = img.resize(target_size, Image.LANCZOS) 288 | img.save(img_path) 289 | else: 290 | img_path.write_bytes(screenshot) 291 | 292 | def row_list(text: str, pos: tuple[int, int], width: int, selected: bool) -> None: 293 | gr.draw_rectangle_r( 294 | [pos[0], pos[1], pos[0] + width, pos[1] + 32], 295 | 5, 296 | fill=(gr.colorBlue if selected else gr.colorGrayL1), 297 | ) 298 | gr.draw_text((pos[0] + 5, pos[1] + 5), text) 299 | 300 | 301 | def button_circle(pos: tuple[int, int], button: str, text: str) -> None: 302 | gr.draw_circle(pos, 25, fill=gr.colorBlueD1) 303 | gr.draw_text((pos[0] + 12, pos[1] + 12), button, anchor="mm") 304 | gr.draw_text((pos[0] + 30, pos[1] + 12), text, font=13, anchor="lm") 305 | 306 | 307 | def button_rectangle(pos: tuple[int, int], button: str, text: str) -> None: 308 | gr.draw_rectangle_r( 309 | (pos[0], pos[1], pos[0] + 60, pos[1] + 25), 5, fill=gr.colorGrayL1 310 | ) 311 | gr.draw_text((pos[0] + 30, pos[1] + 12), button, anchor="mm") 312 | gr.draw_text((pos[0] + 65, pos[1] + 12), text, font=13, anchor="lm") 313 | -------------------------------------------------------------------------------- /tiny_scraper/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "your_user", 3 | "password": "your_password", 4 | "media_type": "ss", 5 | "region": "wor" 6 | } -------------------------------------------------------------------------------- /tiny_scraper/graphic.py: -------------------------------------------------------------------------------- 1 | from fcntl import ioctl 2 | from PIL import Image, ImageDraw, ImageFont 3 | from main import hw_info 4 | import mmap 5 | import os 6 | 7 | fb: int 8 | mm: mmap.mmap 9 | 10 | # Screen resolutions for different devices (width, height, max_elem) 11 | # 1: RGcubexx 12 | # 2: RG34xx 13 | # 3: RG28xx 14 | 15 | screen_resolutions = { 16 | 1: (720, 720, 18), 17 | 2: (720, 480, 11), 18 | 3: (640, 480, 11), 19 | } 20 | 21 | screen_width, screen_height, max_elem = screen_resolutions.get(hw_info, (640, 480, 11)) 22 | bytes_per_pixel = 4 23 | screen_size = screen_width * screen_height * bytes_per_pixel 24 | fb_screeninfo = None 25 | 26 | fontFile = {} 27 | fontFile[17] = ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSansMono.ttf", 17) 28 | fontFile[15] = ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSansMono.ttf", 15) 29 | fontFile[13] = ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSansMono.ttf", 13) 30 | fontFile[11] = ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSansMono.ttf", 11) 31 | colorBlue = "#bb7200" 32 | colorBlueD1 = "#7f4f00" 33 | colorGray = "#292929" 34 | colorGrayL1 = "#383838" 35 | colorGrayD2 = "#141414" 36 | 37 | activeImage: Image.Image 38 | activeDraw: ImageDraw.ImageDraw 39 | 40 | 41 | def get_fb_screeninfo(): 42 | global fb_screeninfo 43 | if hw_info == 3: 44 | fb_screeninfo = b'\xe0\x01\x00\x00\x80\x02\x00\x00\xe0\x01\x00\x00\x80\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x18\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80\x00\x00\x00^\x00\x00\x00\x96\x00\x00\x00\x00\x00\x00\x00\xc2\xa2\x00\x00\x1a\x00\x00\x00T\x00\x00\x00\x0c\x00\x00\x00\x1e\x00\x00\x00\x14\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 45 | else: 46 | fb_fd = os.open("/dev/fb0", os.O_RDWR) 47 | try: 48 | fb_info = bytearray(160) 49 | ioctl(fb_fd, 0x4600, fb_info) 50 | fb_screeninfo = bytes(fb_info) 51 | finally: 52 | #print(fb_screeninfo) 53 | os.close(fb_fd) 54 | return fb_screeninfo 55 | 56 | def screen_reset(): 57 | if fb_screeninfo is not None: 58 | ioctl( 59 | fb, 60 | 0x4601, 61 | bytearray(fb_screeninfo), 62 | ) 63 | ioctl(fb, 0x4611, 0) 64 | 65 | 66 | def draw_start(): 67 | global fb, mm 68 | fb = os.open("/dev/fb0", os.O_RDWR) 69 | mm = mmap.mmap(fb, screen_size) 70 | 71 | 72 | def draw_end(): 73 | global fb, mm 74 | mm.close() 75 | os.close(fb) 76 | 77 | 78 | def create_image(): 79 | image = Image.new("RGBA", (screen_width, screen_height), color="black") 80 | return image 81 | 82 | 83 | def draw_active(image): 84 | global activeImage, activeDraw 85 | activeImage = image 86 | activeDraw = ImageDraw.Draw(activeImage) 87 | 88 | 89 | def draw_paint(): 90 | global activeImage 91 | if hw_info == 3: 92 | img = activeImage.rotate(90, expand=True) 93 | mm.seek(0) 94 | mm.write(img.tobytes()) 95 | else: 96 | mm.seek(0) 97 | mm.write(activeImage.tobytes()) 98 | 99 | 100 | def draw_clear(): 101 | global activeDraw 102 | activeDraw.rectangle((0, 0, screen_width, screen_height), fill="black") 103 | 104 | 105 | def draw_text(position, text, font=15, color="white", **kwargs): 106 | global activeDraw 107 | activeDraw.text(position, text, font=fontFile[font], fill=color, **kwargs) 108 | 109 | 110 | def draw_rectangle(position, fill=None, outline=None, width=1): 111 | global activeDraw 112 | activeDraw.rectangle(position, fill=fill, outline=outline, width=width) 113 | 114 | 115 | def draw_rectangle_r(position, radius, fill=None, outline=None): 116 | global activeDraw 117 | activeDraw.rounded_rectangle(position, radius, fill=fill, outline=outline) 118 | 119 | 120 | def draw_circle(position, radius, fill=None, outline="white"): 121 | global activeDraw 122 | activeDraw.ellipse( 123 | [position[0], position[1], position[0] + radius, position[1] + radius], 124 | fill=fill, 125 | outline=outline, 126 | ) 127 | 128 | 129 | def draw_log(text, fill="Black", outline="black", width=500): 130 | # Center the rectangle horizontally 131 | x = (screen_width - width) / 2 132 | # Center the rectangle vertically 133 | y = (screen_height - 80) / 2 # 80 is the height of the rectangle 134 | draw_rectangle_r([x, y, x + width, y + 80], 5, fill=fill, outline=outline) 135 | 136 | # Center the text within the rectangle 137 | text_x = x + width / 2 138 | text_y = y + 40 # Vertically center within the 80px height 139 | draw_text((text_x, text_y), text, anchor="mm") # Use middle-middle anchor 140 | 141 | 142 | fb_screeninfo = get_fb_screeninfo() 143 | 144 | draw_start() 145 | screen_reset() 146 | 147 | imgMain = create_image() 148 | draw_active(imgMain) 149 | -------------------------------------------------------------------------------- /tiny_scraper/input.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | code = 0 4 | codeName = "" 5 | value = 0 6 | 7 | mapping = { 8 | 304: "A", 9 | 305: "B", 10 | 306: "Y", 11 | 307: "X", 12 | 308: "L1", 13 | 309: "R1", 14 | 314: "L2", 15 | 315: "R2", 16 | 17: "DY", 17 | 16: "DX", 18 | 310: "SELECT", 19 | 311: "START", 20 | 312: "MENUF", 21 | 114: "V+", 22 | 115: "V-", 23 | } 24 | 25 | def check(): 26 | global type, code, codeName, codeDown, value, valueDown 27 | with open("/dev/input/event1", "rb") as f: 28 | while True: 29 | event = f.read(24) 30 | 31 | if event: 32 | (tv_sec, tv_usec, type, kcode, kvalue) = struct.unpack('llHHI', event) 33 | if kvalue != 0: 34 | if kvalue != 1: 35 | kvalue = -1 36 | code = kcode 37 | codeName = mapping.get(code, str(code)) 38 | value = kvalue 39 | return 40 | 41 | def key(keyCodeName, keyValue = 99): 42 | global code, codeName, value 43 | if codeName == keyCodeName: 44 | if keyValue != 99: 45 | return value == keyValue 46 | return True 47 | 48 | def reset_input(): 49 | global codeName, value 50 | codeName = "" 51 | value = 0 -------------------------------------------------------------------------------- /tiny_scraper/lang/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "No internet connection": "Unable to connect to the Internet,\nplease try again after connecting...", 3 | "Select": "Select", 4 | "Exit": "Exit", 5 | "D. All": "D. All", 6 | "Back": "Back", 7 | "Download": "Download", 8 | "Tiny Scraper": "Tiny Scraper", 9 | "Checking existing media...": "Checking existing media...", 10 | "No roms missing media found...": "No roms missing media found...", 11 | "Scraping...": "Scraping...", 12 | "Scraping completed": "Scraping completed", 13 | "Scraping failed!": "Scraping failed!", 14 | "Scraping": "Scraping", 15 | "of": "of", 16 | "Scraping completed! Success:": "Scraping completed! Success:", 17 | "Errors:": "Errors:", 18 | "No roms found in TF": "No roms found in TF", 19 | "Missing media:": "Missing media:", 20 | "Roms:": "Roms:" 21 | } 22 | -------------------------------------------------------------------------------- /tiny_scraper/lang/ja_JP.json: -------------------------------------------------------------------------------- 1 | { 2 | "No internet connection": "インターネットに接続できません。\n接続後にもう一度お試しください...", 3 | "Select": "選択", 4 | "Exit": "終了", 5 | "D. All": "すべてダウン", 6 | "Back": "戻る", 7 | "Download": "クロール", 8 | "Tiny Scraper": "小さなスクレーパー", 9 | "Checking existing media...": "既存のメディアの確認...", 10 | "No roms missing media found...": "欠落しているメディアが見つからない\n ROM はありません...", 11 | "Scraping...": "削り取り...", 12 | "Scraping completed": "削り取り完了", 13 | "Scraping failed!": "スクレイピングに失敗しました!", 14 | "Scraping": "削り取り", 15 | "of": "合計", 16 | "Scraping completed! Success:": "削り取り完了! 成功:", 17 | "Errors:": "ミス:", 18 | "No roms found in TF": "にROMが見つかりません TF", 19 | "Missing media:": "メディアが見つかりません:", 20 | "Roms:": "ゲーム数:" 21 | } 22 | -------------------------------------------------------------------------------- /tiny_scraper/lang/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "No internet connection": "未发现有效的网络连接, \n请联网后再试一次...", 3 | "Select": "选择", 4 | "Exit": "退出", 5 | "D. All": "抓取全部", 6 | "Back": "返回", 7 | "Download": "抓取当前", 8 | "Tiny Scraper": "瑞士小刀抓取器", 9 | "Checking existing media...": "正在检测已有的预览图...", 10 | "No roms missing media found...": "未找到缺少预览图的游戏...", 11 | "Scraping...": "正在抓取...", 12 | "Scraping completed": "抓取成功", 13 | "Scraping failed!": "抓取失败!", 14 | "Scraping": "正在抓取", 15 | "of": "共", 16 | "Scraping completed! Success:": "抓取完成! 成功:", 17 | "Errors:": "失败:", 18 | "No roms found in TF": "未找到游戏保存在 TF", 19 | "Missing media:": "缺少预览图:", 20 | "Roms:": "游戏数量:" 21 | } 22 | -------------------------------------------------------------------------------- /tiny_scraper/language.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | class Translator: 5 | def __init__(self, lang_code='en_US'): 6 | self.lang_data = {} 7 | self.lang_code = lang_code 8 | self.load_language(lang_code) 9 | 10 | def load_language(self, lang_code): 11 | lang_file = f'{os.path.dirname(os.path.abspath(__file__))}/lang/{lang_code}.json' 12 | if not os.path.exists(lang_file): 13 | lang_file = f'{os.path.dirname(os.path.abspath(__file__))}/lang/en_US.json' 14 | try: 15 | with open(lang_file, 'r', encoding='utf-8') as f: 16 | self.lang_data = json.load(f) 17 | except FileNotFoundError: 18 | raise Exception(f"Language {lang_file} file {lang_code}.json not found!") 19 | 20 | def translate(self, key, **kwargs): 21 | message = self.lang_data.get(key, key) 22 | return message.format(**kwargs) 23 | 24 | 25 | translator = Translator() 26 | -------------------------------------------------------------------------------- /tiny_scraper/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import app 3 | from pathlib import Path 4 | 5 | board_mapping = { 6 | 'RGcubexx': 1, 7 | 'RG34xx': 2, 8 | 'RG28xx': 3 9 | } 10 | system_list = ['zh_CN', 'zh_TW', 'en_US', 'ja_JP', 'ko_KR', 'es_LA', 'ru_RU', 'de_DE', 'fr_FR', 'pt_BR'] 11 | 12 | try: 13 | board_info = Path("/mnt/vendor/oem/board.ini").read_text().splitlines()[0] 14 | except (FileNotFoundError, IndexError): 15 | board_info = '' 16 | 17 | try: 18 | lang_info = Path("/mnt/vendor/oem/language.ini").read_text().splitlines()[0] 19 | except (FileNotFoundError, IndexError): 20 | lang_info = 2 21 | 22 | hw_info = board_mapping.get(board_info, 0) 23 | system_lang = system_list[int(lang_info)] 24 | 25 | 26 | def main(): 27 | 28 | path = sys.argv[1] 29 | app.start(path) 30 | 31 | while True: 32 | app.update() 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /tiny_scraper/scraper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import binascii 3 | import json 4 | import base64 5 | from pathlib import Path 6 | import ssl 7 | from urllib.request import urlopen, Request 8 | import urllib.parse 9 | from systems import get_system_extension, systems 10 | 11 | 12 | class Rom: 13 | def __init__(self, name, filename, crc=""): 14 | self.name = name 15 | self.filename = filename 16 | self.crc = crc 17 | 18 | def set_crc(self, crc): 19 | self.crc = crc 20 | 21 | 22 | class Scraper: 23 | def __init__(self): 24 | self.user = "" 25 | self.password = "" 26 | self.devid = "cmVhdmVu" 27 | self.devpassword = "MDZXZUY5bTBldWs=" 28 | self.media_type = "ss" 29 | self.region = "wor" 30 | self.resize = False 31 | 32 | def load_config_from_json(self, filepath) -> bool: 33 | if not os.path.exists(filepath): 34 | print(f"Config file {filepath} not found") 35 | return False 36 | 37 | with open(filepath, "r") as file: 38 | config = json.load(file) 39 | self.user = config.get("user") 40 | self.password = config.get("password") 41 | self.media_type = config.get("media_type") or "ss" 42 | self.region = config.get("region") or "wor" 43 | self.resize = config.get("resize") is True 44 | return True 45 | 46 | def get_crc32_from_file(self, rom, chunk_size = 65536): 47 | crc32 = 0 48 | with rom.open(mode="rb") as file: 49 | while chunk := file.read(chunk_size): 50 | crc32 = binascii.crc32(chunk, crc32) 51 | crc32 = crc32 & 0xFFFFFFFF 52 | return "%08X" % crc32 53 | 54 | def get_files_without_extension(self, folder): 55 | return [f.stem for f in Path(folder).glob("*") if f.is_file()] 56 | 57 | def get_image_files_without_extension(self, folder): 58 | image_extensions = (".jpg", ".jpeg", ".png") 59 | return [ 60 | f.stem for f in folder.glob("*") if f.suffix.lower() in image_extensions 61 | ] 62 | 63 | def get_roms(self, path, system: str) -> list[Rom]: 64 | roms = [] 65 | system_path = Path(path) / system 66 | system_extensions = get_system_extension(system) 67 | if not system_extensions: 68 | print(f"No extensions found for system: {system}") 69 | return roms 70 | 71 | for file in os.listdir(system_path): 72 | file_path = Path(system_path) / file 73 | if file.startswith(".") or file.startswith("-"): 74 | continue 75 | if file_path.is_file(): 76 | file_extension = file_path.suffix.lower().lstrip(".") 77 | if file_extension in system_extensions: 78 | name = file_path.stem 79 | rom = Rom(filename=file, name=name) 80 | roms.append(rom) 81 | 82 | return roms 83 | 84 | def get_available_systems(self, roms_path: str) -> list[str]: 85 | all_systems = [system["name"] for system in systems] 86 | available_systems = [] 87 | for system in all_systems: 88 | system_path = Path(roms_path) / system 89 | if system_path.exists() and any(system_path.iterdir()): 90 | available_systems.append(system) 91 | 92 | return available_systems 93 | 94 | def scrape_screenshot( 95 | self, crc: str, game_name: str, system_id: int 96 | ) -> bytes | None: 97 | ctx = ssl.create_default_context() 98 | ctx.check_hostname = False 99 | ctx.verify_mode = ssl.CERT_NONE 100 | 101 | decoded_devid = base64.b64decode(self.devid).decode() 102 | decoded_devpassword = base64.b64decode(self.devpassword).decode() 103 | encoded_game_name = urllib.parse.quote(game_name) 104 | url = f"https://api.screenscraper.fr/api2/jeuInfos.php?devid={decoded_devid}&devpassword={decoded_devpassword}&softname=tiny-scraper&output=json&ssid={self.user}&sspassword={self.password}&crc={crc}&systemeid={system_id}&romtype=rom&romnom={encoded_game_name}" 105 | 106 | print(f"Scraping screenshot for {game_name}...") 107 | request = Request(url) 108 | try: 109 | with urlopen(request, context=ctx) as response: 110 | if response.status == 200: 111 | try: 112 | data = json.loads(response.read()) 113 | game_data = data.get("response").get("jeu") 114 | 115 | screenshot_url = "" 116 | for media in game_data.get("medias"): 117 | if media["type"] == self.media_type: 118 | if media["region"] == self.region: 119 | screenshot_url = media["url"] 120 | break 121 | elif ( 122 | not screenshot_url 123 | ): # Keep the first one as fallback 124 | print(f"No media found for this region {self.region} and type {self.media_type} combination for {game_name}") 125 | screenshot_url = media["url"] 126 | 127 | if screenshot_url: 128 | img_request = Request(screenshot_url) 129 | with urlopen(img_request, context=ctx) as img_response: 130 | if ( 131 | img_response.headers.get("Content-Type") 132 | == "image/png" 133 | ): 134 | return img_response.read() 135 | else: 136 | print(f"Invalid image format for {game_name}") 137 | else: 138 | print(f"No screenshot URL found for {game_name}") 139 | except ValueError: 140 | print(f"Invalid JSON response for {game_name}") 141 | else: 142 | print(f"Failed to get screenshot for {game_name}") 143 | return None 144 | except Exception as e: 145 | print(f"Error scraping screenshot for {game_name}: {e}") 146 | print(f"URL used: {url}") 147 | return None 148 | -------------------------------------------------------------------------------- /tiny_scraper/systems.py: -------------------------------------------------------------------------------- 1 | systems = [ 2 | {"name": "A2600", "id": 0, "extensions": ["zip", "a26", "bin"]}, 3 | {"name": "A5200", "id": 40, "extensions": ["a52", "zip"]}, 4 | {"name": "A7800", "id": 0, "extensions": ["zip", "a78", "bin"]}, 5 | {"name": "A800", "id": 0, "extensions": ["zip", "atr", "rom"]}, 6 | {"name": "AMIGA","id": 64,"extensions": ["zip", "adf", "uae", "ipf", "dms", "adz", "lha", "m3u", "hdf", "hdz", "iso", "cue", "chd"]}, 7 | {"name": "ATARIST","id": 42,"extensions": ["st", "stx", "msa", "dim", "ipf", "m3u", "zip"],}, 8 | {"name": "ATOMISWAVE", "id": 53, "extensions": ["chd", "bin", "gdi", "zip"]}, 9 | {"name": "C64","id": 66,"extensions": ["zip", "d64", "d71", "d80", "d81", "d82", "g64", "g41", "x64", "t64", "tap", "prg", "p00", "crt", "bin", "d6z", "d7z", "d8z", "g6z", "g4z", "x6z", "cmd", "m3u", "vsf", "nib", "nbz"]}, 10 | {"name": "CPS1", "id": 6, "extensions": ["zip"]}, 11 | {"name": "CPS2", "id": 7, "extensions": ["zip"]}, 12 | {"name": "CPS3", "id": 8, "extensions": ["zip"]}, 13 | {"name": "DOS","id": 135,"extensions": ["dosz", "com", "bat", "exe", "zip"]}, 14 | {"name": "DREAMCAST", "id": 23, "extensions": ["chd", "cdi", "gdi", "cue", "iso", "bin", "zip", "m3u"]}, 15 | {"name": "EASYRPG", "id": 0, "extensions": ["ldb", "sh"]}, 16 | {"name": "FBNEO", "id": 142, "extensions": ["zip"]}, 17 | {"name": "FC","id": 3,"extensions": ["nes", "zip"]}, 18 | {"name": "FDS", "id": 0, "extensions": ["zip", "fds"]}, 19 | {"name": "GB", "id": 9, "extensions": ["gb", "zip"]}, 20 | {"name": "GBA", "id": 12, "extensions": ["gba", "zip"]}, 21 | {"name": "GBC", "id": 10, "extensions": ["gb", "gbc", "zip"]}, 22 | {"name": "GG", "id": 21, "extensions": ["gg", "zip"]}, 23 | {"name": "GW", "id": 52, "extensions": ["mgw"]}, 24 | {"name": "HBMAME", "id": 0, "extensions": ["zip"]}, 25 | {"name": "LYNX", "id": 28, "extensions": ["lnx", "zip"]}, 26 | {"name": "MAME", "id": 75, "extensions": ["zip"]}, 27 | {"name": "MD", "id": 1, "extensions": ["gen", "md", "smd", "bin", "zip"]}, 28 | {"name": "MDCD","id": 20,"extensions": ["zip", "cue", "iso", "chd", "m3u", "sg"]}, 29 | {"name": "MSX","id": 113,"extensions": ["zip", "rom", "ri", "mx1", "mx2", "col", "dsk", "cas", "sg", "sc", "m3u"]}, 30 | {"name": "N64", "id": 14, "extensions": ["n64", "v64", "z64", "bin", "zip"]}, 31 | {"name": "NAOMI", "id": 56, "extensions": ["zip"]}, 32 | {"name": "NEOGEO", "id": 0, "extensions": ["zip"]}, 33 | {"name": "NEOCD", "id": 0, "extensions": ["zip", "cue", "chd", "iso"]}, 34 | {"name": "NGP", "id": 82, "extensions": ["ngp", "ngc", "zip"]}, 35 | {"name": "ONS", "id": 0, "extensions": ["zip", "dat", "txt", "nt", "nt2", "nt3", "ons"]}, 36 | {"name": "PCE", "id": 105, "extensions": ["pce", "cue", "ccd", "zip"]}, 37 | {"name": "PCECD","id": 114,"extensions": ["cue", "ccd", "chd", "toc", "m3u"]}, 38 | {"name": "PGM2", "id": 0, "extensions": ["zip"]}, 39 | {"name": "PICO", "id": 234, "extensions": ["p8", "png"]}, 40 | {"name": "POKE", "id": 211, "extensions": ["min", "zip"]}, 41 | {"name": "PS","id": 57,"extensions": ["bin", "img", "mdf", "iso", "cue", "ccd", "pbp", "chd", "m3u", "toc", "cbn", "sub", "zip"]}, 42 | {"name": "PSP","id": 61,"extensions": ["cso", "pbp", "chd", "iso"],}, 43 | {"name": "SATURN","id": 22,"extensions": ["bin", "cue", "iso", "mds", "ccd", "chd", "rar"],}, 44 | {"name": "SCUMMVM", "id": 123, "extensions": ["zip", "scummvm"]}, 45 | {"name": "SEGA32X","id": 19,"extensions": ["32x", "smd", "md", "bin", "ccd", "cue", "img", "iso", "sub", "wav", "zip"]}, 46 | {"name": "SFC", "id": 4, "extensions": ["zip", "smc", "sfc"]}, 47 | {"name": "SMS", "id": 2, "extensions": ["sms", "zip"]}, 48 | {"name": "VARCADE", "id": 0, "extensions": ["zip"]}, 49 | {"name": "VB", "id": 11, "extensions": ["vb", "zip"]}, 50 | {"name": "VIC20", "id": 0, "extensions": ["zip", "a0", "20", "b0", "d6", "d7", "d8", "g4", "g6", "gz", "x6", "t64", "tap", "prg", "p00", "crt", "bin", "cmd", "m3u", "vsf", "nib", "nbz"]}, 51 | {"name": "WS", "id": 45, "extensions": ["ws", "wsc", "zip"]}, 52 | {"name": "NDS", "id": 15, "extensions": ["nds", "zip"]}, 53 | ] 54 | 55 | 56 | def get_system_id(system_name: str) -> int: 57 | for system in systems: 58 | if system["name"] == system_name: 59 | return system["id"] 60 | return -1 61 | 62 | 63 | def get_system_extension(system_name: str) -> list[str]: 64 | for system in systems: 65 | if system["name"] == system_name: 66 | return system["extensions"] 67 | return [] 68 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.12" 3 | 4 | [[package]] 5 | name = "certifi" 6 | version = "2025.1.31" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, 11 | ] 12 | 13 | [[package]] 14 | name = "charset-normalizer" 15 | version = "3.4.1" 16 | source = { registry = "https://pypi.org/simple" } 17 | sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } 18 | wheels = [ 19 | { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, 20 | { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, 21 | { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, 22 | { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, 23 | { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, 24 | { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, 25 | { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, 26 | { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, 27 | { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, 28 | { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, 29 | { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, 30 | { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, 31 | { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, 32 | { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, 33 | { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, 34 | { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, 35 | { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, 36 | { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, 37 | { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, 38 | { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, 39 | { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, 40 | { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, 41 | { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, 42 | { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, 43 | { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, 44 | { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, 45 | { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, 46 | ] 47 | 48 | [[package]] 49 | name = "idna" 50 | version = "3.10" 51 | source = { registry = "https://pypi.org/simple" } 52 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 53 | wheels = [ 54 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 55 | ] 56 | 57 | [[package]] 58 | name = "requests" 59 | version = "2.32.3" 60 | source = { registry = "https://pypi.org/simple" } 61 | dependencies = [ 62 | { name = "certifi" }, 63 | { name = "charset-normalizer" }, 64 | { name = "idna" }, 65 | { name = "urllib3" }, 66 | ] 67 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } 68 | wheels = [ 69 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, 70 | ] 71 | 72 | [[package]] 73 | name = "ruff" 74 | version = "0.9.6" 75 | source = { registry = "https://pypi.org/simple" } 76 | sdist = { url = "https://files.pythonhosted.org/packages/2a/e1/e265aba384343dd8ddd3083f5e33536cd17e1566c41453a5517b5dd443be/ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9", size = 3639454 } 77 | wheels = [ 78 | { url = "https://files.pythonhosted.org/packages/76/e3/3d2c022e687e18cf5d93d6bfa2722d46afc64eaa438c7fbbdd603b3597be/ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba", size = 11714128 }, 79 | { url = "https://files.pythonhosted.org/packages/e1/22/aff073b70f95c052e5c58153cba735748c9e70107a77d03420d7850710a0/ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504", size = 11682539 }, 80 | { url = "https://files.pythonhosted.org/packages/75/a7/f5b7390afd98a7918582a3d256cd3e78ba0a26165a467c1820084587cbf9/ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83", size = 11132512 }, 81 | { url = "https://files.pythonhosted.org/packages/a6/e3/45de13ef65047fea2e33f7e573d848206e15c715e5cd56095589a7733d04/ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc", size = 11929275 }, 82 | { url = "https://files.pythonhosted.org/packages/7d/f2/23d04cd6c43b2e641ab961ade8d0b5edb212ecebd112506188c91f2a6e6c/ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b", size = 11466502 }, 83 | { url = "https://files.pythonhosted.org/packages/b5/6f/3a8cf166f2d7f1627dd2201e6cbc4cb81f8b7d58099348f0c1ff7b733792/ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e", size = 12676364 }, 84 | { url = "https://files.pythonhosted.org/packages/f5/c4/db52e2189983c70114ff2b7e3997e48c8318af44fe83e1ce9517570a50c6/ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666", size = 13335518 }, 85 | { url = "https://files.pythonhosted.org/packages/66/44/545f8a4d136830f08f4d24324e7db957c5374bf3a3f7a6c0bc7be4623a37/ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5", size = 12823287 }, 86 | { url = "https://files.pythonhosted.org/packages/c5/26/8208ef9ee7431032c143649a9967c3ae1aae4257d95e6f8519f07309aa66/ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5", size = 14592374 }, 87 | { url = "https://files.pythonhosted.org/packages/31/70/e917781e55ff39c5b5208bda384fd397ffd76605e68544d71a7e40944945/ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217", size = 12500173 }, 88 | { url = "https://files.pythonhosted.org/packages/84/f5/e4ddee07660f5a9622a9c2b639afd8f3104988dc4f6ba0b73ffacffa9a8c/ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6", size = 11906555 }, 89 | { url = "https://files.pythonhosted.org/packages/f1/2b/6ff2fe383667075eef8656b9892e73dd9b119b5e3add51298628b87f6429/ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897", size = 11538958 }, 90 | { url = "https://files.pythonhosted.org/packages/3c/db/98e59e90de45d1eb46649151c10a062d5707b5b7f76f64eb1e29edf6ebb1/ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08", size = 12117247 }, 91 | { url = "https://files.pythonhosted.org/packages/ec/bc/54e38f6d219013a9204a5a2015c09e7a8c36cedcd50a4b01ac69a550b9d9/ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656", size = 12554647 }, 92 | { url = "https://files.pythonhosted.org/packages/a5/7d/7b461ab0e2404293c0627125bb70ac642c2e8d55bf590f6fce85f508f1b2/ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d", size = 9949214 }, 93 | { url = "https://files.pythonhosted.org/packages/ee/30/c3cee10f915ed75a5c29c1e57311282d1a15855551a64795c1b2bbe5cf37/ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa", size = 10999914 }, 94 | { url = "https://files.pythonhosted.org/packages/e8/a8/d71f44b93e3aa86ae232af1f2126ca7b95c0f515ec135462b3e1f351441c/ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a", size = 10177499 }, 95 | ] 96 | 97 | [[package]] 98 | name = "tiny-scraper" 99 | version = "1.3.0" 100 | source = { virtual = "." } 101 | dependencies = [ 102 | { name = "requests" }, 103 | ] 104 | 105 | [package.dev-dependencies] 106 | dev = [ 107 | { name = "ruff" }, 108 | ] 109 | 110 | [package.metadata] 111 | requires-dist = [{ name = "requests", specifier = "==2.32.3" }] 112 | 113 | [package.metadata.requires-dev] 114 | dev = [{ name = "ruff", specifier = ">=0.9.6" }] 115 | 116 | [[package]] 117 | name = "urllib3" 118 | version = "2.3.0" 119 | source = { registry = "https://pypi.org/simple" } 120 | sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } 121 | wheels = [ 122 | { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, 123 | ] 124 | --------------------------------------------------------------------------------