├── public ├── .gitignore ├── robots.txt ├── favicon.ico ├── css │ ├── reset.css │ └── skin.css ├── docs │ ├── changelog.txt │ └── index.html └── index.html ├── tmp └── .gitignore ├── log └── .gitignore ├── .gitignore ├── config ├── dalli.yml.sample ├── deploy │ └── production.rb └── deploy.rb ├── Capfile ├── Gemfile ├── README ├── config.ru ├── my_anime_list.rb ├── Gemfile.lock ├── my_anime_list ├── rack.rb ├── anime_list.rb ├── manga_list.rb ├── user.rb ├── manga.rb └── anime.rb ├── app.rb └── samples └── mangalist-chuyeow.html /public/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | config/dalli.yml 3 | .bundle 4 | bin 5 | vendor/bundle 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chuyeow/myanimelist-api/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /config/dalli.yml.sample: -------------------------------------------------------------------------------- 1 | development: 2 | :server: localhost:11211 3 | :expires_in: 86400 # 1.day 4 | 5 | production: 6 | :server: localhost:11211 7 | :expires_in: 86400 # 1.day -------------------------------------------------------------------------------- /Capfile: -------------------------------------------------------------------------------- 1 | load 'deploy' if respond_to?(:namespace) # cap2 differentiator 2 | Dir['vendor/gems/*/recipes/*.rb','vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) } 3 | 4 | load 'config/deploy' # remove this line to skip loading any of the default tasks -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rack', '~> 1.6' 4 | gem 'rack-cache' 5 | gem 'rack-cors', :require => 'rack/cors' 6 | gem 'sinatra' 7 | gem 'sinatra-contrib' 8 | 9 | gem 'builder' 10 | gem 'chronic' 11 | gem 'curb' 12 | gem 'dalli' 13 | gem 'json' 14 | gem 'nokogiri' 15 | 16 | group :development do 17 | gem 'capistrano' 18 | gem 'capistrano-ext' 19 | gem 'thin' 20 | end 21 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | MyAnimeList Unofficial API 2 | ========================== 3 | 4 | An unofficial API for the awesome http://myanimelist.net/. 5 | 6 | Why? 7 | ---- 8 | There're no official plans by MyAnimeList to release an official API yet. I 9 | intend to use this API to build other apps. 10 | 11 | What do I need to run this? 12 | --------------------------- 13 | * ruby 1.9.3 or later. 14 | * libxml2 and libxslt (for nokogiri) 15 | * Bundler gem. 16 | * memcached 17 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | 4 | Bundler.require 5 | 6 | dalli_config = YAML.load(IO.read(File.join('config', 'dalli.yml')))[ENV['RACK_ENV']] 7 | 8 | use Rack::Cache, 9 | :metastore => "memcached://#{dalli_config[:server]}/meta", 10 | :entitystore => "memcached://#{dalli_config[:server]}/body", 11 | :default_ttl => dalli_config[:expires_in], 12 | :allow_reload => true, 13 | :cache_key => Proc.new { |request| 14 | if request.env['HTTP_ORIGIN'] 15 | [Rack::Cache::Key.new(request).generate, request.env['HTTP_ORIGIN']].join 16 | else 17 | Rack::Cache::Key.new(request).generate 18 | end 19 | } 20 | 21 | require './app' 22 | run App -------------------------------------------------------------------------------- /public/css/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2009, Yahoo! Inc. All rights reserved. 3 | Code licensed under the BSD License: 4 | http://developer.yahoo.net/yui/license.txt 5 | version: 2.7.0 6 | */ 7 | html{color:#000;background:#FFF;}body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,button,textarea,p,blockquote,th,td{margin:0;padding:0;}table{border-collapse:collapse;border-spacing:0;}fieldset,img{border:0;}address,caption,cite,code,dfn,em,strong,th,var,optgroup{font-style:inherit;font-weight:inherit;}del,ins{text-decoration:none;}li{list-style:none;}caption,th{text-align:left;}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;}q:before,q:after{content:'';}abbr,acronym{border:0;font-variant:normal;}sup{vertical-align:baseline;}sub{vertical-align:baseline;}legend{color:#000;}input,button,textarea,select,optgroup,option{font-family:inherit;font-size:inherit;font-style:inherit;font-weight:inherit;}input,button,textarea,select{*font-size:100%;} -------------------------------------------------------------------------------- /config/deploy/production.rb: -------------------------------------------------------------------------------- 1 | set :application, 'myanimelist-api' 2 | set :repository, 'git://github.com/chuyeow/myanimelist-api.git' 3 | set :deploy_to, '/var/apps/myanimelist-api' 4 | set :scm, :git 5 | set :deploy_via, :remote_cache 6 | 7 | set :user, 'deploy' 8 | set :runner, 'deploy' 9 | set :use_sudo, false 10 | 11 | set :normalize_asset_timestamps, false 12 | 13 | server '198.211.96.88', :app, :web, :db 14 | 15 | ssh_options[:port] = 3456 16 | ssh_options[:forward_agent] = true 17 | default_run_options[:pty] = true 18 | 19 | namespace :deploy do 20 | 21 | desc 'Copy various config files from shared directory into current release directory.' 22 | task :post_update_code, :roles => :app do 23 | configs = %w(dalli.yml) 24 | config_paths = configs.map { |config| "#{shared_path}/config/#{config}" } 25 | run "cp #{config_paths.join(' ')} #{release_path}/config/" 26 | end 27 | after 'deploy:update_code', 'deploy:post_update_code' 28 | 29 | end -------------------------------------------------------------------------------- /config/deploy.rb: -------------------------------------------------------------------------------- 1 | require 'capistrano/ext/multistage' 2 | require 'bundler/capistrano' 3 | 4 | # Used by Capistrano multistage. 5 | set :stages, %w(production staging) 6 | 7 | namespace :deploy do 8 | after 'deploy:update_code', 'deploy:post_update_code' 9 | after 'deploy', 'deploy:cleanup' 10 | 11 | task :post_update_code, :roles => :app do 12 | 13 | # Copy config files from shared directory into current release directory. 14 | configs = %w(dalli.yml) 15 | config_paths = configs.map { |config| "#{shared_path}/config/#{config}" } 16 | run "cp #{config_paths.join(' ')} #{release_path}/config/" 17 | end 18 | 19 | desc "Start application" 20 | task :start, :roles => :app do 21 | run "touch #{current_release}/tmp/restart.txt" 22 | end 23 | 24 | task :stop, :roles => :app do 25 | # Do nothing. 26 | end 27 | 28 | desc "Restart application" 29 | task :restart, :roles => :app do 30 | run "touch #{current_release}/tmp/restart.txt" 31 | end 32 | end -------------------------------------------------------------------------------- /my_anime_list.rb: -------------------------------------------------------------------------------- 1 | require 'curb' 2 | require 'nokogiri' 3 | 4 | require './my_anime_list/rack' 5 | require './my_anime_list/user' 6 | require './my_anime_list/anime' 7 | require './my_anime_list/anime_list' 8 | require './my_anime_list/manga' 9 | require './my_anime_list/manga_list' 10 | 11 | module MyAnimeList 12 | 13 | # Raised when there're any network errors. 14 | class NetworkError < StandardError 15 | attr_accessor :original_exception 16 | 17 | def initialize(message, original_exception = nil) 18 | @message = message 19 | @original_exception = original_exception 20 | super(message) 21 | end 22 | def to_s; @message; end 23 | end 24 | 25 | # Raised when there's an error updating an anime/manga. 26 | class UpdateError < StandardError 27 | attr_accessor :original_exception 28 | 29 | def initialize(message, original_exception = nil) 30 | @message = message 31 | @original_exception = original_exception 32 | super(message) 33 | end 34 | def to_s; @message; end 35 | end 36 | 37 | class NotFoundError < StandardError 38 | attr_accessor :original_exception 39 | 40 | def initialize(message, original_exception = nil) 41 | @message = message 42 | @original_exception = original_exception 43 | super(message) 44 | end 45 | def to_s; @message; end 46 | end 47 | 48 | # Raised when an error we didn't expect occurs. 49 | class UnknownError < StandardError 50 | attr_accessor :original_exception 51 | 52 | def initialize(message, original_exception = nil) 53 | @message = message 54 | @original_exception = original_exception 55 | super(message) 56 | end 57 | def to_s; @message; end 58 | end 59 | 60 | end -------------------------------------------------------------------------------- /public/docs/changelog.txt: -------------------------------------------------------------------------------- 1 | 21 Feb 2013 2 | ----------- 3 | * Added /profile/username API method - thanks to Peter Graham (https://github.com/6)! 4 | 5 | 26 Jul 2012 6 | ----------- 7 | * Added start_date and end_date to anime model. 8 | 9 | 30 Apr 2012 10 | ----------- 11 | * Added alternative_versions attribute to anime and manga models. 12 | 13 | 29 Apr 2012 14 | ----------- 15 | * Added these Related Anime attributes to anime: 16 | * character_anime 17 | * spin_offs 18 | * summaries 19 | * parent_story 20 | 21 | 11 Feb 2011 22 | ----------- 23 | * Added support for a callback parameter for JSON responses. 24 | 25 | 13 April 2010 26 | ------------- 27 | * Added the following manga API methods - thanks to Guillaume Cassonnet (gcassonnet): 28 | * /mangalist - Read a manga list. 29 | * /mangalist/manga/manga_id - Add, update or delete a manga in a user's manga list. 30 | * /manga/search - Search for manga. 31 | 32 | 3 January 2010 33 | -------------- 34 | * Added API method /manga/manga_id for retrieving details of a single manga. 35 | * The anime "episodes" attribute now returns null (for JSON format responses) or an empty 36 | tag in cases where the number of episodes is "Unknown" on MyAnimeList.net. 37 | 38 | 13 September 2009 39 | ----------------- 40 | * Added support for XML output. Send along the "format=xml" parameter in your requests. 41 | 42 | 8 September 2009 43 | ---------------- 44 | * Fix /anime/search return 500 errors with some queries. Supplemented official 45 | API's search method (which was returning badly encoded Japanese characters) 46 | with scraping MyAnimeList for synopsis. 47 | 48 | 1 September 2009 49 | ---------------- 50 | * Developer preview release via http://myanimelist.net/clubs.php?cid=14973. -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | backports (2.8.2) 5 | builder (3.1.4) 6 | capistrano (2.14.2) 7 | highline 8 | net-scp (>= 1.0.0) 9 | net-sftp (>= 2.0.0) 10 | net-ssh (>= 2.0.14) 11 | net-ssh-gateway (>= 1.1.0) 12 | capistrano-ext (1.2.1) 13 | capistrano (>= 1.0.0) 14 | chronic (0.9.0) 15 | curb (0.8.3) 16 | daemons (1.1.9) 17 | dalli (2.6.2) 18 | eventmachine (1.0.0) 19 | highline (1.6.15) 20 | json (1.7.7) 21 | mini_portile2 (2.5.1) 22 | net-scp (1.1.0) 23 | net-ssh (>= 2.6.5) 24 | net-sftp (2.1.1) 25 | net-ssh (>= 2.6.5) 26 | net-ssh (2.6.5) 27 | net-ssh-gateway (1.2.0) 28 | net-ssh (>= 2.6.5) 29 | nokogiri (1.11.4) 30 | mini_portile2 (~> 2.5.0) 31 | racc (~> 1.4) 32 | racc (1.5.2) 33 | rack (1.6.12) 34 | rack-cache (1.2) 35 | rack (>= 0.4) 36 | rack-cors (1.0.5) 37 | rack (>= 1.6.0) 38 | rack-protection (1.5.5) 39 | rack 40 | rack-test (0.6.2) 41 | rack (>= 1.0) 42 | sinatra (1.3.4) 43 | rack (~> 1.4) 44 | rack-protection (~> 1.3) 45 | tilt (~> 1.3, >= 1.3.3) 46 | sinatra-contrib (1.3.2) 47 | backports (>= 2.0) 48 | eventmachine 49 | rack-protection 50 | rack-test 51 | sinatra (~> 1.3.0) 52 | tilt (~> 1.3) 53 | thin (1.5.0) 54 | daemons (>= 1.0.9) 55 | eventmachine (>= 0.12.6) 56 | rack (>= 1.0.0) 57 | tilt (1.3.3) 58 | 59 | PLATFORMS 60 | ruby 61 | 62 | DEPENDENCIES 63 | builder 64 | capistrano 65 | capistrano-ext 66 | chronic 67 | curb 68 | dalli 69 | json 70 | nokogiri 71 | rack (~> 1.6) 72 | rack-cache 73 | rack-cors 74 | sinatra 75 | sinatra-contrib 76 | thin 77 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | MyAnimeList Unofficial API 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 26 | 27 |

This is the unofficial API for MyAnimeList, the best site there is to keep track of the anime you've watched or plan to watch and the manga you've read. It's like Facebook for anime and manga lovers.

28 | 29 |

This API is a supplement to the official API that's currently being developed. Read the API documentation.

30 | 31 |

For announcements and updates, follow @sliceoflifer on Twitter.

