├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── MANIFEST.in ├── README.markdown ├── __init__.py ├── docs ├── Makefile ├── conf.py ├── getting_started.rst ├── index.rst ├── make.bat ├── modules.rst └── myanimelist.rst ├── myanimelist ├── __init__.py ├── anime.py ├── anime_list.py ├── base.py ├── character.py ├── club.py ├── genre.py ├── manga.py ├── manga_list.py ├── media.py ├── media_list.py ├── myanimelist.py ├── person.py ├── producer.py ├── publication.py ├── session.py ├── tag.py ├── user.py └── utilities.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── anime_list_tests.py ├── anime_tests.py ├── character_tests.py ├── manga_list_tests.py ├── manga_tests.py ├── media_list_tests.py ├── session_tests.py └── user_tests.py └── upload.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-project 2 | *.sublime-workspace 3 | *.pyc 4 | *.egg-info 5 | build/* 6 | dist/* 7 | credentials.txt 8 | README.txt 9 | convert_readme.bat 10 | docs/_build 11 | docs/_static 12 | docs/_templates 13 | .idea 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.7" 4 | install: 5 | - python setup.py install 6 | script: nosetests -a '!obsolete' 7 | env: 8 | - RUNENV=travis MAL_USERNAME=py3mal1 MAL_PASSWORD=KomplikaltJelszo TESTS_SQUID_ADDRESS=mal-squid.k8s.pushrbx.net:3128 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | recursive-include tests *.py -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Not developing this anymore 2 | 3 | Better to use MAL's v2 API or Jikan: 4 | - https://jikan.moe 5 | - https://myanimelist.net/apiconfig/references/api/v2 6 | 7 | 8 | python3-mal [![pypi download][pypi-version-svg]][pypi-link] [![pypi download][pypi-format-svg]][pypi-link] 9 | ========== 10 | 11 | Provides programmatic access to MyAnimeList data. 12 | This is a fork of python-mal. It uses lxml instead of beautifulsoup, and it's working with python 3. 13 | There are automatic travis and teamcity builds on every last sunday of the month to ensure that the package is working fine with the current version of MAL. 14 | 15 | Dependencies 16 | ============ 17 | 18 | - python 3.* (tested with 3.7 and 3.8) 19 | - pytz 20 | - requests 21 | - lxml 22 | - nose (only if you want to run tests, though!) 23 | - cssselect 24 | 25 | Installation 26 | ============ 27 | 28 | After cloning the repository, navigate to the directory and run `python setup.py install`. 29 | 30 | Getting Started 31 | =============== 32 | 33 | The `myanimelist.session.Session` class handles requests to MAL, so you'll want to create one first: 34 | 35 | from myanimelist.session import Session 36 | s = Session() 37 | 38 | Then if you want to fetch an anime, say, Cowboy Bebop: 39 | 40 | bebop = s.anime(1) 41 | print bebop 42 | 43 | Objects in python-mal are lazy-loading: they won't go out and fetch MAL info until you first-request it. So here, if you want to retrieve, say, the things related to Cowboy Bebop: 44 | 45 | for how_related,items in bebop.related.iteritems(): 46 | print how_related 47 | print "=============" 48 | for item in items: 49 | print item 50 | print "" 51 | 52 | You'll note that there's a pause while Cowboy Bebop's information is fetched from MAL. 53 | 54 | Documentation 55 | ============= 56 | 57 | To find out more about what `python-mal` is capable of, [visit the docs here](http://python-mal.readthedocs.org/en/latest/index.html). 58 | 59 | Testing 60 | ======= 61 | 62 | Testing requires `nose`. To run the tests that come with python-mal: 63 | 64 | 1. Navigate to the python-mal directory 65 | 2. Create a textfile named `credentials.txt` and put your MAL username and password in it, separated by a comma, or set environment variables named `MAL_USERNAME` and `MAL_PASSWORD` with the appropriate values. 66 | 3. Run `nosetests`. 67 | 68 | Make sure you don't spam the tests too quickly! One of the tests involves POSTing invalid credentials to MAL, so you're likely to be IP-banned if you do this too much in too short a span of time. 69 | 70 | Differences from the original repo 71 | =================================== 72 | 73 | - Instead of beautiful soup this module uses lxml 74 | - There are scheduled tests every sunday. 75 | - I've removed some of the functionalities: popular tags parsing and favourite parsing on user profiles because they were unstable. 76 | 77 | Change log 78 | ========== 79 | 0.2.7 - Adapted MAL changes: characters and staff on datasheets have absolute urls. Staff table has been changed to multiple table elements. 80 | 0.2.6 - added broadcast time parsing for currently aired anime shows and added some minor fixes. 81 | 0.2.5 - added promotion video parsing on anime datasheets 82 | 0.2.4 - Adapted to the new MAL ssl enforcement 83 | 0.2.3.1 - upgraded to requests 2.11 84 | 0.2.3.0 - performance improvements in xpath queries. 85 | 0.2.2 - adapted to new SEO url rule changes and DOM changes on MAL. 86 | 0.2.1 - replaced beautifulsoup with lxml. 87 | 88 | 89 | [travis-build-svg]: https://travis-ci.org/pushrbx/python3-mal.svg 90 | [teamcity-build-svg]: https://ci.pushrbx.net/app/rest/builds/buildType:(id:Python3mal_Build)/statusIcon.svg 91 | [pypi-format-svg]: https://img.shields.io/pypi/format/python3-mal.svg 92 | [pypi-version-svg]: https://img.shields.io/pypi/v/python3-mal.svg 93 | [pypi-link]: https://pypi.python.org/pypi/python3-mal 94 | [travis-build-link]: https://travis-ci.org/pushrbx/python3-mal 95 | [teamcity-build-link]: https://ci.pushrbx.net/viewType.html?buildTypeId=Python3mal_Build&guest=1 96 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'pushrbx' 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-mal.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-mal.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/python-mal" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/python-mal" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # python-mal_crawler documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Sep 14 15:36:53 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.autodoc', 33 | 'sphinx.ext.coverage', 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix of source filenames. 40 | source_suffix = '.rst' 41 | 42 | # The encoding of source files. 43 | #source_encoding = 'utf-8-sig' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = u'python-mal_crawler' 50 | copyright = u'2014, Charles Guo' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The short X.Y version. 57 | version = '0.1' 58 | # The full version, including alpha/beta/rc tags. 59 | release = '0.1' 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | #language = None 64 | 65 | # There are two options for replacing |today|: either, you set today to some 66 | # non-false value, then it is used: 67 | #today = '' 68 | # Else, today_fmt is used as the format for a strftime call. 69 | #today_fmt = '%B %d, %Y' 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | exclude_patterns = ['_build'] 74 | 75 | # The reST default role (used for this markup: `text`) to use for all 76 | # documents. 77 | #default_role = None 78 | 79 | # If true, '()' will be appended to :func: etc. cross-reference text. 80 | #add_function_parentheses = True 81 | 82 | # If true, the current module name will be prepended to all description 83 | # unit titles (such as .. function::). 84 | #add_module_names = True 85 | 86 | # If true, sectionauthor and moduleauthor directives will be shown in the 87 | # output. They are ignored by default. 88 | #show_authors = False 89 | 90 | # The name of the Pygments (syntax highlighting) style to use. 91 | pygments_style = 'sphinx' 92 | 93 | # A list of ignored prefixes for module index sorting. 94 | #modindex_common_prefix = [] 95 | 96 | # If true, keep warnings as "system message" paragraphs in the built documents. 97 | #keep_warnings = False 98 | 99 | 100 | # -- Options for HTML output ---------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = 'default' 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | #html_theme_options = {} 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | #html_theme_path = [] 113 | 114 | # The name for this set of Sphinx documents. If None, it defaults to 115 | # " v documentation". 116 | #html_title = None 117 | 118 | # A shorter title for the navigation bar. Default is the same as html_title. 119 | #html_short_title = None 120 | 121 | # The name of an image file (relative to this directory) to place at the top 122 | # of the sidebar. 123 | #html_logo = None 124 | 125 | # The name of an image file (within the static path) to use as favicon of the 126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 127 | # pixels large. 128 | #html_favicon = None 129 | 130 | # Add any paths that contain custom static files (such as style sheets) here, 131 | # relative to this directory. They are copied after the builtin static files, 132 | # so a file named "default.css" will overwrite the builtin "default.css". 133 | html_static_path = ['_static'] 134 | 135 | # Add any extra paths that contain custom files (such as robots.txt or 136 | # .htaccess) here, relative to this directory. These files are copied 137 | # directly to the root of the documentation. 138 | #html_extra_path = [] 139 | 140 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 141 | # using the given strftime format. 142 | #html_last_updated_fmt = '%b %d, %Y' 143 | 144 | # If true, SmartyPants will be used to convert quotes and dashes to 145 | # typographically correct entities. 146 | #html_use_smartypants = True 147 | 148 | # Custom sidebar templates, maps document names to template names. 149 | #html_sidebars = {} 150 | 151 | # Additional templates that should be rendered to pages, maps page names to 152 | # template names. 153 | #html_additional_pages = {} 154 | 155 | # If false, no module index is generated. 156 | #html_domain_indices = True 157 | 158 | # If false, no index is generated. 159 | #html_use_index = True 160 | 161 | # If true, the index is split into individual pages for each letter. 162 | #html_split_index = False 163 | 164 | # If true, links to the reST sources are added to the pages. 165 | #html_show_sourcelink = True 166 | 167 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 168 | #html_show_sphinx = True 169 | 170 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 171 | #html_show_copyright = True 172 | 173 | # If true, an OpenSearch description file will be output, and all pages will 174 | # contain a tag referring to it. The value of this option must be the 175 | # base URL from which the finished HTML is served. 176 | #html_use_opensearch = '' 177 | 178 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 179 | #html_file_suffix = None 180 | 181 | # Output file base name for HTML help builder. 182 | htmlhelp_basename = 'python-maldoc' 183 | 184 | 185 | # -- Options for LaTeX output --------------------------------------------- 186 | 187 | latex_elements = { 188 | # The paper size ('letterpaper' or 'a4paper'). 189 | #'papersize': 'letterpaper', 190 | 191 | # The font size ('10pt', '11pt' or '12pt'). 192 | #'pointsize': '10pt', 193 | 194 | # Additional stuff for the LaTeX preamble. 195 | #'preamble': '', 196 | } 197 | 198 | # Grouping the document tree into LaTeX files. List of tuples 199 | # (source start file, target name, title, 200 | # author, documentclass [howto, manual, or own class]). 201 | latex_documents = [ 202 | ('index', 'python-mal_crawler.tex', u'python-mal_crawler Documentation', 203 | u'Charles Guo', 'manual'), 204 | ] 205 | 206 | # The name of an image file (relative to this directory) to place at the top of 207 | # the title page. 208 | #latex_logo = None 209 | 210 | # For "manual" documents, if this is true, then toplevel headings are parts, 211 | # not chapters. 212 | #latex_use_parts = False 213 | 214 | # If true, show page references after internal links. 215 | #latex_show_pagerefs = False 216 | 217 | # If true, show URL addresses after external links. 218 | #latex_show_urls = False 219 | 220 | # Documents to append as an appendix to all manuals. 221 | #latex_appendices = [] 222 | 223 | # If false, no module index is generated. 224 | #latex_domain_indices = True 225 | 226 | 227 | # -- Options for manual page output --------------------------------------- 228 | 229 | # One entry per manual page. List of tuples 230 | # (source start file, name, description, authors, manual section). 231 | man_pages = [ 232 | ('index', 'python-mal_crawler', u'python-mal_crawler Documentation', 233 | [u'Charles Guo'], 1) 234 | ] 235 | 236 | # If true, show URL addresses after external links. 237 | #man_show_urls = False 238 | 239 | 240 | # -- Options for Texinfo output ------------------------------------------- 241 | 242 | # Grouping the document tree into Texinfo files. List of tuples 243 | # (source start file, target name, title, author, 244 | # dir menu entry, description, category) 245 | texinfo_documents = [ 246 | ('index', 'python-mal_crawler', u'python-mal_crawler Documentation', 247 | u'Charles Guo', 'python-mal_crawler', 'One line description of project.', 248 | 'Miscellaneous'), 249 | ] 250 | 251 | # Documents to append as an appendix to all manuals. 252 | #texinfo_appendices = [] 253 | 254 | # If false, no module index is generated. 255 | #texinfo_domain_indices = True 256 | 257 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 258 | #texinfo_show_urls = 'footnote' 259 | 260 | # If true, do not generate a @detailmenu in the "Top" node's menu. 261 | #texinfo_no_detailmenu = False 262 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | .. _getting-started: 2 | 3 | ================================ 4 | Getting Started with myanimelist 5 | ================================ 6 | 7 | This tutorial will walk you through installing ``myanimelist``, as well how to use it. It assumes you are familiar with Python; if you're not, please look up `one of `_ `the many `_ `fantastic Python tutorials `_ out there before continuing. 8 | 9 | Installing python-mal 10 | --------------------- 11 | 12 | You can use ``pip`` to install the latest released version of ``python-mal``:: 13 | 14 | pip install python-mal 15 | 16 | If you want to install ``python-mal`` from source:: 17 | 18 | git clone git://github.com/shaldengeki/python-mal.git 19 | cd python-mal 20 | python setup.py install 21 | 22 | Connecting to MAL 23 | ----------------- 24 | 25 | ``myanimelist`` provides a ``Session`` class that manages requests to MAL. To do anything, you're first going to need to instantiate one: 26 | 27 | >>> import myanimelist.session 28 | >>> session = myanimelist.session.Session() 29 | 30 | If you have a username and password that you want to log into MAL with, you can provide the ``Session`` object with those: 31 | 32 | >>> import myanimelist.session 33 | >>> session = myanimelist.session.Session(username="mal_username", password="mal_password") 34 | >>> session.login() 35 | 36 | Providing credentials to MAL isn't actually required for most tasks, so feel free to forego the login process if you don't need it. 37 | 38 | Interacting with MAL 39 | -------------------- 40 | 41 | Once you have a ``Session`` connected to MAL, there are methods on that object that will return resources on MAL, like ``anime`` or ``manga``. You're typically going to want to fetch all your resources through these convenience methods. The following code demonstrates how to fetch an anime and look up all characters for it:: 42 | 43 | >>> import myanimelist.session 44 | >>> session = myanimelist.session.Session() 45 | # Return an anime object corresponding to an ID of 1. IDs must be natural numbers. 46 | >>> bebop = session.anime(1) 47 | >>> print bebop.title 48 | Cowboy Bebop 49 | # Print each character's name and their role. 50 | >>> for character in bebop.characters: 51 | ... print character.name, '---', bebop.characters[character]['role'] 52 | Spike Spiegel --- Main 53 | Faye Valentine --- Main 54 | Jet Black --- Main 55 | Ein --- Supporting 56 | Rocco Bonnaro --- Supporting 57 | Mad Pierrot --- Supporting 58 | Dr. Londez --- Supporting 59 | ... 60 | 61 | Users on MAL are slightly different; their primary ID is their username, instead of an integral ID. So, say you wanted to look up some user's recommendations. The following code would be one way to do it:: 62 | 63 | >>> import myanimelist.session 64 | >>> session = myanimelist.session.Session() 65 | # Return an user object corresponding to the given username. Usernames _must_ be unicode! 66 | >>> shal = session.user(u'shaldengeki') 67 | >>> print shal.website 68 | llanim.us 69 | # Print each recommendation. 70 | >>> for anime in shal.recommendations: 71 | ... print anime.title, '---', shal.recommendations[anime]['anime'].title 72 | ... print "=====================================" 73 | ... print shal.recommendations[anime]['text'] 74 | Kanon (2006) --- Clannad: After Story 75 | ===================================== 76 | School life, slice of life anime based on a visual novel. Male protagonist gets to know the backgrounds and histories of several girls at his school in successive arcs (the only way that an anime based on a visual novel can be done). Helps them through their problems, and deals with his own in the process. 77 | 78 | Anime and manga lists are similar, being primarily-identified through usernames instead of user IDs. 79 | 80 | Each resource has a slightly-different set of methods. You'll want to refer to the other guides and API references in this documentation to see what's available. -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _index: 2 | 3 | ============================================= 4 | python-mal: A Python interface to MyAnimeList 5 | ============================================= 6 | 7 | `python-mal `_ is a Python library for interacting programmatically with resources on `MyAnimeList `_. 8 | 9 | Confusingly enough, the package is called ``python-mal`` while the module itself is named ``myanimelist``. The docs will refer to ``python-mal`` whenever we talk about the package, e.g. installing it, but for anything else, e.g. actual code, we'll refer to ``myanimelist``. 10 | 11 | All features are tested under Python 2.7. 12 | 13 | Getting Started 14 | --------------- 15 | 16 | If you've never used ``myanimelist`` before, you should read the :doc:`Getting Started with myanimelist ` guide to get familiar with ``myanimelist``. 17 | 18 | Supported MAL Resources 19 | ----------------------- 20 | 21 | * Users 22 | * Anime 23 | * Anime Lists 24 | * Manga 25 | * Manga Lists 26 | * Characters 27 | 28 | Additional Resources 29 | -------------------- 30 | 31 | * `python-mal repository`_ 32 | 33 | .. _python-mal repository: https://github.com/shaldengeki/python-mal 34 | 35 | .. toctree:: 36 | :hidden: 37 | :glob: 38 | 39 | getting_started 40 | modules 41 | 42 | Indices and tables 43 | ================== 44 | 45 | * :ref:`genindex` 46 | * :ref:`modindex` 47 | * :ref:`search` -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\python-mal.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\python-mal.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | myanimelist 2 | =========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | myanimelist 8 | -------------------------------------------------------------------------------- /docs/myanimelist.rst: -------------------------------------------------------------------------------- 1 | myanimelist package 2 | =================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | myanimelist.anime module 8 | ------------------------ 9 | 10 | .. automodule:: myanimelist.anime 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | myanimelist.anime_list module 16 | ----------------------------- 17 | 18 | .. automodule:: myanimelist.anime_list 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | myanimelist.base module 24 | ----------------------- 25 | 26 | .. automodule:: myanimelist.base 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | myanimelist.character module 32 | ---------------------------- 33 | 34 | .. automodule:: myanimelist.character 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | myanimelist.club module 40 | ----------------------- 41 | 42 | .. automodule:: myanimelist.club 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | myanimelist.genre module 48 | ------------------------ 49 | 50 | .. automodule:: myanimelist.genre 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | myanimelist.manga module 56 | ------------------------ 57 | 58 | .. automodule:: myanimelist.manga 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | myanimelist.manga_list module 64 | ----------------------------- 65 | 66 | .. automodule:: myanimelist.manga_list 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | myanimelist.media module 72 | ------------------------ 73 | 74 | .. automodule:: myanimelist.media 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | myanimelist.media_list module 80 | ----------------------------- 81 | 82 | .. automodule:: myanimelist.media_list 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | 87 | myanimelist.myanimelist module 88 | ------------------------------ 89 | 90 | .. automodule:: myanimelist.myanimelist 91 | :members: 92 | :undoc-members: 93 | :show-inheritance: 94 | 95 | myanimelist.person module 96 | ------------------------- 97 | 98 | .. automodule:: myanimelist.person 99 | :members: 100 | :undoc-members: 101 | :show-inheritance: 102 | 103 | myanimelist.publication module 104 | ------------------------------ 105 | 106 | .. automodule:: myanimelist.publication 107 | :members: 108 | :undoc-members: 109 | :show-inheritance: 110 | 111 | myanimelist.session module 112 | -------------------------- 113 | 114 | .. automodule:: myanimelist.session 115 | :members: 116 | :undoc-members: 117 | :show-inheritance: 118 | 119 | myanimelist.tag module 120 | ---------------------- 121 | 122 | .. automodule:: myanimelist.tag 123 | :members: 124 | :undoc-members: 125 | :show-inheritance: 126 | 127 | myanimelist.user module 128 | ----------------------- 129 | 130 | .. automodule:: myanimelist.user 131 | :members: 132 | :undoc-members: 133 | :show-inheritance: 134 | 135 | myanimelist.utilities module 136 | ---------------------------- 137 | 138 | .. automodule:: myanimelist.utilities 139 | :members: 140 | :undoc-members: 141 | :show-inheritance: 142 | 143 | 144 | Module contents 145 | --------------- 146 | 147 | .. automodule:: myanimelist 148 | :members: 149 | :undoc-members: 150 | :show-inheritance: 151 | -------------------------------------------------------------------------------- /myanimelist/__init__.py: -------------------------------------------------------------------------------- 1 | from .myanimelist import * -------------------------------------------------------------------------------- /myanimelist/anime.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import datetime 4 | import re 5 | 6 | from . import utilities 7 | from . import media 8 | from .base import loadable 9 | 10 | 11 | class MalformedAnimePageError(media.MalformedMediaPageError): 12 | """Indicates that an anime-related page on MAL has irreparably broken markup in some way. 13 | """ 14 | pass 15 | 16 | 17 | class InvalidAnimeError(media.InvalidMediaError): 18 | """Indicates that the anime requested does not exist on MAL. 19 | """ 20 | pass 21 | 22 | 23 | class Anime(media.Media): 24 | """Primary interface to anime resources on MAL. 25 | """ 26 | _status_terms = [ 27 | 'Unknown', 28 | 'Currently Airing', 29 | 'Finished Airing', 30 | 'Not yet aired' 31 | ] 32 | _consuming_verb = "watch" 33 | 34 | def __init__(self, session, anime_id): 35 | """Creates a new instance of Anime. 36 | 37 | :type session: :class:`myanimelist.session.Session` 38 | :param session: A valid MAL session 39 | :type anime_id: int 40 | :param anime_id: The desired anime's ID on MAL 41 | 42 | :raises: :class:`.InvalidAnimeError` 43 | 44 | """ 45 | if not isinstance(anime_id, int) or int(anime_id) < 1: 46 | raise InvalidAnimeError(anime_id) 47 | super(Anime, self).__init__(session, anime_id) 48 | self._episodes = None 49 | self._aired = None 50 | self._producers = None 51 | self._licensors = None 52 | self._studios = None 53 | self._duration = None 54 | self._rating = None 55 | self._voice_actors = None 56 | self._staff = None 57 | self._promotion_videos = None 58 | self._broadcast = None 59 | self._source = None 60 | self._premiered = None 61 | 62 | def parse_promotion_videos(self, media_page): 63 | container = utilities.css_select_first("#content", media_page) 64 | if container is None: 65 | return None 66 | 67 | result = [] 68 | video_tags = utilities.css_select("a.iframe", media_page) 69 | 70 | for tag in video_tags: 71 | embed_link = tag.get('href') 72 | title_tag = tag.xpath("//div[@class='info-container']/span") 73 | title = "" 74 | if title_tag is not None and len(title_tag) > 0: 75 | title = title_tag[0].text 76 | 77 | result.append({"embed_link": embed_link, "title": title}) 78 | 79 | self._promotion_videos = result 80 | return result 81 | 82 | def parse_sidebar(self, anime_page): 83 | """Parses the DOM and returns anime attributes in the sidebar. 84 | 85 | :type anime_page: :class:`lxml.html.HtmlElement` 86 | :param anime_page: MAL anime page's DOM 87 | 88 | :rtype: dict 89 | :return: anime attributes 90 | 91 | :raises: :class:`.InvalidAnimeError`, :class:`.MalformedAnimePageError` 92 | """ 93 | # if MAL says the series doesn't exist, raise an InvalidAnimeError. 94 | if not self._validate_page(anime_page): 95 | raise InvalidAnimeError(self.id) 96 | 97 | title_tag = anime_page.xpath(".//div[@id='contentWrapper']//h1") 98 | if len(title_tag) == 0: 99 | raise MalformedAnimePageError(self.id, anime_page.text, message="Could not find title div") 100 | 101 | anime_info = super(Anime, self).parse_sidebar(anime_page) 102 | info_panel_first = None 103 | 104 | try: 105 | container = utilities.css_select("#content", anime_page) 106 | if container is None: 107 | raise MalformedAnimePageError(self.id, anime_page.text, message="Could not find the info table") 108 | 109 | info_panel_first = container[0].find(".//table/tr/td") 110 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Episodes:')]]") 111 | if len(temp) == 0: 112 | raise Exception("Couldn't find episode tag.") 113 | episode_tag = temp[0].getparent().xpath(".//text()")[-1] 114 | anime_info['episodes'] = int(episode_tag.strip()) if episode_tag.strip() != 'Unknown' else 0 115 | except: 116 | if not self.session.suppress_parse_exceptions: 117 | raise 118 | 119 | try: 120 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Aired:')]]") 121 | if len(temp) == 0: 122 | raise Exception("Couldn't find aired tag.") 123 | aired_tag = temp[0].getparent().xpath(".//text()")[2] 124 | aired_parts = aired_tag.strip().split(' to ') 125 | if len(aired_parts) == 1: 126 | # this aired once. 127 | try: 128 | aired_date = utilities.parse_profile_date(aired_parts[0], 129 | suppress=self.session.suppress_parse_exceptions) 130 | except ValueError: 131 | raise MalformedAnimePageError(self.id, aired_parts[0], message="Could not parse single air date") 132 | anime_info['aired'] = (aired_date,) 133 | else: 134 | # two airing dates. 135 | try: 136 | air_start = utilities.parse_profile_date(aired_parts[0], 137 | suppress=self.session.suppress_parse_exceptions) 138 | except ValueError: 139 | raise MalformedAnimePageError(self.id, aired_parts[0], 140 | message="Could not parse first of two air dates") 141 | try: 142 | air_end = utilities.parse_profile_date(aired_parts[1], 143 | suppress=self.session.suppress_parse_exceptions) 144 | except ValueError: 145 | raise MalformedAnimePageError(self.id, aired_parts[1], 146 | message="Could not parse second of two air dates") 147 | anime_info['aired'] = (air_start, air_end) 148 | except: 149 | if not self.session.suppress_parse_exceptions: 150 | raise 151 | 152 | try: 153 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Producers:')]]") 154 | if len(temp) == 0: 155 | raise Exception("Couldn't find producers tag.") 156 | producers_tags = temp[0].getparent().xpath(".//a") 157 | anime_info['producers'] = [] 158 | for producer_link in producers_tags: 159 | if producer_link.text == 'add some': 160 | # MAL is saying "None found, add some". 161 | break 162 | link_parts = producer_link.get('href').split('p=') 163 | # of the form: /anime.php?p=14 164 | if len(link_parts) > 1: 165 | anime_info['producers'].append( 166 | self.session.producer(int(link_parts[1])).set({'name': producer_link.text})) 167 | else: 168 | # of the form: /anime/producer/65 169 | link_parts = producer_link.get('href').split('/') 170 | anime_info['producers'].append( 171 | self.session.producer(int(link_parts[-2])).set({"name": producer_link.text})) 172 | except: 173 | if not self.session.suppress_parse_exceptions: 174 | raise 175 | 176 | try: 177 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Licensors:')]]") 178 | if len(temp) == 0: 179 | raise Exception("Couldn't find licensors tag.") 180 | licensors_tags = temp[0].getparent().xpath(".//a") 181 | anime_info['licensors'] = [] 182 | for producer_link in licensors_tags: 183 | if producer_link.text == 'add some': 184 | # MAL is saying "None found, add some". 185 | break 186 | link_parts = producer_link.get('href').split('p=') 187 | # of the form: /anime.php?p=14 188 | if len(link_parts) > 1: 189 | anime_info['licensors'].append( 190 | self.session.producer(int(link_parts[1])).set({'name': producer_link.text})) 191 | else: 192 | # of the form: /anime/producer/65 193 | link_parts = producer_link.get('href').split('/') 194 | anime_info['licensors'].append( 195 | self.session.producer(int(link_parts[-2])).set({"name": producer_link.text})) 196 | except: 197 | if not self.session.suppress_parse_exceptions: 198 | raise 199 | 200 | try: 201 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Studios:')]]") 202 | if len(temp) == 0: 203 | raise Exception("Couldn't find studios tag.") 204 | studios_tags = temp[0].getparent().xpath(".//a") 205 | anime_info['studios'] = [] 206 | for producer_link in studios_tags: 207 | if producer_link.text == 'add some': 208 | # MAL is saying "None found, add some". 209 | break 210 | link_parts = producer_link.get('href').split('p=') 211 | # of the form: /anime.php?p=14 212 | if len(link_parts) > 1: 213 | anime_info['studios'].append( 214 | self.session.producer(int(link_parts[1])).set({'name': producer_link.text})) 215 | else: 216 | # of the form: /anime/producer/65 217 | link_parts = producer_link.get('href').split('/') 218 | anime_info['studios'].append( 219 | self.session.producer(int(link_parts[-2])).set({"name": producer_link.text})) 220 | except: 221 | if not self.session.suppress_parse_exceptions: 222 | raise 223 | 224 | try: 225 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Duration:')]]") 226 | if len(temp) == 0: 227 | raise Exception("Couldn't find duration tag.") 228 | duration_tag = temp[0].xpath("../text()")[-1] 229 | anime_info['duration'] = duration_tag.strip() 230 | duration_parts = [part.strip() for part in anime_info['duration'].split('.')] 231 | duration_mins = 0 232 | for part in duration_parts: 233 | part_match = re.match('(?P[0-9]+)', part) 234 | if not part_match: 235 | continue 236 | part_volume = int(part_match.group('num')) 237 | if part.endswith('hr'): 238 | duration_mins += part_volume * 60 239 | elif part.endswith('min'): 240 | duration_mins += part_volume 241 | anime_info['duration'] = datetime.timedelta(minutes=duration_mins) 242 | except: 243 | if not self.session.suppress_parse_exceptions: 244 | raise 245 | 246 | try: 247 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Rating:')]]") 248 | if len(temp) == 0: 249 | raise Exception("Couldn't find duration tag.") 250 | rating_tag = temp[0].xpath("../text()")[-1] 251 | anime_info['rating'] = rating_tag.strip() 252 | except: 253 | if not self.session.suppress_parse_exceptions: 254 | raise 255 | 256 | # parse broadcasting times - note: the tests doesnt cover this bit, because its a dynamic data 257 | # todo: figure out a way to cover this bit in the unit tests 258 | try: 259 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Broadcast:')]]") 260 | anime_info['broadcast'] = None 261 | if len(temp) > 0: 262 | broadcast_tag = temp[0].xpath("../text()")[-1].strip() 263 | rex = re.compile("[a-zA-Z]+.[a-z]+.[0-9]{1,2}:[0-9]{1,2}.\([A-Z]+\)") 264 | if broadcast_tag != "Unknown" and rex.match(broadcast_tag) is not None: 265 | anime_info['broadcast'] = {} 266 | 267 | parts = broadcast_tag.split(" at ") 268 | time_parts = parts[-1].split(" ") 269 | subtime_parts = time_parts[0].split(':') 270 | 271 | anime_info['broadcast']['weekday'] = parts[0].rstrip('s') 272 | anime_info['broadcast']['hour'] = int(subtime_parts[0]) 273 | anime_info['broadcast']['minute'] = int(subtime_parts[1]) 274 | anime_info['broadcast']['timezone'] = time_parts[-1].replace('(', '').replace(')', '') 275 | except: 276 | if not self.session.suppress_parse_exceptions: 277 | raise 278 | 279 | try: 280 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Source:')]]") 281 | anime_info['source'] = '' 282 | if len(temp) == 0: 283 | raise Exception("Couldnt find source tag.") 284 | source_tag = temp[0].xpath("../text()")[-1].strip() 285 | if source_tag != "Unknown": 286 | anime_info['source'] = source_tag 287 | except: 288 | if not self.session.suppress_parse_exceptions: 289 | raise 290 | 291 | try: 292 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Premiered:')]]") 293 | anime_info['premiered'] = '' 294 | if len(temp) > 0: 295 | premiered_tag = "".join(temp[0].getparent().xpath(".//text()")).strip().replace('\n', '') \ 296 | .split(": ")[-1].rstrip() 297 | anime_info['premiered'] = premiered_tag.strip() 298 | except: 299 | if not self.session.suppress_parse_exceptions: 300 | raise 301 | 302 | return anime_info 303 | 304 | def parse_characters(self, character_page): 305 | """Parses the DOM and returns anime character attributes in the sidebar. 306 | 307 | :type character_page: :class:`lxml.html.HtmlElement` 308 | :param character_page: MAL anime character page's DOM 309 | 310 | :rtype: dict 311 | :return: anime character attributes 312 | 313 | :raises: :class:`.InvalidAnimeError`, :class:`.MalformedAnimePageError` 314 | 315 | """ 316 | anime_info = self.parse_sidebar(character_page) 317 | 318 | try: 319 | temp = character_page.xpath(".//h2[text()[contains(.,'Characters')]]/following-sibling::table[1]") 320 | 321 | anime_info['characters'] = {} 322 | anime_info['voice_actors'] = {} 323 | 324 | if len(temp) != 0: 325 | curr_elt = temp[0] 326 | while curr_elt is not None: 327 | if curr_elt.tag != 'table': 328 | break 329 | curr_row = curr_elt.find('.//tr') 330 | temp = curr_row.findall("./td") 331 | # we got to the staff part, todo: fix the sibling part. this is ugly 332 | if len(temp) != 3: 333 | break 334 | (_, character_col, va_col) = temp 335 | 336 | character_link = character_col.find('.//a') 337 | character_name = ' '.join(reversed(character_link.text.split(', '))) 338 | link_parts = character_link.get('href').split('/') 339 | # of the form /character/7373/Holo 340 | if "myanimelist.net" not in link_parts: 341 | character_id = int(link_parts[2]) 342 | # or of the form https://myanimelist.net/character/7373/Holo 343 | else: 344 | character_id = int(link_parts[4]) 345 | character = self.session.character(character_id).set({'name': character_name}) 346 | role = character_col.find('.//small').text 347 | character_entry = {'role': role, 'voice_actors': {}} 348 | 349 | va_table = va_col.find('.//table') 350 | if va_table is not None: 351 | for row in va_table.findall("tr"): 352 | va_info_cols = row.findall('td') 353 | if not va_info_cols or len(va_info_cols) == 0: 354 | # don't ask me why MAL has an extra blank table row i don't know!!! 355 | continue 356 | 357 | va_info_col = va_info_cols[0] 358 | va_link = va_info_col.find('.//a') 359 | if va_link is not None: 360 | va_name = ' '.join(reversed(va_link.text.split(', '))) 361 | link_parts = va_link.get('href').split('/') 362 | # of the form /people/70/Ami_Koshimizu 363 | if "myanimelist.net" not in link_parts: 364 | person_id = int(link_parts[2]) 365 | # or of the form https://myanimelist.net/people/70/Ami_Koshimizu 366 | else: 367 | person_id = int(link_parts[4]) 368 | person = self.session.person(person_id).set({'name': va_name}) 369 | language = va_info_col.find('.//small').text 370 | # one person can be voice actor for many characters 371 | if person not in anime_info['voice_actors'].keys(): 372 | anime_info['voice_actors'][person] = [] 373 | anime_info['voice_actors'][person].append({'role': role, 'character': character, 374 | 'language': language}) 375 | character_entry['voice_actors'][person] = language 376 | 377 | anime_info['characters'][character] = character_entry 378 | temp = curr_elt.xpath("./following-sibling::table[1]") 379 | if len(temp) != 0: 380 | curr_elt = temp[0] 381 | else: 382 | curr_elt = None 383 | except: 384 | if not self.session.suppress_parse_exceptions: 385 | raise 386 | 387 | try: 388 | item_tables = character_page.xpath(".//h2[text()[contains(.,'Staff')]]/following-sibling::table") 389 | anime_info['staff'] = {} 390 | if len(item_tables) != 0: 391 | for staff_table in item_tables: 392 | for row in staff_table.findall('.//tr'): 393 | # staff info in second col. 394 | info = row.find('./td[2]') 395 | staff_link = info.find('.//a') 396 | if staff_link is not None: 397 | staff_name = ' '.join(reversed(staff_link.text.split(', '))) 398 | link_parts = staff_link.get('href').split('/') 399 | # of the form /people/1870/Miyazaki_Hayao 400 | person = self.session.person(int(link_parts[-2])).set({'name': staff_name}) 401 | # staff role(s). 402 | smallTag = info.find('.//small') 403 | if smallTag is not None: 404 | anime_info['staff'][person] = set(smallTag.text.split(', ')) 405 | except: 406 | if not self.session.suppress_parse_exceptions: 407 | raise 408 | 409 | return anime_info 410 | 411 | def load_videos(self): 412 | """Fetches the MAL media videos page and sets the current media's promotion videos attribute. 413 | 414 | :rtype: :class:`.Anime` 415 | :return: current media object. 416 | 417 | """ 418 | videos_page = self.session.get( 419 | 'https://myanimelist.net/' + self.__class__.__name__.lower() + '/' + str( 420 | self.id) + '/' + utilities.urlencode(self.title) + '/video').text 421 | self.set({'promotion_videos': self.parse_promotion_videos(utilities.get_clean_dom(videos_page))}) 422 | return self 423 | 424 | @property 425 | @loadable('load') 426 | def episodes(self): 427 | """The number of episodes in this anime. If undetermined, is None, otherwise > 0. 428 | """ 429 | return self._episodes 430 | 431 | @property 432 | @loadable('load') 433 | def aired(self): 434 | """A tuple(2) containing up to two :class:`datetime.date` objects representing the start and end dates of this anime's airing. 435 | 436 | Potential configurations: 437 | 438 | None -- Completely-unknown airing dates. 439 | 440 | (:class:`datetime.date`, None) -- Anime start date is known, end date is unknown. 441 | 442 | (:class:`datetime.date`, :class:`datetime.date`) -- Anime start and end dates are known. 443 | 444 | """ 445 | return self._aired 446 | 447 | @property 448 | @loadable('load') 449 | def producers(self): 450 | """A list of :class:`myanimelist.producer.Producer` objects involved in this anime. 451 | """ 452 | return self._producers 453 | 454 | @property 455 | @loadable('load') 456 | def licensors(self): 457 | """A list of :class:`myanimelist.producer.Producer` objects involved in this anime. 458 | """ 459 | return self._licensors 460 | 461 | @property 462 | @loadable('load') 463 | def studios(self): 464 | """A list of :class:`myanimelist.producer.Producer` objects involved in this anime. 465 | """ 466 | return self._studios 467 | 468 | @property 469 | @loadable('load') 470 | def duration(self): 471 | """The duration of an episode of this anime as a :class:`datetime.timedelta`. 472 | """ 473 | return self._duration 474 | 475 | @property 476 | @loadable('load') 477 | def broadcast(self): 478 | """The broadcast time of this anime as a :class:`dict` if it is being aired currently. 479 | """ 480 | return self._broadcast 481 | 482 | @property 483 | @loadable('load') 484 | def source(self): 485 | """Original source of this anime (Original, Light Novel, Visual Novel, Manga, Unknown). 486 | """ 487 | return self._source 488 | 489 | @property 490 | @loadable('load') 491 | def premiered(self): 492 | """Airing season of this anime. 493 | """ 494 | return self._premiered 495 | 496 | @property 497 | @loadable('load_videos') 498 | def promotion_videos(self): 499 | return self._promotion_videos 500 | 501 | @property 502 | @loadable('load') 503 | def rating(self): 504 | """The MPAA rating given to this anime. 505 | """ 506 | return self._rating 507 | 508 | @property 509 | @loadable('load_characters') 510 | def voice_actors(self): 511 | """A voice actors dict with :class:`myanimelist.person.Person` objects of the voice actors as keys, and dicts containing info about the roles played, e.g. {'role': 'Main', 'character': myanimelist.character.Character(1)} as values. 512 | """ 513 | return self._voice_actors 514 | 515 | @property 516 | @loadable('load_characters') 517 | def staff(self): 518 | """A staff dict with :class:`myanimelist.person.Person` objects of the staff members as keys, and lists containing the various duties performed by staff members as values. 519 | """ 520 | return self._staff 521 | -------------------------------------------------------------------------------- /myanimelist/anime_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from . import utilities 5 | from .base import Base, Error, loadable 6 | from . import media_list 7 | 8 | 9 | class AnimeList(media_list.MediaList): 10 | __id_attribute = "username" 11 | 12 | def __init__(self, session, user_name): 13 | super(AnimeList, self).__init__(session, user_name) 14 | 15 | @property 16 | def type(self): 17 | return "anime" 18 | 19 | @property 20 | def verb(self): 21 | return "watch" 22 | 23 | def parse_entry_media_attributes(self, soup): 24 | attributes = super(AnimeList, self).parse_entry_media_attributes(soup) 25 | 26 | try: 27 | attributes['episodes'] = int(soup.find('.//series_episodes').text) 28 | except ValueError: 29 | attributes['episodes'] = None 30 | except: 31 | if not self.session.suppress_parse_exceptions: 32 | raise 33 | 34 | return attributes 35 | 36 | def parse_entry(self, soup): 37 | anime, entry_info = super(AnimeList, self).parse_entry(soup) 38 | 39 | try: 40 | entry_info['episodes_watched'] = int(soup.find('.//my_watched_episodes').text) 41 | except ValueError: 42 | entry_info['episodes_watched'] = 0 43 | except: 44 | if not self.session.suppress_parse_exceptions: 45 | raise 46 | 47 | try: 48 | entry_info['rewatching'] = bool(soup.find('.//my_rewatching').text) 49 | except ValueError: 50 | entry_info['rewatching'] = False 51 | except: 52 | if not self.session.suppress_parse_exceptions: 53 | raise 54 | 55 | try: 56 | entry_info['episodes_rewatched'] = int(soup.find('.//my_rewatching_ep').text) 57 | except ValueError: 58 | entry_info['episodes_rewatched'] = 0 59 | except: 60 | if not self.session.suppress_parse_exceptions: 61 | raise 62 | 63 | return anime, entry_info 64 | 65 | def parse_section_columns(self, columns): 66 | column_names = super(AnimeList, self).parse_section_columns(columns) 67 | for i, column in enumerate(columns): 68 | if 'Type' in column.text: 69 | column_names['type'] = i 70 | elif 'Progress' in column.text: 71 | column_names['progress'] = i 72 | elif 'Tags' in column.text: 73 | column_names['tags'] = i 74 | elif 'Started' in column.text: 75 | column_names['started'] = i 76 | elif 'Finished' in column.text: 77 | column_names['finished'] = i 78 | return column_names 79 | -------------------------------------------------------------------------------- /myanimelist/base.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import abc 4 | import functools 5 | 6 | from . import utilities 7 | from lxml import html as ht 8 | 9 | 10 | class Error(Exception): 11 | """Base exception class that takes a message to display upon raising. 12 | """ 13 | 14 | def __init__(self, message=None): 15 | """Creates an instance of Error. 16 | 17 | :type message: str 18 | :param message: A message to display when raising the exception. 19 | 20 | """ 21 | super(Error, self).__init__() 22 | self.message = message 23 | 24 | def __str__(self): 25 | return str(self.message) if self.message is not None else "" 26 | 27 | 28 | class MalformedPageError(Error): 29 | """Indicates that a page on MAL has broken markup in some way. 30 | """ 31 | 32 | def __init__(self, id, html, message=None): 33 | super(MalformedPageError, self).__init__(message=message) 34 | if isinstance(id, str): 35 | self.id = id 36 | else: 37 | self.id = str(id) 38 | if isinstance(html, str): 39 | self.html = html 40 | else: 41 | if isinstance(html, ht.HtmlElement): 42 | self.html = html.text 43 | else: 44 | self.html = str(html) 45 | 46 | def __str__(self): 47 | none_str_const = "" 48 | return "\n".join([ 49 | super(MalformedPageError, self).__str__(), 50 | "ID: " + self.id if self.id is not None else none_str_const, 51 | "HTML: " + self.html if self.html is not None else none_str_const 52 | ]) 53 | 54 | 55 | class InvalidBaseError(Error): 56 | """Indicates that the particular resource instance requested does not exist on MAL. 57 | """ 58 | 59 | def __init__(self, id, message=None): 60 | super(InvalidBaseError, self).__init__(message=message) 61 | self.id = id 62 | 63 | def __str__(self): 64 | return "\n".join([ 65 | super(InvalidBaseError, self).__str__(), 66 | "ID: " + str(self.id) 67 | ]) 68 | 69 | 70 | def loadable(func_name): 71 | """Decorator for getters that require a load() upon first access. 72 | 73 | :type func_name: function 74 | :param func_name: class method that requires that load() be called if the class's _attribute value is None 75 | 76 | :rtype: function 77 | :return: the decorated class method. 78 | 79 | """ 80 | 81 | def inner(func): 82 | cached_name = '_' + func.__name__ 83 | 84 | @functools.wraps(func) 85 | def _decorator(self, *args, **kwargs): 86 | if getattr(self, cached_name) is None: 87 | getattr(self, func_name)() 88 | return func(self, *args, **kwargs) 89 | 90 | return _decorator 91 | 92 | return inner 93 | 94 | 95 | class Base(object, metaclass=abc.ABCMeta): 96 | """Abstract base class for MAL resources. Provides autoloading, auto-setting functionality for other MAL objects. 97 | """ 98 | 99 | """Attribute name for primary reference key to this object. 100 | When an attribute by the name given by _id_attribute is passed into set(), set() doesn't prepend an underscore for load()ing. 101 | """ 102 | _id_attribute = "id" 103 | 104 | def __repr__(self): 105 | return "".join([ 106 | "<", 107 | self.__class__.__name__, 108 | " ", 109 | self._id_attribute, 110 | ": ", 111 | str(getattr(self, self._id_attribute)), 112 | ">" 113 | ]) 114 | 115 | def __hash__(self): 116 | return hash('-'.join([self.__class__.__name__, str(getattr(self, self._id_attribute))])) 117 | 118 | def __eq__(self, other): 119 | return isinstance(other, self.__class__) and getattr(self, self._id_attribute) == getattr(other, 120 | other._id_attribute) 121 | 122 | def __ne__(self, other): 123 | return not self.__eq__(other) 124 | 125 | def __init__(self, session): 126 | """Create an instance of Base. 127 | 128 | :type session: :class:`myanimelist.session.Session` 129 | :param session: A valid MAL session. 130 | 131 | """ 132 | self.session = session 133 | 134 | @staticmethod 135 | def _validate_page(media_page): 136 | error_tag = media_page.xpath(".//p[@class='error_code'] | .//div[@class='badresult'] | .//div[" 137 | "@class='error404']") 138 | return len(error_tag) is 0 139 | 140 | @abc.abstractmethod 141 | def load(self): 142 | """A callback to run before any @loadable attributes are returned. 143 | """ 144 | pass 145 | 146 | def set(self, attr_dict): 147 | """Sets attributes of this user object. 148 | 149 | :type attr_dict: dict 150 | :param attr_dict: Parameters to set, with attribute keys. 151 | 152 | :rtype: :class:`.Base` 153 | :return: The current object. 154 | 155 | """ 156 | for key in attr_dict: 157 | if key == self._id_attribute: 158 | setattr(self, self._id_attribute, attr_dict[key]) 159 | else: 160 | setattr(self, "_" + key, attr_dict[key]) 161 | return self 162 | -------------------------------------------------------------------------------- /myanimelist/character.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | 6 | from . import utilities 7 | from .base import Base, MalformedPageError, InvalidBaseError, loadable 8 | 9 | 10 | class MalformedCharacterPageError(MalformedPageError): 11 | """Indicates that a character-related page on MAL has irreparably broken markup in some way. 12 | """ 13 | pass 14 | 15 | 16 | class InvalidCharacterError(InvalidBaseError): 17 | """Indicates that the character requested does not exist on MAL. 18 | """ 19 | pass 20 | 21 | 22 | class Character(Base): 23 | """Primary interface to character resources on MAL. 24 | """ 25 | 26 | def __init__(self, session, character_id): 27 | """Creates a new instance of Character. 28 | 29 | :type session: :class:`myanimelist.session.Session` 30 | :param session: A valid MAL session 31 | :type character_id: int 32 | :param character_id: The desired character's ID on MAL 33 | 34 | :raises: :class:`.InvalidCharacterError` 35 | 36 | """ 37 | super(Character, self).__init__(session) 38 | self.id = character_id 39 | if not isinstance(self.id, int) or int(self.id) < 1: 40 | raise InvalidCharacterError(self.id) 41 | self._name = None 42 | self._full_name = None 43 | self._name_jpn = None 44 | self._description = None 45 | self._voice_actors = None 46 | self._animeography = None 47 | self._mangaography = None 48 | self._num_favorites = None 49 | self._favorites = None 50 | self._picture = None 51 | self._pictures = None 52 | self._clubs = None 53 | 54 | def parse_sidebar(self, character_page): 55 | """Parses the DOM and returns character attributes in the sidebar. 56 | 57 | :type character_page: :class:`lxml.html.HtmlElement` 58 | :param character_page: MAL character page's DOM 59 | 60 | :rtype: dict 61 | :return: Character attributes 62 | 63 | :raises: :class:`.InvalidCharacterError`, :class:`.MalformedCharacterPageError` 64 | """ 65 | character_info = {} 66 | 67 | error_tag = character_page.xpath(".//div[contains(@class,'error')] | .//div[@class='badresult']") 68 | if len(error_tag) > 0: 69 | # MAL says the character does not exist. 70 | raise InvalidCharacterError(self.id) 71 | 72 | info_panel_first = None 73 | 74 | try: 75 | container = character_page.find(".//div[@id='contentWrapper']") 76 | if container is None: 77 | raise MalformedCharacterPageError(self.id, character_page, message="Could not find title div") 78 | full_name_tag = container.find('.//h1') 79 | if full_name_tag is None: 80 | # Page is malformed. 81 | raise MalformedCharacterPageError(self.id, character_page, message="Could not find title div") 82 | title_tag_span = full_name_tag.find("span") 83 | title_text = utilities.extract_datasheet_title(title_tag_span) 84 | 85 | if title_text is None or title_text == '': 86 | raise MalformedCharacterPageError(self.id, character_page, message="Could not find character name") 87 | character_info['full_name'] = title_text 88 | info_panel_first = container.find(".//table/tr/td") 89 | except: 90 | if not self.session.suppress_parse_exceptions: 91 | raise 92 | 93 | if "Invalid" in character_info['full_name']: 94 | raise InvalidCharacterError(self.id) 95 | 96 | try: 97 | picture_tag = info_panel_first.find('.//img') 98 | character_info['picture'] = picture_tag.get('data-src') 99 | except: 100 | if not self.session.suppress_parse_exceptions: 101 | raise 102 | 103 | try: 104 | # assemble animeography for this character. 105 | character_info['animeography'] = {} 106 | temp = info_panel_first.xpath(".//div[text()[contains(.,'Animeography')]]") 107 | if len(temp) == 0: 108 | raise Exception("Could not find Animeography header") 109 | animeography_header = temp[0] 110 | animeography_table = animeography_header.xpath("./following-sibling::table[1]") 111 | if len(animeography_table) == 0: 112 | raise Exception("Could not find Animeography header") 113 | animeography_table = animeography_table[0] 114 | for row in animeography_table.findall('.//tr'): 115 | # second column has anime info. 116 | info_col = row.findall('.//td')[1] 117 | anime_link = info_col.find('a') 118 | link_parts = anime_link.get('href').split('/') 119 | # of the form: /anime/1/Cowboy_Bebop 120 | anime = self.session.anime(int(link_parts[4])).set({'title': anime_link.text}) 121 | role = info_col.find('.//small').text 122 | character_info['animeography'][anime] = role 123 | except: 124 | if not self.session.suppress_parse_exceptions: 125 | raise 126 | 127 | try: 128 | # assemble mangaography for this character. 129 | character_info['mangaography'] = {} 130 | temp = info_panel_first.xpath(".//div[text()[contains(.,'Mangaography')]]") 131 | if len(temp) == 0: 132 | raise Exception("Could not find Mangaography header") 133 | mangaography_header = temp[0] 134 | mangaography_table = mangaography_header.xpath("./following-sibling::table[1]")[0] 135 | for row in mangaography_table.findall('tr'): 136 | # second column has manga info. 137 | info_col = row.findall('.//td')[1] 138 | manga_link = info_col.find('a') 139 | link_parts = manga_link.get('href').split('/') 140 | # of the form: /manga/1/Cowboy_Bebop 141 | manga = self.session.manga(int(link_parts[4])).set({'title': manga_link.text}) 142 | role = info_col.find('.//small').text 143 | character_info['mangaography'][manga] = role 144 | except: 145 | if not self.session.suppress_parse_exceptions: 146 | raise 147 | 148 | try: 149 | temp = info_panel_first.xpath("./text()") 150 | if len(temp) > 0: 151 | needle = None 152 | for n in temp: 153 | ns = str(n) 154 | if "Favorites" in str(n): 155 | needle = ns 156 | break 157 | if needle is None: 158 | character_info['num_favorites'] = 0 159 | else: 160 | num_favorites_node = needle 161 | character_info['num_favorites'] = int(num_favorites_node.strip().split(': ')[1].replace(',', '')) 162 | else: 163 | character_info['num_favorites'] = 0 164 | except: 165 | if not self.session.suppress_parse_exceptions: 166 | raise 167 | 168 | return character_info 169 | 170 | def parse(self, character_page): 171 | """Parses the DOM and returns character attributes in the main-content area. 172 | 173 | :type character_page: :class:`lxml.html.HtmlElement` 174 | :param character_page: MAL character page's DOM 175 | 176 | :rtype: dict 177 | :return: Character attributes. 178 | 179 | """ 180 | character_info = self.parse_sidebar(character_page) 181 | 182 | second_col = character_page.find(".//div[@id='content']//table[1]//tr[1]/td[2]") 183 | name_elt = second_col.find(".//div[@class='normal_header']") 184 | 185 | try: 186 | name_jpn_node = name_elt.find('.//small') 187 | if name_jpn_node is not None: 188 | character_info['name_jpn'] = name_jpn_node.text[1:-1] 189 | else: 190 | character_info['name_jpn'] = None 191 | except: 192 | if not self.session.suppress_parse_exceptions: 193 | raise 194 | 195 | try: 196 | character_info['name'] = name_elt.text.rstrip() 197 | except: 198 | if not self.session.suppress_parse_exceptions: 199 | raise 200 | 201 | try: 202 | character_info['description'] = "".join(name_elt.xpath("./following-sibling::text()")).strip() 203 | except: 204 | if not self.session.suppress_parse_exceptions: 205 | raise 206 | 207 | try: 208 | character_info['voice_actors'] = {} 209 | voice_actors_header = second_col.xpath(".//div[text()[contains(.,'Voice Actors')]]")[0] 210 | if voice_actors_header is not None: 211 | voice_actors_tables = voice_actors_header.xpath("./following-sibling::table") 212 | for voice_actors_table in voice_actors_tables: 213 | for row in voice_actors_table.findall('tr'): 214 | # second column has va info. 215 | info_col = row.find('./td[2]') 216 | voice_actor_link = info_col.find('.//a') 217 | name = ' '.join(reversed(voice_actor_link.text.split(', '))) 218 | link_parts = voice_actor_link.get('href').split('/') 219 | # of the form: /people/82/Romi_Park 220 | if "myanimelist.net" in voice_actor_link.get('href'): 221 | person = self.session.person(int(link_parts[4])).set({'name': name}) 222 | else: 223 | person = self.session.person(int(link_parts[4])).set({'name': name}) 224 | language = info_col.find('.//small').text 225 | character_info['voice_actors'][person] = language 226 | except: 227 | if not self.session.suppress_parse_exceptions: 228 | raise 229 | 230 | return character_info 231 | 232 | def parse_favorites(self, favorites_page): 233 | """Parses the DOM and returns character favorites attributes. 234 | 235 | :type favorites_page: :class:`lxml.html.HtmlElement` 236 | :param favorites_page: MAL character favorites page's DOM 237 | 238 | :rtype: dict 239 | :return: Character favorites attributes. 240 | 241 | """ 242 | character_info = self.parse_sidebar(favorites_page) 243 | second_col = favorites_page.find(".//div[@id='content']//table//tr/td[2]") 244 | 245 | try: 246 | character_info['favorites'] = [] 247 | favorite_links = second_col.findall('a') 248 | for link in favorite_links: 249 | # of the form /profile/shaldengeki 250 | character_info['favorites'].append(self.session.user(username=link.text)) 251 | except: 252 | if not self.session.suppress_parse_exceptions: 253 | raise 254 | 255 | return character_info 256 | 257 | def parse_pictures(self, picture_page): 258 | """Parses the DOM and returns character pictures attributes. 259 | 260 | :type picture_page: :class:`lxml.html.HtmlElement` 261 | :param picture_page: MAL character pictures page's DOM 262 | 263 | :rtype: dict 264 | :return: character pictures attributes. 265 | 266 | """ 267 | character_info = self.parse_sidebar(picture_page) 268 | second_col = picture_page.find(".//div[@id='content']//table[1]//tr[1]/td[2]") 269 | 270 | if second_col is None: 271 | character_info['pictures'] = [] 272 | return character_info 273 | 274 | try: 275 | picture_table = second_col.find('.//table') 276 | character_info['pictures'] = [] 277 | if picture_table is not None: 278 | character_info['pictures'] = [img.get('data-src') for img in picture_table.findall('.//img')] 279 | except: 280 | if not self.session.suppress_parse_exceptions: 281 | raise 282 | 283 | return character_info 284 | 285 | def parse_clubs(self, clubs_page): 286 | """Parses the DOM and returns character clubs attributes. 287 | 288 | :type clubs_page: :class:`lxml.html.HtmlElement` 289 | :param clubs_page: MAL character clubs page's DOM 290 | 291 | :rtype: dict 292 | :return: character clubs attributes. 293 | 294 | """ 295 | character_info = self.parse_sidebar(clubs_page) 296 | second_col = clubs_page.find(".//div[@id='content']//table[1]//tr[1]/td[2]") 297 | 298 | try: 299 | character_info['clubs'] = [] 300 | clubs_header = second_col.xpath(".//h2[text()[contains(.,'Related Clubs')]]")[0] 301 | 302 | if clubs_header is not None: 303 | lines = clubs_header.xpath( 304 | "./following-sibling::div[@class='borderClass' and text()[not(contains(.,'No related clubs'))]]") 305 | for line in lines: 306 | curr_elt = line 307 | link = curr_elt.find('.//a') 308 | club_id = int(re.match(r'/clubs\.php\?cid=(?P[0-9]+)', link.get('href')).group('id')) 309 | num_members = int( 310 | re.match(r'(?P[0-9]+) members', curr_elt.find('.//small').text).group('num')) 311 | character_info['clubs'].append( 312 | self.session.club(club_id).set({'name': link.text, 'num_members': num_members})) 313 | except: 314 | if not self.session.suppress_parse_exceptions: 315 | raise 316 | 317 | return character_info 318 | 319 | def load(self): 320 | """Fetches the MAL character page and sets the current character's attributes. 321 | 322 | :rtype: :class:`.Character` 323 | :return: Current character object. 324 | 325 | """ 326 | character = self.session.get('https://myanimelist.net/character/' + str(self.id)).text 327 | self.set(self.parse(utilities.get_clean_dom(character))) 328 | return self 329 | 330 | def load_favorites(self): 331 | """Fetches the MAL character favorites page and sets the current character's favorites attributes. 332 | 333 | :rtype: :class:`.Character` 334 | :return: Current character object. 335 | 336 | """ 337 | character = self.session.get( 338 | 'https://myanimelist.net/character/' + str(self.id) + '/' + utilities.urlencode( 339 | self.name) + '/favorites').text 340 | self.set(self.parse_favorites(utilities.get_clean_dom(character))) 341 | return self 342 | 343 | def load_pictures(self): 344 | """Fetches the MAL character pictures page and sets the current character's pictures attributes. 345 | 346 | :rtype: :class:`.Character` 347 | :return: Current character object. 348 | 349 | """ 350 | character = self.session.get( 351 | 'https://myanimelist.net/character/' + str(self.id) + '/' + utilities.urlencode( 352 | self.name) + '/pictures').text 353 | self.set(self.parse_pictures(utilities.get_clean_dom(character))) 354 | return self 355 | 356 | def load_clubs(self): 357 | """Fetches the MAL character clubs page and sets the current character's clubs attributes. 358 | 359 | :rtype: :class:`.Character` 360 | :return: Current character object. 361 | 362 | """ 363 | character = self.session.get( 364 | 'https://myanimelist.net/character/' + str(self.id) + '/' + utilities.urlencode( 365 | self.name) + '/clubs').text 366 | self.set(self.parse_clubs(utilities.get_clean_dom(character))) 367 | return self 368 | 369 | @property 370 | @loadable('load') 371 | def name(self): 372 | """Character name. 373 | """ 374 | return self._name 375 | 376 | @property 377 | @loadable('load') 378 | def full_name(self): 379 | """Character's full name. 380 | """ 381 | return self._full_name 382 | 383 | @property 384 | @loadable('load') 385 | def name_jpn(self): 386 | """Character's Japanese name. 387 | """ 388 | return self._name_jpn 389 | 390 | @property 391 | @loadable('load') 392 | def description(self): 393 | """Character's description. 394 | """ 395 | return self._description 396 | 397 | @property 398 | @loadable('load') 399 | def voice_actors(self): 400 | """Voice actor dict for this character, with :class:`myanimelist.person.Person` objects as keys and the language as values. 401 | """ 402 | return self._voice_actors 403 | 404 | @property 405 | @loadable('load') 406 | def animeography(self): 407 | """Anime appearance dict for this character, with :class:`myanimelist.anime.Anime` objects as keys and the type of role as values, e.g. 'Main' 408 | """ 409 | return self._animeography 410 | 411 | @property 412 | @loadable('load') 413 | def mangaography(self): 414 | """Manga appearance dict for this character, with :class:`myanimelist.manga.Manga` objects as keys and the type of role as values, e.g. 'Main' 415 | """ 416 | return self._mangaography 417 | 418 | @property 419 | @loadable('load') 420 | def num_favorites(self): 421 | """Number of users who have favourited this character. 422 | """ 423 | return self._num_favorites 424 | 425 | @property 426 | @loadable('load_favorites') 427 | def favorites(self): 428 | """List of users who have favourited this character. 429 | """ 430 | return self._favorites 431 | 432 | @property 433 | @loadable('load') 434 | def picture(self): 435 | """URL of primary picture for this character. 436 | """ 437 | return self._picture 438 | 439 | @property 440 | @loadable('load_pictures') 441 | def pictures(self): 442 | """List of picture URLs for this character. 443 | """ 444 | return self._pictures 445 | 446 | @property 447 | @loadable('load_clubs') 448 | def clubs(self): 449 | """List of clubs relevant to this character. 450 | """ 451 | return self._clubs 452 | -------------------------------------------------------------------------------- /myanimelist/club.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | 6 | from . import utilities 7 | from .base import Base, MalformedPageError, InvalidBaseError, loadable 8 | 9 | 10 | class MalformedClubPageError(MalformedPageError): 11 | pass 12 | 13 | 14 | class InvalidClubError(InvalidBaseError): 15 | pass 16 | 17 | 18 | class Club(Base): 19 | def __init__(self, session, club_id): 20 | super(Club, self).__init__(session) 21 | self.id = club_id 22 | if not isinstance(self.id, int) or int(self.id) < 1: 23 | raise InvalidClubError(self.id) 24 | self._name = None 25 | self._num_members = None 26 | 27 | def load(self): 28 | # TODO 29 | pass 30 | 31 | @property 32 | @loadable('load') 33 | def name(self): 34 | return self._name 35 | 36 | @property 37 | @loadable('load') 38 | def num_members(self): 39 | return self._num_members 40 | -------------------------------------------------------------------------------- /myanimelist/genre.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | 6 | from . import utilities 7 | from .base import Base, MalformedPageError, InvalidBaseError, loadable 8 | 9 | 10 | class MalformedGenrePageError(MalformedPageError): 11 | pass 12 | 13 | 14 | class InvalidGenreError(InvalidBaseError): 15 | pass 16 | 17 | 18 | class Genre(Base): 19 | def __init__(self, session, genre_id): 20 | super(Genre, self).__init__(session) 21 | self.id = genre_id 22 | if not isinstance(self.id, int) or int(self.id) < 1: 23 | raise InvalidGenreError(self.id) 24 | self._name = None 25 | 26 | def parse(self, genre_page): 27 | """Parses the DOM and returns genre attributes in the main-content area. 28 | 29 | :type genre_page: :class:`lxml.html.HtmlElement` 30 | :param genre_page: MAL character page's DOM 31 | 32 | :rtype: dict 33 | :return: Genre attributes. 34 | 35 | """ 36 | genre_info = {} 37 | 38 | try: 39 | header = genre_page.find(".//div[@id='contentWrapper']//h1[@class='h1']") 40 | if header is not None: 41 | genre_info["name"] = header.text.split(' ')[0] 42 | except: 43 | if not self.session.suppress_parse_exceptions: 44 | raise 45 | 46 | return genre_info 47 | 48 | def load(self): 49 | genre = self.session.get('https://myanimelist.net/anime/genre/' + str(self.id)).text 50 | self.set(self.parse(utilities.get_clean_dom(genre))) 51 | pass 52 | 53 | @property 54 | @loadable('load') 55 | def name(self): 56 | return self._name 57 | -------------------------------------------------------------------------------- /myanimelist/manga.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | from . import utilities 4 | from .base import Base, Error, loadable 5 | from . import media 6 | 7 | 8 | class MalformedMangaPageError(media.MalformedMediaPageError): 9 | """Indicates that a manga-related page on MAL has irreparably broken markup in some way. 10 | """ 11 | pass 12 | 13 | 14 | class InvalidMangaError(media.InvalidMediaError): 15 | """Indicates that the manga requested does not exist on MAL. 16 | """ 17 | pass 18 | 19 | 20 | class Manga(media.Media): 21 | """Primary interface to manga resources on MAL. 22 | """ 23 | _status_terms = [ 24 | 'Unknown', 25 | 'Publishing', 26 | 'Finished', 27 | 'Not yet published' 28 | ] 29 | _consuming_verb = "read" 30 | 31 | def __init__(self, session, manga_id): 32 | """Creates a new instance of Manga. 33 | 34 | :type session: :class:`myanimelist.session.Session` 35 | :param session: A valid MAL session 36 | :type manga_id: int 37 | :param manga_id: The desired manga's ID on MAL 38 | 39 | :raises: :class:`.InvalidMangaError` 40 | 41 | """ 42 | if not isinstance(manga_id, int) or int(manga_id) < 1: 43 | raise InvalidMangaError(manga_id) 44 | super(Manga, self).__init__(session, manga_id) 45 | self._volumes = None 46 | self._chapters = None 47 | self._published = None 48 | self._authors = None 49 | self._serialization = None 50 | 51 | def parse_sidebar(self, manga_page): 52 | """Parses the DOM and returns manga attributes in the sidebar. 53 | 54 | :type manga_page: :class:`lxml.html.HtmlElement` 55 | :param manga_page: MAL manga page's DOM 56 | 57 | :rtype: dict 58 | :return: manga attributes 59 | 60 | :raises: :class:`.InvalidMangaError`, :class:`.MalformedMangaPageError` 61 | """ 62 | # if MAL says the series doesn't exist, raise an InvalidMangaError. 63 | if not self._validate_page(manga_page): 64 | raise InvalidMangaError(self.id) 65 | 66 | title_tag = manga_page.xpath(".//div[@id='contentWrapper']//h1") 67 | if len(title_tag) == 0: 68 | raise MalformedMangaPageError(self.id, manga_page, message="Could not find title div") 69 | 70 | # otherwise, begin parsing. 71 | manga_info = super(Manga, self).parse_sidebar(manga_page) 72 | info_panel_first = None 73 | 74 | try: 75 | container = utilities.css_select("#content", manga_page) 76 | if container is None: 77 | raise MalformedMangaPageError(self.id, manga_page, message="Could not find the info table") 78 | 79 | info_panel_first = container[0].find(".//table/tr/td") 80 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Volumes:')]]") 81 | if len(temp) == 0: 82 | raise Exception("Couldn't find volumes tag.") 83 | volumes_tag = temp[0].getparent().xpath(".//text()")[-1] 84 | manga_info['volumes'] = int(volumes_tag.strip()) if volumes_tag.strip() != 'Unknown' else None 85 | except: 86 | if not self.session.suppress_parse_exceptions: 87 | raise 88 | 89 | try: 90 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Chapters:')]]") 91 | if len(temp) == 0: 92 | raise Exception("Couldn't find chapters tag.") 93 | chapters_tag = temp[0].getparent().xpath(".//text()")[-1] 94 | manga_info['chapters'] = int(chapters_tag.strip()) if chapters_tag.strip() != 'Unknown' else None 95 | except: 96 | if not self.session.suppress_parse_exceptions: 97 | raise 98 | 99 | try: 100 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Published:')]]") 101 | if len(temp) == 0: 102 | raise Exception("Couldn't find published tag.") 103 | published_tag = temp[0].getparent().xpath(".//text()")[-1] 104 | published_parts = published_tag.strip().split(' to ') 105 | if len(published_parts) == 1: 106 | # this published once. 107 | try: 108 | published_date = utilities.parse_profile_date(published_parts[0]) 109 | except ValueError: 110 | raise MalformedMangaPageError(self.id, published_parts[0], 111 | message="Could not parse single publish date") 112 | manga_info['published'] = (published_date,) 113 | else: 114 | # two publishing dates. 115 | try: 116 | publish_start = utilities.parse_profile_date(published_parts[0]) 117 | except ValueError: 118 | raise MalformedMangaPageError(self.id, published_parts[0], 119 | message="Could not parse first of two publish dates") 120 | if published_parts == '?': 121 | # this is still publishing. 122 | publish_end = None 123 | else: 124 | try: 125 | publish_end = utilities.parse_profile_date(published_parts[1]) 126 | except ValueError: 127 | raise MalformedMangaPageError(self.id, published_parts[1], 128 | message="Could not parse second of two publish dates") 129 | manga_info['published'] = (publish_start, publish_end) 130 | except: 131 | if not self.session.suppress_parse_exceptions: 132 | raise 133 | 134 | try: 135 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Authors:')]]") 136 | if len(temp) == 0: 137 | raise Exception("Couldn't find authors tag.") 138 | authors_tags = temp[0].getparent().xpath(".//a") 139 | manga_info['authors'] = {} 140 | for author_link in authors_tags: 141 | link_parts = author_link.get('href').split('/') 142 | # of the form /people/1867/Naoki_Urasawa 143 | person = self.session.person(int(link_parts[2])).set({'name': author_link.text}) 144 | role = author_link.xpath("./following-sibling::text()")[0].replace(' (', '').replace(')', '') 145 | manga_info['authors'][person] = role 146 | except: 147 | if not self.session.suppress_parse_exceptions: 148 | raise 149 | 150 | try: 151 | temp = info_panel_first.xpath(".//div/span[text()[contains(.,'Serialization:')]]") 152 | if len(temp) == 0: 153 | raise Exception("Couldn't find authors tag.") 154 | serialization_tags = temp[0].getparent().xpath(".//a") 155 | 156 | manga_info['serialization'] = None 157 | if len(serialization_tags) != 0: 158 | publication_link = serialization_tags[0] 159 | link_parts = publication_link.get('href').split('mid=') 160 | if len(link_parts) != 1: 161 | # backwards compatibility 162 | # of the form /manga.php?mid=1 163 | manga_info['serialization'] = self.session.publication(int(link_parts[1])).set( 164 | {'name': publication_link.text}) 165 | else: 166 | # of the form /manga/magazine/83/ 167 | link_parts = publication_link.get('href').split('/') 168 | manga_info['serialization'] = self.session.publication(int(link_parts[-2])).set( 169 | {'name': publication_link.text}) 170 | except: 171 | if not self.session.suppress_parse_exceptions: 172 | raise 173 | 174 | return manga_info 175 | 176 | @property 177 | @loadable('load') 178 | def volumes(self): 179 | """The number of volumes in this manga. 180 | """ 181 | return self._volumes 182 | 183 | @property 184 | @loadable('load') 185 | def chapters(self): 186 | """The number of chapters in this manga. 187 | """ 188 | return self._chapters 189 | 190 | @property 191 | @loadable('load') 192 | def published(self): 193 | """A tuple(2) containing up to two :class:`datetime.date` objects representing the start and end dates of this manga's publishing. 194 | 195 | Potential configurations: 196 | 197 | None -- Completely-unknown publishing dates. 198 | 199 | (:class:`datetime.date`, None) -- Manga start date is known, end date is unknown. 200 | 201 | (:class:`datetime.date`, :class:`datetime.date`) -- Manga start and end dates are known. 202 | """ 203 | return self._published 204 | 205 | @property 206 | @loadable('load') 207 | def authors(self): 208 | """An author dict with :class:`myanimelist.person.Person` objects of the authors as keys, and strings describing the duties of these authors as values. 209 | """ 210 | return self._authors 211 | 212 | @property 213 | @loadable('load') 214 | def serialization(self): 215 | """The :class:`myanimelist.publication.Publication` involved in the first serialization of this manga. 216 | """ 217 | return self._serialization 218 | -------------------------------------------------------------------------------- /myanimelist/manga_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from . import utilities 5 | from .base import Base, Error, loadable 6 | from . import media_list 7 | 8 | 9 | class MangaList(media_list.MediaList): 10 | __id_attribute = "username" 11 | 12 | def __init__(self, session, user_name): 13 | super(MangaList, self).__init__(session, user_name) 14 | 15 | @property 16 | def type(self): 17 | return "manga" 18 | 19 | @property 20 | def verb(self): 21 | return "read" 22 | 23 | def parse_entry_media_attributes(self, soup): 24 | attributes = super(MangaList, self).parse_entry_media_attributes(soup) 25 | 26 | try: 27 | attributes['chapters'] = int(soup.find('.//series_chapters').text) 28 | except ValueError: 29 | attributes['chapters'] = None 30 | except: 31 | if not self.session.suppress_parse_exceptions: 32 | raise 33 | 34 | try: 35 | attributes['volumes'] = int(soup.find('.//series_volumes').text) 36 | except ValueError: 37 | attributes['volumes'] = None 38 | except: 39 | if not self.session.suppress_parse_exceptions: 40 | raise 41 | 42 | return attributes 43 | 44 | def parse_entry(self, soup): 45 | manga, entry_info = super(MangaList, self).parse_entry(soup) 46 | 47 | try: 48 | entry_info['chapters_read'] = int(soup.find('.//my_read_chapters').text) 49 | except ValueError: 50 | entry_info['chapters_read'] = 0 51 | except: 52 | if not self.session.suppress_parse_exceptions: 53 | raise 54 | 55 | try: 56 | entry_info['volumes_read'] = int(soup.find('.//my_read_volumes').text) 57 | except ValueError: 58 | entry_info['volumes_read'] = 0 59 | except: 60 | if not self.session.suppress_parse_exceptions: 61 | raise 62 | 63 | try: 64 | entry_info['rereading'] = bool(soup.find('.//my_rereadingg').text) 65 | except ValueError: 66 | entry_info['rereading'] = False 67 | except: 68 | if not self.session.suppress_parse_exceptions: 69 | raise 70 | 71 | try: 72 | entry_info['chapters_reread'] = int(soup.find('.//my_rereading_chap').text) 73 | except ValueError: 74 | entry_info['chapters_reread'] = 0 75 | except: 76 | if not self.session.suppress_parse_exceptions: 77 | raise 78 | 79 | return manga, entry_info 80 | -------------------------------------------------------------------------------- /myanimelist/media_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import abc 5 | import collections 6 | from lxml import etree as et 7 | import decimal 8 | import datetime 9 | import urllib.request, urllib.parse, urllib.error 10 | 11 | from . import utilities 12 | from .base import Base, MalformedPageError, InvalidBaseError, loadable 13 | 14 | 15 | class MalformedMediaListPageError(MalformedPageError): 16 | pass 17 | 18 | 19 | class InvalidMediaListError(InvalidBaseError): 20 | pass 21 | 22 | 23 | class MediaList(Base, collections.Mapping, metaclass=abc.ABCMeta): 24 | _id_attribute = "username" 25 | 26 | def __getitem__(self, media): 27 | return self.list[media] 28 | 29 | def __contains__(self, media): 30 | return media in self.list 31 | 32 | def __len__(self): 33 | return len(self.list) 34 | 35 | def __iter__(self): 36 | for media in self.list: 37 | yield media 38 | 39 | def __init__(self, session, user_name): 40 | super(MediaList, self).__init__(session) 41 | self.username = user_name 42 | if not isinstance(self.username, str) or len(self.username) < 1: 43 | raise InvalidMediaListError(self.username) 44 | self._list = None 45 | self._stats = None 46 | 47 | # subclasses must define a list type, ala "anime" or "manga" 48 | @abc.abstractmethod 49 | def type(self): 50 | pass 51 | 52 | # a list verb ala "watch", "read", etc 53 | @abc.abstractmethod 54 | def verb(self): 55 | pass 56 | 57 | # a list with status ints as indices and status texts as values. 58 | @property 59 | def user_status_terms(self): 60 | statuses = collections.defaultdict(lambda: 'Unknown') 61 | statuses[1] = self.verb.capitalize() + 'ing' 62 | statuses[2] = 'Completed' 63 | statuses[3] = 'On-Hold' 64 | statuses[4] = 'Dropped' 65 | statuses[6] = 'Plan to ' + self.verb.capitalize() 66 | return statuses 67 | 68 | def parse_entry_media_attributes(self, soup): 69 | """ 70 | Args: 71 | soup: a lxml.html.HtmlElement containing a row from the current media list 72 | 73 | Return a dict of attributes of the media the row is about. 74 | """ 75 | row_info = {} 76 | 77 | try: 78 | start = utilities.parse_profile_date(soup.find('.//series_start').text) 79 | except ValueError: 80 | start = None 81 | except: 82 | if not self.session.suppress_parse_exceptions: 83 | raise 84 | 85 | if start is not None: 86 | try: 87 | row_info['aired'] = (start, utilities.parse_profile_date(soup.find('.//series_end').text)) 88 | except ValueError: 89 | row_info['aired'] = (start, None) 90 | except: 91 | if not self.session.suppress_parse_exceptions: 92 | raise 93 | 94 | # look up the given media type's status terms. 95 | status_terms = getattr(self.session, self.type)(1)._status_terms 96 | 97 | try: 98 | row_info['id'] = int(soup.find('.//series_' + self.type + 'db_id').text) 99 | except: 100 | if not self.session.suppress_parse_exceptions: 101 | raise 102 | 103 | try: 104 | row_info['title'] = soup.find('.//series_title').text 105 | except: 106 | if not self.session.suppress_parse_exceptions: 107 | raise 108 | 109 | try: 110 | row_info['status'] = status_terms[int(soup.find('.//series_status').text)] 111 | except: 112 | if not self.session.suppress_parse_exceptions: 113 | raise 114 | 115 | try: 116 | row_info['picture'] = soup.find('.//series_image').text 117 | except: 118 | if not self.session.suppress_parse_exceptions: 119 | raise 120 | 121 | return row_info 122 | 123 | def parse_entry(self, soup): 124 | """ 125 | Given: 126 | soup: a lxml.html.HtmlElement containing a row from the current media list 127 | 128 | Return a tuple: 129 | (media object, dict of this row's parseable attributes) 130 | """ 131 | # parse the media object first. 132 | media_attrs = self.parse_entry_media_attributes(soup) 133 | media_id = media_attrs['id'] 134 | del media_attrs['id'] 135 | media = getattr(self.session, self.type)(media_id).set(media_attrs) 136 | 137 | entry_info = {} 138 | try: 139 | entry_info['started'] = utilities.parse_profile_date(soup.find('.//my_start_date').text) 140 | except ValueError: 141 | entry_info['started'] = None 142 | except: 143 | if not self.session.suppress_parse_exceptions: 144 | raise 145 | 146 | try: 147 | entry_info['finished'] = utilities.parse_profile_date(soup.find('.//my_finish_date').text) 148 | except ValueError: 149 | entry_info['finished'] = None 150 | except: 151 | if not self.session.suppress_parse_exceptions: 152 | raise 153 | 154 | try: 155 | entry_info['status'] = self.user_status_terms[int(soup.find('.//my_status').text)] 156 | except: 157 | if not self.session.suppress_parse_exceptions: 158 | raise 159 | 160 | try: 161 | entry_info['score'] = int(soup.find('.//my_score').text) 162 | # if user hasn't set a score, set it to None to indicate as such. 163 | if entry_info['score'] == 0: 164 | entry_info['score'] = None 165 | except: 166 | if not self.session.suppress_parse_exceptions: 167 | raise 168 | 169 | try: 170 | entry_info['last_updated'] = datetime.datetime.fromtimestamp(int(soup.find('.//my_last_updated').text)) 171 | except: 172 | if not self.session.suppress_parse_exceptions: 173 | raise 174 | 175 | return media, entry_info 176 | 177 | def parse_stats(self, soup): 178 | """ 179 | Given: 180 | soup: a lxml.etree element containing the current media list's stats 181 | 182 | Return a dict of this media list's stats. 183 | """ 184 | stats = {} 185 | for row in soup.getchildren(): 186 | try: 187 | key = row.tag.replace('user_', '') 188 | if key == 'id': 189 | stats[key] = int(row.text) 190 | elif key == 'name': 191 | stats[key] = row.text 192 | elif key == self.verb + 'ing': 193 | try: 194 | stats[key] = int(row.text) 195 | except ValueError: 196 | stats[key] = 0 197 | elif key == 'completed': 198 | try: 199 | stats[key] = int(row.text) 200 | except ValueError: 201 | stats[key] = 0 202 | elif key == 'onhold': 203 | try: 204 | stats['on_hold'] = int(row.text) 205 | except ValueError: 206 | stats[key] = 0 207 | elif key == 'dropped': 208 | try: 209 | stats[key] = int(row.text) 210 | except ValueError: 211 | stats[key] = 0 212 | elif key == 'planto' + self.verb: 213 | try: 214 | stats['plan_to_' + self.verb] = int(row.text) 215 | except ValueError: 216 | stats[key] = 0 217 | # for some reason, MAL doesn't substitute 'read' in for manga for the verb here 218 | elif key == 'days_spent_watching': 219 | try: 220 | stats['days_spent'] = decimal.Decimal(row.text) 221 | except decimal.InvalidOperation: 222 | stats[key] = decimal.Decimal(0) 223 | except: 224 | if not self.session.suppress_parse_exceptions: 225 | raise 226 | return stats 227 | 228 | def parse(self, xml): 229 | list_info = {} 230 | list_page = et.fromstring(xml.encode()) 231 | 232 | primary_elt = list_page 233 | if primary_elt is None: 234 | raise MalformedMediaListPageError(self.username, xml, 235 | message="Could not find root XML element in " + self.type + " list") 236 | 237 | bad_username_elt = list_page.find('.//error') 238 | if bad_username_elt is not None: 239 | raise InvalidMediaListError(self.username, message="Invalid username when fetching " + self.type + " list") 240 | 241 | stats_elt = list_page.find('.//myinfo') 242 | if stats_elt is None and not utilities.check_if_mal_response_is_empty(list_page): 243 | raise MalformedMediaListPageError(self.username, xml, 244 | message="Could not find stats element in " + self.type + " list") 245 | 246 | if utilities.check_if_mal_response_is_empty(list_page): 247 | raise InvalidMediaListError(self.username, message="Empty result set when fetching " + self.type + " list") 248 | 249 | list_info['stats'] = self.parse_stats(stats_elt) 250 | 251 | list_info['list'] = {} 252 | for row in list_page.findall(".//%s" % self.type): 253 | (media, entry) = self.parse_entry(row) 254 | list_info['list'][media] = entry 255 | 256 | return list_info 257 | 258 | def load(self): 259 | media_list = self.session.get('https://myanimelist.net/malappinfo.php?' + urllib.parse.urlencode( 260 | {'u': self.username, 'status': 'all', 'type': self.type})).text 261 | self.set(self.parse(media_list)) 262 | return self 263 | 264 | @property 265 | @loadable('load') 266 | def list(self): 267 | return self._list 268 | 269 | @property 270 | @loadable('load') 271 | def stats(self): 272 | return self._stats 273 | 274 | def section(self, status): 275 | return {k: self.list[k] for k in self.list if self.list[k]['status'] == status} 276 | -------------------------------------------------------------------------------- /myanimelist/myanimelist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # from http import client as httplib 5 | 6 | 7 | # causes httplib to return the partial response from a server in case the read fails to be complete. 8 | # def patch_http_response_read(func): 9 | # def inner(*args): 10 | # try: 11 | # return func(*args) 12 | # except httplib.IncompleteRead as e: 13 | # return e.partial 14 | # 15 | # return inner 16 | # 17 | # 18 | # httplib.HTTPResponse.read = patch_http_response_read(httplib.HTTPResponse.read) 19 | -------------------------------------------------------------------------------- /myanimelist/person.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | 6 | from . import utilities 7 | from .base import Base, MalformedPageError, InvalidBaseError, loadable 8 | 9 | 10 | class MalformedPersonPageError(MalformedPageError): 11 | pass 12 | 13 | 14 | class InvalidPersonError(InvalidBaseError): 15 | pass 16 | 17 | 18 | class Person(Base): 19 | def __init__(self, session, person_id): 20 | super(Person, self).__init__(session) 21 | self.id = person_id 22 | if not isinstance(self.id, int) or int(self.id) < 1: 23 | raise InvalidPersonError(self.id) 24 | self._name = None 25 | 26 | def load(self): 27 | # TODO 28 | pass 29 | 30 | @property 31 | @loadable('load') 32 | def name(self): 33 | return self._name 34 | -------------------------------------------------------------------------------- /myanimelist/producer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | 6 | from . import utilities 7 | from .base import Base, MalformedPageError, InvalidBaseError, loadable 8 | 9 | 10 | class MalformedProducerPageError(MalformedPageError): 11 | pass 12 | 13 | 14 | class InvalidProducerError(InvalidBaseError): 15 | pass 16 | 17 | 18 | class Producer(Base): 19 | def __init__(self, session, producer_id): 20 | super(Producer, self).__init__(session) 21 | self.id = producer_id 22 | if not isinstance(self.id, int) or int(self.id) < 1: 23 | raise InvalidProducerError(self.id) 24 | self._name = None 25 | 26 | def load(self): 27 | # TODO 28 | pass 29 | 30 | @property 31 | @loadable('load') 32 | def name(self): 33 | return self._name 34 | -------------------------------------------------------------------------------- /myanimelist/publication.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import re 4 | 5 | from . import utilities 6 | from .base import Base, MalformedPageError, InvalidBaseError, loadable 7 | 8 | 9 | class MalformedPublicationPageError(MalformedPageError): 10 | pass 11 | 12 | 13 | class InvalidPublicationError(InvalidBaseError): 14 | pass 15 | 16 | 17 | class Publication(Base): 18 | def __init__(self, session, publication_id): 19 | super(Publication, self).__init__(session) 20 | self.id = publication_id 21 | if not isinstance(self.id, int) or int(self.id) < 1: 22 | raise InvalidPublicationError(self.id) 23 | self._name = None 24 | 25 | def load(self): 26 | # TODO 27 | pass 28 | 29 | @property 30 | @loadable('load') 31 | def name(self): 32 | return self._name 33 | -------------------------------------------------------------------------------- /myanimelist/session.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import requests 5 | 6 | from . import anime 7 | from . import manga 8 | 9 | from . import character 10 | from . import person 11 | 12 | from . import user 13 | 14 | from . import club 15 | from . import genre 16 | from . import tag 17 | from . import publication 18 | from . import producer 19 | 20 | from . import anime_list 21 | from . import manga_list 22 | 23 | from .base import Error 24 | 25 | from lxml import html as ht 26 | import time 27 | 28 | 29 | class UnauthorizedError(Error): 30 | """ 31 | Indicates that the current session is unauthorized to make the given request. 32 | """ 33 | 34 | def __init__(self, session, url, result): 35 | """Creates a new instance of UnauthorizedError. 36 | 37 | :type session: :class:`.Session` 38 | :param session: A valid MAL session. 39 | 40 | :type url: str 41 | :param url: The requested URL. 42 | 43 | :type result: str 44 | :param result: The result of the request. 45 | 46 | :rtype: :class:`.UnauthorizedError` 47 | :return: The desired error. 48 | 49 | """ 50 | super(UnauthorizedError, self).__init__() 51 | self.session = session 52 | self.url = url 53 | self.result = result 54 | 55 | def __str__(self): 56 | return "\n".join([ 57 | super(UnauthorizedError, self).__str__(), 58 | "URL: " + self.url, 59 | "Result: " + self.result 60 | ]) 61 | 62 | 63 | class Session(object): 64 | """Class to handle requests to MAL. Handles login, setting HTTP headers, etc. 65 | """ 66 | 67 | def __init__(self, username=None, password=None, 68 | user_agent="Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0", 69 | proxy_settings=None): 70 | """Creates a new instance of Session. 71 | 72 | :type username: str 73 | :param username: A MAL username. May be omitted. 74 | 75 | :type password: str 76 | :param username: A MAL password. May be omitted. 77 | 78 | :type user_agent: str 79 | :param user_agent: A user-agent to send to MAL in requests. If you have a user-agent assigned to you by Incapsula, pass it in here. 80 | 81 | :rtype: :class:`.Session` 82 | :return: The desired session. 83 | 84 | """ 85 | self.username = username 86 | self.password = password 87 | self.session = requests.Session() 88 | self.session.headers.update({ 89 | 'User-Agent': user_agent, 90 | 'DNT': "1", 91 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 92 | 'Accept-Encoding': 'gzip, deflate', 93 | 'Accept-Language': 'en-US,en;q=0.7,ja;q=0.3', 94 | 'Connection': 'keep-alive', 95 | }) 96 | if proxy_settings is not None and type(proxy_settings) is dict: 97 | self.session.proxies.update(proxy_settings) 98 | 99 | """Suppresses any Malformed*PageError exceptions raised during parsing. 100 | 101 | Attributes which raise these exceptions will be set to None. 102 | """ 103 | self.suppress_parse_exceptions = False 104 | 105 | def logged_in(self): 106 | """Checks the logged-in status of the current session. 107 | Expensive (requests a page), so use sparingly! Best practice is to try a request and catch an UnauthorizedError. 108 | 109 | :rtype: bool 110 | :return: Whether or not the current session is logged-in. 111 | 112 | """ 113 | if self.session is None: 114 | return False 115 | 116 | panel_url = 'https://myanimelist.net' 117 | panel = self.session.get(panel_url) 118 | html = ht.fromstring(panel.content.decode("utf-8")) 119 | 120 | if 'Logout' in panel.content.decode("utf-8") or len(html.xpath(".//*[text()[contains(.,'Logout')]]")) > 0: 121 | return True 122 | 123 | if len(html.xpath("//form[@action='https://myanimelist.net/logout.php']")) > 0: 124 | return True 125 | 126 | return False 127 | 128 | def login(self): 129 | """Logs into MAL and sets cookies appropriately. 130 | 131 | :rtype: :class:`.Session` 132 | :return: The current session. 133 | 134 | """ 135 | mal_headers = { 136 | 'Host': 'myanimelist.net', 137 | 'DNT': '1', 138 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 139 | 'Accept-Encoding': 'gzip, deflate', 140 | 'Accept-Language': 'en-US,en;q=0.7,ja;q=0.3', 141 | 'Connection': 'keep-alive', 142 | 'Content-Type': 'application/x-www-form-urlencoded', 143 | 'User-Agent': 'iMAL-iOS', 144 | } 145 | 146 | panel_url = 'https://myanimelist.net' 147 | # set the session cookies: 148 | r = self.session.get(panel_url) 149 | 150 | if len(r.history) > 0: 151 | cookies = r.history[0].cookies 152 | html = ht.fromstring(r.content.decode("utf-8")) 153 | token_tag = html.xpath(".//meta[@name='csrf_token']") 154 | else: 155 | cookies = r.cookies 156 | html = ht.fromstring(r.content.decode("utf-8")) 157 | token_tag = html.xpath(".//meta[@name='csrf_token']") 158 | 159 | if len(token_tag) == 0: 160 | return self 161 | 162 | token_tag = token_tag[0] 163 | token = token_tag.get("content") 164 | 165 | # POSTS a login to mal_crawler. 166 | mal_payload = { 167 | 'user_name': self.username, 168 | 'password': self.password, 169 | 'cookie': '1', 170 | 'sublogin': 'Login', 171 | 'submit': '1', 172 | 'csrf_token': token 173 | } 174 | self.session.headers.update(mal_headers) 175 | if "MALHLOGSESSID" in cookies.keys(): 176 | self.session.cookies = cookies 177 | r = self.session.post('https://myanimelist.net/login.php?from=/', data=mal_payload) 178 | 179 | x = ht.fromstring(r.content.decode('utf-8')) 180 | if len(x.xpath("//div[@class='badresult']")) > 0: 181 | print('Captcha is required to login. Please login first on the website and try again.') 182 | 183 | # remove content type: 184 | self.session.headers.pop("Content-Type") 185 | return self 186 | 187 | def anime(self, anime_id): 188 | """Creates an instance of myanimelist.Anime with the given ID. 189 | 190 | :type anime_id: int 191 | :param anime_id: The desired anime's ID. 192 | 193 | :rtype: :class:`myanimelist.anime.Anime` 194 | :return: A new Anime instance with the given ID. 195 | 196 | """ 197 | return anime.Anime(self, anime_id) 198 | 199 | def anime_list(self, username): 200 | """Creates an instance of myanimelist.AnimeList belonging to the given username. 201 | 202 | :type username: str 203 | :param username: The username to whom the desired anime list belongs. 204 | 205 | :rtype: :class:`myanimelist.anime_list.AnimeList` 206 | :return: A new AnimeList instance belonging to the given username. 207 | 208 | """ 209 | return anime_list.AnimeList(self, username) 210 | 211 | def character(self, character_id): 212 | """Creates an instance of myanimelist.Character with the given ID. 213 | 214 | :type character_id: int 215 | :param character_id: The desired character's ID. 216 | 217 | :rtype: :class:`myanimelist.character.Character` 218 | :return: A new Character instance with the given ID. 219 | 220 | """ 221 | return character.Character(self, character_id) 222 | 223 | def club(self, club_id): 224 | """Creates an instance of myanimelist.Club with the given ID. 225 | 226 | :type club_id: int 227 | :param club_id: The desired club's ID. 228 | 229 | :rtype: :class:`myanimelist.club.Club` 230 | :return: A new Club instance with the given ID. 231 | 232 | """ 233 | return club.Club(self, club_id) 234 | 235 | def genre(self, genre_id): 236 | """Creates an instance of myanimelist.Genre with the given ID. 237 | 238 | :type genre_id: int 239 | :param genre_id: The desired genre's ID. 240 | 241 | :rtype: :class:`myanimelist.genre.Genre` 242 | :return: A new Genre instance with the given ID. 243 | 244 | """ 245 | return genre.Genre(self, genre_id) 246 | 247 | def manga(self, manga_id): 248 | """Creates an instance of myanimelist.Manga with the given ID. 249 | 250 | :type manga_id: int 251 | :param manga_id: The desired manga's ID. 252 | 253 | :rtype: :class:`myanimelist.manga.Manga` 254 | :return: A new Manga instance with the given ID. 255 | 256 | """ 257 | return manga.Manga(self, manga_id) 258 | 259 | def manga_list(self, username): 260 | """Creates an instance of myanimelist.MangaList belonging to the given username. 261 | 262 | :type username: str 263 | :param username: The username to whom the desired manga list belongs. 264 | 265 | :rtype: :class:`myanimelist.manga_list.MangaList` 266 | :return: A new MangaList instance belonging to the given username. 267 | 268 | """ 269 | return manga_list.MangaList(self, username) 270 | 271 | def person(self, person_id): 272 | """Creates an instance of myanimelist.Person with the given ID. 273 | 274 | :type person_id: int 275 | :param person_id: The desired person's ID. 276 | 277 | :rtype: :class:`myanimelist.person.Person` 278 | :return: A new Person instance with the given ID. 279 | 280 | """ 281 | return person.Person(self, person_id) 282 | 283 | def producer(self, producer_id): 284 | """Creates an instance of myanimelist.Producer with the given ID. 285 | 286 | :type producer_id: int 287 | :param producer_id: The desired producer's ID. 288 | 289 | :rtype: :class:`myanimelist.producer.Producer` 290 | :return: A new Producer instance with the given ID. 291 | 292 | """ 293 | return producer.Producer(self, producer_id) 294 | 295 | def publication(self, publication_id): 296 | """Creates an instance of myanimelist.Publication with the given ID. 297 | 298 | :type publication_id: int 299 | :param publication_id: The desired publication's ID. 300 | 301 | :rtype: :class:`myanimelist.publication.Publication` 302 | :return: A new Publication instance with the given ID. 303 | 304 | """ 305 | return publication.Publication(self, publication_id) 306 | 307 | def tag(self, tag_id): 308 | """Creates an instance of myanimelist.Tag with the given ID. 309 | 310 | :type tag_id: int 311 | :param tag_id: The desired tag's ID. 312 | 313 | :rtype: :class:`myanimelist.tag.Tag` 314 | :return: A new Tag instance with the given ID. 315 | 316 | """ 317 | return tag.Tag(self, tag_id) 318 | 319 | def user(self, username): 320 | """Creates an instance of myanimelist.User with the given username 321 | 322 | :type username: str 323 | :param username: The desired user's username. 324 | 325 | :rtype: :class:`myanimelist.user.User` 326 | :return: A new User instance with the given username. 327 | 328 | """ 329 | return user.User(self, username) 330 | 331 | def get(self, url, **kwargs): 332 | retries = 0 333 | response = self.session.get(url, **kwargs) 334 | while response.status_code == 429 and retries < 5: 335 | time.sleep(7) 336 | retries = retries + 1 337 | response = self.session.get(url, **kwargs) 338 | 339 | return response 340 | -------------------------------------------------------------------------------- /myanimelist/tag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | 6 | from . import utilities 7 | from .base import Base, MalformedPageError, InvalidBaseError, loadable 8 | 9 | 10 | class MalformedTagPageError(MalformedPageError): 11 | pass 12 | 13 | 14 | class InvalidTagError(InvalidBaseError): 15 | pass 16 | 17 | 18 | class Tag(Base): 19 | _id_attribute = "name" 20 | 21 | def __init__(self, session, name): 22 | super(Tag, self).__init__(session) 23 | self.name = name 24 | if not isinstance(self.name, str) or len(self.name) < 1: 25 | raise InvalidTagError(self.name) 26 | 27 | def load(self): 28 | # TODO 29 | pass 30 | -------------------------------------------------------------------------------- /myanimelist/utilities.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | from lxml.cssselect import CSSSelector 4 | from lxml import html as ht 5 | from lxml import etree as et 6 | from lxml.html import HtmlElement 7 | import datetime 8 | import re 9 | import urllib.parse as urllib 10 | 11 | 12 | def fix_bad_html(html): 13 | """ 14 | Fixes for various DOM errors that MAL commits. 15 | Yes, I know this is a cardinal sin, but there's really no elegant way to fix this. 16 | """ 17 | # on anime list pages, sometimes tds won't be properly opened. 18 | html = re.sub(r'[\s]td class=', "L represents licensing company', 28 | 'L represents licensing company') 29 | 30 | # on manga character pages, sometimes the character info column will have an extra . 31 | def manga_character_double_closed_div_picture(match): 32 | return "\n\t\t\t
\n\t\t\t" 34 | 35 | html = re.sub( 36 | r"""[^>]+)>\n\t\t\t\n\t\t\t\n\t\t\t""", 37 | manga_character_double_closed_div_picture, html) 38 | 39 | def manga_character_double_closed_div_character(match): 40 | return """""" + match.group( 41 | 'char_name') + """\n\t\t\t
""" + match.group( 42 | 'role') + """
""" 43 | 44 | html = re.sub( 45 | r"""(?P[^<]+)\n\t\t\t
(?P[A-Za-z ]+)
\n\t\t\t""", 46 | manga_character_double_closed_div_character, html) 47 | return html 48 | 49 | 50 | def get_clean_dom(html): 51 | """ 52 | Given raw HTML from a MAL page, return a lxml.objectify object with cleaned HTML. 53 | """ 54 | return ht.fromstring(fix_bad_html(html)) 55 | 56 | 57 | def urlencode(url): 58 | """ 59 | Given a string, return a string that can be used safely in a MAL url. 60 | """ 61 | return urllib.urlencode({'': url.replace(' ', '_')})[1:].replace('%2F', '/') 62 | 63 | 64 | def extract_tags(tags): 65 | list(map(lambda x: x.extract(), tags)) 66 | 67 | 68 | def parse_profile_date(text, suppress=False): 69 | """ 70 | Parses a MAL date on a profile page. 71 | May raise ValueError if a malformed date is found. 72 | If text is "Unknown" or "?" or "Not available" then returns None. 73 | Otherwise, returns a datetime.date object. 74 | """ 75 | try: 76 | if text == "Unknown" or text == "?" or text == "Not available": 77 | return None 78 | if text == "Now": 79 | return datetime.datetime.now() 80 | 81 | seconds_match = re.match(r'(?P[0-9]+) second(s)? ago', text) 82 | if seconds_match: 83 | return datetime.datetime.now() - datetime.timedelta(seconds=int(seconds_match.group('seconds'))) 84 | 85 | minutes_match = re.match(r'(?P[0-9]+) minute(s)? ago', text) 86 | if minutes_match: 87 | return datetime.datetime.now() - datetime.timedelta(minutes=int(minutes_match.group('minutes'))) 88 | 89 | hours_match = re.match(r'(?P[0-9]+) hour(s)? ago', text) 90 | if hours_match: 91 | return datetime.datetime.now() - datetime.timedelta(hours=int(hours_match.group('hours'))) 92 | 93 | today_match = re.match(r'Today, (?P[0-9]+):(?P[0-9]+) (?P[APM]+)', text) 94 | if today_match: 95 | hour = int(today_match.group('hour')) 96 | minute = int(today_match.group('minute')) 97 | am = today_match.group('am') 98 | if am == 'PM' and hour < 12: 99 | hour += 12 100 | today = datetime.date.today() 101 | return datetime.datetime(year=today.year, month=today.month, day=today.day, hour=hour, minute=minute, 102 | second=0) 103 | 104 | yesterday_match = re.match(r'Yesterday, (?P[0-9]+):(?P[0-9]+) (?P[APM]+)', text) 105 | if yesterday_match: 106 | hour = int(yesterday_match.group('hour')) 107 | minute = int(yesterday_match.group('minute')) 108 | am = yesterday_match.group('am') 109 | if am == 'PM' and hour < 12: 110 | hour += 12 111 | yesterday = datetime.date.today() - datetime.timedelta(days=1) 112 | return datetime.datetime(year=yesterday.year, month=yesterday.month, day=yesterday.day, hour=hour, 113 | minute=minute, second=0) 114 | 115 | try: 116 | return datetime.datetime.strptime(text, '%m-%d-%y, %I:%M %p') 117 | except ValueError: 118 | pass 119 | # see if it's a date. 120 | try: 121 | return datetime.datetime.strptime(text, '%m-%d-%y').date() 122 | except ValueError: 123 | pass 124 | try: 125 | return datetime.datetime.strptime(text, '%Y-%m-%d').date() 126 | except ValueError: 127 | pass 128 | try: 129 | return datetime.datetime.strptime(text, '%Y-%m-00').date() 130 | except ValueError: 131 | pass 132 | try: 133 | return datetime.datetime.strptime(text, '%Y-00-00').date() 134 | except ValueError: 135 | pass 136 | try: 137 | return datetime.datetime.strptime(text, '%B %d, %Y').date() 138 | except ValueError: 139 | pass 140 | try: 141 | return datetime.datetime.strptime(text, '%b %d, %Y').date() 142 | except ValueError: 143 | pass 144 | try: 145 | return datetime.datetime.strptime(text, '%Y').date() 146 | except ValueError: 147 | pass 148 | try: 149 | return datetime.datetime.strptime(text, '%b %d, %Y').date() 150 | except ValueError: 151 | pass 152 | try: 153 | return datetime.datetime.strptime(text, '%b, %Y').date() 154 | except ValueError: 155 | pass 156 | try: 157 | return datetime.datetime.strptime(text, "%b %d, %Y %I:%M %p") 158 | except ValueError: 159 | pass 160 | parsed_date = None 161 | try: 162 | parsed_date = datetime.datetime.strptime(text, '%b %d, %I:%M %p') 163 | except ValueError: 164 | try: 165 | parsed_date = datetime.datetime.strptime(text, '%b %d, %Y %I:%M %p') 166 | except ValueError: # if the user don't display his birthday year, it never work. 167 | try: 168 | parsed_date = datetime.datetime.strptime(text, '%b %Y') 169 | except ValueError: 170 | parsed_date = None 171 | # see if it's a month/year pairing. 172 | return parsed_date if parsed_date is not None else None 173 | except: 174 | if suppress: 175 | return None 176 | raise 177 | 178 | 179 | def css_select(selector_str, element): 180 | if not isinstance(element, et.ElementBase): 181 | raise TypeError("css_select_first - the element argument (1) is not a subtype of lxml.etree.ElementBase") 182 | selector = CSSSelector(selector_str) 183 | return selector(element) 184 | 185 | 186 | def css_select_first(selector_str, element): 187 | if not isinstance(element, et.ElementBase): 188 | raise TypeError("css_select_first - the element argument (1) is not a subtype of lxml.etree.ElementBase") 189 | selector = CSSSelector(selector_str) 190 | results = selector(element) 191 | return results[0] if len(results) >= 1 else None 192 | 193 | 194 | def check_if_mal_response_is_empty(xmlel): 195 | if xmlel is None: 196 | raise Exception("xmlel argument cannot be None.") 197 | 198 | if len(xmlel) == 0: 199 | return True 200 | 201 | return False 202 | 203 | 204 | def is_open_graph_style_stat_element(element): 205 | return element is not None and type(element) is HtmlElement and ((element.tail is not None and element.tail.strip() == "") or element.tail is None) 206 | 207 | 208 | def extract_datasheet_title(title_tag): 209 | result = "" 210 | title_tag_span = title_tag.find("span") 211 | if title_tag_span is None and title_tag.text is not None: 212 | result = title_tag.text.strip() 213 | elif title_tag_span is not None and title_tag_span.text is not None: 214 | result = title_tag_span.text.strip() 215 | elif title_tag_span is not None and title_tag_span.text is None: 216 | sub_span = title_tag_span.find("span") 217 | if sub_span is not None: 218 | result = sub_span.text.strip() 219 | 220 | return result 221 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.markdown 3 | 4 | [bdist_wheel] 5 | universal=0 6 | 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | try: 2 | from setuptools import setup 3 | except ImportError: 4 | from distutils.core import setup 5 | 6 | 7 | long_description = open('README.markdown').read() 8 | 9 | 10 | config = { 11 | 'name': 'python3-mal', 12 | 'description': 'Provides programmatic access to MyAnimeList resources.', 13 | 'author': 'pushrbx', 14 | 'keywords': ['myanimelist', 'mal-api', 'mal python', 'myanimelist python', 'python3-mal', 'myanimelist api'], 15 | 'python_requires': '>=3.4, <4', 16 | 'license': 'LICENSE.txt', 17 | 'long_description': long_description, 18 | 'long_description_content_type': 'text/markdown', 19 | 'url': 'https://github.com/pushrbx/python3-mal', 20 | 'download_url': 'https://github.com/pushrbx/python3-mal/archive/master.zip', 21 | 'author_email': 'contact@pushrbx.net', 22 | 'version': '0.2.21', 23 | 'install_requires': ['urllib3>=1.21.1,<1.23', 'requests<=2.18.4', 'pytz', 'lxml==4.5.1', 'cssselect'], 24 | 'tests_require': ['nose'], 25 | 'packages': ['myanimelist'] 26 | } 27 | 28 | setup(**config) 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_proxy_settings(): 5 | proxy_host = os.environ.get("TESTS_SQUID_ADDRESS", None) 6 | if proxy_host is None: 7 | return None 8 | return {"https": proxy_host, 'http': proxy_host} 9 | -------------------------------------------------------------------------------- /tests/anime_list_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from nose.tools import * 5 | import datetime 6 | import os 7 | 8 | from tests import get_proxy_settings 9 | 10 | if "RUNENV" in os.environ and os.environ["RUNENV"] == "travis": 11 | from myanimelist import session 12 | from myanimelist import media_list 13 | from myanimelist import anime_list 14 | else: 15 | from ..myanimelist import session 16 | from ..myanimelist import media_list 17 | from ..myanimelist import anime_list 18 | 19 | 20 | class testAnimeListClass(object): 21 | obsolete = 1 22 | @classmethod 23 | def setUpClass(self): 24 | self.session = session.Session(proxy_settings=get_proxy_settings()) 25 | 26 | self.shal = self.session.anime_list(u'shaldengeki') 27 | self.fz = self.session.anime(10087) 28 | self.trigun = self.session.anime(6) 29 | self.clannad = self.session.anime(2167) 30 | 31 | self.pl = self.session.anime_list(u'PaperLuigi') 32 | self.baccano = self.session.anime(2251) 33 | self.pokemon = self.session.anime(20159) 34 | self.dmc = self.session.anime(3702) 35 | 36 | self.mona = self.session.anime_list(u'monausicaa') 37 | self.zombie = self.session.anime(3354) 38 | self.lollipop = self.session.anime(1509) 39 | self.musume = self.session.anime(5246) 40 | 41 | self.threger = self.session.anime_list(u'threger') 42 | 43 | @raises(TypeError) 44 | def testNoUsernameInvalidAnimeList(self): 45 | self.session.anime_list() 46 | 47 | @raises(media_list.InvalidMediaListError) 48 | def testNonexistentUsernameInvalidAnimeList(self): 49 | self.session.anime_list(u'aspdoifpjsadoifjapodsijfp').load() 50 | 51 | def testUserValid(self): 52 | assert isinstance(self.shal, anime_list.AnimeList) 53 | 54 | def testUsername(self): 55 | assert self.shal.username == u'shaldengeki' 56 | assert self.mona.username == u'monausicaa' 57 | 58 | def testType(self): 59 | assert self.shal.type == u'anime' 60 | 61 | def testList(self): 62 | assert isinstance(self.shal.list, dict) and len(self.shal) == 146 63 | assert self.fz in self.shal and self.clannad in self.shal and self.trigun in self.shal 64 | assert self.shal[self.fz][u'status'] == u'Watching' and self.shal[self.clannad][u'status'] == u'Completed' and \ 65 | self.shal[self.trigun][u'status'] == u'Plan to Watch' 66 | assert self.shal[self.fz][u'score'] is None and self.shal[self.clannad][u'score'] == 9 and \ 67 | self.shal[self.trigun][u'score'] is None 68 | assert self.shal[self.fz][u'episodes_watched'] == 6 and self.shal[self.clannad][u'episodes_watched'] == 23 and \ 69 | self.shal[self.trigun][u'episodes_watched'] == 6 70 | assert self.shal[self.fz][u'started'] is None and self.shal[self.clannad][u'started'] is None and \ 71 | self.shal[self.trigun][u'started'] == None 72 | assert self.shal[self.fz][u'finished'] is None and self.shal[self.clannad][u'finished'] is None and \ 73 | self.shal[self.trigun][u'finished'] is None 74 | 75 | assert isinstance(self.pl.list, dict) and len(self.pl) >= 795 76 | assert self.baccano in self.pl and self.pokemon in self.pl and self.dmc in self.pl 77 | assert self.pl[self.baccano][u'status'] == u'Completed' and self.pl[self.pokemon][u'status'] == u'On-Hold' and \ 78 | self.pl[self.dmc][u'status'] == u'Dropped' 79 | assert self.pl[self.baccano][u'score'] == 10 and self.pl[self.pokemon][u'score'] is None and self.pl[self.dmc][ 80 | u'score'] == 2 81 | assert self.pl[self.baccano][u'episodes_watched'] == 13 and self.pl[self.pokemon][u'episodes_watched'] == 2 and \ 82 | self.pl[self.dmc][u'episodes_watched'] == 1 83 | assert self.pl[self.baccano][u'started'] == datetime.date(year=2009, month=7, day=27) and self.pl[self.pokemon][ 84 | u'started'] == datetime.date( 85 | year=2013, month=10, day=5) and self.pl[self.dmc][u'started'] == datetime.date(year=2010, month=9, 86 | day=27) 87 | assert self.pl[self.baccano][u'finished'] == datetime.date(year=2009, month=7, day=28) and \ 88 | self.pl[self.pokemon][u'finished'] is None and self.pl[self.dmc][u'finished'] is None 89 | 90 | assert isinstance(self.mona.list, dict) and len(self.mona) >= 1822 91 | assert self.zombie in self.mona and self.lollipop in self.mona and self.musume in self.mona 92 | assert self.mona[self.zombie][u'status'] == u'Completed' and self.mona[self.lollipop][u'status'] == u'Completed' and self.mona[self.musume][u'status'] == u'Completed' 93 | assert self.mona[self.zombie][u'score'] == 7 and self.mona[self.lollipop][u'score'] is not None and \ 94 | self.mona[self.musume][u'score'] is not None 95 | assert self.mona[self.zombie][u'episodes_watched'] == 2 and self.mona[self.lollipop][ 96 | u'episodes_watched'] == 13 and \ 97 | self.mona[self.musume][u'episodes_watched'] > 0 98 | assert self.mona[self.zombie][u'started'] is None and self.mona[self.lollipop][u'started'] == datetime.date( 99 | year=2013, month=4, day=14) and self.mona[self.musume][u'started'] is None 100 | assert self.mona[self.zombie][u'finished'] is None and self.mona[self.lollipop][u'finished'] is not None and \ 101 | self.mona[self.musume][u'finished'] is None 102 | 103 | assert isinstance(self.threger.list, dict) and len(self.threger) == 0 104 | 105 | def testStats(self): 106 | assert isinstance(self.shal.stats, dict) and len(self.shal.stats) > 0 107 | assert self.shal.stats[u'watching'] == 10 and self.shal.stats[u'completed'] == 102 and self.shal.stats[ 108 | u'on_hold'] == 1 and \ 109 | self.shal.stats[u'dropped'] == 5 and self.shal.stats[u'plan_to_watch'] == 28 and float( 110 | self.shal.stats[u'days_spent']) == 38.89 111 | 112 | assert isinstance(self.pl.stats, dict) and len(self.pl.stats) > 0 113 | assert self.pl.stats[u'watching'] >= 0 and self.pl.stats[u'completed'] >= 355 and self.pl.stats[ 114 | u'on_hold'] >= 0 and \ 115 | self.pl.stats[u'dropped'] >= 385 and self.pl.stats[u'plan_to_watch'] >= 0 and float( 116 | self.pl.stats[u'days_spent']) >= 125.91 117 | 118 | assert isinstance(self.mona.stats, dict) and len(self.mona.stats) > 0 119 | assert self.mona.stats[u'watching'] >= 0 and self.mona.stats[u'completed'] >= 1721 and self.mona.stats[ 120 | u'on_hold'] >= 0 and \ 121 | self.mona.stats[u'dropped'] >= 0 and self.mona.stats[u'plan_to_watch'] >= 0 and float( 122 | self.mona.stats[u'days_spent']) >= 470.30 123 | 124 | assert isinstance(self.threger.stats, dict) and len(self.threger.stats) > 0 125 | assert self.threger.stats[u'watching'] == 0 and self.threger.stats[u'completed'] == 0 and self.threger.stats[ 126 | u'on_hold'] == 0 and \ 127 | self.threger.stats[u'dropped'] == 0 and self.threger.stats[u'plan_to_watch'] == 0 and float( 128 | self.threger.stats[u'days_spent']) == 0.00 129 | 130 | def testSection(self): 131 | assert isinstance(self.shal.section(u'Watching'), dict) and self.fz in self.shal.section(u'Watching') 132 | assert isinstance(self.pl.section(u'Completed'), dict) and self.baccano in self.pl.section(u'Completed') 133 | assert isinstance(self.mona.section(u'Plan to Watch'), dict) and self.musume in self.mona.section( 134 | u'Completed') 135 | assert isinstance(self.threger.section(u'Watching'), dict) and len(self.threger.section(u'Watching')) == 0 136 | -------------------------------------------------------------------------------- /tests/anime_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from nose.tools import * 5 | import datetime 6 | import os 7 | 8 | from tests import get_proxy_settings 9 | 10 | if "RUNENV" in os.environ and os.environ["RUNENV"] == "travis": 11 | from myanimelist import session 12 | from myanimelist import anime 13 | else: 14 | try: 15 | from ..myanimelist import session 16 | from ..myanimelist import anime 17 | except: 18 | from myanimelist import session 19 | from myanimelist import anime 20 | 21 | 22 | class testAnimeClass(object): 23 | @classmethod 24 | def setUpClass(self): 25 | 26 | self.session = session.Session(proxy_settings=get_proxy_settings()) 27 | self.bebop = self.session.anime(1) 28 | self.sunrise = self.session.producer(14) 29 | self.bandai = self.session.producer(23) 30 | self.fantasia = self.session.producer(24) 31 | self.funimation = self.session.producer(102) 32 | self.action = self.session.genre(1) 33 | self.hex = self.session.character(94717) 34 | self.hex_va = self.session.person(5766) 35 | self.bebop_side_story = self.session.anime(5) 36 | self.space_tag = self.session.tag(u'space') 37 | 38 | self.spicy_wolf = self.session.anime(2966) 39 | self.kadokawa = self.session.producer(352) 40 | self.imagin = self.session.producer(75) 41 | self.romance = self.session.genre(22) 42 | self.holo = self.session.character(7373) 43 | self.holo_va = self.session.person(70) 44 | self.spicy_wolf_sequel = self.session.anime(6007) 45 | self.adventure_tag = self.session.tag(u'adventure') 46 | 47 | self.space_dandy = self.session.anime(20057) 48 | self.bones = self.session.producer(4) 49 | self.scifi = self.session.genre(24) 50 | self.toaster = self.session.character(110427) 51 | self.toaster_va = self.session.person(611) 52 | 53 | self.totoro = self.session.anime(523) 54 | self.gkids = self.session.producer(783) 55 | self.studio_ghibli = self.session.producer(21) 56 | self.supernatural = self.session.genre(37) 57 | self.satsuki = self.session.character(267) 58 | self.satsuki_va = self.session.person(1104) 59 | 60 | self.prisma = self.session.anime(18851) 61 | self.silver_link = self.session.producer(300) 62 | self.sentai_filmworks = self.session.producer(376) 63 | self.fantasy = self.session.genre(10) 64 | self.ilya = self.session.character(503) 65 | self.ilya_va = self.session.person(117) 66 | 67 | self.invalid_anime = self.session.anime(457384754) 68 | self.latest_anime = anime.Anime.newest(self.session) 69 | 70 | self.non_tagged_anime = self.session.anime(10448) 71 | self.mobile_suit_gundam_f91 = self.session.anime(88) 72 | 73 | @raises(TypeError) 74 | def testNoIDInvalidAnime(self): 75 | self.session.anime() 76 | 77 | @raises(TypeError) 78 | def testNoSessionInvalidLatestAnime(self): 79 | anime.Anime.newest() 80 | 81 | @raises(anime.InvalidAnimeError) 82 | def testNegativeInvalidAnime(self): 83 | self.session.anime(-1) 84 | 85 | @raises(anime.InvalidAnimeError) 86 | def testFloatInvalidAnime(self): 87 | self.session.anime(1.5) 88 | 89 | @raises(anime.InvalidAnimeError) 90 | def testNonExistentAnime(self): 91 | self.invalid_anime.load() 92 | 93 | def testLatestAnime(self): 94 | assert isinstance(self.latest_anime, anime.Anime) 95 | assert self.latest_anime.id > 20000 96 | 97 | def testAnimeValid(self): 98 | assert isinstance(self.bebop, anime.Anime) 99 | 100 | def testTitle(self): 101 | assert self.bebop.title == u'Cowboy Bebop' 102 | assert self.spicy_wolf.title == u'Ookami to Koushinryou' 103 | assert self.space_dandy.title == u'Space☆Dandy' 104 | assert self.prisma.title == u'Fate/kaleid liner Prisma☆Illya: Undoukai de Dance!' 105 | 106 | def testPicture(self): 107 | assert isinstance(self.spicy_wolf.picture, str) 108 | assert isinstance(self.space_dandy.picture, str) 109 | assert isinstance(self.bebop.picture, str) 110 | assert isinstance(self.totoro.picture, str) 111 | assert isinstance(self.prisma.picture, str) 112 | 113 | def testAlternativeTitles(self): 114 | assert u'Japanese' in self.bebop.alternative_titles and isinstance(self.bebop.alternative_titles[u'Japanese'], 115 | list) and u'カウボーイビバップ' in \ 116 | self.bebop.alternative_titles[ 117 | u'Japanese'] 118 | assert u'English' in self.spicy_wolf.alternative_titles and isinstance( 119 | self.spicy_wolf.alternative_titles[u'English'], list) and u'Spice and Wolf' in \ 120 | self.spicy_wolf.alternative_titles[u'English'] 121 | assert u'Japanese' in self.space_dandy.alternative_titles and isinstance( 122 | self.space_dandy.alternative_titles[u'Japanese'], list) and u'スペース☆ダンディ' in \ 123 | self.space_dandy.alternative_titles[u'Japanese'] 124 | assert u'Japanese' in self.prisma.alternative_titles and isinstance(self.prisma.alternative_titles[u'Japanese'], 125 | list) and u'Fate/kaleid liner プリズマ☆イリヤ 運動会 DE ダンス!' in \ 126 | self.prisma.alternative_titles[ 127 | u'Japanese'] 128 | 129 | def testTypes(self): 130 | assert self.bebop.type == u'TV' 131 | assert self.totoro.type == u'Movie' 132 | assert self.prisma.type == u'OVA' 133 | 134 | def testEpisodes(self): 135 | assert self.spicy_wolf.episodes == 13 136 | assert self.bebop.episodes == 26 137 | assert self.totoro.episodes == 1 138 | assert self.space_dandy.episodes == 13 139 | assert self.prisma.episodes == 1 140 | 141 | def testStatus(self): 142 | assert self.spicy_wolf.status == u'Finished Airing' 143 | assert self.totoro.status == u'Finished Airing' 144 | assert self.bebop.status == u'Finished Airing' 145 | assert self.space_dandy.status == u'Finished Airing' 146 | assert self.prisma.status == u'Finished Airing' 147 | 148 | def testAired(self): 149 | assert self.spicy_wolf.aired == ( 150 | datetime.date(month=1, day=9, year=2008), datetime.date(month=3, day=26, year=2008)) 151 | assert self.bebop.aired == (datetime.date(month=4, day=3, year=1998), datetime.date(month=4, day=24, year=1999)) 152 | assert self.space_dandy.aired == ( 153 | datetime.date(month=1, day=5, year=2014), datetime.date(month=3, day=27, year=2014)) 154 | assert self.totoro.aired == (datetime.date(month=4, day=16, year=1988),) 155 | assert self.prisma.aired == (datetime.date(month=3, day=10, year=2014),) 156 | 157 | def testProducers(self): 158 | assert isinstance(self.bebop.producers, list) and len(self.bebop.producers) > 0 159 | assert self.bandai in self.bebop.producers 160 | assert isinstance(self.spicy_wolf.producers, list) and len(self.spicy_wolf.producers) > 0 161 | assert self.kadokawa in self.spicy_wolf.producers 162 | assert isinstance(self.space_dandy.producers, list) and len(self.space_dandy.producers) > 0 163 | assert self.bandai in self.space_dandy.producers 164 | assert isinstance(self.totoro.producers, list) and len(self.totoro.producers) > 0 165 | assert self.fantasia in self.totoro.producers 166 | assert isinstance(self.prisma.producers, list) and len(self.prisma.producers) >= 0 167 | assert self.silver_link not in self.prisma.producers 168 | 169 | def testGenres(self): 170 | assert isinstance(self.bebop.genres, list) and len(self.bebop.genres) > 0 171 | assert self.action in self.bebop.genres 172 | assert isinstance(self.spicy_wolf.genres, list) and len(self.spicy_wolf.genres) > 0 173 | assert self.romance in self.spicy_wolf.genres 174 | assert isinstance(self.space_dandy.genres, list) and len(self.space_dandy.genres) > 0 175 | assert self.scifi in self.space_dandy.genres 176 | assert isinstance(self.totoro.genres, list) and len(self.totoro.genres) > 0 177 | assert self.supernatural in self.totoro.genres 178 | assert isinstance(self.prisma.genres, list) and len(self.prisma.genres) > 0 179 | assert self.fantasy in self.prisma.genres 180 | 181 | def testRating(self): 182 | assert self.spicy_wolf.rating != "Rating:" 183 | assert self.spicy_wolf.rating is not None 184 | assert self.bebop.rating != "Rating:" 185 | assert self.bebop.rating is not None 186 | assert self.bebop.rating == "R - 17+ (violence & profanity)" 187 | 188 | def testDuration(self): 189 | assert self.spicy_wolf.duration.total_seconds() == 1440 190 | assert self.totoro.duration.total_seconds() == 5160 191 | assert self.space_dandy.duration.total_seconds() == 1440 192 | assert self.bebop.duration.total_seconds() == 1440 193 | assert self.prisma.duration.total_seconds() == 1500 194 | 195 | def testScore(self): 196 | assert isinstance(self.spicy_wolf.score, tuple) 197 | assert self.spicy_wolf.score[0] > 0 and self.spicy_wolf.score[0] < 10 198 | assert isinstance(self.spicy_wolf.score[1], int) and self.spicy_wolf.score[1] >= 0 199 | assert isinstance(self.bebop.score, tuple) 200 | assert self.bebop.score[0] > 0 and self.bebop.score[0] < 10 201 | assert isinstance(self.bebop.score[1], int) and self.bebop.score[1] >= 0 202 | assert isinstance(self.space_dandy.score, tuple) 203 | assert self.space_dandy.score[0] > 0 and self.space_dandy.score[0] < 10 204 | assert isinstance(self.space_dandy.score[1], int) and self.space_dandy.score[1] >= 0 205 | assert isinstance(self.totoro.score, tuple) 206 | assert self.totoro.score[0] > 0 and self.totoro.score[0] < 10 207 | assert isinstance(self.totoro.score[1], int) and self.totoro.score[1] >= 0 208 | assert self.prisma.score[0] > 0 and self.prisma.score[0] < 10 209 | assert isinstance(self.prisma.score[1], int) and self.prisma.score[1] >= 0 210 | 211 | def testRank(self): 212 | assert isinstance(self.spicy_wolf.rank, int) and self.spicy_wolf.rank > 0 213 | assert isinstance(self.bebop.rank, int) and self.bebop.rank > 0 214 | assert isinstance(self.space_dandy.rank, int) and self.space_dandy.rank > 0 215 | assert isinstance(self.totoro.rank, int) and self.totoro.rank > 0 216 | assert isinstance(self.prisma.rank, int) and self.prisma.rank > 0 217 | 218 | def testPopularity(self): 219 | assert isinstance(self.spicy_wolf.popularity, int) and self.spicy_wolf.popularity > 0 220 | assert isinstance(self.bebop.popularity, int) and self.bebop.popularity > 0 221 | assert isinstance(self.space_dandy.popularity, int) and self.space_dandy.popularity > 0 222 | assert isinstance(self.totoro.popularity, int) and self.totoro.popularity > 0 223 | assert isinstance(self.prisma.popularity, int) and self.prisma.popularity > 0 224 | 225 | def testMembers(self): 226 | assert isinstance(self.spicy_wolf.members, int) and self.spicy_wolf.members > 0 227 | assert isinstance(self.bebop.members, int) and self.bebop.members > 0 228 | assert isinstance(self.space_dandy.members, int) and self.space_dandy.members > 0 229 | assert isinstance(self.totoro.members, int) and self.totoro.members > 0 230 | assert isinstance(self.prisma.members, int) and self.prisma.members > 0 231 | 232 | def testFavorites(self): 233 | assert isinstance(self.spicy_wolf.favorites, int) and self.spicy_wolf.favorites > 0 234 | assert isinstance(self.bebop.favorites, int) and self.bebop.favorites > 0 235 | assert isinstance(self.space_dandy.favorites, int) and self.space_dandy.favorites > 0 236 | assert isinstance(self.totoro.favorites, int) and self.totoro.favorites > 0 237 | assert isinstance(self.prisma.favorites, int) and self.prisma.favorites > 0 238 | 239 | def testSynopsis(self): 240 | assert isinstance(self.spicy_wolf.synopsis, str) and len( 241 | self.spicy_wolf.synopsis) > 0 and u'Holo' in self.spicy_wolf.synopsis 242 | assert isinstance(self.bebop.synopsis, str) and len(self.bebop.synopsis) > 0 and u'Spike' in self.bebop.synopsis 243 | assert isinstance(self.space_dandy.synopsis, str) and len( 244 | self.space_dandy.synopsis) > 0 245 | assert isinstance(self.totoro.synopsis, str) and len( 246 | self.totoro.synopsis) > 0 and u'Satsuki' in self.totoro.synopsis 247 | assert isinstance(self.prisma.synopsis, str) and len( 248 | self.prisma.synopsis) > 0 249 | 250 | def testRelated(self): 251 | assert isinstance(self.spicy_wolf.related, 252 | dict) and 'Sequel' in self.spicy_wolf.related and self.spicy_wolf_sequel in \ 253 | self.spicy_wolf.related[u'Sequel'] 254 | assert isinstance(self.bebop.related, dict) and 'Side story' in self.bebop.related and self.bebop_side_story in \ 255 | self.bebop.related[ 256 | u'Side story'] 257 | 258 | def testCharacters(self): 259 | assert isinstance(self.spicy_wolf.characters, dict) and len(self.spicy_wolf.characters) > 0 260 | assert self.holo in self.spicy_wolf.characters and self.spicy_wolf.characters[self.holo][ 261 | u'role'] == 'Main' and self.holo_va in \ 262 | self.spicy_wolf.characters[ 263 | self.holo][u'voice_actors'] 264 | assert isinstance(self.bebop.characters, dict) and len(self.bebop.characters) > 0 265 | assert self.hex in self.bebop.characters and self.bebop.characters[self.hex][ 266 | u'role'] == 'Supporting' and self.hex_va in \ 267 | self.bebop.characters[self.hex][ 268 | u'voice_actors'] 269 | assert isinstance(self.space_dandy.characters, dict) and len(self.space_dandy.characters) > 0 270 | assert self.toaster in self.space_dandy.characters and self.space_dandy.characters[self.toaster][ 271 | u'role'] == 'Supporting' and self.toaster_va in \ 272 | self.space_dandy.characters[ 273 | self.toaster][ 274 | u'voice_actors'] 275 | assert isinstance(self.totoro.characters, dict) and len(self.totoro.characters) > 0 276 | assert self.satsuki in self.totoro.characters and self.totoro.characters[self.satsuki][ 277 | u'role'] == 'Main' and self.satsuki_va in \ 278 | self.totoro.characters[ 279 | self.satsuki][u'voice_actors'] 280 | assert isinstance(self.prisma.characters, dict) and len(self.prisma.characters) > 0 281 | assert self.ilya in self.prisma.characters and self.prisma.characters[self.ilya][ 282 | u'role'] == 'Main' and self.ilya_va in \ 283 | self.prisma.characters[self.ilya][ 284 | u'voice_actors'] 285 | 286 | def testVoiceActors(self): 287 | assert isinstance(self.spicy_wolf.voice_actors, dict) 288 | assert len(self.spicy_wolf.voice_actors) > 0 289 | assert isinstance(self.spicy_wolf.voice_actors[self.holo_va], list) 290 | assert self.holo_va in self.spicy_wolf.voice_actors and self.spicy_wolf.voice_actors[self.holo_va][0][ 291 | u'role'] == 'Main' and \ 292 | self.spicy_wolf.voice_actors[self.holo_va][0][u'character'] == self.holo 293 | assert isinstance(self.bebop.voice_actors, dict) and len(self.bebop.voice_actors) > 0 294 | assert self.hex_va in self.bebop.voice_actors and self.bebop.voice_actors[self.hex_va][0][ 295 | u'role'] == 'Supporting' and \ 296 | self.bebop.voice_actors[self.hex_va][0][u'character'] == self.hex 297 | assert isinstance(self.space_dandy.voice_actors, dict) and len(self.space_dandy.voice_actors) > 0 298 | assert self.toaster_va in self.space_dandy.voice_actors and self.space_dandy.voice_actors[self.toaster_va][0][ 299 | u'role'] == 'Supporting' and \ 300 | self.space_dandy.voice_actors[self.toaster_va][0][u'character'] == self.toaster 301 | assert isinstance(self.totoro.voice_actors, dict) and len(self.totoro.voice_actors) > 0 302 | assert self.satsuki_va in self.totoro.voice_actors and self.totoro.voice_actors[self.satsuki_va][0][ 303 | u'role'] == 'Main' and \ 304 | self.totoro.voice_actors[self.satsuki_va][0][u'character'] == self.satsuki 305 | assert isinstance(self.prisma.voice_actors, dict) and len(self.prisma.voice_actors) > 0 306 | assert self.ilya_va in self.prisma.voice_actors and self.prisma.voice_actors[self.ilya_va][0][ 307 | u'role'] == 'Main' and \ 308 | self.prisma.voice_actors[self.ilya_va][0][u'character'] == self.ilya 309 | 310 | def testStaff(self): 311 | assert isinstance(self.spicy_wolf.staff, dict) and len(self.spicy_wolf.staff) > 0 312 | assert self.session.person(472) in self.spicy_wolf.staff and u'Producer' in self.spicy_wolf.staff[ 313 | self.session.person(472)] 314 | assert isinstance(self.bebop.staff, dict) and len(self.bebop.staff) > 0 315 | assert self.session.person(12221) in self.bebop.staff and u'Inserted Song Performance' in self.bebop.staff[ 316 | self.session.person(12221)] 317 | assert isinstance(self.space_dandy.staff, dict) and len(self.space_dandy.staff) > 0 318 | assert self.session.person(10127) in self.space_dandy.staff and all( 319 | x in self.space_dandy.staff[self.session.person(10127)] for x in 320 | [u'Theme Song Composition', u'Theme Song Lyrics', u'Theme Song Performance']) 321 | assert isinstance(self.totoro.staff, dict) and len(self.totoro.staff) > 0 322 | assert self.session.person(1870) in self.totoro.staff and all( 323 | x in self.totoro.staff[self.session.person(1870)] for x in [u'Director', u'Script', u'Storyboard']) 324 | assert isinstance(self.prisma.staff, dict) and len(self.prisma.staff) > 0 325 | assert self.session.person(10617) in self.prisma.staff and u'ADR Director' in self.prisma.staff[ 326 | self.session.person(10617)] 327 | 328 | def testPromotionVideos(self): 329 | assert isinstance(self.spicy_wolf.promotion_videos, list) 330 | assert len(self.spicy_wolf.promotion_videos) == 1 331 | assert isinstance(self.bebop.promotion_videos, list) 332 | assert len(self.bebop.promotion_videos) > 0 333 | assert self.spicy_wolf.promotion_videos[0]["title"] == "PV English dub version" 334 | 335 | def testSource(self): 336 | assert self.spicy_wolf.source == u'Light novel' 337 | assert self.bebop.source == u'Original' 338 | assert self.totoro.source == u'Original' 339 | assert self.space_dandy.source == u'Original' 340 | assert self.prisma.source == u'Manga' 341 | assert self.mobile_suit_gundam_f91.source == u'' 342 | 343 | def testLicensors(self): 344 | assert isinstance(self.bebop.licensors, list) and len(self.bebop.licensors) > 0 345 | assert self.funimation in self.bebop.licensors 346 | assert isinstance(self.spicy_wolf.licensors, list) and len(self.spicy_wolf.licensors) > 0 347 | assert self.funimation in self.spicy_wolf.licensors 348 | assert isinstance(self.space_dandy.licensors, list) and len(self.space_dandy.licensors) > 0 349 | assert self.funimation in self.space_dandy.licensors 350 | assert isinstance(self.totoro.licensors, list) and len(self.totoro.licensors) > 0 351 | assert self.gkids in self.totoro.licensors 352 | assert isinstance(self.prisma.licensors, list) and len(self.prisma.licensors) >= 0 353 | assert self.sentai_filmworks in self.prisma.licensors 354 | assert isinstance(self.non_tagged_anime.licensors, list) and len(self.non_tagged_anime.licensors) == 0 355 | assert not self.non_tagged_anime.licensors 356 | 357 | def testStudios(self): 358 | assert isinstance(self.bebop.studios, list) and len(self.bebop.studios) > 0 359 | assert self.sunrise in self.bebop.studios 360 | assert isinstance(self.spicy_wolf.studios, list) and len(self.spicy_wolf.studios) > 0 361 | assert self.imagin in self.spicy_wolf.studios 362 | assert isinstance(self.space_dandy.studios, list) and len(self.space_dandy.studios) > 0 363 | assert self.bones in self.space_dandy.studios 364 | assert isinstance(self.totoro.studios, list) and len(self.totoro.studios) > 0 365 | assert self.studio_ghibli in self.totoro.studios 366 | assert isinstance(self.prisma.studios, list) and len(self.prisma.studios) >= 0 367 | assert self.silver_link in self.prisma.studios 368 | assert isinstance(self.non_tagged_anime.studios, list) and len(self.non_tagged_anime.studios) == 0 369 | assert not self.non_tagged_anime.studios 370 | 371 | def testPremiered(self): 372 | assert self.spicy_wolf.premiered == u'Winter 2008' 373 | assert self.bebop.premiered == u'Spring 1998' 374 | assert self.totoro.premiered == u'' 375 | assert self.space_dandy.premiered == u'Winter 2014' 376 | assert self.prisma.premiered == u'' 377 | 378 | # def testPopularTags(self): 379 | # assert len(self.bebop.popular_tags) > 0 and self.space_tag in self.bebop.popular_tags 380 | # assert len(self.spicy_wolf.popular_tags) > 0 and self.adventure_tag in self.spicy_wolf.popular_tags 381 | # assert len(self.non_tagged_anime.popular_tags) == 0 382 | -------------------------------------------------------------------------------- /tests/character_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from nose.tools import * 5 | import os 6 | 7 | from tests import get_proxy_settings 8 | 9 | if "RUNENV" in os.environ and os.environ["RUNENV"] == "travis": 10 | from myanimelist import session 11 | from myanimelist import character 12 | from myanimelist import user 13 | import myanimelist 14 | else: 15 | from ..myanimelist import session 16 | from ..myanimelist import character 17 | from ..myanimelist import user 18 | from .. import myanimelist 19 | 20 | 21 | class testCharacterClass(object): 22 | @classmethod 23 | def setUpClass(self): 24 | self.session = myanimelist.session.Session(proxy_settings=get_proxy_settings()) 25 | self.spike = self.session.character(1) 26 | self.ed = self.session.character(11) 27 | self.maria = self.session.character(112693) 28 | self.invalid_character = self.session.character(457384754) 29 | 30 | @raises(TypeError) 31 | def testNoIDInvalidCharacter(self): 32 | self.session.character() 33 | 34 | @raises(myanimelist.character.InvalidCharacterError) 35 | def testNegativeInvalidCharacter(self): 36 | self.session.character(-1) 37 | 38 | @raises(myanimelist.character.InvalidCharacterError) 39 | def testFloatInvalidCharacter(self): 40 | self.session.character(1.5) 41 | 42 | @raises(myanimelist.character.InvalidCharacterError) 43 | def testNonExistentCharacter(self): 44 | self.invalid_character.load() 45 | 46 | def testCharacterValid(self): 47 | assert isinstance(self.spike, myanimelist.character.Character) 48 | assert isinstance(self.maria, myanimelist.character.Character) 49 | 50 | def testName(self): 51 | assert self.spike.name == u'Spike Spiegel' 52 | assert self.ed.name == u'Edward Elric' 53 | assert self.maria.name == u'Maria' 54 | 55 | def testFullName(self): 56 | assert self.spike.full_name == u'Spike Spiegel' 57 | assert self.ed.full_name == u'Edward "Ed, Fullmetal Alchemist, Hagane no shounen, Chibi, Pipsqueak" Elric' 58 | assert self.maria.full_name == u'Maria' 59 | 60 | def testJapaneseName(self): 61 | assert self.spike.name_jpn == u'スパイク・スピーゲル' 62 | assert self.ed.name_jpn == u'エドワード・エルリック' 63 | assert self.maria.name_jpn == u'マリア' 64 | 65 | def testDescription(self): 66 | assert isinstance(self.spike.description, str) and len(self.spike.description) > 0 67 | assert isinstance(self.ed.description, str) and len(self.ed.description) > 0 68 | assert isinstance(self.maria.description, str) and len(self.maria.description) > 0 69 | 70 | def testPicture(self): 71 | assert isinstance(self.spike.picture, str) and len(self.spike.picture) > 0 72 | assert isinstance(self.ed.picture, str) and len(self.ed.picture) > 0 73 | assert isinstance(self.maria.picture, str) and len(self.maria.picture) > 0 74 | 75 | def testPictures(self): 76 | assert isinstance(self.spike.pictures, list) and len(self.spike.pictures) > 0 and all( 77 | map(lambda p: isinstance(p, str) and p.startswith(u'https://'), self.spike.pictures)) 78 | assert isinstance(self.ed.pictures, list) and len(self.ed.pictures) > 0 and all( 79 | map(lambda p: isinstance(p, str) and p.startswith(u'https://'), self.ed.pictures)) 80 | assert isinstance(self.maria.pictures, list) 81 | 82 | def testAnimeography(self): 83 | assert isinstance(self.spike.animeography, dict) and len(self.spike.animeography) > 0 and self.session.anime( 84 | 1) in self.spike.animeography 85 | assert isinstance(self.ed.animeography, dict) and len(self.ed.animeography) > 0 and self.session.anime( 86 | 5114) in self.ed.animeography 87 | assert isinstance(self.maria.animeography, dict) and len(self.maria.animeography) > 0 and self.session.anime( 88 | 26441) in self.maria.animeography 89 | 90 | def testMangaography(self): 91 | assert isinstance(self.spike.mangaography, dict) and len(self.spike.mangaography) > 0 and self.session.manga( 92 | 173) in self.spike.mangaography 93 | assert isinstance(self.ed.mangaography, dict) and len(self.ed.mangaography) > 0 and self.session.manga( 94 | 4658) in self.ed.mangaography 95 | assert isinstance(self.maria.mangaography, dict) and len(self.maria.mangaography) > 0 and self.session.manga( 96 | 12336) in self.maria.mangaography 97 | 98 | def testNumFavorites(self): 99 | assert isinstance(self.spike.num_favorites, int) and self.spike.num_favorites > 12000 100 | assert isinstance(self.ed.num_favorites, int) and self.ed.num_favorites > 19000 101 | assert isinstance(self.maria.num_favorites, int) 102 | 103 | # not available anymore i think: 104 | # def testFavorites(self): 105 | # assert isinstance(self.spike.favorites, list) and len(self.spike.favorites) > 12000 and all(map(lambda u: isinstance(u, myanimelist.user.User), self.spike.favorites)) 106 | # assert isinstance(self.ed.favorites, list) and len(self.ed.favorites) > 19000 and all(map(lambda u: isinstance(u, myanimelist.user.User), self.ed.favorites)) 107 | # assert isinstance(self.maria.favorites, list) 108 | 109 | def testClubs(self): 110 | assert isinstance(self.spike.clubs, list) and len(self.spike.clubs) > 50 and all( 111 | map(lambda u: isinstance(u, myanimelist.club.Club), self.spike.clubs)) 112 | assert isinstance(self.ed.clubs, list) and len(self.ed.clubs) > 200 and all( 113 | map(lambda u: isinstance(u, myanimelist.club.Club), self.ed.clubs)) 114 | assert isinstance(self.maria.clubs, list) 115 | -------------------------------------------------------------------------------- /tests/manga_list_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from nose.tools import * 5 | import datetime 6 | 7 | import os 8 | 9 | from tests import get_proxy_settings 10 | 11 | if "RUNENV" in os.environ and os.environ["RUNENV"] == "travis": 12 | from myanimelist import session 13 | from myanimelist import media_list 14 | from myanimelist import manga_list 15 | import myanimelist 16 | else: 17 | from ..myanimelist import session 18 | from ..myanimelist import media_list 19 | from ..myanimelist import manga_list 20 | from .. import myanimelist 21 | 22 | 23 | class testMangaListClass(object): 24 | obsolete = 1 25 | @classmethod 26 | def setUpClass(self): 27 | self.session = myanimelist.session.Session(proxy_settings=get_proxy_settings()) 28 | 29 | self.shal = self.session.manga_list(u'shaldengeki') 30 | self.tomoyo_after = self.session.manga(3941) 31 | self.fma = self.session.manga(25) 32 | 33 | self.pl = self.session.manga_list(u'PaperLuigi') 34 | self.to_love_ru = self.session.manga(671) 35 | self.amnesia = self.session.manga(15805) 36 | self.sao = self.session.manga(21479) 37 | 38 | self.josh = self.session.manga_list(u'angryaria') 39 | self.juicy = self.session.manga(13250) 40 | self.tsubasa = self.session.manga(1147) 41 | self.jojo = self.session.manga(1706) 42 | 43 | self.threger = self.session.manga_list(u'threger') 44 | 45 | @raises(TypeError) 46 | def testNoUsernameInvalidMangaList(self): 47 | self.session.manga_list() 48 | 49 | @raises(myanimelist.media_list.InvalidMediaListError) 50 | def testNonexistentUsernameInvalidMangaList(self): 51 | self.session.manga_list(u'aspdoifpjsadoifjapodsijfp').load() 52 | 53 | def testUserValid(self): 54 | assert isinstance(self.shal, myanimelist.manga_list.MangaList) 55 | 56 | def testUsername(self): 57 | assert self.shal.username == u'shaldengeki' 58 | assert self.josh.username == u'angryaria' 59 | 60 | def testType(self): 61 | assert self.shal.type == u'manga' 62 | 63 | def testList(self): 64 | assert isinstance(self.shal.list, dict) and len(self.shal) == 2 65 | assert self.tomoyo_after in self.shal and self.fma in self.shal 66 | assert self.shal[self.tomoyo_after][u'status'] == u'Completed' and self.shal[self.fma][u'status'] == u'Dropped' 67 | assert self.shal[self.tomoyo_after][u'score'] == 9 and self.shal[self.fma][u'score'] == 6 68 | assert self.shal[self.tomoyo_after][u'chapters_read'] == 4 and self.shal[self.fma][u'chapters_read'] == 73 69 | assert self.shal[self.tomoyo_after][u'volumes_read'] == 1 and self.shal[self.fma][u'volumes_read'] == 18 70 | assert self.shal[self.tomoyo_after][u'started'] == None and self.shal[self.fma][u'started'] == None 71 | assert self.shal[self.tomoyo_after][u'finished'] == None and self.shal[self.fma][u'finished'] == None 72 | 73 | assert isinstance(self.pl.list, dict) and len(self.pl) >= 45 74 | assert self.to_love_ru in self.pl and self.amnesia in self.pl and self.sao in self.pl 75 | assert self.pl[self.to_love_ru][u'status'] == u'Completed' and self.pl[self.amnesia][ 76 | u'status'] == u'On-Hold' and \ 77 | self.pl[self.sao][u'status'] == u'Plan to Read' 78 | assert self.pl[self.to_love_ru][u'score'] == 6 and self.pl[self.amnesia][u'score'] == None and \ 79 | self.pl[self.sao][u'score'] == None 80 | assert self.pl[self.to_love_ru][u'chapters_read'] == 162 and self.pl[self.amnesia][u'chapters_read'] == 9 and \ 81 | self.pl[self.sao][u'chapters_read'] == 0 82 | assert self.pl[self.to_love_ru][u'volumes_read'] == 18 and self.pl[self.amnesia][u'volumes_read'] == 0 and \ 83 | self.pl[self.sao][u'volumes_read'] == 0 84 | assert self.pl[self.to_love_ru][u'started'] == datetime.date(year=2011, month=9, day=8) and \ 85 | self.pl[self.amnesia][u'started'] == datetime.date(year=2010, month=6, day=27) and self.pl[self.sao][ 86 | u'started'] == datetime.date( 87 | year=2012, month=9, day=24) 88 | assert self.pl[self.to_love_ru][u'finished'] == datetime.date(year=2011, month=9, day=16) and \ 89 | self.pl[self.amnesia][u'finished'] == None and self.pl[self.sao][u'finished'] == None 90 | 91 | assert isinstance(self.josh.list, dict) and len(self.josh) >= 151 92 | assert self.juicy in self.josh and self.tsubasa in self.josh and self.jojo in self.josh 93 | assert self.josh[self.juicy][u'status'] == u'Completed' and self.josh[self.tsubasa][u'status'] == u'Dropped' and \ 94 | self.josh[self.jojo][u'status'] == u'Plan to Read' 95 | assert self.josh[self.juicy][u'score'] == 6 and self.josh[self.tsubasa][u'score'] == 6 and self.josh[self.jojo][ 96 | u'score'] == None 97 | assert self.josh[self.juicy][u'chapters_read'] == 33 and self.josh[self.tsubasa][u'chapters_read'] == 27 and \ 98 | self.josh[self.jojo][u'chapters_read'] == 0 99 | assert self.josh[self.juicy][u'volumes_read'] == 2 and self.josh[self.tsubasa][u'volumes_read'] == 0 and \ 100 | self.josh[self.jojo][u'volumes_read'] == 0 101 | assert self.josh[self.juicy][u'started'] == None and self.josh[self.tsubasa][u'started'] == None and \ 102 | self.josh[self.jojo][u'started'] == datetime.date(year=2010, month=9, day=16) 103 | assert self.josh[self.juicy][u'finished'] == None and self.josh[self.tsubasa][u'finished'] == None and \ 104 | self.josh[self.jojo][u'finished'] == None 105 | 106 | assert isinstance(self.threger.list, dict) and len(self.threger) == 0 107 | 108 | def testStats(self): 109 | assert isinstance(self.shal.stats, dict) and len(self.shal.stats) > 0 110 | assert self.shal.stats[u'reading'] == 0 and self.shal.stats[u'completed'] == 1 and self.shal.stats[ 111 | u'on_hold'] == 0 and \ 112 | self.shal.stats[u'dropped'] == 1 and self.shal.stats[u'plan_to_read'] == 0 and float( 113 | self.shal.stats[u'days_spent']) == 0.95 114 | 115 | assert isinstance(self.pl.stats, dict) and len(self.pl.stats) > 0 116 | assert self.pl.stats[u'reading'] >= 0 and self.pl.stats[u'completed'] >= 16 and self.pl.stats[ 117 | u'on_hold'] >= 0 and \ 118 | self.pl.stats[u'dropped'] >= 0 and self.pl.stats[u'plan_to_read'] >= 0 and float( 119 | self.pl.stats[u'days_spent']) >= 10.28 120 | 121 | assert isinstance(self.josh.stats, dict) and len(self.josh.stats) > 0 122 | assert self.josh.stats[u'reading'] >= 0 and self.josh.stats[u'completed'] >= 53 and self.josh.stats[ 123 | u'on_hold'] >= 0 and \ 124 | self.josh.stats[u'dropped'] >= 0 and self.josh.stats[u'plan_to_read'] >= 0 and float( 125 | self.josh.stats[u'days_spent']) >= 25.41 126 | 127 | assert isinstance(self.threger.stats, dict) and len(self.threger.stats) > 0 128 | assert self.threger.stats[u'reading'] == 0 and self.threger.stats[u'completed'] == 0 and self.threger.stats[ 129 | u'on_hold'] == 0 and \ 130 | self.threger.stats[u'dropped'] == 0 and self.threger.stats[u'plan_to_read'] == 0 and float( 131 | self.threger.stats[u'days_spent']) == 0.00 132 | 133 | def testSection(self): 134 | assert isinstance(self.shal.section(u'Completed'), dict) and self.tomoyo_after in self.shal.section( 135 | u'Completed') 136 | assert isinstance(self.pl.section(u'On-Hold'), dict) and self.amnesia in self.pl.section(u'On-Hold') 137 | assert isinstance(self.josh.section(u'Plan to Read'), dict) and self.jojo in self.josh.section(u'Plan to Read') 138 | assert isinstance(self.threger.section(u'Reading'), dict) and len(self.threger.section(u'Reading')) == 0 139 | -------------------------------------------------------------------------------- /tests/manga_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from nose.tools import * 5 | import datetime 6 | import os 7 | 8 | from tests import get_proxy_settings 9 | 10 | if "RUNENV" in os.environ and os.environ["RUNENV"] == "travis": 11 | from myanimelist import session 12 | from myanimelist import manga 13 | import myanimelist 14 | else: 15 | from ..myanimelist import session 16 | from ..myanimelist import manga 17 | from .. import myanimelist 18 | 19 | 20 | class testMangaClass(object): 21 | @classmethod 22 | def setUpClass(self): 23 | self.session = myanimelist.session.Session(proxy_settings=get_proxy_settings()) 24 | 25 | self.monster = self.session.manga(1) 26 | self.mystery = self.session.genre(7) 27 | self.mystery_tag = self.session.tag(u'mystery') 28 | self.urasawa = self.session.person(1867) 29 | self.original = self.session.publication(1) 30 | self.heinemann = self.session.character(6123) 31 | self.monster_side_story = self.session.manga(10968) 32 | 33 | self.holic = self.session.manga(10) 34 | self.supernatural = self.session.genre(37) 35 | self.supernatural_tag = self.session.tag(u'supernatural') 36 | self.clamp = self.session.person(1877) 37 | self.young_magazine = self.session.publication(10) 38 | self.doumeki = self.session.character(567) 39 | self.holic_sequel = self.session.manga(46010) 40 | 41 | self.naruto = self.session.manga(11) 42 | self.shounen = self.session.genre(27) 43 | self.action_tag = self.session.tag(u'action') 44 | self.kishimoto = self.session.person(1879) 45 | self.shonen_jump_weekly = self.session.publication(83) 46 | self.ebizou = self.session.character(31825) 47 | 48 | self.tomoyo_after = self.session.manga(3941) 49 | self.drama = self.session.genre(8) 50 | self.romance_tag = self.session.tag(u'romance') 51 | self.sumiyoshi = self.session.person(3830) 52 | self.dragon_age = self.session.publication(98) 53 | self.kanako = self.session.character(21227) 54 | 55 | self.judos = self.session.manga(79819) 56 | self.action = self.session.genre(1) 57 | self.kondou = self.session.person(18765) 58 | 59 | self.invalid_anime = self.session.manga(457384754) 60 | self.latest_manga = myanimelist.manga.Manga.newest(self.session) 61 | 62 | @raises(TypeError) 63 | def testNoIDInvalidManga(self): 64 | self.session.manga() 65 | 66 | @raises(TypeError) 67 | def testNoSessionInvalidLatestManga(self): 68 | myanimelist.manga.Manga.newest() 69 | 70 | @raises(myanimelist.manga.InvalidMangaError) 71 | def testNegativeInvalidManga(self): 72 | self.session.manga(-1) 73 | 74 | @raises(myanimelist.manga.InvalidMangaError) 75 | def testFloatInvalidManga(self): 76 | self.session.manga(1.5) 77 | 78 | @raises(myanimelist.manga.InvalidMangaError) 79 | def testNonExistentManga(self): 80 | self.invalid_anime.load() 81 | 82 | def testLatestManga(self): 83 | assert isinstance(self.latest_manga, myanimelist.manga.Manga) 84 | assert self.latest_manga.id > 79818 85 | 86 | def testMangaValid(self): 87 | assert isinstance(self.monster, myanimelist.manga.Manga) 88 | 89 | def testTitle(self): 90 | assert self.monster.title == u'Monster' 91 | assert self.holic.title == u'xxxHOLiC' 92 | assert self.naruto.title == u'Naruto' 93 | assert self.tomoyo_after.title == u'Clannad: Tomoyo After' 94 | assert self.judos.title == u'Judos' 95 | 96 | def testPicture(self): 97 | assert isinstance(self.holic.picture, str) 98 | assert isinstance(self.naruto.picture, str) 99 | assert isinstance(self.monster.picture, str) 100 | assert isinstance(self.tomoyo_after.picture, str) 101 | assert isinstance(self.judos.picture, str) 102 | 103 | def testAlternativeTitles(self): 104 | assert u'Japanese' in self.monster.alternative_titles and isinstance( 105 | self.monster.alternative_titles[u'Japanese'], list) and u'MONSTER' in \ 106 | self.monster.alternative_titles[u'Japanese'] 107 | assert u'Synonyms' in self.holic.alternative_titles and isinstance(self.holic.alternative_titles[u'Synonyms'], 108 | list) and u'xxxHolic Cage' in \ 109 | self.holic.alternative_titles[ 110 | u'Synonyms'] 111 | assert u'Japanese' in self.naruto.alternative_titles and isinstance(self.naruto.alternative_titles[u'Japanese'], 112 | list) and 'NARUTO―ナルト―' in \ 113 | self.naruto.alternative_titles[ 114 | u'Japanese'] 115 | assert u'English' in self.tomoyo_after.alternative_titles and isinstance( 116 | self.tomoyo_after.alternative_titles[u'English'], list) and u'Tomoyo After ~Dear Shining Memories~' in \ 117 | self.tomoyo_after.alternative_titles[ 118 | u'English'] 119 | assert u'Synonyms' in self.judos.alternative_titles and isinstance(self.judos.alternative_titles[u'Synonyms'], 120 | list) and u'Juudouzu' in \ 121 | self.judos.alternative_titles[ 122 | u'Synonyms'] 123 | 124 | def testTypes(self): 125 | assert self.monster.type == u'Manga' 126 | assert self.tomoyo_after.type == u'Manga' 127 | assert self.judos.type == u'Manga' 128 | 129 | def testVolumes(self): 130 | assert self.holic.volumes == 19 131 | assert self.monster.volumes == 18 132 | assert self.tomoyo_after.volumes == 1 133 | assert self.naruto.volumes == 72 134 | assert self.judos.volumes == 3 135 | 136 | def testChapters(self): 137 | assert self.holic.chapters == 213 138 | assert self.monster.chapters == 162 139 | assert self.tomoyo_after.chapters == 4 140 | assert self.naruto.chapters == 700 141 | assert self.judos.chapters == 21 142 | 143 | def testStatus(self): 144 | assert self.holic.status == u'Finished' 145 | assert self.tomoyo_after.status == u'Finished' 146 | assert self.monster.status == u'Finished' 147 | assert self.naruto.status == u'Finished' 148 | 149 | def testPublished(self): 150 | assert self.holic.published == ( 151 | datetime.date(month=2, day=24, year=2003), datetime.date(month=2, day=9, year=2011)) 152 | assert self.monster.published == ( 153 | datetime.date(month=12, day=5, year=1994), datetime.date(month=12, day=20, year=2001)) 154 | assert self.naruto.published == ( 155 | datetime.date(month=9, day=21, year=1999), datetime.date(month=11, day=10, year=2014)) 156 | assert self.tomoyo_after.published == ( 157 | datetime.date(month=4, day=20, year=2007), datetime.date(month=10, day=20, year=2007)) 158 | 159 | def testGenres(self): 160 | assert isinstance(self.holic.genres, list) and len( 161 | self.holic.genres) > 0 and self.mystery in self.holic.genres and self.supernatural in self.holic.genres 162 | assert isinstance(self.tomoyo_after.genres, list) and len( 163 | self.tomoyo_after.genres) > 0 and self.drama in self.tomoyo_after.genres 164 | assert isinstance(self.naruto.genres, list) and len( 165 | self.naruto.genres) > 0 and self.shounen in self.naruto.genres 166 | assert isinstance(self.monster.genres, list) and len( 167 | self.monster.genres) > 0 and self.mystery in self.monster.genres 168 | assert isinstance(self.judos.genres, list) and len( 169 | self.judos.genres) > 0 and self.shounen in self.judos.genres and self.action in self.judos.genres 170 | 171 | def testAuthors(self): 172 | assert isinstance(self.holic.authors, dict) and len( 173 | self.holic.authors) > 0 and self.clamp in self.holic.authors and self.holic.authors[ 174 | self.clamp] == u'Story & Art' 175 | assert isinstance(self.tomoyo_after.authors, dict) and len( 176 | self.tomoyo_after.authors) > 0 and self.sumiyoshi in self.tomoyo_after.authors and \ 177 | self.tomoyo_after.authors[self.sumiyoshi] == u'Art' 178 | assert isinstance(self.naruto.authors, dict) and len( 179 | self.naruto.authors) > 0 and self.kishimoto in self.naruto.authors and self.naruto.authors[ 180 | self.kishimoto] == u'Story & Art' 181 | assert isinstance(self.monster.authors, dict) and len( 182 | self.monster.authors) > 0 and self.urasawa in self.monster.authors and self.monster.authors[ 183 | self.urasawa] == u'Story & Art' 184 | assert isinstance(self.judos.authors, dict) and len( 185 | self.judos.authors) > 0 and self.kondou in self.judos.authors and self.judos.authors[ 186 | self.kondou] == u'Story & Art' 187 | 188 | def testSerialization(self): 189 | assert isinstance(self.holic.serialization, 190 | myanimelist.publication.Publication) and self.young_magazine == self.holic.serialization 191 | assert isinstance(self.tomoyo_after.serialization, 192 | myanimelist.publication.Publication) and self.dragon_age == self.tomoyo_after.serialization 193 | assert isinstance(self.naruto.serialization, 194 | myanimelist.publication.Publication) and self.shonen_jump_weekly == self.naruto.serialization 195 | assert isinstance(self.monster.serialization, 196 | myanimelist.publication.Publication) and self.original == self.monster.serialization 197 | assert isinstance(self.judos.serialization, 198 | myanimelist.publication.Publication) and self.shonen_jump_weekly == self.judos.serialization 199 | 200 | def testScore(self): 201 | assert isinstance(self.holic.score, tuple) 202 | assert self.holic.score[0] > 0 and self.holic.score[0] < 10 203 | assert isinstance(self.holic.score[1], int) and self.holic.score[1] >= 0 204 | assert isinstance(self.monster.score, tuple) 205 | assert self.monster.score[0] > 0 and self.monster.score[0] < 10 206 | assert isinstance(self.monster.score[1], int) and self.monster.score[1] >= 0 207 | assert isinstance(self.naruto.score, tuple) 208 | assert self.naruto.score[0] > 0 and self.naruto.score[0] < 10 209 | assert isinstance(self.naruto.score[1], int) and self.naruto.score[1] >= 0 210 | assert isinstance(self.tomoyo_after.score, tuple) 211 | assert self.tomoyo_after.score[0] > 0 and self.tomoyo_after.score[0] < 10 212 | assert isinstance(self.tomoyo_after.score[1], int) and self.tomoyo_after.score[1] >= 0 213 | assert self.judos.score[0] >= 0 and self.judos.score[0] <= 10 214 | assert isinstance(self.judos.score[1], int) and self.judos.score[1] >= 0 215 | 216 | def testRank(self): 217 | assert isinstance(self.holic.rank, int) and self.holic.rank > 0 218 | assert isinstance(self.monster.rank, int) and self.monster.rank > 0 219 | assert isinstance(self.naruto.rank, int) and self.naruto.rank > 0 220 | assert isinstance(self.tomoyo_after.rank, int) and self.tomoyo_after.rank > 0 221 | assert isinstance(self.judos.rank, int) and self.judos.rank > 0 222 | 223 | def testPopularity(self): 224 | assert isinstance(self.holic.popularity, int) and self.holic.popularity > 0 225 | assert isinstance(self.monster.popularity, int) and self.monster.popularity > 0 226 | assert isinstance(self.naruto.popularity, int) and self.naruto.popularity > 0 227 | assert isinstance(self.tomoyo_after.popularity, int) and self.tomoyo_after.popularity > 0 228 | assert isinstance(self.judos.popularity, int) and self.judos.popularity > 0 229 | 230 | def testMembers(self): 231 | assert isinstance(self.holic.members, int) and self.holic.members > 0 232 | assert isinstance(self.monster.members, int) and self.monster.members > 0 233 | assert isinstance(self.naruto.members, int) and self.naruto.members > 0 234 | assert isinstance(self.tomoyo_after.members, int) and self.tomoyo_after.members > 0 235 | assert isinstance(self.judos.members, int) and self.judos.members > 0 236 | 237 | def testFavorites(self): 238 | assert isinstance(self.holic.favorites, int) and self.holic.favorites > 0 239 | assert isinstance(self.monster.favorites, int) and self.monster.favorites > 0 240 | assert isinstance(self.naruto.favorites, int) and self.naruto.favorites > 0 241 | assert isinstance(self.tomoyo_after.favorites, int) and self.tomoyo_after.favorites > 0 242 | assert isinstance(self.judos.favorites, int) and self.judos.favorites >= 0 243 | 244 | # def testPopularTags(self): 245 | # assert isinstance(self.holic.popular_tags, dict) and len( 246 | # self.holic.popular_tags) > 0 and self.supernatural_tag in self.holic.popular_tags and \ 247 | # self.holic.popular_tags[self.supernatural_tag] >= 269 248 | # assert isinstance(self.tomoyo_after.popular_tags, dict) and len( 249 | # self.tomoyo_after.popular_tags) > 0 and self.romance_tag in self.tomoyo_after.popular_tags and \ 250 | # self.tomoyo_after.popular_tags[self.romance_tag] >= 57 251 | # assert isinstance(self.naruto.popular_tags, dict) and len( 252 | # self.naruto.popular_tags) > 0 and self.action_tag in self.naruto.popular_tags and \ 253 | # self.naruto.popular_tags[ 254 | # self.action_tag] >= 561 255 | # assert isinstance(self.monster.popular_tags, dict) and len( 256 | # self.monster.popular_tags) > 0 and self.mystery_tag in self.monster.popular_tags and \ 257 | # self.monster.popular_tags[self.mystery_tag] >= 105 258 | # assert isinstance(self.judos.popular_tags, dict) and len(self.judos.popular_tags) == 0 259 | 260 | def testSynopsis(self): 261 | assert isinstance(self.holic.synopsis, str) and len( 262 | self.holic.synopsis) > 0 and u'Watanuki' in self.holic.synopsis 263 | assert isinstance(self.monster.synopsis, str) and len( 264 | self.monster.synopsis) > 0 and u'Tenma' in self.monster.synopsis 265 | assert isinstance(self.naruto.synopsis, str) and len( 266 | self.naruto.synopsis) > 0 and u'Hokage' in self.naruto.synopsis 267 | assert isinstance(self.tomoyo_after.synopsis, str) and len( 268 | self.tomoyo_after.synopsis) > 0 and u'Clannad' in self.tomoyo_after.synopsis 269 | assert isinstance(self.judos.synopsis, str) and len( 270 | self.judos.synopsis) > 0 and u'hardcore' in self.judos.synopsis 271 | 272 | def testRelated(self): 273 | assert isinstance(self.holic.related, dict) and 'Sequel' in self.holic.related and self.holic_sequel in \ 274 | self.holic.related[u'Sequel'] 275 | assert isinstance(self.monster.related, 276 | dict) and 'Side story' in self.monster.related and self.monster_side_story in \ 277 | self.monster.related[u'Side story'] 278 | 279 | def testCharacters(self): 280 | assert isinstance(self.holic.characters, dict) and len(self.holic.characters) > 0 281 | assert self.doumeki in self.holic.characters and self.holic.characters[self.doumeki]['role'] == 'Main' 282 | 283 | assert isinstance(self.monster.characters, dict) and len(self.monster.characters) > 0 284 | assert self.heinemann in self.monster.characters \ 285 | and self.monster.characters[self.heinemann]['role'] == 'Supporting' 286 | 287 | assert isinstance(self.naruto.characters, dict) and len(self.naruto.characters) > 0 288 | assert self.ebizou in self.naruto.characters and self.naruto.characters[self.ebizou]['role'] == 'Supporting' 289 | 290 | assert isinstance(self.tomoyo_after.characters, dict) and len(self.tomoyo_after.characters) > 0 291 | assert self.kanako in self.tomoyo_after.characters and self.tomoyo_after.characters[self.kanako][ 292 | 'role'] == 'Supporting' 293 | -------------------------------------------------------------------------------- /tests/media_list_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from nose.tools import * 5 | import os 6 | 7 | from tests import get_proxy_settings 8 | 9 | if "RUNENV" in os.environ and os.environ["RUNENV"] == "travis": 10 | from myanimelist import session 11 | from myanimelist import media_list 12 | import myanimelist 13 | else: 14 | from ..myanimelist import session 15 | from ..myanimelist import media_list 16 | from .. import myanimelist 17 | 18 | 19 | class testMediaListClass(object): 20 | @classmethod 21 | def setUpClass(self): 22 | self.session = myanimelist.session.Session(proxy_settings=get_proxy_settings()) 23 | 24 | @raises(TypeError) 25 | def testCannotInstantiateMediaList(self): 26 | myanimelist.media_list.MediaList(self.session, "test_username") 27 | -------------------------------------------------------------------------------- /tests/session_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from nose.tools import * 5 | import os 6 | 7 | from tests import get_proxy_settings 8 | 9 | if "RUNENV" in os.environ and os.environ["RUNENV"] == "travis": 10 | from myanimelist import session 11 | from myanimelist import anime 12 | import myanimelist 13 | else: 14 | from ..myanimelist import session 15 | from ..myanimelist import anime 16 | from .. import myanimelist 17 | 18 | class testSessionClass(object): 19 | @classmethod 20 | def setUpClass(self): 21 | # see if our environment has credentials. 22 | if 'MAL_USERNAME' and 'MAL_PASSWORD' in os.environ: 23 | self.username = os.environ[u'MAL_USERNAME'] 24 | self.password = os.environ[u'MAL_PASSWORD'] 25 | else: 26 | # rely on a flat textfile in project root. 27 | with open(u'credentials.txt', 'r') as cred_file: 28 | line = cred_file.read().strip().split(u'\n')[0] 29 | self.username, self.password = line.strip().split(u',') 30 | 31 | self.session = myanimelist.session.Session(self.username, self.password, proxy_settings=get_proxy_settings()) 32 | self.logged_in_session = myanimelist.session.Session(self.username, self.password).login() 33 | self.fake_session = myanimelist.session.Session(u'no-username', 'no-password') 34 | 35 | def testLoggedIn(self): 36 | assert not self.fake_session.logged_in() 37 | self.fake_session.login() 38 | assert not self.fake_session.logged_in() 39 | assert not self.session.logged_in() 40 | assert self.logged_in_session.logged_in() 41 | 42 | def testLogin(self): 43 | assert not self.session.logged_in() 44 | self.session.login() 45 | assert self.session.logged_in() 46 | 47 | def testAnime(self): 48 | assert isinstance(self.session.anime(1), myanimelist.anime.Anime) 49 | -------------------------------------------------------------------------------- /tests/user_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from nose.tools import * 5 | import datetime 6 | import os 7 | 8 | from tests import get_proxy_settings 9 | 10 | if "RUNENV" in os.environ and os.environ["RUNENV"] == "travis": 11 | from myanimelist import session 12 | from myanimelist import user 13 | import myanimelist 14 | else: 15 | from ..myanimelist import session 16 | from ..myanimelist import user 17 | from .. import myanimelist 18 | 19 | 20 | class testUserClass(object): 21 | @classmethod 22 | def setUpClass(self): 23 | self.session = myanimelist.session.Session(proxy_settings=get_proxy_settings()) 24 | self.shal = self.session.user(u'shaldengeki') 25 | self.gits = self.session.anime(467) 26 | self.clannad_as = self.session.anime(4181) 27 | self.tohsaka = self.session.character(498) 28 | self.fsn = self.session.anime(356) 29 | self.fujibayashi = self.session.character(4605) 30 | self.clannad_movie = self.session.anime(1723) 31 | self.fate_zero = self.session.anime(10087) 32 | self.bebop = self.session.anime(1) 33 | self.kanon = self.session.anime(1530) 34 | self.fang_tan_club = self.session.club(9560) 35 | self.satsuki_club = self.session.club(6246) 36 | 37 | self.ziron = self.session.user(u'Ziron') 38 | self.seraph = self.session.user(u'seraphzero') 39 | 40 | self.mona = self.session.user(u'monausicaa') 41 | self.megami = self.session.manga(446) 42 | self.chobits = self.session.manga(107) 43 | self.kugimiya = self.session.person(8) 44 | self.kayano = self.session.person(10765) 45 | 46 | self.naruleach = self.session.user(u'Naruleach') 47 | self.mal_rewrite_club = self.session.club(6498) 48 | self.fantasy_anime_club = self.session.club(379) 49 | 50 | self.smooched = self.session.user(u'Smooched') 51 | self.sao = self.session.anime(11757) 52 | self.threger = self.session.user(u'threger') 53 | 54 | @raises(TypeError) 55 | def testNoIDInvalidUser(self): 56 | self.session.user() 57 | 58 | @raises(myanimelist.user.InvalidUserError) 59 | def testNegativeInvalidUser(self): 60 | self.session.user(-1) 61 | 62 | @raises(myanimelist.user.InvalidUserError) 63 | def testFloatInvalidUser(self): 64 | self.session.user(1.5) 65 | 66 | @raises(myanimelist.user.InvalidUserError) 67 | def testNonExistentUser(self): 68 | self.session.user(457384754).load() 69 | 70 | def testUserValid(self): 71 | assert isinstance(self.shal, myanimelist.user.User) 72 | 73 | def testId(self): 74 | assert self.shal.id == 64611 75 | assert self.mona.id == 244263 76 | 77 | def testUsername(self): 78 | assert self.shal.username == u'shaldengeki' 79 | assert self.mona.username == u'monausicaa' 80 | 81 | def testPicture(self): 82 | assert isinstance(self.shal.picture, 83 | str) and self.shal.picture == u'https://cdn.myanimelist.net/images/userimages/64611.jpg?t' \ 84 | u'=1554319800' 85 | assert isinstance(self.mona.picture, str) 86 | 87 | def testFavoriteAnime(self): 88 | assert isinstance(self.shal.favorite_anime, list) and len(self.shal.favorite_anime) > 0 89 | assert self.gits in self.shal.favorite_anime and self.clannad_as in self.shal.favorite_anime 90 | assert isinstance(self.mona.favorite_anime, list) and len(self.mona.favorite_anime) > 0 91 | 92 | def testFavoriteManga(self): 93 | assert isinstance(self.shal.favorite_manga, list) and len(self.shal.favorite_manga) == 0 94 | assert isinstance(self.mona.favorite_manga, list) and len(self.mona.favorite_manga) > 0 95 | assert self.megami in self.mona.favorite_manga and self.chobits in self.mona.favorite_manga 96 | 97 | def testFavoriteCharacters(self): 98 | assert isinstance(self.shal.favorite_characters, dict) and len(self.shal.favorite_characters) > 0 99 | assert self.tohsaka in self.shal.favorite_characters and self.fujibayashi in self.shal.favorite_characters 100 | assert self.shal.favorite_characters[self.tohsaka] == self.fsn and self.shal.favorite_characters[ 101 | self.fujibayashi] == self.clannad_movie 102 | assert isinstance(self.mona.favorite_characters, dict) and len(self.mona.favorite_characters) > 0 103 | 104 | def testFavoritePeople(self): 105 | assert isinstance(self.shal.favorite_people, list) and len(self.shal.favorite_people) == 0 106 | assert isinstance(self.mona.favorite_people, list) and len(self.mona.favorite_people) > 0 107 | assert self.kugimiya in self.mona.favorite_people and self.kayano in self.mona.favorite_people 108 | 109 | def testLastOnline(self): 110 | assert isinstance(self.shal.last_online, datetime.datetime) 111 | assert isinstance(self.mona.last_online, datetime.datetime) 112 | 113 | def testGender(self): 114 | assert self.shal.gender is None 115 | assert self.mona.gender == u"Male" 116 | 117 | def testBirthday(self): 118 | assert isinstance(self.shal.birthday, datetime.date) and self.shal.birthday == datetime.date(year=1989, 119 | month=11, day=5) 120 | assert isinstance(self.mona.birthday, datetime.date) and self.mona.birthday == datetime.date(year=1991, month=8, 121 | day=11) 122 | 123 | def testLocation(self): 124 | assert self.shal.location == u'Chicago, IL' 125 | assert isinstance(self.mona.location, str) 126 | 127 | def testWebsite(self): 128 | assert len(self.shal.website) > 0 129 | assert self.shal.website[0]['name'] == u'llanim.us' 130 | assert self.mona.website is None 131 | 132 | def testJoinDate(self): 133 | assert isinstance(self.shal.join_date, datetime.date) and self.shal.join_date == datetime.date(year=2008, 134 | month=5, day=30) 135 | assert isinstance(self.mona.join_date, datetime.date) and self.mona.join_date == datetime.date(year=2009, 136 | month=10, day=9) 137 | 138 | # def testAccessRank(self): 139 | # assert self.shal.access_rank == u'Member' 140 | # assert self.mona.access_rank == u'Member' 141 | # assert self.naruleach.access_rank == u'Anime DB Moderator' 142 | 143 | # def testAnimeListViews(self): 144 | # assert isinstance(self.shal.anime_list_views, int) and self.shal.anime_list_views >= 1767 145 | # assert isinstance(self.mona.anime_list_views, int) and self.mona.anime_list_views >= 1969 146 | 147 | # def testMangaListViews(self): 148 | # assert isinstance(self.shal.manga_list_views, int) and self.shal.manga_list_views >= 1037 149 | # assert isinstance(self.mona.manga_list_views, int) and self.mona.manga_list_views >= 548 150 | 151 | # def testNumComments(self): 152 | # assert isinstance(self.shal.num_comments, int) and self.shal.num_comments >= 93 153 | # assert isinstance(self.mona.num_comments, int) and self.mona.num_comments >= 30 154 | 155 | # def testNumForumPosts(self): 156 | # assert isinstance(self.shal.num_forum_posts, int) and self.shal.num_forum_posts >= 5 157 | # assert isinstance(self.mona.num_forum_posts, int) and self.mona.num_forum_posts >= 1 158 | 159 | def testLastListUpdates(self): 160 | assert isinstance(self.shal.last_list_updates, dict) and len(self.shal.last_list_updates) > 0 161 | assert self.fate_zero in self.shal.last_list_updates and self.bebop in self.shal.last_list_updates 162 | assert self.shal.last_list_updates[self.fate_zero][u'status'] == u'Watching' and \ 163 | self.shal.last_list_updates[self.fate_zero][u'progress'] == 6 and \ 164 | self.shal.last_list_updates[self.fate_zero][u'total'] == 13 165 | 166 | assert isinstance(self.shal.last_list_updates[self.fate_zero][u'time'], datetime.datetime) 167 | assert self.shal.last_list_updates[self.fate_zero][u'time'] == datetime.datetime(year=2014, month=9, day=5, 168 | hour=14, minute=1, second=0) 169 | assert self.bebop in self.shal.last_list_updates and self.bebop in self.shal.last_list_updates 170 | assert self.shal.last_list_updates[self.bebop][u'status'] == u'Completed' and \ 171 | self.shal.last_list_updates[self.bebop][u'progress'] == 26 and self.shal.last_list_updates[self.bebop][ 172 | u'total'] == 26 173 | assert isinstance(self.shal.last_list_updates[self.bebop][u'time'], datetime.datetime) and \ 174 | self.shal.last_list_updates[self.bebop][u'time'] == datetime.datetime(year=2012, month=8, day=20, 175 | hour=11, minute=56, second=0) 176 | assert isinstance(self.mona.last_list_updates, dict) and len(self.mona.last_list_updates) > 0 177 | 178 | def testAnimeStats(self): 179 | assert isinstance(self.shal.anime_stats, dict) and len(self.shal.anime_stats) > 0 180 | assert self.shal.anime_stats[u'Days'] == 38.1 and self.shal.anime_stats[u'Total Entries'] == 146 181 | assert isinstance(self.mona.anime_stats, dict) and len(self.mona.anime_stats) > 0 182 | assert self.mona.anime_stats[u'Days'] >= 470 and self.mona.anime_stats[u'Total Entries'] >= 1822 183 | 184 | def testMangaStats(self): 185 | assert isinstance(self.shal.manga_stats, dict) and len(self.shal.manga_stats) > 0 186 | assert self.shal.manga_stats[u'Days'] == 1.0 and self.shal.manga_stats[u'Total Entries'] == 2 187 | assert isinstance(self.mona.manga_stats, dict) and len(self.mona.manga_stats) > 0 188 | assert self.mona.manga_stats[u'Days'] >= 69.4 and self.mona.manga_stats[u'Total Entries'] >= 186 189 | 190 | def testAbout(self): 191 | if self.shal.about is not None: 192 | assert isinstance(self.shal.about, str) and len(self.shal.about) > 0 193 | assert u'retiree' in self.shal.about 194 | else: 195 | assert self.shal.about is None 196 | if self.mona.about is not None: 197 | assert isinstance(self.mona.about, str) and len(self.mona.about) >= 0 198 | assert self.mona.about == u'Nothing yet' 199 | else: 200 | assert self.mona.about is None 201 | 202 | def testReviews(self): 203 | assert isinstance(self.shal.reviews, dict) and len(self.shal.reviews) == 0 204 | 205 | assert isinstance(self.smooched.reviews, dict) and len(self.smooched.reviews) >= 0 206 | if len(self.smooched.reviews) > 0: 207 | assert self.sao in self.smooched.reviews 208 | assert isinstance(self.smooched.reviews[self.sao][u'date'], datetime.date) and self.smooched.reviews[self.sao][ 209 | u'date'] == datetime.date( 210 | year=2012, month=7, day=24) 211 | assert self.smooched.reviews[self.sao][u'people_helped'] >= 0 and self.smooched.reviews[self.sao][ 212 | u'people_total'] >= 0 213 | assert self.smooched.reviews[self.sao][u'media_consumed'] == 13 and self.smooched.reviews[self.sao][ 214 | u'media_total'] == 25 215 | assert self.smooched.reviews[self.sao][u'rating'] == 6 216 | assert isinstance(self.smooched.reviews[self.sao][u'text'], str) and len( 217 | self.smooched.reviews[self.sao][u'text']) > 0 218 | 219 | assert isinstance(self.threger.reviews, dict) and len(self.threger.reviews) == 0 220 | 221 | # def testRecommendations(self): 222 | # assert isinstance(self.shal.recommendations, dict) and len(self.shal.recommendations) > 0 223 | # assert self.kanon in self.shal.recommendations and self.shal.recommendations[self.kanon][ 224 | # u'anime'] == self.clannad_as 225 | # assert isinstance(self.shal.recommendations[self.kanon][u'date'], datetime.date) and \ 226 | # self.shal.recommendations[self.kanon][u'date'] == datetime.date(year=2009, month=3, day=13) 227 | # assert isinstance(self.shal.recommendations[self.kanon][u'text'], str) and len( 228 | # self.shal.recommendations[self.kanon][u'text']) > 0 229 | # assert isinstance(self.mona.recommendations, dict) and len(self.mona.recommendations) >= 0 230 | # assert isinstance(self.naruleach.recommendations, dict) and len(self.naruleach.recommendations) >= 0 231 | # assert isinstance(self.threger.recommendations, dict) and len(self.threger.recommendations) == 0 232 | 233 | # def testClubs(self): 234 | # assert isinstance(self.shal.clubs, list) and len(self.shal.clubs) == 7 235 | # assert self.fang_tan_club in self.shal.clubs and self.satsuki_club in self.shal.clubs 236 | # assert isinstance(self.naruleach.clubs, list) and len(self.naruleach.clubs) >= 15 237 | # assert self.mal_rewrite_club in self.naruleach.clubs and self.fantasy_anime_club in self.naruleach.clubs 238 | # assert isinstance(self.threger.clubs, list) and len(self.threger.clubs) == 0 239 | 240 | # def testFriends(self): 241 | # assert isinstance(self.shal.friends, dict) and len(self.shal.friends) >= 31 242 | # assert self.ziron in self.shal.friends and isinstance(self.shal.friends[self.ziron][u'last_active'], 243 | # datetime.datetime) 244 | # assert self.ziron in self.shal.friends and isinstance(self.shal.friends[self.ziron][u'last_active'], 245 | # datetime.datetime) 246 | # assert self.seraph in self.shal.friends and isinstance(self.shal.friends[self.seraph][u'last_active'], 247 | # datetime.datetime) and self.shal.friends[self.seraph][ 248 | # u'since'] == datetime.datetime( 249 | # year=2012, month=10, day=13, hour=19, minute=31, second=0) 250 | # assert isinstance(self.mona.friends, dict) and len(self.mona.friends) >= 0 251 | # assert isinstance(self.threger.friends, dict) and len(self.threger.friends) == 0 252 | -------------------------------------------------------------------------------- /upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -rf dist build 3 | python setup.py bdist_wheel 4 | twine upload -r pypi dist/* 5 | --------------------------------------------------------------------------------