b626dc127ae3a061a9457675907cd77fce8817ec
[automanga.git] / manga / batoto.py
1 import urllib.request, urllib.parse, http.cookiejar, re, bs4, os, time
2 from . import profile, lib, htcache
3 soup = bs4.BeautifulSoup
4 soupify = lambda cont: soup(cont, "html.parser")
5
6 class pageerror(Exception):
7     def __init__(self, message, page):
8         super().__init__(message)
9         self.page = page
10
11 def iterlast(itr, default=None):
12     if default is not None:
13         ret = default
14     try:
15         while True:
16             ret = next(itr)
17     except StopIteration:
18         return ret
19
20 def find1(el, *args, **kwargs):
21     ret = el.find(*args, **kwargs)
22     if ret is None:
23         raise pageerror("could not find expected element", iterlast(el.parents, el))
24     return ret
25
26 def byclass(el, name, cl):
27     for ch in el.findAll(name):
28         if not isinstance(ch, bs4.Tag): continue
29         cll = ch.get("class", [])
30         if cl in cll:
31             return ch
32     return None
33
34 def nextel(el):
35     while True:
36         el = el.nextSibling
37         if isinstance(el, bs4.Tag):
38             return el
39
40 def fetchreader(lib, readerid, page):
41     pg = soupify(lib.sess.fetch(lib.base + "areader?" + urllib.parse.urlencode({"id": readerid, "p": str(page)}),
42                                 headers={"Referer": "http://bato.to/reader"}))
43     return pg
44
45 class page(lib.page):
46     def __init__(self, chapter, stack, readerid, n):
47         self.stack = stack
48         self.lib = chapter.lib
49         self.chapter = chapter
50         self.n = n
51         self.id = str(n)
52         self.name = "Page %s" % n
53         self.readerid = readerid
54         self.ciurl = None
55
56     def iurl(self):
57         if self.ciurl is None:
58             page = fetchreader(self.lib, self.readerid, self.n)
59             img = find1(page, "img", id="comic_page")
60             self.ciurl = img["src"]
61         return self.ciurl
62
63     def open(self):
64         return lib.stdimgstream(self.iurl())
65
66     def __str__(self):
67         return self.name
68
69     def __repr(self):
70         return "<batoto.page %r.%r.%r.%r>" % (self.chapter.manga.name, self.chapter.group.name, self.chapter.name, self.name)
71
72 class chapter(lib.pagelist):
73     def __init__(self, group, stack, id, name, readerid):
74         self.stack = stack
75         self.group = group
76         self.manga = group.manga
77         self.lib = self.manga.lib
78         self.id = id
79         self.name = name
80         self.readerid = readerid
81         self.cpag = None
82
83     def __getitem__(self, i):
84         return self.pages()[i]
85
86     def __len__(self):
87         return len(self.pages())
88
89     pnre = re.compile(r"page (\d+)")
90     def pages(self):
91         if self.cpag is None:
92             pg = fetchreader(self.lib, self.readerid, 1)
93             cpag = []
94             for opt in find1(pg, "select", id="page_select").findAll("option"):
95                 n = int(self.pnre.match(opt.string).group(1))
96                 cpag.append(page(self, self.stack + [(self, len(cpag))], self.readerid, n))
97             self.cpag = cpag
98         return self.cpag
99
100     def __str__(self):
101         return self.name
102
103     def __repr__(self):
104         return "<batoto.chapter %r.%r.%r>" % (self.manga.name, self.group.name, self.name)
105
106 class group(lib.pagelist):
107     def __init__(self, manga, stack, id, name):
108         self.stack = stack
109         self.manga = manga
110         self.id = id
111         self.name = name
112         self.ch = []
113
114     def __getitem__(self, i):
115         return self.ch[i]
116
117     def __len__(self):
118         return len(self.ch)
119
120     def __str__(self):
121         return self.name
122
123     def __repr__(self):
124         return "<batoto.group %r.%r" % (self.manga.name, self.name)
125
126 class manga(lib.manga):
127     def __init__(self, lib, id, name, url):
128         self.lib = lib
129         self.sess = lib.sess
130         self.id = id
131         self.name = name
132         self.url = url
133         self.cch = None
134         self.stack = []
135         self.cnames = None
136
137     def __getitem__(self, i):
138         return self.ch()[i]
139
140     def __len__(self):
141         return len(self.ch())
142
143     @staticmethod
144     def vfylogin(page):
145         if page.find("div", id="register_notice"):
146             return False
147         if not byclass(page, "table", "chapters_list"):
148             return False
149         return True
150
151     cure = re.compile(r"/reader#([a-z0-9]+)")
152     def ch(self):
153         if self.cch is None:
154             page = self.sess.lfetch(self.url, self.vfylogin)
155             cls = byclass(page, "table", "chapters_list")
156             if cls.tbody is not None:
157                 cls = cls.tbody
158             scl = "lang_" + self.lib.lang
159             cch = []
160             for ch in cls.childGenerator():
161                 if isinstance(ch, bs4.Tag) and ch.name == "tr":
162                     cll = ch.get("class", [])
163                     if "row" in cll and scl in cll:
164                         url = ch.td.a["href"]
165                         m = self.cure.search(url)
166                         if m is None: raise pageerror("Got weird chapter URL: %r" % url, page)
167                         readerid = m.group(1)
168                         name = ch.td.a.text
169                         gname = nextel(nextel(ch.td)).text.strip()
170                         cch.append((readerid, name, gname))
171             cch.reverse()
172             groups = {}
173             for n, (readerid, name, gname) in enumerate(cch):
174                 groups.setdefault(gname, [n, []])[1].append((readerid, name))
175             groups = sorted(groups.items(), key=lambda o: o[1][0])
176             rgrp = []
177             for n, (gname, (_, gch)) in enumerate(groups):
178                 ngrp = group(self, [(self, n)], gname, gname)
179                 for m, (readerid, name) in enumerate(gch):
180                     ngrp.ch.append(chapter(ngrp, ngrp.stack + [(ngrp, m)], readerid, name, readerid))
181                 rgrp.append(ngrp)
182             self.cch = rgrp
183         return self.cch
184
185     def altnames(self):
186         if self.cnames is None:
187             page = soupify(self.sess.fetch(self.url))
188             cnames = None
189             for tbl in page.findAll("table", attrs={"class": "ipb_table"}):
190                 if tbl.tbody is not None: tbl = tbl.tbody
191                 for tr in tbl.findAll("tr"):
192                     if "Alt Names:" in tr.td.text:
193                         nls = nextel(tr.td)
194                         if nls.name != "td" or nls.span is None:
195                             raise pageerror("Weird altnames table in " + self.id, page)
196                         cnames = [nm.text.strip() for nm in nls.findAll("span")]
197                         break
198                 if cnames is not None:
199                     break
200             if cnames is None:
201                 raise pageerror("Could not find altnames for " + self.id, page)
202             self.cnames = cnames
203         return self.cnames
204
205     def __str__(self):
206         return self.name
207
208     def __repr__(self):
209         return "<batoto.manga %r>" % self.name
210
211 class credentials(object):
212     def __init__(self, username, password):
213         self.username = username
214         self.password = password
215
216     @classmethod
217     def fromfile(cls, path):
218         username, password = None, None
219         with open(path) as fp:
220             for words in profile.splitlines(fp):
221                 if words[0] == "username":
222                     username = words[1]
223                 elif words[0] == "password":
224                     password = words[1]
225                 elif words[0] == "pass64":
226                     import binascii
227                     password = binascii.a2b_base64(words[1]).decode("utf8")
228         if None in (username, password):
229             raise ValueError("Incomplete profile: " + path)
230         return cls(username, password)
231
232     @classmethod
233     def default(cls):
234         path = os.path.join(profile.confdir, "batoto")
235         if os.path.exists(path):
236             return cls.fromfile(path)
237         return None
238
239 class session(object):
240     def __init__(self, base, credentials):
241         self.base = base
242         self.creds = credentials
243         self.jar = http.cookiejar.CookieJar()
244         self.web = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))
245         self.lastlogin = 0
246
247     rlre = re.compile(r"Welcome, (.*) ")
248     def dologin(self, pre=None):
249         now = time.time()
250         if now - self.lastlogin < 60:
251             raise Exception("Too soon since last login attempt")
252         if pre is None:
253             with self.web.open(self.base) as hs:
254                 page = soupify(hs.read())
255         else:
256             page = pre
257
258         cur = page.find("a", id="user_link")
259         if cur:
260             m = self.rlre.search(cur.text)
261             if not m or m.group(1) != self.creds.username:
262                 outurl = None
263                 nav = page.find("div", id="user_navigation")
264                 if nav:
265                     for li in nav.findAll("li"):
266                         if li.a and "Sign Out" in li.a.string:
267                             outurl = li.a["href"]
268                 if not outurl:
269                     raise pageerror("Could not find logout URL", page)
270                 with self.wep.open(outurl) as hs:
271                     hs.read()
272                 with self.web.open(self.base) as hs:
273                     page = soupify(hs.read())
274             else:
275                 return
276         else:
277
278         form = page.find("form", id="login")
279         if not form and pre:
280             return self.dologin()
281         values = {}
282         for el in form.findAll("input", type="hidden"):
283             values[el["name"]] = el["value"]
284         values["ips_username"] = self.creds.username
285         values["ips_password"] = self.creds.password
286         values["rememberMe"] = "1"
287         values["anonymous"] = "1"
288         req = urllib.request.Request(form["action"], urllib.parse.urlencode(values).encode("ascii"))
289         with self.web.open(req) as hs:
290             page = soupify(hs.read())
291         for resp in page.findAll("p", attrs={"class": "message"}):
292             if resp.strong and "You are now signed in" in resp.strong.string:
293                 break
294         else:
295             raise pageerror("Could not log in", page)
296         self.lastlogin = now
297
298     def open(self, url):
299         return self.web.open(url)
300
301     def fetch(self, url, headers=None):
302         req = urllib.request.Request(url)
303         if headers is not None:
304             for k, v in headers.items():
305                 req.add_header(k, v)
306         with self.open(req) as hs:
307             return hs.read()
308
309     def lfetch(self, url, ck):
310         page = soupify(self.fetch(url))
311         if not ck(page):
312             self.dologin(pre=page)
313             page = soupify(self.fetch(url))
314             if not ck(page):
315                 raise pageerror("Could not verify login status despite having logged in", page)
316         return page
317
318 class library(lib.library):
319     def __init__(self, *, creds=None):
320         if creds is None:
321             creds = credentials.default()
322         self.base = "http://bato.to/"
323         self.sess = session(self.base, creds)
324         self.lang = "English"
325
326     def byid(self, id):
327         url = self.base + "comic/_/comics/" + id
328         page = soupify(self.sess.fetch(url))
329         title = page.find("h1", attrs={"class": "ipsType_pagetitle"})
330         if title is None:
331             raise KeyError(id)
332         return manga(self, id, title.string.strip(), url)
333
334     def _search(self, pars):
335         p = 1
336         while True:
337             _pars = dict(pars)
338             _pars["p"] = str(p)
339             resp = urllib.request.urlopen(self.base + "search?" + urllib.parse.urlencode(_pars))
340             try:
341                 page = soupify(resp.read())
342             finally:
343                 resp.close()
344             rls = page.find("div", id="comic_search_results").table
345             if rls.tbody is not None:
346                 rls = rls.tbody
347             hasmore = False
348             for child in rls.findAll("tr"):
349                 if child.th is not None: continue
350                 if child.get("id", "")[:11] == "comic_rowo_": continue
351                 if child.get("id") == "show_more_row":
352                     hasmore = True
353                     continue
354                 link = child.td.strong.a
355                 url = link["href"]
356                 m = self.rure.search(url)
357                 if m is None: raise Exception("Got weird manga URL: %r" % url)
358                 id = m.group(1)
359                 name = link.text.strip()
360                 yield manga(self, id, name, url)
361             p += 1
362             if not hasmore:
363                 break
364
365     rure = re.compile(r"/comic/_/([^/]*)$")
366     def search(self, expr):
367         return self._search({"name": expr, "name_cond": "c"})
368
369     def byname(self, prefix):
370         for res in self._search({"name": prefix, "name_cond": "s"}):
371             if res.name[:len(prefix)].lower() == prefix.lower():
372                 yield res
373             else:
374                 for aname in res.altnames():
375                     if aname[:len(prefix)].lower() == prefix.lower():
376                         yield manga(self, res.id, aname, res.url)
377                         break
378                 else:
379                     if False:
380                         print("eliding " + res.name)
381                         print(res.altnames())