32 |
33 | 34 | 38 | 43 | 44 | -------------------------------------------------------------------------------- /my_anime_list/rack.rb: -------------------------------------------------------------------------------- 1 | module MyAnimeList 2 | module Rack 3 | module Auth 4 | 5 | def auth 6 | @auth ||= ::Rack::Auth::Basic::Request.new(request.env) 7 | end 8 | 9 | def unauthenticated!(realm = 'myanimelist.net') 10 | headers['WWW-Authenticate'] = %(Basic realm="#{realm}") 11 | throw :halt, [ 401, 'Authorization Required' ] 12 | end 13 | 14 | def bad_request! 15 | throw :halt, [ 400, 'Bad Request' ] 16 | end 17 | 18 | def authenticated? 19 | request.env['REMOTE_USER'] 20 | end 21 | 22 | # Authenticate with MyAnimeList.net. 23 | def authenticate_with_mal(username, password) 24 | 25 | curl = Curl::Easy.new('http://myanimelist.net/login.php') 26 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 27 | 28 | authenticated = false 29 | cookies = [] 30 | curl.on_header { |header| 31 | 32 | # Parse cookies from the headers (yes, this is a naive implementation but it's fast). 33 | cookies << "#{$1}=#{$2}" if header =~ /^Set-Cookie: ([^=])=([^;]+;)/ 34 | 35 | # A HTTP 302 redirection to the MAL panel indicates successful authentication. 36 | authenticated = true if header =~ %r{^Location: http://myanimelist.net/panel.php\s+} 37 | 38 | header.length 39 | } 40 | curl.http_post( 41 | Curl::PostField.content('username', username), 42 | Curl::PostField.content('password', password), 43 | Curl::PostField.content('cookies', '1') 44 | ) 45 | 46 | # Reset the on_header handler. 47 | curl.on_header 48 | 49 | # Save cookie string into session. 50 | session['cookie_string'] = cookies.join(' ') if authenticated 51 | 52 | authenticated 53 | end 54 | 55 | def authenticate 56 | return if authenticated? 57 | unauthenticated! unless auth.provided? 58 | bad_request! unless auth.basic? 59 | unauthenticated! unless authenticate_with_mal(*auth.credentials) 60 | request.env['REMOTE_USER'] = auth.username 61 | end 62 | end # END module Auth 63 | end # END module Rack 64 | end -------------------------------------------------------------------------------- /public/css/skin.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | background-color: #fff; 7 | color: #333; 8 | font-family: Helvetica, Arial, sans-serif; 9 | font-size: 14px; 10 | height: 100%; 11 | } 12 | 13 | em { 14 | font-style: italic; 15 | } 16 | 17 | h1, h2, h3, h4, h5, h6, strong, th, label { 18 | font-weight: bold; 19 | } 20 | 21 | h1 { 22 | font-size: 20px; 23 | margin: 0 0 10px; 24 | } 25 | 26 | h2 { 27 | font-size: 18px; 28 | margin: 24px 0 10px; 29 | } 30 | 31 | h3 { 32 | font-size: 16px; 33 | margin: 16px 0 4px; 34 | } 35 | 36 | h4 { 37 | font-size: 14px; 38 | margin: 8px 0 4px; 39 | } 40 | 41 | p { 42 | line-height: 20px; 43 | margin: 0 0 15px; 44 | } 45 | 46 | table { 47 | margin: 8px 0; 48 | } 49 | 50 | th, td { 51 | border: 1px solid #ccc; 52 | padding: 3px 5px; 53 | } 54 | 55 | a { 56 | color: #c45; 57 | text-decoration: none; 58 | } 59 | a:hover { 60 | color: #911; 61 | text-decoration: underline; 62 | } 63 | /*a:visited { 64 | color: #c77; 65 | }*/ 66 | 67 | 68 | #container { 69 | margin: auto; 70 | width: 740px; 71 | padding: 20px; 72 | } 73 | 74 | h3.logo { 75 | font-size: 30px; 76 | margin: 0 0 16px; 77 | } 78 | .logo a { 79 | color: #333; 80 | text-decoration: none; 81 | } 82 | 83 | #nav_main { 84 | font-size: 18px; 85 | margin: 0 0 20px; 86 | padding: 0; 87 | } 88 | 89 | #nav_main li { 90 | float: left; 91 | list-style-type: none; 92 | padding: 0 16px 0 0; 93 | } 94 | 95 | #nav_main:after { 96 | content: "."; 97 | clear: both; 98 | display: block; 99 | height: 0; 100 | visibility: hidden; 101 | } 102 | 103 | div.section { 104 | margin: 0 0 30px; 105 | } 106 | 107 | ul { 108 | margin: 0 0 12px; 109 | padding: 0 0 0 30px; 110 | } 111 | 112 | ul li { 113 | list-style-type: disc; 114 | padding: 2px 0; 115 | } 116 | 117 | ul li ul { 118 | margin: 0; 119 | padding: 0 0 0 30px; 120 | } 121 | 122 | ul li ul li { 123 | list-style-type: circle; 124 | } 125 | 126 | pre, blockquote { 127 | margin: 14px 28px; 128 | } 129 | 130 | code { 131 | color: #060; 132 | } 133 | 134 | .args { 135 | color: #060; 136 | } 137 | 138 | .warn { 139 | color: red; 140 | } -------------------------------------------------------------------------------- /my_anime_list/anime_list.rb: -------------------------------------------------------------------------------- 1 | module MyAnimeList 2 | class AnimeList 3 | attr_writer :anime 4 | 5 | def self.anime_list_of(username) 6 | curl = Curl::Easy.new("http://myanimelist.net/malappinfo.php?u=#{username}&status=all&type=anime") 7 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 8 | begin 9 | curl.perform 10 | rescue Exception => e 11 | raise NetworkError("Network error getting anime list for '#{username}'. Original exception: #{e.message}.", e) 12 | end 13 | 14 | raise NetworkError("Network error getting anime list for '#{username}'. MyAnimeList returned HTTP status code #{curl.response_code}.", e) unless curl.response_code == 200 15 | 16 | response = curl.body_str 17 | 18 | # Check for usernames that don't exist. malappinfo.php returns a simple "Invalid username" string (but doesn't 19 | # return a 404 status code). 20 | throw :halt, [404, 'User not found'] if response =~ /^invalid username/i 21 | 22 | xml_doc = Nokogiri::XML.parse(response) 23 | 24 | anime_list = AnimeList.new 25 | 26 | # Parse anime. 27 | anime_list.anime = xml_doc.search('anime').map do |anime_node| 28 | anime = MyAnimeList::Anime.new 29 | anime.id = anime_node.at('series_animedb_id').text.to_i 30 | anime.title = anime_node.at('series_title').text 31 | anime.type = anime_node.at('series_type').text 32 | anime.status = anime_node.at('series_status').text 33 | anime.episodes = anime_node.at('series_episodes').text.to_i 34 | anime.image_url = anime_node.at('series_image').text 35 | anime.listed_anime_id = anime_node.at('my_id').text.to_i 36 | anime.watched_episodes = anime_node.at('my_watched_episodes').text.to_i 37 | anime.score = anime_node.at('my_score').text.to_i 38 | anime.watched_status = anime_node.at('my_status').text 39 | 40 | anime 41 | end 42 | 43 | # Parse statistics. 44 | anime_list.statistics[:days] = xml_doc.at('myinfo user_days_spent_watching').text.to_f 45 | 46 | anime_list 47 | end 48 | 49 | def anime 50 | @anime ||= [] 51 | end 52 | 53 | def statistics 54 | @statistics ||= {} 55 | end 56 | 57 | def to_json(*args) 58 | { 59 | :anime => anime, 60 | :statistics => statistics 61 | }.to_json(*args) 62 | end 63 | 64 | def to_xml 65 | xml = Builder::XmlMarkup.new(:indent => 2) 66 | xml.instruct! 67 | 68 | xml.animelist do |xml| 69 | anime.each do |a| 70 | xml << a.to_xml(:skip_instruct => true) 71 | end 72 | 73 | xml.statistics do |xml| 74 | xml.days statistics[:days] 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /my_anime_list/manga_list.rb: -------------------------------------------------------------------------------- 1 | module MyAnimeList 2 | class MangaList 3 | attr_writer :manga 4 | 5 | def self.manga_list_of(username) 6 | curl = Curl::Easy.new("http://myanimelist.net/malappinfo.php?u=#{username}&status=all&type=manga") 7 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 8 | begin 9 | curl.perform 10 | rescue Exception => e 11 | raise NetworkError("Network error getting manga list for '#{username}'. Original exception: #{e.message}.", e) 12 | end 13 | 14 | raise NetworkError("Network error getting manga list for '#{username}'. MyAnimeList returned HTTP status code #{curl.response_code}.", e) unless curl.response_code == 200 15 | 16 | response = curl.body_str 17 | 18 | # Check for usernames that don't exist. malappinfo.php returns a simple "Invalid username" string (but doesn't 19 | # return a 404 status code). 20 | throw :halt, [404, 'User not found'] if response =~ /^invalid username/i 21 | 22 | xml_doc = Nokogiri::XML.parse(response) 23 | 24 | manga_list = MangaList.new 25 | 26 | # Parse manga. 27 | manga_list.manga = xml_doc.search('manga').map do |manga_node| 28 | manga = MyAnimeList::Manga.new 29 | manga.id = manga_node.at('series_mangadb_id').text.to_i 30 | manga.title = manga_node.at('series_title').text 31 | manga.type = manga_node.at('series_type').text 32 | manga.status = manga_node.at('series_status').text 33 | manga.chapters = manga_node.at('series_chapters').text.to_i 34 | manga.volumes = manga_node.at('series_volumes').text.to_i 35 | manga.image_url = manga_node.at('series_image').text 36 | manga.listed_manga_id = manga_node.at('my_id').text.to_i 37 | manga.volumes_read = manga_node.at('my_read_volumes').text.to_i 38 | manga.chapters_read = manga_node.at('my_read_chapters').text.to_i 39 | manga.score = manga_node.at('my_score').text.to_i 40 | manga.read_status = manga_node.at('my_status').text 41 | 42 | manga 43 | end 44 | 45 | # Parse statistics. 46 | manga_list.statistics[:days] = xml_doc.at('myinfo user_days_spent_watching').text.to_f 47 | 48 | manga_list 49 | end 50 | 51 | def manga 52 | @manga ||= [] 53 | end 54 | 55 | def statistics 56 | @statistics ||= {} 57 | end 58 | 59 | def to_json(*args) 60 | { 61 | :manga => manga, 62 | :statistics => statistics 63 | }.to_json(*args) 64 | end 65 | 66 | def to_xml 67 | xml = Builder::XmlMarkup.new(:indent => 2) 68 | xml.instruct! 69 | 70 | xml.mangalist do |xml| 71 | manga.each do |a| 72 | xml << a.to_xml(:skip_instruct => true) 73 | end 74 | 75 | xml.statistics do |xml| 76 | xml.days statistics[:days] 77 | end 78 | end 79 | end 80 | 81 | end # END class MangaList 82 | end 83 | -------------------------------------------------------------------------------- /my_anime_list/user.rb: -------------------------------------------------------------------------------- 1 | require 'chronic' 2 | 3 | module MyAnimeList 4 | class User 5 | attr_accessor :username 6 | 7 | # Returns a user's history. 8 | # 9 | # Options: 10 | # * type - Set to :anime or :manga to return only anime or manga history respectively. Otherwise, both anime and 11 | # manga history are returned. 12 | def history(options = {}) 13 | 14 | history_url = case options[:type] 15 | when :anime 16 | "http://myanimelist.net/history/#{username}/anime" 17 | when :manga 18 | "http://myanimelist.net/history/#{username}/manga" 19 | else 20 | "http://myanimelist.net/history/#{username}" 21 | end 22 | 23 | curl = Curl::Easy.new(history_url) 24 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 25 | begin 26 | curl.perform 27 | rescue Exception => e 28 | raise MyAnimeList::NetworkError.new("Network error getting history for username=#{username}. Original exception: #{e.message}.", e) 29 | end 30 | 31 | response = curl.body_str 32 | 33 | doc = Nokogiri::HTML(response) 34 | 35 | results = [] 36 | doc.search('div#content table tr').each do |tr| 37 | cells = tr.search('td') 38 | next unless cells && cells.size == 2 39 | 40 | link = cells[0].at('a') 41 | anime_id = link['href'][%r{http://myanimelist.net/anime.php\?id=(\d+)}, 1] 42 | anime_id = link['href'][%r{http://myanimelist.net/anime/(\d+)/?.*}, 1] unless anime_id 43 | anime_id = anime_id.to_i 44 | 45 | manga_id = link['href'][%r{http://myanimelist.net/manga.php\?id=(\d+)}, 1] 46 | manga_id = link['href'][%r{http://myanimelist.net/manga/(\d+)/?.*}, 1] unless manga_id 47 | manga_id = manga_id.to_i 48 | 49 | title = link.text.strip 50 | episode_or_chapter = cells[0].at('strong').text.to_i 51 | time_string = cells[1].text.strip 52 | 53 | begin 54 | # FIXME The datetime is in the user's timezone set in his profile http://myanimelist.net/editprofile.php. 55 | datetime = DateTime.strptime(time_string, '%m-%d-%y, %H:%M %p') 56 | time = Time.utc(datetime.year, datetime.month, datetime.day, datetime.hour, datetime.min, datetime.sec) 57 | rescue ArgumentError 58 | time = Chronic.parse(time_string) 59 | end 60 | 61 | # Constructs either an anime object, or manga object 62 | # based on the presence of either id. 63 | results << Hash.new.tap do |history_entry| 64 | history_entry[:anime_id] = anime_id if anime_id > 0 65 | history_entry[:episode] = episode_or_chapter if anime_id > 0 66 | history_entry[:manga_id] = manga_id if manga_id > 0 67 | history_entry[:chapter] = episode_or_chapter if manga_id > 0 68 | history_entry[:title] = title 69 | history_entry[:time] = time 70 | end 71 | end 72 | 73 | results 74 | rescue Exception => e 75 | raise MyAnimeList::UnknownError.new("Error getting history for username=#{username}. Original exception: #{e.message}.", e) 76 | end 77 | 78 | 79 | def profile 80 | profile_url = "http://myanimelist.net/profile/#{username}" 81 | curl = Curl::Easy.new(profile_url) 82 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 83 | begin 84 | curl.perform 85 | rescue Exception => e 86 | raise MyAnimeList::NetworkError.new("Network error getting profile details for username=#{username}. Original exception: #{e.message}.", e) 87 | end 88 | 89 | response = curl.body_str 90 | 91 | doc = Nokogiri::HTML(response) 92 | 93 | left_content = doc.at("#content .profile_leftcell") 94 | avatar = left_content.at("#profileRows").previous_element.at("img") 95 | 96 | main_content = doc.at('#content #horiznav_nav').next_element 97 | details, updates, anime_stats, manga_stats = main_content.search("> table table") 98 | 99 | { 100 | :avatar_url => avatar['src'], 101 | :details => UserDetails.parse(details), 102 | :anime_stats => UserStats.parse(anime_stats), 103 | :manga_stats => UserStats.parse(manga_stats), 104 | } 105 | rescue Exception => e 106 | raise MyAnimeList::UnknownError.new("Error getting history for username=#{username}. Original exception: #{e.message}.", e) 107 | end 108 | 109 | class UserDetails 110 | def self.parse(node) 111 | result = {} 112 | node.search("tr").each do |tr| 113 | label, value = tr.search("> td") 114 | parameterized_label = label.text.downcase.gsub(/\s+/, "_") 115 | result[parameterized_label] = case parameterized_label 116 | when "anime_list_views", "manga_list_views", "comments" 117 | parse_integer(value.text) 118 | when "forum_posts" 119 | parse_integer(value.text.match(/^[,0-9]+/)[0]) 120 | when "website" 121 | value.at("a")['href'] 122 | else 123 | value.text 124 | end 125 | end 126 | add_defaults(result) 127 | end 128 | 129 | def self.parse_integer(integer_string) 130 | integer_string.gsub(",", "").to_i 131 | end 132 | 133 | def self.add_defaults(result) 134 | # Default values for details that are not necessarily visible 135 | { 136 | "birthday" => nil, 137 | "location" => nil, 138 | "website" => nil, 139 | "aim" => nil, 140 | "msn" => nil, 141 | "yahoo" => nil, 142 | "comments" => 0, 143 | "forum_posts" => 0, 144 | }.merge(result) 145 | end 146 | end 147 | 148 | class UserStats 149 | def self.parse(node) 150 | result = {} 151 | node.search("tr").each do |tr| 152 | label, value, _ = tr.search("td") 153 | parameterized_label = label.text.downcase.gsub(/[\(\)]/, "").gsub(/\s+/, "_") 154 | result[parameterized_label] = value.text.to_f 155 | end 156 | result 157 | end 158 | end 159 | 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'sinatra/reloader' 3 | 4 | require 'curb' 5 | require 'net/http' 6 | require 'nokogiri' 7 | require 'builder' 8 | require 'json' 9 | require './my_anime_list' 10 | 11 | 12 | class App < Sinatra::Base 13 | 14 | configure do 15 | enable :sessions, :static, :methodoverride 16 | disable :raise_errors 17 | 18 | set :public_folder, Proc.new { File.join(File.dirname(__FILE__), 'public') } 19 | 20 | # JSON CSRF protection interferes with CORS requests. Seeing as we're only acting 21 | # as a proxy and not dealing with sensitive information, we'll disable this to 22 | # prevent all manner of headaches. 23 | set :protection, :except => :json_csrf 24 | end 25 | 26 | configure :development do 27 | register Sinatra::Reloader 28 | end 29 | 30 | # CORS support: this let's us make cross domain ajax requests to 31 | # this app without having to resort to jsonp. 32 | # 33 | # For more details, see the project's readme: https://github.com/cyu/rack-cors 34 | use Rack::Cors do 35 | # Blanket whitelist all cross-domain xhr requests 36 | allow do 37 | origins '*' 38 | resource '*', :headers => :any, :methods => [:get, :post, :put, :delete] 39 | end 40 | end 41 | 42 | JSON_RESPONSE_MIME_TYPE = 'application/json' 43 | mime_type :json, JSON_RESPONSE_MIME_TYPE 44 | 45 | # Error handlers. 46 | 47 | error MyAnimeList::NetworkError do 48 | details = "Exception message: #{request.env['sinatra.error'].message}" 49 | case params[:format] 50 | when 'xml' 51 | "network-error
#{details}
" 52 | else 53 | body = { :error => 'network-error', :details => details }.to_json 54 | params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 55 | end 56 | end 57 | 58 | error MyAnimeList::UpdateError do 59 | details = "Exception message: #{request.env['sinatra.error'].message}" 60 | case params[:format] 61 | when 'xml' 62 | "anime-update-error
#{details}
" 63 | else 64 | body = { :error => 'anime-update-error', :details => details }.to_json 65 | params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 66 | end 67 | end 68 | 69 | error MyAnimeList::NotFoundError do 70 | status 404 71 | case params[:format] 72 | when 'xml' 73 | "not-found
#{request.env['sinatra.error'].message}
" 74 | else 75 | body = { :error => 'not-found', :details => request.env['sinatra.error'].message }.to_json 76 | params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 77 | end 78 | end 79 | 80 | error MyAnimeList::UnknownError do 81 | details = "Exception message: #{request.env['sinatra.error'].message}" 82 | case params[:format] 83 | when 'xml' 84 | "unknown-error
#{details}
" 85 | else 86 | body = { :error => 'unknown-error', :details => details }.to_json 87 | params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 88 | end 89 | end 90 | 91 | error do 92 | details = "Exception message: #{request.env['sinatra.error'].message}" 93 | case params[:format] 94 | when 'xml' 95 | "unknown-error
#{details}
" 96 | else 97 | body = { :error => 'unknown-error', :details => details }.to_json 98 | params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 99 | end 100 | end 101 | 102 | 103 | helpers do 104 | include MyAnimeList::Rack::Auth 105 | end 106 | 107 | before do 108 | case params[:format] 109 | when 'xml' 110 | content_type(:xml) 111 | else 112 | content_type(:json) 113 | end 114 | end 115 | 116 | 117 | # GET /anime/#{anime_id} 118 | # Get an anime's details. 119 | # Optional parameters: 120 | # * mine=1 - If specified, include the authenticated user's anime details (e.g. user's score, watched status, watched 121 | # episodes). Requires authentication. 122 | get '/anime/:id' do 123 | pass unless params[:id] =~ /^\d+$/ 124 | 125 | if params[:mine] == '1' 126 | authenticate unless session['cookie_string'] 127 | anime = MyAnimeList::Anime.scrape_anime(params[:id], session['cookie_string']) 128 | else 129 | anime = MyAnimeList::Anime.scrape_anime(params[:id]) 130 | 131 | # Caching. 132 | expires 3600, :public, :must_revalidate 133 | last_modified Time.now 134 | etag "anime/#{anime.id}" 135 | end 136 | 137 | case params[:format] 138 | when 'xml' 139 | anime.to_xml 140 | else 141 | params[:callback].nil? ? anime.to_json : "#{params[:callback]}(#{anime.to_json})" 142 | end 143 | end 144 | 145 | 146 | # POST /animelist/anime 147 | # Adds an anime to a user's anime list. 148 | post '/animelist/anime' do 149 | authenticate unless session['cookie_string'] 150 | 151 | # Ensure "anime_id" param is given. 152 | if params[:anime_id] !~ /\S/ 153 | case params[:format] 154 | when 'xml' 155 | halt 400, 'anime_id-required' 156 | else 157 | body = { :error => 'anime_id-required' }.to_json 158 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 159 | end 160 | end 161 | 162 | successful = MyAnimeList::Anime.add(params[:anime_id], session['cookie_string'], { 163 | :status => params[:status], 164 | :episodes => params[:episodes], 165 | :score => params[:score] 166 | }) 167 | 168 | if successful 169 | nil # Return HTTP 200 OK and empty response body if successful. 170 | else 171 | case params[:format] 172 | when 'xml' 173 | halt 400, 'unknown-error' 174 | else 175 | body = { :error => 'unknown-error' }.to_json 176 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 177 | end 178 | end 179 | end 180 | 181 | 182 | # PUT /animelist/anime/#{anime_id} 183 | # Updates an anime already on a user's anime list. 184 | put '/animelist/anime/:anime_id' do 185 | authenticate unless session['cookie_string'] 186 | 187 | successful = MyAnimeList::Anime.update(params[:anime_id], session['cookie_string'], { 188 | :status => params[:status], 189 | :episodes => params[:episodes], 190 | :score => params[:score] 191 | }) 192 | 193 | if successful 194 | nil # Return HTTP 200 OK and empty response body if successful. 195 | else 196 | case params[:format] 197 | when 'xml' 198 | halt 400, 'unknown-error' 199 | else 200 | body = { :error => 'unknown-error' }.to_json 201 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 202 | end 203 | end 204 | end 205 | 206 | 207 | # DELETE /animelist/anime/#{anime_id} 208 | # Delete an anime from user's anime list. 209 | delete '/animelist/anime/:anime_id' do 210 | authenticate unless session['cookie_string'] 211 | 212 | anime = MyAnimeList::Anime.delete(params[:anime_id], session['cookie_string']) 213 | 214 | if anime 215 | # Return HTTP 200 OK and the original anime if successful. 216 | case params[:format] 217 | when 'xml' 218 | anime.to_xml 219 | else 220 | params[:callback].nil? ? anime.to_json : "#{params[:callback]}(#{anime.to_json})" 221 | end 222 | else 223 | case params[:format] 224 | when 'xml' 225 | halt 400, 'unknown-error' 226 | else 227 | body = { :error => 'unknown-error' }.to_json 228 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 229 | end 230 | end 231 | end 232 | 233 | 234 | # GET /animelist/#{username} 235 | # Get a user's anime list. 236 | get '/animelist/:username' do 237 | response['Cache-Control'] = 'private,max-age=0,must-revalidate,no-store' 238 | 239 | anime_list = MyAnimeList::AnimeList.anime_list_of(params[:username]) 240 | 241 | case params[:format] 242 | when 'xml' 243 | anime_list.to_xml 244 | else 245 | params[:callback].nil? ? anime_list.to_json : "#{params[:callback]}(#{anime_list.to_json})" 246 | end 247 | end 248 | 249 | # GET /anime/search 250 | # Search for anime. 251 | get '/anime/search' do 252 | # Ensure "q" param is given. 253 | if params[:q] !~ /\S/ 254 | case params[:format] 255 | when 'xml' 256 | halt 400, 'q-required' 257 | else 258 | body = { :error => 'q-required' }.to_json 259 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 260 | end 261 | end 262 | 263 | results = MyAnimeList::Anime.search(params[:q]) 264 | 265 | # Caching. 266 | expires 3600, :public, :must_revalidate 267 | last_modified Time.now 268 | etag "anime/search/#{params[:q]}" 269 | 270 | case params[:format] 271 | when 'xml' 272 | xml = Builder::XmlMarkup.new(:indent => 2) 273 | xml.instruct! 274 | 275 | xml.results do |xml| 276 | xml.query params[:q] 277 | xml.count results.size 278 | 279 | results.each do |a| 280 | xml << a.to_xml(:skip_instruct => true) 281 | end 282 | end 283 | 284 | xml.target! 285 | else 286 | params[:callback].nil? ? results.to_json : "#{params[:callback]}(#{results.to_json})" 287 | end 288 | end 289 | 290 | 291 | # GET /anime/top 292 | # Get the top anime. 293 | get '/anime/top' do 294 | anime = MyAnimeList::Anime.top( 295 | :type => params[:type], 296 | :page => params[:page], 297 | :per_page => params[:per_page] 298 | ) 299 | 300 | case params[:format] 301 | when 'xml' 302 | anime.to_xml 303 | else 304 | params[:callback].nil? ? anime.to_json : "#{params[:callback]}(#{anime.to_json})" 305 | end 306 | end 307 | 308 | # GET /anime/popular 309 | # Get the popular anime. 310 | get '/anime/popular' do 311 | anime = MyAnimeList::Anime.top( 312 | :type => 'bypopularity', 313 | :page => params[:page], 314 | :per_page => params[:per_page] 315 | ) 316 | 317 | case params[:format] 318 | when 'xml' 319 | anime.to_xml 320 | else 321 | params[:callback].nil? ? anime.to_json : "#{params[:callback]}(#{anime.to_json})" 322 | end 323 | end 324 | 325 | # GET /anime/upcoming 326 | # Get the upcoming anime 327 | get '/anime/upcoming' do 328 | anime = MyAnimeList::Anime.upcoming( 329 | :page => params[:page], 330 | :per_page => params[:per_page], 331 | :start_date => params[:start_date] 332 | ) 333 | 334 | case params[:format] 335 | when 'xml' 336 | anime.to_xml 337 | else 338 | params[:callback].nil? ? anime.to_json : "#{params[:callback]}(#{anime.to_json}" 339 | end 340 | end 341 | 342 | # GET /anime/just_added 343 | # Get just added anime 344 | get '/anime/just_added' do 345 | anime = MyAnimeList::Anime.just_added( 346 | :page => params[:page], 347 | :per_page => params[:per_page] 348 | ) 349 | 350 | case params[:format] 351 | when 'xml' 352 | anime.to_xml 353 | else 354 | params[:callback].nil? ? anime.to_json : "#{params[:callback]}(#{anime.to_json}" 355 | end 356 | end 357 | 358 | # GET /history/#{username} 359 | # Get user's history. 360 | get '/history/:username/?:type?' do 361 | user = MyAnimeList::User.new 362 | user.username = params[:username] 363 | 364 | options = Hash.new.tap do |options| 365 | options[:type] = params[:type].to_sym unless params[:type].nil? 366 | end 367 | 368 | history = user.history(options) 369 | 370 | case params[:format] 371 | when 'xml' 372 | history.to_xml 373 | else 374 | params[:callback].nil? ? history.to_json : "#{params[:callback]}(#{history.to_json})" 375 | end 376 | end 377 | 378 | # GET /profile/#{username} 379 | # Get user's profile information. 380 | get '/profile/:username' do 381 | user = MyAnimeList::User.new 382 | user.username = params[:username] 383 | 384 | profile = user.profile 385 | 386 | case params[:format] 387 | when 'xml' 388 | profile.to_xml 389 | else 390 | params[:callback].nil? ? profile.to_json : "#{params[:callback]}(#{profile.to_json})" 391 | end 392 | end 393 | 394 | # GET /manga/#{manga_id} 395 | # Get a manga's details. 396 | # Optional parameters: 397 | # * mine=1 - If specified, include the authenticated user's manga details (e.g. user's score, read status). Requires 398 | # authentication. 399 | get '/manga/:id' do 400 | pass unless params[:id] =~ /^\d+$/ 401 | 402 | if params[:mine] == '1' 403 | authenticate unless session['cookie_string'] 404 | manga = MyAnimeList::Manga.scrape_manga(params[:id], session['cookie_string']) 405 | else 406 | manga = MyAnimeList::Manga.scrape_manga(params[:id]) 407 | 408 | # Caching. 409 | expires 3600, :public, :must_revalidate 410 | last_modified Time.now 411 | etag "manga/#{manga.id}" 412 | end 413 | 414 | case params[:format] 415 | when 'xml' 416 | manga.to_xml 417 | else 418 | params[:callback].nil? ? manga.to_json : "#{params[:callback]}(#{manga.to_json})" 419 | end 420 | end 421 | 422 | 423 | # POST /mangalist/manga 424 | # Adds a manga to a user's manga list. 425 | post '/mangalist/manga' do 426 | authenticate unless session['cookie_string'] 427 | 428 | # Ensure "manga_id" param is given. 429 | if params[:manga_id] !~ /\S/ 430 | case params[:format] 431 | when 'xml' 432 | halt 400, 'manga_id-required' 433 | else 434 | body = { :error => 'manga_id-required' }.to_json 435 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 436 | end 437 | end 438 | 439 | successful = MyAnimeList::Manga.add(params[:manga_id], session['cookie_string'], { 440 | :status => params[:status], 441 | :chapters => params[:chapters], 442 | :volumes => params[:volumes], 443 | :score => params[:score] 444 | }) 445 | 446 | if successful 447 | nil # Return HTTP 200 OK and empty response body if successful. 448 | else 449 | case params[:format] 450 | when 'xml' 451 | halt 400, 'unknown-error' 452 | else 453 | body = { :error => 'unknown-error' }.to_json 454 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 455 | end 456 | end 457 | end 458 | 459 | 460 | # PUT /mangalist/manga/#{manga_id} 461 | # Updates a manga already on a user's manga list. 462 | put '/mangalist/manga/:manga_id' do 463 | authenticate unless session['cookie_string'] 464 | 465 | successful = MyAnimeList::Manga.update(params[:manga_id], session['cookie_string'], { 466 | :status => params[:status], 467 | :chapters => params[:chapters], 468 | :volumes => params[:volumes], 469 | :score => params[:score] 470 | }) 471 | 472 | if successful 473 | nil # Return HTTP 200 OK and empty response body if successful. 474 | else 475 | case params[:format] 476 | when 'xml' 477 | halt 400, 'unknown-error' 478 | else 479 | body = { :error => 'unknown-error' }.to_json 480 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 481 | end 482 | end 483 | end 484 | 485 | 486 | # DELETE /mangalist/manga/#{manga_id} 487 | # Delete a manga from user's manga list. 488 | delete '/mangalist/manga/:manga_id' do 489 | authenticate unless session['cookie_string'] 490 | 491 | manga = MyAnimeList::Manga.delete(params[:manga_id], session['cookie_string']) 492 | 493 | if manga 494 | # Return HTTP 200 OK and the original manga if successful. 495 | case params[:format] 496 | when 'xml' 497 | manga.to_xml 498 | else 499 | params[:callback].nil? ? manga.to_json : "#{params[:callback]}(#{manga.to_json})" 500 | end 501 | else 502 | case params[:format] 503 | when 'xml' 504 | halt 400, 'unknown-error' 505 | else 506 | body = { :error => 'unknown-error' }.to_json 507 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 508 | end 509 | end 510 | end 511 | 512 | 513 | # GET /mangalist/#{username} 514 | # Get a user's manga list. 515 | get '/mangalist/:username' do 516 | manga_list = MyAnimeList::MangaList.manga_list_of(params[:username]) 517 | 518 | case params[:format] 519 | when 'xml' 520 | manga_list.to_xml 521 | else 522 | params[:callback].nil? ? manga_list.to_json : "#{params[:callback]}(#{manga_list.to_json})" 523 | end 524 | end 525 | 526 | 527 | # GET /manga/search 528 | # Search for manga. 529 | get '/manga/search' do 530 | # Ensure "q" param is given. 531 | if params[:q] !~ /\S/ 532 | case params[:format] 533 | when 'xml' 534 | halt 400, 'q-required' 535 | else 536 | body = { :error => 'q-required' }.to_json 537 | halt 400, params[:callback].nil? ? body : "#{params[:callback]}(#{body})" 538 | end 539 | end 540 | 541 | results = MyAnimeList::Manga.search(params[:q]) 542 | 543 | # Caching. 544 | expires 3600, :public, :must_revalidate 545 | last_modified Time.now 546 | etag "manga/search/#{params[:q]}" 547 | 548 | case params[:format] 549 | when 'xml' 550 | xml = Builder::XmlMarkup.new(:indent => 2) 551 | xml.instruct! 552 | 553 | xml.results do |xml| 554 | xml.query params[:q] 555 | xml.count results.size 556 | 557 | results.each do |a| 558 | xml << a.to_xml(:skip_instruct => true) 559 | end 560 | end 561 | 562 | xml.target! 563 | else 564 | params[:callback].nil? ? results.to_json : "#{params[:callback]}(#{results.to_json})" 565 | end 566 | end 567 | 568 | 569 | # Verify that authentication credentials are valid. 570 | # Returns an HTTP 200 OK response if authentication was successful, or an HTTP 401 response. 571 | # FIXME This should be rate-limited to avoid brute-force attacks. 572 | get '/account/verify_credentials' do 573 | # Authenticate with MyAnimeList if we don't have a cookie string. 574 | authenticate unless session['cookie_string'] 575 | 576 | nil # Reponse body is empy. 577 | end 578 | end 579 | -------------------------------------------------------------------------------- /my_anime_list/manga.rb: -------------------------------------------------------------------------------- 1 | module MyAnimeList 2 | class Manga 3 | attr_accessor :id, :title, :rank, :image_url, :popularity_rank, :volumes, :chapters, 4 | :members_score, :members_count, :favorited_count, :synopsis 5 | attr_accessor :listed_manga_id 6 | attr_reader :type, :status 7 | attr_writer :genres, :tags, :other_titles, :anime_adaptations, :related_manga, :alternative_versions 8 | 9 | # These attributes are specific to a user-manga pair. 10 | attr_accessor :volumes_read, :chapters_read, :score 11 | attr_reader :read_status 12 | 13 | # Scrape manga details page on MyAnimeList.net. 14 | def self.scrape_manga(id, cookie_string = nil) 15 | curl = Curl::Easy.new("http://myanimelist.net/manga/#{id}") 16 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 17 | curl.cookies = cookie_string if cookie_string 18 | begin 19 | curl.perform 20 | rescue Exception => e 21 | raise MyAnimeList::NetworkError.new("Network error scraping manga with ID=#{id}. Original exception: #{e.message}.", e) 22 | end 23 | 24 | response = curl.body_str 25 | 26 | # Check for missing manga. 27 | raise MyAnimeList::NotFoundError.new("Manga with ID #{id} doesn't exist.", nil) if response =~ /No manga found/i 28 | 29 | manga = parse_manga_response(response) 30 | 31 | manga 32 | rescue MyAnimeList::NotFoundError => e 33 | raise 34 | rescue Exception => e 35 | raise MyAnimeList::UnknownError.new("Error scraping manga with ID=#{id}. Original exception: #{e.message}.", e) 36 | end 37 | 38 | def self.add(id, cookie_string, options) 39 | # This is the same as self.update except that the "status" param is required and the URL is 40 | # http://myanimelist.net/includes/ajax.inc.php?t=49. 41 | 42 | # Default read_status to 1/reading if not given. 43 | options[:status] = 1 if options[:status] !~ /\S/ 44 | options[:new] = true 45 | 46 | update(id, cookie_string, options) 47 | end 48 | 49 | def self.update(id, cookie_string, options) 50 | 51 | # Convert status to the number values that MyAnimeList uses. 52 | # 1 = Reading, 2 = Completed, 3 = On-hold, 4 = Dropped, 6 = Plan to Read 53 | status = case options[:status] 54 | when 'Reading', 'reading', 1 55 | 1 56 | when 'Completed', 'completed', 2 57 | 2 58 | when 'On-hold', 'on-hold', 3 59 | 3 60 | when 'Dropped', 'dropped', 4 61 | 4 62 | when 'Plan to Read', 'plan to read', 6 63 | 6 64 | else 65 | 1 66 | end 67 | 68 | # There're different URLs to POST to for adding and updating a manga. 69 | url = options[:new] ? 'http://myanimelist.net/includes/ajax.inc.php?t=49' : 'http://myanimelist.net/includes/ajax.inc.php?t=34' 70 | 71 | curl = Curl::Easy.new(url) 72 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 73 | curl.cookies = cookie_string 74 | params = [ 75 | Curl::PostField.content('mid', id), 76 | Curl::PostField.content('status', status) 77 | ] 78 | params << Curl::PostField.content('chapters', options[:chapters]) if options[:chapters] 79 | params << Curl::PostField.content('volumes', options[:volumes]) if options[:volumes] 80 | params << Curl::PostField.content('score', options[:score]) if options[:score] 81 | 82 | begin 83 | curl.http_post(*params) 84 | rescue Exception => e 85 | raise MyAnimeList::UpdateError.new("Error updating manga with ID=#{id}. Original exception: #{e.message}", e) 86 | end 87 | 88 | if options[:new] 89 | # An add is successful for an HTTP 200 response containing "successful". 90 | # The MyAnimeList site is actually pretty bad and seems to respond with 200 OK for all requests. 91 | # It's also oblivious to IDs for non-existent manga and responds wrongly with a "successful" message. 92 | # It responds with an empty response body for bad adds or if you try to add a manga that's already on the 93 | # manga list. 94 | # Due to these limitations, we will return false if the response body doesn't match "successful" and assume that 95 | # anything else is a failure. 96 | return curl.response_code == 200 && curl.body_str =~ /Added/i 97 | else 98 | # Update is successful for an HTTP 200 response with this string. 99 | curl.response_code == 200 && curl.body_str =~ /Updated/i 100 | end 101 | end 102 | 103 | def self.delete(id, cookie_string) 104 | manga = scrape_manga(id, cookie_string) 105 | 106 | curl = Curl::Easy.new("http://myanimelist.net/panel.php?go=editmanga&id=#{manga.listed_manga_id}") 107 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 108 | curl.cookies = cookie_string 109 | 110 | begin 111 | curl.http_post( 112 | Curl::PostField.content('entry_id', manga.listed_manga_id), 113 | Curl::PostField.content('manga_id', id), 114 | Curl::PostField.content('submitIt', '3') 115 | ) 116 | rescue Exception => e 117 | raise MyAnimeList::UpdateError.new("Error deleting manga with ID=#{id}. Original exception: #{e.message}", e) 118 | end 119 | 120 | # Deletion is successful for an HTTP 200 response with this string. 121 | if curl.response_code == 200 && curl.body_str =~ /Successfully deleted manga entry/i 122 | manga # Return the original manga if successful. 123 | else 124 | false 125 | end 126 | end 127 | 128 | def self.search(query) 129 | 130 | begin 131 | response = Net::HTTP.start('myanimelist.net', 80) do |http| 132 | http.get("/manga.php?c[]=a&c[]=b&c[]=c&c[]=d&c[]=e&c[]=f&c[]=g&q=#{Curl::Easy.new.escape(query)}", {'User-Agent' => ENV['USER_AGENT']}) 133 | end 134 | 135 | case response 136 | when Net::HTTPRedirection 137 | redirected = true 138 | 139 | # Strip everything after the manga ID - in cases where there is a non-ASCII character in the URL, 140 | # MyAnimeList.net will return a page that says "Access has been restricted for this account". 141 | redirect_url = response['location'].sub(%r{(http://myanimelist.net/manga/\d+)/?.*}, '\1') 142 | 143 | response = Net::HTTP.start('myanimelist.net', 80) do |http| 144 | http.get(redirect_url, {'User-Agent' => ENV['USER_AGENT']}) 145 | end 146 | end 147 | 148 | rescue Exception => e 149 | raise MyAnimeList::UpdateError.new("Error searching manga with query '#{query}'. Original exception: #{e.message}", e) 150 | end 151 | 152 | results = [] 153 | if redirected 154 | # If there's a single redirect, it means there's only 1 match and MAL is redirecting to the manga's details 155 | # page. 156 | 157 | manga = parse_manga_response(response.body) 158 | results << manga 159 | 160 | else 161 | # Otherwise, parse the table of search results. 162 | 163 | doc = Nokogiri::HTML(response.body) 164 | results_table = doc.xpath('//div[@id="content"]/div[2]/table') 165 | 166 | results_table.xpath('//tr').each do |results_row| 167 | 168 | manga_title_node = results_row.at('td a strong') 169 | next unless manga_title_node 170 | url = manga_title_node.parent['href'] 171 | next unless url.match %r{/manga/(\d+)/?.*} 172 | 173 | manga = Manga.new 174 | manga.id = $1.to_i 175 | manga.title = manga_title_node.text 176 | if image_node = results_row.at('td a img') 177 | manga.image_url = image_node['src'] 178 | end 179 | 180 | table_cell_nodes = results_row.search('td') 181 | 182 | manga.volumes = table_cell_nodes[3].text.to_i 183 | manga.chapters = table_cell_nodes[4].text.to_i 184 | manga.members_score = table_cell_nodes[5].text.to_f 185 | synopsis_node = results_row.at('div.spaceit_pad') 186 | if synopsis_node 187 | synopsis_node.search('a').remove 188 | manga.synopsis = synopsis_node.text.strip 189 | end 190 | manga.type = table_cell_nodes[2].text 191 | 192 | results << manga 193 | end 194 | end 195 | 196 | results 197 | end 198 | 199 | def read_status=(value) 200 | @read_status = case value 201 | when /reading/i, '1', 1 202 | :reading 203 | when /completed/i, '2', 2 204 | :completed 205 | when /on-hold/i, /onhold/i, '3', 3 206 | :"on-hold" 207 | when /dropped/i, '4', 4 208 | :dropped 209 | when /plan/i, '6', 6 210 | :"plan to read" 211 | else 212 | :reading 213 | end 214 | end 215 | 216 | def status=(value) 217 | @status = case value 218 | when '2', 2, /finished/i 219 | :finished 220 | when '1', 1, /publishing/i 221 | :publishing 222 | when '3', 3, /not yet published/i 223 | :"not yet published" 224 | else 225 | :finished 226 | end 227 | end 228 | 229 | def type=(value) 230 | @type = case value 231 | when /manga/i, '1', 1 232 | :Manga 233 | when /novel/i, '2', 2 234 | :Novel 235 | when /one shot/i, '3', 3 236 | :"One Shot" 237 | when /doujin/i, '4', 4 238 | :Doujin 239 | when /manwha/i, '5', 5 240 | :Manwha 241 | when /manhua/i, '6', 6 242 | :Manhua 243 | when /OEL/i, '7', 7 # "OEL manga = Original English-language manga" 244 | :OEL 245 | else 246 | :Manga 247 | end 248 | end 249 | 250 | def other_titles 251 | @other_titles ||= {} 252 | end 253 | 254 | def genres 255 | @genres ||= [] 256 | end 257 | 258 | def tags 259 | @tags ||= [] 260 | end 261 | 262 | def anime_adaptations 263 | @anime_adaptations ||= [] 264 | end 265 | 266 | def related_manga 267 | @related_manga ||= [] 268 | end 269 | 270 | def alternative_versions 271 | @alternative_versions ||= [] 272 | end 273 | 274 | def attributes 275 | { 276 | :id => id, 277 | :title => title, 278 | :other_titles => other_titles, 279 | :rank => rank, 280 | :image_url => image_url, 281 | :type => type, 282 | :status => status, 283 | :volumes => volumes, 284 | :chapters => chapters, 285 | :genres => genres, 286 | :members_score => members_score, 287 | :members_count => members_count, 288 | :popularity_rank => popularity_rank, 289 | :favorited_count => favorited_count, 290 | :tags => tags, 291 | :synopsis => synopsis, 292 | :anime_adaptations => anime_adaptations, 293 | :related_manga => related_manga, 294 | :alternative_versions => alternative_versions, 295 | :read_status => read_status, 296 | :listed_manga_id => listed_manga_id, 297 | :chapters_read => chapters_read, 298 | :volumes_read => volumes_read, 299 | :score => score 300 | } 301 | end 302 | 303 | def to_json(*args) 304 | attributes.to_json(*args) 305 | end 306 | 307 | def to_xml(options = {}) 308 | xml = Builder::XmlMarkup.new(:indent => 2) 309 | xml.instruct! unless options[:skip_instruct] 310 | xml.anime do |xml| 311 | xml.id id 312 | xml.title title 313 | xml.rank rank 314 | xml.image_url image_url 315 | xml.type type.to_s 316 | xml.status status.to_s 317 | xml.volumes volumes 318 | xml.chapters chapters 319 | xml.members_score members_score 320 | xml.members_count members_count 321 | xml.popularity_rank popularity_rank 322 | xml.favorited_count favorited_count 323 | xml.synopsis synopsis 324 | xml.read_status read_status.to_s 325 | xml.chapters_read chapters_read 326 | xml.volumes_read volumes_read 327 | xml.score score 328 | 329 | other_titles[:synonyms].each do |title| 330 | xml.synonym title 331 | end if other_titles[:synonyms] 332 | other_titles[:english].each do |title| 333 | xml.english_title title 334 | end if other_titles[:english] 335 | other_titles[:japanese].each do |title| 336 | xml.japanese_title title 337 | end if other_titles[:japanese] 338 | 339 | genres.each do |genre| 340 | xml.genre genre 341 | end 342 | tags.each do |tag| 343 | xml.tag tag 344 | end 345 | 346 | anime_adaptations.each do |anime| 347 | xml.anime_adaptation do |xml| 348 | xml.anime_id anime[:anime_id] 349 | xml.title anime[:title] 350 | xml.url anime[:url] 351 | end 352 | end 353 | 354 | related_manga.each do |manga| 355 | xml.related_manga do |xml| 356 | xml.manga_id manga[:manga_id] 357 | xml.title manga[:title] 358 | xml.url manga[:url] 359 | end 360 | end 361 | 362 | alternative_versions.each do |manga| 363 | xml.alternative_version do |xml| 364 | xml.manga_id manga[:manga_id] 365 | xml.title manga[:title] 366 | xml.url manga[:url] 367 | end 368 | end 369 | end 370 | 371 | xml.target! 372 | end 373 | 374 | private 375 | 376 | def self.parse_manga_response(response) 377 | 378 | doc = Nokogiri::HTML(response) 379 | 380 | manga = Manga.new 381 | 382 | # Manga ID. 383 | # Example: 384 | # 385 | manga_id_input = doc.at('input[@name="mid"]') 386 | if manga_id_input 387 | 388 | manga.id = manga_id_input['value'].to_i 389 | else 390 | details_link = doc.at('//a[text()="Details"]') 391 | manga.id = details_link['href'][%r{http://myanimelist.net/manga/(\d+)/.*?}, 1].to_i 392 | end 393 | 394 | # Title and rank. 395 | # Example: 396 | #

