diff --git a/.env.sample b/.env.sample index 27de81a..51845fe 100644 --- a/.env.sample +++ b/.env.sample @@ -178,6 +178,14 @@ # Default: https://rebrickable.com/parts/{number}/_/{color} # BK_REBRICKABLE_LINK_PART_PATTERN= +# Optional: Pattern of the link to Rebrickable for instructions. Will be passed to Python .format() +# Default: https://rebrickable.com/instructions/{number} +# BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN= + +# Optional: User-Agent to use when querying Rebrickable outside of the Rebrick python library +# Default: 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' +# BK_REBRICKABLE_USER_AGENT= + # Optional: Display Rebrickable links wherever applicable # Default: false # Legacy name: LINKS diff --git a/bricktracker/config.py b/bricktracker/config.py index 8cfdbba..36defc6 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -43,6 +43,8 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'REBRICKABLE_IMAGE_NIL_MINIFIGURE', 'd': 'https://rebrickable.com/static/img/nil_mf.jpg'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{number}'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'}, # noqa: E501 + {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{number}'}, # noqa: E501 + {'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, {'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int}, {'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501 diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index 1924aba..0c966d3 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -8,6 +8,7 @@ import humanize from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename +from io import BytesIO import requests from bs4 import BeautifulSoup @@ -117,21 +118,30 @@ class BrickInstructions(object): )) file.save(target) - + # Info logger.info('The instruction file {file} has been imported'.format( file=self.filename )) - - def find_instructions(self, set_id: str, /) -> None: - - url = f"https://rebrickable.com/instructions/{set_id}" - print(url) + + # Compute the url for the rebrickable instructions page + def url_for_instructions(self, /) -> str: + try: + return current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 + number=self.filename, + ) + except Exception: + pass + + return '' + def find_instructions(self, set: str, /) -> None: + headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + 'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'] } - response = requests.get(url, headers=headers) + + response = requests.get(BrickInstructions.url_for_instructions(self), headers=headers) if response.status_code != 200: raise ErrorException('Failed to load page. Status code: {response.status_code}') @@ -144,6 +154,7 @@ class BrickInstructions(object): img_tag = a_tag.find('img', alt=True) if img_tag and "LEGO Building Instructions" in img_tag['alt']: found_tags.append((img_tag['alt'].replace('LEGO Building Instructions for ', ''), a_tag['href'])) # Save alt and href + return found_tags def get_list(self, request_form, /) -> list: @@ -153,30 +164,28 @@ class BrickInstructions(object): if key.startswith('instruction-') and request_form.get(key) == 'on': # Checkbox is checked index = key.split('-')[-1] alt_text = request_form.get(f'instruction-alt-text-{index}') - href_text = request_form.get(f'instruction-href-text-{index}') + href_text = request_form.get(f'instruction-href-text-{index}').replace('/instructions/', '') # Remove the /instructions/ part selected_instructions.append((href_text,alt_text)) return selected_instructions - def download(self, href: str, /) -> None: - target = self.path(secure_filename(self.filename)) + def download(self, href: str, /) -> None: + target = self.path(filename=secure_filename(self.filename)) if os.path.isfile(target): raise ErrorException('Cannot download {target} as it already exists'.format( # noqa: E501 target=self.filename )) - url = f"https://rebrickable.com/{href}" + url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format(number=href) response = requests.get(url) if response.status_code == 200: - # Save the file - with open(target, 'wb') as file: - file.write(response.content) + # Save the content to the target path + FileStorage(stream=BytesIO(response.content)).save(target) else: raise ErrorException(f"Failed to download {self.filename}. Status code: {response.status_code}") - # Info logger.info('The instruction file {file} has been imported'.format( file=self.filename diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py index 159f8db..585a981 100644 --- a/bricktracker/views/instructions.py +++ b/bricktracker/views/instructions.py @@ -140,35 +140,35 @@ def download() -> str: error=request.args.get('error') ) -# Actually download an instructions file +# Show search results @instructions_page.route('/download/', methods=['POST']) @login_required @exception_handler(__file__, post_redirect='instructions.download') def do_download() -> Response: + # get set_id from input field set_id: str = request.form.get('add-set', '') - # BrickInstructions require an argument. Not sure which makes sense here. - found_tags = BrickInstructions(set_id).find_instructions(set_id) - - return render_template('instructions.html', download=True, found_tags=found_tags) + # get list of instructions for the set and offer them to download + instructions = BrickInstructions(set_id).find_instructions(set_id) + + return render_template('instructions.html', download=True, instructions=instructions) @instructions_page.route('/confirm_download', methods=['POST']) @login_required @exception_handler(__file__, post_redirect='instructions.download') def confirm_download() -> Response: - # BrickInstructions require an argument. Not sure which makes sense here. + # Get list of selected instructions selected_instructions = BrickInstructions("").get_list(request.form) - + + # No instructions selected if not selected_instructions: - # No instructions selected return redirect(url_for('instructions.download')) + # Loop over selected instructions and download them for href, filename in selected_instructions: BrickInstructions(f"{filename}.pdf").download(href) - BrickInstructionsList(force=True) - #flash("Selected instructions have been downloaded", 'success') return redirect(url_for('instructions.list')) diff --git a/templates/instructions/download.html b/templates/instructions/download.html index 79b3ccc..20cb688 100644 --- a/templates/instructions/download.html +++ b/templates/instructions/download.html @@ -17,7 +17,7 @@ - {% if found_tags %} + {% if instructions %}