Mercurial > thp2epub
comparison thp2epub.py @ 0:c6103c5987da draft default tip
Hello Gensokyo!
| author | Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> |
|---|---|
| date | Fri, 24 Aug 2012 14:48:18 +0200 |
| parents | |
| children |
comparison
equal
deleted
inserted
replaced
| -1:000000000000 | 0:c6103c5987da |
|---|---|
| 1 #!/usr/bin/env python | |
| 2 # -*- encoding: UTF-8 -*- | |
| 3 ## | |
| 4 ## Copyright © 2012 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> | |
| 5 ## | |
| 6 ## This program is free software; you can redistribute it and/or modify | |
| 7 ## it under the terms of the GNU General Public License as published | |
| 8 ## by the Free Software Foundation; version 3 only. | |
| 9 ## | |
| 10 ## This program is distributed in the hope that it will be useful, | |
| 11 ## but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| 12 ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| 13 ## GNU General Public License for more details. | |
| 14 ## | |
| 15 | |
| 16 | |
| 17 from __future__ import unicode_literals | |
| 18 | |
| 19 from lxml import etree | |
| 20 from lxml.builder import E | |
| 21 from time import strptime, strftime | |
| 22 import epub | |
| 23 from argparse import ArgumentParser | |
| 24 | |
| 25 try: | |
| 26 from urllib.request import urlopen | |
| 27 from urllib.error import HTTPError | |
| 28 except ImportError: | |
| 29 from urllib2 import urlopen | |
| 30 from urllib2 import HTTPError | |
| 31 | |
| 32 | |
| 33 images_list = [] | |
| 34 mime_type = { | |
| 35 '.jpg': 'image/jpeg', | |
| 36 'jpeg': 'image/jpeg', | |
| 37 '.png': 'image/png', | |
| 38 '.gif': 'image/gif', | |
| 39 '.svg': 'image/svg+xml' | |
| 40 } | |
| 41 | |
| 42 | |
| 43 class Thread(object): | |
| 44 def __init__(self, op, replies): | |
| 45 self.op = op | |
| 46 self.replies = replies | |
| 47 self.title = op.title | |
| 48 self.author = op.author | |
| 49 | |
| 50 def render(self, only_op): | |
| 51 html = E.html( | |
| 52 E.head( | |
| 53 E.title(self.title), | |
| 54 E.link(href='story.css', type='text/css', rel='stylesheet') #TODO: convert it to a PI. | |
| 55 ), | |
| 56 E.body( | |
| 57 E.h1(self.title) | |
| 58 ), | |
| 59 xmlns='http://www.w3.org/1999/xhtml' | |
| 60 ) | |
| 61 | |
| 62 body = html.find('body') | |
| 63 body.append(self.op.render(display_title=False)) | |
| 64 | |
| 65 for reply in self.replies: | |
| 66 # Remove user answers if not wanted. | |
| 67 if only_op and not reply.is_op(self.op): | |
| 68 continue | |
| 69 | |
| 70 body.append(reply.render()) | |
| 71 | |
| 72 return html | |
| 73 | |
| 74 | |
| 75 class Post(object): | |
| 76 def __init__(self, title, author, date, image, content): | |
| 77 self.title = title | |
| 78 self.author = author | |
| 79 self.date = date | |
| 80 self.image = image | |
| 81 self.content = content | |
| 82 | |
| 83 def is_op(self, op): | |
| 84 return self.author.trip == op.author.trip | |
| 85 | |
| 86 def render(self, display_title=True): | |
| 87 if display_title: | |
| 88 title = E.h2(self.title) if self.title and display_title else E.h2('⁂') | |
| 89 else: | |
| 90 title = '' | |
| 91 | |
| 92 img = self.image.render() if self.image else '' | |
| 93 | |
| 94 p = E.p() | |
| 95 for item in self.content: | |
| 96 #TODO: remove useless attributes like onclick. | |
| 97 p.append(item) | |
| 98 | |
| 99 article = E.article( | |
| 100 title, | |
| 101 E.footer( | |
| 102 E.cite(self.author.render()), | |
| 103 ' ', | |
| 104 E.time(strftime('%y/%m/%d(%a)%H:%M', self.date), time=strftime('%y-%m-%dT%H:%M:%SZ', self.date)) | |
| 105 ), | |
| 106 img, | |
| 107 p | |
| 108 ) | |
| 109 return article | |
| 110 | |
| 111 | |
| 112 class Image(object): | |
| 113 def __init__(self, name, filesize, size, url): | |
| 114 self.name = name | |
| 115 self.filesize = filesize | |
| 116 self.size = size | |
| 117 self.url = url | |
| 118 | |
| 119 def render(self): | |
| 120 try: | |
| 121 url_file = urlopen(self.url) | |
| 122 except HTTPError: | |
| 123 return '' | |
| 124 with open(self.name, 'wb') as out: | |
| 125 out.write(url_file.read()) | |
| 126 images_list.append(self.name) | |
| 127 return E.img(src=self.name, alt=self.name) | |
| 128 | |
| 129 | |
| 130 class Author(object): | |
| 131 def __init__(self, name, trip, mail): | |
| 132 self.name = name | |
| 133 self.trip = trip | |
| 134 self.mail = mail | |
| 135 | |
| 136 def render(self): | |
| 137 return '{}{}'.format(self.name, self.trip) | |
| 138 | |
| 139 | |
| 140 def parse_post(root): | |
| 141 # We use the filesize element because it contains the image name. | |
| 142 label = root.find('label') | |
| 143 filesize = root.find('span[@class="filesize"]') | |
| 144 if filesize is not None: | |
| 145 a = filesize.getnext().getnext() | |
| 146 filesize = etree.tostring(filesize, method='text', encoding='UTF-8') | |
| 147 filesize = filesize.split() | |
| 148 name = filesize[7:-2] | |
| 149 name = b' '.join(name) | |
| 150 try: | |
| 151 name = name.decode('UTF-8') | |
| 152 except UnicodeDecodeError: | |
| 153 for i in range(-5, -42, -1): | |
| 154 char = name[i] if type(name[i]) is int else ord(name[i]) | |
| 155 if char & 0xc0 == 0xc0: | |
| 156 name = name[:i-1] + b'\xef\xbf\xbd' + name[-4:] | |
| 157 break | |
| 158 name = name.decode('UTF-8') | |
| 159 filesize, size = filesize[3][1:], filesize[5] | |
| 160 if a.tag == 'a': | |
| 161 url = a.get('href') | |
| 162 else: | |
| 163 url = a.find('img').get('src') | |
| 164 | |
| 165 image = Image(name, filesize, size, url) | |
| 166 else: | |
| 167 image = None | |
| 168 | |
| 169 label = root.find('label') | |
| 170 title = label.find('span[@class="filetitle"]') | |
| 171 title = title.text.strip() if title is not None else None | |
| 172 name = label.find('span[@class="postername"]') | |
| 173 if name is not None: | |
| 174 last = name | |
| 175 a = name.find('a') | |
| 176 if a is not None: | |
| 177 mail = a.get('href') | |
| 178 name = a.text | |
| 179 else: | |
| 180 mail = '' | |
| 181 name = name.text | |
| 182 else: | |
| 183 mail = '' | |
| 184 name = '' | |
| 185 | |
| 186 postertrip = label.find('span[@class="postertrip"]') | |
| 187 if postertrip is not None: | |
| 188 trip = postertrip.text | |
| 189 last = postertrip | |
| 190 else: | |
| 191 trip = '' | |
| 192 | |
| 193 author = Author(name, trip, mail) | |
| 194 | |
| 195 date = strptime(last.tail.strip(), '%y/%m/%d(%a)%H:%M') | |
| 196 | |
| 197 blockquote = root.find('blockquote') | |
| 198 content = [] | |
| 199 for item in blockquote: | |
| 200 if item is str and item.strip() == '': | |
| 201 continue | |
| 202 content.append(item) | |
| 203 | |
| 204 return Post(title, author, date, image, content) | |
| 205 | |
| 206 | |
| 207 def parse_thread(url): | |
| 208 tree = etree.parse(url, etree.HTMLParser()) | |
| 209 | |
| 210 root = tree.find('//form[@id="delform"]') | |
| 211 if root is None: | |
| 212 root = tree.find('//body') | |
| 213 op = parse_post(root) | |
| 214 | |
| 215 replies = [] | |
| 216 td = root.findall('.//td[@class="reply"]') | |
| 217 for reply in td: | |
| 218 replies.append(parse_post(reply)) | |
| 219 | |
| 220 return Thread(op, replies) | |
| 221 | |
| 222 | |
| 223 def main(url, forum, only_op, threads): | |
| 224 threads_list = [] | |
| 225 for thread in threads: | |
| 226 print('Rendering of thread №{}…'.format(thread)) | |
| 227 t = parse_thread(url.format(forum, thread)) | |
| 228 threads_list.append(t) | |
| 229 | |
| 230 html = t.render(only_op) | |
| 231 | |
| 232 # Use b mode as it allows us to directly dump UTF-8 data. | |
| 233 with open('{}.xhtml'.format(thread), 'wb') as f: | |
| 234 f.write(etree.tostring(html, pretty_print=True, xml_declaration=True, doctype='<!DOCTYPE html SYSTEM "/tmp/test.dtd">', encoding='UTF-8')) | |
| 235 | |
| 236 with epub.open('story.epub', 'w') as book: | |
| 237 t = threads_list[0] | |
| 238 | |
| 239 book.opf.metadata.add_title(t.title) | |
| 240 book.opf.metadata.add_creator(t.author.render()) | |
| 241 book.opf.metadata.add_date(strftime('%y-%m-%dT%H:%M:%SZ')) | |
| 242 book.opf.metadata.add_language('en') | |
| 243 | |
| 244 for thread in threads: | |
| 245 filename = '{}.xhtml'.format(thread) | |
| 246 manifest_item = epub.opf.ManifestItem(identifier='thread_{}'.format(thread), | |
| 247 href=filename, | |
| 248 media_type='application/xhtml+xml') | |
| 249 book.add_item(filename, manifest_item, True) | |
| 250 | |
| 251 for image in images_list: | |
| 252 extension = image[-4:] | |
| 253 manifest_item = epub.opf.ManifestItem(identifier='image_{}'.format(image), | |
| 254 href=image, | |
| 255 media_type=mime_type[extension]) | |
| 256 book.add_item(image, manifest_item, True) | |
| 257 | |
| 258 manifest_item = epub.opf.ManifestItem(identifier='style', | |
| 259 href='story.css', | |
| 260 media_type='text/css') | |
| 261 book.add_item('story.css', manifest_item) | |
| 262 | |
| 263 book.toc.title = t.title | |
| 264 nav_map = book.toc.nav_map | |
| 265 for thread in threads: | |
| 266 nav_point = epub.ncx.NavPoint() | |
| 267 nav_point.identifier = 'thread_%d' % thread | |
| 268 nav_point.add_label('Thread №%d' % thread) | |
| 269 nav_point.src = '%d.xhtml' % thread | |
| 270 nav_map.nav_point.append(nav_point) | |
| 271 | |
| 272 | |
| 273 if __name__ == '__main__': | |
| 274 parser = ArgumentParser(description='Download and convert THP stories.') | |
| 275 | |
| 276 parser.add_argument('threads', metavar='THREADS', nargs='+', type=int, help='List of the threads of the story.') | |
| 277 parser.add_argument('-u', '--url', metavar='URL', default='http://www.touhou-project.com/{}/res/{}.html', help='URL pattern from which the story will be downloaded, with the first {} as the forum, and the second as the thread.') | |
| 278 parser.add_argument('-f', '--forum', metavar='FORUM', default='sdm', help='The name of the forum (example: sdm, th, etc.).') | |
| 279 parser.add_argument('-o', '--only-op', action='store_true', help='Include only posts made by the original poster.') | |
| 280 | |
| 281 args = parser.parse_args() | |
| 282 | |
| 283 main(args.url, args.forum, args.only_op, args.threads) |