397 | #
Ranked #8
Yotsuba&! 398 | # (Manga) 399 | #

400 | manga.title = doc.at(:h1).children.find { |o| o.text? }.to_s.strip 401 | manga.rank = doc.at('h1 > div').text.gsub(/\D/, '').to_i 402 | 403 | # Image URL. 404 | if image_node = doc.at('div#content tr td div img') 405 | manga.image_url = image_node['src'] 406 | end 407 | 408 | # - 409 | # Extract from sections on the left column: Alternative Titles, Information, Statistics, Popular Tags. 410 | # - 411 | left_column_nodeset = doc.xpath('//div[@id="content"]/table/tr/td[@class="borderClass"]') 412 | 413 | # Alternative Titles section. 414 | # Example: 415 | #

Alternative Titles

416 | #
English: Yotsuba&!
417 | #
Synonyms: Yotsubato!, Yotsuba and !, Yotsuba!, Yotsubato, Yotsuba and!
418 | #
Japanese: よつばと!
419 | if (node = left_column_nodeset.at('//span[text()="English:"]')) && node.next 420 | manga.other_titles[:english] = node.next.text.strip.split(/,\s?/) 421 | end 422 | if (node = left_column_nodeset.at('//span[text()="Synonyms:"]')) && node.next 423 | manga.other_titles[:synonyms] = node.next.text.strip.split(/,\s?/) 424 | end 425 | if (node = left_column_nodeset.at('//span[text()="Japanese:"]')) && node.next 426 | manga.other_titles[:japanese] = node.next.text.strip.split(/,\s?/) 427 | end 428 | 429 | 430 | # Information section. 431 | # Example: 432 | #

