Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
mikf
GitHub Repository: mikf/gallery-dl
Path: blob/master/gallery_dl/extractor/exhentai.py
5399 views
1
# -*- coding: utf-8 -*-
2
3
# Copyright 2014-2025 Mike Fährmann
4
#
5
# This program is free software; you can redistribute it and/or modify
6
# it under the terms of the GNU General Public License version 2 as
7
# published by the Free Software Foundation.
8
9
"""Extractors for https://e-hentai.org/ and https://exhentai.org/"""
10
11
from .common import Extractor, Message
12
from .. import text, util, exception
13
from ..cache import cache
14
import collections
15
import itertools
16
import math
17
18
BASE_PATTERN = r"(?:https?://)?(e[x-]|g\.e-)hentai\.org"
19
20
21
class ExhentaiExtractor(Extractor):
22
"""Base class for exhentai extractors"""
23
category = "exhentai"
24
directory_fmt = ("{category}", "{gid} {title[:247]}")
25
filename_fmt = "{gid}_{num:>04}_{image_token}_{filename}.{extension}"
26
archive_fmt = "{gid}_{num}"
27
cookies_domain = ".exhentai.org"
28
cookies_names = ("ipb_member_id", "ipb_pass_hash")
29
root = "https://exhentai.org"
30
request_interval = (3.0, 6.0)
31
ciphers = "DEFAULT:!DH"
32
33
LIMIT = False
34
35
def __init__(self, match):
36
Extractor.__init__(self, match)
37
self.version = match[1]
38
39
def initialize(self):
40
domain = self.config("domain", "auto")
41
if domain == "auto":
42
domain = ("ex" if self.version == "ex" else "e-") + "hentai.org"
43
self.root = "https://" + domain
44
self.api_url = self.root + "/api.php"
45
self.cookies_domain = "." + domain
46
47
Extractor.initialize(self)
48
49
if self.version != "ex":
50
self.cookies.set("nw", "1", domain=self.cookies_domain)
51
52
def request(self, url, **kwargs):
53
response = Extractor.request(self, url, **kwargs)
54
if "Cache-Control" not in response.headers and not response.content:
55
self.log.info("blank page")
56
raise exception.AuthorizationError()
57
return response
58
59
def login(self):
60
"""Login and set necessary cookies"""
61
if self.LIMIT:
62
raise exception.AbortExtraction("Image limit reached!")
63
64
if self.cookies_check(self.cookies_names):
65
return
66
67
username, password = self._get_auth_info()
68
if username:
69
return self.cookies_update(self._login_impl(username, password))
70
71
if self.version == "ex":
72
self.log.info("No username or cookies given; using e-hentai.org")
73
self.root = "https://e-hentai.org"
74
self.cookies_domain = ".e-hentai.org"
75
self.cookies.set("nw", "1", domain=self.cookies_domain)
76
self.original = False
77
self.limits = False
78
79
@cache(maxage=90*86400, keyarg=1)
80
def _login_impl(self, username, password):
81
self.log.info("Logging in as %s", username)
82
83
url = "https://forums.e-hentai.org/index.php?act=Login&CODE=01"
84
headers = {
85
"Referer": "https://e-hentai.org/bounce_login.php?b=d&bt=1-1",
86
}
87
data = {
88
"CookieDate": "1",
89
"b": "d",
90
"bt": "1-1",
91
"UserName": username,
92
"PassWord": password,
93
"ipb_login_submit": "Login!",
94
}
95
96
self.cookies.clear()
97
98
response = self.request(url, method="POST", headers=headers, data=data)
99
content = response.content
100
if b"You are now logged in as:" not in content:
101
if b"The captcha was not entered correctly" in content:
102
raise exception.AuthenticationError(
103
"CAPTCHA required. Use cookies instead.")
104
raise exception.AuthenticationError()
105
106
# collect more cookies
107
url = self.root + "/favorites.php"
108
response = self.request(url)
109
if response.history:
110
self.request(url)
111
112
return self.cookies
113
114
115
class ExhentaiGalleryExtractor(ExhentaiExtractor):
116
"""Extractor for image galleries from exhentai.org"""
117
subcategory = "gallery"
118
pattern = (BASE_PATTERN +
119
r"(?:/g/(\d+)/([\da-f]{10})"
120
r"|/s/([\da-f]{10})/(\d+)-(\d+))")
121
example = "https://e-hentai.org/g/12345/67890abcde/"
122
123
def __init__(self, match):
124
ExhentaiExtractor.__init__(self, match)
125
self.gallery_id = text.parse_int(match[2] or match[5])
126
self.gallery_token = match[3]
127
self.image_token = match[4]
128
self.image_num = text.parse_int(match[6], 1)
129
self.key_start = None
130
self.key_show = None
131
self.key_next = None
132
self.count = 0
133
self.data = None
134
135
def _init(self):
136
source = self.config("source")
137
if source == "hitomi":
138
self.items = self._items_hitomi
139
elif source == "metadata":
140
self.items = self._items_metadata
141
142
limits = self.config("limits", False)
143
if limits and limits.__class__ is int:
144
self.limits = limits
145
self._limits_remaining = 0
146
else:
147
self.limits = False
148
149
self.fallback_retries = self.config("fallback-retries", 2)
150
self.original = self.config("original", True)
151
152
def finalize(self):
153
if self.data:
154
self.log.info("Use '%s/s/%s/%s-%s' as input URL "
155
"to continue downloading from the current position",
156
self.root, self.data["image_token"],
157
self.gallery_id, self.data["num"])
158
159
def favorite(self, slot="0"):
160
url = self.root + "/gallerypopups.php"
161
params = {
162
"gid": self.gallery_id,
163
"t" : self.gallery_token,
164
"act": "addfav",
165
}
166
data = {
167
"favcat" : slot,
168
"apply" : "Apply Changes",
169
"update" : "1",
170
}
171
self.request(url, method="POST", params=params, data=data)
172
173
def items(self):
174
self.login()
175
176
if self.gallery_token:
177
gpage = self._gallery_page()
178
self.image_token = text.extr(gpage, 'hentai.org/s/', '"')
179
if not self.image_token:
180
self.log.debug("Page content:\n%s", gpage)
181
raise exception.AbortExtraction(
182
"Failed to extract initial image token")
183
ipage = self._image_page()
184
else:
185
ipage = self._image_page()
186
part = text.extr(ipage, 'hentai.org/g/', '"')
187
if not part:
188
self.log.debug("Page content:\n%s", ipage)
189
raise exception.AbortExtraction(
190
"Failed to extract gallery token")
191
self.gallery_token = part.split("/")[1]
192
gpage = self._gallery_page()
193
194
self.data = data = self.get_metadata(gpage)
195
self.count = text.parse_int(data["filecount"])
196
yield Message.Directory, data
197
198
images = itertools.chain(
199
(self.image_from_page(ipage),), self.images_from_api())
200
for url, image in images:
201
data.update(image)
202
if self.limits:
203
self._limits_check(data)
204
if "/fullimg" in url:
205
data["_http_validate"] = self._validate_response
206
else:
207
data["_http_validate"] = None
208
data["_http_signature"] = self._validate_signature
209
yield Message.Url, url, data
210
211
fav = self.config("fav")
212
if fav is not None:
213
self.favorite(fav)
214
self.data = None
215
216
def _items_hitomi(self):
217
if self.config("metadata", False):
218
data = self.metadata_from_api()
219
data["date"] = text.parse_timestamp(data["posted"])
220
else:
221
data = {}
222
223
from .hitomi import HitomiGalleryExtractor
224
url = f"https://hitomi.la/galleries/{self.gallery_id}.html"
225
data["_extractor"] = HitomiGalleryExtractor
226
yield Message.Queue, url, data
227
228
def _items_metadata(self):
229
yield Message.Directory, self.metadata_from_api()
230
231
def get_metadata(self, page):
232
"""Extract gallery metadata"""
233
data = self.metadata_from_page(page)
234
if self.config("metadata", False):
235
data.update(self.metadata_from_api())
236
data["date"] = text.parse_timestamp(data["posted"])
237
if self.config("tags", False):
238
tags = collections.defaultdict(list)
239
for tag in data["tags"]:
240
type, _, value = tag.partition(":")
241
tags[type].append(value)
242
for type, values in tags.items():
243
data["tags_" + type] = values
244
return data
245
246
def metadata_from_page(self, page):
247
extr = text.extract_from(page)
248
249
if api_url := extr('var api_url = "', '"'):
250
self.api_url = api_url
251
252
data = {
253
"gid" : self.gallery_id,
254
"token" : self.gallery_token,
255
"thumb" : extr("background:transparent url(", ")"),
256
"title" : text.unescape(extr('<h1 id="gn">', '</h1>')),
257
"title_jpn" : text.unescape(extr('<h1 id="gj">', '</h1>')),
258
"_" : extr('<div id="gdc"><div class="cs ct', '"'),
259
"eh_category" : extr('>', '<'),
260
"uploader" : extr('<div id="gdn">', '</div>'),
261
"date" : text.parse_datetime(extr(
262
'>Posted:</td><td class="gdt2">', '</td>'), "%Y-%m-%d %H:%M"),
263
"parent" : extr(
264
'>Parent:</td><td class="gdt2"><a href="', '"'),
265
"expunged" : "Yes" != extr(
266
'>Visible:</td><td class="gdt2">', '<'),
267
"language" : extr('>Language:</td><td class="gdt2">', ' '),
268
"filesize" : text.parse_bytes(extr(
269
'>File Size:</td><td class="gdt2">', '<').rstrip("Bbi")),
270
"filecount" : extr('>Length:</td><td class="gdt2">', ' '),
271
"favorites" : extr('id="favcount">', ' '),
272
"rating" : extr(">Average: ", "<"),
273
"torrentcount" : extr('>Torrent Download (', ')'),
274
}
275
276
uploader = data["uploader"]
277
if uploader and uploader[0] == "<":
278
data["uploader"] = text.unescape(text.extr(uploader, ">", "<"))
279
280
f = data["favorites"][0]
281
if f == "N":
282
data["favorites"] = "0"
283
elif f == "O":
284
data["favorites"] = "1"
285
286
data["lang"] = util.language_to_code(data["language"])
287
data["tags"] = [
288
text.unquote(tag.replace("+", " "))
289
for tag in text.extract_iter(page, 'hentai.org/tag/', '"')
290
]
291
292
return data
293
294
def metadata_from_api(self):
295
data = {
296
"method" : "gdata",
297
"gidlist" : ((self.gallery_id, self.gallery_token),),
298
"namespace": 1,
299
}
300
301
data = self.request_json(self.api_url, method="POST", json=data)
302
if "error" in data:
303
raise exception.AbortExtraction(data["error"])
304
305
return data["gmetadata"][0]
306
307
def image_from_page(self, page):
308
"""Get image url and data from webpage"""
309
pos = page.index('<div id="i3"><a onclick="return load_image(') + 26
310
extr = text.extract_from(page, pos)
311
312
self.key_next = extr("'", "'")
313
iurl = extr('<img id="img" src="', '"')
314
nl = extr(" nl(", ")").strip("\"'")
315
orig = extr('hentai.org/fullimg', '"')
316
317
try:
318
if self.original and orig:
319
url = self.root + "/fullimg" + text.unescape(orig)
320
data = self._parse_original_info(extr('ownload original', '<'))
321
data["_fallback"] = self._fallback_original(nl, url)
322
else:
323
url = iurl
324
data = self._parse_image_info(url)
325
data["_fallback"] = self._fallback_1280(nl, self.image_num)
326
except IndexError:
327
self.log.debug("Page content:\n%s", page)
328
raise exception.AbortExtraction(
329
f"Unable to parse image info for '{url}'")
330
331
data["num"] = self.image_num
332
data["image_token"] = self.key_start = extr('var startkey="', '";')
333
data["_url_1280"] = iurl
334
data["_nl"] = nl
335
self.key_show = extr('var showkey="', '";')
336
337
self._check_509(iurl)
338
return url, text.nameext_from_url(url, data)
339
340
def images_from_api(self):
341
"""Get image url and data from api calls"""
342
api_url = self.api_url
343
nextkey = self.key_next
344
request = {
345
"method" : "showpage",
346
"gid" : self.gallery_id,
347
"page" : 0,
348
"imgkey" : nextkey,
349
"showkey": self.key_show,
350
}
351
352
for request["page"] in range(self.image_num + 1, self.count + 1):
353
page = self.request_json(api_url, method="POST", json=request)
354
355
i3 = page["i3"]
356
i6 = page["i6"]
357
358
imgkey = nextkey
359
nextkey, pos = text.extract(i3, "'", "'")
360
imgurl , pos = text.extract(i3, 'id="img" src="', '"', pos)
361
nl , pos = text.extract(i3, " nl(", ")", pos)
362
nl = (nl or "").strip("\"'")
363
364
try:
365
pos = i6.find("hentai.org/fullimg")
366
if self.original and pos >= 0:
367
origurl, pos = text.rextract(i6, '"', '"', pos)
368
url = text.unescape(origurl)
369
data = self._parse_original_info(text.extract(
370
i6, "ownload original", "<", pos)[0])
371
data["_fallback"] = self._fallback_original(nl, url)
372
else:
373
url = imgurl
374
data = self._parse_image_info(url)
375
data["_fallback"] = self._fallback_1280(
376
nl, request["page"], imgkey)
377
except IndexError:
378
self.log.debug("Page content:\n%s", page)
379
raise exception.AbortExtraction(
380
f"Unable to parse image info for '{url}'")
381
382
data["num"] = request["page"]
383
data["image_token"] = imgkey
384
data["_url_1280"] = imgurl
385
data["_nl"] = nl
386
387
self._check_509(imgurl)
388
yield url, text.nameext_from_url(url, data)
389
390
request["imgkey"] = nextkey
391
392
def _validate_response(self, response):
393
if response.history or not response.headers.get(
394
"content-type", "").startswith("text/html"):
395
return True
396
397
page = response.text
398
self.log.warning("'%s'", page)
399
400
if " requires GP" in page:
401
gp = self.config("gp")
402
if gp == "stop":
403
raise exception.AbortExtraction("Not enough GP")
404
elif gp == "wait":
405
self.input("Press ENTER to continue.")
406
return response.url
407
408
self.log.info("Falling back to non-original downloads")
409
self.original = False
410
return self.data["_url_1280"]
411
412
if " temporarily banned " in page:
413
raise exception.AuthorizationError("Temporarily Banned")
414
415
self._limits_exceeded()
416
return response.url
417
418
def _validate_signature(self, signature):
419
"""Return False if all file signature bytes are zero"""
420
if signature:
421
if byte := signature[0]:
422
# 60 == b"<"
423
if byte == 60 and b"<!doctype html".startswith(
424
signature[:14].lower()):
425
return "HTML response"
426
return True
427
for byte in signature:
428
if byte:
429
return True
430
return False
431
432
def _request_home(self, **kwargs):
433
url = "https://e-hentai.org/home.php"
434
kwargs["cookies"] = {
435
cookie.name: cookie.value
436
for cookie in self.cookies
437
if cookie.domain == self.cookies_domain and
438
cookie.name != "igneous"
439
}
440
page = self.request(url, **kwargs).text
441
442
# update image limits
443
current = text.extr(page, "<strong>", "</strong>").replace(",", "")
444
self.log.debug("Image Limits: %s/%s", current, self.limits)
445
self._limits_remaining = self.limits - text.parse_int(current)
446
447
return page
448
449
def _check_509(self, url):
450
# full 509.gif URLs
451
# - https://exhentai.org/img/509.gif
452
# - https://ehgt.org/g/509.gif
453
if url.endswith(("hentai.org/img/509.gif",
454
"ehgt.org/g/509.gif")):
455
self.log.debug(url)
456
self._limits_exceeded()
457
458
def _limits_exceeded(self):
459
msg = "Image limit exceeded!"
460
action = self.config("limits-action")
461
462
if not action or action == "stop":
463
ExhentaiExtractor.LIMIT = True
464
raise exception.AbortExtraction(msg)
465
466
self.log.warning(msg)
467
if action == "wait":
468
self.input("Press ENTER to continue.")
469
self._limits_update()
470
elif action == "reset":
471
self._limits_reset()
472
else:
473
self.log.error("Invalid 'limits-action' value '%s'", action)
474
475
def _limits_check(self, data):
476
if not self._limits_remaining or data["num"] % 25 == 0:
477
self._limits_update()
478
self._limits_remaining -= data["cost"]
479
if self._limits_remaining <= 0:
480
self._limits_exceeded()
481
482
def _limits_reset(self):
483
self.log.info("Resetting image limits")
484
self._request_home(
485
method="POST",
486
headers={"Content-Type": "application/x-www-form-urlencoded"},
487
data=b"reset_imagelimit=Reset+Quota")
488
489
_limits_update = _request_home
490
491
def _gallery_page(self):
492
url = f"{self.root}/g/{self.gallery_id}/{self.gallery_token}/"
493
response = self.request(url, fatal=False)
494
page = response.text
495
496
if response.status_code == 404 and "Gallery Not Available" in page:
497
raise exception.AuthorizationError()
498
if page.startswith(("Key missing", "Gallery not found")):
499
raise exception.NotFoundError("gallery")
500
if page.count("hentai.org/mpv/") > 1:
501
self.log.warning("Enabled Multi-Page Viewer is not supported")
502
return page
503
504
def _image_page(self):
505
url = (f"{self.root}/s/{self.image_token}"
506
f"/{self.gallery_id}-{self.image_num}")
507
page = self.request(url, fatal=False).text
508
509
if page.startswith(("Invalid page", "Keep trying")):
510
raise exception.NotFoundError("image page")
511
return page
512
513
def _fallback_original(self, nl, fullimg):
514
url = f"{fullimg}?nl={nl}"
515
for _ in util.repeat(self.fallback_retries):
516
yield url
517
518
def _fallback_1280(self, nl, num, token=None):
519
if not token:
520
token = self.key_start
521
522
for _ in util.repeat(self.fallback_retries):
523
url = f"{self.root}/s/{token}/{self.gallery_id}-{num}?nl={nl}"
524
525
page = self.request(url, fatal=False).text
526
if page.startswith(("Invalid page", "Keep trying")):
527
return
528
url, data = self.image_from_page(page)
529
yield url
530
531
nl = data["_nl"]
532
533
def _parse_image_info(self, url):
534
for part in url.split("/")[4:]:
535
try:
536
_, size, width, height, _ = part.split("-")
537
break
538
except ValueError:
539
pass
540
else:
541
size = width = height = 0
542
543
return {
544
"cost" : 1,
545
"size" : text.parse_int(size),
546
"width" : text.parse_int(width),
547
"height": text.parse_int(height),
548
}
549
550
def _parse_original_info(self, info):
551
parts = info.lstrip().split(" ")
552
size = text.parse_bytes(parts[3] + parts[4][0])
553
554
return {
555
# 1 initial point + 1 per 0.1 MB
556
"cost" : 1 + math.ceil(size / 100000),
557
"size" : size,
558
"width" : text.parse_int(parts[0]),
559
"height": text.parse_int(parts[2]),
560
}
561
562
563
class ExhentaiSearchExtractor(ExhentaiExtractor):
564
"""Extractor for exhentai search results"""
565
subcategory = "search"
566
pattern = BASE_PATTERN + r"/(?:\?([^#]*)|tag/([^/?#]+))"
567
example = "https://e-hentai.org/?f_search=QUERY"
568
569
def __init__(self, match):
570
ExhentaiExtractor.__init__(self, match)
571
572
_, query, tag = self.groups
573
if tag:
574
if "+" in tag:
575
ns, _, tag = tag.rpartition(":")
576
tag = f"{ns}:\"{tag.replace('+', ' ')}$\""
577
else:
578
tag += "$"
579
self.params = {"f_search": tag, "page": 0}
580
else:
581
self.params = text.parse_query(query)
582
if "next" not in self.params:
583
self.params["page"] = text.parse_int(self.params.get("page"))
584
585
def _init(self):
586
self.search_url = self.root
587
588
def items(self):
589
self.login()
590
data = {"_extractor": ExhentaiGalleryExtractor}
591
search_url = self.search_url
592
params = self.params
593
594
while True:
595
last = None
596
page = self.request(search_url, params=params).text
597
598
for match in ExhentaiGalleryExtractor.pattern.finditer(page):
599
url = match[0]
600
if url == last:
601
continue
602
last = url
603
data["gallery_id"] = text.parse_int(match[2])
604
data["gallery_token"] = match[3]
605
yield Message.Queue, url + "/", data
606
607
next_url = text.extr(page, 'nexturl="', '"', None)
608
if next_url is not None:
609
if not next_url:
610
return
611
search_url = next_url
612
params = None
613
614
elif 'class="ptdd">&gt;<' in page or ">No hits found</p>" in page:
615
return
616
else:
617
params["page"] += 1
618
619
620
class ExhentaiFavoriteExtractor(ExhentaiSearchExtractor):
621
"""Extractor for favorited exhentai galleries"""
622
subcategory = "favorite"
623
pattern = BASE_PATTERN + r"/favorites\.php(?:\?([^#]*)())?"
624
example = "https://e-hentai.org/favorites.php"
625
626
def _init(self):
627
self.search_url = self.root + "/favorites.php"
628
629