├── __init__.py ├── test ├── bd │ ├── a.jpg │ ├── b.jpg │ ├── c.gif │ └── ComicInfo.xml ├── __init__py ├── bd.cbr ├── bd.cbz ├── Couv_245127.jpg ├── Couv_272757.jpg ├── Nains 1 00a.jpg ├── feature_matching.jpg ├── bdnex.yaml ├── test_archive_tools.py ├── test_cover.py ├── test_utils.py ├── .local │ └── share │ │ └── bdnex │ │ └── bedetheque │ │ ├── albums_json │ │ ├── BD-Nains-de-jardin-Premiere-brouette-31026.html.json │ │ └── BD-Nains-Tome-1-Redwin-de-la-Forge-245127.html.json │ │ ├── series_html │ │ └── serie-47467-BD-Nains.html │ │ └── albums_html │ │ └── BD-Nains-Tome-1-Redwin-de-la-Forge-245127.html ├── test_bdgest.py └── mobile_redwin.html ├── bdnex ├── __init__.py ├── conf │ ├── __init__.py │ ├── bdnex.ini │ ├── logging.conf │ ├── bdnex.yaml │ ├── bdgest_mapping.json │ ├── bedetheque_sitemap.json │ └── ComicInfo.xsd ├── lib │ ├── __init__.py │ ├── archive_tools.py │ ├── cover.py │ ├── colargulog.py │ ├── comicrack.py │ ├── utils.py │ └── bdgest.py ├── __main__.py └── ui │ └── __init__.py ├── bdnex_main ├── .coveragerc ├── environment.yml ├── .github └── workflows │ └── test.yml ├── setup.py ├── README.md ├── .gitignore └── LICENSE /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/bd/a.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/bd/b.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/bd/c.gif: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bdnex/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/__init__py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bdnex/conf/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bdnex/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/bd/ComicInfo.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/bd.cbr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbesnard/bdnex/HEAD/test/bd.cbr -------------------------------------------------------------------------------- /test/bd.cbz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbesnard/bdnex/HEAD/test/bd.cbz -------------------------------------------------------------------------------- /test/Couv_245127.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbesnard/bdnex/HEAD/test/Couv_245127.jpg -------------------------------------------------------------------------------- /test/Couv_272757.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbesnard/bdnex/HEAD/test/Couv_272757.jpg -------------------------------------------------------------------------------- /test/Nains 1 00a.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbesnard/bdnex/HEAD/test/Nains 1 00a.jpg -------------------------------------------------------------------------------- /bdnex/conf/bdnex.ini: -------------------------------------------------------------------------------- 1 | [general] 2 | config_path=~/.config/bdnex 3 | local_path=~/.local/share/bdnex -------------------------------------------------------------------------------- /test/feature_matching.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbesnard/bdnex/HEAD/test/feature_matching.jpg -------------------------------------------------------------------------------- /bdnex_main: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import bdnex.ui 4 | 5 | if __name__ == '__main__': 6 | bdnex.ui.main() -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc 2 | [report] 3 | show_missing = True 4 | omit = 5 | test/* 6 | bdnex/lib/colargulog.py 7 | -------------------------------------------------------------------------------- /bdnex/__main__.py: -------------------------------------------------------------------------------- 1 | """The __main__ module lets you run the bdnex CLI interface by typing 2 | `python -m bdnex`. 3 | """ 4 | 5 | 6 | import sys 7 | from .ui import main 8 | 9 | if __name__ == "__main__": 10 | main(sys.argv[1:]) -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | # conda env create --file=environment.yml 2 | # conda env update --file=environment.yml 3 | name: bdnex 4 | channels: 5 | - conda-forge 6 | - defaults 7 | dependencies: 8 | - python=3.8 9 | - pip>=20.0.2 10 | -------------------------------------------------------------------------------- /bdnex/conf/logging.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,simpleExample 3 | 4 | [handlers] 5 | keys=consoleHandler 6 | 7 | [formatters] 8 | keys=simpleFormatter 9 | 10 | 11 | [logger_root] 12 | level=DEBUG 13 | handlers=consoleHandler 14 | 15 | [logger_simpleExample] 16 | level=DEBUG 17 | handlers=consoleHandler 18 | qualname=simpleExample 19 | propagate=0 20 | 21 | [handler_consoleHandler] 22 | class=StreamHandler 23 | level=DEBUG 24 | formatter=simpleFormatter 25 | args=(sys.stdout,) 26 | 27 | [formatter_simpleFormatter] 28 | format=%(asctime)s - %(name)s - %(levelname)s - %(message)s -------------------------------------------------------------------------------- /test/bdnex.yaml: -------------------------------------------------------------------------------- 1 | bdnex: 2 | config_path: ~/.config/bdnex 3 | share_path: ~/.local/share/bdnex 4 | 5 | directory: /path/to/comics/library 6 | 7 | import: 8 | copy: no 9 | move: yes 10 | replace: yes 11 | autotag: no 12 | rename: yes 13 | 14 | library: ~/.local/share/bdnex/bdnex.sqlite 15 | 16 | paths: 17 | default: '%language/%type/%title (%author) [%year]/%title - %volume (%author) [%year]' 18 | oneshot: '%language/oneShots/%title (%author) [%year]/%title (%author) [%year]' 19 | series: '%language/series/%title (%author)/%title - %volume' 20 | 21 | cover: 22 | match_percentage: 40 -------------------------------------------------------------------------------- /bdnex/conf/bdnex.yaml: -------------------------------------------------------------------------------- 1 | bdnex: 2 | config_path: ~/.config/bdnex 3 | share_path: ~/.local/share/bdnex 4 | 5 | directory: /path/to/comics/library 6 | 7 | import: 8 | copy: no 9 | move: yes 10 | replace: yes 11 | autotag: no 12 | rename: yes 13 | 14 | library: ~/.local/share/bdnex/bdnex.sqlite 15 | 16 | paths: 17 | default: '%language/%type/%title (%author) [%year]/%title - %volume (%author) [%year]' 18 | oneshot: '%language/oneShots/%title (%author) [%year]/%title (%author) [%year]' 19 | series: '%language/series/%title (%author)/%title - %volume' 20 | 21 | cover: 22 | match_percentage: 40 -------------------------------------------------------------------------------- /bdnex/conf/bdgest_mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "Couleurs": "Colorist", 3 | "Couverture": "CoverArtist", 4 | "Dessin": "Penciller", 5 | "Editeur": "Publisher", 6 | "Format": "Format", 7 | "Language": "LanguageISO", 8 | "Lettrage": "Letterer", 9 | "Scénario": "Writer", 10 | "Titre": "Title", 11 | "Tome": "Volume", 12 | "AlternateNumber": "AlternateNumber", 13 | "author": "Writer", 14 | "description": "Summary", 15 | "Style": "Genre", 16 | "illustrator": "Inker", 17 | "Planches": "PageCount", 18 | "publisher": "Publisher", 19 | "score": "Notes", 20 | "Série": "Series", 21 | "album_url": "Web", 22 | "Note": "CommunityRating" 23 | } 24 | -------------------------------------------------------------------------------- /test/test_archive_tools.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import unittest 4 | 5 | from bdnex.lib.archive_tools import archive_get_front_cover 6 | 7 | ARCHIVE_CBZ_PATH = os.path.join(os.path.dirname(__file__), 'bd.cbz') 8 | ARCHIVE_CBR_PATH = os.path.join(os.path.dirname(__file__), 'bd.cbr') 9 | 10 | 11 | class TestArchiveTools(unittest.TestCase): 12 | def test_archive_get_front_cover(self): 13 | cover_path = archive_get_front_cover(ARCHIVE_CBZ_PATH) 14 | self.assertEqual('a.jpg', os.path.basename(cover_path)) # add assertion here 15 | 16 | cover_path = archive_get_front_cover(ARCHIVE_CBR_PATH) 17 | self.assertEqual('a.jpg', os.path.basename(cover_path)) # add assertion here 18 | 19 | 20 | if __name__ == '__main__': 21 | unittest.main() 22 | -------------------------------------------------------------------------------- /test/test_cover.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from bdnex.lib.cover import front_cover_similarity 5 | 6 | TEST_ROOT = os.path.dirname(__file__) 7 | 8 | BDGEST_COVER = os.path.join(TEST_ROOT, 'Couv_245127.jpg') 9 | ARCHIVE_COVER = os.path.join(TEST_ROOT, 'Nains 1 00a.jpg') 10 | BDGEST_OTHER_COVER = os.path.join(TEST_ROOT, 'Couv_272757.jpg') 11 | 12 | 13 | class TestCover(unittest.TestCase): 14 | def test_front_cover_similarity(self): 15 | # check good cover similarity 16 | match_res = front_cover_similarity(ARCHIVE_COVER, BDGEST_COVER) 17 | self.assertEqual(True, match_res > 50) # 18 | 19 | # check bad cover similarity 20 | match_res = front_cover_similarity(ARCHIVE_COVER, BDGEST_OTHER_COVER) 21 | self.assertEqual(True, match_res < 5) 22 | 23 | 24 | if __name__ == '__main__': 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Python Tests 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Install Python 3 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.8 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install setuptools 21 | pip install . 22 | pip install --upgrade pytest 23 | pytest 24 | - name: Generate Report 25 | run: | 26 | #pytest --cov=bdnex/ --cov-report=xml test/ 27 | pip install coverage 28 | coverage run -m pytest 29 | coverage xml -i 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v3 32 | with: 33 | files: coverage.xml 34 | 35 | -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from unittest.mock import patch 4 | from bdnex.lib.utils import bdnex_config, yesno, enter_album_url 5 | 6 | 7 | class TestUtils(unittest.TestCase): 8 | 9 | @patch('bdnex.lib.utils._init_config') 10 | def test_bdnex_config(self, _init_config_mock): 11 | _init_config_mock.return_value = os.path.join(os.path.join(os.path.dirname(__file__), "bdnex.yaml")) 12 | conf = bdnex_config() 13 | self.assertTrue('bdnex' in conf) 14 | 15 | @patch('builtins.input', side_effect=['nooooo', 'Y']) 16 | def test_yesno(self, input): 17 | self.assertTrue(yesno('do you need this? Y/N')) 18 | 19 | @patch('builtins.input', side_effect=['nooooo', 'def nop', 'i give up', 'n']) 20 | def test_yesno(self, input): 21 | self.assertFalse(yesno('do you need this? Y/N')) 22 | 23 | @patch('builtins.input', side_effect=['a', 'b', 'c', 'https://www.bedetheque.com/nain.html']) 24 | def test_enter_album_url(self, input): 25 | self.assertEqual('https://m.bedetheque.com/nain.html', enter_album_url()) 26 | 27 | @patch('builtins.input', side_effect=['a', 'b', 'https://www.bedetheque.com/nain.html']) 28 | def test_enter_album_url(self, input): 29 | self.assertEqual('https://m.bedetheque.com/nain.html', enter_album_url()) 30 | -------------------------------------------------------------------------------- /bdnex/lib/archive_tools.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import zipfile 4 | 5 | import rarfile 6 | 7 | 8 | def archive_get_front_cover(archive_path): 9 | if archive_path.lower().endswith('.cbz'): 10 | tmpdir = tempfile.mkdtemp() 11 | 12 | with zipfile.ZipFile(archive_path, 'r') as zipf: 13 | 14 | # order is not necessarily alphabetical, so doing the following 15 | sorted_filelist = sorted(zipf.namelist()) 16 | 17 | for f in sorted_filelist: 18 | basename = os.path.basename(f).lower() 19 | if basename.endswith('.jpg') or basename.endswith('.png') or basename.endswith('.jpeg') or basename.endswith('.bmp') or basename.endswith('.wbpp'): 20 | 21 | cover = f 22 | break 23 | cover_path = zipf.extract(cover, tmpdir) 24 | return cover_path 25 | 26 | elif archive_path.lower().endswith('.cbr'): 27 | tmpdir = tempfile.mkdtemp() 28 | rarfile.RarFile(archive_path).extractall(tmpdir) 29 | 30 | filelist = list() 31 | for (dirpath, dirnames, filenames) in os.walk(tmpdir): 32 | filelist += [os.path.join(dirpath, file) for file in filenames] 33 | 34 | filelist = sorted(filelist) 35 | for f in filelist: 36 | basename = os.path.basename(f).lower() 37 | if basename.endswith('.jpg') or basename.endswith('.png') or basename.endswith( 38 | '.jpeg') or basename.endswith('.bmp') or basename.endswith('.wbpp'): 39 | cover_path = f 40 | break 41 | return cover_path 42 | -------------------------------------------------------------------------------- /bdnex/conf/bedetheque_sitemap.json: -------------------------------------------------------------------------------- 1 | { 2 | "sitemaps": { 3 | "0": "https://www.bedetheque.com/albums_1_10000_map.xml", 4 | "1": "https://www.bedetheque.com/albums_10001_20000_map.xml", 5 | "2": "https://www.bedetheque.com/albums_20001_30000_map.xml", 6 | "3": "https://www.bedetheque.com/albums_30001_40000_map.xml", 7 | "4": "https://www.bedetheque.com/albums_40001_50000_map.xml", 8 | "5": "https://www.bedetheque.com/albums_50001_60000_map.xml", 9 | "6": "https://www.bedetheque.com/albums_60001_70000_map.xml", 10 | "7": "https://www.bedetheque.com/albums_70001_80000_map.xml", 11 | "8": "https://www.bedetheque.com/albums_80001_90000_map.xml", 12 | "9": "https://www.bedetheque.com/albums_90001_100000_map.xml", 13 | "10": "https://www.bedetheque.com/albums_100001_150000_map.xml", 14 | 15 | 16 | "10": "https://www.bedetheque.com/albums_140001_150000_map.xml", 17 | "11": "https://www.bedetheque.com/albums_150001_160000_map.xml", 18 | "12": "https://www.bedetheque.com/albums_160001_170000_map.xml", 19 | "3": "https://www.bedetheque.com/albums_180001_190000_map.xml", 20 | "4": "https://www.bedetheque.com/albums_20001_30000_map.xml", 21 | "5": "https://www.bedetheque.com/albums_230001_240000_map.xml", 22 | "6": "https://www.bedetheque.com/albums_240001_250000_map.xml", 23 | "7": "https://www.bedetheque.com/albums_260001_270000_map.xml", 24 | "8": "https://www.bedetheque.com/albums_270001_280000_map.xml", 25 | "9": "https://www.bedetheque.com/albums_280001_290000_map.xml", 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/.local/share/bdnex/bedetheque/albums_json/BD-Nains-de-jardin-Premiere-brouette-31026.html.json: -------------------------------------------------------------------------------- 1 | { 2 | "3_éditions": "06/2015 -1- Soleil Productions\n06/2015 -1a2016- Soleil Productions\n10/2018 -1TL- Soleil Productions", 3 | "Autres_info": "", 4 | "Couleurs": "Digikore Studios", 5 | "Couverture": "Goux, Pierre-Denis", 6 | "Créé_le": "(maj 03/10/2021 22:29:29)", 7 | "Dessin": "Goux, Pierre-Denis", 8 | "Dépot_légal": "(Parution le 03/06/2015)", 9 | "Editeur": "Soleil Productions", 10 | "Estimation": "non coté", 11 | "Format": "Grand format", 12 | "ISBN": "978-2-302-04644-3", 13 | "Identifiant": "245127", 14 | "Note": "", 15 | "Origine": "Europe", 16 | "Planches": "56", 17 | "Scénario": "Jarry, Nicolas", 18 | "Style": "Fantasy", 19 | "Série": "Nains", 20 | "Titre": "Redwin de la Forge", 21 | "Tome": "1", 22 | "album_url": "https://m.bedetheque.com/BD-Nains-de-jardin-Premiere-brouette-31026.html", 23 | "cover_url": "https://www.bedetheque.com/media/Couvertures/Couv_245127.jpg", 24 | "description": "Redwin, fils d'Ulrog, a grandi auprès d'un père aimant et attentif à son apprentissage de la forge. Mais, autrefois admiré de tous, Ulrog ne veut plus créer d'armes runiques. À compter de ce jour, Ulrog le forgeron est devenu Ulrog le Lâche.\nHumilié, fou de rage, Redwin est prêt à tout pour s'éloigner de son père et devenir un seigneur des runes : le maître forgeron et maître combattant de l'ordre de la Forge.\nContre la volonté de son père, il se rend à la forteresse-état retrouver son oncle, un Vénérable de l'Ordre qui accepte de lui enseigner le combat et la forge d'armes.\nPourtant ses victoires ne lui apportent aucune paix, aucun répit, bien au contraire, sa haine envers son père grandit de jour en jour.\nDévoré par sa propre colère, Redwin deviendra seigneur des runes. Loin d'être un aboutissement, ça sera le début d'un long calvaire..." 25 | } 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from setuptools import setup 6 | 7 | 8 | def _read(fn): 9 | path = os.path.join(os.path.dirname(__file__), fn) 10 | return open(path).read() 11 | 12 | 13 | setup( 14 | name='bdnex', 15 | version='0.1', 16 | description='BD (french comic) tagger and library organizer', 17 | author='Laurent Besnard', 18 | author_email='besnard.laurent@gmail.com', 19 | url='https://bdnex.io/', 20 | license='MIT', 21 | platforms='ALL', 22 | long_description=_read('README.md'), 23 | test_suite='test', 24 | zip_safe=False, 25 | include_package_data=True, # Install plugin resources. 26 | 27 | packages=[ 28 | 'bdnex', 29 | 'bdnex.conf', 30 | 'bdnex.lib', 31 | 'bdnex.ui' 32 | ], 33 | package_data={ # Optional 34 | "bdnex.conf": ["*.json", 35 | "*.ini", 36 | "ComicInfo.xsd"], 37 | }, 38 | entry_points={ 39 | 'console_scripts': [ 40 | 'bdnex = bdnex.ui:main', 41 | ], 42 | }, 43 | 44 | install_requires=[ 45 | 'InquirerPy', 46 | 'argparse', 47 | 'beautifulsoup4', 48 | 'duckduckgo-search', 49 | 'html5lib', # bs4 dependency 50 | 'imutils', 51 | 'lxml', # bs4 dependency 52 | 'numpy', 53 | 'opencv-contrib-python-headless', 54 | 'pandas', 55 | 'patool', 56 | 'pyyaml', 57 | 'rapidfuzz', 58 | 'rarfile', 59 | 'tenacity', 60 | 'termcolor', 61 | 'thefuzz', 62 | 'xmldiff', 63 | 'xmlschema', 64 | ], 65 | 66 | extras_require={ 67 | 'dev': [ 68 | 'pytest', 69 | 'ipdb' 70 | ] 71 | }, 72 | 73 | tests_require=[ 74 | 'pytest', 75 | 'ipdb' 76 | ], 77 | 78 | classifiers=[ 79 | 'License :: OSI Approved :: MIT License', 80 | 'Environment :: Console', 81 | 'Environment :: Web Environment', 82 | 'Programming Language :: Python', 83 | 'Programming Language :: Python :: 3', 84 | 'Programming Language :: Python :: 3.8', 85 | 'Programming Language :: Python :: 3.9', 86 | 'Programming Language :: Python :: Implementation :: CPython', 87 | ], 88 | ) 89 | -------------------------------------------------------------------------------- /test/.local/share/bdnex/bedetheque/albums_json/BD-Nains-Tome-1-Redwin-de-la-Forge-245127.html.json: -------------------------------------------------------------------------------- 1 | { 2 | "3_éditions": "06/2015 -1- Soleil Productions\n06/2015 -1a2016- Soleil Productions\n10/2018 -1TL- Soleil Productions", 3 | "Autres_info": "", 4 | "Couleurs": "Digikore Studios", 5 | "Couverture": "Goux, Pierre-Denis", 6 | "Créé_le": "(maj 03/10/2021 22:29:29)", 7 | "Dessin": "Goux, Pierre-Denis", 8 | "Dépot_légal": "(Parution le 03/06/2015)", 9 | "Editeur": "Soleil Productions", 10 | "Estimation": "non coté", 11 | "Format": "Grand format", 12 | "ISBN": "978-2-302-04644-3", 13 | "Identifiant": "245127", 14 | "Note": 4.25, 15 | "Origine": "Europe", 16 | "Planches": 56, 17 | "Scénario": "Jarry, Nicolas", 18 | "Style": "Fantasy", 19 | "Série": "Nains", 20 | "Titre": "Redwin de la Forge", 21 | "Tome": 1, 22 | "album_url": "https://m.bedetheque.com/BD-Nains-Tome-1-Redwin-de-la-Forge-245127.html", 23 | "cover_url": "https://www.bedetheque.com/media/Couvertures/Couv_245127.jpg", 24 | "description": "Redwin, fils d'Ulrog, a grandi auprès d'un père aimant et attentif à son apprentissage de la forge. Mais, autrefois admiré de tous, Ulrog ne veut plus créer d'armes runiques. À compter de ce jour, Ulrog le forgeron est devenu Ulrog le Lâche.\nHumilié, fou de rage, Redwin est prêt à tout pour s'éloigner de son père et devenir un seigneur des runes : le maître forgeron et maître combattant de l'ordre de la Forge.\nContre la volonté de son père, il se rend à la forteresse-état retrouver son oncle, un Vénérable de l'Ordre qui accepte de lui enseigner le combat et la forge d'armes.\nPourtant ses victoires ne lui apportent aucune paix, aucun répit, bien au contraire, sa haine envers son père grandit de jour en jour.\nDévoré par sa propre colère, Redwin deviendra seigneur des runes. Loin d'être un aboutissement, ça sera le début d'un long calvaire...\n Redwin, fils d'Ulrog, a grandi auprès d'un père aimant et attentif à son apprentissage de la forge. Mais, autrefois admiré de tous, Ulrog ne veut plus créer d'armes runiques. À compter de ce jour, Ulrog le forgeron est devenu Ulrog le Lâche.\nHumilié, fou de rage, Redwin est prêt à tout pour s'éloigner de son père et devenir un seigneur des runes : le maître forgeron et maître combattant de l'ordre de la Forge.\nContre la volonté de son père, il se rend à la forteresse-état retrouver son oncle, un Vénérable de l'Ordre qui accepte de lui enseigner le combat et la forge d'armes.\nPourtant ses victoires ne lui apportent aucune paix, aucun répit, bien au contraire, sa haine envers son père grandit de jour en jour.\nDévoré par sa propre colère, Redwin deviendra seigneur des runes. Loin d'être un aboutissement, ça sera le début d'un long calvaire..." 25 | } -------------------------------------------------------------------------------- /bdnex/lib/cover.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os.path 3 | 4 | import cv2 5 | import imutils 6 | from termcolor import colored 7 | 8 | from bdnex.lib.utils import download_link 9 | 10 | 11 | def get_bdgest_cover(cover_url): 12 | logger = logging.getLogger(__name__) 13 | 14 | cover_name = os.path.basename(cover_url) 15 | os.path.join(os.environ["HOME"], '.local/share/bdnex/bedetheque/') 16 | covers_local_path = os.path.join(os.environ["HOME"], '.local/share/bdnex/bedetheque/covers') 17 | cover_local_path = os.path.join(covers_local_path, cover_name) 18 | 19 | if os.path.exists(cover_local_path): 20 | logger.debug(f'Cover {cover_local_path} already downloaded') 21 | 22 | return cover_local_path 23 | else: 24 | logger.debug(f'Cover missing. Downloading {cover_url}') 25 | 26 | cover_web_fp = download_link(cover_url, covers_local_path) 27 | return cover_web_fp 28 | 29 | 30 | def front_cover_similarity(original, image_to_compare): 31 | """ 32 | check similarity between images 33 | inspired from pysource website 34 | :param original: 35 | :param image_to_compare: 36 | :return: percentage of confidence of similarity 37 | """ 38 | logger = logging.getLogger(__name__) 39 | logger.info('Checking Cover from input file with online cover') 40 | 41 | original_cv = cv2.imread(original, 0) # convert to grayscale 42 | image_to_compare_cv = cv2.imread(image_to_compare, 0) # convert to grayscale 43 | 44 | # resize the images to make them small in size. A bigger size image may take a significant time 45 | # more computing power and time 46 | original_cv = imutils.resize(original_cv, height=600) 47 | image_to_compare_cv = imutils.resize(image_to_compare_cv, height=600) 48 | 49 | # Check for similarities between the 2 images 50 | sift = cv2.xfeatures2d.SIFT_create() 51 | kp_1, desc_1 = sift.detectAndCompute(original_cv, None) 52 | kp_2, desc_2 = sift.detectAndCompute(image_to_compare_cv, None) 53 | 54 | index_params = dict(algorithm=0, trees=5) 55 | search_params = dict() 56 | flann = cv2.FlannBasedMatcher(index_params, search_params) 57 | 58 | matches = flann.knnMatch(desc_1, desc_2, k=2) 59 | 60 | good_points = [] 61 | for m, n in matches: 62 | if m.distance < 0.6*n.distance: 63 | good_points.append(m) 64 | 65 | # Define how similar they are 66 | number_keypoints = 0 67 | if len(kp_1) <= len(kp_2): 68 | number_keypoints = len(kp_1) 69 | else: 70 | number_keypoints = len(kp_2) 71 | 72 | try: 73 | match_percentage = len(good_points) / number_keypoints * 100 74 | text = colored(f'{match_percentage}', 'red', attrs=['bold']) 75 | except Exception as err: 76 | logger.error(f"{err}. Covers couldn't be compared") 77 | return 0 78 | 79 | logger.info(f'Cover matching percentage: {text}') 80 | 81 | return match_percentage 82 | -------------------------------------------------------------------------------- /bdnex/ui/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import logging 4 | import shutil 5 | 6 | from bdnex.lib.archive_tools import archive_get_front_cover 7 | from bdnex.lib.bdgest import BdGestParse 8 | from bdnex.lib.comicrack import comicInfo 9 | from bdnex.lib.cover import front_cover_similarity, get_bdgest_cover 10 | from bdnex.lib.utils import yesno, args, bdnex_config 11 | from pathlib import Path 12 | from termcolor import colored 13 | 14 | 15 | def add_metadata_from_bdgest(filename): 16 | bdnex_conf = bdnex_config() 17 | 18 | logger = logging.getLogger(__name__) 19 | start_separator = colored(f'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', 20 | 'red', attrs=['bold']) 21 | 22 | logger.info(start_separator) 23 | logger.info(f"Processing {filename}") 24 | 25 | album_name = os.path.splitext(os.path.basename(filename))[0] 26 | bdgest_meta, comicrack_meta = BdGestParse().parse_album_metadata_mobile(album_name) 27 | 28 | cover_archive_fp = archive_get_front_cover(filename) 29 | cover_web_fp = get_bdgest_cover(bdgest_meta["cover_url"]) 30 | 31 | percentage_similarity = front_cover_similarity(cover_archive_fp, cover_web_fp) 32 | 33 | if percentage_similarity > bdnex_conf['cover']['match_percentage']: 34 | comicInfo(filename, comicrack_meta).append_comicinfo_to_archive() 35 | else: 36 | logger.warning("UserPrompt required") 37 | ans = yesno("Cover matching confidence is low. Do you still want to append the metadata to the file?") 38 | if ans: 39 | comicInfo(filename, comicrack_meta).append_comicinfo_to_archive() 40 | else: 41 | logger.info(f"Looking manually for {colored(os.path.basename(filename), 'red', attrs=['bold'])}") 42 | album_url = BdGestParse().search_album_from_sitemaps_interactive() 43 | 44 | bdgest_meta, comicrack_meta = BdGestParse().parse_album_metadata_mobile(album_name, album_url=album_url) 45 | comicInfo(filename, comicrack_meta).append_comicinfo_to_archive() 46 | 47 | cover_path = Path(cover_archive_fp).parent.as_posix() 48 | shutil.rmtree(cover_path) 49 | 50 | logger.info(f"Processing album done") 51 | 52 | 53 | def main(): 54 | vargs = args() 55 | 56 | if vargs.init: 57 | BdGestParse().download_sitemaps() 58 | 59 | if vargs.input_dir: 60 | dirpath = vargs.input_dir 61 | 62 | files = [] 63 | 64 | for path in Path(dirpath).rglob('*.cbz'): 65 | files.append(path.absolute().as_posix()) 66 | 67 | for path in Path(dirpath).rglob('*.cbr'): 68 | files.append(path.absolute().as_posix()) 69 | 70 | for file in files: 71 | try: 72 | add_metadata_from_bdgest(file) 73 | except: 74 | logger = logging.getLogger(__name__) 75 | logger.error(f"{file} couldn't be processed") 76 | 77 | elif vargs.input_file: 78 | file = vargs.input_file 79 | add_metadata_from_bdgest(file) 80 | 81 | 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![BDneX](https://github.com/lbesnard/bdnex/actions/workflows/test.yml/badge.svg) 2 | [![codecov](https://codecov.io/gh/lbesnard/bdnex/branch/main/graph/badge.svg?token=V9WJWRCTK5)](https://codecov.io/gh/lbesnard/bdnex) 3 | 4 | BDneX french comics tagger and library manager (POF at this stage) 5 | 6 | ### Motivation 7 | Contrary to music tagging, there is no agreed standard vocabulary for comics 8 | tagging in general. However the ComicRack standard is used by most library 9 | managers such as [Komga](https://komga.org/) 10 | 11 | A few teams are working on metadata for American comics, such as [comic tagger](https://github.com/comictagger/comictagger) 12 | This tool retrieves data from the ComicVine REST API [Comic Vine](https://comicvine.gamespot.com). 13 | However it is mostly for american comics, and only the most famous french ones 14 | are represented. 15 | 16 | BDneX comes here to hopefully fill the gap, with search capabilities of metadata, 17 | which then can be added to **CBZ** and **CBR** file format. 18 | 19 | Why doing this? 20 | On big libraries, it becomes easy then to find a book, based on its genre, 21 | community score, author, colorist, penciller! 22 | 23 | Read List can then be generated and more easily shared accross the community as 24 | based on metadata and not an obscure filename. 25 | 26 | ### Current Features 27 | - retrieve sitemaps from bedetheque.com 28 | - levenhstein fuzzy string matching to find album name on external website 29 | (since no API is available) 30 | - alternatively, there is currently a duckduckgo search, but will probably be 31 | deprecated 32 | - Parse content of webpage with beautifulSoup 33 | - convert parsed metadata into ComicInfo.xsd template 34 | - Image comparaison between online cover and archive cover to bring confidence 35 | into creating metadata file 36 | 37 | ### Roadmap (?) 38 | Further Feature(?): 39 | - SQLight database to keep record of already processed data 40 | - Interactive mode 41 | - catalog manager 42 | - renaming convention, based on user conf in ~/.local/bdnex/bdnex.ini 43 | - add more "API", fmor bdfuge ... 44 | - resume 45 | 46 | Get inspiration from beets music manager: [beets](https://github.com/beetbox/beets) 47 | 48 | 49 | ## Installation 50 | 51 | It is recommended to create a virtual environmnent with Conda 52 | ```commandline 53 | conda env create --file=environment.yml 54 | conda activate bdnex 55 | ``` 56 | 57 | User mode: 58 | ``` 59 | pip install . 60 | ``` 61 | 62 | Dev mode: 63 | ``` 64 | pip install -e .[dev] 65 | ``` 66 | 67 | 68 | ## Examples: 69 | 70 | ``` 71 | bdnex -f /tmp/ # folder containing albums 72 | ``` 73 | 74 | ```commandline 75 | 2022-07-22 02:22:28,605 - INFO - bdnex.ui - Processing /tmp/dummy.cbz 76 | 2022-07-22 02:22:28,605 - INFO - bdnex.lib.bdgest - Searching for "dummuy"" in bedetheque.com sitemap files 77 | 2022-07-22 02:22:28,605 - DEBUG - bdnex.lib.bdgest - Searching for "dummy"" in bedetheque.com sitemap files [FAST VERSION] 78 | 2022-07-22 02:22:28,605 - DEBUG - bdnex.lib.bdgest - Merging sitemaps 79 | 2022-07-22 02:22:32,993 - DEBUG - bdnex.lib.bdgest - Match album name succeeded 80 | 2022-07-22 02:22:32,993 - DEBUG - bdnex.lib.bdgest - Levenhstein score: 53.333333333333336 81 | 2022-07-22 02:22:32,993 - DEBUG - bdnex.lib.bdgest - Matched url: https://m.bedetheque.com/BD-dummy.html 82 | 2022-07-22 02:22:32,993 - DEBUG - bdnex.lib.bdgest - Parsing JSON metadata from already parsed web page ~/.local/share/bdnex/bedetheque/albums_json/BD-dummy.json 83 | 2022-07-22 02:22:33,002 - INFO - bdnex.lib.bdgest - Converting parsed metadata to ComicRack template 84 | 2022-07-22 02:22:33,011 - DEBUG - bdnex.lib.cover - Cover ~/.local/share/bdnex/bedetheque/covers/Couv_dummy.jpg already downloaded 85 | 2022-07-22 02:22:33,011 - INFO - bdnex.lib.cover - Checking Cover from input file with online cover 86 | 2022-07-22 02:22:33,442 - INFO - bdnex.lib.cover - Cover matching percentage: 44.9264705882353 87 | 2022-07-22 02:22:33,442 - INFO - bdnex.lib.comicrack - Add ComicInfo.xml to /tmp/dummy.cbz 88 | 2022-07-22 02:22:33,442 - INFO - bdnex.lib.comicrack - Create ComicInfo.xml 89 | 2022-07-22 02:22:33,444 - INFO - bdnex.lib.comicrack - Successfully appended ComicInfo.xml to /tmp/dummy.cbz 90 | 2022-07-22 02:22:33,445 - INFO - bdnex.ui - Processing album done 91 | ... 92 | ``` 93 | -------------------------------------------------------------------------------- /bdnex/lib/colargulog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.handlers 3 | import re 4 | 5 | """ 6 | source from https://medium.com/analytics-vidhya/python-logging-colorize-your-arguments-41567a754ac 7 | """ 8 | 9 | class ColorCodes: 10 | grey = "\x1b[38;21m" 11 | green = "\x1b[1;32m" 12 | yellow = "\x1b[33;21m" 13 | red = "\x1b[31;21m" 14 | bold_red = "\x1b[31;1m" 15 | blue = "\x1b[1;34m" 16 | light_blue = "\x1b[1;36m" 17 | purple = "\x1b[1;35m" 18 | reset = "\x1b[0m" 19 | 20 | 21 | class ColorizedArgsFormatter(logging.Formatter): 22 | arg_colors = [ColorCodes.purple, ColorCodes.light_blue] 23 | level_fields = ["levelname", "levelno"] 24 | level_to_color = { 25 | logging.DEBUG: ColorCodes.grey, 26 | logging.INFO: ColorCodes.green, 27 | logging.WARNING: ColorCodes.yellow, 28 | logging.ERROR: ColorCodes.red, 29 | logging.CRITICAL: ColorCodes.bold_red, 30 | } 31 | 32 | def __init__(self, fmt: str): 33 | super().__init__() 34 | self.level_to_formatter = {} 35 | 36 | def add_color_format(level: int): 37 | color = ColorizedArgsFormatter.level_to_color[level] 38 | _format = fmt 39 | for fld in ColorizedArgsFormatter.level_fields: 40 | search = "(%\(" + fld + "\).*?s)" 41 | _format = re.sub(search, f"{color}\\1{ColorCodes.reset}", _format) 42 | formatter = logging.Formatter(_format) 43 | self.level_to_formatter[level] = formatter 44 | 45 | add_color_format(logging.DEBUG) 46 | add_color_format(logging.INFO) 47 | add_color_format(logging.WARNING) 48 | add_color_format(logging.ERROR) 49 | add_color_format(logging.CRITICAL) 50 | 51 | @staticmethod 52 | def rewrite_record(record: logging.LogRecord): 53 | if not BraceFormatStyleFormatter.is_brace_format_style(record): 54 | return 55 | 56 | msg = record.msg 57 | msg = msg.replace("{", "_{{") 58 | msg = msg.replace("}", "_}}") 59 | placeholder_count = 0 60 | # add ANSI escape code for next alternating color before each formatting parameter 61 | # and reset color after it. 62 | while True: 63 | if "_{{" not in msg: 64 | break 65 | color_index = placeholder_count % len(ColorizedArgsFormatter.arg_colors) 66 | color = ColorizedArgsFormatter.arg_colors[color_index] 67 | msg = msg.replace("_{{", color + "{", 1) 68 | msg = msg.replace("_}}", "}" + ColorCodes.reset, 1) 69 | placeholder_count += 1 70 | 71 | record.msg = msg.format(*record.args) 72 | record.args = [] 73 | 74 | def format(self, record): 75 | orig_msg = record.msg 76 | orig_args = record.args 77 | formatter = self.level_to_formatter.get(record.levelno) 78 | self.rewrite_record(record) 79 | formatted = formatter.format(record) 80 | record.msg = orig_msg 81 | record.args = orig_args 82 | return formatted 83 | 84 | 85 | class BraceFormatStyleFormatter(logging.Formatter): 86 | def __init__(self, fmt: str): 87 | super().__init__() 88 | self.formatter = logging.Formatter(fmt) 89 | 90 | @staticmethod 91 | def is_brace_format_style(record: logging.LogRecord): 92 | if len(record.args) == 0: 93 | return False 94 | 95 | msg = record.msg 96 | if '%' in msg: 97 | return False 98 | 99 | count_of_start_param = msg.count("{") 100 | count_of_end_param = msg.count("}") 101 | 102 | if count_of_start_param != count_of_end_param: 103 | return False 104 | 105 | if count_of_start_param != len(record.args): 106 | return False 107 | 108 | return True 109 | 110 | @staticmethod 111 | def rewrite_record(record: logging.LogRecord): 112 | if not BraceFormatStyleFormatter.is_brace_format_style(record): 113 | return 114 | 115 | record.msg = record.msg.format(*record.args) 116 | record.args = [] 117 | 118 | def format(self, record): 119 | orig_msg = record.msg 120 | orig_args = record.args 121 | self.rewrite_record(record) 122 | formatted = self.formatter.format(record) 123 | 124 | # restore log record to original state for other handlers 125 | record.msg = orig_msg 126 | record.args = orig_args 127 | return formatted -------------------------------------------------------------------------------- /bdnex/lib/comicrack.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import json 3 | import logging 4 | import os 5 | import shutil 6 | import tempfile 7 | import xml.etree.ElementTree as ET 8 | 9 | import patoolib 10 | import rarfile 11 | import xmlschema 12 | from pkg_resources import resource_filename 13 | from termcolor import colored 14 | from xmldiff import formatting 15 | from xmldiff import main 16 | 17 | from bdnex.lib.utils import yesno 18 | 19 | COMICINFO_TEMPLATE = resource_filename(__name__, "../conf/ComicInfo.xsd") 20 | 21 | 22 | class comicInfo(): 23 | def __init__(self, input_filename=None, comic_info=None): 24 | self.input_filename = input_filename 25 | self.comic_info = comic_info 26 | self.logger = logging.getLogger(__name__) 27 | 28 | def comicInfo_xml_create(self): 29 | self.logger.info("Create ComicInfo.xml") 30 | 31 | tmpdir = tempfile.mkdtemp() 32 | comic_info_fp = os.path.join(tmpdir, 'ComicInfo.xml') 33 | 34 | schema = xmlschema.XMLSchema(COMICINFO_TEMPLATE) 35 | 36 | data = json.dumps(self.comic_info, default=str, sort_keys=True) 37 | tmp_xml = xmlschema.from_json(data, preserve_root=True, schema=schema) 38 | ET.ElementTree(tmp_xml).write(comic_info_fp, encoding='UTF-8', xml_declaration=True) 39 | 40 | return comic_info_fp 41 | 42 | def append_comicinfo_to_archive(self): 43 | self.logger.info("Add ComicInfo.xml to {album_name}".format(album_name=self.input_filename)) 44 | 45 | comic_info_fp = self.comicInfo_xml_create() 46 | 47 | extracted_dir = tempfile.mkdtemp() 48 | extracted_dir = os.path.join(extracted_dir, os.path.basename(os.path.splitext(self.input_filename)[0])) 49 | 50 | if patoolib.get_archive_format(self.input_filename)[0] == 'rar': # issue https://github.com/wummel/patool/pull/101 51 | rarfile.RarFile(self.input_filename).extractall(extracted_dir) 52 | else: 53 | patoolib.extract_archive(self.input_filename, outdir=extracted_dir, interactive=False) 54 | # keeping the same structure as the original archive 55 | new_archive_path = os.path.join(os.path.dirname(extracted_dir), 56 | os.path.basename(os.path.splitext(self.input_filename)[0]) + '.cbz') 57 | 58 | if os.path.exists(os.path.join(extracted_dir, 'ComicInfo.xml')): 59 | formatter = formatting.XmlDiffFormatter(pretty_print=True, normalize=formatting.WS_BOTH) 60 | diff = main.diff_files(os.path.join(extracted_dir, 'ComicInfo.xml'), comic_info_fp, formatter=formatter) 61 | 62 | diff = diff.replace('remove', colored(f'remove', 'red', attrs=['bold'])) 63 | diff = diff.replace('insert', colored(f'insert', 'green', attrs=['bold'])) 64 | diff = diff.replace('update', colored(f'update', 'yellow', attrs=['bold'])) 65 | diff = diff.replace('move-after', colored(f'move-after', 'yellow', attrs=['bold'])) 66 | diff = diff.replace('move-first', colored(f'move-first', 'yellow', attrs=['bold'])) 67 | 68 | self.logger.warning("Displaying difference between original and newly created ComicInfo.xml") 69 | self.logger.warning(diff) 70 | 71 | ans = yesno('ComicInfo.xml already exist, replace ? Y/N') 72 | if ans: 73 | os.remove(os.path.join(extracted_dir, 'ComicInfo.xml')) 74 | 75 | files_folders_to_add = glob.glob(glob.escape(extracted_dir) + '/*') # have to escape special characters otherwise the returned list is empty 76 | if files_folders_to_add == []: 77 | self.logger.error("new archive file counldn't be created, report bug") 78 | return 79 | 80 | patoolib.create_archive(new_archive_path, 81 | (comic_info_fp, *files_folders_to_add), 82 | interactive=False) 83 | 84 | # compare original and new archive file size to make sure we're not making a "bad" archive 85 | og_file_size = os.path.getsize(self.input_filename) 86 | new_file_size = os.path.getsize(new_archive_path) 87 | similarity = 1 - abs(og_file_size - new_file_size) / (og_file_size + new_file_size) 88 | 89 | if similarity < 0.9: 90 | self.logger.warning(f"New comic {new_archive_path} created is significantly smaller in size from original {self.input_filename}. Please check Manually") 91 | ans = yesno("Replace original comic file with new one") 92 | if ans is False: 93 | return 94 | 95 | else: 96 | self.logger.info("Original file not modified") 97 | shutil.rmtree(os.path.dirname(comic_info_fp)) 98 | shutil.rmtree(os.path.dirname(extracted_dir)) 99 | return 100 | else: 101 | files_folders_to_add = glob.glob(extracted_dir + '/*') 102 | patoolib.create_archive(new_archive_path, 103 | (comic_info_fp, *files_folders_to_add), 104 | interactive=False) 105 | 106 | if not patoolib.test_archive(new_archive_path): 107 | shutil.copy2(new_archive_path, os.path.dirname(self.input_filename)) 108 | self.logger.info(f"Created new {os.path.basename(new_archive_path)} with ComicInfo.xml") 109 | 110 | if os.path.basename(new_archive_path) != os.path.basename(self.input_filename): 111 | ans = yesno(f"Removing original {self.input_filename} replaced by cbz equivalent ?") 112 | if ans: 113 | os.remove(self.input_filename) 114 | else: 115 | self.logger.error(f"Created corrupted cbz archive. report bug") 116 | 117 | shutil.rmtree(os.path.dirname(comic_info_fp)) 118 | shutil.rmtree(os.path.dirname(extracted_dir)) 119 | -------------------------------------------------------------------------------- /bdnex/lib/utils.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import contextlib 3 | import json 4 | import logging 5 | import logging.config 6 | import os 7 | import shutil 8 | import sys 9 | import tempfile 10 | import urllib.request 11 | 12 | import yaml 13 | from pkg_resources import resource_filename 14 | 15 | from bdnex.lib.colargulog import ColorizedArgsFormatter 16 | 17 | LOGGING_CONF = resource_filename('bdnex', "/conf/logging.conf") 18 | DEFAULT_CONFIG_YAML = resource_filename('bdnex', "/conf/bdnex.yaml") 19 | UNIX_DIR_VAR = 'XDG_CONFIG_HOME' 20 | UNIX_DIR_FALLBACK = '~/.config' 21 | 22 | 23 | def dump_json(json_path, json_data): 24 | with open(json_path, "w") as outfile: 25 | json.dump(json_data, outfile, indent=4, 26 | sort_keys=True, ensure_ascii=False) 27 | 28 | 29 | def load_json(json_path): 30 | logger = logging.getLogger(__name__) 31 | 32 | if os.path.exists(json_path): 33 | logger.debug(f"Loading JSON: {json_path}") 34 | 35 | with open(json_path) as f: 36 | return json.load(f) 37 | else: 38 | logger.error(f"{json_path} does not exist") 39 | return 40 | 41 | 42 | def yesno(question): 43 | """Simple Yes/No Function.""" 44 | prompt = f'{question} ? (y/n): ' 45 | ans = input(prompt).strip().lower() 46 | if ans not in ['y', 'n']: 47 | print(f'{ans} is invalid, please try again...') 48 | return yesno(question) 49 | if ans == 'y': 50 | return True 51 | return False 52 | 53 | 54 | def enter_album_url(): 55 | 56 | prompt = "Please enter manually a valid bedetheque mobile url starting with https://m.bedetheque.com/ " 57 | ans = input(prompt).strip().lower() 58 | 59 | ans = ans.replace("https://www.bedetheque.com/", "https://m.bedetheque.com/") 60 | 61 | iter = 0 62 | while not ans.startswith('https://m.bedetheque.com/') and iter < 2: # TODO: could modify this to replace www. with m. 63 | prompt = "Please enter manually a valid bedetheque mobile url" 64 | ans = input(prompt).strip().lower().replace("https://www.bedetheque.com/", "https://m.bedetheque.com/") 65 | iter += 1 66 | 67 | if 'ans' in locals(): 68 | return ans 69 | else: 70 | logger = logging.getLogger(__name__) 71 | logger.error("No valid url was entered") 72 | 73 | return 74 | 75 | 76 | def download_link(url, output_folder=None): 77 | if output_folder is None: 78 | output_folder = tempfile.mkdtemp() 79 | else: 80 | if not os.path.exists(output_folder): 81 | os.makedirs(output_folder) 82 | 83 | urllib.request.urlretrieve(url, os.path.join(output_folder, os.path.basename(url))) 84 | 85 | return os.path.join(output_folder, os.path.basename(url)) 86 | 87 | 88 | def init_logging(): 89 | root_logger = logging.getLogger() 90 | root_logger.setLevel(logging.DEBUG) 91 | 92 | console_level = "DEBUG" 93 | console_handler = logging.StreamHandler(stream=sys.stdout) 94 | 95 | console_handler.setLevel(console_level) 96 | 97 | console_format = "%(asctime)s - %(levelname)-8s:L%(lineno)s - %(name)-5s - %(message)s" 98 | colored_formatter = ColorizedArgsFormatter(console_format) 99 | console_handler.setFormatter(colored_formatter) 100 | root_logger.addHandler(console_handler) 101 | 102 | 103 | def _init_config(): 104 | if UNIX_DIR_VAR in os.environ: 105 | bdnex_user_path = os.path.join(os.environ[UNIX_DIR_VAR], 106 | 'bdnex') 107 | else: 108 | bdnex_user_path = os.path.join(os.environ[UNIX_DIR_FALLBACK], 109 | 'bdnex') 110 | user_config_path = os.path.join(bdnex_user_path, 111 | 'bdnex.yaml') 112 | 113 | if os.path.exists(bdnex_user_path): 114 | if os.path.exists(user_config_path): 115 | return user_config_path 116 | else: 117 | shutil.copy(DEFAULT_CONFIG_YAML, user_config_path) 118 | return user_config_path 119 | else: 120 | os.makedirs(bdnex_user_path) 121 | shutil.copy(DEFAULT_CONFIG_YAML, user_config_path) 122 | return _init_config() 123 | 124 | 125 | def bdnex_config(): 126 | """ 127 | Parse bdnex configuration 128 | Returns: dictionary containing configuration 129 | 130 | """ 131 | config = yaml.safe_load(open(_init_config())) 132 | 133 | return config 134 | 135 | 136 | def args(): 137 | """ 138 | Returns the script arguments 139 | 140 | Parameters: 141 | 142 | Returns: 143 | vargs (obj): input arguments 144 | """ 145 | parser = argparse.ArgumentParser(description='BD metadata retriever') 146 | parser.add_argument('-f', '--input-file', dest='input_file', type=str, default=None, 147 | help="BD file path", 148 | required=False) 149 | 150 | parser.add_argument('-d', '--input-dir', dest='input_dir', type=str, default=None, 151 | help="BD dir path to process", 152 | required=False) 153 | 154 | parser.add_argument('-i', '--init', dest='init', 155 | help="initialise or force bdnex to download sitemaps from bedetheque for album matching", 156 | required=False) 157 | 158 | parser.add_argument('-v', 159 | '--verbose', 160 | default='info', 161 | help='Provide logging level. default=info') 162 | 163 | init_logging() 164 | 165 | logging.info('Logging now setup.') 166 | 167 | vargs = parser.parse_args() 168 | 169 | if 'vargs.input_file' in locals(): 170 | if not os.path.exists(vargs.input_file): 171 | raise ValueError('{path} not a valid path'.format(path=vargs.input_file)) 172 | 173 | if 'vargs.input_dir' in locals(): 174 | if not os.path.exists(vargs.input_dir): 175 | raise ValueError('{path} not a valid path'.format(path=vargs.input_dir)) 176 | 177 | return vargs 178 | 179 | 180 | @contextlib.contextmanager 181 | def temporary_directory(*args, **kwargs): 182 | d = tempfile.mkdtemp(*args, **kwargs) 183 | try: 184 | yield d 185 | finally: 186 | shutil.rmtree(d) 187 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | lib/__pycache__/ 21 | lib/__pypackages__/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # Byte-compiled / optimized / DLL files 133 | __pycache__/ 134 | *.py[cod] 135 | *$py.class 136 | 137 | # C extensions 138 | *.so 139 | 140 | # Distribution / packaging 141 | .Python 142 | build/ 143 | develop-eggs/ 144 | dist/ 145 | downloads/ 146 | eggs/ 147 | .eggs/ 148 | lib/ 149 | lib64/ 150 | parts/ 151 | sdist/ 152 | var/ 153 | wheels/ 154 | share/python-wheels/ 155 | *.egg-info/ 156 | .installed.cfg 157 | *.egg 158 | MANIFEST 159 | 160 | # PyInstaller 161 | # Usually these files are written by a python script from a template 162 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 163 | *.manifest 164 | *.spec 165 | 166 | # Installer logs 167 | pip-log.txt 168 | pip-delete-this-directory.txt 169 | 170 | # Unit test / coverage reports 171 | htmlcov/ 172 | .tox/ 173 | .nox/ 174 | .coverage 175 | .coverage.* 176 | .cache 177 | nosetests.xml 178 | coverage.xml 179 | *.cover 180 | *.py,cover 181 | .hypothesis/ 182 | .pytest_cache/ 183 | cover/ 184 | 185 | # Translations 186 | *.mo 187 | *.pot 188 | 189 | # Django stuff: 190 | *.log 191 | local_settings.py 192 | db.sqlite3 193 | db.sqlite3-journal 194 | 195 | # Flask stuff: 196 | instance/ 197 | .webassets-cache 198 | 199 | # Scrapy stuff: 200 | .scrapy 201 | 202 | # Sphinx documentation 203 | docs/_build/ 204 | 205 | # PyBuilder 206 | .pybuilder/ 207 | target/ 208 | 209 | # Jupyter Notebook 210 | .ipynb_checkpoints 211 | 212 | # IPython 213 | profile_default/ 214 | ipython_config.py 215 | 216 | # pyenv 217 | # For a library or package, you might want to ignore these files since the code is 218 | # intended to run in multiple environments; otherwise, check them in: 219 | # .python-version 220 | 221 | # pipenv 222 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 223 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 224 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 225 | # install all needed dependencies. 226 | #Pipfile.lock 227 | 228 | # poetry 229 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 230 | # This is especially recommended for binary packages to ensure reproducibility, and is more 231 | # commonly ignored for libraries. 232 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 233 | #poetry.lock 234 | 235 | # pdm 236 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 237 | #pdm.lock 238 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 239 | # in version control. 240 | # https://pdm.fming.dev/#use-with-ide 241 | .pdm.toml 242 | 243 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 244 | __pypackages__/ 245 | 246 | # Celery stuff 247 | celerybeat-schedule 248 | celerybeat.pid 249 | 250 | # SageMath parsed files 251 | *.sage.py 252 | 253 | # Environments 254 | .env 255 | .venv 256 | env/ 257 | venv/ 258 | ENV/ 259 | env.bak/ 260 | venv.bak/ 261 | 262 | # Spyder project settings 263 | .spyderproject 264 | .spyproject 265 | 266 | # Rope project settings 267 | .ropeproject 268 | 269 | # mkdocs documentation 270 | /site 271 | 272 | # mypy 273 | .mypy_cache/ 274 | .dmypy.json 275 | dmypy.json 276 | 277 | # Pyre type checker 278 | .pyre/ 279 | 280 | # pytype static type analyzer 281 | .pytype/ 282 | 283 | # Cython debug symbols 284 | cython_debug/ 285 | 286 | # PyCharm 287 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 288 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 289 | # and can be added to the global gitignore or merged into this file. For a more nuclear 290 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 291 | #.idea/ 292 | -------------------------------------------------------------------------------- /test/test_bdgest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import unittest 4 | from unittest.mock import patch, MagicMock 5 | 6 | from bdnex.lib.bdgest import BdGestParse 7 | 8 | ALBUM_URL_MATCH = { 9 | 'Nains-Redwin de la forge': "https://m.bedetheque.com/BD-Nains-Tome-1-Redwin-de-la-Forge-245127.html", 10 | 'Redwin de la forge': "https://m.bedetheque.com/BD-Nains-Tome-1-Redwin-de-la-Forge-245127.html", 11 | } 12 | 13 | SERIE_URL_MATCH = { 14 | 'Nains': "https://m.bedetheque.com/serie-47467-BD-Nains.html" 15 | } 16 | 17 | BEDETHEQUE_METADATA_HTML = os.path.join(os.path.dirname(__file__), 'mobile_redwin.html') # mocked html page 18 | 19 | 20 | def read_file_content(fp): 21 | with open(fp, 'r') as file: 22 | data = file.read() 23 | return data 24 | 25 | 26 | class TestBdGestParse(unittest.TestCase): 27 | def setUp(self): 28 | # mock patch at the class level 29 | # see https://stackoverflow.com/questions/25857655/django-tests-patch-object-in-all-tests 30 | self.patcher = patch('bdnex.lib.bdgest.bdnex_config') 31 | self.bdnex_config_mock = self.patcher.start() 32 | 33 | self.bdnex_config_mock.return_value = { 34 | "bdnex": {"share_path": os.path.join(os.path.dirname(os.path.realpath(__file__)), 35 | '.local/share/bdnex'), 36 | } 37 | } 38 | 39 | def tearDown(self): 40 | self.patcher.stop() 41 | 42 | def test_generate_sitemaps_url(self): 43 | urls = BdGestParse().generate_sitemaps_url() 44 | self.assertEqual('https://www.bedetheque.com/albums_50001_60000_map.xml', urls[5]) 45 | 46 | def test_concatenate_sitemaps_files(self): 47 | self.tempfile = BdGestParse().concatenate_sitemaps_files() 48 | 49 | with open(self.tempfile, 'r') as f: 50 | first_line = f.readline() 51 | 52 | expected_string = '\n' 55 | self.assertEqual(expected_string, first_line) 56 | 57 | def test_clean_sitemaps_urls(self): 58 | cleaned_list, urls_list = BdGestParse().clean_sitemaps_urls() 59 | self.assertEqual('mimura kataguri days of days of mimura kataguri', cleaned_list[0]) 60 | self.assertEqual('https://m.bedetheque.com/BD-Mimura-Kataguri-Days-of-Days-of-Mimura-Kataguri-240001.html', urls_list[0]) 61 | 62 | def test_remove_common_words_from_string(self): 63 | res = BdGestParse().remove_common_words_from_string("la MAisOn du lAc") 64 | self.assertEqual("maison lac", res) 65 | 66 | def test_search_album_from_sitemaps_fast(self): 67 | for album_name in ALBUM_URL_MATCH.keys(): 68 | res = BdGestParse().search_album_from_sitemaps_fast(album_name) 69 | self.assertEqual(ALBUM_URL_MATCH[album_name], res) 70 | 71 | def test_search_album_from_sitemaps_slow(self): 72 | for album_name in ALBUM_URL_MATCH.keys(): 73 | res = BdGestParse().search_album_from_sitemaps_slow(album_name) 74 | self.assertEqual(ALBUM_URL_MATCH[album_name], res) 75 | 76 | def test_search_album_url(self): 77 | for album_name in ALBUM_URL_MATCH.keys(): 78 | res = BdGestParse().search_album_url(album_name) 79 | self.assertEqual(ALBUM_URL_MATCH[album_name], res) 80 | 81 | @patch('urllib.request.urlopen') 82 | @patch('time.sleep', return_value=None) # mocking time as we're waiting some random seconds between each query to the remote website 83 | def test_parse_album_metadata_mobile_url(self, patched_time_sleep, mock_urlopen): 84 | time.sleep(60) # Should be instant 85 | cm = MagicMock() 86 | cm.getcode.return_value = 200 87 | cm.read.return_value = read_file_content(BEDETHEQUE_METADATA_HTML) 88 | cm.__enter__.return_value = cm 89 | mock_urlopen.return_value = cm 90 | 91 | # json file of parsed data already exist for this album 92 | album_meta_dict, comicrack_dict = \ 93 | BdGestParse().parse_album_metadata_mobile('Nains-Redwin de la forge') 94 | 95 | self.assertEqual("Redwin de la Forge", comicrack_dict["Title"]) 96 | self.assertEqual("Nains", comicrack_dict["Series"]) 97 | self.assertEqual("https://m.bedetheque.com/BD-Nains-Tome-1-Redwin-de-la-Forge-245127.html", comicrack_dict["Web"]) 98 | self.assertEqual(4.25, comicrack_dict["CommunityRating"]) 99 | 100 | # delete html and json from .local so we can test the other part of the function which is doing the parsing from scratch 101 | album_metadata_html_path = os.path.join(os.path.dirname(__file__), '.local/share/bdnex/bedetheque/albums_html') 102 | album_metadata_json_path = os.path.join(os.path.dirname(__file__), '.local/share/bdnex/bedetheque/albums_json') 103 | 104 | album_html_path = '{filepath}'.format(filepath=os.path.join(album_metadata_html_path, 105 | os.path.basename(album_meta_dict["album_url"]) 106 | )) 107 | album_json_path = '{filepath}.json'.format(filepath=os.path.join(album_metadata_json_path, 108 | os.path.basename(album_meta_dict["album_url"]) 109 | )) 110 | # remove the previously generated files 111 | os.remove(album_html_path) 112 | os.remove(album_json_path) 113 | 114 | # json file of parsed data already exist for this album 115 | album_meta_dict, comicrack_dict = \ 116 | BdGestParse().parse_album_metadata_mobile('Nains-Redwin de la forge') 117 | 118 | self.assertEqual("Redwin de la Forge", comicrack_dict["Title"]) 119 | self.assertEqual("Nains", comicrack_dict["Series"]) 120 | self.assertTrue(comicrack_dict["Summary"].startswith("Redwin,")) # this tests the function parse_serie_metadata_mobile 121 | 122 | # don't delete the html and json file so another part of the code can be tested 123 | 124 | @patch("bdnex.lib.bdgest.prompt") 125 | def test_search_album_from_sitemaps_interactive(self, mocked_prompt): 126 | mocked_prompt.return_value = [["love peach"]] 127 | res = BdGestParse().search_album_from_sitemaps_interactive() 128 | self.assertEqual("https://m.bedetheque.com/BD-Love-Peach-250200.html", res) 129 | 130 | 131 | if __name__ == '__main__': 132 | unittest.main() 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /bdnex/conf/ComicInfo.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /bdnex/lib/bdgest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import shutil 5 | import tempfile 6 | import time 7 | import urllib 8 | from datetime import datetime 9 | from functools import lru_cache 10 | from os import listdir 11 | from os.path import isfile, join 12 | from random import randint 13 | 14 | import bs4 15 | import dateutil.parser 16 | import pandas as pd 17 | import requests 18 | from InquirerPy import prompt 19 | from bs4 import BeautifulSoup 20 | from pkg_resources import resource_filename 21 | from rapidfuzz import fuzz 22 | from termcolor import colored 23 | 24 | from bdnex.lib.utils import dump_json, load_json, bdnex_config 25 | 26 | BDGEST_MAPPING = resource_filename('bdnex', "conf/bdgest_mapping.json") 27 | BDGEST_SITEMAPS = resource_filename('bdnex', "conf/bedetheque_sitemap.json") 28 | 29 | 30 | class BdGestParse: 31 | def __init__(self): 32 | self.logger = logging.getLogger(__name__) 33 | 34 | bdnex_conf = bdnex_config() 35 | share_path = os.path.expanduser(bdnex_conf['bdnex']['share_path']) 36 | 37 | self.bdnex_local_path = os.path.join(share_path, 'bedetheque/') 38 | if not os.path.exists(self.bdnex_local_path): 39 | os.makedirs(self.bdnex_local_path) 40 | 41 | self.sitemaps_path = os.path.join(self.bdnex_local_path, 'sitemaps') 42 | if not os.path.exists(self.sitemaps_path): 43 | os.makedirs(self.sitemaps_path) 44 | 45 | self.album_metadata_json_path = os.path.join(self.bdnex_local_path, 'albums_json') 46 | if not os.path.exists(self.album_metadata_json_path): 47 | os.makedirs(self.album_metadata_json_path) 48 | 49 | self.album_metadata_html_path = os.path.join(self.bdnex_local_path, 'albums_html') 50 | if not os.path.exists(self.album_metadata_html_path): 51 | os.makedirs(self.album_metadata_html_path) 52 | 53 | self.serie_metadata_json_path = os.path.join(self.bdnex_local_path, 'series_json') 54 | if not os.path.exists(self.serie_metadata_json_path): 55 | os.makedirs(self.serie_metadata_json_path) 56 | 57 | self.serie_metadata_html_path = os.path.join(self.bdnex_local_path, 'series_html') 58 | if not os.path.exists(self.serie_metadata_html_path): 59 | os.makedirs(self.serie_metadata_html_path) 60 | 61 | if len(os.listdir(self.sitemaps_path)) == 0: 62 | self.logger.info(f"No sitemaps exist yet. Downloading all available sitemaps locally to {self.sitemaps_path}") 63 | self.download_sitemaps() 64 | 65 | @staticmethod 66 | def generate_sitemaps_url(): 67 | """ 68 | Generate a list of sitemaps urls. Each url points to a sub sitemap file 69 | Returns: urls(list) : list of individual url of sitemap files 70 | 71 | """ 72 | urls = [] 73 | last_val = 0 74 | for i in range(47): 75 | val_min = 1 + last_val 76 | val_max = val_min + 9999 77 | last_val = val_max 78 | url = "https://www.bedetheque.com/albums_{val_min}_{val_max}_map.xml".format(val_min=val_min, 79 | val_max=val_max) 80 | urls.append(url) 81 | return urls 82 | 83 | def download_sitemaps(self): 84 | sitemaps_url = self.generate_sitemaps_url() 85 | 86 | for url in sitemaps_url: 87 | self.logger.info(f"Downloading all sitemaps from bedetheque.com {url}") 88 | 89 | r = requests.get(url, allow_redirects=True) 90 | 91 | open(os.path.join(self.sitemaps_path, os.path.basename(url)), 'wb').write(r.content) 92 | 93 | @lru_cache() 94 | def concatenate_sitemaps_files(self): 95 | self.logger.debug("Merging sitemaps") 96 | 97 | sitemaps_xml = [os.path.join(self.sitemaps_path, f) 98 | for f in listdir(self.sitemaps_path) if isfile(join(self.sitemaps_path, f))] 99 | 100 | sitemaps_xml.sort() 101 | 102 | if not sitemaps_xml: 103 | self.logger.error(f"No sitemaps files available in {self.sitemaps_path}") 104 | raise FileNotFoundError 105 | 106 | tmpfile_obj = tempfile.mkstemp() 107 | with open(tmpfile_obj[1], 'wb') as wfd: 108 | for f in sitemaps_xml: 109 | with open(f, 'rb') as fd: 110 | shutil.copyfileobj(fd, wfd) 111 | 112 | return tmpfile_obj[1] 113 | 114 | @lru_cache(maxsize=32) 115 | def clean_sitemaps_urls(self): 116 | tempfile_path = self.concatenate_sitemaps_files() 117 | 118 | with open(tempfile_path, 'r') as f: 119 | myNames = [line.strip() for line in f] 120 | 121 | # keep only mobile links 122 | stringlist = [x for x in myNames if "m.bedetheque.com/BD-" in x] 123 | 124 | # various string cleaning 125 | urls_list = [re.search(r"(?Phttps?://[^\s]+)", x).group("url").replace('"', '') for x in stringlist] 126 | cleansed = [x.replace('https://m.bedetheque.com/BD-', '').replace('.html', '').replace('-', ' ') 127 | for x in urls_list] 128 | 129 | cleansed = [ re.sub(r'\d+$', '', x) for x in cleansed ] # remove ending numbers 130 | # remove common french words. Will make levenshtein distance work better 131 | album_list = [] 132 | for val in cleansed: 133 | album_list.append(self.remove_common_words_from_string(val)) 134 | 135 | os.remove(tempfile_path) 136 | return album_list, urls_list 137 | 138 | @staticmethod 139 | def remove_common_words_from_string(string_to_clean): 140 | # remove common french words. Will make levenshtein distance work better 141 | stopwords = ['le', 'de', 'a', 'les', 'l', 'au', 'int', 'des', 'aut', 142 | 'du', 'tome', 'un', 'la', 'et', 'en', 'que', 'il', 'ne', 'se'] 143 | 144 | cleaned_string = [word.lower() for word in string_to_clean.split() if word.lower() not in stopwords] 145 | cleaned_string = ' '.join(cleaned_string) 146 | 147 | return cleaned_string 148 | 149 | def accept_match(self, match, threshold=30): 150 | if match[1] > threshold: 151 | url = match[2] 152 | match_score_text = colored(f'{match[1]}', 'red', attrs=['bold']) 153 | 154 | self.logger.debug(f"Match album name succeeded") 155 | self.logger.debug(f"Levenhstein score: {match_score_text}") 156 | self.logger.debug(f"Matched url: {url}") 157 | 158 | return True 159 | else: 160 | return False 161 | 162 | def search_album_from_sitemaps_fast(self, album_name): 163 | self.logger.debug(f"Searching for \"{album_name}\" in bedetheque.com sitemap files [FAST VERSION]") 164 | 165 | album_list, urls = self.clean_sitemaps_urls() 166 | album_name_simplified = self.remove_common_words_from_string(album_name) 167 | 168 | # faster but relies on matching first word from album name and assuming there is no mistake in it 169 | album_name_first_word = re.match(r'\W*(\w[^,-_. !?"]*)', album_name_simplified).groups()[0] 170 | 171 | test_album = [x for id,x in enumerate(album_list) if album_name_first_word in x] 172 | test_id = [id for id,x in enumerate(album_list) if album_name_first_word in x] 173 | 174 | df = [[x, fuzz.ratio(album_name, x)] for x in test_album] 175 | df = pd.DataFrame(df) 176 | df["urls"] = [urls[x] for x in test_id] 177 | 178 | try: 179 | match = df.sort_values([1], ascending=[False]).values[0] 180 | if self.accept_match(match): 181 | url = match[2] 182 | return url 183 | except Exception as err: 184 | self.logger.error("Fast search didn't provide any results") 185 | 186 | def search_album_from_sitemaps_interactive(self): 187 | # interactive fuzzy search for user prompt 188 | 189 | album_list, urls = self.clean_sitemaps_urls() 190 | 191 | questions = [ 192 | { 193 | "type": "fuzzy", 194 | "message": "Write & Select album name with >TAB: (avoid writting common articles such as [le ,la ,les, de, des ...]", 195 | "choices": album_list, 196 | "multiselect": True, 197 | "validate": lambda result: len(result) == 1, 198 | "invalid_message": "maximum 1 selection", 199 | "max_height": "70%", 200 | }, 201 | ] 202 | result = prompt(questions=questions) 203 | self.logger.info(f"Manual matching album {result[0][0]}") 204 | return urls[album_list.index(result[0][0])] 205 | 206 | def search_album_from_sitemaps_slow(self, album_name): 207 | self.logger.debug(f"Searching for \"{album_name}\" in bedetheque.com sitemap files [SLOW VERSION]") 208 | 209 | # slower, but should deal with mistakes maybe a bit better 210 | album_list, urls = self.clean_sitemaps_urls() 211 | album_name_simplified = self.remove_common_words_from_string(album_name) 212 | 213 | df = [[x, fuzz.ratio(album_name_simplified, x)] for x in album_list] 214 | df = pd.DataFrame(df) 215 | df["urls"] = [urls[x] for x in range(len(album_list))] 216 | 217 | match = df.sort_values([1], ascending=[False]).values[0] 218 | if self.accept_match(match): 219 | url = match[2] 220 | return url 221 | 222 | @lru_cache(maxsize=32) 223 | def search_album_url(self, album_name): 224 | self.logger.info(f"Searching for \"{album_name}\" in bedetheque.com sitemap files") 225 | 226 | url = self.search_album_from_sitemaps_fast(album_name) 227 | 228 | if not url: 229 | url = self.search_album_from_sitemaps_slow(album_name) 230 | 231 | self.album_url = url 232 | return url 233 | 234 | def parse_album_metadata_mobile(self, album_name, album_url=None): 235 | """ 236 | Parse a mobile version HTML file containing metadata of an album 237 | Args: 238 | album_name: 239 | 240 | Returns: 241 | 242 | """ 243 | # case when user enters manually a url 244 | if album_url: 245 | self.album_url = album_url 246 | else: 247 | self.search_album_url(album_name) 248 | 249 | album_meta_json_path = '{filepath}.json'.format(filepath=os.path.join(self.album_metadata_json_path, 250 | os.path.basename(self.album_url))) 251 | album_meta_html_path = os.path.join(self.album_metadata_html_path, 252 | os.path.basename(self.album_url)) 253 | 254 | if os.path.exists(album_meta_json_path): 255 | # deleting existing json, and re-recreating it to handle breaking code changes if they happen 256 | self.logger.debug(f"Deleting existing JSON metadata from already parsed web page {album_meta_json_path}") 257 | os.remove(album_meta_json_path) 258 | 259 | if os.path.exists(album_meta_html_path): 260 | self.logger.debug(f"Parsing HTML metadata from already downloaded web page {album_meta_html_path}") 261 | 262 | with open(album_meta_html_path) as fp: 263 | soup = BeautifulSoup(fp, 'html.parser') 264 | else: 265 | 266 | self.logger.debug(f"Parsing metadata from {self.album_url}") 267 | 268 | time.sleep(randint(3, 10)) # we don't want to be suspicious between queries 269 | 270 | url = urllib.request.urlopen(self.album_url) 271 | try: 272 | content = url.read().decode('utf8') 273 | except: 274 | content = url.read() # mainly for unittesting as content already decoded 275 | 276 | # save html content in .local for future re-parse if needed. reprocess can be achieved without 277 | # unnecessary loads on bedetheque.com risking IP ban 278 | with open(album_meta_html_path, 'w') as out_file: 279 | out_file.write(content) 280 | 281 | soup = BeautifulSoup(content, 'lxml') 282 | 283 | album_meta_dict = {} 284 | album_meta_dict['album_url'] = self.album_url 285 | 286 | for label in soup.select("label"): 287 | 288 | if label.contents: 289 | try: 290 | key = label.contents[0].split(':')[0].rstrip().replace(' ', '_') 291 | if "Note" in key: 292 | val = label.find_parent().contents[8] 293 | val = float(re.search(r'(\d+.*)/', val).group()[:-1]) 294 | 295 | elif label.find_next_sibling(): 296 | val = label.find_next_sibling().text.rstrip() 297 | 298 | else: 299 | if "Dépot" in key: 300 | val = label.find_parent().contents[2] 301 | else: 302 | val = label.find_parent().contents[1] 303 | 304 | if key == 'Série': 305 | try: 306 | series_href = label.find_parent().find_all(href=True)[0].get('href') # get series link 307 | except: 308 | pass 309 | album_meta_dict[key] = val 310 | except: 311 | pass 312 | 313 | cover_url = soup.find_all('img', alt=True)[1].attrs['src'] 314 | album_meta_dict['cover_url'] = cover_url 315 | self.logger.debug(cover_url) 316 | summary_extract = soup.find_all('span', attrs={"class": 'infoedition'}) 317 | for name in summary_extract: 318 | if 'Résumé' in name.contents[0].contents[0]: 319 | album_meta_dict["description"] = name.contents[1] 320 | 321 | for key in album_meta_dict.keys(): 322 | try: 323 | album_meta_dict[key] = album_meta_dict[key].strip('\n').rstrip().lstrip() 324 | except: 325 | pass 326 | 327 | if isinstance(album_meta_dict['Planches'], str): 328 | album_meta_dict['Planches'] = int(album_meta_dict['Planches']) 329 | 330 | if 'Tome' in album_meta_dict.keys(): 331 | if isinstance(album_meta_dict['Tome'], str): 332 | if not album_meta_dict['Tome'][0].isdigit(): # dealing with Hors-Serie or integral albums 333 | album_meta_dict['AlternateNumber'] = album_meta_dict['Tome'] 334 | del album_meta_dict['Tome'] 335 | else: 336 | regex = re.compile(r'(\d+|\s+)') 337 | r = regex.split(album_meta_dict['Tome']) 338 | tome = list(filter(None, r))[-1] 339 | 340 | album_meta_dict['Tome'] = int(tome) 341 | 342 | # remove bad metadata still containing an html tag,sign it was wrongly parsed 343 | key_to_remove = [] 344 | for key in album_meta_dict.keys(): 345 | if isinstance(album_meta_dict[key], bs4.element.Tag): 346 | self.logger.error(f"{key} info wrongly parsed and removed from parsed metadata. Lodge an issue") 347 | key_to_remove.append(key) 348 | if key_to_remove: 349 | for key in key_to_remove: 350 | album_meta_dict.pop(key) 351 | 352 | self.album_meta_dict = album_meta_dict 353 | 354 | # retrieving series information (abstract mainly) 355 | if 'Tome' in album_meta_dict.keys(): # this should mean this is a series 356 | if 'series_href' in locals(): 357 | series_meta_dict = self.parse_serie_metadata_mobile(series_href) 358 | if 'series_abstract' in series_meta_dict: 359 | series_abstract = series_meta_dict['series_abstract'] 360 | 361 | # append summary from series to album summary 362 | if 'description' in album_meta_dict: 363 | if 'series_abstract' in locals(): 364 | album_meta_dict['description'] = f"{series_abstract}\n {album_meta_dict['description']}" 365 | 366 | else: 367 | if 'series_abstract' in locals(): 368 | album_meta_dict['description'] = series_abstract 369 | 370 | comicrack_dict = self.comicinfo_metadata(album_meta_dict) 371 | 372 | album_name_colored = colored(f'{album_name}', 'magenta', attrs=['bold']) 373 | album_name_matched = colored(f'{album_meta_dict["Titre"]}', 'blue', attrs=['bold']) 374 | 375 | self.logger.debug(f"Matching {album_name_colored} with {album_name_matched}") 376 | 377 | try: 378 | dump_json(album_meta_json_path, album_meta_dict) 379 | except TypeError as err: 380 | os.remove(album_meta_json_path) 381 | self.logger.error(f"{err}. {album_meta_json_path} can not be written") 382 | 383 | return album_meta_dict, comicrack_dict 384 | 385 | def parse_serie_metadata_mobile(self, serie_url): 386 | """ 387 | Parse a mobile version HTML file containing metadata of an album 388 | Args: 389 | series_url: 390 | 391 | Returns: 392 | 393 | """ 394 | serie_meta_json_path = '{filepath}.json'.format(filepath=os.path.join(self.serie_metadata_json_path, 395 | os.path.basename(serie_url))) 396 | serie_meta_html_path = os.path.join(self.serie_metadata_html_path, 397 | os.path.basename(serie_url)) 398 | 399 | if os.path.exists(serie_meta_json_path): 400 | # deleting existing json, and re-recreating it to handle breaking code changes if they happen 401 | self.logger.debug(f"Deleting existing JSON metadata from already parsed web page {serie_meta_json_path}") 402 | os.remove(serie_meta_json_path) 403 | 404 | if os.path.exists(serie_meta_html_path): 405 | self.logger.debug(f"Parsing HTML metadata from already downloaded web page {serie_meta_html_path}") 406 | 407 | with open(serie_meta_html_path) as fp: 408 | soup = BeautifulSoup(fp, 'html.parser') 409 | else: 410 | 411 | self.logger.debug(f"Parsing metadata from {serie_url}") 412 | 413 | time.sleep(randint(3, 10)) # we don't want to be suspicious between queries 414 | 415 | url = urllib.request.urlopen(serie_url) 416 | try: 417 | content = url.read().decode('utf8') 418 | except: 419 | content = url.read() # mainly for unittesting as content already decoded 420 | 421 | # save html content in .local for future re-parse if needed. reprocess can be achieved without 422 | # unnecessary loads on bedetheque.com risking IP ban 423 | with open(serie_meta_html_path, 'w') as out_file: 424 | out_file.write(content) 425 | 426 | soup = BeautifulSoup(content, 'lxml') 427 | 428 | series_abstract = soup.find(id='full-commentaire').attrs['value'] 429 | series_meta_dict = {} 430 | series_meta_dict['series_abstract'] = series_abstract 431 | 432 | return series_meta_dict 433 | 434 | def comicinfo_metadata(self, metadata_dict): 435 | self.logger.info("Converting parsed metadata to ComicRack template") 436 | 437 | bdgest_mapping = load_json(BDGEST_MAPPING) 438 | comicrack_dict = {} 439 | for key in bdgest_mapping.keys(): 440 | if key in metadata_dict.keys(): 441 | comicrack_dict[bdgest_mapping[key]] = metadata_dict[key] 442 | 443 | try: 444 | published_date = dateutil.parser.parse(metadata_dict['Dépot_légal']) 445 | except dateutil.parser._parser.ParserError: 446 | try: 447 | published_date = datetime.strptime(metadata_dict['Dépot_légal'], '(Parution le %d/%m/%Y)') 448 | except Exception as err2: 449 | self.logger.error('{published_date}'.format(published_date=metadata_dict['Dépot_légal'])) 450 | except: 451 | self.logger.error('{published_date}'.format(published_date=metadata_dict['Dépot_légal'])) 452 | 453 | if "published_date" in locals(): 454 | comicrack_dict["Year"] = published_date.year 455 | comicrack_dict["Month"] = published_date.month 456 | comicrack_dict["Day"] = published_date.day 457 | 458 | return comicrack_dict 459 | 460 | 461 | -------------------------------------------------------------------------------- /test/.local/share/bdnex/bedetheque/series_html/serie-47467-BD-Nains.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Nains 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 73 | 74 | 75 |
76 | 94 | 95 | 103 | 104 |
105 | 106 | 107 |
108 | 109 |

Nains

110 | 111 |

Europe / Fantasy

112 | 113 |
114 | 115 | Français 2015-2022 En cours 116 |
117 |
118 | Nain 119 | Série concept 120 | Terres d'Arran 121 |
122 | 123 |

124 | 129 | Redwin, fils d'Ulrog, a grandi auprès d'un père aimant et attentif à son apprentissage de la forge. Mais, autrefois admiré de tous, Ulrog ne veut plus créer d'armes runiques. À compter de ce jour, Ulrog le forgeron est devenu Ulrog le Lâche. Humilié, fou de rage, Redwin est prêt à tout pour s'éloigner de son père et devenir un seigneur des runes : le maître forgeron et maître combattant de l'ordre... Lire la suite

130 | 131 | 132 |

26 albums :

133 | 396 | 397 | 398 |

A lire aussi :

399 | 443 | 444 | 445 |

Dans le même univers :

446 | 466 | 467 | 468 | 469 | 470 | 471 |
472 | 473 | 474 | 475 | 476 | 477 |
478 | 479 |
480 |

481 | BDGest 2014 - Tous droits réservés 482 |

483 | 484 |
485 | 486 |
487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | -------------------------------------------------------------------------------- /test/mobile_redwin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Nains -1- Redwin de la Forge 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 73 | 74 | 75 |
76 | 94 | 95 | 102 | 103 |
104 | 105 | 106 | 113 | 114 |
    115 | 116 |
  • Informations sur l'album
  • 117 |
  • 118 | 119 | 120 | Nains 121 |
  • 122 |
  • Redwin de la Forge
  • 123 |
  • 1
  • 124 |
  • 125 | 132 |
  • 133 | 134 | 135 |
  • 136 | 137 | 4.25/5 (105 votes) 138 |
  • 139 | 140 |
  • Europe
  • 141 |
  • Fantasy
  • 142 |
  • 245127
  • 143 |
  • 144 | 145 | 146 | 147 | Jarry, Nicolas 148 | 149 | 150 |
  • 151 |
  • 152 | 153 | 154 | 155 | Goux, Pierre-Denis 156 | 157 | 158 |
  • 159 |
  • 160 | 161 | 162 | 163 | Digikore Studios 164 | 165 | 166 |
  • 167 |
  • 168 | 169 | 170 | 171 | Goux, Pierre-Denis 172 | 173 | 174 |
  • 175 |
  • 176 | 177 | 178 | 179 | Saïto, Diogo 180 | 181 | 182 |
  • 183 |
  • 184 | 06/2015 (Parution le 03/06/2015)
  • 185 |
  • non coté
  • 186 |
  • Soleil Productions
  • 187 |
  • Grand format
  • 188 |
  • 978-2-302-04644-3
  • 189 |
  • 56
  • 190 |
  • 191 |
  • 192 | 05/05/2015 16:17:32 (maj 03/10/2021 22:29:29)
  • 193 |
  • Info édition : Noté "Première édition". Cahier graphique de 6 pages + glossaire d'une page. Avec vernis sélectif sur le 1er plat.
  • 194 | 195 |
  • Résumé: Redwin, fils d'Ulrog, a grandi auprès d'un père aimant et attentif à son apprentissage de la forge. Mais, autrefois admiré de tous, Ulrog ne veut plus créer d'armes runiques. À compter de ce jour, Ulrog le forgeron est devenu Ulrog le Lâche. 196 | Humilié, fou de rage, Redwin est prêt à tout pour s'éloigner de son père et devenir un seigneur des runes : le maître forgeron et maître combattant de l'ordre de la Forge. 197 | Contre la volonté de son père, il se rend à la forteresse-état retrouver son oncle, un Vénérable de l'Ordre qui accepte de lui enseigner le combat et la forge d'armes. 198 | Pourtant ses victoires ne lui apportent aucune paix, aucun répit, bien au contraire, sa haine envers son père grandit de jour en jour. 199 | Dévoré par sa propre colère, Redwin deviendra seigneur des runes. Loin d'être un aboutissement, ça sera le début d'un long calvaire...
  • 200 | 201 |
202 | 203 | Acheter sur Amazon 204 | Acheter sur BDfugue 205 | Acheter sur Rakuten 206 | 207 |
    208 |
  • La chronique
  • 209 | 210 |
  • 211 | 212 |

    Par O. Vrignon

    213 |
    214 | 215 | R 216 | edwin s’apprête à livrer son dernier combat. Quelle qu’en soit l'issue, pour lui la vie de seigneur des runes est désormais terminée. Il a consacré toute sa vie pour obtenir ce titre, le plus honorifique de l’ordre de la Forge, lui-même le plus craint et le plus respecté du peuple des Nains. Mais il l’a fait pour de mauvaises raisons et cela le ronge,, au point de lui rendre la vie insupportable. La richesse et la gloire ne sont rien lorsque l’on se renie.
    217 |
    218 | Voilà, à peine annonce-t-on la nouvelle de la sortie de Nains que cela ricane. Évidemment, les a priori s'accumulent : une série-concept, dans le genre fantasy et aux allures de spin-off d’Elfes (simple référence marketing, aucun lien n’apparaît). Grincheux va pouvoir s’en donner à cœur joie. Pourtant, cet aimable grognon ferait bien d’y regarder de plus près. Tout d’abord, parce que ce premier tome est dessiné par Pierre-Denis Goux, qui a déjà prouvé qu’il est très à l’aise dans ce style d’univers (Mjöllnir, Les Maîtres Inquisiteurs). Une fois encore, son travail donne vie à l’histoire, alliant précision et évocation. Son découpage et ses choix de cadres insufflent ce qu'il faut de dynamisme, sans jamais négliger les émotions des protagonistes.
    219 |
    220 | Cela tombe bien car de sentiments, le scénario n’en est pas avare. Il s’agit là du deuxième point fort. Conteur expérimenté, Nicolas Jarry offre un récit puissant, ne piochant pas dans les poncifs habituels. Point d’élu, de rites initiatiques, de mondes ou de trônes à sauver ou conquérir. S’il est bien question d’une quête, il s’agit de celle d’un être qui s’est perdu. Pour la narrer, le scénariste s’appuie sur une voix off à la première personne permettant au lecteur de partager le ressenti du personnage central qui revient sur ce qu’il a accompli, de plonger dans son intimité sans jamais que cela soit pesant. Au-delà de la violence et de l’âpreté de la vie au sein de la nation nain, la vraie puissance émotionnelle provient de l’ancrage dans la relation père/fils qui provoquera certainement quelques échos dans la vie de nombreux lecteurs.
    221 |
    222 | Fort bien écrit et mise en images, Redwin de la Forge est un album (auto-conclusif) de grande qualité. Nul ne peut prévoir ce que les quatre prochains tomes proposeront, mais là, le duo aux manettes envoie du lourd (non Grincheux, rien à voir avec le physique).
    223 | 224 | 225 |
  • 226 | 227 |
228 | 229 |
    230 |
  • La preview
  • 231 |
  • 232 | 233 | 255 |
  • 256 |
257 | 258 | 259 | 260 | 261 |
    262 |
  • Les avis
  • 263 |
  • 264 | 265 |

    Fradagast

    266 |
    267 |

    Le 25/02/2022 à 19:07:15

    268 | Les dessins sont grandioses avec des événements croisés sur la même page, le scénario est travaillé et s’appuie sur les moments récurrents de nombreux tomes de la série « nains » : enfance, combats, ascension. 269 | L’ambiance devient rapidement sombre et amère, dynamisée par l’ascension guerrière de Redwin. Les liens entre le héros et son entourage sont parmi les plus complexes et réalistes de toutes les histoires en terre d’Arran. 270 | Le final magistral allie violence, tristesse, rébellion et happy end. Cet album adulte lançait parfaitement l’ordre nain le plus intéressant et la série dans sa globalité. La récurrence du personnage au-delà de « nains » témoigne de cette réussite. 271 | 272 | 273 |
  • 274 |
  • 275 | 276 |

    Pulp_Sirius

    277 |
    278 |

    Le 23/11/2021 à 16:10:24

    279 | - Avis sur les 20 premiers albums - 280 | 281 | Ouf. Tant à dire sur cette série. Je ne suis pas grand fan de la série mère, Elfes, que je trouve trop souvent médiocre. Même constat pour Les brumes d'Asceltis, du même auteur, que j'avais trouvée tout aussi médiocre. J'ai donc entamé Nains avec une certaine appréhension. Autant dire que je ne m'attendais à rien de génial. Mais voilà, mais voilà... 282 | 283 | Tome 1 - J'ai trouvé cet album plutôt bon, mais j'avais trouvé l'histoire peu originale, déjà vue ailleurs. Et je trouvais le parler nain un peu lourd. 284 | Tome 2 - Moins bon que le premier, pas trop aimé. 285 | Tome 3 - Encore moins bon, je me suis ennuyé. 286 | 287 | C'était assez mal parti pour la série. Sans surprise, me suis-je dit alors. Mais comme il ne faut jamais abandonner... 288 | 289 | Tome 4 - OK!? C'est plutôt bon ça! 290 | Tome 5 - Excellent! 291 | 292 | Le parler nain, maintenant, je m'y suis fait. Ça donne de l'originalité à cette race de guerriers bâtisseurs nés. Et tout bascule. 293 | 294 | Tome 6 - Excellent!! 295 | Tome 7 - Excellent!! 296 | Tome 8 - Excellent! 297 | Tome 9 - Excellent! 298 | Tome 10 - Excellent!!! 299 | Tome 11 - Excellent!! 300 | Tome 12 - Excellent!! 301 | Tome 13 - Excellent! 302 | Tome 14 - Excellent! 303 | 304 | Tant de bons albums consécutifs! Est-ce vraiment possible? Ça ne pouvait durer! 305 | 306 | Tome 15 - Décevant. Beaucoup d'action, aucune profondeur. 307 | Tome 16 - Mauvais. Je déteste ces histoires qui divisent. Les hommes insultent les femmes, les femmes rabaissent les hommes pour s'élever. Très différent de Tiss ou de Fey, par exemple. Mais j'irai écrire un avis sur cet album plus tard. 308 | Tome 17 - Correct. Mais il est où mon album intelligent avec jeux de pouvoir et politique sur l'ordre du Talion!?? On a fait sauter un album de l'ordre du Talion!! J'en pleure. :( 309 | Tome 18 - Excellent!! Mais il aurait dû faire quelques pages de plus... 310 | Tome 19 - Archimauvais. De loin le pire album de la série. 311 | Tome 20 - Excellent!! 312 | 313 | Et je n'ai pas encore lu le tome 21. 314 | 315 | Vous comprendrez que, les cinq étoiles, c'est pour l'ensemble de la série. Et non pas pour chaque tome individuel. 316 | 317 | Est-ce bien vrai? Suis-je vraiment en train de lire du Nicolas Jarry? Mais qu'est-ce qui explique cette nette amélioration scénaristique? Sans aller jusqu'à dire que les meilleurs albums soient des chefs-d’œuvre, parce qu'il ne faudrait pas exagérer, j'ai quelques théories à ce sujet. 318 | 319 | 1) Est-ce l'absence absolue du nom de Jean-Luc Istin sur les couvertures de la série? 320 | 2) Est-ce parce que, justement, comme Jarry est "seul" à développer l'univers nain, ça lui permet de créer un monde plus cohérent, qui lui laisse la liberté de s'exprimer comme il l'entend? 321 | 3) Est-ce l'absence quasi totale de personnages tirés de la série mère ou des autres séries dérivées, qui briment la liberté artistique en forçant l'intégration de personnages qu'on ne veut pas nécessairement inclure dans l'histoire? 322 | 4) Est-ce la division nette et claire des différents ordres qui ne s'entremêlent pas au fil des albums, qui permet d'écrire des histoires plus concises, donc mieux structurées? J'ai vu plusieurs lecteurs se plaindre de ce format, mais selon moi le fil rouge de la série mère lui a plutôt nui, ruinant tantôt les Elfes blancs, tantôt les Elfes noirs. 323 | 5) Est-ce parce que, dans le fond, l'âme de Nicolas Jarry est véritablement celle d'un nain, endurcie au fil des tranchoirs, et qu'il porte plus d'amour envers cette petite race qu'envers les autres peuples d'Arran? 324 | 325 | Je ne saurais le dire. Mais peu importe les raisons, j'espère que ça va continuer comme ça. M. Jarry, continuez d'écrire des histoires complexes, réfléchies, bourrées de texte (pertinent), adultes, matures, qui nous empêchent de refermer l'album avant d'avoir tourné la dernière page. 326 | 327 | Alors non, tout n'est pas parfait. En passant par des citations probablement glanées sur Internet (voir tomes 12 & 13), à des erreurs de dessin sur un même album, à des fautes de grammaire aussi nombreuses que les étoiles du ciel, à une vulgarité des mots parfois excessive qui n'apporte rien du tout, à des dialogues parfois lourds et tarabiscotés - il y a toujours place à l'amélioration. 328 | 329 | Mais somme toute, ce que nous avons ici est généralement très bon, et de loin meilleur que tout ce qu'il y a à trouver sur les terres d'Arran. Bravo. En espérant que vos pognes, M. Jarry, celles qui étripaillent des crayons, puissent écrire encore longtemps! 330 | 331 | 332 |
  • 333 |
  • 334 | 335 |

    Saigneurdeguerre

    336 |
    337 |

    Le 13/04/2021 à 23:16:21

    338 | Moi, Redwin, fils d'Ulrog, je me demandais pourquoi mon père refusait-il toujours de m’enseigner la forge de bataille ? Pourquoi gâchait-il son talent à fabriquer des outils ou des bijoux, aussi magnifiques soient-ils ? Pourtant, autrefois, il était admiré de tous pour son incroyable talent à fabriquer des armes runiques. 339 | J’étais la risée des autres nains. J’étais le fils du lâche ! Rom, qui avait mon âge me rossait régulièrement. J’avais soif de vengeance. Je voulais devenir un Seigneur des Runes. 340 | A l’insu de mon père (et non à l’insu de mon plein gré), j’ai bâti ma propre forge et entrepris de fabriquer des armes, mais celles-ci étaient de bien médiocre qualité… Jusqu’au jour où mon oncle est venu me chercher. Forgeron talentueux, il m’apprit la forge de bataille et comment concevoir les meilleures armes. Mais pas que ! Il m’apprit à me battre car un Seigneur des Runes se doit de combattre. Me voilà prêt à affronter mes premiers adversaires, pour cela mon oncle m’a conduit dans une ville libre et indépendante, la Cité des Sang-Mêlé. Je vais devoir vaincre ou mourir… 341 | 342 | Critique : 343 | 344 | Voici le premier album de la série nains. Nicolas Jarry développe un scénario basé sur un conflit père-fils, mais je devrais plutôt dire fils-père. Un père qui aime son fils plus que tout et qui ne tient pas à ce qu’il gâche sa vie à courir derrière la gloire, les honneurs et l’argent. Un père qui veut avant tout que son fils soit heureux et ne se noie pas dans son orgueil. Mais comment son fils pourrait-il accepter d’être humilié par les gens de son âge qui ne voient pas en lui un vrai nain, encore moins un membre de l’Ordre de la Forge, le plus prestigieux des cinq ordres ? Sa décision de rompre avec son père et de lui garder une rancune tenace ne va-t-elle pas le mener à sa perte ? Enormément d’émotion dans cette bande dessinée. Il y a clairement du Freud dans cette histoire. J.L. Istin, l’homme qui scénarise plus vite que son ombre, a refilé cette idée originale à Nicolas Jarry qui l’a superbement développée. 345 | 346 | Les dessins de Pierre-Denis Goux sont d’un grand dynamisme et donnent une impression de mouvement très réussie. J’aime tout spécialement ses décors, comme c’est le cas dans la plupart des livres des aventures en Terres d’Arran. Mon bémol, ce sont les monstres. Je n’aime pas les monstruosités qui, avec la magie, inondent la fantasy. C’est que j’ai un petit cœur sensible, moi ! 347 | 348 | Les couleurs ont été confiées à Digikore Studios, où ses équipes ont fait de l’excellent travail. 349 | 350 | 351 |
  • 352 |
  • 353 | 354 |

    bristethobennic

    355 |
    356 |

    Le 27/08/2020 à 14:08:58

    357 | J'ai acquis hier ce premier tome de la série. J'ai beaucoup aimé. Le scénario est bon, les bulles et les dessins lui rendent parfaitement honneur. En tous cas, ce premier tome appelle l'achat du second, et ce, dès demain ! :-) Après avoir commencer " Elfes ", je crois que " Nains " sera bel et bien ma seconde collection aux éditions Soleil. 358 | 359 | 360 |
  • 361 |
  • 362 | 363 |

    hyansolo

    364 |
    365 |

    Le 23/06/2020 à 15:26:10

    366 | J'en suis à la 5ème relecture et je ne m'en lasse pas. Un tome indispensable à avoir dans sa bibliothèque. 367 | 368 | 369 |
  • 370 |
  • 371 | 372 |

    pysa

    373 |
    374 |

    Le 26/12/2017 à 19:13:13

    375 | Redwin s'apprête à devenir forgeron comme son père Ulrog mais il ne comprend pas que ce dernier refuse de forger des armes. Cédant à l'ambition, il suit son oncle et se lance dans un furie belliqueuse. Le scénario est bien maîtrisé et abouti, peut-être un peu bavard parfois. Les dessins sont précis et réussis. 376 | 377 | 378 |
  • 379 |
  • 380 | 381 |

    Fafnir

    382 |
    383 |

    Le 21/10/2017 à 00:09:00

    384 | Un univers à l'inspiration assumée. Cependant ça ne s'arrête pas là, les grands thèmes et clichés sont appropriés et développés pour notre plus grand plaisir. Ces nains grincheux et leur état d'esprit, mais aussi leur forge runique (époustouflante). Tout ces éléments réunis dans une très belle histoire de filiation, d'ambition, de vengeance, sur un tempo épique et magique. Seul petit bémol à mon goût, un aspect parfois un peut trop caricatural et excessif, qui se laisse aisément pardonner. 385 | 386 | Sans leur être réservé, les amateurs d'univers type Seigneur des anneaux, et plus particulièrement de Warhammer peuvent y aller les yeux fermés (enfin même si ce serait un peu dommage vu le dessin). 387 | 388 | 389 |
  • 390 |
  • 391 | 392 |

    Dunyre

    393 |
    394 |

    Le 12/05/2017 à 20:25:39

    395 | Une claque... vraiment. Cette histoire est celle d'une relation père-fils, marquée par une opposition initiale entre un père marqué par de grands principes et un fils adolescent en quête d'un autre univers. À côté de cela se trouve une histoire de vengeance entre jeunes gens, l'un promis à un grand avenir et de ce fait toisant l'autre, le fameux Redwin, qui est un "fils de lâche" 396 | 397 | Parti en rébellion auprès de son oncle afin d'assouvir sa soif de découverte, le jeune Redwin va se rendre compte que tout a un prix, en particulier lorsqu'il s'agit d'une quete de vengeance. 398 | 399 | Au final, la fin de l'histoire (sans rien dévoiler) amène à une vraie réflexion sur le sens de la vie et sur les relations filiales. Vraiment très puissant émotionnellement pour moi. 400 | 401 | Les dessins sont superbes comme toujours chez Mr Goux et donnent une profondeur incroyable à toute l'épopée de Redwin. 402 | 403 | Sûrement le meilleur tome de cette série, que je recommande tout particulièrement aux jeunes gens en rébellion contre les principes de leur paternel, tout comme aux pères ayant à gérer ce type de conflits... 404 | 405 | Une ode sur la vie en somme. 406 | 407 | 408 |
  • 409 |
  • 410 | 411 |

    norius

    412 |
    413 |

    Le 20/12/2016 à 00:30:56

    414 | Le premier d'une longue série qui j’espère sera longue. Cet album met bien en place le monde dans lequel les prochains l'album. l'histoire est prenante meme si je reste sur ma faim a la derniere case ... 415 | 416 | 417 |
  • 418 |
  • 419 | 420 |

    judoc

    421 |
    422 |

    Le 23/08/2016 à 14:51:49

    423 | L'un des meilleurs albums de l'univers des "Terres d'Arran". Des dessins somptueux et un scénario noir et violent qui font de Redwin un personnage des plus emblématiques. 424 | 425 | A ne pas rater ! 426 | 427 | 428 |
  • 429 |
  • 430 | 431 |

    Powerslater10

    432 |
    433 |

    Le 04/08/2016 à 22:31:24

    434 | Le fait que Nains soit dans le même univers qu'Elfe est super-intéressant, de plus on rencontre certains nains dans Elfes dont ce fameux Redwin. Les dessins sont traches mais avec une précision assez remarquable et agréable, une BD qui met environ 1 heure 1 heure et demie si on veut apprécier la BD, personnellement je l'ont relirais bien une deuxième fois, elle est à lire absolument ! 435 | 436 | 437 |
  • 438 |
  • 439 | 440 |

    Rody Sansei

    441 |
    442 |

    Le 06/12/2015 à 18:53:07

    443 | Une nouvelle série de one-shots dans la veine de Elfes ou Oracles, dessinée par le talentueux Pierre-Denis Goux (Mjollnir). C'est brutal, bien raconté, prometteur pour la suite. Ce premier tome m'a bien plus convaincu que les "Maîtres Inquisiteurs" de Peru/Goux, série à one-shots elle aussi toute récente. 444 | 445 | 446 |
  • 447 |
  • 448 | 449 |

    darwin03

    450 |
    451 |

    Le 29/07/2015 à 16:41:46

    452 | Tres bon debut de serie Il va etre difficile de faire mieux je suis fan de MR Goux son dessin est top Le scenario va crescendo Que de plaisir dans la vision et la lecture de cet album 453 | 454 | 455 |
  • 456 |
  • 457 | 458 |

    Captainaja

    459 |
    460 |

    Le 27/07/2015 à 20:46:01

    461 | Une série qui débute de la plus belle des manières. Son héros Redwin de la forge est charismatique et nous transporte avec lui dans son ascension ou sa descente (ça dépend comment nous voyons les choses). Très agréablement surpris par cet album avec de très beaux dessins et un scénario qui gagne en profondeur au fil des pages. Un très bon moment 462 | 463 | 464 |
  • 465 |
  • 466 | 467 |

    Docteur Parangon

    468 |
    469 |

    Le 13/06/2015 à 19:19:49

    470 | Une très belle couverture gaufrée nous présente Redwin de la Forge, le héros de ce premier tome. Il est sombre, violent et orgueilleux. Son passé nous est conté pour mieux comprendre le présent. 471 | Le scénario se dévoile peu à peu en nous faisant entrer dans le monde d'un seigneur des runes. Un Nain qui forge aussi bien qu’il combat. La magie runique est intéressante avec un univers recherché. On sent qu'il y a eu un travail en amont pour avoir une cohérence, comme en témoigne le cahier graphique, les cinq ordres nains et le lexique. Il y a quelques clins d’œil à la série des Elfes. 472 | Tout le récit de ce tome est centré sur Redwin et sa volonté de se dépasser, quelles qu'en soient les conséquences. En tournant les pages, l’histoire s’étoffe et gagne en profondeur. 473 | Le dessin est bon avec des traits anguleux collant bien au thème "nain" et les décors sont soignés. La couleur est excellente avec des effets de lumière qui pose l'ambiance. 474 | Ce oneshot ouvre la série des Nains avec brio. 475 | 476 | 477 |
  • 478 |
479 |
480 | 481 |
482 |

483 | BDGest 2014 - Tous droits réservés 484 |

485 | 486 |
487 | 488 |
489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | -------------------------------------------------------------------------------- /test/.local/share/bdnex/bedetheque/albums_html/BD-Nains-Tome-1-Redwin-de-la-Forge-245127.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Nains -1- Redwin de la Forge 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 73 | 74 | 75 |
76 | 94 | 95 | 102 | 103 |
104 | 105 | 106 | 113 | 114 |
    115 | 116 |
  • Informations sur l'album
  • 117 |
  • 118 | 119 | 120 | Nains 121 |
  • 122 |
  • Redwin de la Forge
  • 123 |
  • 1
  • 124 |
  • 125 | 132 |
  • 133 | 134 | 135 |
  • 136 | 137 | 4.25/5 (105 votes) 138 |
  • 139 | 140 |
  • Europe
  • 141 |
  • Fantasy
  • 142 |
  • 245127
  • 143 |
  • 144 | 145 | 146 | 147 | Jarry, Nicolas 148 | 149 | 150 |
  • 151 |
  • 152 | 153 | 154 | 155 | Goux, Pierre-Denis 156 | 157 | 158 |
  • 159 |
  • 160 | 161 | 162 | 163 | Digikore Studios 164 | 165 | 166 |
  • 167 |
  • 168 | 169 | 170 | 171 | Goux, Pierre-Denis 172 | 173 | 174 |
  • 175 |
  • 176 | 177 | 178 | 179 | Saïto, Diogo 180 | 181 | 182 |
  • 183 |
  • 184 | 06/2015 (Parution le 03/06/2015)
  • 185 |
  • non coté
  • 186 |
  • Soleil Productions
  • 187 |
  • Grand format
  • 188 |
  • 978-2-302-04644-3
  • 189 |
  • 56
  • 190 |
  • 191 |
  • 192 | 05/05/2015 16:17:32 (maj 03/10/2021 22:29:29)
  • 193 |
  • Info édition : Noté "Première édition". Cahier graphique de 6 pages + glossaire d'une page. Avec vernis sélectif sur le 1er plat.
  • 194 | 195 |
  • Résumé: Redwin, fils d'Ulrog, a grandi auprès d'un père aimant et attentif à son apprentissage de la forge. Mais, autrefois admiré de tous, Ulrog ne veut plus créer d'armes runiques. À compter de ce jour, Ulrog le forgeron est devenu Ulrog le Lâche. 196 | Humilié, fou de rage, Redwin est prêt à tout pour s'éloigner de son père et devenir un seigneur des runes : le maître forgeron et maître combattant de l'ordre de la Forge. 197 | Contre la volonté de son père, il se rend à la forteresse-état retrouver son oncle, un Vénérable de l'Ordre qui accepte de lui enseigner le combat et la forge d'armes. 198 | Pourtant ses victoires ne lui apportent aucune paix, aucun répit, bien au contraire, sa haine envers son père grandit de jour en jour. 199 | Dévoré par sa propre colère, Redwin deviendra seigneur des runes. Loin d'être un aboutissement, ça sera le début d'un long calvaire...
  • 200 | 201 |
202 | 203 | Acheter sur Amazon 204 | Acheter sur BDfugue 205 | Acheter sur Rakuten 206 | 207 |
    208 |
  • La chronique
  • 209 | 210 |
  • 211 | 212 |

    Par O. Vrignon

    213 |
    214 | 215 | R 216 | edwin s’apprête à livrer son dernier combat. Quelle qu’en soit l'issue, pour lui la vie de seigneur des runes est désormais terminée. Il a consacré toute sa vie pour obtenir ce titre, le plus honorifique de l’ordre de la Forge, lui-même le plus craint et le plus respecté du peuple des Nains. Mais il l’a fait pour de mauvaises raisons et cela le ronge,, au point de lui rendre la vie insupportable. La richesse et la gloire ne sont rien lorsque l’on se renie.
    217 |
    218 | Voilà, à peine annonce-t-on la nouvelle de la sortie de Nains que cela ricane. Évidemment, les a priori s'accumulent : une série-concept, dans le genre fantasy et aux allures de spin-off d’Elfes (simple référence marketing, aucun lien n’apparaît). Grincheux va pouvoir s’en donner à cœur joie. Pourtant, cet aimable grognon ferait bien d’y regarder de plus près. Tout d’abord, parce que ce premier tome est dessiné par Pierre-Denis Goux, qui a déjà prouvé qu’il est très à l’aise dans ce style d’univers (Mjöllnir, Les Maîtres Inquisiteurs). Une fois encore, son travail donne vie à l’histoire, alliant précision et évocation. Son découpage et ses choix de cadres insufflent ce qu'il faut de dynamisme, sans jamais négliger les émotions des protagonistes.
    219 |
    220 | Cela tombe bien car de sentiments, le scénario n’en est pas avare. Il s’agit là du deuxième point fort. Conteur expérimenté, Nicolas Jarry offre un récit puissant, ne piochant pas dans les poncifs habituels. Point d’élu, de rites initiatiques, de mondes ou de trônes à sauver ou conquérir. S’il est bien question d’une quête, il s’agit de celle d’un être qui s’est perdu. Pour la narrer, le scénariste s’appuie sur une voix off à la première personne permettant au lecteur de partager le ressenti du personnage central qui revient sur ce qu’il a accompli, de plonger dans son intimité sans jamais que cela soit pesant. Au-delà de la violence et de l’âpreté de la vie au sein de la nation nain, la vraie puissance émotionnelle provient de l’ancrage dans la relation père/fils qui provoquera certainement quelques échos dans la vie de nombreux lecteurs.
    221 |
    222 | Fort bien écrit et mise en images, Redwin de la Forge est un album (auto-conclusif) de grande qualité. Nul ne peut prévoir ce que les quatre prochains tomes proposeront, mais là, le duo aux manettes envoie du lourd (non Grincheux, rien à voir avec le physique).
    223 | 224 | 225 |
  • 226 | 227 |
228 | 229 |
    230 |
  • La preview
  • 231 |
  • 232 | 233 | 255 |
  • 256 |
257 | 258 | 259 | 260 | 261 |
    262 |
  • Les avis
  • 263 |
  • 264 | 265 |

    Fradagast

    266 |
    267 |

    Le 25/02/2022 à 19:07:15

    268 | Les dessins sont grandioses avec des événements croisés sur la même page, le scénario est travaillé et s’appuie sur les moments récurrents de nombreux tomes de la série « nains » : enfance, combats, ascension. 269 | L’ambiance devient rapidement sombre et amère, dynamisée par l’ascension guerrière de Redwin. Les liens entre le héros et son entourage sont parmi les plus complexes et réalistes de toutes les histoires en terre d’Arran. 270 | Le final magistral allie violence, tristesse, rébellion et happy end. Cet album adulte lançait parfaitement l’ordre nain le plus intéressant et la série dans sa globalité. La récurrence du personnage au-delà de « nains » témoigne de cette réussite. 271 | 272 | 273 |
  • 274 |
  • 275 | 276 |

    Pulp_Sirius

    277 |
    278 |

    Le 23/11/2021 à 16:10:24

    279 | - Avis sur les 20 premiers albums - 280 | 281 | Ouf. Tant à dire sur cette série. Je ne suis pas grand fan de la série mère, Elfes, que je trouve trop souvent médiocre. Même constat pour Les brumes d'Asceltis, du même auteur, que j'avais trouvée tout aussi médiocre. J'ai donc entamé Nains avec une certaine appréhension. Autant dire que je ne m'attendais à rien de génial. Mais voilà, mais voilà... 282 | 283 | Tome 1 - J'ai trouvé cet album plutôt bon, mais j'avais trouvé l'histoire peu originale, déjà vue ailleurs. Et je trouvais le parler nain un peu lourd. 284 | Tome 2 - Moins bon que le premier, pas trop aimé. 285 | Tome 3 - Encore moins bon, je me suis ennuyé. 286 | 287 | C'était assez mal parti pour la série. Sans surprise, me suis-je dit alors. Mais comme il ne faut jamais abandonner... 288 | 289 | Tome 4 - OK!? C'est plutôt bon ça! 290 | Tome 5 - Excellent! 291 | 292 | Le parler nain, maintenant, je m'y suis fait. Ça donne de l'originalité à cette race de guerriers bâtisseurs nés. Et tout bascule. 293 | 294 | Tome 6 - Excellent!! 295 | Tome 7 - Excellent!! 296 | Tome 8 - Excellent! 297 | Tome 9 - Excellent! 298 | Tome 10 - Excellent!!! 299 | Tome 11 - Excellent!! 300 | Tome 12 - Excellent!! 301 | Tome 13 - Excellent! 302 | Tome 14 - Excellent! 303 | 304 | Tant de bons albums consécutifs! Est-ce vraiment possible? Ça ne pouvait durer! 305 | 306 | Tome 15 - Décevant. Beaucoup d'action, aucune profondeur. 307 | Tome 16 - Mauvais. Je déteste ces histoires qui divisent. Les hommes insultent les femmes, les femmes rabaissent les hommes pour s'élever. Très différent de Tiss ou de Fey, par exemple. Mais j'irai écrire un avis sur cet album plus tard. 308 | Tome 17 - Correct. Mais il est où mon album intelligent avec jeux de pouvoir et politique sur l'ordre du Talion!?? On a fait sauter un album de l'ordre du Talion!! J'en pleure. :( 309 | Tome 18 - Excellent!! Mais il aurait dû faire quelques pages de plus... 310 | Tome 19 - Archimauvais. De loin le pire album de la série. 311 | Tome 20 - Excellent!! 312 | 313 | Et je n'ai pas encore lu le tome 21. 314 | 315 | Vous comprendrez que, les cinq étoiles, c'est pour l'ensemble de la série. Et non pas pour chaque tome individuel. 316 | 317 | Est-ce bien vrai? Suis-je vraiment en train de lire du Nicolas Jarry? Mais qu'est-ce qui explique cette nette amélioration scénaristique? Sans aller jusqu'à dire que les meilleurs albums soient des chefs-d’œuvre, parce qu'il ne faudrait pas exagérer, j'ai quelques théories à ce sujet. 318 | 319 | 1) Est-ce l'absence absolue du nom de Jean-Luc Istin sur les couvertures de la série? 320 | 2) Est-ce parce que, justement, comme Jarry est "seul" à développer l'univers nain, ça lui permet de créer un monde plus cohérent, qui lui laisse la liberté de s'exprimer comme il l'entend? 321 | 3) Est-ce l'absence quasi totale de personnages tirés de la série mère ou des autres séries dérivées, qui briment la liberté artistique en forçant l'intégration de personnages qu'on ne veut pas nécessairement inclure dans l'histoire? 322 | 4) Est-ce la division nette et claire des différents ordres qui ne s'entremêlent pas au fil des albums, qui permet d'écrire des histoires plus concises, donc mieux structurées? J'ai vu plusieurs lecteurs se plaindre de ce format, mais selon moi le fil rouge de la série mère lui a plutôt nui, ruinant tantôt les Elfes blancs, tantôt les Elfes noirs. 323 | 5) Est-ce parce que, dans le fond, l'âme de Nicolas Jarry est véritablement celle d'un nain, endurcie au fil des tranchoirs, et qu'il porte plus d'amour envers cette petite race qu'envers les autres peuples d'Arran? 324 | 325 | Je ne saurais le dire. Mais peu importe les raisons, j'espère que ça va continuer comme ça. M. Jarry, continuez d'écrire des histoires complexes, réfléchies, bourrées de texte (pertinent), adultes, matures, qui nous empêchent de refermer l'album avant d'avoir tourné la dernière page. 326 | 327 | Alors non, tout n'est pas parfait. En passant par des citations probablement glanées sur Internet (voir tomes 12 & 13), à des erreurs de dessin sur un même album, à des fautes de grammaire aussi nombreuses que les étoiles du ciel, à une vulgarité des mots parfois excessive qui n'apporte rien du tout, à des dialogues parfois lourds et tarabiscotés - il y a toujours place à l'amélioration. 328 | 329 | Mais somme toute, ce que nous avons ici est généralement très bon, et de loin meilleur que tout ce qu'il y a à trouver sur les terres d'Arran. Bravo. En espérant que vos pognes, M. Jarry, celles qui étripaillent des crayons, puissent écrire encore longtemps! 330 | 331 | 332 |
  • 333 |
  • 334 | 335 |

    Saigneurdeguerre

    336 |
    337 |

    Le 13/04/2021 à 23:16:21

    338 | Moi, Redwin, fils d'Ulrog, je me demandais pourquoi mon père refusait-il toujours de m’enseigner la forge de bataille ? Pourquoi gâchait-il son talent à fabriquer des outils ou des bijoux, aussi magnifiques soient-ils ? Pourtant, autrefois, il était admiré de tous pour son incroyable talent à fabriquer des armes runiques. 339 | J’étais la risée des autres nains. J’étais le fils du lâche ! Rom, qui avait mon âge me rossait régulièrement. J’avais soif de vengeance. Je voulais devenir un Seigneur des Runes. 340 | A l’insu de mon père (et non à l’insu de mon plein gré), j’ai bâti ma propre forge et entrepris de fabriquer des armes, mais celles-ci étaient de bien médiocre qualité… Jusqu’au jour où mon oncle est venu me chercher. Forgeron talentueux, il m’apprit la forge de bataille et comment concevoir les meilleures armes. Mais pas que ! Il m’apprit à me battre car un Seigneur des Runes se doit de combattre. Me voilà prêt à affronter mes premiers adversaires, pour cela mon oncle m’a conduit dans une ville libre et indépendante, la Cité des Sang-Mêlé. Je vais devoir vaincre ou mourir… 341 | 342 | Critique : 343 | 344 | Voici le premier album de la série nains. Nicolas Jarry développe un scénario basé sur un conflit père-fils, mais je devrais plutôt dire fils-père. Un père qui aime son fils plus que tout et qui ne tient pas à ce qu’il gâche sa vie à courir derrière la gloire, les honneurs et l’argent. Un père qui veut avant tout que son fils soit heureux et ne se noie pas dans son orgueil. Mais comment son fils pourrait-il accepter d’être humilié par les gens de son âge qui ne voient pas en lui un vrai nain, encore moins un membre de l’Ordre de la Forge, le plus prestigieux des cinq ordres ? Sa décision de rompre avec son père et de lui garder une rancune tenace ne va-t-elle pas le mener à sa perte ? Enormément d’émotion dans cette bande dessinée. Il y a clairement du Freud dans cette histoire. J.L. Istin, l’homme qui scénarise plus vite que son ombre, a refilé cette idée originale à Nicolas Jarry qui l’a superbement développée. 345 | 346 | Les dessins de Pierre-Denis Goux sont d’un grand dynamisme et donnent une impression de mouvement très réussie. J’aime tout spécialement ses décors, comme c’est le cas dans la plupart des livres des aventures en Terres d’Arran. Mon bémol, ce sont les monstres. Je n’aime pas les monstruosités qui, avec la magie, inondent la fantasy. C’est que j’ai un petit cœur sensible, moi ! 347 | 348 | Les couleurs ont été confiées à Digikore Studios, où ses équipes ont fait de l’excellent travail. 349 | 350 | 351 |
  • 352 |
  • 353 | 354 |

    bristethobennic

    355 |
    356 |

    Le 27/08/2020 à 14:08:58

    357 | J'ai acquis hier ce premier tome de la série. J'ai beaucoup aimé. Le scénario est bon, les bulles et les dessins lui rendent parfaitement honneur. En tous cas, ce premier tome appelle l'achat du second, et ce, dès demain ! :-) Après avoir commencer " Elfes ", je crois que " Nains " sera bel et bien ma seconde collection aux éditions Soleil. 358 | 359 | 360 |
  • 361 |
  • 362 | 363 |

    hyansolo

    364 |
    365 |

    Le 23/06/2020 à 15:26:10

    366 | J'en suis à la 5ème relecture et je ne m'en lasse pas. Un tome indispensable à avoir dans sa bibliothèque. 367 | 368 | 369 |
  • 370 |
  • 371 | 372 |

    pysa

    373 |
    374 |

    Le 26/12/2017 à 19:13:13

    375 | Redwin s'apprête à devenir forgeron comme son père Ulrog mais il ne comprend pas que ce dernier refuse de forger des armes. Cédant à l'ambition, il suit son oncle et se lance dans un furie belliqueuse. Le scénario est bien maîtrisé et abouti, peut-être un peu bavard parfois. Les dessins sont précis et réussis. 376 | 377 | 378 |
  • 379 |
  • 380 | 381 |

    Fafnir

    382 |
    383 |

    Le 21/10/2017 à 00:09:00

    384 | Un univers à l'inspiration assumée. Cependant ça ne s'arrête pas là, les grands thèmes et clichés sont appropriés et développés pour notre plus grand plaisir. Ces nains grincheux et leur état d'esprit, mais aussi leur forge runique (époustouflante). Tout ces éléments réunis dans une très belle histoire de filiation, d'ambition, de vengeance, sur un tempo épique et magique. Seul petit bémol à mon goût, un aspect parfois un peut trop caricatural et excessif, qui se laisse aisément pardonner. 385 | 386 | Sans leur être réservé, les amateurs d'univers type Seigneur des anneaux, et plus particulièrement de Warhammer peuvent y aller les yeux fermés (enfin même si ce serait un peu dommage vu le dessin). 387 | 388 | 389 |
  • 390 |
  • 391 | 392 |

    Dunyre

    393 |
    394 |

    Le 12/05/2017 à 20:25:39

    395 | Une claque... vraiment. Cette histoire est celle d'une relation père-fils, marquée par une opposition initiale entre un père marqué par de grands principes et un fils adolescent en quête d'un autre univers. À côté de cela se trouve une histoire de vengeance entre jeunes gens, l'un promis à un grand avenir et de ce fait toisant l'autre, le fameux Redwin, qui est un "fils de lâche" 396 | 397 | Parti en rébellion auprès de son oncle afin d'assouvir sa soif de découverte, le jeune Redwin va se rendre compte que tout a un prix, en particulier lorsqu'il s'agit d'une quete de vengeance. 398 | 399 | Au final, la fin de l'histoire (sans rien dévoiler) amène à une vraie réflexion sur le sens de la vie et sur les relations filiales. Vraiment très puissant émotionnellement pour moi. 400 | 401 | Les dessins sont superbes comme toujours chez Mr Goux et donnent une profondeur incroyable à toute l'épopée de Redwin. 402 | 403 | Sûrement le meilleur tome de cette série, que je recommande tout particulièrement aux jeunes gens en rébellion contre les principes de leur paternel, tout comme aux pères ayant à gérer ce type de conflits... 404 | 405 | Une ode sur la vie en somme. 406 | 407 | 408 |
  • 409 |
  • 410 | 411 |

    norius

    412 |
    413 |

    Le 20/12/2016 à 00:30:56

    414 | Le premier d'une longue série qui j’espère sera longue. Cet album met bien en place le monde dans lequel les prochains l'album. l'histoire est prenante meme si je reste sur ma faim a la derniere case ... 415 | 416 | 417 |
  • 418 |
  • 419 | 420 |

    judoc

    421 |
    422 |

    Le 23/08/2016 à 14:51:49

    423 | L'un des meilleurs albums de l'univers des "Terres d'Arran". Des dessins somptueux et un scénario noir et violent qui font de Redwin un personnage des plus emblématiques. 424 | 425 | A ne pas rater ! 426 | 427 | 428 |
  • 429 |
  • 430 | 431 |

    Powerslater10

    432 |
    433 |

    Le 04/08/2016 à 22:31:24

    434 | Le fait que Nains soit dans le même univers qu'Elfe est super-intéressant, de plus on rencontre certains nains dans Elfes dont ce fameux Redwin. Les dessins sont traches mais avec une précision assez remarquable et agréable, une BD qui met environ 1 heure 1 heure et demie si on veut apprécier la BD, personnellement je l'ont relirais bien une deuxième fois, elle est à lire absolument ! 435 | 436 | 437 |
  • 438 |
  • 439 | 440 |

    Rody Sansei

    441 |
    442 |

    Le 06/12/2015 à 18:53:07

    443 | Une nouvelle série de one-shots dans la veine de Elfes ou Oracles, dessinée par le talentueux Pierre-Denis Goux (Mjollnir). C'est brutal, bien raconté, prometteur pour la suite. Ce premier tome m'a bien plus convaincu que les "Maîtres Inquisiteurs" de Peru/Goux, série à one-shots elle aussi toute récente. 444 | 445 | 446 |
  • 447 |
  • 448 | 449 |

    darwin03

    450 |
    451 |

    Le 29/07/2015 à 16:41:46

    452 | Tres bon debut de serie Il va etre difficile de faire mieux je suis fan de MR Goux son dessin est top Le scenario va crescendo Que de plaisir dans la vision et la lecture de cet album 453 | 454 | 455 |
  • 456 |
  • 457 | 458 |

    Captainaja

    459 |
    460 |

    Le 27/07/2015 à 20:46:01

    461 | Une série qui débute de la plus belle des manières. Son héros Redwin de la forge est charismatique et nous transporte avec lui dans son ascension ou sa descente (ça dépend comment nous voyons les choses). Très agréablement surpris par cet album avec de très beaux dessins et un scénario qui gagne en profondeur au fil des pages. Un très bon moment 462 | 463 | 464 |
  • 465 |
  • 466 | 467 |

    Docteur Parangon

    468 |
    469 |

    Le 13/06/2015 à 19:19:49

    470 | Une très belle couverture gaufrée nous présente Redwin de la Forge, le héros de ce premier tome. Il est sombre, violent et orgueilleux. Son passé nous est conté pour mieux comprendre le présent. 471 | Le scénario se dévoile peu à peu en nous faisant entrer dans le monde d'un seigneur des runes. Un Nain qui forge aussi bien qu’il combat. La magie runique est intéressante avec un univers recherché. On sent qu'il y a eu un travail en amont pour avoir une cohérence, comme en témoigne le cahier graphique, les cinq ordres nains et le lexique. Il y a quelques clins d’œil à la série des Elfes. 472 | Tout le récit de ce tome est centré sur Redwin et sa volonté de se dépasser, quelles qu'en soient les conséquences. En tournant les pages, l’histoire s’étoffe et gagne en profondeur. 473 | Le dessin est bon avec des traits anguleux collant bien au thème "nain" et les décors sont soignés. La couleur est excellente avec des effets de lumière qui pose l'ambiance. 474 | Ce oneshot ouvre la série des Nains avec brio. 475 | 476 | 477 |
  • 478 |
479 |
480 | 481 |
482 |

483 | BDGest 2014 - Tous droits réservés 484 |

485 | 486 |
487 | 488 |
489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | --------------------------------------------------------------------------------