Information

433 | #
Type: Manga
434 | #
Volumes: Unknown
435 | #
Chapters: Unknown
436 | #
Status: Publishing
437 | #
Published: Mar 21, 2003 to ?
438 | #
Genres: 439 | # Comedy, 440 | # Slice of Life 441 | #
442 | #
Authors: 443 | # Azuma, Kiyohiko (Story & Art) 444 | #
445 | #
Serialization: 446 | # Dengeki Daioh (Monthly) 447 | #
448 | if (node = left_column_nodeset.at('//span[text()="Type:"]')) && node.next 449 | manga.type = node.next.text.strip 450 | end 451 | if (node = left_column_nodeset.at('//span[text()="Volumes:"]')) && node.next 452 | manga.volumes = node.next.text.strip.gsub(',', '').to_i 453 | manga.volumes = nil if manga.volumes == 0 454 | end 455 | if (node = left_column_nodeset.at('//span[text()="Chapters:"]')) && node.next 456 | manga.chapters = node.next.text.strip.gsub(',', '').to_i 457 | manga.chapters = nil if manga.chapters == 0 458 | end 459 | if (node = left_column_nodeset.at('//span[text()="Status:"]')) && node.next 460 | manga.status = node.next.text.strip 461 | end 462 | if node = left_column_nodeset.at('//span[text()="Genres:"]') 463 | node.parent.search('a').each do |a| 464 | manga.genres << a.text.strip 465 | end 466 | end 467 | 468 | # Statistics 469 | # Example: 470 | #

Statistics

471 | #
Score: 8.901 (scored by 4899 users) 472 | #
473 | #
Ranked: #82
474 | #
Popularity: #32
475 | #
Members: 8,344
476 | #
Favorites: 1,700
477 | if (node = left_column_nodeset.at('//span[text()="Score:"]')) && node.next 478 | manga.members_score = node.next.text.strip.to_f 479 | end 480 | if (node = left_column_nodeset.at('//span[text()="Popularity:"]')) && node.next 481 | manga.popularity_rank = node.next.text.strip.sub('#', '').gsub(',', '').to_i 482 | end 483 | if (node = left_column_nodeset.at('//span[text()="Members:"]')) && node.next 484 | manga.members_count = node.next.text.strip.gsub(',', '').to_i 485 | end 486 | if (node = left_column_nodeset.at('//span[text()="Favorites:"]')) && node.next 487 | manga.favorited_count = node.next.text.strip.gsub(',', '').to_i 488 | end 489 | 490 | # Popular Tags 491 | # Example: 492 | #

Popular Tags

493 | # 494 | # comedy 495 | # slice of life 496 | # 497 | if (node = left_column_nodeset.at('//span[preceding-sibling::h2[text()="Popular Tags"]]')) 498 | node.search('a').each do |a| 499 | manga.tags << a.text 500 | end 501 | end 502 | 503 | 504 | # - 505 | # Extract from sections on the right column: Synopsis, Related Manga 506 | # - 507 | right_column_nodeset = doc.xpath('//div[@id="content"]/table/tr/td/div/table') 508 | 509 | # Synopsis 510 | # Example: 511 | #

Synopsis

512 | # Yotsuba's daily life is full of adventure. She is energetic, curious, and a bit odd – odd enough to be called strange by her father as well as ignorant of many things that even a five-year-old should know. Because of this, the most ordinary experience can become an adventure for her. As the days progress, she makes new friends and shows those around her that every day can be enjoyable.
513 | #
514 | # [Written by MAL Rewrite] 515 | synopsis_h2 = right_column_nodeset.at('//h2[text()="Synopsis"]') 516 | if synopsis_h2 517 | node = synopsis_h2.next 518 | while node 519 | if manga.synopsis 520 | manga.synopsis << node.to_s 521 | else 522 | manga.synopsis = node.to_s 523 | end 524 | 525 | node = node.next 526 | end 527 | end 528 | 529 | # Related Manga 530 | # Example: 531 | #

Related Manga

532 | # Adaptation: Azumanga Daioh
533 | # Side story: Azumanga Daioh: Supplementary Lessons
534 | related_manga_h2 = right_column_nodeset.at('//h2[text()="Related Manga"]') 535 | if related_manga_h2 536 | 537 | # Get all text between

Related Manga

and the next

tag. 538 | match_data = related_manga_h2.parent.to_s.match(%r{

Related Manga

(.+?)

}m) 539 | 540 | if match_data 541 | related_anime_text = match_data[1] 542 | 543 | if related_anime_text.match %r{Adaptation: ?((.+?)}) do |url, anime_id, title| 545 | manga.anime_adaptations << { 546 | :anime_id => anime_id, 547 | :title => title, 548 | :url => url 549 | } 550 | end 551 | end 552 | 553 | if related_anime_text.match %r{.+: ?((.+?)}) do |url, manga_id, title| 555 | manga.related_manga << { 556 | :manga_id => manga_id, 557 | :title => title, 558 | :url => url 559 | } 560 | end 561 | end 562 | 563 | if related_anime_text.match %r{Alternative versions?: ?((.+?)}) do |url, manga_id, title| 565 | manga.alternative_versions << { 566 | :manga_id => manga_id, 567 | :title => title, 568 | :url => url 569 | } 570 | end 571 | end 572 | end 573 | end 574 | 575 | 576 | # User's manga details (only available if he authenticates). 577 | #

My Info

578 | #
579 | # 580 | # 581 | # 582 | # 583 | # 584 | # 585 | # 586 | # 587 | # 588 | # 589 | # 590 | # 591 | # 592 | # 593 | # 594 | # 595 | # 596 | # 597 | # 598 | # 599 | # 600 | # 601 | #
Status:
Chap. Read: / 0
Vol. Read: / ?
Your Score:
  Edit Details
602 | #
603 | read_status_select_node = doc.at('select#myinfo_status') 604 | if read_status_select_node && (selected_option = read_status_select_node.at('option[selected="selected"]')) 605 | manga.read_status = selected_option['value'] 606 | end 607 | chapters_node = doc.at('input#myinfo_chapters') 608 | if chapters_node 609 | manga.chapters_read = chapters_node['value'].to_i 610 | end 611 | volumes_node = doc.at('input#myinfo_volumes') 612 | if volumes_node 613 | manga.volumes_read = volumes_node['value'].to_i 614 | end 615 | score_select_node = doc.at('select#myinfo_score') 616 | if score_select_node && (selected_option = score_select_node.at('option[selected="selected"]')) 617 | manga.score = selected_option['value'].to_i 618 | end 619 | listed_manga_id_node = doc.at('//a[text()="Edit Details"]') 620 | if listed_manga_id_node 621 | manga.listed_manga_id = listed_manga_id_node['href'].match('id=(\d+)')[1].to_i 622 | end 623 | 624 | manga 625 | end 626 | end 627 | end 628 | -------------------------------------------------------------------------------- /my_anime_list/anime.rb: -------------------------------------------------------------------------------- 1 | module MyAnimeList 2 | 3 | class Anime 4 | attr_accessor :id, :title, :rank, :popularity_rank, :image_url, :episodes, :classification, 5 | :members_score, :members_count, :favorited_count, :synopsis, :start_date, :end_date 6 | attr_accessor :listed_anime_id, :parent_story 7 | attr_reader :type, :status 8 | attr_writer :genres, :tags, :other_titles, :manga_adaptations, :prequels, :sequels, :side_stories, 9 | :character_anime, :spin_offs, :summaries, :alternative_versions 10 | 11 | # These attributes are specific to a user-anime pair, probably should go into another model. 12 | attr_accessor :watched_episodes, :score 13 | attr_reader :watched_status 14 | 15 | # Scrape anime details page on MyAnimeList.net. Very fragile! 16 | def self.scrape_anime(id, cookie_string = nil) 17 | curl = Curl::Easy.new("http://myanimelist.net/anime/#{id}") 18 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 19 | curl.cookies = cookie_string if cookie_string 20 | begin 21 | curl.perform 22 | rescue Exception => e 23 | raise MyAnimeList::NetworkError.new("Network error scraping anime with ID=#{id}. Original exception: #{e.message}.", e) 24 | end 25 | 26 | response = curl.body_str 27 | 28 | # Check for missing anime. 29 | raise MyAnimeList::NotFoundError.new("Anime with ID #{id} doesn't exist.", nil) if response =~ /No series found/i 30 | 31 | anime = parse_anime_response(response) 32 | 33 | anime 34 | rescue MyAnimeList::NotFoundError => e 35 | raise 36 | rescue Exception => e 37 | raise MyAnimeList::UnknownError.new("Error scraping anime with ID=#{id}. Original exception: #{e.message}.", e) 38 | end 39 | 40 | def self.add(id, cookie_string, options) 41 | # This is the same as self.update except that the "status" param is required and the URL is 42 | # http://myanimelist.net/includes/ajax.inc.php?t=61. 43 | 44 | # Default watched_status to 1/watching if not given. 45 | options[:status] = 1 if options[:status] !~ /\S/ 46 | options[:new] = true 47 | 48 | update(id, cookie_string, options) 49 | end 50 | 51 | def self.update(id, cookie_string, options) 52 | 53 | # Convert status to the number values that MyAnimeList uses. 54 | # 1 = Watching, 2 = Completed, 3 = On-hold, 4 = Dropped, 6 = Plan to Watch 55 | status = case options[:status] 56 | when 'Watching', 'watching', 1 57 | 1 58 | when 'Completed', 'completed', 2 59 | 2 60 | when 'On-hold', 'on-hold', 3 61 | 3 62 | when 'Dropped', 'dropped', 4 63 | 4 64 | when 'Plan to Watch', 'plan to watch', 6 65 | 6 66 | else 67 | 1 68 | end 69 | 70 | # There're different URLs to POST to for adding and updating an anime. 71 | url = options[:new] ? 'http://myanimelist.net/includes/ajax.inc.php?t=61' : 'http://myanimelist.net/includes/ajax.inc.php?t=62' 72 | 73 | curl = Curl::Easy.new(url) 74 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 75 | curl.cookies = cookie_string 76 | params = [ 77 | Curl::PostField.content('aid', id), 78 | Curl::PostField.content('status', status) 79 | ] 80 | params << Curl::PostField.content('epsseen', options[:episodes]) if options[:episodes] 81 | params << Curl::PostField.content('score', options[:score]) if options[:score] 82 | 83 | begin 84 | curl.http_post(*params) 85 | rescue Exception => e 86 | raise MyAnimeList::UpdateError.new("Error updating anime with ID=#{id}. Original exception: #{e.message}", e) 87 | end 88 | 89 | if options[:new] 90 | # An add is successful for an HTTP 200 response containing "successful". 91 | # The MyAnimeList site is actually pretty bad and seems to respond with 200 OK for all requests. 92 | # It's also oblivious to IDs for non-existent anime and responds wrongly with a "successful" message. 93 | # It responds with an empty response body for bad adds or if you try to add an anime that's already on the 94 | # anime list. 95 | # Due to these limitations, we will return false if the response body doesn't match "successful" and assume that 96 | # anything else is a failure. 97 | return curl.response_code == 200 && curl.body_str =~ /successful/i 98 | else 99 | # Update is successful for an HTTP 200 response with this string. 100 | curl.response_code == 200 && curl.body_str =~ /successful/i 101 | end 102 | end 103 | 104 | def self.delete(id, cookie_string) 105 | anime = scrape_anime(id, cookie_string) 106 | 107 | curl = Curl::Easy.new("http://myanimelist.net/panel.php?go=edit&id=#{anime.listed_anime_id}") 108 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 109 | curl.cookies = cookie_string 110 | 111 | begin 112 | curl.http_post( 113 | Curl::PostField.content('series_id', anime.listed_anime_id), 114 | Curl::PostField.content('series_title', id), 115 | Curl::PostField.content('submitIt', '3') 116 | ) 117 | rescue Exception => e 118 | raise MyAnimeList::UpdateError.new("Error deleting anime with ID=#{id}. Original exception: #{e.message}", e) 119 | end 120 | 121 | # Deletion is successful for an HTTP 200 response with this string. 122 | if curl.response_code == 200 && curl.body_str =~ /Entry Successfully Deleted/i 123 | anime # Return the original anime if successful. 124 | else 125 | false 126 | end 127 | end 128 | 129 | def self.search(query) 130 | perform_search "/anime.php?c[]=a&c[]=b&c[]=c&c[]=d&c[]=e&c[]=f&c[]=g&q=#{Curl::Easy.new.escape(query)}" 131 | end 132 | 133 | def self.upcoming(options = {}) 134 | page = options[:page] || 1 135 | # TODO: Implement page size in options. Can we even control the page size when calling into MAL? 136 | page_size = 20 137 | limit = (page.to_i - 1) * page_size.to_i 138 | start_date = Date.today 139 | start_date = Date.parse(options[:start_date]) unless options[:start_date].nil? 140 | perform_search "/anime.php?sm=#{start_date.month}&sd=#{start_date.day}&sy=#{start_date.year}&em=0&ed=0&ey=0&o=2&w=&c[]=a&c[]=d&c[]=a&c[]=b&c[]=c&c[]=d&c[]=e&c[]=f&c[]=g&cv=1&show=#{limit}" 141 | end 142 | 143 | def self.just_added(options = {}) 144 | page = options[:page] || 1 145 | # TODO: Implement page size in options. Can we even control the page size when calling into MAL? 146 | page_size = 20 147 | limit = (page.to_i - 1) * page_size.to_i 148 | perform_search "/anime.php?o=9&c[]=a&c[]=b&c[]=c&c[]=d&c[]=e&c[]=f&c[]=g&cv=2&w=1&show=#{limit}" 149 | end 150 | 151 | # Returns top Anime. 152 | # Options: 153 | # * type - Type of anime to return. Possible values: tv, movie, ova, special, bypopularity. Defaults to nothing, which returns 154 | # top anime of any type. 155 | # * page - Page of top anime to return. Defaults to 1. 156 | # * per_page - Number of anime to return per page. Defaults to 30. 157 | def self.top(options = {}) 158 | page = options[:page] || 1 159 | limit = (page.to_i - 1) * 30 160 | type = options[:type].to_s.downcase 161 | 162 | curl = Curl::Easy.new("http://myanimelist.net/topanime.php?type=#{type}&limit=#{limit}") 163 | curl.headers['User-Agent'] = ENV['USER_AGENT'] 164 | begin 165 | curl.perform 166 | rescue Exception => e 167 | raise MyAnimeList::NetworkError.new("Network error getting top anime. Original exception: #{e.message}.", e) 168 | end 169 | 170 | response = curl.body_str 171 | 172 | doc = Nokogiri::HTML(response) 173 | 174 | results = [] 175 | 176 | doc.search('div#content table tr').each do |results_row| 177 | anime_title_node = results_row.at('td a strong') 178 | next unless anime_title_node 179 | anime_url = anime_title_node.parent['href'] 180 | next unless anime_url 181 | anime_url.match %r{http://myanimelist.net/anime/(\d+)/?.*} 182 | 183 | anime = Anime.new 184 | anime.id = $1.to_i 185 | anime.title = anime_title_node.text 186 | 187 | table_cell_nodes = results_row.search('td') 188 | content_cell = table_cell_nodes.at('div.spaceit_pad') 189 | 190 | members_cell = content_cell.at('span.lightLink') 191 | members = members_cell.text.strip.gsub!(/\D/, '').to_i 192 | members_cell.remove 193 | 194 | stats = content_cell.text.strip.split(',') 195 | type = stats[0] 196 | episodes = stats[1].gsub!(/\D/, '') 197 | episodes = if episodes.size > 0 then episodes.to_i else nil end 198 | members_score = stats[2].match(/\d+(\.\d+)?/).to_s.to_f 199 | 200 | anime.type = type 201 | anime.episodes = episodes 202 | anime.members_count = members 203 | anime.members_score = members_score 204 | 205 | if image_node = results_row.at('td a img') 206 | anime.image_url = image_node['src'] 207 | end 208 | 209 | results << anime 210 | end 211 | 212 | results 213 | end 214 | 215 | def watched_status=(value) 216 | @watched_status = case value 217 | when /watching/i, '1', 1 218 | :watching 219 | when /completed/i, '2', 2 220 | :completed 221 | when /on-hold/i, /onhold/i, '3', 3 222 | :"on-hold" 223 | when /dropped/i, '4', 4 224 | :dropped 225 | when /plan to watch/i, /plantowatch/i, '6', 6 226 | :"plan to watch" 227 | else 228 | :watching 229 | end 230 | end 231 | 232 | def type=(value) 233 | @type = case value 234 | when /TV/i, '1', 1 235 | :TV 236 | when /OVA/i, '2', 2 237 | :OVA 238 | when /Movie/i, '3', 3 239 | :Movie 240 | when /Special/i, '4', 4 241 | :Special 242 | when /ONA/i, '5', 5 243 | :ONA 244 | when /Music/i, '6', 6 245 | :Music 246 | else 247 | :TV 248 | end 249 | end 250 | 251 | def status=(value) 252 | @status = case value 253 | when '2', 2, /finished airing/i 254 | :"finished airing" 255 | when '1', 1, /currently airing/i 256 | :"currently airing" 257 | when '3', 3, /not yet aired/i 258 | :"not yet aired" 259 | else 260 | :"finished airing" 261 | end 262 | end 263 | 264 | def other_titles 265 | @other_titles ||= {} 266 | end 267 | 268 | def genres 269 | @genres ||= [] 270 | end 271 | 272 | def tags 273 | @tags ||= [] 274 | end 275 | 276 | def manga_adaptations 277 | @manga_adaptations ||= [] 278 | end 279 | 280 | def prequels 281 | @prequels ||= [] 282 | end 283 | 284 | def sequels 285 | @sequels ||= [] 286 | end 287 | 288 | def side_stories 289 | @side_stories ||= [] 290 | end 291 | 292 | def character_anime 293 | @character_anime ||= [] 294 | end 295 | 296 | def spin_offs 297 | @spin_offs ||= [] 298 | end 299 | 300 | def summaries 301 | @summaries ||= [] 302 | end 303 | 304 | def alternative_versions 305 | @alternative_versions ||= [] 306 | end 307 | 308 | def attributes 309 | { 310 | :id => id, 311 | :title => title, 312 | :other_titles => other_titles, 313 | :synopsis => synopsis, 314 | :type => type, 315 | :rank => rank, 316 | :popularity_rank => popularity_rank, 317 | :image_url => image_url, 318 | :episodes => episodes, 319 | :status => status, 320 | :start_date => start_date, 321 | :end_date => end_date, 322 | :genres => genres, 323 | :tags => tags, 324 | :classification => classification, 325 | :members_score => members_score, 326 | :members_count => members_count, 327 | :favorited_count => favorited_count, 328 | :manga_adaptations => manga_adaptations, 329 | :prequels => prequels, 330 | :sequels => sequels, 331 | :side_stories => side_stories, 332 | :parent_story => parent_story, 333 | :character_anime => character_anime, 334 | :spin_offs => spin_offs, 335 | :summaries => summaries, 336 | :alternative_versions => alternative_versions, 337 | :listed_anime_id => listed_anime_id, 338 | :watched_episodes => watched_episodes, 339 | :score => score, 340 | :watched_status => watched_status 341 | } 342 | end 343 | 344 | def to_json(*args) 345 | attributes.to_json(*args) 346 | end 347 | 348 | def to_xml(options = {}) 349 | xml = Builder::XmlMarkup.new(:indent => 2) 350 | xml.instruct! unless options[:skip_instruct] 351 | xml.anime do |xml| 352 | xml.id id 353 | xml.title title 354 | xml.synopsis synopsis 355 | xml.type type.to_s 356 | xml.rank rank 357 | xml.popularity_rank popularity_rank 358 | xml.image_url image_url 359 | xml.episodes episodes 360 | xml.status status.to_s 361 | xml.start_date start_date 362 | xml.end_date end_date 363 | xml.classification classification 364 | xml.members_score members_score 365 | xml.members_count members_count 366 | xml.favorited_count favorited_count 367 | xml.listed_anime_id listed_anime_id 368 | xml.watched_episodes watched_episodes 369 | xml.score score 370 | xml.watched_status watched_status.to_s 371 | 372 | other_titles[:synonyms].each do |title| 373 | xml.synonym title 374 | end if other_titles[:synonyms] 375 | other_titles[:english].each do |title| 376 | xml.english_title title 377 | end if other_titles[:english] 378 | other_titles[:japanese].each do |title| 379 | xml.japanese_title title 380 | end if other_titles[:japanese] 381 | 382 | genres.each do |genre| 383 | xml.genre genre 384 | end 385 | tags.each do |tag| 386 | xml.tag tag 387 | end 388 | 389 | manga_adaptations.each do |manga| 390 | xml.manga_adaptation do |xml| 391 | xml.manga_id manga[:manga_id] 392 | xml.title manga[:title] 393 | xml.url manga[:url] 394 | end 395 | end 396 | 397 | prequels.each do |prequel| 398 | xml.prequel do |xml| 399 | xml.anime_id prequel[:anime_id] 400 | xml.title prequel[:title] 401 | xml.url prequel[:url] 402 | end 403 | end 404 | 405 | sequels.each do |sequel| 406 | xml.sequel do |xml| 407 | xml.anime_id sequel[:anime_id] 408 | xml.title sequel[:title] 409 | xml.url sequel[:url] 410 | end 411 | end 412 | 413 | side_stories.each do |side_story| 414 | xml.side_story do |xml| 415 | xml.anime_id side_story[:anime_id] 416 | xml.title side_story[:title] 417 | xml.url side_story[:url] 418 | end 419 | end 420 | 421 | xml.parent_story do |xml| 422 | xml.anime_id parent_story[:anime_id] 423 | xml.title parent_story[:title] 424 | xml.url parent_story[:url] 425 | end if parent_story 426 | 427 | character_anime.each do |o| 428 | xml.character_anime do |xml| 429 | xml.anime_id o[:anime_id] 430 | xml.title o[:title] 431 | xml.url o[:url] 432 | end 433 | end 434 | 435 | spin_offs.each do |o| 436 | xml.spin_off do |xml| 437 | xml.anime_id o[:anime_id] 438 | xml.title o[:title] 439 | xml.url o[:url] 440 | end 441 | end 442 | 443 | summaries.each do |o| 444 | xml.summary do |xml| 445 | xml.anime_id o[:anime_id] 446 | xml.title o[:title] 447 | xml.url o[:url] 448 | end 449 | end 450 | 451 | alternative_versions.each do |o| 452 | xml.alternative_version do |xml| 453 | xml.anime_id o[:anime_id] 454 | xml.title o[:title] 455 | xml.url o[:url] 456 | end 457 | end 458 | end 459 | 460 | xml.target! 461 | end 462 | 463 | private 464 | def self.perform_search(url) 465 | begin 466 | response = Net::HTTP.start('myanimelist.net', 80) do |http| 467 | http.get(url, {'User-Agent' => ENV['USER_AGENT']}) 468 | end 469 | 470 | case response 471 | when Net::HTTPRedirection 472 | redirected = true 473 | 474 | # Strip everything after the anime ID - in cases where there is a non-ASCII character in the URL, 475 | # MyAnimeList.net will return a page that says "Access has been restricted for this account". 476 | redirect_url = response['location'].sub(%r{(http://myanimelist.net/anime/\d+)/?.*}, '\1') 477 | 478 | response = Net::HTTP.start('myanimelist.net', 80) do |http| 479 | http.get(redirect_url, {'User-Agent' => ENV['USER_AGENT']}) 480 | end 481 | end 482 | 483 | rescue Exception => e 484 | raise MyAnimeList::UpdateError.new("Error searching anime with query '#{query}'. Original exception: #{e.message}", e) 485 | end 486 | 487 | results = [] 488 | if redirected 489 | # If there's a single redirect, it means there's only 1 match and MAL is redirecting to the anime's details 490 | # page. 491 | 492 | anime = parse_anime_response(response.body) 493 | results << anime 494 | 495 | else 496 | # Otherwise, parse the table of search results. 497 | doc = Nokogiri::HTML(response.body) 498 | results_table = doc.xpath('//div[@id="content"]/div[2]/table') 499 | 500 | results_table.xpath('//tr').each do |results_row| 501 | 502 | anime_title_node = results_row.at('td a strong') 503 | next unless anime_title_node 504 | url = anime_title_node.parent['href'] 505 | next unless url.match %r{/anime/(\d+)/?.*} 506 | 507 | anime = Anime.new 508 | anime.id = $1.to_i 509 | anime.title = anime_title_node.text 510 | if image_node = results_row.at('td a img') 511 | anime.image_url = image_node['src'] 512 | end 513 | 514 | table_cell_nodes = results_row.search('td') 515 | 516 | anime.episodes = table_cell_nodes[3].text.to_i 517 | anime.members_score = table_cell_nodes[4].text.to_f 518 | synopsis_node = results_row.at('div.spaceit') 519 | if synopsis_node 520 | synopsis_node.search('a').remove 521 | anime.synopsis = synopsis_node.text.strip 522 | end 523 | anime.type = table_cell_nodes[2].text 524 | anime.start_date = parse_start_date(table_cell_nodes[5].text) 525 | anime.end_date = parse_end_date(table_cell_nodes[6].text) 526 | anime.classification = table_cell_nodes[8].text if table_cell_nodes[8] 527 | 528 | results << anime 529 | end 530 | end 531 | 532 | results 533 | end 534 | 535 | def self.parse_anime_response(response) 536 | doc = Nokogiri::HTML(response) 537 | 538 | anime = Anime.new 539 | 540 | # Anime ID. 541 | # Example: 542 | # 543 | anime_id_input = doc.at('input[@name="aid"]') 544 | if anime_id_input 545 | anime.id = anime_id_input['value'].to_i 546 | else 547 | details_link = doc.at('//a[text()="Details"]') 548 | anime.id = details_link['href'][%r{http://myanimelist.net/anime/(\d+)/.*?}, 1].to_i 549 | end 550 | 551 | # Title and rank. 552 | # Example: 553 | #

Ranked #96
Lucky ☆ Star

554 | anime.title = doc.at(:h1).children.find { |o| o.text? }.to_s 555 | anime.rank = doc.at('h1 > div').text.gsub(/\D/, '').to_i 556 | 557 | if image_node = doc.at('div#content tr td div img') 558 | anime.image_url = image_node['src'] 559 | end 560 | 561 | # - 562 | # Extract from sections on the left column: Alternative Titles, Information, Statistics, Popular Tags. 563 | # - 564 | 565 | # Alternative Titles section. 566 | # Example: 567 | #

Alternative Titles

568 | #
English: Lucky Star/div> 569 | #
Synonyms: Lucky Star, Raki ☆ Suta
570 | #
Japanese: らき すた
571 | left_column_nodeset = doc.xpath('//div[@id="content"]/table/tr/td[@class="borderClass"]') 572 | 573 | if (node = left_column_nodeset.at('//span[text()="English:"]')) && node.next 574 | anime.other_titles[:english] = node.next.text.strip.split(/,\s?/) 575 | end 576 | if (node = left_column_nodeset.at('//span[text()="Synonyms:"]')) && node.next 577 | anime.other_titles[:synonyms] = node.next.text.strip.split(/,\s?/) 578 | end 579 | if (node = left_column_nodeset.at('//span[text()="Japanese:"]')) && node.next 580 | anime.other_titles[:japanese] = node.next.text.strip.split(/,\s?/) 581 | end 582 | 583 | 584 | # Information section. 585 | # Example: 586 | #

Information

587 | #
Type: TV
588 | #
Episodes: 24
589 | #
Status: Finished Airing
590 | #
Aired: Apr 9, 2007 to Sep 17, 2007
591 | #
592 | # Producers: 593 | # Kyoto Animation, 594 | # Lantis, 595 | # Kadokawa Pictures USAL, 596 | # Bang Zoom! Entertainment 597 | #
598 | #
599 | # Genres: 600 | # Comedy, 601 | # Parody, 602 | # School, 603 | # Slice of Life 604 | #
605 | #
Duration: 24 min. per episode
606 | #
Rating: PG-13 - Teens 13 or older
607 | if (node = left_column_nodeset.at('//span[text()="Type:"]')) && node.next 608 | anime.type = node.next.text.strip 609 | end 610 | if (node = left_column_nodeset.at('//span[text()="Episodes:"]')) && node.next 611 | anime.episodes = node.next.text.strip.gsub(',', '').to_i 612 | anime.episodes = nil if anime.episodes == 0 613 | end 614 | if (node = left_column_nodeset.at('//span[text()="Status:"]')) && node.next 615 | anime.status = node.next.text.strip 616 | end 617 | if (node = left_column_nodeset.at('//span[text()="Aired:"]')) && node.next 618 | airdates_text = node.next.text.strip 619 | anime.start_date = parse_start_date(airdates_text) 620 | anime.end_date = parse_end_date(airdates_text) 621 | end 622 | if node = left_column_nodeset.at('//span[text()="Genres:"]') 623 | node.parent.search('a').each do |a| 624 | anime.genres << a.text.strip 625 | end 626 | end 627 | if (node = left_column_nodeset.at('//span[text()="Rating:"]')) && node.next 628 | anime.classification = node.next.text.strip 629 | end 630 | 631 | # Statistics 632 | # Example: 633 | #

Statistics

634 | #
635 | # Score: 8.411 636 | # (scored by 22601 users) 637 | #
638 | #
Ranked: #962
639 | #
Popularity: #15
640 | #
Members: 36,961
641 | #
Favorites: 2,874
642 | if (node = left_column_nodeset.at('//span[text()="Score:"]')) && node.next 643 | anime.members_score = node.next.text.strip.to_f 644 | end 645 | if (node = left_column_nodeset.at('//span[text()="Popularity:"]')) && node.next 646 | anime.popularity_rank = node.next.text.strip.sub('#', '').gsub(',', '').to_i 647 | end 648 | if (node = left_column_nodeset.at('//span[text()="Members:"]')) && node.next 649 | anime.members_count = node.next.text.strip.gsub(',', '').to_i 650 | end 651 | if (node = left_column_nodeset.at('//span[text()="Favorites:"]')) && node.next 652 | anime.favorited_count = node.next.text.strip.gsub(',', '').to_i 653 | end 654 | 655 | # Popular Tags 656 | # Example: 657 | #

Popular Tags

658 | # 659 | # comedy 660 | # parody 661 | # school 662 | # slice of life 663 | # 664 | if (node = left_column_nodeset.at('//span[preceding-sibling::h2[text()="Popular Tags"]]')) 665 | node.search('a').each do |a| 666 | anime.tags << a.text 667 | end 668 | end 669 | 670 | 671 | # - 672 | # Extract from sections on the right column: Synopsis, Related Anime, Characters & Voice Actors, Reviews 673 | # Recommendations. 674 | # - 675 | right_column_nodeset = doc.xpath('//div[@id="content"]/table/tr/td/div/table') 676 | 677 | # Synopsis 678 | # Example: 679 | # 680 | #

Synopsis

681 | # Having fun in school, doing homework together, cooking and eating, playing videogames, watching anime. All those little things make up the daily life of the anime- and chocolate-loving Izumi Konata and her friends. Sometimes relaxing but more than often simply funny!
682 | # -From AniDB 683 | synopsis_h2 = right_column_nodeset.at('//h2[text()="Synopsis"]') 684 | if synopsis_h2 685 | node = synopsis_h2.next 686 | while node 687 | if anime.synopsis 688 | anime.synopsis << node.to_s 689 | else 690 | anime.synopsis = node.to_s 691 | end 692 | 693 | node = node.next 694 | end 695 | end 696 | 697 | # Related Anime 698 | # Example: 699 | # 700 | #
701 | #

Related Anime

702 | # Adaptation: Higurashi no Naku Koro ni Kai Minagoroshi-hen, 703 | # Higurashi no Naku Koro ni Matsuribayashi-hen
704 | # Prequel: Higurashi no Naku Koro ni
705 | # Sequel: Higurashi no Naku Koro ni Rei
706 | # Side story: Higurashi no Naku Koro ni Kai DVD Specials
707 | related_anime_h2 = right_column_nodeset.at('//h2[text()="Related Anime"]') 708 | if related_anime_h2 709 | 710 | # Get all text between

Related Anime

and the next

tag. 711 | match_data = related_anime_h2.parent.to_s.match(%r{

Related Anime

(.+?)

}m) 712 | 713 | if match_data 714 | related_anime_text = match_data[1] 715 | 716 | if related_anime_text.match %r{Adaptation: ?((.+?)}) do |url, manga_id, title| 718 | anime.manga_adaptations << { 719 | :manga_id => manga_id, 720 | :title => title, 721 | :url => url 722 | } 723 | end 724 | end 725 | 726 | if related_anime_text.match %r{Prequel: ?((.+?)}) do |url, anime_id, title| 728 | anime.prequels << { 729 | :anime_id => anime_id, 730 | :title => title, 731 | :url => url 732 | } 733 | end 734 | end 735 | 736 | if related_anime_text.match %r{Sequel: ?((.+?)}) do |url, anime_id, title| 738 | anime.sequels << { 739 | :anime_id => anime_id, 740 | :title => title, 741 | :url => url 742 | } 743 | end 744 | end 745 | 746 | if related_anime_text.match %r{Side story: ?((.+?)}) do |url, anime_id, title| 748 | anime.side_stories << { 749 | :anime_id => anime_id, 750 | :title => title, 751 | :url => url 752 | } 753 | end 754 | end 755 | 756 | if related_anime_text.match %r{Parent story: ?((.+?)}) do |url, anime_id, title| 758 | anime.parent_story = { 759 | :anime_id => anime_id, 760 | :title => title, 761 | :url => url 762 | } 763 | end 764 | end 765 | 766 | if related_anime_text.match %r{Character: ?((.+?)}) do |url, anime_id, title| 768 | anime.character_anime << { 769 | :anime_id => anime_id, 770 | :title => title, 771 | :url => url 772 | } 773 | end 774 | end 775 | 776 | if related_anime_text.match %r{Spin-off: ?((.+?)}) do |url, anime_id, title| 778 | anime.spin_offs << { 779 | :anime_id => anime_id, 780 | :title => title, 781 | :url => url 782 | } 783 | end 784 | end 785 | 786 | if related_anime_text.match %r{Summary: ?((.+?)}) do |url, anime_id, title| 788 | anime.summaries << { 789 | :anime_id => anime_id, 790 | :title => title, 791 | :url => url 792 | } 793 | end 794 | end 795 | 796 | if related_anime_text.match %r{Alternative versions?: ?((.+?)}) do |url, anime_id, title| 798 | anime.alternative_versions << { 799 | :anime_id => anime_id, 800 | :title => title, 801 | :url => url 802 | } 803 | end 804 | end 805 | end 806 | 807 | end 808 | 809 | #

My Info

810 | # 811 | #
812 | # 813 | # 814 | # 815 | # 816 | # 817 | # 818 | # 819 | # 820 | # 821 | # 822 | # 823 | # 824 | # 825 | # 826 | # 827 | # 828 | # 829 | # 830 | # 831 | # 832 | #
Status:
Eps Seen: / 26
Your Score:
  Edit Details
833 | watched_status_select_node = doc.at('select#myinfo_status') 834 | if watched_status_select_node && (selected_option = watched_status_select_node.at('option[selected="selected"]')) 835 | anime.watched_status = selected_option['value'] 836 | end 837 | episodes_input_node = doc.at('input#myinfo_watchedeps') 838 | if episodes_input_node 839 | anime.watched_episodes = episodes_input_node['value'].to_i 840 | end 841 | score_select_node = doc.at('select#myinfo_score') 842 | if score_select_node && (selected_option = score_select_node.at('option[selected="selected"]')) 843 | anime.score = selected_option['value'].to_i 844 | end 845 | listed_anime_id_node = doc.at('//a[text()="Edit Details"]') 846 | if listed_anime_id_node 847 | anime.listed_anime_id = listed_anime_id_node['href'].match('id=(\d+)')[1].to_i 848 | end 849 | 850 | anime 851 | end 852 | 853 | def self.parse_start_date(text) 854 | text = text.strip 855 | 856 | case text 857 | when /^\d{4}$/ 858 | return text.strip 859 | when /^(\d{4}) to \?/ 860 | return $1 861 | when /^\d{2}-\d{2}-\d{2}$/ 862 | return Date.strptime(text, '%m-%d-%y') 863 | else 864 | date_string = text.split(/\s+to\s+/).first 865 | return nil if !date_string 866 | 867 | if date_string =~ /^\d{4}$/ 868 | return date_string.strip 869 | else 870 | Chronic.parse(date_string) 871 | end 872 | end 873 | end 874 | 875 | def self.parse_end_date(text) 876 | text = text.strip 877 | 878 | case text 879 | when /^\d{4}$/ 880 | return text.strip 881 | when /^\? to (\d{4})/ 882 | return $1 883 | when /^\d{2}-\d{2}-\d{2}$/ 884 | return Date.strptime(text, '%m-%d-%y') 885 | else 886 | date_string = text.split(/\s+to\s+/).last 887 | return nil if !date_string 888 | 889 | if date_string =~ /^\d{4}$/ 890 | return date_string.strip 891 | else 892 | Chronic.parse(date_string) 893 | end 894 | end 895 | end 896 | 897 | end # END class Anime 898 | end 899 | -------------------------------------------------------------------------------- /samples/mangalist-chuyeow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | chuyeow's Manga List - MyAnimeList.net 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 270 | 271 | 272 | 273 |
274 | 275 |
276 |
277 | 278 | 279 |
Currently ReadingCompletedOn HoldDroppedPlan to ReadShow All
280 |

281 | 282 | 283 | 290 | 291 |
284 | 285 |
286 | 287 | Currently Reading 288 |
289 |
292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 |
#Manga TitleScoreChaptersVolumes
301 | 302 | 303 | 304 | 305 | 306 | 315 | 316 |
1 307 |
308 | 309 | Add - More 310 | 311 |
312 | 313 | Berserk 314 | Publishing
-1/--/-
317 | 318 | 319 | 346 | 347 | 348 | 349 | 350 | 359 | 360 |
2 351 |
352 | 353 | Add - More 354 | 355 |
356 | 357 | Bloody Monday 358 |
-29/96-/11
361 | 362 | 363 | 390 | 391 | 392 | 393 | 394 | 403 | 404 |
3 395 |
396 | 397 | Add - More 398 | 399 |
400 | 401 | Buddha 402 |
--/661/14
405 | 406 | 407 | 434 | 435 | 436 | 437 | 438 | 447 | 448 |
4 439 |
440 | 441 | Add - More 442 | 443 |
444 | 445 | Claymore 446 | Publishing
896/--/-
449 | 450 | 451 | 478 | 479 | 480 | 481 | 482 | 491 | 492 |
5 483 |
484 | 485 | Add - More 486 | 487 |
488 | 489 | GANTZ 490 | Publishing
10309/--/-
493 | 494 | 495 | 522 | 523 | 524 | 525 | 526 | 535 | 536 |
6 527 |
528 | 529 | Add - More 530 | 531 |
532 | 533 | Kodomo no Jikan 534 | Publishing
--/--/-
537 | 538 | 539 | 566 | 567 | 568 | 569 | 570 | 579 | 580 |
7 571 |
572 | 573 | Add - More 574 | 575 |
576 | 577 | Lucky Star Pocket Travelers 578 |
-1/--/1
581 | 582 | 583 | 610 | 611 | 612 | 613 | 614 | 623 | 624 |
8 615 |
616 | 617 | Add - More 618 | 619 |
620 | 621 | Yotsuba&! 622 | Publishing
1062/-5/-
625 | 626 | 627 | 654 | 655 | 656 | 664 | 665 |
657 | Chapters: 498, 658 | Volumes: 6, 659 | Days: 2.77 660 | Mean Score: 9.3, 661 | Score Dev.: 0.66 662 | 663 |
666 | 667 |

668 | 669 | 670 | 677 | 678 |
671 | 672 |
673 | 674 | Completed 675 |
676 |
679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 |
#Manga TitleScoreChaptersVolumes
688 | 689 | 690 | 691 | 692 | 693 | 702 | 703 |
1 694 |
695 | 696 | Add - More 697 | 698 |
699 | 700 | Azumanga Daioh 701 |
8394
704 | 705 | 706 | 733 | 734 | 735 | 736 | 737 | 746 | 747 |
2 738 |
739 | 740 | Add - More 741 | 742 |
743 | 744 | Try! Try! Try! 745 |
811
748 | 749 | 750 | 777 | 778 | 779 | 780 | 781 | 790 | 791 |
3 782 |
783 | 784 | Add - More 785 | 786 |
787 | 788 | Try! Try! Try! Webcomics 789 |
72-
792 | 793 | 794 | 821 | 822 | 823 | 831 | 832 |
824 | Chapters: 42, 825 | Volumes: 5, 826 | Days: 0.23 827 | Mean Score: 7.7, 828 | Score Dev.: -0.13 829 | 830 |
833 | 834 |

835 | 836 | 837 | 844 | 845 |
838 | 839 |
840 | 841 | On-Hold 842 |
843 |
846 | 847 | 848 | 849 | 850 | 851 | 852 | 853 | 854 |
#Manga TitleScoreChaptersVolumes
855 | 856 |

857 | 858 | 859 | 866 | 867 |
860 | 861 |
862 | 863 | Dropped 864 |
865 |
868 | 869 | 870 | 871 | 872 | 873 | 874 | 875 | 876 |
#Manga TitleScoreChaptersVolumes
877 | 878 |

879 | 880 | 881 | 888 | 889 |
882 | 883 |
884 | 885 | Plan to Read 886 |
887 |
890 | 891 | 892 | 893 | 894 | 895 | 896 | 897 | 898 | 899 | 900 |
#Manga TitleScoreChaptersVolumesPriority
901 | 902 | 903 | 904 | 905 | 906 | 915 | 916 |
1 907 |
908 | 909 | Add - More 910 | 911 |
912 | 913 | Azumanga Daioh: Supplementary Lessons 914 | Publishing
--/3-/-Low
917 | 918 | 919 | 946 | 947 | 948 | 949 | 950 | 959 | 960 |
2 951 |
952 | 953 | Add - More 954 | 955 |
956 | 957 | Saiyuki 958 |
--/55-/9Low
961 | 962 | 963 | 990 | 991 | 992 | 993 | 994 | 1003 | 1004 |
3 995 |
996 | 997 | Add - More 998 | 999 |
1000 | 1001 | Scrapped Princess 1002 |
--/70-/13Low
1005 | 1006 | 1007 | 1034 | 1035 | 1036 | 1044 | 1045 |
1037 | Chapters: 0, 1038 | Volumes: 0, 1039 | Days: 1040 | Mean Score: 0.0, 1041 | Score Dev.: 0.00 1042 | 1043 |
1046 | 1047 |

1048 | 1049 |
1050 |
1051 |
Grand Totals
1052 | Manga: 11, 1053 | Novel: 1, One Shot: 2, Manwha: 0, Chapters: 540 Volumes: 11, Days: 3.00, Mean: 8.5 1054 |
1055 | chuyeow's scores average about 0.26 points higher than the average scores posted by other members. 1056 |
1057 | 1058 | 1059 | 1061 |
1065 | 1070 | 1071 | 1072 | 1077 | 1078 | 1081 | 1082 | 1083 | 1084 | 1087 | 1088 | 1099 | 1102 | 1103 | 1104 | 1105 | -------------------------------------------------------------------------------- /public/docs/index.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | MyAnimeList Unofficial API Documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |

MyAnimeList Unofficial API

21 | 22 | 26 | 27 |

MyAnimeList Unofficial API Documentation

28 | 29 |

This unofficial API is a supplement to the official API. The main reasons I'm working on this are:

30 |
    31 |
  • development of the official API has been rather slow,
  • 32 |
  • to focus on the more commonly used features on MyAnimeList.net, and
  • 33 |
  • to provide a developer-friendly API following HTTP REST principles.
  • 34 |
35 | 36 |

Please report any bugs to @sliceoflifer on Twitter.

37 | 38 | 39 |

Table of Contents

40 | 41 | 94 | 95 | 96 |

Summary

97 | 98 |

The MyAnimeList Unofficial API allows developers to interact with the MyAnimeList site programmatically via HTTP requests.

99 | 100 |
101 |

Read methods

102 | 103 |

All read methods are HTTP GET requests. Some requests require authentication. The response data format is JSON by default (see data formats for XML responses).

104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |
RequestDescriptionExample
/profile/usernameFetch a user's MAL profilehttp://mal-api.com/profile/xinil
/animelist/usernameFetch a user's anime listhttp://mal-api.com/animelist/xinil
/anime/anime_idFetch an anime's detailshttp://mal-api.com/anime/1887
/history/usernameFetch a user's historyhttp://mal-api.com/history/xinil
/anime/searchSearch for anime matching a query.http://mal-api.com/anime/search?q=haruhi
/anime/topFetch the top anime.http://mal-api.com/anime/top
/anime/popularFetch the popular anime.http://mal-api.com/anime/popular
/anime/upcomingFetch the upcoming anime.http://mal-api.com/anime/upcoming
/anime/just_addedFetch the just added anime.http://mal-api.com/anime/just_added
/mangalist/usernameFetch a user's manga listhttp://mal-api.com/mangalist/xinil
/manga/manga_idFetch a manga's detailshttp://mal-api.com/manga/104
/manga/searchSearch for manga matching a query.http://mal-api.com/manga/search?q=berserk
187 | 188 |

Check out the full documentation for these read methods below.

189 |
190 | 191 |
192 |

Write methods

193 | 194 |

All write methods are HTTP POST, PUT or DELETE requests. Read the section on HTTP verb emulation if you have difficulties performing PUT or DELETE requests. Authentication is required.

195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 |
RequestMethodDescriptionArguments
/animelist/animePOSTAdd an anime to user's anime listanime_id, status?, episodes?, score?
/animelist/anime/anime_idPUTUpdate an anime already on user's anime liststatus?, episodes?, score?
/animelist/anime/anime_idDELETEDelete an anime from user's anime list-
/mangalist/mangaPOSTAdd a manga to user's manga listmanga_id, status?, chapters?, score?
/mangalist/manga/manga_idPUTUpdate a manga already on user's manga liststatus?, chapters?, score?
/mangalist/manga/manga_idDELETEDelete a manga from user's manga list-
247 | 248 |

Check out the full documentation for these write methods below.

249 |
250 | 251 | 252 |

Core Concepts

253 | 254 |
255 |

Making API requests

256 | 257 |

All API methods are HTTP requests and support the full set of HTTP verbs - GET, POST, HEAD, PUT, DELETE. If your client doesn't support the full set of HTTP verbs and is capable of only GET and POST requests, HTTP verb emulation is also supported.

258 | 259 |

HTTP verb emulation

260 | 261 |

If you're using a client that only supports GET and POST requests, or making API requests from a web browser via AJAX, you'll need to fake PUT, DELETE and HEAD requests. Simply send along a _method parameter as part of your request body with the HTTP verb (case-insensitive).

262 | 263 |

For example, to emulate a HTTP DELETE to /animelist/anime/1:

264 | 265 |
266 |

POST /animelist/anime/1

267 |
Request body:
_method=delete
268 |
269 |
270 | 271 |
272 |

Response types

273 | 274 |

Anime list

275 | 276 |

An anime list is a user's personal list of anime. It also includes statistics on a user based on her list, such as her average score given to anime and number of days spent watching anime.

277 | 278 |

Format:

279 |
    280 |
  • 281 | anime - A list of anime. 282 |
  • 283 |
  • 284 | statistics - A hash/dictionary containing statistics. 285 |
      286 |
    • days - Number of days spent watching anime.
    • 287 |
    288 |
  • 289 |
290 | 291 |

Anime

292 | 293 |

Note that not all these properties are available in /animelist requests and will be indicated below.

294 | 295 |
    296 |
  • id - The anime ID.
  • 297 |
  • title - The anime title.
  • 298 |
  • 299 | other_titles - A hash/dictionary containing other titles this anime has. 300 |
      301 |
    • synonyms - A list of synonym(s) of the anime's title.
    • 302 |
    • english - A list of English title(s). Not available in /animelist requests.
    • 303 |
    • japanese - A list of Japanese title(s). Not available in /animelist requests.
    • 304 |
    305 |
  • 306 |
  • rank - Global rank of this anime. Not available in /animelist requests.
  • 307 |
  • popularity_rank - Rank of this anime based on its popularity, i.e. number of users that have added this anime. Not available in /animelist requests.
  • 308 |
  • image_url - URL to an image for this anime.
  • 309 |
  • type - Type of anime. Possible values: TV, Movie, OVA, ONA, Special, Music.
  • 310 |
  • episodes - Number of episodes. null is returned if the number of episodes is unknown.
  • 311 |
  • status - Airing status of this anime. Possible values: finished airing, currently airing, not yet aired.
  • 312 |
  • start_date - Beginning date from which this anime was/will be aired.
  • 313 |
  • end_date - Ending air date of this anime.
  • 314 |
  • classification - Classification or rating of this anime. This is a freeform text field, with possible values like: R - 17+ (violence & profanity), PG - Children. Not available in /animelist requests.
  • 315 |
  • members_score - Weighted score members of MyAnimeList have given to this anime. Not available in /animelist requests.
  • 316 |
  • members_count - Number of members who have this anime on their list. Not available in /animelist requests.
  • 317 |
  • favorited_count - Number of members who have this anime marked as one of their favorites. Not available in /animelist requests.
  • 318 |
  • synopsis - Text describing the anime. Not available in /animelist requests.
  • 319 |
  • genres - A list of genres for this anime, e.g. ["Action", "Comedy", "Shounen"]. Not available in /animelist requests.
  • 320 |
  • tags - A list of popular tags for this anime, e.g. ["supernatural", "comedy"]. Not available in /animelist requests.
  • 321 |
  • 322 | manga_adaptations - A list of manga adaptations of this anime (or conversely, manga from which this anime is adapted). Not available in /animelist requests. 323 |
      324 |
    • manga_id - ID of the manga.
    • 325 |
    • title - Title of the manga.
    • 326 |
    • url - URL to the manga on the MyAnimeList website.
    • 327 |
    328 |
  • 329 |
  • 330 | prequels - A list of anime prequels of this anime. Not available in /animelist requests. 331 |
      332 |
    • anime_id - ID of the anime.
    • 333 |
    • title - Title of the anime.
    • 334 |
    • url - URL to the anime on the MyAnimeList website.
    • 335 |
    336 |
  • 337 |
  • 338 | sequels - A list of anime sequels of this anime. Not available in /animelist requests. 339 |
      340 |
    • anime_id - ID of the anime.
    • 341 |
    • title - Title of the anime.
    • 342 |
    • url - URL to the anime on the MyAnimeList website.
    • 343 |
    344 |
  • 345 |
  • 346 | side_stories - A list of anime side stories of this anime. Not available in /animelist requests. 347 |
      348 |
    • anime_id - ID of the anime.
    • 349 |
    • title - Title of the anime.
    • 350 |
    • url - URL to the anime on the MyAnimeList website.
    • 351 |
    352 |
  • 353 |
  • 354 | parent_story - Parent story of this anime. Not available in /animelist requests. 355 |
      356 |
    • anime_id - ID of the anime.
    • 357 |
    • title - Title of the anime.
    • 358 |
    • url - URL to the anime on the MyAnimeList website.
    • 359 |
    360 |
  • 361 |
  • 362 | character_anime - A list of character anime of this anime. Not available in /animelist requests. 363 |
      364 |
    • anime_id - ID of the anime.
    • 365 |
    • title - Title of the anime.
    • 366 |
    • url - URL to the anime on the MyAnimeList website.
    • 367 |
    368 |
  • 369 |
  • 370 | spin_offs - A list of spin-offs of this anime. Not available in /animelist requests. 371 |
      372 |
    • anime_id - ID of the anime.
    • 373 |
    • title - Title of the anime.
    • 374 |
    • url - URL to the anime on the MyAnimeList website.
    • 375 |
    376 |
  • 377 |
  • 378 | summaries - A list of summaries of this anime. Not available in /animelist requests. 379 |
      380 |
    • anime_id - ID of the anime.
    • 381 |
    • title - Title of the anime.
    • 382 |
    • url - URL to the anime on the MyAnimeList website.
    • 383 |
    384 |
  • 385 |
  • 386 | alternative_versions - A list of alternative versions of this anime. Not available in /animelist requests. 387 |
      388 |
    • anime_id - ID of the anime.
    • 389 |
    • title - Title of the anime.
    • 390 |
    • url - URL to the anime on the MyAnimeList website.
    • 391 |
    392 |
  • 393 |
394 | 395 |

These additional properties are available for authenticated users who have the requested anime on their anime list:

396 |
    397 |
  • watched_status - User's watched status of the anime. This is a string that is one of: watching, completed, on-hold, dropped, plan to watch.
  • 398 |
  • watched_episodes - Number of episodes already watched by the user.
  • 399 |
  • score - User's score for the anime, from 1 to 10.
  • 400 |
401 | 402 | 403 |

Manga list

404 | 405 |

An anime list is a user's personal list of manga.

406 | 407 |

Format:

408 |
    409 |
  • 410 | manga - A list of manga. 411 |
  • 412 |
413 | 414 | 415 |

Manga

416 | 417 |
    418 |
  • id - The manga ID.
  • 419 |
  • title - The manga title.
  • 420 |
  • 421 | other_titles - A hash/dictionary containing other titles this manga has. 422 |
      423 |
    • synonyms - A list of synonym(s) of the manga's title.
    • 424 |
    • english - A list of English title(s).
    • 425 |
    • japanese - A list of Japanese title(s).
    • 426 |
    427 |
  • 428 |
  • rank - Global rank of this manga.
  • 429 |
  • popularity_rank - Rank of this manga based on its popularity, i.e. number of users that have added this manga.
  • 430 |
  • image_url - URL to an image for this manga.
  • 431 |
  • type - Type of manga. Possible values: Manga, Novel, One Shot, Doujin, Manwha, Manhua, OEL ("OEL manga" refers to "Original English-Language manga").
  • 432 |
  • chapters - Number of chapters. null is returned if the number of chapters is unknown.
  • 433 |
  • volumes - Number of volumes. null is returned if the number of volumes is unknown.
  • 434 |
  • status - Publishing status of this anime. Possible values: finished, publishing, not yet published.
  • 435 |
  • members_score - Weighted score members of MyAnimeList have given to this manga.
  • 436 |
  • members_count - Number of members who have this manga on their list.
  • 437 |
  • favorited_count - Number of members who have this manga marked as one of their favorites.
  • 438 |
  • synopsis - Text describing the manga.
  • 439 |
  • genres - A list of genres for this manga, e.g. ["Comedy", "Slice of Life"].
  • 440 |
  • tags - A list of popular tags for this manga, e.g. ["comedy", "slice of life"].
  • 441 | 442 |
  • 443 | anime_adaptations - A list of anime adaptations of this anime (or conversely, anime from which this manga is adapted). 444 |
      445 |
    • anime_id - ID of the anime.
    • 446 |
    • title - Title of the anime.
    • 447 |
    • url - URL to the anime on the MyAnimeList website.
    • 448 |
    449 |
  • 450 |
  • 451 | related_manga - A list of related manga. 452 |
      453 |
    • manga_id - ID of the manga.
    • 454 |
    • title - Title of the manga.
    • 455 |
    • url - URL to the manga on the MyAnimeList website.
    • 456 |
    457 |
  • 458 |
  • 459 | alternative_versions - A list of alternative versions of this manga. 460 |
      461 |
    • manga_id - ID of the manga.
    • 462 |
    • title - Title of the manga.
    • 463 |
    • url - URL to the manga on the MyAnimeList website.
    • 464 |
    465 |
  • 466 |
467 | 468 |

These additional properties are available for authenticated users who have the requested manga on their manga list:

469 |
    470 |
  • read_status - User's read status of the anime. This is a string that is one of: reading, completed, on-hold, dropped, plan to read.
  • 471 |
  • chapters_read - Number of chapters already read by the user.
  • 472 |
  • volumes_read - Number of volumes already read by the user.
  • 473 |
  • score - User's score for the manga, from 1 to 10.
  • 474 |
475 | 476 | 477 |

User

478 | 479 |

A representation of a MyAnimeList user.

480 | 481 |

Format:

482 |
    483 |
  • details - user's general (not anime/manga-specific) details.
  • 484 |
  • anime_stats - user's anime statistics.
  • 485 |
  • manga_stats - user's manga statistics.
  • 486 |
487 | 488 |
489 | 490 | 491 |
492 |

Data formats

493 | 494 |

The MyAnimeList Unofficial API supports both JSON and XML output formats. Specify the output format using the format parameter (JSON is the default):

495 | 496 |
497 |

498 | http://mal-api.com/anime/1887?format=json
499 | http://mal-api.com/anime/1887?format=xml 500 |

501 |
502 |
503 | 504 | 505 |
506 |

Authentication

507 | 508 |

Authentication is required for certain API requests. The MyAnimeList Unofficial API only supports HTTP Basic Authentication because of the limitations of the MyAnimeList site.

509 | 510 |

Is my password secure?

511 | 512 |

The MyAnimeList Unofficial API takes your username and password and authenticates it with MyAnimeList directly - no passwords are saved or logged.

513 | 514 |

However, it is clearly inferior to secure solutions like OAuth and requires you to trust an unofficial API with your MyAnimeList password. It bothers us too. You can help us bug the MyAnimeList developers at the MAL API club.

515 |
516 | 517 | 518 |
519 |

Errors

520 | 521 |

The API returns appropriate HTTP status codes. In addition, the API also includes error information in the response body. Error responses have a single error property that is a string describing the error, e.g.:

522 | 523 |
524 |

{"error":"forbidden"}

525 |
526 | 527 |

An additional details property is sometimes available for further diagnostics.

528 | 529 |

Possible error codes are:

530 |
    531 |
  • not-found - Unrecognized/missing anime/manga ID.
  • 532 |
  • ARG-required - The HTTP argument ARG is required for this request, e.g., anime_id-required.
  • 533 |
  • network-error - A network error has occurred connecting to MyAnimeList.
  • 534 |
  • unauthorized - The request requires authentication.
  • 535 |
  • forbidden - User does not have access.
  • 536 |
537 |
538 | 539 | 540 |

Reading data from MyAnimeList

541 | 542 |
543 |

/profile - Read a user's profile

544 | 545 |

Fetch the MAL profile for the username:

546 | 547 |
548 |

http://mal-api.com/profile/username

549 | 550 |

Example: http://mal-api.com/profile/xinil

551 |
552 | 553 |

The response is an user.

554 |
555 | 556 | 557 |
558 |

/animelist - Read an anime list

559 | 560 |

Fetch an anime list with the given username:

561 | 562 |
563 |

http://mal-api.com/animelist/username

564 | 565 |

Example: http://mal-api.com/animelist/xinil

566 |
567 | 568 |

The response is an anime list.

569 |
570 | 571 | 572 |
573 |

/anime - Read an anime's details

574 | 575 |

Fetch an anime with the given anime id:

576 | 577 |
578 |

http://mal-api.com/anime/anime_id

579 | 580 |

Example: http://mal-api.com/anime/1887

581 |
582 | 583 |

The response is an anime.

584 | 585 |

The following optional parameters are supported:

586 |
    587 |
  • mine=1 - If specified, include the authenticated user's anime details (e.g. user's score, watched status, watched episodes). Requires authentication. See anime response type.
  • 588 |
589 |
590 | 591 | 592 |
593 |

/history - Read a user's history

594 | 595 |

NOT YET IMPLEMENTED

596 | 597 |

Fetch the history of a user with the given username.

598 | 599 |
600 |

http://mal-api.com/history/username

601 | 602 |

Example: http://mal-api.com/history/xinil

603 |
604 | 605 |

The response is a list of anime and/or manga IDs together with the episode/chapter watched or read, and the time it was watched or read.

606 | 607 |

TODO Give an example of the response format.

608 | 609 |

Anime-only and manga-only history

610 |

To get only the user's anime history, use the http://mal-api.com/history/username/anime

611 | 612 |

To get only the user's manga history, use the http://mal-api.com/history/username/manga

613 |
614 | 615 | 616 |
617 |

/anime/search - Search anime

618 | 619 |

Search for anime matching a query.

620 | 621 |
622 |

http://mal-api.com/anime/search?q=query

623 | 624 |

Example: http://mal-api.com/anime/search?q=haruhi

625 |
626 | 627 |

Only 1 required parameter is supported:

628 |
    629 |
  • q - The query (URL encoded).
  • 630 |
631 | 632 |

The response is a list of anime. Only the following anime properties are available: id, title, episodes, type, synopsis, image_url, members_score, start_date, end_date, classification

633 |
634 | 635 | 636 |
637 |

/anime/top - Read the top anime

638 | 639 |

Fetch the top ranking anime.

640 | 641 |
642 |

http://mal-api.com/anime/top

643 | 644 |

Example: http://mal-api.com/anime/top?page=1&per_page=30

645 |
646 | 647 |

The following optional parameters are supported:

648 |
    649 |
  • type - Type of anime to return. Possible values: tv, movie, ova, special. Defaults to nothing, which returns top anime of any type.
  • 650 |
  • page - Page of top anime to return. Defaults to 1, i.e. the 1st page.
  • 651 |
  • per_page - Number of anime to return per page. Defaults to 30. NOT YET IMPLEMENTED
  • 652 |
653 | 654 |

The response is a list of anime. Only the following anime properties are available: id, title, episodes, type, image_url, members_count, members_score

655 | 656 |

How are the top anime determined?

657 | 658 |

To quote MyAnimeList:

659 |
660 |

Weighted Rank (WR) = (v / (v + m)) * S + (m / (v + m)) * C

661 |

S = Average score for the Anime (mean).
662 | v = Number of votes for the Anime = (Number of people scoring the Anime).
663 | m = Minimum votes/scores required to get a calculated score (currently 50 scores required).
664 | C = The mean score across the entire Anime DB.

665 |
666 | 667 |
668 | 669 | 670 |
671 | 672 | 673 |

Fetch the most popular anime. The popularity of an anime is determined by the number of MyAnimeList members watching it.

674 | 675 |
676 |

http://mal-api.com/anime/popular

677 | 678 |

Example: http://mal-api.com/anime/popular?page=1&per_page=30

679 |
680 | 681 |

The following optional parameters are supported:

682 |
    683 |
  • page - Page of anime to return. Defaults to 1, i.e. the 1st page.
  • 684 |
  • per_page - Number of anime to return per page. Defaults to 30. NOT YET IMPLEMENTED
  • 685 |
686 | 687 |

The response is a list of anime. Only the following anime properties are available: id, title, episodes, type, image_url, members_count, score

688 |
689 | 690 | 691 |
692 |

/anime/upcoming - Read the upcoming anime

693 | 694 |

Fetch the upcoming anime. This is a list of anime sorted by airing date.

695 | 696 |
697 |

http://mal-api.com/anime/upcoming

698 | 699 |

Example: http://mal-api.com/anime/upcoming?start_date=20090815

700 |
701 | 702 |

The following optional parameters are supported:

703 |
    704 |
  • start_date - Only anime which air after this date (in YYYYMMDD format) are returned. Defaults to the current date in UTC.
  • 705 |
  • page - Page of anime to return. Defaults to 1, i.e. the 1st page.
  • 706 |
  • per_page - Number of anime to return per page. Defaults to 20. NOT YET IMPLEMENTED
  • 707 |
708 | 709 |

The response is a list of anime. Only the following anime properties are available: id, title, episodes, type, synopsis, image_url, members_score, start_date, end_date, classification

710 |
711 | 712 | 713 |
714 |

/anime/just_added - Read the anime that have just been added to MyAnimeList

715 | 716 |

Fetch anime that have just been added to the MyAnimeList database. The anime are sorted with the most recently added ones in front.

717 | 718 |
719 |

http://mal-api.com/anime/just_added

720 | 721 |

Example: http://mal-api.com/anime/just_added?page=1&per_page=30

722 |
723 | 724 |

The following optional parameters are supported:

725 |
    726 |
  • page - Page of anime to return. Defaults to 1, i.e. the 1st page.
  • 727 |
  • per_page - Number of anime to return per page. Defaults to 20. NOT YET IMPLEMENTED
  • 728 |
729 | 730 |

The response is a list of anime. Only the following anime properties are available: id, title, episodes, type, synopsis, image_url, members_score, start_date, end_date, classification

731 |
732 | 733 | 734 |
735 |

/mangalist - Read a manga list

736 | 737 |

Fetch a manga list with the given username:

738 | 739 |
740 |

http://mal-api.com/mangalist/username

741 | 742 |

Example: http://mal-api.com/mangalist/xinil

743 |
744 | 745 |

The response is a manga list.

746 |
747 | 748 | 749 |
750 |

/manga - Read a manga's details

751 | 752 |

Retrieve details for the manga with the given manga id:

753 | 754 |
755 |

http://mal-api.com/manga/manga_id

756 | 757 |

Example: http://mal-api.com/manga/104

758 |
759 | 760 |

The response is an manga.

761 | 762 |

The following optional parameters are supported:

763 |
    764 |
  • mine=1 - If specified, include the authenticated user's manga details (e.g. user's score, chapters read, volumes read). Requires authentication. See manga response type.
  • 765 |
766 |
767 | 768 | 769 |
770 |

/manga/search - Search manga

771 | 772 |

Search for manga matching a query.

773 | 774 |
775 |

http://mal-api.com/manga/search?q=query

776 | 777 |

Example: http://mal-api.com/manga/search?q=berserk

778 |
779 | 780 |

Only 1 required parameter is supported:

781 |
    782 |
  • q - The query (URL encoded).
  • 783 |
784 | 785 |

The response is a list of manga. Only the following manga properties are available: id, title, chapters, volumes, type, synopsis, image_url, members_score

786 |
787 | 788 | 789 |

Writing data to MyAnimeList

790 | 791 |

All write methods require authentication.

792 | 793 | 794 |
795 |

/animelist/anime - Add anime to anime list

796 | 797 |

Adds an anime to a user's anime list:

798 | 799 |
800 |

POST http://mal-api.com/animelist/anime

801 |
802 | 803 |

Parameters:

804 |
    805 |
  • anime_id - required - ID of anime to add to user's animelist.
  • 806 |
  • status - Integer or string representing user's watched status of the anime. Possible values: 1/"watching", 2/"completed", 3/"on-hold"/"onhold", 4/"dropped", 6/"plan to watch"/"plantowatch". Default: 1/"watching"
  • 807 |
  • episodes - Number of episodes watched. Default: 0
  • 808 |
  • score - User's score for an anime. An integer from 1 to 10.
  • 809 |
810 | 811 |

Example:

812 |
813 |

POST http://mal-api.com/animelist/anime

814 |
Request body:
815 |   anime_id=1887
816 |   status=watching
817 |   episodes=1
818 |   score=9
819 |
820 |
821 | 822 | 823 |
824 |

/animelist/anime/anime_id - Update an anime on user's anime list

825 | 826 |

Updates an anime already on a user's anime list:

827 | 828 |
829 |

PUT http://mal-api.com/animelist/anime/anime_id

830 |
831 | 832 |

Parameters:

833 |
    834 |
  • status - Integer or string representing user's watched status of the anime. Possible values: 1/"watching", 2/"completed", 3/"on-hold"/"onhold", 4/"dropped", 6/"plan to watch"/"plantowatch". Default: 1/"watching"
  • 835 |
  • episodes - Number of episodes watched. Default: 0
  • 836 |
  • score - User's score for an anime. An integer from 1 to 10.
  • 837 |
838 | 839 |

Example:

840 |
841 |

PUT http://mal-api.com/animelist/anime/1887

842 |
Request body:
843 |   status=completed
844 |   episodes=24
845 |   score=10
846 |
847 |
848 | 849 | 850 |
851 |

/animelist/anime/anime_id - Delete an anime from user's anime list

852 | 853 |

Delete an anime from a user's anime list. This removes any record of the anime from a user's anime list and cannot be undone.

854 | 855 |
856 | DELETE http://mal-api.com/animelist/anime/anime_id 857 |
858 | 859 |

Parameters: none.

860 | 861 |

Returns: HTTP 200 OK and the original anime (this is useful for undoing a delete) if the anime was successfully deleted from animelist. Otherwise, returns appropriate HTTP response code and an error message.

862 | 863 |

Example:

864 |
865 |

DELETE http://mal-api.com/animelist/anime/1887

866 |
867 |
868 | 869 | 870 |
871 |

/mangalist/manga - Add manga to manga list

872 | 873 |

Adds a manga to a user's manga list:

874 | 875 |
876 |

POST http://mal-api.com/mangalist/manga

877 |
878 | 879 |

Parameters:

880 |
    881 |
  • manga_id - required - ID of manga to add to user's mangalist.
  • 882 |
  • status - Integer or string representing user's watched status of the manga. Possible values: 1/"reading", 2/"completed", 3/"on-hold"/"onhold", 4/"dropped", 6/"plan to read"/"plantoread". Default: 1/"reading"
  • 883 |
  • chapters - Number of chapters read. Default: 0
  • 884 |
  • volumes - Number of volumes read. Default: 0
  • 885 |
  • score - User's score for an manga. An integer from 1 to 10.
  • 886 |
887 | 888 |

Example:

889 |
890 |

POST http://mal-api.com/mangalist/manga

891 |
Request body:
892 |   manga_id=1887
893 |   status=watching
894 |   chapters=12
895 |   score=9
896 |
897 |
898 | 899 | 900 |
901 |

/mangalist/manga/manga_id - Update a manga on user's manga list

902 | 903 |

Updates a manga already on a user's manga list:

904 | 905 |
906 |

PUT http://mal-api.com/mangalist/manga/manga_id

907 |
908 | 909 |

Parameters:

910 |
    911 |
  • status - Integer or string representing user's watched status of the manga. Possible values: 1/"reading", 2/"completed", 3/"on-hold"/"onhold", 4/"dropped", 6/"plan to read"/"plantoread". Default: 1/"reading"
  • 912 |
  • chapters - Number of chapters read. Default: 0
  • 913 |
  • volumes - Number of volumes read. Default: 0
  • 914 |
  • score - User's score for an manga. An integer from 1 to 10.
  • 915 |
916 | 917 |

Example:

918 |
919 |

PUT http://mal-api.com/mangalist/manga/1887

920 |
Request body:
921 |   status=completed
922 |   chapters=24
923 |   score=10
924 |
925 |
926 | 927 | 928 |
929 |

/mangalist/manga/manga_id - Delete a manga from user's manga list

930 | 931 |

Delete a manga from a user's manga list. This removes any record of the manga from a user's manga list and cannot be undone.

932 | 933 |
934 | DELETE http://mal-api.com/mangalist/manga/manga_id 935 |
936 | 937 |

Parameters: none.

938 | 939 |

Returns: HTTP 200 OK and the original manga (this is useful for undoing a delete) if the manga was successfully deleted from mangalist. Otherwise, returns appropriate HTTP response code and an error message.

940 | 941 |

Example:

942 |
943 |

DELETE http://mal-api.com/mangalist/manga/1887

944 |
945 |
946 | 947 | 948 |

Utility methods

949 | 950 | 951 |
952 |

/account/verify_credentials - Test if user credentials are valid

953 | 954 |

Test whether supplied user credentials are valid. The authentication mechanism is HTTP Basic Authentication. This method is rate-limited (the response status code will be HTTP 503 Service Temporarily Unavailable) because it can be a vector for brute force attacks.

955 | 956 |
957 |

http://mal-api.com/account/verify_credentials

958 |
959 | 960 |

The response is an HTTP 200 OK status code if authentication was successful. Otherwise, an HTTP 401 Unauthorized status code is returned.

961 |
962 | 963 | 964 |
965 | 966 | 970 | 975 | 976 | 977 | --------------------------------------------------------------------------------