mirror of
https://github.com/wassname/FanFicFare.git
synced 2026-09-13 12:11:20 +08:00
Compare commits
387
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd293909c0 | ||
|
|
27ec7228ae | ||
|
|
d8bf424270 | ||
|
|
5a861cafc6 | ||
|
|
0700104d7e | ||
|
|
6a525ca9fb | ||
|
|
4306bfc301 | ||
|
|
e4ce51787c | ||
|
|
a9cecbbe4f | ||
|
|
f0445f106c | ||
|
|
a89cec6844 | ||
|
|
baaf151fa2 | ||
|
|
0bea4afd01 | ||
|
|
6a83131a99 | ||
|
|
076744f2d4 | ||
|
|
668946adcc | ||
|
|
b10dca9ae5 | ||
|
|
3f9abb8c07 | ||
|
|
1274f37668 | ||
|
|
99891ae176 | ||
|
|
35bb2a49ba | ||
|
|
f0006a7db7 | ||
|
|
e1e7a79006 | ||
|
|
ba9b82657a | ||
|
|
71e33ed8ea | ||
|
|
640c676cc5 | ||
|
|
f29844fe14 | ||
|
|
b6c0917bcc | ||
|
|
1c50d7c918 | ||
|
|
2d7790fc83 | ||
|
|
f17d837fc8 | ||
|
|
6134ba7e79 | ||
|
|
56157546be | ||
|
|
baa037254c | ||
|
|
7cf76b7a47 | ||
|
|
b69ede76bb | ||
|
|
986c7181a2 | ||
|
|
994b5aa676 | ||
|
|
50c1cd2d1d | ||
|
|
ed004e8637 | ||
|
|
af65982c9a | ||
|
|
5acf21119a | ||
|
|
d001799372 | ||
|
|
dac306d0ba | ||
|
|
c6e06903c0 | ||
|
|
b19385ada3 | ||
|
|
52fc8633e5 | ||
|
|
a029b3de5e | ||
|
|
54b25ee0b7 | ||
|
|
2973f1b526 | ||
|
|
1ffde4f8ef | ||
|
|
beae2b560f | ||
|
|
1a71a43ca8 | ||
|
|
346ea6fcda | ||
|
|
75895835cf | ||
|
|
c3e5d59215 | ||
|
|
8eda8d36ec | ||
|
|
7d9b5f6412 | ||
|
|
d7ab4d7011 | ||
|
|
2505f040be | ||
|
|
5a7c9f3ffd | ||
|
|
779f615a7a | ||
|
|
7a58ea13ed | ||
|
|
c9b0686e9b | ||
|
|
e26a05491d | ||
|
|
d1680a74dc | ||
|
|
9b17e0c396 | ||
|
|
e73284d3cd | ||
|
|
7529a24521 | ||
|
|
78abc1ae74 | ||
|
|
fb8ba4147c | ||
|
|
62e0d41a7b | ||
|
|
8b0cef709f | ||
|
|
5681d5e2bd | ||
|
|
5162cb4ddb | ||
|
|
45382ad424 | ||
|
|
37fce63735 | ||
|
|
927ea0298c | ||
|
|
2079952737 | ||
|
|
dd53a33551 | ||
|
|
2a86c3c9ab | ||
|
|
ce379a0700 | ||
|
|
ac6b790b7d | ||
|
|
f587731797 | ||
|
|
afff98a9af | ||
|
|
8dcb178ad0 | ||
|
|
237e95933c | ||
|
|
f7b5feede6 | ||
|
|
2375e0b6b0 | ||
|
|
ee7bb5775b | ||
|
|
fdcf1ecf3a | ||
|
|
52ff63dbc3 | ||
|
|
092a0bcea0 | ||
|
|
6cbc009666 | ||
|
|
a8a049df79 | ||
|
|
d04380092b | ||
|
|
7bd141ba59 | ||
|
|
f76c0ac823 | ||
|
|
35c4f0c609 | ||
|
|
413c24d149 | ||
|
|
ce630d98bf | ||
|
|
537af7e4c4 | ||
|
|
36eb6c2e2c | ||
|
|
6ee4be0236 | ||
|
|
f7c321a36e | ||
|
|
fbcc582541 | ||
|
|
d561a9a2e6 | ||
|
|
ae103ff128 | ||
|
|
102cc3498f | ||
|
|
0f6f24123b | ||
|
|
ee2db5549d | ||
|
|
445ff17981 | ||
|
|
9850fc0805 | ||
|
|
8bcf2e53db | ||
|
|
c944d837fb | ||
|
|
82efb462aa | ||
|
|
d455a97e3b | ||
|
|
4ac42526bd | ||
|
|
81e6134139 | ||
|
|
af1f69facc | ||
|
|
db513cb62a | ||
|
|
f9c173f12f | ||
|
|
102a55131d | ||
|
|
851d3df751 | ||
|
|
307ff464f1 | ||
|
|
aa2b1961e2 | ||
|
|
e3ceeb937f | ||
|
|
74ac6fb348 | ||
|
|
6e52a5b096 | ||
|
|
3aaf9ec91d | ||
|
|
d4280e84d1 | ||
|
|
6bbe59c76b | ||
|
|
2c6b0f9aa7 | ||
|
|
8fb62499cd | ||
|
|
dbda2f96a6 | ||
|
|
c9a69ff3c8 | ||
|
|
56ac912706 | ||
|
|
b75c9d4946 | ||
|
|
cbc8cadd91 | ||
|
|
f634629b98 | ||
|
|
c0ea637aef | ||
|
|
5511b4fd28 | ||
|
|
a9fa06fd4d | ||
|
|
d5c38927fb | ||
|
|
97e257d22a | ||
|
|
6c2f025ca1 | ||
|
|
71981c157f | ||
|
|
68c9e3cb8e | ||
|
|
dc0a4c2b25 | ||
|
|
33b15fe7c1 | ||
|
|
63faea59d8 | ||
|
|
8905a08862 | ||
|
|
1d1e0899ee | ||
|
|
18f26de557 | ||
|
|
32bae4c47b | ||
|
|
6e41e5d59a | ||
|
|
f9f7c6ea2a | ||
|
|
9b98cb8cce | ||
|
|
5ba3a36518 | ||
|
|
672cc09b0d | ||
|
|
fed75f6684 | ||
|
|
be645e012b | ||
|
|
1cf87eae3a | ||
|
|
2cd89b2e55 | ||
|
|
a3402170ad | ||
|
|
141ed90d74 | ||
|
|
33e520fef1 | ||
|
|
7e6963bbaf | ||
|
|
4a4ec3863d | ||
|
|
070096e9cb | ||
|
|
88bfd4ec58 | ||
|
|
c96381a0b4 | ||
|
|
f9466ef491 | ||
|
|
8e02856b55 | ||
|
|
8d0a29270a | ||
|
|
06920119db | ||
|
|
75fb648ace | ||
|
|
e4ad7edb48 | ||
|
|
ca0082797c | ||
|
|
fe684926b1 | ||
|
|
f920f505c6 | ||
|
|
3ca4447279 | ||
|
|
ac11984c97 | ||
|
|
09f3f62792 | ||
|
|
eefdaf8518 | ||
|
|
0e7a211ce9 | ||
|
|
9863616eae | ||
|
|
5afd7b8c06 | ||
|
|
0e7ef6d804 | ||
|
|
2a154c13ac | ||
|
|
24120a73e7 | ||
|
|
fddd146b39 | ||
|
|
8bf22729fe | ||
|
|
e202105248 | ||
|
|
4f0ce05006 | ||
|
|
57534c26c5 | ||
|
|
df3b95aa6f | ||
|
|
fd2d4a935d | ||
|
|
71ae63e1ea | ||
|
|
2a353a74f9 | ||
|
|
aa629c2c39 | ||
|
|
520a295f88 | ||
|
|
951bc3b030 | ||
|
|
09d37c4426 | ||
|
|
b76e812eaa | ||
|
|
ecabbd3af2 | ||
|
|
152ee61ed4 | ||
|
|
f2283a2052 | ||
|
|
fb5740c549 | ||
|
|
7133189601 | ||
|
|
6d24caee1e | ||
|
|
583275f037 | ||
|
|
50b80b6d68 | ||
|
|
f7857ad6dc | ||
|
|
a3f75cb3eb | ||
|
|
94063d478c | ||
|
|
f25eae83d3 | ||
|
|
433e371b74 | ||
|
|
def2937327 | ||
|
|
e99adf3fa9 | ||
|
|
a05bf24ed3 | ||
|
|
aa58f1c1ee | ||
|
|
35aeae59f2 | ||
|
|
6d5785a5ac | ||
|
|
07fbb8c5d5 | ||
|
|
ac74918005 | ||
|
|
b85a5ab95b | ||
|
|
215552af00 | ||
|
|
7f0c989319 | ||
|
|
d38360be6b | ||
|
|
bf52020876 | ||
|
|
58b95af0da | ||
|
|
df9e877865 | ||
|
|
a88d4cac50 | ||
|
|
756b6944fc | ||
|
|
6d2f657729 | ||
|
|
9b2450a609 | ||
|
|
36d4b9afcf | ||
|
|
d90c9ebced | ||
|
|
ecd1690b70 | ||
|
|
87a57cc286 | ||
|
|
d91f2e74fd | ||
|
|
d3b1ddcda9 | ||
|
|
a7b80388dd | ||
|
|
9515ff00be | ||
|
|
605c4c40fc | ||
|
|
3e1f0dd53b | ||
|
|
0e28d86a7b | ||
|
|
94669a2179 | ||
|
|
547411666d | ||
|
|
150316f460 | ||
|
|
f75abaad72 | ||
|
|
bdaea79875 | ||
|
|
7420d27652 | ||
|
|
1426626d0a | ||
|
|
666e9666cd | ||
|
|
1c2c1a5d62 | ||
|
|
7c5aaa36d5 | ||
|
|
13b96946d1 | ||
|
|
1119ba52a0 | ||
|
|
b058477b97 | ||
|
|
1f303013bd | ||
|
|
302a8f12a3 | ||
|
|
3e381dd33f | ||
|
|
bd82311d51 | ||
|
|
efb521c829 | ||
|
|
925324bd13 | ||
|
|
1d1ed1ef71 | ||
|
|
389fc010f2 | ||
|
|
5ab5991d6e | ||
|
|
8c606e45fd | ||
|
|
e645c054d8 | ||
|
|
e6ebd956f8 | ||
|
|
ef5e7700b9 | ||
|
|
53e2a0f204 | ||
|
|
0e9d992e13 | ||
|
|
790aca39da | ||
|
|
7064873b4e | ||
|
|
95e7ba636d | ||
|
|
fe557bbce5 | ||
|
|
783a614345 | ||
|
|
a32ccf7dac | ||
|
|
d3abfbf5d6 | ||
|
|
d95c72f447 | ||
|
|
e05749678e | ||
|
|
f7d94105ee | ||
|
|
50b0573033 | ||
|
|
48b882c745 | ||
|
|
3fdd079b97 | ||
|
|
e674f7c7df | ||
|
|
8f58164dde | ||
|
|
4c99fdbcc1 | ||
|
|
c6b79cb57b | ||
|
|
f114a2438e | ||
|
|
2f0bb31e24 | ||
|
|
30455bfda2 | ||
|
|
cfb218f21e | ||
|
|
617dc21eb9 | ||
|
|
2d7f6c2e5c | ||
|
|
719aed32a9 | ||
|
|
ccef466090 | ||
|
|
708f750bab | ||
|
|
3b0a2639e7 | ||
|
|
edb4f5a3bd | ||
|
|
f3571959df | ||
|
|
ebcce7e42d | ||
|
|
cf0d1bf09b | ||
|
|
ecdeef4b3b | ||
|
|
dd483db783 | ||
|
|
348786f073 | ||
|
|
5549786946 | ||
|
|
f584b3f1e8 | ||
|
|
df6b85b723 | ||
|
|
26f6ddabd9 | ||
|
|
22d0989d0a | ||
|
|
9b6fdb4540 | ||
|
|
e3b4769bfb | ||
|
|
5618d8036b | ||
|
|
dbec6c95b2 | ||
|
|
c77498e6ea | ||
|
|
99ba1b48aa | ||
|
|
bdf429b41c | ||
|
|
981a922d00 | ||
|
|
b51dd549f5 | ||
|
|
35065593af | ||
|
|
696223a456 | ||
|
|
30b914788c | ||
|
|
ed4ff4b6ab | ||
|
|
3f40df82dd | ||
|
|
7c252e83a1 | ||
|
|
d4c18fe1a8 | ||
|
|
b2ea9c3e73 | ||
|
|
33d1fabd11 | ||
|
|
c326aa47b0 | ||
|
|
d67668de23 | ||
|
|
363c211401 | ||
|
|
379efc34f1 | ||
|
|
778deaea00 | ||
|
|
33c3f0b4fd | ||
|
|
5bca3106df | ||
|
|
c056ed0740 | ||
|
|
25cbab225c | ||
|
|
4e53481d7d | ||
|
|
b9a72d372d | ||
|
|
7f8d7a7dbb | ||
|
|
dafa33c64e | ||
|
|
275a1dd6ad | ||
|
|
f8701a9e8d | ||
|
|
466d5f4ee3 | ||
|
|
ca4b09d69d | ||
|
|
f75910ce7d | ||
|
|
773ff3c03c | ||
|
|
9434431c7b | ||
|
|
3be9b3331e | ||
|
|
84fea5b896 | ||
|
|
fe67201f95 | ||
|
|
ccb3fb36c5 | ||
|
|
31c68ff796 | ||
|
|
950807747c | ||
|
|
b22d961e73 | ||
|
|
2b2e7d52ec | ||
|
|
48d561de1a | ||
|
|
1740e0da48 | ||
|
|
ed9e2992c0 | ||
|
|
c123e77221 | ||
|
|
5143a2187a | ||
|
|
1f897843e0 | ||
|
|
ef97755ed3 | ||
|
|
33855b7877 | ||
|
|
615228a2ac | ||
|
|
028c2df521 | ||
|
|
2352ba6dfc | ||
|
|
59697f1a75 | ||
|
|
a01e0e2ed6 | ||
|
|
8c621d6a59 | ||
|
|
f55e6d3c05 | ||
|
|
d2d183430d | ||
|
|
6e73e5dc11 | ||
|
|
fd2ccb30ff | ||
|
|
41caae6226 | ||
|
|
5adcf131b3 | ||
|
|
bcf82e2632 | ||
|
|
259c0f3650 | ||
|
|
861d1561d9 | ||
|
|
6ef95c634d | ||
|
|
a9748061f0 | ||
|
|
c0459faa43 |
@@ -1,20 +1,22 @@
|
||||
# fanfictionloader ffd-retief
|
||||
application: fanfictionloader
|
||||
version: 4-0-7
|
||||
runtime: python
|
||||
# ffd-retief-hrd fanfictiondownloader
|
||||
application: ffd-retief-hrd
|
||||
version: 4-3-3
|
||||
runtime: python27
|
||||
api_version: 1
|
||||
threadsafe: true
|
||||
|
||||
handlers:
|
||||
|
||||
- url: /r3m0v3r.*
|
||||
script: utils/remover.py
|
||||
script: utils.remover.app
|
||||
login: admin
|
||||
|
||||
- url: /tally.*
|
||||
script: utils/tally.py
|
||||
script: utils.tally.app
|
||||
login: admin
|
||||
|
||||
- url: /fdownloadtask
|
||||
script: main.py
|
||||
script: main.app
|
||||
login: admin
|
||||
|
||||
- url: /css
|
||||
@@ -31,7 +33,14 @@ handlers:
|
||||
upload: static/favicon\.ico
|
||||
|
||||
- url: /.*
|
||||
script: main.py
|
||||
script: main.app
|
||||
|
||||
builtins:
|
||||
- datastore_admin: on
|
||||
|
||||
libraries:
|
||||
- name: django
|
||||
version: "1.2"
|
||||
|
||||
- name: PIL
|
||||
version: "1.1.7"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2011, Jim Miller'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
# The class that all Interface Action plugin wrappers must inherit from
|
||||
from calibre.customize import InterfaceActionBase
|
||||
|
||||
## Apparently the name for this class doesn't matter--it was still
|
||||
## 'demo' for the first few versions.
|
||||
class FanFictionDownLoaderBase(InterfaceActionBase):
|
||||
'''
|
||||
This class is a simple wrapper that provides information about the
|
||||
actual plugin class. The actual interface plugin class is called
|
||||
InterfacePlugin and is defined in the ffdl_plugin.py file, as
|
||||
specified in the actual_plugin field below.
|
||||
|
||||
The reason for having two classes is that it allows the command line
|
||||
calibre utilities to run without needing to load the GUI libraries.
|
||||
'''
|
||||
name = 'FanFictionDownLoader'
|
||||
description = 'UI plugin to download FanFiction stories from various sites.'
|
||||
supported_platforms = ['windows', 'osx', 'linux']
|
||||
author = 'Jim Miller'
|
||||
version = (1, 5, 2)
|
||||
minimum_calibre_version = (0, 8, 30)
|
||||
|
||||
#: This field defines the GUI plugin class that contains all the code
|
||||
#: that actually does something. Its format is module_path:class_name
|
||||
#: The specified class must be defined in the specified module.
|
||||
actual_plugin = 'calibre_plugins.fanfictiondownloader_plugin.ffdl_plugin:FanFictionDownLoaderPlugin'
|
||||
|
||||
def is_customizable(self):
|
||||
'''
|
||||
This method must return True to enable customization via
|
||||
Preferences->Plugins
|
||||
'''
|
||||
return True
|
||||
|
||||
def config_widget(self):
|
||||
'''
|
||||
Implement this method and :meth:`save_settings` in your plugin to
|
||||
use a custom configuration dialog.
|
||||
|
||||
This method, if implemented, must return a QWidget. The widget can have
|
||||
an optional method validate() that takes no arguments and is called
|
||||
immediately after the user clicks OK. Changes are applied if and only
|
||||
if the method returns True.
|
||||
|
||||
If for some reason you cannot perform the configuration at this time,
|
||||
return a tuple of two strings (message, details), these will be
|
||||
displayed as a warning dialog to the user and the process will be
|
||||
aborted.
|
||||
|
||||
The base class implementation of this method raises NotImplementedError
|
||||
so by default no user configuration is possible.
|
||||
'''
|
||||
# It is important to put this import statement here rather than at the
|
||||
# top of the module as importing the config class will also cause the
|
||||
# GUI libraries to be loaded, which we do not want when using calibre
|
||||
# from the command line
|
||||
from calibre_plugins.fanfictiondownloader_plugin.config import ConfigWidget
|
||||
return ConfigWidget(self.actual_plugin_)
|
||||
|
||||
def save_settings(self, config_widget):
|
||||
'''
|
||||
Save the settings specified by the user with config_widget.
|
||||
|
||||
:param config_widget: The widget returned by :meth:`config_widget`.
|
||||
'''
|
||||
config_widget.save_settings()
|
||||
|
||||
# Apply the changes
|
||||
ac = self.actual_plugin_
|
||||
if ac is not None:
|
||||
ac.apply_settings()
|
||||
|
||||
# For testing, run from command line with this:
|
||||
# calibre-debug -e __init__.py
|
||||
#
|
||||
if __name__ == '__main__':
|
||||
from PyQt4.Qt import QApplication
|
||||
from calibre.gui2.preferences import test_widget
|
||||
app = QApplication([])
|
||||
test_widget('Advanced', 'Plugins')
|
||||
@@ -0,0 +1,20 @@
|
||||
<hr />
|
||||
|
||||
<p>Created by Jim Miller, borrowing heavily from Grant Drake's
|
||||
'<a href="http://www.mobileread.com/forums/showthread.php?t=134856">Reading List</a>',
|
||||
'<a href="http://www.mobileread.com/forums/showthread.php?t=126727">Extract ISBN</a>' and
|
||||
'<a href="http://www.mobileread.com/forums/showthread.php?t=134000">Count Pages</a>'
|
||||
plugins.</p>
|
||||
|
||||
<p>
|
||||
Calibre officially distributes plugins from the mobileread.com forum site.
|
||||
The official distro channel for this plugin is there: <a href="http://www.mobileread.com/forums/showthread.php?t=163261">FanFictionDownLoader</a>
|
||||
</p>
|
||||
|
||||
<p> I also monitor the
|
||||
<a href="http://groups.google.com/group/fanfic-downloader">general users
|
||||
group</a> for the downloader. That covers the web application and CLI, too.
|
||||
</p>
|
||||
|
||||
The source for this plugin is available
|
||||
<a href="http://code.google.com/p/fanficdownloader/source/checkout">here</a>.
|
||||
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2011, Grant Drake <grant.drake@gmail.com>'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import os
|
||||
from PyQt4 import QtGui
|
||||
from PyQt4.Qt import (Qt, QIcon, QPixmap, QLabel, QDialog, QHBoxLayout,
|
||||
QTableWidgetItem, QFont, QLineEdit, QComboBox,
|
||||
QVBoxLayout, QDialogButtonBox, QStyledItemDelegate, QDateTime)
|
||||
from calibre.constants import iswindows
|
||||
from calibre.gui2 import gprefs, error_dialog, UNDEFINED_QDATETIME
|
||||
from calibre.gui2.actions import menu_action_unique_name
|
||||
from calibre.gui2.keyboard import ShortcutConfig
|
||||
from calibre.utils.config import config_dir
|
||||
from calibre.utils.date import now, format_date, qt_to_dt, UNDEFINED_DATE
|
||||
|
||||
# Global definition of our plugin name. Used for common functions that require this.
|
||||
plugin_name = None
|
||||
# Global definition of our plugin resources. Used to share between the xxxAction and xxxBase
|
||||
# classes if you need any zip images to be displayed on the configuration dialog.
|
||||
plugin_icon_resources = {}
|
||||
|
||||
|
||||
def set_plugin_icon_resources(name, resources):
|
||||
'''
|
||||
Set our global store of plugin name and icon resources for sharing between
|
||||
the InterfaceAction class which reads them and the ConfigWidget
|
||||
if needed for use on the customization dialog for this plugin.
|
||||
'''
|
||||
global plugin_icon_resources, plugin_name
|
||||
plugin_name = name
|
||||
plugin_icon_resources = resources
|
||||
|
||||
|
||||
def get_icon(icon_name):
|
||||
'''
|
||||
Retrieve a QIcon for the named image from the zip file if it exists,
|
||||
or if not then from Calibre's image cache.
|
||||
'''
|
||||
if icon_name:
|
||||
pixmap = get_pixmap(icon_name)
|
||||
if pixmap is None:
|
||||
# Look in Calibre's cache for the icon
|
||||
return QIcon(I(icon_name))
|
||||
else:
|
||||
return QIcon(pixmap)
|
||||
return QIcon()
|
||||
|
||||
|
||||
def get_pixmap(icon_name):
|
||||
'''
|
||||
Retrieve a QPixmap for the named image
|
||||
Any icons belonging to the plugin must be prefixed with 'images/'
|
||||
'''
|
||||
global plugin_icon_resources, plugin_name
|
||||
|
||||
if not icon_name.startswith('images/'):
|
||||
# We know this is definitely not an icon belonging to this plugin
|
||||
pixmap = QPixmap()
|
||||
pixmap.load(I(icon_name))
|
||||
return pixmap
|
||||
|
||||
# Check to see whether the icon exists as a Calibre resource
|
||||
# This will enable skinning if the user stores icons within a folder like:
|
||||
# ...\AppData\Roaming\calibre\resources\images\Plugin Name\
|
||||
if plugin_name:
|
||||
local_images_dir = get_local_images_dir(plugin_name)
|
||||
local_image_path = os.path.join(local_images_dir, icon_name.replace('images/', ''))
|
||||
if os.path.exists(local_image_path):
|
||||
pixmap = QPixmap()
|
||||
pixmap.load(local_image_path)
|
||||
return pixmap
|
||||
|
||||
# As we did not find an icon elsewhere, look within our zip resources
|
||||
if icon_name in plugin_icon_resources:
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(plugin_icon_resources[icon_name])
|
||||
return pixmap
|
||||
return None
|
||||
|
||||
|
||||
def get_local_images_dir(subfolder=None):
|
||||
'''
|
||||
Returns a path to the user's local resources/images folder
|
||||
If a subfolder name parameter is specified, appends this to the path
|
||||
'''
|
||||
images_dir = os.path.join(config_dir, 'resources/images')
|
||||
if subfolder:
|
||||
images_dir = os.path.join(images_dir, subfolder)
|
||||
if iswindows:
|
||||
images_dir = os.path.normpath(images_dir)
|
||||
return images_dir
|
||||
|
||||
|
||||
def create_menu_item(ia, parent_menu, menu_text, image=None, tooltip=None,
|
||||
shortcut=(), triggered=None, is_checked=None):
|
||||
'''
|
||||
Create a menu action with the specified criteria and action
|
||||
Note that if no shortcut is specified, will not appear in Preferences->Keyboard
|
||||
This method should only be used for actions which either have no shortcuts,
|
||||
or register their menus only once. Use create_menu_action_unique for all else.
|
||||
'''
|
||||
if shortcut is not None:
|
||||
if len(shortcut) == 0:
|
||||
shortcut = ()
|
||||
else:
|
||||
shortcut = _(shortcut)
|
||||
ac = ia.create_action(spec=(menu_text, None, tooltip, shortcut),
|
||||
attr=menu_text)
|
||||
if image:
|
||||
ac.setIcon(get_icon(image))
|
||||
if triggered is not None:
|
||||
ac.triggered.connect(triggered)
|
||||
if is_checked is not None:
|
||||
ac.setCheckable(True)
|
||||
if is_checked:
|
||||
ac.setChecked(True)
|
||||
|
||||
parent_menu.addAction(ac)
|
||||
return ac
|
||||
|
||||
|
||||
def create_menu_action_unique(ia, parent_menu, menu_text, image=None, tooltip=None,
|
||||
shortcut=None, triggered=None, is_checked=None, shortcut_name=None,
|
||||
unique_name=None):
|
||||
'''
|
||||
Create a menu action with the specified criteria and action, using the new
|
||||
InterfaceAction.create_menu_action() function which ensures that regardless of
|
||||
whether a shortcut is specified it will appear in Preferences->Keyboard
|
||||
'''
|
||||
orig_shortcut = shortcut
|
||||
kb = ia.gui.keyboard
|
||||
if unique_name is None:
|
||||
unique_name = menu_text
|
||||
if not shortcut == False:
|
||||
full_unique_name = menu_action_unique_name(ia, unique_name)
|
||||
if full_unique_name in kb.shortcuts:
|
||||
shortcut = False
|
||||
else:
|
||||
if shortcut is not None and not shortcut == False:
|
||||
if len(shortcut) == 0:
|
||||
shortcut = None
|
||||
else:
|
||||
shortcut = _(shortcut)
|
||||
|
||||
if shortcut_name is None:
|
||||
shortcut_name = menu_text.replace('&','')
|
||||
|
||||
ac = ia.create_menu_action(parent_menu, unique_name, menu_text, icon=None, shortcut=shortcut,
|
||||
description=tooltip, triggered=triggered, shortcut_name=shortcut_name)
|
||||
if shortcut == False and not orig_shortcut == False:
|
||||
if ac.calibre_shortcut_unique_name in ia.gui.keyboard.shortcuts:
|
||||
kb.replace_action(ac.calibre_shortcut_unique_name, ac)
|
||||
if image:
|
||||
ac.setIcon(get_icon(image))
|
||||
if is_checked is not None:
|
||||
ac.setCheckable(True)
|
||||
if is_checked:
|
||||
ac.setChecked(True)
|
||||
return ac
|
||||
|
||||
|
||||
def swap_author_names(author):
|
||||
if author.find(',') == -1:
|
||||
return author
|
||||
name_parts = author.strip().partition(',')
|
||||
return name_parts[2].strip() + ' ' + name_parts[0]
|
||||
|
||||
|
||||
def get_library_uuid(db):
|
||||
try:
|
||||
library_uuid = db.library_id
|
||||
except:
|
||||
library_uuid = ''
|
||||
return library_uuid
|
||||
|
||||
|
||||
class ImageLabel(QLabel):
|
||||
|
||||
def __init__(self, parent, icon_name, size=16):
|
||||
QLabel.__init__(self, parent)
|
||||
pixmap = get_pixmap(icon_name)
|
||||
self.setPixmap(pixmap)
|
||||
self.setMaximumSize(size, size)
|
||||
self.setScaledContents(True)
|
||||
|
||||
|
||||
class ImageTitleLayout(QHBoxLayout):
|
||||
'''
|
||||
A reusable layout widget displaying an image followed by a title
|
||||
'''
|
||||
def __init__(self, parent, icon_name, title):
|
||||
QHBoxLayout.__init__(self)
|
||||
title_image_label = QLabel(parent)
|
||||
pixmap = get_pixmap(icon_name)
|
||||
if pixmap is None:
|
||||
pixmap = get_pixmap('library.png')
|
||||
# error_dialog(parent, _('Restart required'),
|
||||
# _('You must restart Calibre before using this plugin!'), show=True)
|
||||
else:
|
||||
title_image_label.setPixmap(pixmap)
|
||||
title_image_label.setMaximumSize(32, 32)
|
||||
title_image_label.setScaledContents(True)
|
||||
self.addWidget(title_image_label)
|
||||
|
||||
title_font = QFont()
|
||||
title_font.setPointSize(16)
|
||||
shelf_label = QLabel(title, parent)
|
||||
shelf_label.setFont(title_font)
|
||||
self.addWidget(shelf_label)
|
||||
self.insertStretch(-1)
|
||||
|
||||
|
||||
class SizePersistedDialog(QDialog):
|
||||
'''
|
||||
This dialog is a base class for any dialogs that want their size/position
|
||||
restored when they are next opened.
|
||||
'''
|
||||
def __init__(self, parent, unique_pref_name):
|
||||
QDialog.__init__(self, parent)
|
||||
self.unique_pref_name = unique_pref_name
|
||||
self.geom = gprefs.get(unique_pref_name, None)
|
||||
self.finished.connect(self.dialog_closing)
|
||||
|
||||
def resize_dialog(self):
|
||||
if self.geom is None:
|
||||
self.resize(self.sizeHint())
|
||||
else:
|
||||
self.restoreGeometry(self.geom)
|
||||
|
||||
def dialog_closing(self, result):
|
||||
geom = bytearray(self.saveGeometry())
|
||||
gprefs[self.unique_pref_name] = geom
|
||||
|
||||
|
||||
class ReadOnlyTableWidgetItem(QTableWidgetItem):
|
||||
|
||||
def __init__(self, text):
|
||||
if text is None:
|
||||
text = ''
|
||||
QTableWidgetItem.__init__(self, text, QtGui.QTableWidgetItem.UserType)
|
||||
self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled)
|
||||
|
||||
|
||||
class RatingTableWidgetItem(QTableWidgetItem):
|
||||
|
||||
def __init__(self, rating, is_read_only=False):
|
||||
QTableWidgetItem.__init__(self, '', QtGui.QTableWidgetItem.UserType)
|
||||
self.setData(Qt.DisplayRole, rating)
|
||||
if is_read_only:
|
||||
self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled)
|
||||
|
||||
|
||||
class DateTableWidgetItem(QTableWidgetItem):
|
||||
|
||||
def __init__(self, date_read, is_read_only=False, default_to_today=False):
|
||||
if date_read == UNDEFINED_DATE and default_to_today:
|
||||
date_read = now()
|
||||
if is_read_only:
|
||||
QTableWidgetItem.__init__(self, format_date(date_read, None), QtGui.QTableWidgetItem.UserType)
|
||||
self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled)
|
||||
else:
|
||||
QTableWidgetItem.__init__(self, '', QtGui.QTableWidgetItem.UserType)
|
||||
self.setData(Qt.DisplayRole, QDateTime(date_read))
|
||||
|
||||
|
||||
class NoWheelComboBox(QComboBox):
|
||||
|
||||
def wheelEvent (self, event):
|
||||
# Disable the mouse wheel on top of the combo box changing selection as plays havoc in a grid
|
||||
event.ignore()
|
||||
|
||||
|
||||
class CheckableTableWidgetItem(QTableWidgetItem):
|
||||
|
||||
def __init__(self, checked=False, is_tristate=False):
|
||||
QTableWidgetItem.__init__(self, '')
|
||||
self.setFlags(Qt.ItemFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled ))
|
||||
if is_tristate:
|
||||
self.setFlags(self.flags() | Qt.ItemIsTristate)
|
||||
if checked:
|
||||
self.setCheckState(Qt.Checked)
|
||||
else:
|
||||
if is_tristate and checked is None:
|
||||
self.setCheckState(Qt.PartiallyChecked)
|
||||
else:
|
||||
self.setCheckState(Qt.Unchecked)
|
||||
|
||||
def get_boolean_value(self):
|
||||
'''
|
||||
Return a boolean value indicating whether checkbox is checked
|
||||
If this is a tristate checkbox, a partially checked value is returned as None
|
||||
'''
|
||||
if self.checkState() == Qt.PartiallyChecked:
|
||||
return None
|
||||
else:
|
||||
return self.checkState() == Qt.Checked
|
||||
|
||||
|
||||
class TextIconWidgetItem(QTableWidgetItem):
|
||||
|
||||
def __init__(self, text, icon):
|
||||
QTableWidgetItem.__init__(self, text)
|
||||
if icon:
|
||||
self.setIcon(icon)
|
||||
|
||||
|
||||
class ReadOnlyTextIconWidgetItem(ReadOnlyTableWidgetItem):
|
||||
|
||||
def __init__(self, text, icon):
|
||||
ReadOnlyTableWidgetItem.__init__(self, text)
|
||||
if icon:
|
||||
self.setIcon(icon)
|
||||
|
||||
|
||||
class ReadOnlyLineEdit(QLineEdit):
|
||||
|
||||
def __init__(self, text, parent):
|
||||
if text is None:
|
||||
text = ''
|
||||
QLineEdit.__init__(self, text, parent)
|
||||
self.setEnabled(False)
|
||||
|
||||
|
||||
class KeyValueComboBox(QComboBox):
|
||||
|
||||
def __init__(self, parent, values, selected_key):
|
||||
QComboBox.__init__(self, parent)
|
||||
self.values = values
|
||||
self.populate_combo(selected_key)
|
||||
|
||||
def populate_combo(self, selected_key):
|
||||
self.clear()
|
||||
selected_idx = idx = -1
|
||||
for key, value in self.values.iteritems():
|
||||
idx = idx + 1
|
||||
self.addItem(value)
|
||||
if key == selected_key:
|
||||
selected_idx = idx
|
||||
self.setCurrentIndex(selected_idx)
|
||||
|
||||
def selected_key(self):
|
||||
for key, value in self.values.iteritems():
|
||||
if value == unicode(self.currentText()).strip():
|
||||
return key
|
||||
|
||||
|
||||
class CustomColumnComboBox(QComboBox):
|
||||
|
||||
def __init__(self, parent, custom_columns, selected_column, initial_items=['']):
|
||||
QComboBox.__init__(self, parent)
|
||||
self.populate_combo(custom_columns, selected_column, initial_items)
|
||||
|
||||
def populate_combo(self, custom_columns, selected_column, initial_items=['']):
|
||||
self.clear()
|
||||
self.column_names = initial_items
|
||||
if len(initial_items) > 0:
|
||||
self.addItems(initial_items)
|
||||
selected_idx = 0
|
||||
for idx, value in enumerate(initial_items):
|
||||
if value == selected_column:
|
||||
selected_idx = idx
|
||||
for key in sorted(custom_columns.keys()):
|
||||
self.column_names.append(key)
|
||||
self.addItem('%s (%s)'%(key, custom_columns[key]['name']))
|
||||
if key == selected_column:
|
||||
selected_idx = len(self.column_names) - 1
|
||||
self.setCurrentIndex(selected_idx)
|
||||
|
||||
def get_selected_column(self):
|
||||
return self.column_names[self.currentIndex()]
|
||||
|
||||
|
||||
class KeyboardConfigDialog(SizePersistedDialog):
|
||||
'''
|
||||
This dialog is used to allow editing of keyboard shortcuts.
|
||||
'''
|
||||
def __init__(self, gui, group_name):
|
||||
SizePersistedDialog.__init__(self, gui, 'Keyboard shortcut dialog')
|
||||
self.gui = gui
|
||||
self.setWindowTitle('Keyboard shortcuts')
|
||||
layout = QVBoxLayout(self)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.keyboard_widget = ShortcutConfig(self)
|
||||
layout.addWidget(self.keyboard_widget)
|
||||
self.group_name = group_name
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self.commit)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Cause our dialog size to be restored from prefs or created on first usage
|
||||
self.resize_dialog()
|
||||
self.initialize()
|
||||
|
||||
def initialize(self):
|
||||
self.keyboard_widget.initialize(self.gui.keyboard)
|
||||
self.keyboard_widget.highlight_group(self.group_name)
|
||||
|
||||
def commit(self):
|
||||
self.keyboard_widget.commit()
|
||||
self.accept()
|
||||
|
||||
|
||||
class DateDelegate(QStyledItemDelegate):
|
||||
'''
|
||||
Delegate for dates. Because this delegate stores the
|
||||
format as an instance variable, a new instance must be created for each
|
||||
column. This differs from all the other delegates.
|
||||
'''
|
||||
def __init__(self, parent):
|
||||
QStyledItemDelegate.__init__(self, parent)
|
||||
self.format = 'dd MMM yyyy'
|
||||
|
||||
def displayText(self, val, locale):
|
||||
d = val.toDateTime()
|
||||
if d <= UNDEFINED_QDATETIME:
|
||||
return ''
|
||||
return format_date(qt_to_dt(d, as_utc=False), self.format)
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
|
||||
qde.setDisplayFormat(self.format)
|
||||
qde.setMinimumDateTime(UNDEFINED_QDATETIME)
|
||||
qde.setSpecialValueText(_('Undefined'))
|
||||
qde.setCalendarPopup(True)
|
||||
return qde
|
||||
|
||||
def setEditorData(self, editor, index):
|
||||
val = index.model().data(index, Qt.DisplayRole).toDateTime()
|
||||
if val is None or val == UNDEFINED_QDATETIME:
|
||||
val = now()
|
||||
editor.setDateTime(val)
|
||||
|
||||
def setModelData(self, editor, model, index):
|
||||
val = editor.dateTime()
|
||||
if val <= UNDEFINED_QDATETIME:
|
||||
model.setData(index, UNDEFINED_QDATETIME, Qt.EditRole)
|
||||
else:
|
||||
model.setData(index, QDateTime(val), Qt.EditRole)
|
||||
@@ -0,0 +1,573 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2011, Jim Miller'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import traceback, copy
|
||||
|
||||
from PyQt4.Qt import (QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QFont,
|
||||
QTextEdit, QComboBox, QCheckBox, QPushButton, QTabWidget, QVariant)
|
||||
|
||||
from calibre.gui2 import dynamic, info_dialog
|
||||
from calibre.utils.config import JSONConfig
|
||||
from calibre.gui2.ui import get_gui
|
||||
|
||||
from calibre_plugins.fanfictiondownloader_plugin.dialogs \
|
||||
import (SKIP, ADDNEW, UPDATE, UPDATEALWAYS, OVERWRITE, OVERWRITEALWAYS,
|
||||
CALIBREONLY,collision_order)
|
||||
|
||||
from calibre_plugins.fanfictiondownloader_plugin.common_utils \
|
||||
import ( get_library_uuid, KeyboardConfigDialog )
|
||||
|
||||
from calibre.gui2.complete import MultiCompleteLineEdit
|
||||
|
||||
# This is where all preferences for this plugin will be stored
|
||||
# Remember that this name (i.e. plugins/fanfictiondownloader_plugin) is also
|
||||
# in a global namespace, so make it as unique as possible.
|
||||
# You should always prefix your config file name with plugins/,
|
||||
# so as to ensure you dont accidentally clobber a calibre config file
|
||||
all_prefs = JSONConfig('plugins/fanfictiondownloader_plugin')
|
||||
|
||||
# Set defaults used by all. Library specific settings continue to
|
||||
# take from here.
|
||||
all_prefs.defaults['personal.ini'] = get_resources('plugin-example.ini')
|
||||
all_prefs.defaults['updatemeta'] = True
|
||||
all_prefs.defaults['updatecover'] = False
|
||||
all_prefs.defaults['keeptags'] = False
|
||||
all_prefs.defaults['urlsfromclip'] = True
|
||||
all_prefs.defaults['updatedefault'] = True
|
||||
all_prefs.defaults['fileform'] = 'epub'
|
||||
all_prefs.defaults['collision'] = OVERWRITE
|
||||
all_prefs.defaults['deleteotherforms'] = False
|
||||
all_prefs.defaults['adddialogstatsontop'] = False
|
||||
all_prefs.defaults['send_lists'] = ''
|
||||
all_prefs.defaults['read_lists'] = ''
|
||||
all_prefs.defaults['addtolists'] = False
|
||||
all_prefs.defaults['addtoreadlists'] = False
|
||||
all_prefs.defaults['addtolistsonread'] = False
|
||||
all_prefs.defaults['custom_cols'] = {}
|
||||
|
||||
# The list of settings to copy from all_prefs or the previous library
|
||||
# when config is called for the first time on a library.
|
||||
copylist = ['personal.ini',
|
||||
'updatemeta',
|
||||
'updatecover',
|
||||
'keeptags',
|
||||
'urlsfromclip',
|
||||
'updatedefault',
|
||||
'fileform',
|
||||
'collision',
|
||||
'deleteotherforms',
|
||||
'adddialogstatsontop']
|
||||
|
||||
# fake out so I don't have to change the prefs calls anywhere. The
|
||||
# Java programmer in me is offended by op-overloading, but it's very
|
||||
# tidy.
|
||||
class PrefsFacade():
|
||||
def __init__(self,all_prefs):
|
||||
self.all_prefs = all_prefs
|
||||
self.lastlibid = None
|
||||
|
||||
def _get_copylist_prefs(self,frompref):
|
||||
return filter( lambda x : x[0] in copylist, frompref.items() )
|
||||
|
||||
def _get_prefs(self):
|
||||
libraryid = get_library_uuid(get_gui().current_db)
|
||||
if libraryid not in self.all_prefs:
|
||||
if self.lastlibid == None:
|
||||
self.all_prefs[libraryid] = dict(self._get_copylist_prefs(self.all_prefs))
|
||||
else:
|
||||
self.all_prefs[libraryid] = dict(self._get_copylist_prefs(self.all_prefs[self.lastlibid]))
|
||||
self.lastlibid = libraryid
|
||||
|
||||
return self.all_prefs[libraryid]
|
||||
|
||||
def _save_prefs(self,prefs):
|
||||
libraryid = get_library_uuid(get_gui().current_db)
|
||||
self.all_prefs[libraryid] = prefs
|
||||
|
||||
def __getitem__(self,k):
|
||||
prefs = self._get_prefs()
|
||||
if k not in prefs:
|
||||
# pulls from all_prefs.defaults automatically if not set
|
||||
# in all_prefs
|
||||
return self.all_prefs[k]
|
||||
return prefs[k]
|
||||
|
||||
def __setitem__(self,k,v):
|
||||
prefs = self._get_prefs()
|
||||
prefs[k]=v
|
||||
self._save_prefs(prefs)
|
||||
|
||||
# to be avoided--can cause unexpected results as possibly ancient
|
||||
# all_pref settings may be pulled.
|
||||
def __delitem__(self,k):
|
||||
prefs = self._get_prefs()
|
||||
del prefs[k]
|
||||
self._save_prefs(prefs)
|
||||
|
||||
prefs = PrefsFacade(all_prefs)
|
||||
|
||||
class ConfigWidget(QWidget):
|
||||
|
||||
def __init__(self, plugin_action):
|
||||
QWidget.__init__(self)
|
||||
self.plugin_action = plugin_action
|
||||
|
||||
self.l = QVBoxLayout()
|
||||
self.setLayout(self.l)
|
||||
|
||||
tab_widget = QTabWidget(self)
|
||||
self.l.addWidget(tab_widget)
|
||||
|
||||
self.basic_tab = BasicTab(self, plugin_action)
|
||||
tab_widget.addTab(self.basic_tab, 'Basic')
|
||||
|
||||
self.personalini_tab = PersonalIniTab(self, plugin_action)
|
||||
tab_widget.addTab(self.personalini_tab, 'personal.ini')
|
||||
|
||||
self.list_tab = ListTab(self, plugin_action)
|
||||
tab_widget.addTab(self.list_tab, 'Reading Lists')
|
||||
if 'Reading List' not in plugin_action.gui.iactions:
|
||||
self.list_tab.setEnabled(False)
|
||||
|
||||
self.columns_tab = ColumnsTab(self, plugin_action)
|
||||
tab_widget.addTab(self.columns_tab, 'Custom Columns')
|
||||
|
||||
self.other_tab = OtherTab(self, plugin_action)
|
||||
tab_widget.addTab(self.other_tab, 'Other')
|
||||
|
||||
|
||||
def save_settings(self):
|
||||
|
||||
# basic
|
||||
prefs['fileform'] = unicode(self.basic_tab.fileform.currentText())
|
||||
prefs['collision'] = unicode(self.basic_tab.collision.currentText())
|
||||
prefs['updatemeta'] = self.basic_tab.updatemeta.isChecked()
|
||||
prefs['updatecover'] = self.basic_tab.updatecover.isChecked()
|
||||
prefs['keeptags'] = self.basic_tab.keeptags.isChecked()
|
||||
prefs['urlsfromclip'] = self.basic_tab.urlsfromclip.isChecked()
|
||||
prefs['updatedefault'] = self.basic_tab.updatedefault.isChecked()
|
||||
prefs['deleteotherforms'] = self.basic_tab.deleteotherforms.isChecked()
|
||||
prefs['adddialogstatsontop'] = self.basic_tab.adddialogstatsontop.isChecked()
|
||||
|
||||
if self.list_tab:
|
||||
# lists
|
||||
prefs['send_lists'] = ', '.join(map( lambda x : x.strip(), filter( lambda x : x.strip() != '', unicode(self.list_tab.send_lists_box.text()).split(','))))
|
||||
prefs['read_lists'] = ', '.join(map( lambda x : x.strip(), filter( lambda x : x.strip() != '', unicode(self.list_tab.read_lists_box.text()).split(','))))
|
||||
# print("send_lists: %s"%prefs['send_lists'])
|
||||
# print("read_lists: %s"%prefs['read_lists'])
|
||||
prefs['addtolists'] = self.list_tab.addtolists.isChecked()
|
||||
prefs['addtoreadlists'] = self.list_tab.addtoreadlists.isChecked()
|
||||
prefs['addtolistsonread'] = self.list_tab.addtolistsonread.isChecked()
|
||||
|
||||
# personal.ini
|
||||
ini = unicode(self.personalini_tab.ini.toPlainText())
|
||||
if ini:
|
||||
prefs['personal.ini'] = ini
|
||||
else:
|
||||
# if they've removed everything, reset to default.
|
||||
prefs['personal.ini'] = get_resources('plugin-example.ini')
|
||||
|
||||
# Custom Columns tab
|
||||
colsmap = {}
|
||||
for (col,combo) in self.columns_tab.custcol_dropdowns.iteritems():
|
||||
val = unicode(combo.itemData(combo.currentIndex()).toString())
|
||||
if val != 'none':
|
||||
colsmap[col] = val
|
||||
#print("colsmap[%s]:%s"%(col,colsmap[col]))
|
||||
prefs['custom_cols'] = colsmap
|
||||
|
||||
def edit_shortcuts(self):
|
||||
self.save_settings()
|
||||
# Force the menus to be rebuilt immediately, so we have all our actions registered
|
||||
self.plugin_action.rebuild_menus()
|
||||
d = KeyboardConfigDialog(self.plugin_action.gui, self.plugin_action.action_spec[0])
|
||||
if d.exec_() == d.Accepted:
|
||||
self.plugin_action.gui.keyboard.finalize()
|
||||
|
||||
class BasicTab(QWidget):
|
||||
|
||||
def __init__(self, parent_dialog, plugin_action):
|
||||
self.parent_dialog = parent_dialog
|
||||
self.plugin_action = plugin_action
|
||||
QWidget.__init__(self)
|
||||
|
||||
self.l = QVBoxLayout()
|
||||
self.setLayout(self.l)
|
||||
|
||||
label = QLabel('These settings control the basic features of the plugin--downloading FanFiction.')
|
||||
label.setWordWrap(True)
|
||||
self.l.addWidget(label)
|
||||
self.l.addSpacing(5)
|
||||
|
||||
horz = QHBoxLayout()
|
||||
label = QLabel('Default Output &Format:')
|
||||
horz.addWidget(label)
|
||||
self.fileform = QComboBox(self)
|
||||
self.fileform.addItem('epub')
|
||||
self.fileform.addItem('mobi')
|
||||
self.fileform.addItem('html')
|
||||
self.fileform.addItem('txt')
|
||||
self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform']))
|
||||
self.fileform.setToolTip('Choose output format to create. May set default from plugin configuration.')
|
||||
self.fileform.activated.connect(self.set_collisions)
|
||||
label.setBuddy(self.fileform)
|
||||
horz.addWidget(self.fileform)
|
||||
self.l.addLayout(horz)
|
||||
|
||||
horz = QHBoxLayout()
|
||||
label = QLabel('Default If Story Already Exists?')
|
||||
label.setToolTip("What to do if there's already an existing story with the same title and author.")
|
||||
horz.addWidget(label)
|
||||
self.collision = QComboBox(self)
|
||||
# add collision options
|
||||
self.set_collisions()
|
||||
i = self.collision.findText(prefs['collision'])
|
||||
if i > -1:
|
||||
self.collision.setCurrentIndex(i)
|
||||
# self.collision.setToolTip('Overwrite will replace the existing story. Add New will create a new story with the same title and author.')
|
||||
label.setBuddy(self.collision)
|
||||
horz.addWidget(self.collision)
|
||||
self.l.addLayout(horz)
|
||||
|
||||
self.updatemeta = QCheckBox('Default Update Calibre &Metadata?',self)
|
||||
self.updatemeta.setToolTip('Update title, author, URL, tags, custom columns, etc for story in Calibre from web site.')
|
||||
self.updatemeta.setChecked(prefs['updatemeta'])
|
||||
self.l.addWidget(self.updatemeta)
|
||||
|
||||
self.updatecover = QCheckBox('Update Cover when Updating Metadata?',self)
|
||||
self.updatecover.setToolTip("Update cover image from EPUB when metadata is updated. (EPUB only.)\nDoesn't go looking for new images on 'Update Calibre Metadata Only'.")
|
||||
self.updatecover.setChecked(prefs['updatecover'])
|
||||
self.l.addWidget(self.updatecover)
|
||||
|
||||
self.keeptags = QCheckBox('Keep Existing Tags when Updating Metadata?',self)
|
||||
self.keeptags.setToolTip('Existing tags will be kept and any new tags added.\nCompleted and In-Progress tags will be still be updated, if known.\nLast Updated tags will be updated if lastupdate in include_subject_tags.')
|
||||
self.keeptags.setChecked(prefs['keeptags'])
|
||||
self.l.addWidget(self.keeptags)
|
||||
|
||||
self.urlsfromclip = QCheckBox('Take URLs from Clipboard?',self)
|
||||
self.urlsfromclip.setToolTip('Prefill URLs from valid URLs in Clipboard when Adding New.')
|
||||
self.urlsfromclip.setChecked(prefs['urlsfromclip'])
|
||||
self.l.addWidget(self.urlsfromclip)
|
||||
|
||||
self.updatedefault = QCheckBox('Default to Update when books selected?',self)
|
||||
self.updatedefault.setToolTip('The top FanFictionDownLoader plugin button will start Update if\n'+
|
||||
'books are selected. If unchecked, it will always bring up \'Add New\'.')
|
||||
self.updatedefault.setChecked(prefs['updatedefault'])
|
||||
self.l.addWidget(self.updatedefault)
|
||||
|
||||
self.deleteotherforms = QCheckBox('Delete other existing formats?',self)
|
||||
self.deleteotherforms.setToolTip('Check this to automatically delete all other ebook formats when updating an existing book.\nHandy if you have both a Nook(epub) and Kindle(mobi), for example.')
|
||||
self.deleteotherforms.setChecked(prefs['deleteotherforms'])
|
||||
self.l.addWidget(self.deleteotherforms)
|
||||
|
||||
self.adddialogstatsontop = QCheckBox("Keep 'Add New from URL(s)' dialog on top?",self)
|
||||
self.adddialogstatsontop.setToolTip("Instructs the OS and Window Manager to keep the 'Add New from URL(s)'\ndialog on top of all other windows. Useful for dragging URLs onto it.")
|
||||
self.adddialogstatsontop.setChecked(prefs['adddialogstatsontop'])
|
||||
self.l.addWidget(self.adddialogstatsontop)
|
||||
|
||||
self.l.insertStretch(-1)
|
||||
|
||||
def set_collisions(self):
|
||||
prev=self.collision.currentText()
|
||||
self.collision.clear()
|
||||
for o in collision_order:
|
||||
if self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]:
|
||||
self.collision.addItem(o)
|
||||
i = self.collision.findText(prev)
|
||||
if i > -1:
|
||||
self.collision.setCurrentIndex(i)
|
||||
|
||||
def show_defaults(self):
|
||||
text = get_resources('plugin-defaults.ini')
|
||||
ShowDefaultsIniDialog(self.windowIcon(),text,self).exec_()
|
||||
|
||||
class PersonalIniTab(QWidget):
|
||||
|
||||
def __init__(self, parent_dialog, plugin_action):
|
||||
self.parent_dialog = parent_dialog
|
||||
self.plugin_action = plugin_action
|
||||
QWidget.__init__(self)
|
||||
|
||||
self.l = QVBoxLayout()
|
||||
self.setLayout(self.l)
|
||||
|
||||
label = QLabel('These settings provide more detailed control over what metadata will be displayed inside the ebook as well as let you set is_adult and user/password for different sites.')
|
||||
label.setWordWrap(True)
|
||||
self.l.addWidget(label)
|
||||
self.l.addSpacing(5)
|
||||
|
||||
self.label = QLabel('personal.ini:')
|
||||
self.l.addWidget(self.label)
|
||||
|
||||
self.ini = QTextEdit(self)
|
||||
try:
|
||||
self.ini.setFont(QFont("Courier",
|
||||
self.plugin_action.gui.font().pointSize()+1));
|
||||
except Exception as e:
|
||||
print("Couldn't get font: %s"%e)
|
||||
self.ini.setLineWrapMode(QTextEdit.NoWrap)
|
||||
self.ini.setText(prefs['personal.ini'])
|
||||
self.l.addWidget(self.ini)
|
||||
|
||||
self.defaults = QPushButton('View Defaults', self)
|
||||
self.defaults.setToolTip("View all of the plugin's configurable settings\nand their default settings.")
|
||||
self.defaults.clicked.connect(self.show_defaults)
|
||||
self.l.addWidget(self.defaults)
|
||||
|
||||
# self.l.insertStretch(-1)
|
||||
# let edit box fill the space.
|
||||
|
||||
def show_defaults(self):
|
||||
text = get_resources('plugin-defaults.ini')
|
||||
ShowDefaultsIniDialog(self.windowIcon(),text,self).exec_()
|
||||
|
||||
class ShowDefaultsIniDialog(QDialog):
|
||||
|
||||
def __init__(self, icon, text, parent=None):
|
||||
QDialog.__init__(self, parent)
|
||||
self.resize(600, 500)
|
||||
self.l = QVBoxLayout()
|
||||
self.setLayout(self.l)
|
||||
self.label = QLabel("Plugin Defaults (Read-Only)")
|
||||
self.label.setToolTip("These are all of the plugin's configurable options\nand their default settings.")
|
||||
self.setWindowTitle(_('Plugin Defaults'))
|
||||
self.setWindowIcon(icon)
|
||||
self.l.addWidget(self.label)
|
||||
|
||||
self.ini = QTextEdit(self)
|
||||
self.ini.setToolTip("These are all of the plugin's configurable options\nand their default settings.")
|
||||
try:
|
||||
self.ini.setFont(QFont("Courier",
|
||||
get_gui().font().pointSize()+1));
|
||||
except Exception as e:
|
||||
print("Couldn't get font: %s"%e)
|
||||
self.ini.setLineWrapMode(QTextEdit.NoWrap)
|
||||
self.ini.setText(text)
|
||||
self.ini.setReadOnly(True)
|
||||
self.l.addWidget(self.ini)
|
||||
|
||||
self.ok_button = QPushButton('OK', self)
|
||||
self.ok_button.clicked.connect(self.hide)
|
||||
self.l.addWidget(self.ok_button)
|
||||
|
||||
class ListTab(QWidget):
|
||||
|
||||
def __init__(self, parent_dialog, plugin_action):
|
||||
self.parent_dialog = parent_dialog
|
||||
self.plugin_action = plugin_action
|
||||
QWidget.__init__(self)
|
||||
|
||||
self.l = QVBoxLayout()
|
||||
self.setLayout(self.l)
|
||||
|
||||
try:
|
||||
rl_plugin = plugin_action.gui.iactions['Reading List']
|
||||
reading_lists = rl_plugin.get_list_names()
|
||||
except KeyError:
|
||||
reading_lists= []
|
||||
|
||||
label = QLabel('These settings provide integration with the Reading List Plugin. Reading List can automatically send to devices and change custom columns. You have to create and configure the lists in Reading List to be useful.')
|
||||
label.setWordWrap(True)
|
||||
self.l.addWidget(label)
|
||||
self.l.addSpacing(5)
|
||||
|
||||
self.addtolists = QCheckBox('Add new/updated stories to "Send to Device" Reading List(s).',self)
|
||||
self.addtolists.setToolTip('Automatically add new/updated stories to these lists in the Reading List plugin.')
|
||||
self.addtolists.setChecked(prefs['addtolists'])
|
||||
self.l.addWidget(self.addtolists)
|
||||
|
||||
horz = QHBoxLayout()
|
||||
label = QLabel('"Send to Device" Reading Lists')
|
||||
label.setToolTip("When enabled, new/updated stories will be automatically added to these lists.")
|
||||
horz.addWidget(label)
|
||||
self.send_lists_box = MultiCompleteLineEdit(self)
|
||||
self.send_lists_box.setToolTip("When enabled, new/updated stories will be automatically added to these lists.")
|
||||
self.send_lists_box.update_items_cache(reading_lists)
|
||||
self.send_lists_box.setText(prefs['send_lists'])
|
||||
horz.addWidget(self.send_lists_box)
|
||||
self.l.addLayout(horz)
|
||||
|
||||
self.addtoreadlists = QCheckBox('Add new/updated stories to "To Read" Reading List(s).',self)
|
||||
self.addtoreadlists.setToolTip('Automatically add new/updated stories to these lists in the Reading List plugin.\nAlso offers menu option to remove stories from the "To Read" lists.')
|
||||
self.addtoreadlists.setChecked(prefs['addtoreadlists'])
|
||||
self.l.addWidget(self.addtoreadlists)
|
||||
|
||||
horz = QHBoxLayout()
|
||||
label = QLabel('"To Read" Reading Lists')
|
||||
label.setToolTip("When enabled, new/updated stories will be automatically added to these lists.")
|
||||
horz.addWidget(label)
|
||||
self.read_lists_box = MultiCompleteLineEdit(self)
|
||||
self.read_lists_box.setToolTip("When enabled, new/updated stories will be automatically added to these lists.")
|
||||
self.read_lists_box.update_items_cache(reading_lists)
|
||||
self.read_lists_box.setText(prefs['read_lists'])
|
||||
horz.addWidget(self.read_lists_box)
|
||||
self.l.addLayout(horz)
|
||||
|
||||
self.addtolistsonread = QCheckBox('Add stories back to "Send to Device" Reading List(s) when marked "Read".',self)
|
||||
self.addtolistsonread.setToolTip('Menu option to remove from "To Read" lists will also add stories back to "Send to Device" Reading List(s)')
|
||||
self.addtolistsonread.setChecked(prefs['addtolistsonread'])
|
||||
self.l.addWidget(self.addtolistsonread)
|
||||
|
||||
self.l.insertStretch(-1)
|
||||
|
||||
class OtherTab(QWidget):
|
||||
|
||||
def __init__(self, parent_dialog, plugin_action):
|
||||
self.parent_dialog = parent_dialog
|
||||
self.plugin_action = plugin_action
|
||||
QWidget.__init__(self)
|
||||
|
||||
self.l = QVBoxLayout()
|
||||
self.setLayout(self.l)
|
||||
|
||||
label = QLabel("These controls aren't plugin settings as such, but convenience buttons for setting Keyboard shortcuts and getting all the FanFictionDownLoader confirmation dialogs back again.")
|
||||
label.setWordWrap(True)
|
||||
self.l.addWidget(label)
|
||||
self.l.addSpacing(5)
|
||||
|
||||
keyboard_shortcuts_button = QPushButton('Keyboard shortcuts...', self)
|
||||
keyboard_shortcuts_button.setToolTip(_(
|
||||
'Edit the keyboard shortcuts associated with this plugin'))
|
||||
keyboard_shortcuts_button.clicked.connect(parent_dialog.edit_shortcuts)
|
||||
self.l.addWidget(keyboard_shortcuts_button)
|
||||
|
||||
reset_confirmation_button = QPushButton(_('Reset disabled &confirmation dialogs'), self)
|
||||
reset_confirmation_button.setToolTip(_(
|
||||
'Reset all show me again dialogs for the FanFictionDownLoader plugin'))
|
||||
reset_confirmation_button.clicked.connect(self.reset_dialogs)
|
||||
self.l.addWidget(reset_confirmation_button)
|
||||
|
||||
self.l.insertStretch(-1)
|
||||
|
||||
def reset_dialogs(self):
|
||||
for key in dynamic.keys():
|
||||
if key.startswith('fanfictiondownloader_') and key.endswith('_again') \
|
||||
and dynamic[key] is False:
|
||||
dynamic[key] = True
|
||||
info_dialog(self, _('Done'),
|
||||
_('Confirmation dialogs have all been reset'),
|
||||
show=True,
|
||||
show_copy_button=False)
|
||||
|
||||
permitted_values = {
|
||||
'int' : ['numWords','numChapters'],
|
||||
'float' : ['numWords','numChapters'],
|
||||
'bool' : ['status-C','status-I'],
|
||||
'datetime' : ['datePublished', 'dateUpdated', 'dateCreated'],
|
||||
'series' : ['series'],
|
||||
'enumeration' : ['category',
|
||||
'genre',
|
||||
'language',
|
||||
'series',
|
||||
'characters',
|
||||
'status',
|
||||
'datePublished',
|
||||
'dateUpdated',
|
||||
'dateCreated',
|
||||
'rating',
|
||||
'warnings',
|
||||
'numChapters',
|
||||
'numWords',
|
||||
'site',
|
||||
'storyId',
|
||||
'authorId',
|
||||
'extratags',
|
||||
'title',
|
||||
'storyUrl',
|
||||
'description',
|
||||
'author',
|
||||
'authorUrl',
|
||||
'formatname'
|
||||
#,'formatext' # not useful information.
|
||||
#,'siteabbrev'
|
||||
#,'version'
|
||||
]
|
||||
}
|
||||
# no point copying the whole list.
|
||||
permitted_values['text'] = permitted_values['enumeration']
|
||||
permitted_values['comments'] = permitted_values['enumeration']
|
||||
|
||||
titleLabels = {
|
||||
'category':'Category',
|
||||
'genre':'Genre',
|
||||
'language':'Language',
|
||||
'status':'Status',
|
||||
'status-C':'Status:Completed',
|
||||
'status-I':'Status:In-Progress',
|
||||
'series':'Series',
|
||||
'characters':'Characters',
|
||||
'datePublished':'Published',
|
||||
'dateUpdated':'Updated',
|
||||
'dateCreated':'Packaged',
|
||||
'rating':'Rating',
|
||||
'warnings':'Warnings',
|
||||
'numChapters':'Chapters',
|
||||
'numWords':'Words',
|
||||
'site':'Site',
|
||||
'storyId':'Story ID',
|
||||
'authorId':'Author ID',
|
||||
'extratags':'Extra Tags',
|
||||
'title':'Title',
|
||||
'storyUrl':'Story URL',
|
||||
'description':'Summary',
|
||||
'author':'Author',
|
||||
'authorUrl':'Author URL',
|
||||
'formatname':'File Format',
|
||||
'formatext':'File Extension',
|
||||
'siteabbrev':'Site Abbrev',
|
||||
'version':'FFDL Version'
|
||||
}
|
||||
|
||||
class ColumnsTab(QWidget):
|
||||
|
||||
def __init__(self, parent_dialog, plugin_action):
|
||||
self.parent_dialog = parent_dialog
|
||||
self.plugin_action = plugin_action
|
||||
QWidget.__init__(self)
|
||||
|
||||
self.l = QVBoxLayout()
|
||||
self.setLayout(self.l)
|
||||
|
||||
label = QLabel("If you have custom columns defined, they will be listed below. Choose a metadata value type to fill your columns automatically.")
|
||||
label.setWordWrap(True)
|
||||
self.l.addWidget(label)
|
||||
self.l.addSpacing(5)
|
||||
|
||||
self.custcol_dropdowns = {}
|
||||
|
||||
custom_columns = self.plugin_action.gui.library_view.model().custom_columns
|
||||
|
||||
for key, column in custom_columns.iteritems():
|
||||
|
||||
if column['datatype'] in permitted_values:
|
||||
# print("\n============== %s ===========\n"%key)
|
||||
# for (k,v) in column.iteritems():
|
||||
# print("column['%s'] => %s"%(k,v))
|
||||
horz = QHBoxLayout()
|
||||
label = QLabel('%s(%s)'%(column['name'],key))
|
||||
label.setToolTip("Update this %s column with..."%column['datatype'])
|
||||
horz.addWidget(label)
|
||||
dropdown = QComboBox(self)
|
||||
dropdown.addItem('',QVariant('none'))
|
||||
for md in permitted_values[column['datatype']]:
|
||||
dropdown.addItem(titleLabels[md],QVariant(md))
|
||||
self.custcol_dropdowns[key] = dropdown
|
||||
if key in prefs['custom_cols']:
|
||||
dropdown.setCurrentIndex(dropdown.findData(QVariant(prefs['custom_cols'][key])))
|
||||
if column['datatype'] == 'enumeration':
|
||||
dropdown.setToolTip("Metadata values valid for this type of column.\nValues that aren't valid for this enumeration column will be ignored.")
|
||||
else:
|
||||
dropdown.setToolTip("Metadata values valid for this type of column.")
|
||||
|
||||
horz.addWidget(dropdown)
|
||||
self.l.addLayout(horz)
|
||||
|
||||
self.l.insertStretch(-1)
|
||||
|
||||
#print("prefs['custom_cols'] %s"%prefs['custom_cols'])
|
||||
@@ -0,0 +1,648 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
from __future__ import (unicode_literals, division,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2011, Jim Miller'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import traceback
|
||||
|
||||
from PyQt4 import QtGui
|
||||
from PyQt4.Qt import (QDialog, QTableWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QPushButton, QProgressDialog, QString, QLabel, QCheckBox, QIcon, QTextCursor,
|
||||
QTextEdit, QLineEdit, QInputDialog, QComboBox, QClipboard, QVariant,
|
||||
QProgressDialog, QTimer, QDialogButtonBox, QPixmap, Qt, QAbstractItemView )
|
||||
|
||||
from calibre.gui2 import error_dialog, warning_dialog, question_dialog, info_dialog
|
||||
from calibre.gui2.dialogs.confirm_delete import confirm
|
||||
|
||||
from calibre import confirm_config_name
|
||||
from calibre.gui2 import dynamic
|
||||
|
||||
from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters,writers,exceptions
|
||||
from calibre_plugins.fanfictiondownloader_plugin.common_utils \
|
||||
import (ReadOnlyTableWidgetItem, ReadOnlyTextIconWidgetItem, SizePersistedDialog,
|
||||
ImageTitleLayout, get_icon)
|
||||
|
||||
SKIP='Skip'
|
||||
ADDNEW='Add New Book'
|
||||
UPDATE='Update EPUB if New Chapters'
|
||||
UPDATEALWAYS='Update EPUB Always'
|
||||
OVERWRITE='Overwrite if Newer'
|
||||
OVERWRITEALWAYS='Overwrite Always'
|
||||
CALIBREONLY='Update Calibre Metadata Only'
|
||||
collision_order=[SKIP,
|
||||
ADDNEW,
|
||||
UPDATE,
|
||||
UPDATEALWAYS,
|
||||
OVERWRITE,
|
||||
OVERWRITEALWAYS,
|
||||
CALIBREONLY,]
|
||||
|
||||
class NotGoingToDownload(Exception):
|
||||
def __init__(self,error,icon='dialog_error.png'):
|
||||
self.error=error
|
||||
self.icon=icon
|
||||
|
||||
def __str__(self):
|
||||
return self.error
|
||||
|
||||
class DroppableQTextEdit(QTextEdit):
|
||||
def __init__(self,parent):
|
||||
QTextEdit.__init__(self,parent)
|
||||
|
||||
def canInsertFromMimeData(self, source):
|
||||
if source.hasUrls():
|
||||
return True;
|
||||
else:
|
||||
return QTextEdit.canInsertFromMimeData(self,source)
|
||||
|
||||
def insertFromMimeData(self, source):
|
||||
if source.hasText():
|
||||
self.append(source.text())
|
||||
else:
|
||||
return QTextEdit.insertFromMimeData(self, source)
|
||||
|
||||
class AddNewDialog(SizePersistedDialog):
|
||||
|
||||
def __init__(self, gui, prefs, icon, url_list_text):
|
||||
SizePersistedDialog.__init__(self, gui, 'FanFictionDownLoader plugin:add new dialog')
|
||||
self.gui = gui
|
||||
|
||||
if prefs['adddialogstatsontop']:
|
||||
QDialog.setWindowFlags ( self, Qt.Dialog|Qt.WindowStaysOnTopHint )
|
||||
|
||||
self.setMinimumWidth(300)
|
||||
self.l = QVBoxLayout()
|
||||
self.setLayout(self.l)
|
||||
|
||||
self.setWindowTitle('FanFictionDownLoader')
|
||||
self.setWindowIcon(icon)
|
||||
|
||||
self.l.addWidget(QLabel('Story URL(s), one per line:'))
|
||||
self.url = DroppableQTextEdit(self)
|
||||
self.url.setToolTip('URLs for stories, one per line.\nWill take URLs from clipboard, but only valid URLs.')
|
||||
self.url.setLineWrapMode(QTextEdit.NoWrap)
|
||||
self.url.setText(url_list_text)
|
||||
self.l.addWidget(self.url)
|
||||
|
||||
horz = QHBoxLayout()
|
||||
label = QLabel('Output &Format:')
|
||||
horz.addWidget(label)
|
||||
self.fileform = QComboBox(self)
|
||||
self.fileform.addItem('epub')
|
||||
self.fileform.addItem('mobi')
|
||||
self.fileform.addItem('html')
|
||||
self.fileform.addItem('txt')
|
||||
self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform']))
|
||||
self.fileform.setToolTip('Choose output format to create. May set default from plugin configuration.')
|
||||
self.fileform.activated.connect(self.set_collisions)
|
||||
|
||||
label.setBuddy(self.fileform)
|
||||
horz.addWidget(self.fileform)
|
||||
self.l.addLayout(horz)
|
||||
|
||||
horz = QHBoxLayout()
|
||||
label = QLabel('If Story Already Exists?')
|
||||
label.setToolTip("What to do if there's already an existing story with the same title and author.")
|
||||
horz.addWidget(label)
|
||||
self.collision = QComboBox(self)
|
||||
# add collision options
|
||||
self.set_collisions()
|
||||
i = self.collision.findText(prefs['collision'])
|
||||
if i > -1:
|
||||
self.collision.setCurrentIndex(i)
|
||||
# self.collision.setToolTip(OVERWRITE+' will replace the existing story.\n'+
|
||||
# UPDATE+' will download new chapters only and add to existing EPUB.\n'+
|
||||
# ADDNEW+' will create a new story with the same title and author.\n'+
|
||||
# SKIP+' will not download existing stories.\n'+
|
||||
# CALIBREONLY+' will not download stories, but will update Calibre metadata.')
|
||||
label.setBuddy(self.collision)
|
||||
horz.addWidget(self.collision)
|
||||
self.l.addLayout(horz)
|
||||
|
||||
self.updatemeta = QCheckBox('Update Calibre &Metadata?',self)
|
||||
self.updatemeta.setToolTip('Update metadata for story in Calibre from web site?')
|
||||
self.updatemeta.setChecked(prefs['updatemeta'])
|
||||
self.l.addWidget(self.updatemeta)
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
self.l.addWidget(button_box)
|
||||
|
||||
if url_list_text:
|
||||
button_box.button(QDialogButtonBox.Ok).setFocus()
|
||||
|
||||
# restore saved size.
|
||||
self.resize_dialog()
|
||||
#self.resize(self.sizeHint())
|
||||
|
||||
def set_collisions(self):
|
||||
prev=self.collision.currentText()
|
||||
self.collision.clear()
|
||||
for o in collision_order:
|
||||
if self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]:
|
||||
self.collision.addItem(o)
|
||||
i = self.collision.findText(prev)
|
||||
if i > -1:
|
||||
self.collision.setCurrentIndex(i)
|
||||
|
||||
def get_ffdl_options(self):
|
||||
return {
|
||||
'fileform': unicode(self.fileform.currentText()),
|
||||
'collision': unicode(self.collision.currentText()),
|
||||
'updatemeta': self.updatemeta.isChecked(),
|
||||
}
|
||||
|
||||
def get_urlstext(self):
|
||||
return unicode(self.url.toPlainText())
|
||||
|
||||
class UserPassDialog(QDialog):
|
||||
'''
|
||||
Need to collect User/Pass for some sites.
|
||||
'''
|
||||
def __init__(self, gui, site):
|
||||
QDialog.__init__(self, gui)
|
||||
self.gui = gui
|
||||
self.status=False
|
||||
self.setWindowTitle('User/Password')
|
||||
|
||||
self.l = QGridLayout()
|
||||
self.setLayout(self.l)
|
||||
|
||||
self.l.addWidget(QLabel("%s requires you to login to download this story."%site),0,0,1,2)
|
||||
|
||||
self.l.addWidget(QLabel("User:"),1,0)
|
||||
self.user = QLineEdit(self)
|
||||
self.l.addWidget(self.user,1,1)
|
||||
|
||||
self.l.addWidget(QLabel("Password:"),2,0)
|
||||
self.passwd = QLineEdit(self)
|
||||
self.passwd.setEchoMode(QLineEdit.Password)
|
||||
self.l.addWidget(self.passwd,2,1)
|
||||
|
||||
self.ok_button = QPushButton('OK', self)
|
||||
self.ok_button.clicked.connect(self.ok)
|
||||
self.l.addWidget(self.ok_button,3,0)
|
||||
|
||||
self.cancel_button = QPushButton('Cancel', self)
|
||||
self.cancel_button.clicked.connect(self.cancel)
|
||||
self.l.addWidget(self.cancel_button,3,1)
|
||||
|
||||
self.resize(self.sizeHint())
|
||||
|
||||
def ok(self):
|
||||
self.status=True
|
||||
self.hide()
|
||||
|
||||
def cancel(self):
|
||||
self.status=False
|
||||
self.hide()
|
||||
|
||||
class LoopProgressDialog(QProgressDialog):
|
||||
'''
|
||||
ProgressDialog displayed while fetching metadata for each story.
|
||||
'''
|
||||
def __init__(self, gui,
|
||||
book_list,
|
||||
foreach_function,
|
||||
finish_function,
|
||||
init_label="Fetching metadata for stories...",
|
||||
win_title="Downloading metadata for stories",
|
||||
status_prefix="Fetched metadata for"):
|
||||
QProgressDialog.__init__(self,
|
||||
init_label,
|
||||
QString(), 0, len(book_list), gui)
|
||||
self.setWindowTitle(win_title)
|
||||
self.setMinimumWidth(500)
|
||||
self.gui = gui
|
||||
self.book_list = book_list
|
||||
self.foreach_function = foreach_function
|
||||
self.finish_function = finish_function
|
||||
self.status_prefix = status_prefix
|
||||
self.i = 0
|
||||
|
||||
## self.do_loop does QTimer.singleShot on self.do_loop also.
|
||||
## A weird way to do a loop, but that was the example I had.
|
||||
QTimer.singleShot(0, self.do_loop)
|
||||
self.exec_()
|
||||
|
||||
def updateStatus(self):
|
||||
self.setLabelText("%s %d of %d"%(self.status_prefix,self.i+1,len(self.book_list)))
|
||||
self.setValue(self.i+1)
|
||||
print(self.labelText())
|
||||
|
||||
def do_loop(self):
|
||||
|
||||
if self.i == 0:
|
||||
self.setValue(0)
|
||||
|
||||
book = self.book_list[self.i]
|
||||
try:
|
||||
## collision spec passed into getadapter by partial from ffdl_plugin
|
||||
## no retval only if it exists, but collision is SKIP
|
||||
self.foreach_function(book)
|
||||
|
||||
except NotGoingToDownload as d:
|
||||
book['good']=False
|
||||
book['comment']=unicode(d)
|
||||
book['icon'] = d.icon
|
||||
|
||||
except Exception as e:
|
||||
book['good']=False
|
||||
book['comment']=unicode(e)
|
||||
print("Exception: %s:%s"%(book,unicode(e)))
|
||||
traceback.print_exc()
|
||||
|
||||
self.updateStatus()
|
||||
self.i += 1
|
||||
|
||||
if self.i >= len(self.book_list) or self.wasCanceled():
|
||||
return self.do_when_finished()
|
||||
else:
|
||||
QTimer.singleShot(0, self.do_loop)
|
||||
|
||||
def do_when_finished(self):
|
||||
self.hide()
|
||||
self.gui = None
|
||||
# Queues a job to process these books in the background.
|
||||
self.finish_function(self.book_list)
|
||||
|
||||
class AboutDialog(QDialog):
|
||||
|
||||
def __init__(self, parent, icon, text):
|
||||
QDialog.__init__(self, parent)
|
||||
self.resize(400, 250)
|
||||
self.l = QGridLayout()
|
||||
self.setLayout(self.l)
|
||||
self.logo = QLabel()
|
||||
self.logo.setMaximumWidth(110)
|
||||
self.logo.setPixmap(QPixmap(icon.pixmap(100,100)))
|
||||
self.label = QLabel(text)
|
||||
self.label.setOpenExternalLinks(True)
|
||||
self.label.setWordWrap(True)
|
||||
self.setWindowTitle(_('About FanFictionDownLoader'))
|
||||
self.setWindowIcon(icon)
|
||||
self.l.addWidget(self.logo, 0, 0)
|
||||
self.l.addWidget(self.label, 0, 1)
|
||||
self.bb = QDialogButtonBox(self)
|
||||
b = self.bb.addButton(_('OK'), self.bb.AcceptRole)
|
||||
b.setDefault(True)
|
||||
self.l.addWidget(self.bb, 2, 0, 1, -1)
|
||||
self.bb.accepted.connect(self.accept)
|
||||
|
||||
class IconWidgetItem(ReadOnlyTextIconWidgetItem):
|
||||
def __init__(self, text, icon, sort_key):
|
||||
ReadOnlyTextIconWidgetItem.__init__(self, text, icon)
|
||||
self.sort_key = sort_key
|
||||
|
||||
#Qt uses a simple < check for sorting items, override this to use the sortKey
|
||||
def __lt__(self, other):
|
||||
return self.sort_key < other.sort_key
|
||||
|
||||
class AuthorTableWidgetItem(ReadOnlyTableWidgetItem):
|
||||
def __init__(self, text, sort_key):
|
||||
ReadOnlyTableWidgetItem.__init__(self, text)
|
||||
self.sort_key = sort_key
|
||||
|
||||
#Qt uses a simple < check for sorting items, override this to use the sortKey
|
||||
def __lt__(self, other):
|
||||
return self.sort_key < other.sort_key
|
||||
|
||||
class UpdateExistingDialog(SizePersistedDialog):
|
||||
def __init__(self, gui, header, prefs, icon, books,
|
||||
save_size_name='fanfictiondownloader_plugin:update list dialog'):
|
||||
SizePersistedDialog.__init__(self, gui, save_size_name)
|
||||
self.gui = gui
|
||||
|
||||
self.setWindowTitle(header)
|
||||
self.setWindowIcon(icon)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.setLayout(layout)
|
||||
title_layout = ImageTitleLayout(self, 'images/icon.png',
|
||||
header)
|
||||
layout.addLayout(title_layout)
|
||||
books_layout = QHBoxLayout()
|
||||
layout.addLayout(books_layout)
|
||||
|
||||
self.books_table = StoryListTableWidget(self)
|
||||
books_layout.addWidget(self.books_table)
|
||||
|
||||
button_layout = QVBoxLayout()
|
||||
books_layout.addLayout(button_layout)
|
||||
# self.move_up_button = QtGui.QToolButton(self)
|
||||
# self.move_up_button.setToolTip('Move selected books up the list')
|
||||
# self.move_up_button.setIcon(QIcon(I('arrow-up.png')))
|
||||
# self.move_up_button.clicked.connect(self.books_table.move_rows_up)
|
||||
# button_layout.addWidget(self.move_up_button)
|
||||
spacerItem = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding)
|
||||
button_layout.addItem(spacerItem)
|
||||
self.remove_button = QtGui.QToolButton(self)
|
||||
self.remove_button.setToolTip('Remove selected books from the list')
|
||||
self.remove_button.setIcon(get_icon('list_remove.png'))
|
||||
self.remove_button.clicked.connect(self.remove_from_list)
|
||||
button_layout.addWidget(self.remove_button)
|
||||
spacerItem1 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding)
|
||||
button_layout.addItem(spacerItem1)
|
||||
# self.move_down_button = QtGui.QToolButton(self)
|
||||
# self.move_down_button.setToolTip('Move selected books down the list')
|
||||
# self.move_down_button.setIcon(QIcon(I('arrow-down.png')))
|
||||
# self.move_down_button.clicked.connect(self.books_table.move_rows_down)
|
||||
# button_layout.addWidget(self.move_down_button)
|
||||
|
||||
options_layout = QHBoxLayout()
|
||||
|
||||
label = QLabel('Output &Format:')
|
||||
options_layout.addWidget(label)
|
||||
self.fileform = QComboBox(self)
|
||||
self.fileform.addItem('epub')
|
||||
self.fileform.addItem('mobi')
|
||||
self.fileform.addItem('html')
|
||||
self.fileform.addItem('txt')
|
||||
self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform']))
|
||||
self.fileform.setToolTip('Choose output format to create. May set default from plugin configuration.')
|
||||
self.fileform.activated.connect(self.set_collisions)
|
||||
label.setBuddy(self.fileform)
|
||||
options_layout.addWidget(self.fileform)
|
||||
|
||||
label = QLabel('Update Mode:')
|
||||
label.setToolTip("What sort of update to perform. May set default from plugin configuration.")
|
||||
options_layout.addWidget(label)
|
||||
self.collision = QComboBox(self)
|
||||
# add collision options
|
||||
self.set_collisions()
|
||||
i = self.collision.findText(prefs['collision'])
|
||||
if i > -1:
|
||||
self.collision.setCurrentIndex(i)
|
||||
# self.collision.setToolTip('Overwrite will replace the existing story. Add New will create a new story with the same title and author.')
|
||||
label.setBuddy(self.collision)
|
||||
options_layout.addWidget(self.collision)
|
||||
|
||||
self.updatemeta = QCheckBox('Update Calibre &Metadata?',self)
|
||||
self.updatemeta.setToolTip('Update metadata for story in Calibre from web site? May set default from plugin configuration.')
|
||||
self.updatemeta.setChecked(prefs['updatemeta'])
|
||||
options_layout.addWidget(self.updatemeta)
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
options_layout.addWidget(button_box)
|
||||
|
||||
layout.addLayout(options_layout)
|
||||
|
||||
# Cause our dialog size to be restored from prefs or created on first usage
|
||||
self.resize_dialog()
|
||||
self.books_table.populate_table(books)
|
||||
|
||||
def set_collisions(self):
|
||||
prev=self.collision.currentText()
|
||||
self.collision.clear()
|
||||
for o in collision_order:
|
||||
if o not in [ADDNEW,SKIP] and \
|
||||
(self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]):
|
||||
self.collision.addItem(o)
|
||||
i = self.collision.findText(prev)
|
||||
if i > -1:
|
||||
self.collision.setCurrentIndex(i)
|
||||
|
||||
def remove_from_list(self):
|
||||
self.books_table.remove_selected_rows()
|
||||
|
||||
def get_books(self):
|
||||
return self.books_table.get_books()
|
||||
|
||||
def get_ffdl_options(self):
|
||||
return {
|
||||
'fileform': unicode(self.fileform.currentText()),
|
||||
'collision': unicode(self.collision.currentText()),
|
||||
'updatemeta': self.updatemeta.isChecked(),
|
||||
}
|
||||
|
||||
def display_story_list(gui, header, prefs, icon, books,
|
||||
label_text='',
|
||||
save_size_name='fanfictiondownloader_plugin:display list dialog',
|
||||
offer_skip=False):
|
||||
all_good = True
|
||||
for b in books:
|
||||
if not b['good']:
|
||||
all_good=False
|
||||
break
|
||||
|
||||
##
|
||||
if all_good and not dynamic.get(confirm_config_name(save_size_name), True):
|
||||
return True
|
||||
pass
|
||||
## fake accept?
|
||||
d = DisplayStoryListDialog(gui, header, prefs, icon, books,
|
||||
label_text,
|
||||
save_size_name,
|
||||
offer_skip and all_good)
|
||||
d.exec_()
|
||||
return d.result() == d.Accepted
|
||||
|
||||
class DisplayStoryListDialog(SizePersistedDialog):
|
||||
def __init__(self, gui, header, prefs, icon, books,
|
||||
label_text='',
|
||||
save_size_name='fanfictiondownloader_plugin:display list dialog',
|
||||
offer_skip=False):
|
||||
SizePersistedDialog.__init__(self, gui, save_size_name)
|
||||
self.name = save_size_name
|
||||
self.gui = gui
|
||||
|
||||
self.setWindowTitle(header)
|
||||
self.setWindowIcon(icon)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.setLayout(layout)
|
||||
title_layout = ImageTitleLayout(self, 'images/icon.png',
|
||||
header)
|
||||
layout.addLayout(title_layout)
|
||||
|
||||
self.books_table = StoryListTableWidget(self)
|
||||
layout.addWidget(self.books_table)
|
||||
|
||||
options_layout = QHBoxLayout()
|
||||
self.label = QLabel(label_text)
|
||||
#self.label.setOpenExternalLinks(True)
|
||||
#self.label.setWordWrap(True)
|
||||
options_layout.addWidget(self.label)
|
||||
|
||||
if offer_skip:
|
||||
spacerItem1 = QtGui.QSpacerItem(2, 4, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum)
|
||||
options_layout.addItem(spacerItem1)
|
||||
self.again = QCheckBox('Show this again?',self)
|
||||
self.again.setChecked(True)
|
||||
self.again.stateChanged.connect(self.toggle)
|
||||
self.again.setToolTip('Uncheck to skip review and update stories immediately when no problems.')
|
||||
options_layout.addWidget(self.again)
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
options_layout.addWidget(button_box)
|
||||
|
||||
layout.addLayout(options_layout)
|
||||
|
||||
# Cause our dialog size to be restored from prefs or created on first usage
|
||||
self.resize_dialog()
|
||||
self.books_table.populate_table(books)
|
||||
|
||||
def get_books(self):
|
||||
return self.books_table.get_books()
|
||||
|
||||
def toggle(self, *args):
|
||||
dynamic[confirm_config_name(self.name)] = self.again.isChecked()
|
||||
|
||||
|
||||
|
||||
class StoryListTableWidget(QTableWidget):
|
||||
|
||||
def __init__(self, parent):
|
||||
QTableWidget.__init__(self, parent)
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
|
||||
def populate_table(self, books):
|
||||
self.clear()
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setRowCount(len(books))
|
||||
header_labels = ['','Title', 'Author', 'URL', 'Comment']
|
||||
self.setColumnCount(len(header_labels))
|
||||
self.setHorizontalHeaderLabels(header_labels)
|
||||
self.horizontalHeader().setStretchLastSection(True)
|
||||
#self.verticalHeader().setDefaultSectionSize(24)
|
||||
self.verticalHeader().hide()
|
||||
|
||||
self.books={}
|
||||
for row, book in enumerate(books):
|
||||
self.populate_table_row(row, book)
|
||||
self.books[row] = book
|
||||
|
||||
# turning True breaks up/down. Do we need either sorting or up/down?
|
||||
self.setSortingEnabled(True)
|
||||
self.resizeColumnsToContents()
|
||||
self.setMinimumColumnWidth(1, 100)
|
||||
self.setMinimumColumnWidth(2, 100)
|
||||
self.setMinimumColumnWidth(3, 100)
|
||||
self.setMinimumSize(300, 0)
|
||||
# if len(books) > 0:
|
||||
# self.selectRow(0)
|
||||
self.sortItems(1)
|
||||
self.sortItems(0)
|
||||
|
||||
def setMinimumColumnWidth(self, col, minimum):
|
||||
if self.columnWidth(col) < minimum:
|
||||
self.setColumnWidth(col, minimum)
|
||||
|
||||
def populate_table_row(self, row, book):
|
||||
if book['good']:
|
||||
icon = get_icon('ok.png')
|
||||
val = 0
|
||||
else:
|
||||
icon = get_icon('minus.png')
|
||||
val = 1
|
||||
if 'icon' in book:
|
||||
icon = get_icon(book['icon'])
|
||||
|
||||
status_cell = IconWidgetItem(None,icon,val)
|
||||
status_cell.setData(Qt.UserRole, QVariant(val))
|
||||
self.setItem(row, 0, status_cell)
|
||||
|
||||
title_cell = ReadOnlyTableWidgetItem(book['title'])
|
||||
title_cell.setData(Qt.UserRole, QVariant(row))
|
||||
self.setItem(row, 1, title_cell)
|
||||
|
||||
self.setItem(row, 2, AuthorTableWidgetItem(book['author'], book['author_sort']))
|
||||
|
||||
url_cell = ReadOnlyTableWidgetItem(book['url'])
|
||||
#url_cell.setData(Qt.UserRole, QVariant(book['url']))
|
||||
self.setItem(row, 3, url_cell)
|
||||
|
||||
comment_cell = ReadOnlyTableWidgetItem(book['comment'])
|
||||
#comment_cell.setData(Qt.UserRole, QVariant(book))
|
||||
self.setItem(row, 4, comment_cell)
|
||||
|
||||
def get_books(self):
|
||||
books = []
|
||||
#print("=========================\nbooks:%s"%self.books)
|
||||
for row in range(self.rowCount()):
|
||||
rnum = self.item(row, 1).data(Qt.UserRole).toPyObject()
|
||||
book = self.books[rnum]
|
||||
books.append(book)
|
||||
return books
|
||||
|
||||
def remove_selected_rows(self):
|
||||
self.setFocus()
|
||||
rows = self.selectionModel().selectedRows()
|
||||
if len(rows) == 0:
|
||||
return
|
||||
message = '<p>Are you sure you want to remove this book from the list?'
|
||||
if len(rows) > 1:
|
||||
message = '<p>Are you sure you want to remove the selected %d books from the list?'%len(rows)
|
||||
if not confirm(message,'fanfictiondownloader_delete_item', self):
|
||||
return
|
||||
first_sel_row = self.currentRow()
|
||||
for selrow in reversed(rows):
|
||||
self.removeRow(selrow.row())
|
||||
if first_sel_row < self.rowCount():
|
||||
self.select_and_scroll_to_row(first_sel_row)
|
||||
elif self.rowCount() > 0:
|
||||
self.select_and_scroll_to_row(first_sel_row - 1)
|
||||
|
||||
def select_and_scroll_to_row(self, row):
|
||||
self.selectRow(row)
|
||||
self.scrollToItem(self.currentItem())
|
||||
|
||||
def move_rows_up(self):
|
||||
self.setFocus()
|
||||
rows = self.selectionModel().selectedRows()
|
||||
if len(rows) == 0:
|
||||
return
|
||||
first_sel_row = rows[0].row()
|
||||
if first_sel_row <= 0:
|
||||
return
|
||||
# Workaround for strange selection bug in Qt which "alters" the selection
|
||||
# in certain circumstances which meant move down only worked properly "once"
|
||||
selrows = []
|
||||
for row in rows:
|
||||
selrows.append(row.row())
|
||||
selrows.sort()
|
||||
for selrow in selrows:
|
||||
self.swap_row_widgets(selrow - 1, selrow + 1)
|
||||
scroll_to_row = first_sel_row - 1
|
||||
if scroll_to_row > 0:
|
||||
scroll_to_row = scroll_to_row - 1
|
||||
self.scrollToItem(self.item(scroll_to_row, 0))
|
||||
|
||||
def move_rows_down(self):
|
||||
self.setFocus()
|
||||
rows = self.selectionModel().selectedRows()
|
||||
if len(rows) == 0:
|
||||
return
|
||||
last_sel_row = rows[-1].row()
|
||||
if last_sel_row == self.rowCount() - 1:
|
||||
return
|
||||
# Workaround for strange selection bug in Qt which "alters" the selection
|
||||
# in certain circumstances which meant move down only worked properly "once"
|
||||
selrows = []
|
||||
for row in rows:
|
||||
selrows.append(row.row())
|
||||
selrows.sort()
|
||||
for selrow in reversed(selrows):
|
||||
self.swap_row_widgets(selrow + 2, selrow)
|
||||
scroll_to_row = last_sel_row + 1
|
||||
if scroll_to_row < self.rowCount() - 1:
|
||||
scroll_to_row = scroll_to_row + 1
|
||||
self.scrollToItem(self.item(scroll_to_row, 0))
|
||||
|
||||
def swap_row_widgets(self, src_row, dest_row):
|
||||
self.blockSignals(True)
|
||||
self.insertRow(dest_row)
|
||||
for col in range(0, self.columnCount()):
|
||||
self.setItem(dest_row, col, self.takeItem(src_row, col))
|
||||
self.removeRow(src_row)
|
||||
self.blockSignals(False)
|
||||
@@ -0,0 +1,983 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2012, Jim Miller'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import time, os, copy, threading
|
||||
from ConfigParser import SafeConfigParser
|
||||
from StringIO import StringIO
|
||||
from functools import partial
|
||||
from datetime import datetime
|
||||
|
||||
from PyQt4.Qt import (QApplication, QMenu, QToolButton)
|
||||
|
||||
from PyQt4.Qt import QPixmap, Qt
|
||||
from PyQt4.QtCore import QBuffer
|
||||
|
||||
|
||||
from calibre.ptempfile import PersistentTemporaryFile, PersistentTemporaryDirectory, remove_dir
|
||||
from calibre.ebooks.metadata import MetaInformation, authors_to_string
|
||||
from calibre.ebooks.metadata.meta import get_metadata
|
||||
from calibre.gui2 import error_dialog, warning_dialog, question_dialog, info_dialog
|
||||
from calibre.gui2.dialogs.message_box import ViewLog
|
||||
from calibre.gui2.dialogs.confirm_delete import confirm
|
||||
from calibre.utils.date import local_tz
|
||||
|
||||
# The class that all interface action plugins must inherit from
|
||||
from calibre.gui2.actions import InterfaceAction
|
||||
|
||||
from calibre_plugins.fanfictiondownloader_plugin.common_utils import (set_plugin_icon_resources, get_icon,
|
||||
create_menu_action_unique, get_library_uuid)
|
||||
|
||||
from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters, writers, exceptions
|
||||
from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.htmlcleanup import stripHTML
|
||||
from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.epubutils import get_dcsource, get_dcsource_chaptercount
|
||||
|
||||
from calibre_plugins.fanfictiondownloader_plugin.config import (prefs, permitted_values)
|
||||
from calibre_plugins.fanfictiondownloader_plugin.dialogs import (
|
||||
AddNewDialog, UpdateExistingDialog, display_story_list, DisplayStoryListDialog,
|
||||
LoopProgressDialog, UserPassDialog, AboutDialog,
|
||||
OVERWRITE, OVERWRITEALWAYS, UPDATE, UPDATEALWAYS, ADDNEW, SKIP, CALIBREONLY,
|
||||
NotGoingToDownload )
|
||||
|
||||
# because calibre immediately transforms html into zip and don't want
|
||||
# to have an 'if html'. db.has_format is cool with the case mismatch,
|
||||
# but if I'm doing it anyway...
|
||||
formmapping = {
|
||||
'epub':'EPUB',
|
||||
'mobi':'MOBI',
|
||||
'html':'ZIP',
|
||||
'txt':'TXT'
|
||||
}
|
||||
|
||||
PLUGIN_ICONS = ['images/icon.png']
|
||||
|
||||
class FanFictionDownLoaderPlugin(InterfaceAction):
|
||||
|
||||
name = 'FanFictionDownLoader'
|
||||
|
||||
# Declare the main action associated with this plugin
|
||||
# The keyboard shortcut can be None if you dont want to use a keyboard
|
||||
# shortcut. Remember that currently calibre has no central management for
|
||||
# keyboard shortcuts, so try to use an unusual/unused shortcut.
|
||||
# (text, icon_path, tooltip, keyboard shortcut)
|
||||
# icon_path isn't in the zip--icon loaded below.
|
||||
action_spec = (name, None,
|
||||
'Download FanFiction stories from various web sites', ())
|
||||
# None for keyboard shortcut doesn't allow shortcut. () does, there just isn't one yet
|
||||
|
||||
action_type = 'global'
|
||||
# make button menu drop down only
|
||||
#popup_type = QToolButton.InstantPopup
|
||||
|
||||
def genesis(self):
|
||||
|
||||
# This method is called once per plugin, do initial setup here
|
||||
|
||||
# Read the plugin icons and store for potential sharing with the config widget
|
||||
icon_resources = self.load_resources(PLUGIN_ICONS)
|
||||
set_plugin_icon_resources(self.name, icon_resources)
|
||||
|
||||
base = self.interface_action_base_plugin
|
||||
self.version = base.name+" v%d.%d.%d"%base.version
|
||||
|
||||
# Set the icon for this interface action
|
||||
# The get_icons function is a builtin function defined for all your
|
||||
# plugin code. It loads icons from the plugin zip file. It returns
|
||||
# QIcon objects, if you want the actual data, use the analogous
|
||||
# get_resources builtin function.
|
||||
|
||||
# Note that if you are loading more than one icon, for performance, you
|
||||
# should pass a list of names to get_icons. In this case, get_icons
|
||||
# will return a dictionary mapping names to QIcons. Names that
|
||||
# are not found in the zip file will result in null QIcons.
|
||||
icon = get_icon('images/icon.png')
|
||||
|
||||
#self.qaction.setText('FFDL')
|
||||
|
||||
# The qaction is automatically created from the action_spec defined
|
||||
# above
|
||||
self.qaction.setIcon(icon)
|
||||
|
||||
# Call function when plugin triggered.
|
||||
self.qaction.triggered.connect(self.plugin_button)
|
||||
|
||||
# Assign our menu to this action
|
||||
self.menu = QMenu(self.gui)
|
||||
self.old_actions_unique_map = {}
|
||||
self.qaction.setMenu(self.menu)
|
||||
self.menu.aboutToShow.connect(self.about_to_show_menu)
|
||||
|
||||
self.menus_lock = threading.RLock()
|
||||
|
||||
def initialization_complete(self):
|
||||
# otherwise configured hot keys won't work until the menu's
|
||||
# been displayed once.
|
||||
self.rebuild_menus()
|
||||
|
||||
def about_to_show_menu(self):
|
||||
self.rebuild_menus()
|
||||
|
||||
def library_changed(self, db):
|
||||
# We need to reset our menus after switching libraries
|
||||
self.rebuild_menus()
|
||||
|
||||
def rebuild_menus(self):
|
||||
with self.menus_lock:
|
||||
# Show the config dialog
|
||||
# The config dialog can also be shown from within
|
||||
# Preferences->Plugins, which is why the do_user_config
|
||||
# method is defined on the base plugin class
|
||||
do_user_config = self.interface_action_base_plugin.do_user_config
|
||||
self.menu.clear()
|
||||
self.actions_unique_map = {}
|
||||
self.add_action = self.create_menu_item_ex(self.menu, '&Add New from URL(s)', image='plus.png',
|
||||
unique_name='Add New FanFiction Book(s) from URL(s)',
|
||||
shortcut_name='Add New FanFiction Book(s) from URL(s)',
|
||||
triggered=self.add_dialog )
|
||||
|
||||
self.update_action = self.create_menu_item_ex(self.menu, '&Update Existing FanFiction Book(s)', image='plusplus.png',
|
||||
unique_name='Update Existing FanFiction Book(s)',
|
||||
shortcut_name='Update Existing FanFiction Book(s)',
|
||||
triggered=self.update_existing)
|
||||
|
||||
if 'Reading List' in self.gui.iactions and (prefs['addtolists'] or prefs['addtoreadlists']) :
|
||||
self.menu.addSeparator()
|
||||
addmenutxt, rmmenutxt = None, None
|
||||
if prefs['addtolists'] and prefs['addtoreadlists'] :
|
||||
addmenutxt = 'Add to "To Read" and "Send to Device" Lists'
|
||||
if prefs['addtolistsonread']:
|
||||
rmmenutxt = 'Remove from "To Read" and add to "Send to Device" Lists'
|
||||
else:
|
||||
rmmenutxt = 'Remove from "To Read" Lists'
|
||||
elif prefs['addtolists'] :
|
||||
addmenutxt = 'Add Selected to "Send to Device" Lists'
|
||||
elif prefs['addtoreadlists']:
|
||||
addmenutxt = 'Add to "To Read" Lists'
|
||||
rmmenutxt = 'Remove from "To Read" Lists'
|
||||
|
||||
if addmenutxt:
|
||||
self.add_send_action = self.create_menu_item_ex(self.menu, addmenutxt, image='plusplus.png',
|
||||
unique_name=addmenutxt,
|
||||
shortcut_name=addmenutxt,
|
||||
triggered=partial(self.update_lists,add=True))
|
||||
|
||||
if rmmenutxt:
|
||||
self.add_remove_action = self.create_menu_item_ex(self.menu, rmmenutxt, image='minusminus.png',
|
||||
unique_name=rmmenutxt,
|
||||
shortcut_name=rmmenutxt,
|
||||
triggered=partial(self.update_lists,add=False))
|
||||
|
||||
# try:
|
||||
# self.add_send_action.setEnabled( len(self.gui.library_view.get_selected_ids()) > 0 )
|
||||
# except:
|
||||
# pass
|
||||
# try:
|
||||
# self.add_remove_action.setEnabled( len(self.gui.library_view.get_selected_ids()) > 0 )
|
||||
# except:
|
||||
# pass
|
||||
|
||||
self.menu.addSeparator()
|
||||
self.get_list_action = self.create_menu_item_ex(self.menu, 'Get URLs from Selected Books', image='bookmarks.png',
|
||||
unique_name='Get URLs from Selected Books',
|
||||
shortcut_name='Get URLs from Selected Books',
|
||||
triggered=self.get_list_urls)
|
||||
|
||||
self.menu.addSeparator()
|
||||
self.config_action = create_menu_action_unique(self, self.menu, '&Configure Plugin', shortcut=False,
|
||||
image= 'config.png',
|
||||
unique_name='Configure FanFictionDownLoader',
|
||||
shortcut_name='Configure FanFictionDownLoader',
|
||||
triggered=partial(do_user_config,parent=self.gui))
|
||||
|
||||
self.config_action = create_menu_action_unique(self, self.menu, '&About Plugin', shortcut=False,
|
||||
image= 'images/icon.png',
|
||||
unique_name='About FanFictionDownLoader',
|
||||
shortcut_name='About FanFictionDownLoader',
|
||||
triggered=self.about)
|
||||
|
||||
# Before we finalize, make sure we delete any actions for menus that are no longer displayed
|
||||
for menu_id, unique_name in self.old_actions_unique_map.iteritems():
|
||||
if menu_id not in self.actions_unique_map:
|
||||
self.gui.keyboard.unregister_shortcut(unique_name)
|
||||
self.old_actions_unique_map = self.actions_unique_map
|
||||
self.gui.keyboard.finalize()
|
||||
|
||||
def about(self):
|
||||
# Get the about text from a file inside the plugin zip file
|
||||
# The get_resources function is a builtin function defined for all your
|
||||
# plugin code. It loads files from the plugin zip file. It returns
|
||||
# the bytes from the specified file.
|
||||
#
|
||||
# Note that if you are loading more than one file, for performance, you
|
||||
# should pass a list of names to get_resources. In this case,
|
||||
# get_resources will return a dictionary mapping names to bytes. Names that
|
||||
# are not found in the zip file will not be in the returned dictionary.
|
||||
|
||||
text = get_resources('about.txt')
|
||||
AboutDialog(self.gui,self.qaction.icon(),self.version + text).exec_()
|
||||
|
||||
def create_menu_item_ex(self, parent_menu, menu_text, image=None, tooltip=None,
|
||||
shortcut=None, triggered=None, is_checked=None, shortcut_name=None,
|
||||
unique_name=None):
|
||||
ac = create_menu_action_unique(self, parent_menu, menu_text, image, tooltip,
|
||||
shortcut, triggered, is_checked, shortcut_name, unique_name)
|
||||
self.actions_unique_map[ac.calibre_shortcut_unique_name] = ac.calibre_shortcut_unique_name
|
||||
return ac
|
||||
|
||||
def plugin_button(self):
|
||||
if len(self.gui.library_view.get_selected_ids()) > 0 and prefs['updatedefault']:
|
||||
self.update_existing()
|
||||
else:
|
||||
self.add_dialog()
|
||||
|
||||
def update_lists(self,add=True):
|
||||
if len(self.gui.library_view.get_selected_ids()) > 0 and \
|
||||
(prefs['addtolists'] or prefs['addtoreadlists']) :
|
||||
self._update_reading_lists(self.gui.library_view.get_selected_ids(),add)
|
||||
|
||||
def get_list_urls(self):
|
||||
if len(self.gui.library_view.get_selected_ids()) > 0:
|
||||
book_list = map( partial(self._convert_id_to_book, good=False), self.gui.library_view.get_selected_ids() )
|
||||
|
||||
LoopProgressDialog(self.gui,
|
||||
book_list,
|
||||
partial(self._get_story_url_for_list, db=self.gui.current_db),
|
||||
self._finish_get_list_urls,
|
||||
init_label="Collecting URLs for stories...",
|
||||
win_title="Get URLs for stories",
|
||||
status_prefix="URL retrieved")
|
||||
|
||||
def _get_story_url_for_list(self,book,db=None):
|
||||
book['url'] = self._get_story_url(db,book['calibre_id'])
|
||||
if book['url'] == None:
|
||||
book['good']=False
|
||||
else:
|
||||
book['good']=True
|
||||
|
||||
def _finish_get_list_urls(self, book_list):
|
||||
url_list = [ x['url'] for x in book_list if x['good'] ]
|
||||
if url_list:
|
||||
d = ViewLog(_("List of URLs"),"\n".join(url_list),parent=self.gui)
|
||||
d.setWindowIcon(get_icon('bookmarks.png'))
|
||||
d.exec_()
|
||||
else:
|
||||
info_dialog(self.gui, _('List of URLs'),
|
||||
_('No URLs found in selected books.'),
|
||||
show=True,
|
||||
show_copy_button=False)
|
||||
|
||||
def add_dialog(self):
|
||||
|
||||
#print("add_dialog()")
|
||||
|
||||
url_list = self.get_urls_clip()
|
||||
url_list_text = "\n".join(url_list)
|
||||
|
||||
# self.gui is the main calibre GUI. It acts as the gateway to access
|
||||
# all the elements of the calibre user interface, it should also be the
|
||||
# parent of the dialog
|
||||
# AddNewDialog just collects URLs, format and presents buttons.
|
||||
d = AddNewDialog(self.gui,
|
||||
prefs,
|
||||
self.qaction.icon(),
|
||||
url_list_text,
|
||||
)
|
||||
d.exec_()
|
||||
if d.result() != d.Accepted:
|
||||
return
|
||||
|
||||
url_list = get_url_list(d.get_urlstext())
|
||||
add_books = self._convert_urls_to_books(url_list)
|
||||
#print("add_books:%s"%add_books)
|
||||
#print("options:%s"%d.get_ffdl_options())
|
||||
|
||||
options = d.get_ffdl_options()
|
||||
options['version'] = self.version
|
||||
print(self.version)
|
||||
|
||||
self.start_downloads( options, add_books )
|
||||
|
||||
def update_existing(self):
|
||||
if len(self.gui.library_view.get_selected_ids()) == 0:
|
||||
return
|
||||
#print("update_existing()")
|
||||
|
||||
db = self.gui.current_db
|
||||
book_list = map( partial(self._convert_id_to_book, good=False), self.gui.library_view.get_selected_ids() )
|
||||
#book_ids = self.gui.library_view.get_selected_ids()
|
||||
|
||||
LoopProgressDialog(self.gui,
|
||||
book_list,
|
||||
partial(self._populate_book_from_calibre_id, db=self.gui.current_db),
|
||||
self._update_existing_2,
|
||||
init_label="Collecting stories for update...",
|
||||
win_title="Get stories for updates",
|
||||
status_prefix="URL retrieved")
|
||||
|
||||
#books = self._convert_calibre_ids_to_books(db, book_ids)
|
||||
#print("update books:%s"%books)
|
||||
|
||||
def _update_existing_2(self,book_list):
|
||||
|
||||
d = UpdateExistingDialog(self.gui,
|
||||
'Update Existing List',
|
||||
prefs,
|
||||
self.qaction.icon(),
|
||||
book_list,
|
||||
)
|
||||
d.exec_()
|
||||
if d.result() != d.Accepted:
|
||||
return
|
||||
|
||||
update_books = d.get_books()
|
||||
|
||||
#print("update_books:%s"%update_books)
|
||||
#print("options:%s"%d.get_ffdl_options())
|
||||
# only if there's some good ones.
|
||||
if 0 < len(filter(lambda x : x['good'], update_books)):
|
||||
options = d.get_ffdl_options()
|
||||
options['version'] = self.version
|
||||
print(self.version)
|
||||
self.start_downloads( options, update_books )
|
||||
|
||||
def get_urls_clip(self):
|
||||
url_list = []
|
||||
if prefs['urlsfromclip']:
|
||||
for url in unicode(QApplication.instance().clipboard().text()).split():
|
||||
if( self._is_good_downloader_url(url) ):
|
||||
url_list.append(url)
|
||||
return url_list
|
||||
|
||||
def apply_settings(self):
|
||||
# No need to do anything with perfs here, but we could.
|
||||
prefs
|
||||
|
||||
def start_downloads(self, options, books):
|
||||
|
||||
#print("start_downloads:%s"%books)
|
||||
|
||||
# create and pass temp dir.
|
||||
tdir = PersistentTemporaryDirectory(prefix='fanfictiondownloader_')
|
||||
options['tdir']=tdir
|
||||
|
||||
self.gui.status_bar.show_message(_('Started fetching metadata for %s stories.'%len(books)), 3000)
|
||||
|
||||
if 0 < len(filter(lambda x : x['good'], books)):
|
||||
LoopProgressDialog(self.gui,
|
||||
books,
|
||||
partial(self.get_metadata_for_book, options = options),
|
||||
partial(self.start_download_list, options = options))
|
||||
# LoopProgressDialog calls get_metadata_for_book for each 'good' story,
|
||||
# get_metadata_for_book updates book for each,
|
||||
# LoopProgressDialog calls start_download_list at the end which goes
|
||||
# into the BG, or shows list if no 'good' books.
|
||||
|
||||
def get_metadata_for_book(self,book,
|
||||
options={'fileform':'epub',
|
||||
'collision':ADDNEW,
|
||||
'updatemeta':True}):
|
||||
'''
|
||||
Update passed in book dict with metadata from website and
|
||||
necessary data. To be called from LoopProgressDialog
|
||||
'loop'. Also pops dialogs for is adult, user/pass.
|
||||
'''
|
||||
|
||||
# The current database shown in the GUI
|
||||
# db is an instance of the class LibraryDatabase2 from database.py
|
||||
# This class has many, many methods that allow you to do a lot of
|
||||
# things.
|
||||
db = self.gui.current_db
|
||||
|
||||
fileform = options['fileform']
|
||||
collision = options['collision']
|
||||
updatemeta= options['updatemeta']
|
||||
|
||||
if not book['good']:
|
||||
# book has already been flagged bad for whatever reason.
|
||||
return
|
||||
|
||||
url = book['url']
|
||||
print("url:%s"%url)
|
||||
skip_date_update = False
|
||||
|
||||
## was self.ffdlconfig, but we need to be able to change it
|
||||
## when doing epub update.
|
||||
ffdlconfig = SafeConfigParser()
|
||||
ffdlconfig.readfp(StringIO(get_resources("plugin-defaults.ini")))
|
||||
ffdlconfig.readfp(StringIO(prefs['personal.ini']))
|
||||
adapter = adapters.getAdapter(ffdlconfig,url,fileform)
|
||||
|
||||
options['personal.ini'] = prefs['personal.ini']
|
||||
|
||||
## three tries, that's enough if both user/pass & is_adult needed,
|
||||
## or a couple tries of one or the other
|
||||
for x in range(0,2):
|
||||
try:
|
||||
adapter.getStoryMetadataOnly()
|
||||
except exceptions.FailedToLogin:
|
||||
print("Login Failed, Need Username/Password.")
|
||||
userpass = UserPassDialog(self.gui,url)
|
||||
userpass.exec_() # exec_ will make it act modal
|
||||
if userpass.status:
|
||||
adapter.username = userpass.user.text()
|
||||
adapter.password = userpass.passwd.text()
|
||||
|
||||
except exceptions.AdultCheckRequired:
|
||||
if question_dialog(self.gui, 'Are You Adult?', '<p>'+
|
||||
"%s requires that you be an adult. Please confirm you are an adult in your locale:"%url,
|
||||
show_copy_button=False):
|
||||
adapter.is_adult=True
|
||||
|
||||
# let other exceptions percolate up.
|
||||
story = adapter.getStoryMetadataOnly()
|
||||
writer = writers.getWriter(options['fileform'],adapter.config,adapter)
|
||||
|
||||
book['all_metadata'] = story.getAllMetadata(removeallentities=True)
|
||||
book['title'] = story.getMetadata("title", removeallentities=True)
|
||||
book['author_sort'] = book['author'] = story.getMetadata("author", removeallentities=True)
|
||||
book['publisher'] = story.getMetadata("site")
|
||||
book['tags'] = writer.getTags()
|
||||
book['comments'] = stripHTML(story.getMetadata("description")) #, removeallentities=True) comments handles entities better.
|
||||
book['series'] = story.getMetadata("series")
|
||||
|
||||
# adapter.opener is the element with a threadlock. But del
|
||||
# adapter.opener doesn't work--subproc fails when it tries
|
||||
# to pull in the adapter object that hasn't been imported yet.
|
||||
# book['adapter'] = adapter
|
||||
|
||||
book['is_adult'] = adapter.is_adult
|
||||
book['username'] = adapter.username
|
||||
book['password'] = adapter.password
|
||||
|
||||
book['icon'] = 'plus.png'
|
||||
if story.getMetadataRaw('datePublished'):
|
||||
# should only happen when an adapter is broken, but better to
|
||||
# fail gracefully.
|
||||
book['pubdate'] = story.getMetadataRaw('datePublished').replace(tzinfo=local_tz)
|
||||
book['timestamp'] = None # filled below if not skipped.
|
||||
|
||||
if collision in (CALIBREONLY):
|
||||
book['icon'] = 'metadata.png'
|
||||
|
||||
# Dialogs should prevent this case now.
|
||||
if collision in (UPDATE,UPDATEALWAYS) and fileform != 'epub':
|
||||
raise NotGoingToDownload("Cannot update non-epub format.")
|
||||
|
||||
book_id = None
|
||||
|
||||
if book['calibre_id'] != None:
|
||||
# updating an existing book. Update mode applies.
|
||||
print("update existing id:%s"%book['calibre_id'])
|
||||
book_id = book['calibre_id']
|
||||
# No handling needed: OVERWRITEALWAYS,CALIBREONLY
|
||||
|
||||
# only care about collisions when not ADDNEW
|
||||
elif collision != ADDNEW:
|
||||
# 'new' book from URL. collision handling applies.
|
||||
print("from URL")
|
||||
|
||||
# find dups
|
||||
mi = MetaInformation(story.getMetadata("title", removeallentities=True),
|
||||
(story.getMetadata("author", removeallentities=True),)) # author is a list.
|
||||
identicalbooks = db.find_identical_books(mi)
|
||||
## removed for being overkill.
|
||||
# for ib in identicalbooks:
|
||||
# # only *really* identical if URL matches, too.
|
||||
# # XXX make an option?
|
||||
# if self._get_story_url(db,ib) == url:
|
||||
# identicalbooks.append(ib)
|
||||
#print("identicalbooks:%s"%identicalbooks)
|
||||
|
||||
if collision == SKIP and identicalbooks:
|
||||
raise NotGoingToDownload("Skipping duplicate story.","list_remove.png")
|
||||
|
||||
if len(identicalbooks) > 1:
|
||||
raise NotGoingToDownload("More than one identical book--can't tell which to update/overwrite.","minusminus.png")
|
||||
|
||||
if collision == CALIBREONLY and not identicalbooks:
|
||||
raise NotGoingToDownload("Not updating Calibre Metadata, no existing book to update.","search_delete_saved.png")
|
||||
|
||||
if len(identicalbooks)>0:
|
||||
book_id = identicalbooks.pop()
|
||||
book['calibre_id'] = book_id
|
||||
book['icon'] = 'edit-redo.png'
|
||||
|
||||
if book_id != None and collision != ADDNEW:
|
||||
if options['collision'] in (CALIBREONLY):
|
||||
book['comment'] = 'Metadata collected.'
|
||||
# don't need temp file created below.
|
||||
return
|
||||
|
||||
## newer/chaptercount checks are the same for both:
|
||||
# Update epub, but only if more chapters.
|
||||
if collision in (UPDATE,UPDATEALWAYS): # collision == UPDATE
|
||||
# 'book' can exist without epub. If there's no existing epub,
|
||||
# let it go and it will download it.
|
||||
if db.has_format(book_id,fileform,index_is_id=True):
|
||||
(epuburl,chaptercount) = \
|
||||
get_dcsource_chaptercount(StringIO(db.format(book_id,'EPUB',
|
||||
index_is_id=True)))
|
||||
urlchaptercount = int(story.getMetadata('numChapters'))
|
||||
if chaptercount == urlchaptercount:
|
||||
if collision == UPDATE:
|
||||
raise NotGoingToDownload("Already contains %d chapters."%chaptercount,'edit-undo.png')
|
||||
else:
|
||||
# UPDATEALWAYS
|
||||
skip_date_update = True
|
||||
elif chaptercount > urlchaptercount:
|
||||
raise NotGoingToDownload("Existing epub contains %d chapters, web site only has %d. Use Overwrite to force update." % (chaptercount,urlchaptercount),'dialog_error.png')
|
||||
|
||||
if collision == OVERWRITE and \
|
||||
db.has_format(book_id,formmapping[fileform],index_is_id=True):
|
||||
# check make sure incoming is newer.
|
||||
lastupdated=story.getMetadataRaw('dateUpdated').date()
|
||||
fileupdated=datetime.fromtimestamp(os.stat(db.format_abspath(book_id, formmapping[fileform], index_is_id=True))[8]).date()
|
||||
if fileupdated > lastupdated:
|
||||
raise NotGoingToDownload("Not Overwriting, web site is not newer.",'edit-undo.png')
|
||||
|
||||
# For update, provide a tmp file copy of the existing epub so
|
||||
# it can't change underneath us.
|
||||
if collision in (UPDATE,UPDATEALWAYS) and \
|
||||
db.has_format(book['calibre_id'],'EPUB',index_is_id=True):
|
||||
tmp = PersistentTemporaryFile(prefix='old-%s-'%book['calibre_id'],
|
||||
suffix='.epub',
|
||||
dir=options['tdir'])
|
||||
db.copy_format_to(book_id,fileform,tmp,index_is_id=True)
|
||||
print("existing epub tmp:"+tmp.name)
|
||||
book['epub_for_update'] = tmp.name
|
||||
|
||||
if collision != CALIBREONLY and not skip_date_update:
|
||||
# I'm half convinced this should be dateUpdated instead, but
|
||||
# this behavior matches how epubs come out when imported
|
||||
# dateCreated == packaged--epub/etc created.
|
||||
book['timestamp'] = story.getMetadataRaw('dateCreated').replace(tzinfo=local_tz)
|
||||
|
||||
if book['good']: # there shouldn't be any !'good' books at this point.
|
||||
# if still 'good', make a temp file to write the output to.
|
||||
tmp = PersistentTemporaryFile(prefix='new-%s-'%book['calibre_id'],
|
||||
suffix='.'+options['fileform'],
|
||||
dir=options['tdir'])
|
||||
print("title:"+book['title'])
|
||||
print("outfile:"+tmp.name)
|
||||
book['outfile'] = tmp.name
|
||||
|
||||
return
|
||||
|
||||
def start_download_list(self,book_list,
|
||||
options={'fileform':'epub',
|
||||
'collision':ADDNEW,
|
||||
'updatemeta':True}):
|
||||
'''
|
||||
Called by LoopProgressDialog to start story downloads BG processing.
|
||||
adapter_list is a list of tuples of (url,adapter)
|
||||
'''
|
||||
#print("start_download_list:book_list:%s"%book_list)
|
||||
|
||||
## No need to BG process when CALIBREONLY! Fake it.
|
||||
if options['collision'] in (CALIBREONLY):
|
||||
class NotJob(object):
|
||||
def __init__(self,result):
|
||||
self.failed=False
|
||||
self.result=result
|
||||
notjob = NotJob(book_list)
|
||||
self.download_list_completed(notjob,options=options)
|
||||
return
|
||||
|
||||
for book in book_list:
|
||||
if book['good']:
|
||||
break
|
||||
else:
|
||||
## No good stories to try to download, go straight to
|
||||
## list.
|
||||
d = DisplayStoryListDialog(self.gui,
|
||||
'Nothing to Download',
|
||||
prefs,
|
||||
self.qaction.icon(),
|
||||
book_list,
|
||||
label_text='None of the URLs/stories given can be/need to be downloaded.'
|
||||
)
|
||||
d.exec_()
|
||||
return
|
||||
|
||||
func = 'arbitrary_n'
|
||||
cpus = self.gui.job_manager.server.pool_size
|
||||
args = ['calibre_plugins.fanfictiondownloader_plugin.jobs', 'do_download_worker',
|
||||
(book_list, options, cpus)]
|
||||
desc = 'Download FanFiction Book'
|
||||
job = self.gui.job_manager.run_job(
|
||||
self.Dispatcher(partial(self.download_list_completed,options=options)),
|
||||
func, args=args,
|
||||
description=desc)
|
||||
|
||||
self.gui.status_bar.show_message('Starting %d FanFictionDownLoads'%len(book_list),3000)
|
||||
|
||||
def _update_book(self,book,db=None,
|
||||
options={'fileform':'epub',
|
||||
'collision':ADDNEW,
|
||||
'updatemeta':True}):
|
||||
print("add/update %s %s"%(book['title'],book['url']))
|
||||
mi = self._make_mi_from_book(book)
|
||||
|
||||
if options['collision'] != CALIBREONLY:
|
||||
self._add_or_update_book(book,options,prefs,mi)
|
||||
|
||||
if options['collision'] == CALIBREONLY or \
|
||||
(options['updatemeta'] and book['good']):
|
||||
self._update_metadata(db, book['calibre_id'], book, mi, options)
|
||||
|
||||
def _update_books_completed(self, book_list, options={}):
|
||||
|
||||
add_list = filter(lambda x : x['good'] and x['added'], book_list)
|
||||
update_list = filter(lambda x : x['good'] and not x['added'], book_list)
|
||||
update_ids = [ x['calibre_id'] for x in update_list ]
|
||||
|
||||
if len(add_list):
|
||||
## even shows up added to searchs. Nice.
|
||||
self.gui.library_view.model().books_added(len(add_list))
|
||||
|
||||
if update_ids:
|
||||
self.gui.library_view.model().refresh_ids(update_ids)
|
||||
|
||||
current = self.gui.library_view.currentIndex()
|
||||
self.gui.library_view.model().current_changed(current, self.previous)
|
||||
self.gui.tags_view.recount()
|
||||
|
||||
if self.gui.cover_flow:
|
||||
self.gui.cover_flow.dataChanged()
|
||||
|
||||
self.gui.status_bar.show_message(_('Finished Adding/Updating %d books.'%(len(update_list) + len(add_list))), 3000)
|
||||
|
||||
if len(update_list) + len(add_list) != len(book_list):
|
||||
d = DisplayStoryListDialog(self.gui,
|
||||
'Updates completed, final status',
|
||||
prefs,
|
||||
self.qaction.icon(),
|
||||
book_list,
|
||||
label_text='Stories have be added or updated in Calibre, some had additional problems.'
|
||||
)
|
||||
d.exec_()
|
||||
|
||||
print("all done, remove temp dir.")
|
||||
remove_dir(options['tdir'])
|
||||
|
||||
def download_list_completed(self, job, options={}):
|
||||
if job.failed:
|
||||
self.gui.job_exception(job, dialog_title='Failed to Download Stories')
|
||||
return
|
||||
|
||||
self.previous = self.gui.library_view.currentIndex()
|
||||
db = self.gui.current_db
|
||||
|
||||
if display_story_list(self.gui,
|
||||
'Downloads finished, confirm to update Calibre',
|
||||
prefs,
|
||||
self.qaction.icon(),
|
||||
job.result,
|
||||
label_text='Stories will not be added or updated in Calibre without confirmation.',
|
||||
offer_skip=True):
|
||||
|
||||
book_list = job.result
|
||||
good_list = filter(lambda x : x['good'], book_list)
|
||||
total_good = len(good_list)
|
||||
|
||||
self.gui.status_bar.show_message(_('Adding/Updating %s books.'%total_good))
|
||||
|
||||
if total_good > 0:
|
||||
LoopProgressDialog(self.gui,
|
||||
good_list,
|
||||
partial(self._update_book, options=options, db=self.gui.current_db),
|
||||
partial(self._update_books_completed, options=options),
|
||||
init_label="Updating calibre for stories...",
|
||||
win_title="Update calibre for stories",
|
||||
status_prefix="Updated")
|
||||
|
||||
def _add_or_update_book(self,book,options,prefs,mi=None):
|
||||
db = self.gui.current_db
|
||||
|
||||
if mi == None:
|
||||
mi = self._make_mi_from_book(book)
|
||||
|
||||
book_id = book['calibre_id']
|
||||
if book_id == None:
|
||||
book_id = db.create_book_entry(mi,
|
||||
add_duplicates=True)
|
||||
book['calibre_id'] = book_id
|
||||
book['added'] = True
|
||||
else:
|
||||
book['added'] = False
|
||||
|
||||
if not db.add_format_with_hooks(book_id,
|
||||
options['fileform'],
|
||||
book['outfile'], index_is_id=True):
|
||||
book['comment'] = "Adding format to book failed for some reason..."
|
||||
book['good']=False
|
||||
book['icon']='dialog_error.png'
|
||||
|
||||
if prefs['deleteotherforms']:
|
||||
fmts = db.formats(book['calibre_id'], index_is_id=True).split(',')
|
||||
for fmt in fmts:
|
||||
if fmt != formmapping[options['fileform']]:
|
||||
print("remove f:"+fmt)
|
||||
db.remove_format(book['calibre_id'], fmt, index_is_id=True)#, notify=False
|
||||
|
||||
if prefs['addtolists'] or prefs['addtoreadlists']:
|
||||
self._update_reading_lists([book_id],add=True)
|
||||
|
||||
return book_id
|
||||
|
||||
def _update_metadata(self, db, book_id, book, mi, options):
|
||||
if prefs['keeptags']:
|
||||
old_tags = db.get_tags(book_id)
|
||||
# remove old Completed/In-Progress only if there's a new one.
|
||||
if 'Completed' in mi.tags or 'In-Progress' in mi.tags:
|
||||
old_tags = filter( lambda x : x not in ('Completed', 'In-Progress'), old_tags)
|
||||
# remove old Last Update tags if there are new ones.
|
||||
if len(filter( lambda x : not x.startswith("Last Update"), mi.tags)) > 0:
|
||||
old_tags = filter( lambda x : not x.startswith("Last Update"), old_tags)
|
||||
# mi.tags needs to be list, but set kills dups.
|
||||
mi.tags = list(set(list(old_tags)+mi.tags))
|
||||
|
||||
if 'langcode' in book['all_metadata']:
|
||||
mi.languages=[book['all_metadata']['langcode']]
|
||||
else:
|
||||
# Set language english, but only if not already set.
|
||||
oldmi = db.get_metadata(book_id,index_is_id=True)
|
||||
if not oldmi.languages:
|
||||
mi.languages=['eng']
|
||||
|
||||
if options['fileform'] == 'epub' and prefs['updatecover']:
|
||||
existingepub = db.format(book_id,'EPUB',index_is_id=True, as_file=True)
|
||||
epubmi = get_metadata(existingepub,'EPUB')
|
||||
if epubmi.cover_data[1] is not None:
|
||||
db.set_cover(book_id, epubmi.cover_data[1])
|
||||
#mi.cover = epubmi.cover_data[1]
|
||||
|
||||
db.set_metadata(book_id,mi)
|
||||
|
||||
# do configured column updates here.
|
||||
#print("all_metadata: %s"%book['all_metadata'])
|
||||
custom_columns = self.gui.library_view.model().custom_columns
|
||||
|
||||
#print("prefs['custom_cols'] %s"%prefs['custom_cols'])
|
||||
for col, meta in prefs['custom_cols'].iteritems():
|
||||
#print("setting %s to %s"%(col,meta))
|
||||
if col not in custom_columns:
|
||||
print("%s not an existing column, skipping."%col)
|
||||
continue
|
||||
coldef = custom_columns[col]
|
||||
if not meta.startswith('status-') and meta not in book['all_metadata']:
|
||||
print("No value for %s, skipping."%meta)
|
||||
continue
|
||||
if meta not in permitted_values[coldef['datatype']]:
|
||||
print("%s not a valid column type for %s, skipping."%(col,meta))
|
||||
continue
|
||||
label = coldef['label']
|
||||
if coldef['datatype'] in ('enumeration','text','comments','datetime','series'):
|
||||
db.set_custom(book_id, book['all_metadata'][meta], label=label, commit=False)
|
||||
elif coldef['datatype'] in ('int','float'):
|
||||
num = unicode(book['all_metadata'][meta]).replace(",","")
|
||||
db.set_custom(book_id, num, label=label, commit=False)
|
||||
elif coldef['datatype'] == 'bool' and meta.startswith('status-'):
|
||||
if meta == 'status-C':
|
||||
val = book['all_metadata']['status'] == 'Completed'
|
||||
if meta == 'status-I':
|
||||
val = book['all_metadata']['status'] == 'In-Progress'
|
||||
db.set_custom(book_id, val, label=label, commit=False)
|
||||
|
||||
db.commit()
|
||||
|
||||
def _get_clean_reading_lists(self,lists):
|
||||
if lists == None or lists.strip() == "" :
|
||||
return []
|
||||
else:
|
||||
return filter( lambda x : x, map( lambda x : x.strip(), lists.split(',') ) )
|
||||
|
||||
def _update_reading_lists(self,book_ids,add=True):
|
||||
try:
|
||||
rl_plugin = self.gui.iactions['Reading List']
|
||||
except:
|
||||
if prefs['addtolists'] or prefs['addtoreadlists']:
|
||||
message="<p>You configured FanFictionDownLoader to automatically update Reading Lists, but you don't have the Reading List plugin installed anymore?</p>"
|
||||
confirm(message,'fanfictiondownloader_no_reading_list_plugin', self.gui)
|
||||
return
|
||||
|
||||
# XXX check for existence of lists, warning if not.
|
||||
if prefs['addtoreadlists']:
|
||||
if add:
|
||||
addremovefunc = rl_plugin.add_books_to_list
|
||||
else:
|
||||
addremovefunc = rl_plugin.remove_books_from_list
|
||||
|
||||
lists = self._get_clean_reading_lists(prefs['read_lists'])
|
||||
if len(lists) < 1 :
|
||||
message="<p>You configured FanFictionDownLoader to automatically update \"To Read\" Reading Lists, but you don't have any lists set?</p>"
|
||||
confirm(message,'fanfictiondownloader_no_read_lists', self.gui)
|
||||
for l in lists:
|
||||
if l in rl_plugin.get_list_names():
|
||||
#print("add good read l:(%s)"%l)
|
||||
addremovefunc(l,
|
||||
book_ids,
|
||||
display_warnings=False)
|
||||
else:
|
||||
if l != '':
|
||||
message="<p>You configured FanFictionDownLoader to automatically update Reading List '%s', but you don't have a list of that name?</p>"%l
|
||||
confirm(message,'fanfictiondownloader_no_reading_list_%s'%l, self.gui)
|
||||
|
||||
if prefs['addtolists'] and (add or (prefs['addtolistsonread'] and prefs['addtoreadlists']) ):
|
||||
lists = self._get_clean_reading_lists(prefs['send_lists'])
|
||||
if len(lists) < 1 :
|
||||
message="<p>You configured FanFictionDownLoader to automatically update \"Send to Device\" Reading Lists, but you don't have any lists set?</p>"
|
||||
confirm(message,'fanfictiondownloader_no_send_lists', self.gui)
|
||||
for l in lists:
|
||||
if l in rl_plugin.get_list_names():
|
||||
#print("good send l:(%s)"%l)
|
||||
rl_plugin.add_books_to_list(l,
|
||||
book_ids,
|
||||
display_warnings=False)
|
||||
else:
|
||||
if l != '':
|
||||
message="<p>You configured FanFictionDownLoader to automatically update Reading List '%s', but you don't have a list of that name?</p>"%l
|
||||
confirm(message,'fanfictiondownloader_no_reading_list_%s'%l, self.gui)
|
||||
|
||||
def _find_existing_book_id(self,db,book,matchurl=True):
|
||||
mi = MetaInformation(book["title"],(book["author"],)) # author is a list.
|
||||
identicalbooks = db.find_identical_books(mi)
|
||||
if matchurl: # only *really* identical if URL matches, too.
|
||||
for ib in identicalbooks:
|
||||
if self._get_story_url(db,ib) == book['url']:
|
||||
return ib
|
||||
if identicalbooks:
|
||||
return identicalbooks.pop()
|
||||
return None
|
||||
|
||||
def _make_mi_from_book(self,book):
|
||||
mi = MetaInformation(book['title'],(book['author'],)) # author is a list.
|
||||
mi.set_identifiers({'url':book['url']})
|
||||
mi.publisher = book['publisher']
|
||||
mi.tags = book['tags']
|
||||
#mi.languages = ['en'] # handled in _update_metadata so it can check for existing lang.
|
||||
mi.pubdate = book['pubdate']
|
||||
mi.timestamp = book['timestamp']
|
||||
mi.comments = book['comments']
|
||||
mi.series = book['series']
|
||||
return mi
|
||||
|
||||
|
||||
def _convert_urls_to_books(self, urls):
|
||||
books = []
|
||||
uniqueurls = set()
|
||||
for url in urls:
|
||||
book = self._convert_url_to_book(url)
|
||||
if book['url'] in uniqueurls:
|
||||
book['good'] = False
|
||||
book['comment'] = "Same story already included."
|
||||
uniqueurls.add(book['url'])
|
||||
books.append(book)
|
||||
return books
|
||||
|
||||
def _convert_url_to_book(self, url):
|
||||
book = {}
|
||||
book['good'] = True
|
||||
book['calibre_id'] = None
|
||||
book['title'] = 'Unknown'
|
||||
book['author'] = 'Unknown'
|
||||
book['author_sort'] = 'Unknown'
|
||||
|
||||
book['comment'] = ''
|
||||
book['url'] = ''
|
||||
book['added'] = False
|
||||
|
||||
self._set_book_url_and_comment(book,url)
|
||||
return book
|
||||
|
||||
def _convert_id_to_book(self, idval, good=True):
|
||||
book = {}
|
||||
book['good'] = good
|
||||
book['calibre_id'] = idval
|
||||
book['title'] = 'Unknown'
|
||||
book['author'] = 'Unknown'
|
||||
book['author_sort'] = 'Unknown'
|
||||
|
||||
book['comment'] = ''
|
||||
book['url'] = ''
|
||||
book['added'] = False
|
||||
|
||||
return book
|
||||
|
||||
def _populate_book_from_calibre_id(self, book, db=None):
|
||||
mi = db.get_metadata(book['calibre_id'], index_is_id=True)
|
||||
#book = {}
|
||||
book['good'] = True
|
||||
book['calibre_id'] = mi.id
|
||||
book['title'] = mi.title
|
||||
book['author'] = authors_to_string(mi.authors)
|
||||
book['author_sort'] = mi.author_sort
|
||||
book['comment'] = ''
|
||||
book['url'] = ""
|
||||
book['added'] = False
|
||||
|
||||
url = self._get_story_url(db,book['calibre_id'])
|
||||
self._set_book_url_and_comment(book,url)
|
||||
#return book
|
||||
|
||||
def _set_book_url_and_comment(self,book,url):
|
||||
if not url:
|
||||
book['comment'] = "No story URL found."
|
||||
book['good'] = False
|
||||
book['icon'] = 'search_delete_saved.png'
|
||||
else:
|
||||
# get normalized url or None.
|
||||
book['url'] = self._is_good_downloader_url(url)
|
||||
if book['url'] == None:
|
||||
book['url'] = url
|
||||
book['comment'] = "URL is not a valid story URL."
|
||||
book['good'] = False
|
||||
book['icon']='dialog_error.png'
|
||||
|
||||
def _get_story_url(self, db, book_id):
|
||||
identifiers = db.get_identifiers(book_id,index_is_id=True)
|
||||
if 'url' in identifiers:
|
||||
# identifiers have :->| in url.
|
||||
#print("url from book:"+identifiers['url'].replace('|',':'))
|
||||
return identifiers['url'].replace('|',':')
|
||||
else:
|
||||
## only epub has URL in it--at least where I can easily find it.
|
||||
if db.has_format(book_id,'EPUB',index_is_id=True):
|
||||
existingepub = db.format(book_id,'EPUB',index_is_id=True, as_file=True)
|
||||
mi = get_metadata(existingepub,'EPUB')
|
||||
identifiers = mi.get_identifiers()
|
||||
if 'url' in identifiers:
|
||||
#print("url from epub:"+identifiers['url'].replace('|',':'))
|
||||
return identifiers['url'].replace('|',':')
|
||||
# look for dc:source
|
||||
return get_dcsource(existingepub)
|
||||
return None
|
||||
|
||||
def _is_good_downloader_url(self,url):
|
||||
# this is the accepted way to 'check for existance'? really?
|
||||
try:
|
||||
self.dummyconfig
|
||||
except AttributeError:
|
||||
self.dummyconfig = SafeConfigParser()
|
||||
# pulling up an adapter is pretty low over-head. If
|
||||
# it fails, it's a bad url.
|
||||
try:
|
||||
adapter = adapters.getAdapter(self.dummyconfig,url)
|
||||
url = adapter.url
|
||||
del adapter
|
||||
return url
|
||||
except:
|
||||
return None;
|
||||
|
||||
def get_url_list(urls):
|
||||
def f(x):
|
||||
if x.strip(): return True
|
||||
else: return False
|
||||
# set removes dups.
|
||||
return set(filter(f,urls.strip().splitlines()))
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2012, Jim Miller'
|
||||
__copyright__ = '2011, Grant Drake <grant.drake@gmail.com>'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import time, os, traceback
|
||||
|
||||
from ConfigParser import SafeConfigParser
|
||||
from StringIO import StringIO
|
||||
|
||||
from calibre.utils.ipc.server import Server
|
||||
from calibre.utils.ipc.job import ParallelJob
|
||||
from calibre.utils.logging import Log
|
||||
|
||||
from calibre_plugins.fanfictiondownloader_plugin.dialogs import (NotGoingToDownload,
|
||||
OVERWRITE, OVERWRITEALWAYS, UPDATE, UPDATEALWAYS, ADDNEW, SKIP, CALIBREONLY)
|
||||
from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters, writers, exceptions
|
||||
from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.epubutils import get_update_data
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
#
|
||||
# Functions to perform downloads using worker jobs
|
||||
#
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def do_download_worker(book_list, options,
|
||||
cpus, notification=lambda x,y:x):
|
||||
'''
|
||||
Master job, to launch child jobs to extract ISBN for a set of books
|
||||
This is run as a worker job in the background to keep the UI more
|
||||
responsive and get around the memory leak issues as it will launch
|
||||
a child job for each book as a worker process
|
||||
'''
|
||||
server = Server(pool_size=cpus)
|
||||
|
||||
print(options['version'])
|
||||
total = 0
|
||||
# Queue all the jobs
|
||||
print("Adding jobs for URLs:")
|
||||
for book in book_list:
|
||||
if book['good']:
|
||||
print("%s"%book['url'])
|
||||
total += 1
|
||||
args = ['calibre_plugins.fanfictiondownloader_plugin.jobs',
|
||||
'do_download_for_worker',
|
||||
(book,options)]
|
||||
job = ParallelJob('arbitrary',
|
||||
"url:(%s) id:(%s)"%(book['url'],book['calibre_id']),
|
||||
done=None,
|
||||
args=args)
|
||||
job._book = book
|
||||
# job._book_id = book_id
|
||||
# job._title = title
|
||||
# job._modified_date = modified_date
|
||||
# job._existing_isbn = existing_isbn
|
||||
server.add_job(job)
|
||||
|
||||
# This server is an arbitrary_n job, so there is a notifier available.
|
||||
# Set the % complete to a small number to avoid the 'unavailable' indicator
|
||||
notification(0.01, 'Downloading FanFiction Stories')
|
||||
|
||||
# dequeue the job results as they arrive, saving the results
|
||||
count = 0
|
||||
while True:
|
||||
job = server.changed_jobs_queue.get()
|
||||
# A job can 'change' when it is not finished, for example if it
|
||||
# produces a notification. Ignore these.
|
||||
job.update()
|
||||
if not job.is_finished:
|
||||
continue
|
||||
# A job really finished. Get the information.
|
||||
output_book = job.result
|
||||
#print("output_book:%s"%output_book)
|
||||
book_list.remove(job._book)
|
||||
book_list.append(job.result)
|
||||
book_id = job._book['calibre_id']
|
||||
#title = job._title
|
||||
count = count + 1
|
||||
notification(float(count)/total, 'Downloaded Story')
|
||||
# Add this job's output to the current log
|
||||
print('Logfile for book ID %s (%s)'%(book_id, job._book['title']))
|
||||
print(job.details)
|
||||
|
||||
if count >= total:
|
||||
# All done!
|
||||
break
|
||||
|
||||
server.close()
|
||||
|
||||
# return the book list as the job result
|
||||
return book_list
|
||||
|
||||
def do_download_for_worker(book,options):
|
||||
'''
|
||||
Child job, to extract isbn from formats for this specific book,
|
||||
when run as a worker job
|
||||
'''
|
||||
try:
|
||||
book['comment'] = 'Download started...'
|
||||
|
||||
ffdlconfig = SafeConfigParser()
|
||||
ffdlconfig.readfp(StringIO(get_resources("plugin-defaults.ini")))
|
||||
ffdlconfig.readfp(StringIO(options['personal.ini']))
|
||||
|
||||
adapter = adapters.getAdapter(ffdlconfig,book['url'],options['fileform'])
|
||||
adapter.is_adult = book['is_adult']
|
||||
adapter.username = book['username']
|
||||
adapter.password = book['password']
|
||||
|
||||
story = adapter.getStoryMetadataOnly()
|
||||
writer = writers.getWriter(options['fileform'],adapter.config,adapter)
|
||||
|
||||
outfile = book['outfile']
|
||||
|
||||
## No need to download at all. Shouldn't ever get down here.
|
||||
if options['collision'] in (CALIBREONLY):
|
||||
print("Skipping CALIBREONLY 'update' down inside worker--this shouldn't be happening...")
|
||||
book['comment'] = 'Metadata collected.'
|
||||
|
||||
## checks were done earlier, it's new or not dup or newer--just write it.
|
||||
elif options['collision'] in (ADDNEW, SKIP, OVERWRITE, OVERWRITEALWAYS) or \
|
||||
('epub_for_update' not in book and options['collision'] in (UPDATE, UPDATEALWAYS)):
|
||||
print("write to %s"%outfile)
|
||||
writer.writeStory(outfilename=outfile, forceOverwrite=True)
|
||||
book['comment'] = 'Download %s completed, %s chapters.'%(options['fileform'],story.getMetadata("numChapters"))
|
||||
|
||||
## checks were done earlier, just update it.
|
||||
elif 'epub_for_update' in book and options['collision'] in (UPDATE, UPDATEALWAYS):
|
||||
|
||||
# update now handled by pre-populating the old images and
|
||||
# chapters in the adapter rather than merging epubs.
|
||||
urlchaptercount = int(story.getMetadata('numChapters'))
|
||||
(url,chaptercount,
|
||||
adapter.oldchapters,
|
||||
adapter.oldimgs) = get_update_data(book['epub_for_update'])
|
||||
|
||||
print("Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount))
|
||||
print("write to %s"%outfile)
|
||||
|
||||
writer.writeStory(outfilename=outfile, forceOverwrite=True)
|
||||
|
||||
book['comment'] = 'Update %s completed, added %s chapters for %s total.'%\
|
||||
(options['fileform'],(urlchaptercount-chaptercount),urlchaptercount)
|
||||
|
||||
except NotGoingToDownload as d:
|
||||
book['good']=False
|
||||
book['comment']=unicode(d)
|
||||
book['icon'] = d.icon
|
||||
|
||||
except Exception as e:
|
||||
book['good']=False
|
||||
book['comment']=unicode(e)
|
||||
book['icon']='dialog_error.png'
|
||||
print("Exception: %s:%s"%(book,unicode(e)))
|
||||
traceback.print_exc()
|
||||
|
||||
#time.sleep(10)
|
||||
return book
|
||||
@@ -3,6 +3,8 @@ cron:
|
||||
url: /r3m0v3r
|
||||
schedule: every 2 hours
|
||||
|
||||
- description: orphan cleanup job
|
||||
url: /r3m0v3rOrphans
|
||||
schedule: every 4 hours
|
||||
# There's a bug in the Python 2.7 runtime that prevents this from
|
||||
# working properly. In theory, there should never be orphans anyway.
|
||||
#- description: orphan cleanup job
|
||||
# url: /r3m0v3rOrphans
|
||||
# schedule: every 4 hours
|
||||
|
||||
+149
-10
@@ -1,4 +1,4 @@
|
||||
# Copyright 2011 Fanficdownloader team
|
||||
# Copyright 2012 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -36,7 +36,9 @@ formatext_label:File Extension
|
||||
## Sometimes there are multiple categories and/or genres.
|
||||
category_label:Category
|
||||
genre_label:Genre
|
||||
language_label:Language
|
||||
characters_label:Characters
|
||||
series_label:Series
|
||||
## Completed/In-Progress
|
||||
status_label:Status
|
||||
## Dates story first published, last updated, and downloaded(last with time).
|
||||
@@ -61,12 +63,19 @@ authorId_label:Author ID
|
||||
extratags_label:Extra Tags
|
||||
## The version of fanficdownloader
|
||||
##
|
||||
version_label:FFD Version
|
||||
version_label:FFDL Version
|
||||
|
||||
## items to include in the title page
|
||||
## Empty entries will *not* appear, even if in the list.
|
||||
## All current formats already include title and author.
|
||||
titlepage_entries: category,genre,characters,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,numWords,site,description
|
||||
titlepage_entries: series,category,genre,language,characters,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,numWords,site,description
|
||||
|
||||
## Try to collect series name and number of this story in series.
|
||||
## Some sites (ab)use 'series' for reading lists and personal
|
||||
## collections. This lets us turn it on and off by site without
|
||||
## keeping a lengthy titlepage_entries per site and prevents it
|
||||
## updating in the plugin.
|
||||
collect_series: true
|
||||
|
||||
## include title page as first page.
|
||||
include_titlepage: true
|
||||
@@ -88,8 +97,10 @@ include_tocpage: true
|
||||
#output_filename: books/${title}-${siteabbrev}_${storyId}${formatext}
|
||||
#output_filename: books/${formatname}/${siteabbrev}/${authorId}/${title}-${siteabbrev}_${storyId}${formatext}
|
||||
output_filename: ${title}-${siteabbrev}_${storyId}${formatext}
|
||||
|
||||
## Make directories as needed.
|
||||
make_directories: true
|
||||
|
||||
## Always overwrite output files. Otherwise, the downloader checks
|
||||
## the timestamp on the existing file and only overwrites if the story
|
||||
## has been updated more recently. Command line version only
|
||||
@@ -97,6 +108,7 @@ make_directories: true
|
||||
|
||||
## put output (with output_filename) in a zip file zip_filename.
|
||||
zip_output: false
|
||||
|
||||
## Can include directories. .zip will be added if not in name somewhere
|
||||
zip_filename: ${title}-${siteabbrev}_${storyId}${formatext}.zip
|
||||
|
||||
@@ -105,20 +117,66 @@ zip_filename: ${title}-${siteabbrev}_${storyId}${formatext}.zip
|
||||
## zip_filename.
|
||||
allow_unsafe_filename: false
|
||||
|
||||
## entries to make epub subjects and calibre tags
|
||||
## lastupdate creates two tags: "Last Update Year/Month: %Y/%m" and "Last Update: %Y/%m/%d"
|
||||
include_subject_tags: extratags, genre, category, characters, lastupdate, status
|
||||
|
||||
## extra tags (comma separated) to include, primarily for epub.
|
||||
extratags: FanFiction
|
||||
|
||||
## number of seconds to sleep between calls to the story site. May by
|
||||
## useful if pulling large numbers of stories or if the site is slow.
|
||||
## Primarily for commandline.
|
||||
#slow_down_sleep_time:0.5
|
||||
|
||||
## For use only with stand-alone CLI version--run a command on the
|
||||
## generated file after it's produced. All of the titlepage_entries
|
||||
## values are available, plus output_filename.
|
||||
#post_process_cmd: addbook -f "${output_filename}" -t "${title}"
|
||||
|
||||
## Use regular expressions to find and replace (or remove) metadata.
|
||||
## For example, you could change Sci-Fi=>SF, remove *-Centered tags,
|
||||
## etc. See http://docs.python.org/library/re.html (look for re.sub)
|
||||
## for regexp details.
|
||||
## Make sure to keep at least one space at the start of each line and
|
||||
## to escape % to %%, if used.
|
||||
#replace_metadata:
|
||||
# Sci-Fi=>SF
|
||||
# Puella Magi Madoka Magica.* => Madoka
|
||||
# Comedy=>Humor
|
||||
# Crossover: (.*)=>\1
|
||||
# (.*)Great(.*)=>\1Moderate\2
|
||||
# .*-Centered=>
|
||||
|
||||
## Each output format has a section that overrides [defaults]
|
||||
[html]
|
||||
|
||||
## output background color--only used by html and epub (and ignored in
|
||||
## epub by many readers). Included below in output_css--will be
|
||||
## ignored if not in output_css.
|
||||
background_color: ffffff
|
||||
|
||||
## Allow customization of CSS. Make sure to keep at least one space
|
||||
## at the start of each line and to escape % to %%. Also need
|
||||
## background_color to be in the same section, if included in CSS.
|
||||
output_css:
|
||||
body { background-color: #%(background_color)s; }
|
||||
.CI {
|
||||
text-align:center;
|
||||
margin-top:0px;
|
||||
margin-bottom:0px;
|
||||
padding:0px;
|
||||
}
|
||||
.center {text-align: center;}
|
||||
.cover {text-align: center;}
|
||||
.full {width: 100%%; }
|
||||
.quarter {width: 25%%; }
|
||||
.smcap {font-variant: small-caps;}
|
||||
.u {text-decoration: underline;}
|
||||
.bold {font-weight: bold;}
|
||||
|
||||
[txt]
|
||||
## Add URLs since there aren't links.
|
||||
titlepage_entries: category,genre,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,numWords,site,storyUrl, authorUrl, description
|
||||
titlepage_entries: series,category,genre,language,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,numWords,site,storyUrl, authorUrl, description
|
||||
|
||||
## use \r\n for line endings, the windows convention. text output only.
|
||||
windows_eol: true
|
||||
@@ -128,10 +186,6 @@ windows_eol: true
|
||||
## epub is already a zip file.
|
||||
zip_output: false
|
||||
|
||||
## entries to make epub subject tags
|
||||
## lastupdate creates two tags: "Last Update Year/Month: %Y/%m" and "Last Update: %Y/%m/%d"
|
||||
include_subject_tags: extratags, genre, category, characters, lastupdate, status
|
||||
|
||||
## epub carries the TOC in metadata.
|
||||
## mobi generated from epub will have a TOC at the end.
|
||||
include_tocpage: false
|
||||
@@ -142,11 +196,75 @@ titlepage_use_table: false
|
||||
## When using tables, make these span both columns.
|
||||
wide_titlepage_entries: description, storyUrl, author URL
|
||||
|
||||
## output background color--only used by html and epub (and ignored in
|
||||
## epub by many readers). Included below in output_css--will be
|
||||
## ignored if not in output_css.
|
||||
background_color: ffffff
|
||||
|
||||
## Allow customization of CSS. Make sure to keep at least one space
|
||||
## at the start of each line and to escape % to %%. Also need
|
||||
## background_color to be in the same section, if included in CSS.
|
||||
output_css:
|
||||
body { background-color: #%(background_color)s;
|
||||
text-align: justify;
|
||||
margin: 2%%; }
|
||||
pre { font-size: x-small; }
|
||||
sml { font-size: small; }
|
||||
h1 { text-align: center; }
|
||||
h2 { text-align: center; }
|
||||
h3 { text-align: center; }
|
||||
h4 { text-align: center; }
|
||||
h5 { text-align: center; }
|
||||
h6 { text-align: center; }
|
||||
.CI {
|
||||
text-align:center;
|
||||
margin-top:0px;
|
||||
margin-bottom:0px;
|
||||
padding:0px;
|
||||
}
|
||||
.center {text-align: center;}
|
||||
.cover {text-align: center;}
|
||||
.full {width: 100%%; }
|
||||
.quarter {width: 25%%; }
|
||||
.smcap {font-variant: small-caps;}
|
||||
.u {text-decoration: underline;}
|
||||
.bold {font-weight: bold;}
|
||||
|
||||
## include images from img tags in the body and summary of
|
||||
## stories. Images will be converted to jpg for size if possible.
|
||||
#include_images:false
|
||||
|
||||
## If not set, the summary will have all html stripped for safety.
|
||||
## Both this and include_images must be true to get images in the
|
||||
## summary.
|
||||
#keep_summary_html:false
|
||||
|
||||
## If set, the first image found will be made the cover image. If
|
||||
## keep_summary_html is true, any images in summary will be before any
|
||||
## in chapters.
|
||||
#make_firstimage_cover: false
|
||||
|
||||
## If set, and there isn't already a cover image from the adapter or
|
||||
## from make_firstimage_cover, this image will be made the cover.
|
||||
## It can be either a 'file:' or 'http:' url.
|
||||
## Note that if you enable make_firstimage_cover in [epub], but want
|
||||
## to use default_cover_image for a specific site, use the site:format
|
||||
## section, for example: [www.ficwad.com:epub]
|
||||
#default_cover_image:file:///C:/Users/username/Desktop/nook/images/icon.png
|
||||
#default_cover_image:http://www.somesite.com/someimage.gif
|
||||
|
||||
## Resize images down to width, height, preserving aspect ratio.
|
||||
## Nook size, with margin.
|
||||
image_max_size: 580, 725
|
||||
|
||||
## Change image to grayscale, if graphics library allows, to save
|
||||
## space.
|
||||
#grayscale_images: false
|
||||
|
||||
[mobi]
|
||||
## mobi TOC cannot be turned off right now.
|
||||
#include_tocpage: true
|
||||
|
||||
|
||||
## Each site has a section that overrides [defaults] *and* the format
|
||||
## sections test1.com specifically is not a real story site. Instead,
|
||||
## it is a fake site for testing configuration and output. It uses
|
||||
@@ -185,6 +303,9 @@ extratags:
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
## twilighted.net (ab)uses series as personal reading lists.
|
||||
collect_series: false
|
||||
|
||||
[www.twiwrite.net]
|
||||
## Some sites require login (or login for some rated stories) The
|
||||
## program can prompt you, or you can save it in config. In
|
||||
@@ -193,6 +314,9 @@ extratags:
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
## twiwrite.net (ab)uses series as personal reading lists.
|
||||
collect_series: false
|
||||
|
||||
[www.whofic.com]
|
||||
|
||||
[www.mediaminer.org]
|
||||
@@ -210,6 +334,9 @@ extratags:
|
||||
## personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
## thewriterscoffeeshop.com (ab)uses series as personal reading lists.
|
||||
collect_series: false
|
||||
|
||||
[www.ficwad.com]
|
||||
## Some sites require login (or login for some rated stories) The
|
||||
## program can prompt you, or you can save it in config. In
|
||||
@@ -245,6 +372,18 @@ output_filename: ${title}-${siteabbrev}_${authorId}_${storyId}${formatext}
|
||||
## this should go in your personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
[www.tthfanfic.org]
|
||||
## Some sites do not require a login, but do require the user to
|
||||
## confirm they are adult for adult content. In commandline version,
|
||||
## this should go in your personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
## tth is a little unusual--it doesn't require user/pass, but the site
|
||||
## keeps track of which chapters you've read and won't send another
|
||||
## update until it thinks you're up to date. This way, on download,
|
||||
## it thinks you're up to date.
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
[overrides]
|
||||
## It may sometimes be useful to override all of the specific format,
|
||||
## site and site:format sections in your private configuration. For
|
||||
|
||||
+65
-73
@@ -24,24 +24,25 @@ from os.path import normpath, expanduser, isfile, join
|
||||
from StringIO import StringIO
|
||||
from optparse import OptionParser
|
||||
import getpass
|
||||
import string
|
||||
import ConfigParser
|
||||
from subprocess import call
|
||||
|
||||
from epubmerge import doMerge
|
||||
from fanficdownloader import adapters,writers,exceptions
|
||||
from fanficdownloader.epubutils import get_dcsource_chaptercount, get_update_data
|
||||
|
||||
if sys.version_info < (2, 5):
|
||||
print "This program requires Python 2.5 or newer."
|
||||
sys.exit(1)
|
||||
|
||||
from fanficdownloader import adapters,writers,exceptions
|
||||
|
||||
import ConfigParser
|
||||
|
||||
def writeStory(config,adapter,writeformat,metaonly=False,outstream=None):
|
||||
writer = writers.getWriter(writeformat,config,adapter)
|
||||
writer.writeStory(outstream=outstream,metaonly=metaonly)
|
||||
output_filename=writer.getOutputFileName()
|
||||
del writer
|
||||
return output_filename
|
||||
|
||||
def main():
|
||||
|
||||
# read in args, anything starting with -- will be treated as --<varible>=<value>
|
||||
usage = "usage: %prog [options] storyurl"
|
||||
parser = OptionParser(usage)
|
||||
@@ -65,7 +66,7 @@ def main():
|
||||
help="Update an existing epub with new chapter, give epub filename instead of storyurl. Not compatible with inserted TOC.",)
|
||||
parser.add_option("--force",
|
||||
action="store_true", dest="force",
|
||||
help="Force update of an existing epub, download and overwrite all chapters.",)
|
||||
help="Force overwrite or update of an existing epub, download and overwrite all chapters.",)
|
||||
|
||||
(options, args) = parser.parse_args()
|
||||
|
||||
@@ -79,14 +80,17 @@ def main():
|
||||
|
||||
conflist = []
|
||||
homepath = join(expanduser("~"),".fanficdownloader")
|
||||
|
||||
if isfile(join(homepath,"defaults.ini")):
|
||||
conflist.append(join(homepath,"defaults.ini"))
|
||||
if isfile(join(homepath,"personal.ini")):
|
||||
conflist.append(join(homepath,"personal.ini"))
|
||||
if isfile("defaults.ini"):
|
||||
conflist.append("defaults.ini")
|
||||
|
||||
if isfile(join(homepath,"personal.ini")):
|
||||
conflist.append(join(homepath,"personal.ini"))
|
||||
if isfile("personal.ini"):
|
||||
conflist.append("personal.ini")
|
||||
|
||||
if options.configfile:
|
||||
conflist.extend(options.configfile)
|
||||
|
||||
@@ -97,6 +101,10 @@ def main():
|
||||
config.add_section("overrides")
|
||||
except ConfigParser.DuplicateSectionError:
|
||||
pass
|
||||
|
||||
if options.force:
|
||||
config.set("overrides","always_overwrite","true")
|
||||
|
||||
if options.options:
|
||||
for opt in options.options:
|
||||
(var,val) = opt.split('=')
|
||||
@@ -105,34 +113,40 @@ def main():
|
||||
try:
|
||||
## Attempt to update an existing epub.
|
||||
if options.update:
|
||||
updateio = StringIO()
|
||||
(url,chaptercount) = doMerge(updateio,
|
||||
args,
|
||||
titlenavpoints=False,
|
||||
striptitletoc=True,
|
||||
forceunique=False)
|
||||
(url,chaptercount) = get_dcsource_chaptercount(args[0])
|
||||
print "Updating %s, URL: %s" % (args[0],url)
|
||||
filename = args[0]
|
||||
output_filename = args[0]
|
||||
config.set("overrides","output_filename",args[0])
|
||||
else:
|
||||
url = args[0]
|
||||
|
||||
adapter = adapters.getAdapter(config,url)
|
||||
|
||||
try:
|
||||
adapter.getStoryMetadataOnly()
|
||||
except exceptions.FailedToLogin:
|
||||
print "Login Failed, Need Username/Password."
|
||||
sys.stdout.write("Username: ")
|
||||
adapter.username = sys.stdin.readline().strip()
|
||||
adapter.password = getpass.getpass(prompt='Password: ')
|
||||
#print("Login: `%s`, Password: `%s`" % (adapter.username, adapter.password))
|
||||
adapter.getStoryMetadataOnly()
|
||||
except exceptions.AdultCheckRequired:
|
||||
print "Please confirm you are an adult in your locale: (y/n)?"
|
||||
if sys.stdin.readline().strip().lower().startswith('y'):
|
||||
adapter.is_adult=True
|
||||
adapter.getStoryMetadataOnly()
|
||||
adapter = adapters.getAdapter(config,url,options.format)
|
||||
|
||||
## Check for include_images and absence of PIL, give warning.
|
||||
if adapter.getConfig('include_images'):
|
||||
try:
|
||||
import Image
|
||||
except:
|
||||
print "You have include_images enabled, but Python Image Library(PIL) isn't found.\nImages will be included full size in original format.\nContinue? (y/n)?"
|
||||
if not sys.stdin.readline().strip().lower().startswith('y'):
|
||||
return
|
||||
|
||||
|
||||
## three tries, that's enough if both user/pass & is_adult needed,
|
||||
## or a couple tries of one or the other
|
||||
for x in range(0,2):
|
||||
try:
|
||||
adapter.getStoryMetadataOnly()
|
||||
except exceptions.FailedToLogin:
|
||||
print "Login Failed, Need Username/Password."
|
||||
sys.stdout.write("Username: ")
|
||||
adapter.username = sys.stdin.readline().strip()
|
||||
adapter.password = getpass.getpass(prompt='Password: ')
|
||||
#print("Login: `%s`, Password: `%s`" % (adapter.username, adapter.password))
|
||||
except exceptions.AdultCheckRequired:
|
||||
print "Please confirm you are an adult in your locale: (y/n)?"
|
||||
if sys.stdin.readline().strip().lower().startswith('y'):
|
||||
adapter.is_adult=True
|
||||
|
||||
if options.update and not options.force:
|
||||
urlchaptercount = int(adapter.getStoryMetadataOnly().getMetadata('numChapters'))
|
||||
@@ -143,40 +157,17 @@ def main():
|
||||
print "%s contains %d chapters, more than source: %d." % (args[0],chaptercount,urlchaptercount)
|
||||
else:
|
||||
print "Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount)
|
||||
## Get updated title page/metadata by itself in an epub.
|
||||
## Even if the title page isn't included, this carries the metadata.
|
||||
titleio = StringIO()
|
||||
writeStory(config,adapter,"epub",metaonly=True,outstream=titleio)
|
||||
|
||||
newchaptersio = None
|
||||
if not options.metaonly:
|
||||
## Go get the new chapters only in another epub.
|
||||
newchaptersio = StringIO()
|
||||
adapter.setChaptersRange(chaptercount+1,urlchaptercount)
|
||||
config.set("overrides",'include_tocpage','false')
|
||||
config.set("overrides",'include_titlepage','false')
|
||||
writeStory(config,adapter,"epub",outstream=newchaptersio)
|
||||
|
||||
# out = open("testing/titleio.epub","wb")
|
||||
# out.write(titleio.getvalue())
|
||||
# out.close()
|
||||
|
||||
# out = open("testing/updateio.epub","wb")
|
||||
# out.write(updateio.getvalue())
|
||||
# out.close()
|
||||
|
||||
# out = open("testing/newchaptersio.epub","wb")
|
||||
# out.write(newchaptersio.getvalue())
|
||||
# out.close()
|
||||
|
||||
## Merge the three epubs together.
|
||||
doMerge(args[0],
|
||||
[titleio,updateio,newchaptersio],
|
||||
fromfirst=True,
|
||||
titlenavpoints=False,
|
||||
striptitletoc=False,
|
||||
forceunique=False)
|
||||
|
||||
# update now handled by pre-populating the old
|
||||
# images and chapters in the adapter rather than
|
||||
# merging epubs.
|
||||
(url,chaptercount,
|
||||
adapter.oldchapters,
|
||||
adapter.oldimgs) = get_update_data(args[0])
|
||||
|
||||
writeStory(config,adapter,"epub")
|
||||
|
||||
else:
|
||||
# regular download
|
||||
if options.metaonly:
|
||||
@@ -184,16 +175,14 @@ def main():
|
||||
|
||||
adapter.setChaptersRange(options.begin,options.end)
|
||||
|
||||
if options.format == "all":
|
||||
## For testing. Doing all three formats actually causes
|
||||
## some interesting config issues with format-specific
|
||||
## sections. But it should rarely be an issue.
|
||||
writeStory(config,adapter,"epub",options.metaonly)
|
||||
writeStory(config,adapter,"html",options.metaonly)
|
||||
writeStory(config,adapter,"txt",options.metaonly)
|
||||
else:
|
||||
writeStory(config,adapter,options.format,options.metaonly)
|
||||
output_filename=writeStory(config,adapter,options.format,options.metaonly)
|
||||
|
||||
if not options.metaonly and adapter.getConfig("post_process_cmd"):
|
||||
metadata = adapter.story.metadata
|
||||
metadata['output_filename']=output_filename
|
||||
call(string.Template(adapter.getConfig("post_process_cmd"))
|
||||
.substitute(metadata), shell=True)
|
||||
|
||||
del adapter
|
||||
|
||||
except exceptions.InvalidStoryURL, isu:
|
||||
@@ -204,4 +193,7 @@ def main():
|
||||
print us
|
||||
|
||||
if __name__ == "__main__":
|
||||
#import time
|
||||
#start = time.time()
|
||||
main()
|
||||
#print("Total time seconds:%f"%(time.time()-start))
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<link href="/css/index.css" rel="stylesheet" type="text/css">
|
||||
<title>Fanfiction Downloader - read fanfiction from twilighted.net, fanfiction.net, fictionpress.com, fictionalley.org, ficwad.com, potionsandsnitches.net, harrypotterfanfiction.com, mediaminer.org on Kindle, Nook, Sony Reader, iPad, iPhone, Android, Aldiko, Stanza</title>
|
||||
<title>FanFictionDownLoader - read fanfiction from twilighted.net, fanfiction.net, fictionpress.com, fictionalley.org, ficwad.com, potionsandsnitches.net, harrypotterfanfiction.com, mediaminer.org on Kindle, Nook, Sony Reader, iPad, iPhone, Android, Aldiko, Stanza</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="google-site-verification" content="kCFc-G4bka_pJN6Rv8CapPBcwmq0hbAUZPkKWqRsAYU" />
|
||||
<script type="text/javascript">
|
||||
@@ -22,7 +22,7 @@
|
||||
<body>
|
||||
<div id='main' style="width: 80%; margin-left: 10%;">
|
||||
<h1>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFiction Downloader</a>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFictionDownLoader</a>
|
||||
</h1>
|
||||
|
||||
<div style="text-align: center">
|
||||
@@ -67,7 +67,7 @@
|
||||
<img src="http://code.google.com/appengine/images/appengine-silver-120x30.gif"
|
||||
alt="Powered by Google App Engine" />
|
||||
<br/><br/>
|
||||
FanfictionLoader is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">fanficdownloader</a><br/>
|
||||
This is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">FanFictionDownLoader</a><br/>
|
||||
Copyright © Fanficdownloader team
|
||||
</div>
|
||||
|
||||
|
||||
+5
-358
@@ -16,363 +16,10 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
#import StringIO
|
||||
from optparse import OptionParser
|
||||
|
||||
import zlib
|
||||
import zipfile
|
||||
from zipfile import ZipFile, ZIP_STORED, ZIP_DEFLATED
|
||||
from time import time
|
||||
|
||||
from exceptions import KeyError
|
||||
|
||||
from xml.dom.minidom import parse, parseString, getDOMImplementation
|
||||
|
||||
def main(argv):
|
||||
# read in args, anything starting with -- will be treated as --<varible>=<value>
|
||||
usage = "usage: %prog [options] <input epub> [<input epub>...]"
|
||||
parser = OptionParser(usage)
|
||||
parser.add_option("-o", "--output", dest="outputopt", default="merge.epub",
|
||||
help="Set OUTPUT file, Default: merge.epub", metavar="OUTPUT")
|
||||
parser.add_option("-t", "--title", dest="titleopt", default=None,
|
||||
help="Use TITLE as the metadata title. Default: '<first epub title> Anthology'", metavar="TITLE")
|
||||
parser.add_option("-d", "--description", dest="descopt", default=None,
|
||||
help="Use DESC as the metadata description. Default: '<epub title> by <author>' for each epub.", metavar="DESC")
|
||||
parser.add_option("-a", "--author",
|
||||
action="append", dest="authoropts", default=[],
|
||||
help="Use AUTHOR as a metadata author, multiple authors may be given, Default: <All authors from epubs>", metavar="AUTHOR")
|
||||
parser.add_option("-f", "--first",
|
||||
action="store_true", dest="fromfirst", default=False,
|
||||
help="Take all metadata from first input epub",)
|
||||
parser.add_option("-n", "--titles-in-toc",
|
||||
action="store_true", dest="titlenavpoints",
|
||||
help="Put an entry in the TOC for each epub, in addition to each epub's chapters.",)
|
||||
parser.add_option("-s", "--strip-title-toc",
|
||||
action="store_true", dest="striptitletoc",
|
||||
help="Strip any title_page.xhtml and toc_page.xhtml files.",)
|
||||
|
||||
(options, args) = parser.parse_args()
|
||||
|
||||
## Add .epub if not already there.
|
||||
if not options.outputopt.lower().endswith(".epub"):
|
||||
options.outputopt=options.outputopt+".epub"
|
||||
|
||||
print "output file: "+options.outputopt
|
||||
doMerge(options.outputopt,
|
||||
args,
|
||||
options.authoropts,
|
||||
options.titleopt,
|
||||
options.descopt,
|
||||
options.fromfirst,
|
||||
options.titlenavpoints,
|
||||
options.striptitletoc)
|
||||
|
||||
# output = StringIO.StringIO()
|
||||
# files = []
|
||||
# for file in args:
|
||||
# f = open(file,"rb")
|
||||
# fio = StringIO.StringIO(f.read())
|
||||
# f.close()
|
||||
# files.append(fio)
|
||||
|
||||
# doMerge(output,files,authoropts,titleopt,descopt,fromfirst,titlenavpoints,striptitletoc)
|
||||
|
||||
# out = open(outputopt,"wb")
|
||||
# out.write(output.getvalue())
|
||||
|
||||
def doMerge(outputio,files,authoropts=[],titleopt=None,descopt=None,
|
||||
fromfirst=False,
|
||||
titlenavpoints=True,
|
||||
striptitletoc=False,
|
||||
forceunique=True):
|
||||
'''
|
||||
outputio = output file name or StringIO.
|
||||
files = list of input file names or StringIOs.
|
||||
authoropts = list of authors to use, otherwise add from all input
|
||||
titleopt = title, otherwise '<first title> Anthology'
|
||||
descopt = description, otherwise '<title> by <author>' list for all input
|
||||
fromfirst if true, take all metadata (including author, title, desc) from first input
|
||||
titlenavpoints if true, put in a new TOC entry for each epub
|
||||
striptitletoc if true, strip out any (title|toc)_page.xhtml files
|
||||
forceunique if true, guarantee uniqueness of contents by adding a dir for each input
|
||||
'''
|
||||
## Python 2.5 ZipFile is rather more primative than later
|
||||
## versions. It can operate on a file, or on a StringIO, but
|
||||
## not on an open stream. OTOH, I suspect we would have had
|
||||
## problems with closing and opening again to change the
|
||||
## compression type anyway.
|
||||
|
||||
filecount=0
|
||||
source=None
|
||||
|
||||
## Write mimetype file, must be first and uncompressed.
|
||||
## Older versions of python(2.4/5) don't allow you to specify
|
||||
## compression by individual file.
|
||||
## Overwrite if existing output file.
|
||||
outputepub = ZipFile(outputio, "w", compression=ZIP_STORED)
|
||||
outputepub.debug = 3
|
||||
outputepub.writestr("mimetype", "application/epub+zip")
|
||||
outputepub.close()
|
||||
|
||||
## Re-open file for content.
|
||||
outputepub = ZipFile(outputio, "a", compression=ZIP_DEFLATED)
|
||||
outputepub.debug = 3
|
||||
|
||||
## Create META-INF/container.xml file. The only thing it does is
|
||||
## point to content.opf
|
||||
containerdom = getDOMImplementation().createDocument(None, "container", None)
|
||||
containertop = containerdom.documentElement
|
||||
containertop.setAttribute("version","1.0")
|
||||
containertop.setAttribute("xmlns","urn:oasis:names:tc:opendocument:xmlns:container")
|
||||
rootfiles = containerdom.createElement("rootfiles")
|
||||
containertop.appendChild(rootfiles)
|
||||
rootfiles.appendChild(newTag(containerdom,"rootfile",{"full-path":"content.opf",
|
||||
"media-type":"application/oebps-package+xml"}))
|
||||
outputepub.writestr("META-INF/container.xml",containerdom.toprettyxml(indent=' ',encoding='utf-8'))
|
||||
|
||||
## Process input epubs.
|
||||
|
||||
items = [] # list of (id, href, type) tuples(all strings) -- From .opfs' manifests
|
||||
items.append(("ncx","toc.ncx","application/x-dtbncx+xml")) ## we'll generate the toc.ncx file,
|
||||
## but it needs to be in the items manifest.
|
||||
itemrefs = [] # list of strings -- idrefs from .opfs' spines
|
||||
navmaps = [] # list of navMap DOM elements -- TOC data for each from toc.ncx files
|
||||
|
||||
booktitles = [] # list of strings -- Each book's title
|
||||
allauthors = [] # list of lists of strings -- Each book's list of authors.
|
||||
|
||||
filelist = []
|
||||
|
||||
booknum=1
|
||||
firstmetadom = None
|
||||
for file in files:
|
||||
if file == None : continue
|
||||
|
||||
book = "%d" % booknum
|
||||
bookdir = ""
|
||||
bookid = ""
|
||||
if forceunique:
|
||||
bookdir = "%d/" % booknum
|
||||
bookid = "a%d" % booknum
|
||||
#print "book %d" % booknum
|
||||
|
||||
epub = ZipFile(file, 'r')
|
||||
|
||||
## Find the .opf file.
|
||||
container = epub.read("META-INF/container.xml")
|
||||
containerdom = parseString(container)
|
||||
rootfilenodelist = containerdom.getElementsByTagName("rootfile")
|
||||
rootfilename = rootfilenodelist[0].getAttribute("full-path")
|
||||
|
||||
## Save the path to the .opf file--hrefs inside it are relative to it.
|
||||
relpath = os.path.dirname(rootfilename)
|
||||
if( len(relpath) > 0 ):
|
||||
relpath=relpath+"/"
|
||||
|
||||
metadom = parseString(epub.read(rootfilename))
|
||||
if booknum==1:
|
||||
firstmetadom = metadom.getElementsByTagName("metadata")[0]
|
||||
try:
|
||||
source=firstmetadom.getElementsByTagName("dc:source")[0].firstChild.data.encode("utf-8")
|
||||
except:
|
||||
source=""
|
||||
#print "Source:%s"%source
|
||||
|
||||
## Save indiv book title
|
||||
booktitles.append(metadom.getElementsByTagName("dc:title")[0].firstChild.data)
|
||||
|
||||
## Save authors.
|
||||
authors=[]
|
||||
for creator in metadom.getElementsByTagName("dc:creator"):
|
||||
if( creator.getAttribute("opf:role") == "aut" ):
|
||||
authors.append(creator.firstChild.data)
|
||||
allauthors.append(authors)
|
||||
|
||||
for item in metadom.getElementsByTagName("item"):
|
||||
if( item.getAttribute("media-type") == "application/x-dtbncx+xml" ):
|
||||
# TOC file is only one with this type--as far as I know.
|
||||
# grab the whole navmap, deal with it later.
|
||||
tocdom = parseString(epub.read(relpath+item.getAttribute("href")))
|
||||
|
||||
for navpoint in tocdom.getElementsByTagName("navPoint"):
|
||||
navpoint.setAttribute("id",bookid+navpoint.getAttribute("id"))
|
||||
|
||||
for content in tocdom.getElementsByTagName("content"):
|
||||
content.setAttribute("src",bookdir+relpath+content.getAttribute("src"))
|
||||
|
||||
navmaps.append(tocdom.getElementsByTagName("navMap")[0])
|
||||
else:
|
||||
id=bookid+item.getAttribute("id")
|
||||
href=bookdir+relpath+item.getAttribute("href")
|
||||
href=href.encode('utf8')
|
||||
#print "href:"+href
|
||||
if not striptitletoc or not re.match(r'.*/(title|toc)_page\.xhtml',
|
||||
item.getAttribute("href")):
|
||||
if href not in filelist:
|
||||
try:
|
||||
outputepub.writestr(href,
|
||||
epub.read(relpath+item.getAttribute("href")))
|
||||
if re.match(r'.*/(file|chapter)\d+\.xhtml',href):
|
||||
filecount+=1
|
||||
items.append((id,href,item.getAttribute("media-type")))
|
||||
filelist.append(href)
|
||||
except KeyError, ke:
|
||||
pass # Skip missing files.
|
||||
|
||||
for itemref in metadom.getElementsByTagName("itemref"):
|
||||
if not striptitletoc or not re.match(r'(title|toc)_page', itemref.getAttribute("idref")):
|
||||
itemrefs.append(bookid+itemref.getAttribute("idref"))
|
||||
|
||||
booknum=booknum+1;
|
||||
|
||||
## create content.opf file.
|
||||
uniqueid="epubmerge-uid-%d" % time() # real sophisticated uid scheme.
|
||||
contentdom = getDOMImplementation().createDocument(None, "package", None)
|
||||
package = contentdom.documentElement
|
||||
if fromfirst and firstmetadom:
|
||||
metadata = firstmetadom
|
||||
firstpackage = firstmetadom.parentNode
|
||||
package.setAttribute("version",firstpackage.getAttribute("version"))
|
||||
package.setAttribute("xmlns",firstpackage.getAttribute("xmlns"))
|
||||
package.setAttribute("unique-identifier",firstpackage.getAttribute("unique-identifier"))
|
||||
else:
|
||||
package.setAttribute("version","2.0")
|
||||
package.setAttribute("xmlns","http://www.idpf.org/2007/opf")
|
||||
package.setAttribute("unique-identifier","epubmerge-id")
|
||||
metadata=newTag(contentdom,"metadata",
|
||||
attrs={"xmlns:dc":"http://purl.org/dc/elements/1.1/",
|
||||
"xmlns:opf":"http://www.idpf.org/2007/opf"})
|
||||
metadata.appendChild(newTag(contentdom,"dc:identifier",text=uniqueid,attrs={"id":"epubmerge-id"}))
|
||||
if( titleopt is None ):
|
||||
titleopt = booktitles[0]+" Anthology"
|
||||
metadata.appendChild(newTag(contentdom,"dc:title",text=titleopt))
|
||||
|
||||
# If cmdline authors, use those instead of those collected from the epubs
|
||||
# (allauthors kept for TOC & description gen below.
|
||||
if( len(authoropts) > 1 ):
|
||||
useauthors=[authoropts]
|
||||
else:
|
||||
useauthors=allauthors
|
||||
|
||||
usedauthors=dict()
|
||||
for authorlist in useauthors:
|
||||
for author in authorlist:
|
||||
if( not usedauthors.has_key(author) ):
|
||||
usedauthors[author]=author
|
||||
metadata.appendChild(newTag(contentdom,"dc:creator",
|
||||
attrs={"opf:role":"aut"},
|
||||
text=author))
|
||||
|
||||
metadata.appendChild(newTag(contentdom,"dc:contributor",text="epubmerge",attrs={"opf:role":"bkp"}))
|
||||
metadata.appendChild(newTag(contentdom,"dc:rights",text="Copyrights as per source stories"))
|
||||
metadata.appendChild(newTag(contentdom,"dc:language",text="en"))
|
||||
|
||||
if not descopt:
|
||||
# created now, but not filled in until TOC generation to save loops.
|
||||
description = newTag(contentdom,"dc:description",text="Anthology containing:\n")
|
||||
else:
|
||||
description = newTag(contentdom,"dc:description",text=descopt)
|
||||
metadata.appendChild(description)
|
||||
|
||||
package.appendChild(metadata)
|
||||
|
||||
manifest = contentdom.createElement("manifest")
|
||||
package.appendChild(manifest)
|
||||
for item in items:
|
||||
(id,href,type)=item
|
||||
manifest.appendChild(newTag(contentdom,"item",
|
||||
attrs={'id':id,
|
||||
'href':href,
|
||||
'media-type':type}))
|
||||
|
||||
spine = newTag(contentdom,"spine",attrs={"toc":"ncx"})
|
||||
package.appendChild(spine)
|
||||
for itemref in itemrefs:
|
||||
spine.appendChild(newTag(contentdom,"itemref",
|
||||
attrs={"idref":itemref,
|
||||
"linear":"yes"}))
|
||||
|
||||
## create toc.ncx file
|
||||
tocncxdom = getDOMImplementation().createDocument(None, "ncx", None)
|
||||
ncx = tocncxdom.documentElement
|
||||
ncx.setAttribute("version","2005-1")
|
||||
ncx.setAttribute("xmlns","http://www.daisy.org/z3986/2005/ncx/")
|
||||
head = tocncxdom.createElement("head")
|
||||
ncx.appendChild(head)
|
||||
head.appendChild(newTag(tocncxdom,"meta",
|
||||
attrs={"name":"dtb:uid", "content":uniqueid}))
|
||||
head.appendChild(newTag(tocncxdom,"meta",
|
||||
attrs={"name":"dtb:depth", "content":"1"}))
|
||||
head.appendChild(newTag(tocncxdom,"meta",
|
||||
attrs={"name":"dtb:totalPageCount", "content":"0"}))
|
||||
head.appendChild(newTag(tocncxdom,"meta",
|
||||
attrs={"name":"dtb:maxPageNumber", "content":"0"}))
|
||||
|
||||
docTitle = tocncxdom.createElement("docTitle")
|
||||
docTitle.appendChild(newTag(tocncxdom,"text",text=titleopt))
|
||||
ncx.appendChild(docTitle)
|
||||
|
||||
tocnavMap = tocncxdom.createElement("navMap")
|
||||
ncx.appendChild(tocnavMap)
|
||||
|
||||
## TOC navPoints can be nested, but this flattens them for
|
||||
## simplicity, plus adds a navPoint for each epub.
|
||||
booknum=0
|
||||
for navmap in navmaps:
|
||||
navpoints = navmap.getElementsByTagName("navPoint")
|
||||
if titlenavpoints:
|
||||
## Copy first navPoint of each epub, give a different id and
|
||||
## text: bookname by authorname
|
||||
newnav = navpoints[0].cloneNode(True)
|
||||
newnav.setAttribute("id","book"+newnav.getAttribute("id"))
|
||||
## For purposes of TOC titling & desc, use first book author
|
||||
newtext = newTag(tocncxdom,"text",text=booktitles[booknum]+" by "+allauthors[booknum][0])
|
||||
text = newnav.getElementsByTagName("text")[0]
|
||||
text.parentNode.replaceChild(newtext,text)
|
||||
tocnavMap.appendChild(newnav)
|
||||
|
||||
if not descopt and not fromfirst:
|
||||
description.appendChild(contentdom.createTextNode(booktitles[booknum]+" by "+allauthors[booknum][0]+"\n"))
|
||||
|
||||
for navpoint in navpoints:
|
||||
#print "navpoint:%s"%navpoint.getAttribute("id")
|
||||
if not striptitletoc or not re.match(r'(title|toc)_page',navpoint.getAttribute("id")):
|
||||
tocnavMap.appendChild(navpoint)
|
||||
booknum=booknum+1;
|
||||
|
||||
## Force strict ordering of playOrder
|
||||
playorder=1
|
||||
for navpoint in tocncxdom.getElementsByTagName("navPoint"):
|
||||
navpoint.setAttribute("playOrder","%d" % playorder)
|
||||
if( not navpoint.getAttribute("id").startswith("book") ):
|
||||
playorder = playorder + 1
|
||||
|
||||
## content.opf written now due to description being filled in
|
||||
## during TOC generation to save loops.
|
||||
outputepub.writestr("content.opf",contentdom.toxml('utf-8'))
|
||||
outputepub.writestr("toc.ncx",tocncxdom.toxml('utf-8'))
|
||||
|
||||
# declares all the files created by Windows. otherwise, when
|
||||
# it runs in appengine, windows unzips the files as 000 perms.
|
||||
for zf in outputepub.filelist:
|
||||
zf.create_system = 0
|
||||
outputepub.close()
|
||||
|
||||
return (source,filecount)
|
||||
|
||||
## Utility method for creating new tags.
|
||||
def newTag(dom,name,attrs=None,text=None):
|
||||
tag = dom.createElement(name)
|
||||
if( attrs is not None ):
|
||||
for attr in attrs.keys():
|
||||
tag.setAttribute(attr,attrs[attr])
|
||||
if( text is not None ):
|
||||
tag.appendChild(dom.createTextNode(text))
|
||||
return tag
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
print('''
|
||||
The this utility has been split out into it's own project.
|
||||
See: http://code.google.com/p/epubmerge/
|
||||
...for a CLI epubmerge.py program and calibre plugin.
|
||||
''')
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
## This is an example of what your personal configuration might look
|
||||
## like.
|
||||
|
||||
[defaults]
|
||||
## Some sites also require the user to confirm they are adult for
|
||||
## adult content. In commandline version, this should go in your
|
||||
## personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
## Most common, I expect will be using this to save username/passwords
|
||||
## for different sites.
|
||||
[www.twilighted.net]
|
||||
|
||||
@@ -1,78 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2011 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import os, re, sys, glob
|
||||
from os.path import dirname, basename, normpath
|
||||
import logging
|
||||
import urlparse as up
|
||||
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
|
||||
## This bit of complexity allows adapters to be added by just adding
|
||||
## the source file. It eliminates the long if/else clauses we used to
|
||||
## need to pick out the adapter.
|
||||
|
||||
## List of registered site adapters.
|
||||
|
||||
__class_list = []
|
||||
|
||||
def getAdapter(config,url):
|
||||
## fix up leading protocol.
|
||||
fixedurl = re.sub(r"(?i)^[htp]+[:/]+","http://",url.strip())
|
||||
if not fixedurl.startswith("http"):
|
||||
fixedurl = "http://%s"%url
|
||||
## remove any trailing '#' locations.
|
||||
fixedurl = re.sub(r"#.*$","",fixedurl)
|
||||
|
||||
## remove any trailing '&' parameters--?sid=999 will be left.
|
||||
## that's all that any of the current adapters need or want.
|
||||
fixedurl = re.sub(r"&.*$","",fixedurl)
|
||||
|
||||
parsedUrl = up.urlparse(fixedurl)
|
||||
domain = parsedUrl.netloc.lower()
|
||||
if( domain != parsedUrl.netloc ):
|
||||
fixedurl = fixedurl.replace(parsedUrl.netloc,domain)
|
||||
|
||||
logging.debug("site:"+domain)
|
||||
cls = getClassFor(domain)
|
||||
if not cls:
|
||||
logging.debug("trying site:www."+domain)
|
||||
cls = getClassFor("www."+domain)
|
||||
fixedurl = fixedurl.replace("http://","http://www.")
|
||||
if cls:
|
||||
adapter = cls(config,fixedurl) # raises InvalidStoryURL
|
||||
return adapter
|
||||
# No adapter found.
|
||||
raise exceptions.UnknownSite( url, [cls.getSiteDomain() for cls in __class_list] )
|
||||
|
||||
def getClassFor(domain):
|
||||
for cls in __class_list:
|
||||
if cls.matchesSite(domain):
|
||||
return cls
|
||||
|
||||
## Automatically import each adapter_*.py file.
|
||||
## Each implement getClass() to their class
|
||||
|
||||
filelist = glob.glob(dirname(__file__)+'/adapter_*.py')
|
||||
sys.path.insert(0,normpath(dirname(__file__)))
|
||||
|
||||
for file in filelist:
|
||||
#print "file: "+basename(file)[:-3]
|
||||
module = __import__(basename(file)[:-3])
|
||||
__class_list.append(module.getClass())
|
||||
|
||||
del sys.path[0]
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2011 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import os, re, sys, glob, types
|
||||
from os.path import dirname, basename, normpath
|
||||
import logging
|
||||
import urlparse as up
|
||||
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
## must import each adapter here.
|
||||
|
||||
import adapter_test1
|
||||
import adapter_fanfictionnet
|
||||
import adapter_castlefansorg
|
||||
import adapter_fanfictionnet
|
||||
import adapter_fictionalleyorg
|
||||
import adapter_fictionpresscom
|
||||
import adapter_ficwadcom
|
||||
import adapter_fimfictionnet
|
||||
import adapter_harrypotterfanfictioncom
|
||||
import adapter_mediaminerorg
|
||||
import adapter_potionsandsnitchesnet
|
||||
import adapter_tenhawkpresentscom
|
||||
import adapter_adastrafanficcom
|
||||
import adapter_thewriterscoffeeshopcom
|
||||
import adapter_tthfanficorg
|
||||
import adapter_twilightednet
|
||||
import adapter_twiwritenet
|
||||
import adapter_whoficcom
|
||||
import adapter_siyecouk
|
||||
import adapter_archiveofourownorg
|
||||
import adapter_ficbooknet
|
||||
import adapter_gayauthorsorg
|
||||
|
||||
## This bit of complexity allows adapters to be added by just adding
|
||||
## importing. It eliminates the long if/else clauses we used to need
|
||||
## to pick out the adapter.
|
||||
|
||||
## List of registered site adapters.
|
||||
__class_list = []
|
||||
|
||||
def imports():
|
||||
for name, val in globals().items():
|
||||
if isinstance(val, types.ModuleType):
|
||||
yield val.__name__
|
||||
|
||||
for x in imports():
|
||||
if "fanficdownloader.adapters.adapter_" in x:
|
||||
#print x
|
||||
__class_list.append(sys.modules[x].getClass())
|
||||
|
||||
def getAdapter(config,url,fileform=None):
|
||||
## fix up leading protocol.
|
||||
fixedurl = re.sub(r"(?i)^[htp]+[:/]+","http://",url.strip())
|
||||
if not fixedurl.startswith("http"):
|
||||
fixedurl = "http://%s"%url
|
||||
## remove any trailing '#' locations.
|
||||
fixedurl = re.sub(r"#.*$","",fixedurl)
|
||||
|
||||
## remove any trailing '&' parameters--?sid=999 will be left.
|
||||
## that's all that any of the current adapters need or want.
|
||||
fixedurl = re.sub(r"&.*$","",fixedurl)
|
||||
|
||||
parsedUrl = up.urlparse(fixedurl)
|
||||
domain = parsedUrl.netloc.lower()
|
||||
if( domain != parsedUrl.netloc ):
|
||||
fixedurl = fixedurl.replace(parsedUrl.netloc,domain)
|
||||
|
||||
logging.debug("site:"+domain)
|
||||
cls = getClassFor(domain)
|
||||
if not cls:
|
||||
logging.debug("trying site:www."+domain)
|
||||
cls = getClassFor("www."+domain)
|
||||
fixedurl = fixedurl.replace("http://","http://www.")
|
||||
if cls:
|
||||
adapter = cls(config,fixedurl) # raises InvalidStoryURL
|
||||
adapter.setSectionOrder(adapter.getSiteDomain(),fileform)
|
||||
return adapter
|
||||
# No adapter found.
|
||||
raise exceptions.UnknownSite( url, [cls.getSiteDomain() for cls in __class_list] )
|
||||
|
||||
def getClassFor(domain):
|
||||
for cls in __class_list:
|
||||
if cls.matchesSite(domain):
|
||||
return cls
|
||||
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class AdAstraFanficComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -79,6 +79,11 @@ class AdAstraFanficComSiteAdapter(BaseSiteAdapter):
|
||||
if "Content is only suitable for mature adults. May contain explicit language and adult themes. Equivalent of NC-17." in data:
|
||||
raise exceptions.AdultCheckRequired(self.url)
|
||||
|
||||
# problems with some stories, but only in calibre. I suspect
|
||||
# issues with different SGML parsers in python. This is a
|
||||
# nasty hack, but it works.
|
||||
data = data[data.index("<body"):]
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
soup = bs.BeautifulSoup(data)
|
||||
|
||||
@@ -128,7 +133,8 @@ class AdAstraFanficComSiteAdapter(BaseSiteAdapter):
|
||||
# sometimes poorly formated desc (<p> w/o </p>) leads
|
||||
# to all labels being included.
|
||||
svalue=svalue[:svalue.find('<span class="label">')]
|
||||
self.story.setMetadata('description',stripHTML(svalue))
|
||||
self.setDescription(url,svalue)
|
||||
#self.story.setMetadata('description',stripHTML(svalue))
|
||||
|
||||
if 'Rated' in label:
|
||||
self.story.setMetadata('rating', value)
|
||||
@@ -142,6 +148,12 @@ class AdAstraFanficComSiteAdapter(BaseSiteAdapter):
|
||||
for cat in catstext:
|
||||
self.story.addToList('category',cat.string)
|
||||
|
||||
if 'Characters' in label:
|
||||
chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters'))
|
||||
charstext = [char.string for char in chars]
|
||||
for char in charstext:
|
||||
self.story.addToList('characters',char.string)
|
||||
|
||||
if 'Genre' in label:
|
||||
genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1'))
|
||||
genrestext = [genre.string for genre in genres]
|
||||
@@ -163,19 +175,45 @@ class AdAstraFanficComSiteAdapter(BaseSiteAdapter):
|
||||
self.story.setMetadata('status', 'In-Progress')
|
||||
|
||||
if 'Published' in label:
|
||||
self.story.setMetadata('datePublished', makeDate(value.strip(), "%m/%d/%Y"))
|
||||
self.story.setMetadata('datePublished', makeDate(value.strip(), "%d %b %Y"))
|
||||
|
||||
if 'Updated' in label:
|
||||
# there's a stray [ at the end.
|
||||
#value = value[0:-1]
|
||||
self.story.setMetadata('dateUpdated', makeDate(value.strip(), "%m/%d/%Y"))
|
||||
self.story.setMetadata('dateUpdated', makeDate(value.strip(), "%d %b %Y"))
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
|
||||
soup = bs.BeautifulStoneSoup(self._fetchUrl(url),
|
||||
data = self._fetchUrl(url)
|
||||
# problems with some stories, but only in calibre. I suspect
|
||||
# issues with different SGML parsers in python. This is a
|
||||
# nasty hack, but it works.
|
||||
data = data[data.index("<body"):]
|
||||
|
||||
soup = bs.BeautifulStoneSoup(data,
|
||||
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
|
||||
span = soup.find('div', {'id' : 'story'})
|
||||
@@ -183,7 +221,7 @@ class AdAstraFanficComSiteAdapter(BaseSiteAdapter):
|
||||
if None == span:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(span)
|
||||
return self.utf8FromSoup(url,span)
|
||||
|
||||
def getClass():
|
||||
return AdAstraFanficComSiteAdapter
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2011 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import time
|
||||
import logging
|
||||
import re
|
||||
import urllib2
|
||||
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
def getClass():
|
||||
return ArchiveOfOurOwnOrgAdapter
|
||||
|
||||
|
||||
class ArchiveOfOurOwnOrgAdapter(BaseSiteAdapter):
|
||||
|
||||
def __init__(self, config, url):
|
||||
BaseSiteAdapter.__init__(self, config, url)
|
||||
|
||||
self.decode = ["utf8",
|
||||
"Windows-1252"] # 1252 is a superset of iso-8859-1.
|
||||
# Most sites that claim to be
|
||||
# iso-8859-1 (and some that claim to be
|
||||
# utf8) are really windows-1252.
|
||||
|
||||
|
||||
|
||||
# get storyId from url--url validation guarantees query is only sid=1234
|
||||
self.story.setMetadata('storyId',self.parsedUrl.path.split('/',)[2])
|
||||
logging.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
|
||||
|
||||
# normalized story URL.
|
||||
self._setURL('http://' + self.getSiteDomain() + '/works/'+self.story.getMetadata('storyId'))
|
||||
|
||||
# Each adapter needs to have a unique site abbreviation.
|
||||
self.story.setMetadata('siteabbrev','ao3')
|
||||
|
||||
# The date format will vary from site to site.
|
||||
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
|
||||
self.dateformat = "%Y-%b-%d"
|
||||
|
||||
@staticmethod # must be @staticmethod, don't remove it.
|
||||
def getSiteDomain():
|
||||
# The site domain. Does have www here, if it uses it.
|
||||
return 'www.archiveofourown.org'
|
||||
|
||||
def getSiteExampleURLs(self):
|
||||
return "http://"+self.getSiteDomain()+"/works/123456"
|
||||
|
||||
def getSiteURLPattern(self):
|
||||
return re.escape("http://"+self.getSiteDomain()+"/works/")+r"\d+(/chapters/\d+)?/?$"
|
||||
|
||||
## Getting the chapter list and the meta data, plus 'is adult' checking.
|
||||
def extractChapterUrlsAndMetadata(self):
|
||||
|
||||
if self.is_adult or self.getConfig("is_adult"):
|
||||
addurl = "?view_adult=true"
|
||||
else:
|
||||
addurl=""
|
||||
|
||||
meta = self.url+addurl
|
||||
url = self.url+'/navigate'+addurl
|
||||
logging.debug("URL: "+meta)
|
||||
|
||||
try:
|
||||
data = self._fetchUrl(url)
|
||||
meta = self._fetchUrl(meta)
|
||||
|
||||
if "This work could have adult content. If you proceed you have agreed that you are willing to see such content." in meta:
|
||||
raise exceptions.AdultCheckRequired(self.url)
|
||||
|
||||
except urllib2.HTTPError, e:
|
||||
if e.code == 404:
|
||||
raise exceptions.StoryDoesNotExist(self.meta)
|
||||
else:
|
||||
raise e
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
soup = bs.BeautifulSoup(data)
|
||||
metasoup = bs.BeautifulSoup(meta)
|
||||
# print data
|
||||
|
||||
# Now go hunting for all the meta data and the chapter list.
|
||||
|
||||
## Title
|
||||
a = soup.find('a', href=re.compile(r"^/works/\w+"))
|
||||
self.story.setMetadata('title',a.string)
|
||||
|
||||
# Find authorid and URL from... author url.
|
||||
a = soup.find('a', href=re.compile(r"^/users/\w+/pseuds/\w+"))
|
||||
self.story.setMetadata('authorId',a['href'].split('/')[2])
|
||||
self.story.setMetadata('authorUrl','http://'+self.host+a['href'])
|
||||
self.story.setMetadata('author',a.text)
|
||||
|
||||
# Find the chapters:
|
||||
chapters=soup.findAll('a', href=re.compile(r'/works/'+self.story.getMetadata('storyId')+"/chapters/\d+$"))
|
||||
self.story.setMetadata('numChapters',len(chapters))
|
||||
logging.debug("numChapters: (%s)"%self.story.getMetadata('numChapters'))
|
||||
for x in range(0,len(chapters)):
|
||||
# just in case there's tags, like <i> in chapter titles.
|
||||
chapter=chapters[x]
|
||||
if len(chapters)==1:
|
||||
self.chapterUrls.append((self.story.getMetadata('title'),'http://'+self.host+chapter['href']+addurl))
|
||||
else:
|
||||
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+chapter['href']+addurl))
|
||||
|
||||
|
||||
|
||||
a = metasoup.find('blockquote',{'class':'userstuff'})
|
||||
if a != None:
|
||||
self.setDescription(url,a.text)
|
||||
#self.story.setMetadata('description',a.text)
|
||||
|
||||
a = metasoup.find('dd',{'class':"rating tags"})
|
||||
if a != None:
|
||||
self.story.setMetadata('rating',stripHTML(a.text))
|
||||
|
||||
a = metasoup.find('dd',{'class':"fandom tags"})
|
||||
fandoms = a.findAll('a',{'class':"tag"})
|
||||
for fandom in fandoms:
|
||||
self.story.addToList('category',fandom.string)
|
||||
|
||||
a = metasoup.find('dd',{'class':"warning tags"})
|
||||
if a != None:
|
||||
warnings = a.findAll('a',{'class':"tag"})
|
||||
for warning in warnings:
|
||||
if warning.string == "Author Chose Not To Use Archive Warnings":
|
||||
warning.string = "No Archive Warnings Apply"
|
||||
if warning.string != "No Archive Warnings Apply":
|
||||
self.story.addToList('warnings',warning.string)
|
||||
|
||||
a = metasoup.find('dd',{'class':"freeform tags"})
|
||||
if a != None:
|
||||
genres = a.findAll('a',{'class':"tag"})
|
||||
for genre in genres:
|
||||
self.story.addToList('genre',genre.string)
|
||||
a = metasoup.find('dd',{'class':"category tags"})
|
||||
if a != None:
|
||||
genres = a.findAll('a',{'class':"tag"})
|
||||
for genre in genres:
|
||||
if genre != "Gen":
|
||||
self.story.addToList('genre',genre.string)
|
||||
|
||||
a = metasoup.find('dd',{'class':"character tags"})
|
||||
if a != None:
|
||||
chars = a.findAll('a',{'class':"tag"})
|
||||
for char in chars:
|
||||
self.story.addToList('characters',char.string)
|
||||
a = metasoup.find('dd',{'class':"relationship tags"})
|
||||
if a != None:
|
||||
chars = a.findAll('a',{'class':"tag"})
|
||||
for char in chars:
|
||||
self.story.addToList('characters',char.string)
|
||||
|
||||
|
||||
stats = metasoup.find('dl',{'class':'stats'})
|
||||
dt = stats.findAll('dt')
|
||||
dd = stats.findAll('dd')
|
||||
for x in range(0,len(dt)):
|
||||
label = dt[x].text
|
||||
value = dd[x].text
|
||||
|
||||
if 'Words:' in label:
|
||||
self.story.setMetadata('numWords', value)
|
||||
|
||||
if 'Chapters:' in label:
|
||||
if value.split('/')[0] == value.split('/')[1]:
|
||||
self.story.setMetadata('status', 'Completed')
|
||||
else:
|
||||
self.story.setMetadata('status', 'In-Progress')
|
||||
|
||||
|
||||
if 'Published' in label:
|
||||
self.story.setMetadata('datePublished', makeDate(stripHTML(value), self.dateformat))
|
||||
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat))
|
||||
|
||||
if 'Updated' in label:
|
||||
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat))
|
||||
|
||||
if 'Completed' in label:
|
||||
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat))
|
||||
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = metasoup.find('dd',{'class':"series"})
|
||||
b = a.find('a', href=re.compile(r"/series/\d+"))
|
||||
series_name = b.string
|
||||
series_url = 'http://'+self.host+'/fanfic/'+b['href']
|
||||
series_index = int(a.text.split(' ')[1])
|
||||
self.setSeries(series_name, series_index)
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
# grab the text for an individual chapter.
|
||||
def getChapterText(self, url):
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
|
||||
chapter=bs.BeautifulSoup('<div class="story"></div>')
|
||||
data = self._fetchUrl(url)
|
||||
soup = bs.BeautifulSoup(data,selfClosingTags=('br','hr'))
|
||||
|
||||
headnotes = soup.find('div', {'class' : "preface group"}).find('div', {'class' : "notes module"})
|
||||
if headnotes != None:
|
||||
headnotes = headnotes.find('blockquote', {'class' : "userstuff"})
|
||||
if headnotes != None:
|
||||
chapter.append("<b>Author's Note:</b>")
|
||||
chapter.append(headnotes)
|
||||
|
||||
chapsumm = soup.find('div', {'id' : "summary"})
|
||||
if chapsumm != None:
|
||||
chapsumm = chapsumm.find('blockquote')
|
||||
chapter.append("<b>Summary for the Chapter:</b>")
|
||||
chapter.append(chapsumm)
|
||||
chapnotes = soup.find('div', {'id' : "notes"})
|
||||
if chapnotes != None:
|
||||
chapnotes = chapnotes.find('blockquote')
|
||||
if chapnotes != None:
|
||||
chapter.append("<b>Notes for the Chapter:</b>")
|
||||
chapter.append(chapnotes)
|
||||
|
||||
text = soup.find('div', {'class' : "userstuff module"})
|
||||
chtext = text.find('h3', {'class' : "landmark heading"})
|
||||
if chtext:
|
||||
chtext.extract()
|
||||
chapter.append(text)
|
||||
|
||||
chapfoot = soup.find('div', {'class' : "end notes module", 'role' : "complementary"})
|
||||
if chapfoot != None:
|
||||
chapfoot = chapfoot.find('blockquote')
|
||||
chapter.append("<b>Notes for the Chapter:</b>")
|
||||
chapter.append(chapfoot)
|
||||
|
||||
footnotes = soup.find('div', {'id' : "work_endnotes"})
|
||||
if footnotes != None:
|
||||
footnotes = footnotes.find('blockquote')
|
||||
chapter.append("<b>Author's Note:</b>")
|
||||
chapter.append(footnotes)
|
||||
|
||||
if None == soup:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return self.utf8FromSoup(url,chapter)
|
||||
+42
-15
@@ -20,11 +20,11 @@ import logging
|
||||
import re
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
# By virtue of being recent and requiring both is_adult and user/pass,
|
||||
# adapter_fanficcastletvnet.py is the best choice for learning to
|
||||
@@ -51,11 +51,11 @@ from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
# updated to reflect the class below it. That, plus getSiteDomain()
|
||||
# take care of 'Registering'.
|
||||
def getClass():
|
||||
return FanficCastleTVNetAdapter # XXX
|
||||
return CastleFansOrgAdapter # XXX
|
||||
|
||||
# Class name has to be unique. Our convention is camel case the
|
||||
# sitename with Adapter at the end. www is skipped.
|
||||
class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
class CastleFansOrgAdapter(BaseSiteAdapter): # XXX
|
||||
|
||||
def __init__(self, config, url):
|
||||
BaseSiteAdapter.__init__(self, config, url)
|
||||
@@ -74,10 +74,10 @@ class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
logging.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
|
||||
|
||||
# normalized story URL.
|
||||
self._setURL('http://' + self.getSiteDomain() + '/viewstory.php?sid='+self.story.getMetadata('storyId'))
|
||||
self._setURL('http://' + self.getSiteDomain() + '/fanfic/viewstory.php?sid='+self.story.getMetadata('storyId'))
|
||||
|
||||
# Each adapter needs to have a unique site abbreviation.
|
||||
self.story.setMetadata('siteabbrev','csltv') # XXX
|
||||
self.story.setMetadata('siteabbrev','cslf') # XXX
|
||||
|
||||
# If all stories from the site fall into the same category,
|
||||
# the site itself isn't likely to label them as such, so we
|
||||
@@ -91,13 +91,13 @@ class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
@staticmethod # must be @staticmethod, don't remove it.
|
||||
def getSiteDomain():
|
||||
# The site domain. Does have www here, if it uses it.
|
||||
return 'fanfic.castletv.net' # XXX
|
||||
return 'castlefans.org' # XXX
|
||||
|
||||
def getSiteExampleURLs(self):
|
||||
return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234"
|
||||
return "http://"+self.getSiteDomain()+"/fanfic/viewstory.php?sid=1234"
|
||||
|
||||
def getSiteURLPattern(self):
|
||||
return re.escape("http://"+self.getSiteDomain()+"/viewstory.php?sid=")+r"\d+$"
|
||||
return re.escape("http://"+self.getSiteDomain()+"/fanfic/viewstory.php?sid=")+r"\d+$"
|
||||
|
||||
## Login seems to be reasonably standard across eFiction sites.
|
||||
def needToLoginCheck(self, data):
|
||||
@@ -120,7 +120,7 @@ class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
params['cookiecheck'] = '1'
|
||||
params['submit'] = 'Submit'
|
||||
|
||||
loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login'
|
||||
loginUrl = 'http://' + self.getSiteDomain() + '/fanfic/user.php?action=login'
|
||||
logging.debug("Will now login to URL (%s) as (%s)" % (loginUrl,
|
||||
params['penname']))
|
||||
|
||||
@@ -192,7 +192,7 @@ class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
# Find the chapters:
|
||||
for chapter in soup.findAll('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"&chapter=\d+$")):
|
||||
# just in case there's tags, like <i> in chapter titles.
|
||||
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['href']+addurl))
|
||||
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/fanfic/'+chapter['href']+addurl))
|
||||
|
||||
self.story.setMetadata('numChapters',len(self.chapterUrls))
|
||||
|
||||
@@ -218,7 +218,8 @@ class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
while not defaultGetattr(value,'class') == 'label':
|
||||
svalue += str(value)
|
||||
value = value.nextSibling
|
||||
self.story.setMetadata('description',stripHTML(svalue))
|
||||
self.setDescription(url,svalue)
|
||||
#self.story.setMetadata('description',stripHTML(svalue))
|
||||
|
||||
if 'Rated' in label:
|
||||
self.story.setMetadata('rating', value)
|
||||
@@ -232,6 +233,12 @@ class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
for cat in catstext:
|
||||
self.story.addToList('category',cat.string)
|
||||
|
||||
if 'Characters' in label:
|
||||
chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters'))
|
||||
charstext = [char.string for char in chars]
|
||||
for char in charstext:
|
||||
self.story.addToList('characters',char.string)
|
||||
|
||||
## Not all sites use Genre, but there's no harm to
|
||||
## leaving it in. Check to make sure the type_id number
|
||||
## is correct, though--it's site specific.
|
||||
@@ -266,6 +273,26 @@ class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
#value = value[0:-1]
|
||||
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat))
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/fanfic/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
# grab the text for an individual chapter.
|
||||
def getChapterText(self, url):
|
||||
|
||||
@@ -279,4 +306,4 @@ class FanficCastleTVNetAdapter(BaseSiteAdapter): # XXX
|
||||
if None == div:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(div)
|
||||
return self.utf8FromSoup(url,div)
|
||||
@@ -21,10 +21,10 @@ import re
|
||||
import urllib2
|
||||
import time
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class FanFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -70,10 +70,11 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
url = self.origurl
|
||||
logging.debug("URL: "+url)
|
||||
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
try:
|
||||
data = self._fetchUrl(url)
|
||||
#print("\n===================\n%s\n===================\n"%data)
|
||||
soup = bs.BeautifulSoup(data)
|
||||
except urllib2.HTTPError, e:
|
||||
if e.code == 404:
|
||||
@@ -83,10 +84,33 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
if "Unable to locate story with id of " in data:
|
||||
raise exceptions.StoryDoesNotExist(url)
|
||||
|
||||
if "Chapter not found. Please check to see you are not using an outdated url." in data:
|
||||
|
||||
# some times "Chapter not found...", sometimes "Chapter text not found..."
|
||||
if "not found. Please check to see you are not using an outdated url." in data:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! 'Chapter not found. Please check to see you are not using an outdated url.'" % url)
|
||||
|
||||
|
||||
try:
|
||||
# rather nasty way to check for a newer chapter. ffnet has a
|
||||
# tendency to send out update notices in email before all
|
||||
# their servers are showing the update on the first chapter.
|
||||
try:
|
||||
chapcount = len(soup.find('select', { 'name' : 'chapter' } ).findAll('option'))
|
||||
# get chapter part of url.
|
||||
except:
|
||||
chapcount = 1
|
||||
chapter = url.split('/',)[5]
|
||||
tryurl = "http://%s/s/%s/%d/"%(self.getSiteDomain(),
|
||||
self.story.getMetadata('storyId'),
|
||||
chapcount+1)
|
||||
print('=Trying newer chapter: %s' % tryurl)
|
||||
newdata = self._fetchUrl(tryurl)
|
||||
if "not found. Please check to see you are not using an outdated url." \
|
||||
not in newdata:
|
||||
print('=======Found newer chapter: %s' % tryurl)
|
||||
soup = bs.BeautifulSoup(newdata)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Find authorid and URL from... author url.
|
||||
a = soup.find('a', href=re.compile(r"^/u/\d+"))
|
||||
self.story.setMetadata('authorId',a['href'].split('/')[2])
|
||||
@@ -118,7 +142,7 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
continue
|
||||
if 'var storyid' in script.string:
|
||||
for line in script.string.split('\n'):
|
||||
m = re.match(r"^ +var ([^ ]+) = '?(.*?)'?;$",line)
|
||||
m = re.match(r"^ +var ([^ ]+) = '?(.*?)'?;\r?$",line)
|
||||
if m == None : continue
|
||||
var,value = m.groups()
|
||||
# remove javascript escaping from values.
|
||||
@@ -129,7 +153,8 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
if 'title_t' in var:
|
||||
self.story.setMetadata('title', value)
|
||||
if 'summary' in var:
|
||||
self.story.setMetadata('description', value)
|
||||
self.setDescription(url,value)
|
||||
#self.story.setMetadata('description', value)
|
||||
if 'datep' in var:
|
||||
self.story.setMetadata('datePublished',makeDate(value, '%m-%d-%y'))
|
||||
if 'dateu' in var:
|
||||
@@ -175,6 +200,11 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
a = soup.find('a', href='http://www.fictionratings.com/')
|
||||
self.story.setMetadata('rating',a.string)
|
||||
|
||||
# used below to get correct characters.
|
||||
metatext = a.findNext(text=re.compile(r' - Reviews:'))
|
||||
if metatext == None: # indicates there's no Reviews, look for id: instead.
|
||||
metatext = a.findNext(text=re.compile(r' - id:'))
|
||||
|
||||
# after Rating, the same bit of text containing id:123456 contains
|
||||
# Complete--if completed.
|
||||
if 'Complete' in a.findNext(text=re.compile(r'id:'+self.story.getMetadata('storyId'))):
|
||||
@@ -183,21 +213,41 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
self.story.setMetadata('status', 'In-Progress')
|
||||
|
||||
# Parse genre(s) from <meta name="description" content="..."
|
||||
# <meta name="description" content="Chapter 1 of a Harry Potter - Family/Friendship fanfiction. Dudley Dursley would be the first to say he lived a very normal life. But what happens when he gets invited to his cousin Harry Potter's wedding? Will Dudley get the courage to apologize for the torture he caused all those years ago? Harry/Ginny story..">
|
||||
# <meta name="description" content="A Gundam Wing/AC and Gundam Seed - Romance/Sci-Fi crossover fanfiction with characters: & Kira Y.. Story summary: One-Shoot dividido en dos partes. Kira va en camino a rescatar a Lacus, pero él no es el unico. Dos personajes de diferentes universos Gundams. SEED vs ZERO.">
|
||||
# <meta name="description" content="Chapter 1 of a Alvin and the chipmunks and Alpha and Omega crossover fanfiction with characters: Alvin S. & Humphrey. You'll just have to read to find out... No Flames Plesae... and tell me what you want to see by PM'ing me....">
|
||||
# genre is after first -, but before first 'fanfiction'.
|
||||
m = re.match(r"^(?:Chapter \d+ of a|A) (?:.*?) (?:- (?P<genres>.*?)) (?:crossover )?fanfiction",
|
||||
# <meta name="description" content="A Transformers/Beast Wars - Humor fanfiction with characters Prowl & Sideswipe. Story summary: Sideswipe is bored. Prowl appears to be so, too or at least, Sideswipe thinks he looks bored . So Sideswipe entertains them. After all, what's more fun than a race? Song-fic.">
|
||||
# <meta name="description" content="Chapter 1 of a Transformers/Beast Wars - Adventure/Friendship fanfiction with characters Bumblebee. TFA: What would you do if you was being abused all you life? Follow NightRunner as she goes through her spark breaking adventure of getting away from her father..">
|
||||
# (fp)<meta name="description" content="Chapter 1 of a Sci-Fi - Adventure/Humor fiction. Felix Max was just your regular hyperactive kid until he accidently caused his own fathers death. Now he has meta-humans trying to hunt him down with a corrupt goverment to back them up. Oh, and did I mention he has no Powers yet?.">
|
||||
# <meta name="description" content="Chapter 1 of a Bleach - Adventure/Angst fanfiction with characters Ichigo K. & Neliel T. O./Nel. Time travel with a twist. Time can be a real bi***. Ichigo finds that fact out when he accidentally goes back in time. Is this his second chance or is fate just screwing with him. Not a crack fic.IchixNelXHime.">
|
||||
# <meta name="description" content="Chapter 1 of a Harry Potter and Transformers - Humor/Adventure crossover fanfiction with characters: Harry P. & Ironhide. IT’s one thing to be tossed thru the Veil for something he didn’t do. It was quite another to wake in his animigus form in a world not his own. Harry just knew someone was laughing at him somewhere. Mech/Mech pairings inside..">
|
||||
m = re.match(r"^(?:Chapter \d+ of a|A) (?:.*?) (?:- (?P<genres>.*?) )?(?:crossover )?(?:fan)?fiction(?P<chars>[ ]+with characters)?",
|
||||
soup.find('meta',{'name':'description'})['content'])
|
||||
if m != None:
|
||||
genres=m.group('genres')
|
||||
# Hurt/Comfort is one genre.
|
||||
genres=re.sub('Hurt/Comfort','Hurt-Comfort',genres)
|
||||
for g in genres.split('/'):
|
||||
self.story.addToList('genre',g)
|
||||
|
||||
return
|
||||
if genres != None:
|
||||
# Hurt/Comfort is one genre.
|
||||
genres=re.sub('Hurt/Comfort','Hurt-Comfort',genres)
|
||||
for g in genres.split('/'):
|
||||
self.story.addToList('genre',g)
|
||||
|
||||
if m.group('chars') != None:
|
||||
|
||||
# At this point we've proven that there's character(s)
|
||||
# We can't reliably parse characters out of meta name="description".
|
||||
# There's no way to tell that "with characters Ichigo K. & Neliel T. O./Nel. " ends at "Nel.", not "T."
|
||||
# But we can pull them from the reviewstext line, now that we know about existance of chars.
|
||||
# reviewstext can take form of:
|
||||
# - English - Shinji H. - Updated: 01-13-12 - Published: 12-20-11 - id:7654123
|
||||
# - English - Adventure/Angst - Ichigo K. & Neliel T. O./Nel - Reviews:
|
||||
# - English - Humor/Adventure - Harry P. & Ironhide - Reviews:
|
||||
mc = re.match(r" - (?P<lang>[^ ]+ - )(?P<genres>[^ ]+ - )? (?P<chars>.+?) - (Reviews|Updated|Published)",
|
||||
metatext)
|
||||
chars = mc.group("chars")
|
||||
for c in chars.split(' & '):
|
||||
self.story.addToList('characters',c)
|
||||
m = re.match(r" - (?P<lang>[^ ]+)",metatext)
|
||||
if m.group('lang') != None:
|
||||
self.story.setMetadata('language',m.group('lang'))
|
||||
|
||||
return
|
||||
|
||||
def getChapterText(self, url):
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
@@ -221,7 +271,7 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
logging.debug('div id=storytext not found. data:%s'%data)
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(div)
|
||||
return self.utf8FromSoup(url,div)
|
||||
|
||||
def getClass():
|
||||
return FanFictionNetSiteAdapter
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2011 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import time
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import urllib2
|
||||
from .. import translit
|
||||
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
|
||||
def getClass():
|
||||
return FicBookNetAdapter
|
||||
|
||||
|
||||
class FicBookNetAdapter(BaseSiteAdapter):
|
||||
|
||||
def __init__(self, config, url):
|
||||
BaseSiteAdapter.__init__(self, config, url)
|
||||
|
||||
self.decode = ["utf8",
|
||||
"Windows-1252"] # 1252 is a superset of iso-8859-1.
|
||||
# Most sites that claim to be
|
||||
# iso-8859-1 (and some that claim to be
|
||||
# utf8) are really windows-1252.
|
||||
self.username = "NoneGiven" # if left empty, site doesn't return any message at all.
|
||||
self.password = ""
|
||||
self.is_adult=False
|
||||
|
||||
# get storyId from url--url validation guarantees query is only sid=1234
|
||||
self.story.setMetadata('storyId',self.parsedUrl.path.split('/',)[2])
|
||||
logging.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
|
||||
|
||||
# normalized story URL.
|
||||
self._setURL('http://' + self.getSiteDomain() + '/readfic/'+self.story.getMetadata('storyId'))
|
||||
|
||||
# Each adapter needs to have a unique site abbreviation.
|
||||
self.story.setMetadata('siteabbrev','fbn')
|
||||
|
||||
# The date format will vary from site to site.
|
||||
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
|
||||
self.dateformat = "%d %m %Y"
|
||||
|
||||
@staticmethod # must be @staticmethod, don't remove it.
|
||||
def getSiteDomain():
|
||||
# The site domain. Does have www here, if it uses it.
|
||||
return 'www.ficbook.net'
|
||||
|
||||
def getSiteExampleURLs(self):
|
||||
return "http://"+self.getSiteDomain()+"/readfic/12345"
|
||||
|
||||
def getSiteURLPattern(self):
|
||||
return re.escape("http://"+self.getSiteDomain()+"/readfic/")+r"\d+"
|
||||
|
||||
## Getting the chapter list and the meta data, plus 'is adult' checking.
|
||||
def extractChapterUrlsAndMetadata(self):
|
||||
url=self.url
|
||||
logging.debug("URL: "+url)
|
||||
try:
|
||||
data = self._fetchUrl(url)
|
||||
except urllib2.HTTPError, e:
|
||||
if e.code == 404:
|
||||
raise exceptions.StoryDoesNotExist(self.url)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
soup = bs.BeautifulSoup(data)
|
||||
|
||||
# Now go hunting for all the meta data and the chapter list.
|
||||
|
||||
table = soup.find('td',{'width':'50%'})
|
||||
|
||||
## Title
|
||||
a = soup.find('h1')
|
||||
self.story.setMetadata('title',a.string)
|
||||
logging.debug("Title: (%s)"%self.story.getMetadata('title'))
|
||||
|
||||
# Find authorid and URL from... author url.
|
||||
a = table.find('a')
|
||||
self.story.setMetadata('authorId',a.text) # Author's name is unique
|
||||
self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href'])
|
||||
self.story.setMetadata('author',a.text)
|
||||
logging.debug("Author: (%s)"%self.story.getMetadata('author'))
|
||||
|
||||
# Find the chapters:
|
||||
chapters = soup.find('div', {'class' : 'part_list'})
|
||||
if chapters != None:
|
||||
chapters=chapters.findAll('a', href=re.compile(r'/readfic/'+self.story.getMetadata('storyId')+"/\d+#part_content$"))
|
||||
self.story.setMetadata('numChapters',len(chapters))
|
||||
for x in range(0,len(chapters)):
|
||||
chapter=chapters[x]
|
||||
churl='http://'+self.host+chapter['href']
|
||||
self.chapterUrls.append((stripHTML(chapter),churl))
|
||||
if x == 0:
|
||||
pubdate = translit.translit(stripHTML(bs.BeautifulSoup(self._fetchUrl(churl)).find('div', {'class' : 'part_added'}).find('span')))
|
||||
if x == len(chapters)-1:
|
||||
update = translit.translit(stripHTML(bs.BeautifulSoup(self._fetchUrl(churl)).find('div', {'class' : 'part_added'}).find('span')))
|
||||
else:
|
||||
self.chapterUrls.append((self.story.getMetadata('title'),url))
|
||||
self.story.setMetadata('numChapters',1)
|
||||
pubdate=translit.translit(stripHTML(soup.find('div', {'class' : 'part_added'}).find('span')))
|
||||
update=pubdate
|
||||
|
||||
logging.debug("numChapters: (%s)"%self.story.getMetadata('numChapters'))
|
||||
|
||||
if not ',' in pubdate:
|
||||
pubdate=datetime.date.today().strftime(self.dateformat)
|
||||
if not ',' in update:
|
||||
update=datetime.date.today().strftime(self.dateformat)
|
||||
pubdate=pubdate.split(',')[0]
|
||||
update=update.split(',')[0]
|
||||
|
||||
fullmon = {"yanvarya":"01", "января":"01",
|
||||
"fievralya":"02", "февраля":"02",
|
||||
"marta":"03", "марта":"03",
|
||||
"aprielya":"04", "апреля":"04",
|
||||
"maya":"05", "мая":"05",
|
||||
"iyunya":"06", "июня":"06",
|
||||
"iyulya":"07", "июля":"07",
|
||||
"avghusta":"08", "августа":"08",
|
||||
"sentyabrya":"09", "сентября":"09",
|
||||
"oktyabrya":"10", "октября":"10",
|
||||
"noyabrya":"11", "ноября":"11",
|
||||
"diekabrya":"12", "декабря":"12" }
|
||||
for (name,num) in fullmon.items():
|
||||
if name in pubdate:
|
||||
pubdate = pubdate.replace(name,num)
|
||||
if name in update:
|
||||
update = update.replace(name,num)
|
||||
|
||||
self.story.setMetadata('dateUpdated', makeDate(update, self.dateformat))
|
||||
self.story.setMetadata('datePublished', makeDate(pubdate, self.dateformat))
|
||||
self.story.setMetadata('language','Russian')
|
||||
|
||||
pr=soup.find('a', href=re.compile(r'/printfic/\w+'))
|
||||
pr='http://'+self.host+pr['href']
|
||||
pr = bs.BeautifulSoup(self._fetchUrl(pr))
|
||||
pr=pr.findAll('div', {'class' : 'part_text'})
|
||||
i=0
|
||||
for part in pr:
|
||||
i=i+len(stripHTML(part).split(' '))
|
||||
self.story.setMetadata('numWords', str(i))
|
||||
|
||||
i=0
|
||||
fandoms = table.findAll('a', href=re.compile(r'/fanfiction/\w+'))
|
||||
for fandom in fandoms:
|
||||
self.story.addToList('category',fandom.string)
|
||||
i=i+1
|
||||
if i > 1:
|
||||
self.story.addToList('genre', 'Кроссовер')
|
||||
|
||||
meta=table.findAll('a', href=re.compile(r'/ratings/'))
|
||||
i=0
|
||||
for m in meta:
|
||||
if i == 0:
|
||||
self.story.setMetadata('rating', m.find('b').text)
|
||||
i=1
|
||||
elif i == 1:
|
||||
if not "," in m.nextSibling:
|
||||
i=2
|
||||
self.story.addToList('genre', m.find('b').text)
|
||||
elif i == 2:
|
||||
self.story.addToList('warnings', m.find('b').text)
|
||||
|
||||
|
||||
if table.find('span', {'style' : 'color: green'}):
|
||||
self.story.setMetadata('status', 'Completed')
|
||||
else:
|
||||
self.story.setMetadata('status', 'In Progress')
|
||||
|
||||
|
||||
tags = table.findAll('b')
|
||||
for tag in tags:
|
||||
label = translit.translit(tag.text)
|
||||
if 'Piersonazhi:' in label or 'Персонажи:' in label:
|
||||
chars=tag.nextSibling.string.split(', ')
|
||||
for char in chars:
|
||||
self.story.addToList('characters',char)
|
||||
break
|
||||
|
||||
summary=soup.find('span', {'class' : 'urlize'})
|
||||
self.setDescription(url,summary.text)
|
||||
#self.story.setMetadata('description', summary.text)
|
||||
|
||||
# grab the text for an individual chapter.
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
|
||||
soup = bs.BeautifulStoneSoup(self._fetchUrl(url),
|
||||
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
|
||||
chapter = soup.find('div', {'class' : 'public_beta'})
|
||||
if chapter == None:
|
||||
chapter = soup.find('div', {'class' : 'public_beta_disabled'})
|
||||
|
||||
if None == chapter:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return self.utf8FromSoup(url,chapter)
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class FictionAlleyOrgSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -187,7 +187,8 @@ class FictionAlleyOrgSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
for small in storydd.findAll('small'):
|
||||
small.extract() ## removes the <small> tags, leaving only the summary.
|
||||
self.story.setMetadata('description',stripHTML(storydd))
|
||||
self.setDescription(url,storydd)
|
||||
#self.story.setMetadata('description',stripHTML(storydd))
|
||||
|
||||
return
|
||||
|
||||
@@ -203,6 +204,10 @@ class FictionAlleyOrgSiteAdapter(BaseSiteAdapter):
|
||||
# our div with poor html inside the story text.
|
||||
data = data.replace('<!-- headerend -->','<crazytagstringnobodywouldstumbleonaccidently id="storytext">').replace('<!-- footerstart -->','</crazytagstringnobodywouldstumbleonaccidently>')
|
||||
|
||||
# problems with some stories confusing Soup. This is a nasty
|
||||
# hack, but it works.
|
||||
data = data[data.index("<crazytagstringnobodywouldstumbleonaccidently"):]
|
||||
|
||||
soup = bs.BeautifulStoneSoup(data,
|
||||
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
body = soup.findAll('body') ## some stories use a nested body and body
|
||||
@@ -219,7 +224,7 @@ class FictionAlleyOrgSiteAdapter(BaseSiteAdapter):
|
||||
if not data or not text:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(text)
|
||||
return self.utf8FromSoup(url,text)
|
||||
|
||||
def getClass():
|
||||
return FictionAlleyOrgSiteAdapter
|
||||
|
||||
@@ -22,11 +22,11 @@ import urllib2
|
||||
import time
|
||||
import httplib, urllib
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
from .. import BeautifulSoup as bs
|
||||
from .. import exceptions as exceptions
|
||||
from ..htmlcleanup import stripHTML
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class FicwadComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -124,7 +124,8 @@ class FicwadComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
# description
|
||||
storydiv = soup.find("div",{"id":"story"})
|
||||
self.story.setMetadata('description', storydiv.find("blockquote",{'class':'summary'}).p.string)
|
||||
self.setDescription(url,storydiv.find("blockquote",{'class':'summary'}).p.string)
|
||||
#self.story.setMetadata('description', storydiv.find("blockquote",{'class':'summary'}).p.string)
|
||||
|
||||
# most of the meta data is here:
|
||||
metap = storydiv.find("p",{"class":"meta"})
|
||||
@@ -152,6 +153,12 @@ class FicwadComSiteAdapter(BaseSiteAdapter):
|
||||
for g in m.group(1).split(','):
|
||||
self.story.addToList('genre',g)
|
||||
|
||||
m = re.match(r".*?Characters: (.*?) -.*?",metastr)
|
||||
if m:
|
||||
for g in m.group(1).split(','):
|
||||
if g:
|
||||
self.story.addToList('characters',g)
|
||||
|
||||
m = re.match(r".*?Published: ([0-9/]+?) -.*?",metastr)
|
||||
if m:
|
||||
self.story.setMetadata('datePublished',makeDate(m.group(1), "%Y/%m/%d"))
|
||||
@@ -195,9 +202,6 @@ class FicwadComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
def getChapterText(self, url):
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
time.sleep(0.5) ## ffnet tends to fail more if hit too fast.
|
||||
## This is in additional to what ever the
|
||||
## slow_down_sleep_time setting is.
|
||||
soup = bs.BeautifulStoneSoup(self._fetchUrl(url),
|
||||
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
|
||||
@@ -206,7 +210,7 @@ class FicwadComSiteAdapter(BaseSiteAdapter):
|
||||
if None == span:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(span)
|
||||
return self.utf8FromSoup(url,span)
|
||||
|
||||
def getClass():
|
||||
return FicwadComSiteAdapter
|
||||
|
||||
@@ -20,12 +20,13 @@ import logging
|
||||
import re
|
||||
import urllib2
|
||||
import cookielib as cl
|
||||
import datetime
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
def getClass():
|
||||
return FimFictionNetSiteAdapter
|
||||
@@ -87,8 +88,10 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
raise exceptions.AdultCheckRequired(self.url)
|
||||
|
||||
soup = bs.BeautifulSoup(data).find("div", {"class":"content_box post_content_box"})
|
||||
|
||||
title, author = [link.text for link in soup.find("h2").findAll("a")]
|
||||
|
||||
titleheader = soup.find("h2")
|
||||
title = titleheader.find("a", href=re.compile(r'^/story/')).text
|
||||
author = titleheader.find("a", href=re.compile(r'^/user/')).text
|
||||
self.story.setMetadata("title", title)
|
||||
self.story.setMetadata("author", author)
|
||||
self.story.setMetadata("authorId", author) # The author's name will be unique
|
||||
@@ -105,7 +108,7 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
for character in [character_icon['title'] for character_icon in soup.findAll("a", {"class":"character_icon"})]:
|
||||
self.story.addToList("characters", character)
|
||||
for category in [category.text for category in soup.find("div", {"class":"categories"}).findAll("a")]:
|
||||
self.story.addToList("category", category)
|
||||
self.story.addToList("genre", category)
|
||||
self.story.addToList("category", "My Little Pony")
|
||||
|
||||
|
||||
@@ -126,7 +129,9 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
status = status_bar.text.split("|")[0].strip().replace("Incomplete", "In-Progress").replace("On Hiatus", "In-Progress").replace("Complete", "Completed")
|
||||
self.story.setMetadata('status', status)
|
||||
self.story.setMetadata('rating', status_bar.span.text)
|
||||
self.story.setMetadata('numWords', status_bar.div.b.text)
|
||||
# This way is less elegant, perhaps, but more robust in face of format changes.
|
||||
numWords = status_bar.find("div",{"class":"word_count"}).b.text
|
||||
self.story.setMetadata('numWords', numWords)
|
||||
|
||||
description_soup = soup.find("div", {"class":"description"})
|
||||
# Sometimes the description has an expanding element
|
||||
@@ -136,20 +141,35 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
description_soup.find('a', {"class":"more"}).extract()
|
||||
except:
|
||||
pass
|
||||
self.story.setMetadata('description', description_soup.text)
|
||||
|
||||
# Unfortunately, nowhere on the page is the year mentioned. Because we would much rather update the story needlessly
|
||||
# than miss an update, we hardcode the year of creation and update to be 2011.
|
||||
|
||||
# fimfic is the first site with an explicit cover image.
|
||||
story_img = soup.find('img',{'class':'story_image'})
|
||||
if self.getConfig('include_images') and story_img:
|
||||
self.story.addImgUrl(self,self.url,story_img['src'],self._fetchUrlRaw,cover=True)
|
||||
self.setDescription(self.url,description_soup.text)
|
||||
#self.story.setMetadata('description', description_soup.text)
|
||||
|
||||
# Unfortunately, nowhere on the page is the year mentioned.
|
||||
# Best effort to deal with this:
|
||||
# Use this year, if that's a date in the future, subtract one year.
|
||||
# Their earliest story is Jun, so they'll probably change the date
|
||||
# around then.
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
# Get the date of creation from the first chapter
|
||||
datePublished_text = chapterDates[0]
|
||||
day, month = datePublished_text.split()
|
||||
day = re.sub(r"[^\d.]+", '', day)
|
||||
datePublished = makeDate("2011"+month+day, "%Y%b%d")
|
||||
datePublished = makeDate("%s%s%s"%(now.year,month,day), "%Y%b%d")
|
||||
if datePublished > now :
|
||||
datePublished = datePublished.replace(year=now.year-1)
|
||||
self.story.setMetadata("datePublished", datePublished)
|
||||
dateUpdated_soup = bs.BeautifulSoup(data).find("div", {"class":"calendar"})
|
||||
dateUpdated_soup.find('span').extract()
|
||||
dateUpdated = makeDate("2011"+dateUpdated_soup.text, "%Y%b%d")
|
||||
dateUpdated = makeDate("%s%s"%(now.year,dateUpdated_soup.text), "%Y%b%d")
|
||||
if dateUpdated > now :
|
||||
dateUpdated = datePublished.replace(year=now.year-1)
|
||||
self.story.setMetadata("dateUpdated", dateUpdated)
|
||||
|
||||
def getChapterText(self, url):
|
||||
@@ -157,5 +177,5 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
|
||||
soup = bs.BeautifulSoup(self._fetchUrl(url),selfClosingTags=('br','hr')).find('div', {'id' : 'chapter_container'})
|
||||
if soup == None:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
return utf8FromSoup(soup)
|
||||
|
||||
return self.utf8FromSoup(url,soup)
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2011 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import urllib2
|
||||
from urllib import unquote
|
||||
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
def getClass():
|
||||
return GayAuthorsAdapter
|
||||
|
||||
# Class name has to be unique. Our convention is camel case the
|
||||
# sitename with Adapter at the end. www is skipped.
|
||||
class GayAuthorsAdapter(BaseSiteAdapter):
|
||||
|
||||
def __init__(self, config, url):
|
||||
BaseSiteAdapter.__init__(self, config, url)
|
||||
|
||||
self.decode = ["utf8",
|
||||
"Windows-1252"] # 1252 is a superset of iso-8859-1.
|
||||
# Most sites that claim to be
|
||||
# iso-8859-1 (and some that claim to be
|
||||
# utf8) are really windows-1252.
|
||||
self.username = "NoneGiven" # if left empty, site doesn't return any message at all.
|
||||
self.password = ""
|
||||
self.is_adult=False
|
||||
|
||||
|
||||
# get storyId from url--url validation guarantees query is only sid=1234
|
||||
self.story.setMetadata('storyId',self.parsedUrl.path.split('/',)[3])
|
||||
logging.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
|
||||
|
||||
# unqoute, change '_' and ' ' to '-', downcase, and remove non-[a-z0-9-]
|
||||
authid = unquote(self.parsedUrl.path.split('/',)[2])
|
||||
authid = authid.lower().replace('_','-').replace(' ','-')
|
||||
authid = re.sub(r"[^a-z0-9-]","",authid)
|
||||
|
||||
self.story.setMetadata('authorId',authid)
|
||||
logging.debug("authorId: (%s)"%self.story.getMetadata('authorId'))
|
||||
|
||||
# normalized story URL.
|
||||
self._setURL('http://' + self.getSiteDomain() + '/story/'+self.story.getMetadata('authorId') + '/' + self.story.getMetadata('storyId'))
|
||||
|
||||
# Each adapter needs to have a unique site abbreviation.
|
||||
self.story.setMetadata('siteabbrev','ga')
|
||||
|
||||
# The date format will vary from site to site.
|
||||
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
|
||||
self.dateformat = "%d %b %Y"
|
||||
|
||||
@staticmethod # must be @staticmethod, don't remove it.
|
||||
def getSiteDomain():
|
||||
# The site domain. Does have www here, if it uses it.
|
||||
return 'www.gayauthors.org'
|
||||
|
||||
def getSiteExampleURLs(self):
|
||||
return "http://"+self.getSiteDomain()+"/story/author/storytitle"
|
||||
|
||||
def getSiteURLPattern(self):
|
||||
return re.escape("http://"+self.getSiteDomain()+"/story/")+r".*?/\w+.*?$"
|
||||
|
||||
|
||||
|
||||
## Getting the chapter list and the meta data, plus 'is adult' checking.
|
||||
def extractChapterUrlsAndMetadata(self):
|
||||
|
||||
|
||||
url = self.url
|
||||
logging.debug("URL: "+url)
|
||||
|
||||
try:
|
||||
data = self._fetchUrl(url)
|
||||
except urllib2.HTTPError, e:
|
||||
if e.code == 404:
|
||||
raise exceptions.StoryDoesNotExist(self.url)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
soup = bs.BeautifulSoup(data)
|
||||
# print data
|
||||
|
||||
# Now go hunting for all the meta data and the chapter list.
|
||||
msoup = soup.find('div', {'class' : 'story'})
|
||||
if msoup == None:
|
||||
msoup = soup.find('div', {'class' : 'story ispinned'})
|
||||
csoup = soup.find('div', {'id' : 'story_chapters'})
|
||||
|
||||
## Title
|
||||
a = msoup.find('span', {'class' : 'title'})
|
||||
title=a.find('span', {'itemprop' : 'name'})
|
||||
self.story.setMetadata('title',title.text)
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
series = a.find('span',{'class':"description"})
|
||||
series_name = series.find('a')
|
||||
series_name.extract()
|
||||
series_index = int(series.text.split(' ')[1])
|
||||
self.setSeries(series_name.text, series_index)
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
# Find authorid and URL from... author url.
|
||||
a = msoup.find('a', href=re.compile(r'/author/'+self.story.getMetadata('authorId')))
|
||||
self.story.setMetadata('authorUrl',a['href'])
|
||||
self.story.setMetadata('author',a.text)
|
||||
|
||||
|
||||
# Find the chapters:
|
||||
spans=csoup.findAll('span', {'class' : 'desc chapter-info'})
|
||||
for span in spans:
|
||||
span.extract()
|
||||
for chapter in csoup.findAll('a'):
|
||||
# just in case there's tags, like <i> in chapter titles.
|
||||
a=chapter['href'].split(self.story.getMetadata('author'))
|
||||
a=a[0]+self.story.getMetadata('authorId')+a[1]
|
||||
self.chapterUrls.append((stripHTML(chapter),a))
|
||||
|
||||
|
||||
self.story.setMetadata('numChapters',len(self.chapterUrls))
|
||||
|
||||
cats = msoup.findAll('a', href=re.compile(r'/browse/list/page__filtertype_0__category\w+$'))
|
||||
for cat in cats:
|
||||
self.story.addToList('category',cat.text)
|
||||
|
||||
genres = msoup.findAll('a', href=re.compile(r'/browse/list/page__filtertype_1__genre\w+$'))
|
||||
for genre in genres:
|
||||
self.story.addToList('genre',genre.text)
|
||||
genres = msoup.findAll('a', href=re.compile(r'/browse/list/page__filtertype_2__tag\w+$'))
|
||||
for genre in genres:
|
||||
self.story.addToList('genre',genre.text)
|
||||
|
||||
status = msoup.find('a', href=re.compile(r'/browse/list/page__filtertype_3__status\w+$'))
|
||||
self.story.setMetadata('status',status.text)
|
||||
|
||||
rating = msoup.find('a', href=re.compile(r'/browse/list/page__filtertype_4__rating\w+$'))
|
||||
self.story.setMetadata('rating',rating.text)
|
||||
|
||||
summary = msoup.find('span', {'itemprop' : 'description'})
|
||||
self.setDescription(self.url,summary.text)
|
||||
#self.story.setMetadata('description',summary.text)
|
||||
|
||||
|
||||
stats = msoup.find('dl',{'class':'info'})
|
||||
dt = stats.findAll('dt')
|
||||
dd = stats.findAll('dd')
|
||||
for x in range(0,len(dt)):
|
||||
label = dt[x].text
|
||||
value = dd[x].text
|
||||
|
||||
if 'Words:' in label:
|
||||
self.story.setMetadata('numWords', value)
|
||||
|
||||
if 'Published:' in label:
|
||||
date=stripHTML(value.split(' - ')[0])
|
||||
if ',' in date:
|
||||
date=datetime.date.today().strftime(self.dateformat)
|
||||
self.story.setMetadata('datePublished', makeDate(date, self.dateformat))
|
||||
|
||||
if 'Updated:' in label:
|
||||
date=stripHTML(value.split(' - ')[0])
|
||||
if ',' in date:
|
||||
date=datetime.date.today().strftime(self.dateformat)
|
||||
self.story.setMetadata('dateUpdated', makeDate(date, self.dateformat))
|
||||
|
||||
# grab the text for an individual chapter.
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
|
||||
data = self._fetchUrl(url)
|
||||
data = data[data.index("<div id='chapter-content'>"):]
|
||||
soup = bs.BeautifulSoup(data) # this one's happier with Soup, not StoneSoup for some reason.
|
||||
|
||||
div = soup.find('div', {'id' : 'chapter-content'})
|
||||
|
||||
if None == div:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return self.utf8FromSoup(url,div)
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class HarryPotterFanFictionComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -125,7 +125,8 @@ class HarryPotterFanFictionComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
## Finding the metadata is a bit of a pain. Desc is the only thing this color.
|
||||
desctable= soup.find('table',{'bgcolor':'#f0e8e8'})
|
||||
self.story.setMetadata('description',stripHTML(desctable))
|
||||
self.setDescription(url,desctable)
|
||||
#self.story.setMetadata('description',stripHTML(desctable))
|
||||
|
||||
## Finding the metadata is a bit of a pain. Most of the meta
|
||||
## data is in a center.table without a bgcolor.
|
||||
@@ -159,6 +160,11 @@ class HarryPotterFanFictionComSiteAdapter(BaseSiteAdapter):
|
||||
for g in m.group(1).split(','):
|
||||
self.story.addToList('genre',g)
|
||||
|
||||
m = re.match(r".*?Characters: (.+?) Genre.*?",metastr)
|
||||
if m:
|
||||
for g in m.group(1).split(','):
|
||||
self.story.addToList('characters',g)
|
||||
|
||||
m = re.match(r".*?Warnings: (.+).*?",metastr)
|
||||
if m:
|
||||
for w in m.group(1).split(','):
|
||||
@@ -188,7 +194,7 @@ class HarryPotterFanFictionComSiteAdapter(BaseSiteAdapter):
|
||||
if None == div:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(div)
|
||||
return self.utf8FromSoup(url,div)
|
||||
|
||||
def getClass():
|
||||
return HarryPotterFanFictionComSiteAdapter
|
||||
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -174,7 +174,8 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
|
||||
# Summary: ....
|
||||
m = re.match(r".*?Summary: (.*)$",metastr)
|
||||
if m:
|
||||
self.story.setMetadata('description', m.group(1))
|
||||
self.setDescription(url, m.group(1))
|
||||
#self.story.setMetadata('description', m.group(1))
|
||||
|
||||
# completed
|
||||
m = re.match(r".*?Status: Completed.*?",metastr)
|
||||
@@ -210,7 +211,7 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
|
||||
del div['style']
|
||||
del div['align']
|
||||
anchor.name='div'
|
||||
return utf8FromSoup(anchor)
|
||||
return self.utf8FromSoup(url,anchor)
|
||||
|
||||
else:
|
||||
logging.debug('Using kludgey text find for older mediaminer story.')
|
||||
@@ -226,7 +227,7 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
|
||||
soup.findAll('table',{'class':'tbbrdr'}):
|
||||
tag.extract() # remove tag from soup.
|
||||
|
||||
return utf8FromSoup(soup)
|
||||
return self.utf8FromSoup(url,soup)
|
||||
|
||||
|
||||
def getClass():
|
||||
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class PotionsAndSnitchesNetSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -131,7 +131,8 @@ class PotionsAndSnitchesNetSiteAdapter(BaseSiteAdapter):
|
||||
while not defaultGetattr(value,'class') == 'listbox':
|
||||
svalue += str(value)
|
||||
value = value.nextSibling
|
||||
self.story.setMetadata('description',stripHTML(svalue))
|
||||
self.setDescription(url,svalue)
|
||||
#self.story.setMetadata('description',stripHTML(svalue))
|
||||
|
||||
if 'Rated' in label:
|
||||
self.story.setMetadata('rating', value)
|
||||
@@ -145,6 +146,16 @@ class PotionsAndSnitchesNetSiteAdapter(BaseSiteAdapter):
|
||||
for cat in catstext:
|
||||
self.story.addToList('category',cat.string)
|
||||
|
||||
if 'Characters' in label:
|
||||
chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters'))
|
||||
charstext = [char.string for char in chars]
|
||||
for char in charstext:
|
||||
if char == "!Snape and Harry (required)":
|
||||
self.story.addToList('characters',"Snape")
|
||||
self.story.addToList('characters',"Harry")
|
||||
else:
|
||||
self.story.addToList('characters',char.string)
|
||||
|
||||
if 'Genre' in label:
|
||||
genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class'))
|
||||
genrestext = [genre.string for genre in genres]
|
||||
@@ -166,7 +177,27 @@ class PotionsAndSnitchesNetSiteAdapter(BaseSiteAdapter):
|
||||
#value = value[0:-1]
|
||||
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), "%b %d %Y"))
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/fanfiction/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
@@ -179,7 +210,7 @@ class PotionsAndSnitchesNetSiteAdapter(BaseSiteAdapter):
|
||||
if None == div:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(div)
|
||||
return self.utf8FromSoup(url,div)
|
||||
|
||||
def getClass():
|
||||
return PotionsAndSnitchesNetSiteAdapter
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2011 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import time
|
||||
import logging
|
||||
import re
|
||||
import urllib2
|
||||
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
# This function is called by the downloader in all adapter_*.py files
|
||||
# in this dir to register the adapter class. So it needs to be
|
||||
# updated to reflect the class below it. That, plus getSiteDomain()
|
||||
# take care of 'Registering'.
|
||||
def getClass():
|
||||
return SiyeCoUkAdapter # XXX
|
||||
|
||||
# Class name has to be unique. Our convention is camel case the
|
||||
# sitename with Adapter at the end. www is skipped.
|
||||
class SiyeCoUkAdapter(BaseSiteAdapter): # XXX
|
||||
|
||||
def __init__(self, config, url):
|
||||
BaseSiteAdapter.__init__(self, config, url)
|
||||
|
||||
self.decode = ["Windows-1252",
|
||||
"utf8",]# 1252 is a superset of iso-8859-1.
|
||||
# Most sites that claim to be
|
||||
# iso-8859-1 (and some that claim to be
|
||||
# utf8) are really windows-1252.
|
||||
# self.username = "NoneGiven" # if left empty, site doesn't return any message at all.
|
||||
# self.password = ""
|
||||
# self.is_adult=False
|
||||
|
||||
# get storyId from url--url validation guarantees query is only sid=1234
|
||||
self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1])
|
||||
logging.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
|
||||
|
||||
# normalized story URL.
|
||||
self._setURL('http://' + self.getSiteDomain() + '/siye/viewstory.php?sid='+self.story.getMetadata('storyId'))
|
||||
|
||||
# Each adapter needs to have a unique site abbreviation.
|
||||
self.story.setMetadata('siteabbrev','siye') # XXX
|
||||
|
||||
# If all stories from the site fall into the same category,
|
||||
# the site itself isn't likely to label them as such, so we
|
||||
# do.
|
||||
self.story.addToList("category","Harry Potter") # XXX
|
||||
|
||||
# The date format will vary from site to site.
|
||||
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
|
||||
self.dateformat = "%Y.%m.%d" # XXX
|
||||
|
||||
@staticmethod # must be @staticmethod, don't remove it.
|
||||
def getSiteDomain():
|
||||
# The site domain. Does have www here, if it uses it.
|
||||
return 'www.siye.co.uk' # XXX
|
||||
|
||||
@classmethod
|
||||
def getAcceptDomains(cls):
|
||||
return ['www.siye.co.uk','siye.co.uk']
|
||||
|
||||
def getSiteExampleURLs(self):
|
||||
return "http://"+self.getSiteDomain()+"/siye/viewstory.php?sid=1234"
|
||||
|
||||
def getSiteURLPattern(self):
|
||||
return re.escape("http://")+r"(www\.)?siye\.co\.uk/(siye/)?"+re.escape("viewstory.php?sid=")+r"\d+$"
|
||||
|
||||
# ## Login seems to be reasonably standard across eFiction sites.
|
||||
# def needToLoginCheck(self, data):
|
||||
# if 'Registered Users Only' in data \
|
||||
# or 'There is no such account on our website' in data \
|
||||
# or "That password doesn't match the one in our database" in data:
|
||||
# return True
|
||||
# else:
|
||||
# return False
|
||||
|
||||
# def performLogin(self, url):
|
||||
# params = {}
|
||||
|
||||
# if self.password:
|
||||
# params['penname'] = self.username
|
||||
# params['password'] = self.password
|
||||
# else:
|
||||
# params['penname'] = self.getConfig("username")
|
||||
# params['password'] = self.getConfig("password")
|
||||
# params['cookiecheck'] = '1'
|
||||
# params['submit'] = 'Submit'
|
||||
|
||||
# loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login'
|
||||
# logging.debug("Will now login to URL (%s) as (%s)" % (loginUrl,
|
||||
# params['penname']))
|
||||
|
||||
# d = self._fetchUrl(loginUrl, params)
|
||||
|
||||
# if "Member Account" not in d : #Member Account
|
||||
# logging.info("Failed to login to URL %s as %s" % (loginUrl,
|
||||
# params['penname']))
|
||||
# raise exceptions.FailedToLogin(url,params['penname'])
|
||||
# return False
|
||||
# else:
|
||||
# return True
|
||||
|
||||
## Getting the chapter list and the meta data, plus 'is adult' checking.
|
||||
def extractChapterUrlsAndMetadata(self):
|
||||
|
||||
# if self.is_adult or self.getConfig("is_adult"):
|
||||
# # Weirdly, different sites use different warning numbers.
|
||||
# # If the title search below fails, there's a good chance
|
||||
# # you need a different number. print data at that point
|
||||
# # and see what the 'click here to continue' url says.
|
||||
# addurl = "&ageconsent=ok&warning=4" # XXX
|
||||
# else:
|
||||
# addurl=""
|
||||
|
||||
# index=1 makes sure we see the story chapter index. Some
|
||||
# sites skip that for one-chapter stories.
|
||||
# Except it doesn't this time. :-/
|
||||
url = self.url #+'&index=1'+addurl
|
||||
logging.debug("URL: "+url)
|
||||
|
||||
try:
|
||||
data = self._fetchUrl(url)
|
||||
except urllib2.HTTPError, e:
|
||||
if e.code == 404:
|
||||
raise exceptions.StoryDoesNotExist(self.url)
|
||||
else:
|
||||
raise e
|
||||
|
||||
# if self.needToLoginCheck(data):
|
||||
# # need to log in for this one.
|
||||
# self.performLogin(url)
|
||||
# data = self._fetchUrl(url)
|
||||
|
||||
# # The actual text that is used to announce you need to be an
|
||||
# # adult varies from site to site. Again, print data before
|
||||
# # the title search to troubleshoot.
|
||||
# if "Age Consent Required" in data: # XXX
|
||||
# raise exceptions.AdultCheckRequired(self.url)
|
||||
|
||||
# if "Access denied. This story has not been validated by the adminstrators of this site." in data:
|
||||
# raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.")
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
soup = bs.BeautifulSoup(data)
|
||||
# print data
|
||||
|
||||
# Now go hunting for all the meta data and the chapter list.
|
||||
|
||||
# Find authorid and URL from... author url.
|
||||
a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+"))
|
||||
self.story.setMetadata('authorId',a['href'].split('=')[1])
|
||||
self.story.setMetadata('authorUrl','http://'+self.host+'/siye/'+a['href'])
|
||||
self.story.setMetadata('author',a.string)
|
||||
|
||||
# need(or easier) to pull other metadata from the author's list page.
|
||||
authsoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl')))
|
||||
|
||||
## Title
|
||||
titlea = authsoup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$"))
|
||||
self.story.setMetadata('title',titlea.string)
|
||||
|
||||
# Find the chapters (from soup, not authsoup):
|
||||
for chapter in soup.findAll('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"&chapter=\d+$")):
|
||||
# just in case there's tags, like <i> in chapter titles.
|
||||
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/siye/'+chapter['href']))
|
||||
|
||||
if self.chapterUrls:
|
||||
self.story.setMetadata('numChapters',len(self.chapterUrls))
|
||||
else:
|
||||
self.chapterUrls.append((self.story.getMetadata('title'),url))
|
||||
self.story.setMetadata('numChapters',1)
|
||||
|
||||
# The stuff we can get from the chapter list/one-shot page are
|
||||
# in the first table with 95% width.
|
||||
metatable = soup.find('table',{'width':'95%'})
|
||||
|
||||
# Categories
|
||||
cat_as = metatable.findAll('a', href=re.compile(r'categories.php'))
|
||||
for cat_a in cat_as:
|
||||
self.story.addToList('category',stripHTML(cat_a))
|
||||
|
||||
moremetaparts = stripHTML(metatable).split('\n')
|
||||
for part in moremetaparts:
|
||||
part = part.strip()
|
||||
if part.startswith("Characters:"):
|
||||
part = part[part.find(':')+1:]
|
||||
for item in part.split(','):
|
||||
if item.strip() == "Harry/Ginny":
|
||||
self.story.addToList('characters',"Harry")
|
||||
self.story.addToList('characters',"Ginny")
|
||||
elif item.strip() not in ("None","All"):
|
||||
self.story.addToList('characters',item)
|
||||
|
||||
if part.startswith("Genres:"):
|
||||
part = part[part.find(':')+1:]
|
||||
for item in part.split(','):
|
||||
if item.strip() != "None":
|
||||
self.story.addToList('genre',item)
|
||||
|
||||
if part.startswith("Warnings:"):
|
||||
part = part[part.find(':')+1:]
|
||||
for item in part.split(','):
|
||||
if item.strip() != "None":
|
||||
self.story.addToList('warnings',item)
|
||||
|
||||
if part.startswith("Rating:"):
|
||||
part = part[part.find(':')+1:]
|
||||
self.story.setMetadata('rating',part)
|
||||
|
||||
if part.startswith("Summary:"):
|
||||
part = part[part.find(':')+1:]
|
||||
self.setDescription(url,part)
|
||||
#self.story.setMetadata('description',part)
|
||||
|
||||
# want to get the next tr of the table.
|
||||
#print("%s"%titlea.parent.parent.findNextSibling('tr'))
|
||||
|
||||
# eFiction sites don't help us out a lot with their meta data
|
||||
# formating, so it's a little ugly.
|
||||
moremeta = stripHTML(titlea.parent.parent.parent.find('div',{'class':'desc'}))
|
||||
for part in moremeta.replace(' - ','\n').split('\n'):
|
||||
#print("part:%s"%part)
|
||||
try:
|
||||
(name,value) = part.split(': ')
|
||||
except:
|
||||
# not going to worry about fancier processing for the bits
|
||||
# that don't match.
|
||||
continue
|
||||
name=name.strip()
|
||||
value=value.strip()
|
||||
if name == 'Published':
|
||||
self.story.setMetadata('datePublished', makeDate(value, self.dateformat))
|
||||
if name == 'Updated':
|
||||
self.story.setMetadata('dateUpdated', makeDate(value, self.dateformat))
|
||||
if name == 'Completed':
|
||||
if value == 'Yes':
|
||||
self.story.setMetadata('status', 'Completed')
|
||||
else:
|
||||
self.story.setMetadata('status', 'In-Progress')
|
||||
if name == 'Words':
|
||||
self.story.setMetadata('numWords', value)
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = titlea.findPrevious('a', href=re.compile(r"series.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
# grab the text for an individual chapter.
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
|
||||
# soup = bs.BeautifulSoup(self._fetchUrl(url))
|
||||
# BeautifulSoup objects to <p> inside <span>, which
|
||||
# technically isn't allowed.
|
||||
soup = bs.BeautifulStoneSoup(self._fetchUrl(url),
|
||||
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
|
||||
# not the most unique thing in the world, but it appears to be
|
||||
# the best we can do here.
|
||||
story = soup.find('span', {'style' : 'font-size: 100%;'})
|
||||
|
||||
if None == story:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return self.utf8FromSoup(url,story)
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class TenhawkPresentsComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -164,7 +164,8 @@ class TenhawkPresentsComSiteAdapter(BaseSiteAdapter):
|
||||
while not defaultGetattr(value,'class') == 'label':
|
||||
svalue += str(value)
|
||||
value = value.nextSibling
|
||||
self.story.setMetadata('description',stripHTML(svalue))
|
||||
self.setDescription(url,svalue)
|
||||
#self.story.setMetadata('description',stripHTML(svalue))
|
||||
|
||||
if 'Rated' in label:
|
||||
self.story.setMetadata('rating', value)
|
||||
@@ -178,6 +179,12 @@ class TenhawkPresentsComSiteAdapter(BaseSiteAdapter):
|
||||
for cat in catstext:
|
||||
self.story.addToList('category',cat.string)
|
||||
|
||||
if 'Characters' in label:
|
||||
chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters'))
|
||||
charstext = [char.string for char in chars]
|
||||
for char in charstext:
|
||||
self.story.addToList('characters',char.string)
|
||||
|
||||
if 'Genre' in label:
|
||||
genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class'))
|
||||
genrestext = [genre.string for genre in genres]
|
||||
@@ -199,6 +206,26 @@ class TenhawkPresentsComSiteAdapter(BaseSiteAdapter):
|
||||
#value = value[0:-1]
|
||||
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat))
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
|
||||
def getChapterText(self, url):
|
||||
|
||||
@@ -212,7 +239,7 @@ class TenhawkPresentsComSiteAdapter(BaseSiteAdapter):
|
||||
if None == span:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(span)
|
||||
return self.utf8FromSoup(url,span)
|
||||
|
||||
def getClass():
|
||||
return TenhawkPresentsComSiteAdapter
|
||||
|
||||
@@ -19,10 +19,10 @@ import datetime
|
||||
import time
|
||||
import logging
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from .. import exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class TestSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -54,6 +54,12 @@ class TestSiteAdapter(BaseSiteAdapter):
|
||||
if self.story.getMetadata('storyId') == '666':
|
||||
raise exceptions.StoryDoesNotExist(self.url)
|
||||
|
||||
if self.story.getMetadata('storyId').startswith('670'):
|
||||
time.sleep(1.0)
|
||||
|
||||
if self.story.getMetadata('storyId').startswith('671'):
|
||||
time.sleep(1.0)
|
||||
|
||||
if self.getConfig("username"):
|
||||
self.username = self.getConfig("username")
|
||||
|
||||
@@ -61,25 +67,43 @@ class TestSiteAdapter(BaseSiteAdapter):
|
||||
raise exceptions.FailedToLogin(self.url,self.username)
|
||||
|
||||
if self.story.getMetadata('storyId') == '664':
|
||||
self.story.setMetadata(u'title',"Test Story Title "+self.crazystring)
|
||||
self.story.setMetadata(u'title',"Test Story Title "+self.story.getMetadata('storyId')+self.crazystring)
|
||||
self.story.setMetadata('author','Test Author aa bare amp(&) quote(') amp(&)')
|
||||
else:
|
||||
self.story.setMetadata(u'title',"Test Story Title")
|
||||
self.story.setMetadata(u'title',"Test Story Title "+self.story.getMetadata('storyId'))
|
||||
self.story.setMetadata('author','Test Author aa')
|
||||
self.story.setMetadata('storyUrl',self.url)
|
||||
self.story.setMetadata('description',u'Description '+self.crazystring+u''' Done
|
||||
|
||||
Some more longer description. "I suck at summaries!" "Better than it sounds!" "My first fic"
|
||||
''')
|
||||
self.story.setMetadata('datePublished',makeDate("1972-01-31","%Y-%m-%d"))
|
||||
self.story.setMetadata('dateCreated',datetime.datetime.now())
|
||||
self.story.setMetadata('datePublished',makeDate("1975-03-15","%Y-%m-%d"))
|
||||
if self.story.getMetadata('storyId') == '669':
|
||||
self.story.setMetadata('dateUpdated',datetime.datetime.now())
|
||||
else:
|
||||
self.story.setMetadata('dateUpdated',makeDate("1975-01-31","%Y-%m-%d"))
|
||||
self.story.setMetadata('dateUpdated',makeDate("1975-04-15","%Y-%m-%d"))
|
||||
self.story.setMetadata('numWords','123456')
|
||||
self.story.setMetadata('status','In-Completed')
|
||||
|
||||
idnum = int(self.story.getMetadata('storyId'))
|
||||
if idnum % 2 == 1:
|
||||
self.story.setMetadata('status','In-Progress')
|
||||
else:
|
||||
self.story.setMetadata('status','Completed')
|
||||
|
||||
langs = {
|
||||
0:"English",
|
||||
1:"Russian",
|
||||
2:"French",
|
||||
3:"German",
|
||||
}
|
||||
if idnum < 10:
|
||||
self.story.setMetadata('language',langs[idnum%len(langs)])
|
||||
# greater than 10, no language.
|
||||
|
||||
self.setSeries('The Great Test',idnum)
|
||||
|
||||
self.story.setMetadata('rating','Tweenie')
|
||||
|
||||
self.story.setMetadata('author','Test Author aa')
|
||||
self.story.setMetadata('authorId','98765')
|
||||
self.story.setMetadata('authorUrl','http://author/url')
|
||||
|
||||
@@ -89,7 +113,8 @@ Some more longer description. "I suck at summaries!" "Better than it sounds!"
|
||||
self.story.addToList('category','Harry Potter')
|
||||
self.story.addToList('category','Furbie')
|
||||
self.story.addToList('category','Crossover')
|
||||
|
||||
self.story.addToList('category',u'Puella Magi Madoka Magica/魔法少女まどか★マギカ')
|
||||
self.story.addToList('category',u'Magical Girl Lyrical Nanoha')
|
||||
self.story.addToList('genre','Fantasy')
|
||||
self.story.addToList('genre','SF')
|
||||
self.story.addToList('genre','Noir')
|
||||
@@ -100,7 +125,7 @@ Some more longer description. "I suck at summaries!" "Better than it sounds!"
|
||||
('Chapter 3, Over Cinnabar',self.url+"&chapter=4"),
|
||||
('Chapter 4',self.url+"&chapter=5"),
|
||||
('Chapter 5',self.url+"&chapter=6"),
|
||||
# ('Chapter 6',self.url+"&chapter=6"),
|
||||
('Chapter 6',self.url+"&chapter=6"),
|
||||
# ('Chapter 7',self.url+"&chapter=6"),
|
||||
# ('Chapter 8',self.url+"&chapter=6"),
|
||||
# ('Chapter 9',self.url+"&chapter=6"),
|
||||
@@ -128,8 +153,9 @@ Some more longer description. "I suck at summaries!" "Better than it sounds!"
|
||||
if self.story.getMetadata('storyId') == '667':
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s!" % url)
|
||||
|
||||
if self.story.getMetadata('storyId') == '670':
|
||||
time.sleep(2.0)
|
||||
if self.story.getMetadata('storyId').startswith('670') or \
|
||||
self.story.getMetadata('storyId').startswith('672'):
|
||||
time.sleep(1.0)
|
||||
|
||||
if "chapter=1" in url :
|
||||
text=u'''
|
||||
@@ -143,16 +169,20 @@ Some more longer description. "I suck at summaries!" "Better than it sounds!"
|
||||
<p>http://test1.com?sid=668 - raises FailedToLogin unless username='Me'</p>
|
||||
<p>http://test1.com?sid=669 - Succeeds with Updated Date=now</p>
|
||||
<p>http://test1.com?sid=670 - Succeeds, but sleeps 2sec on each chapter</p>
|
||||
<p>http://test1.com?sid=671 - Succeeds, but sleeps 2sec metadata only</p>
|
||||
<p>http://test1.com?sid=672 - Succeeds, quick meta, sleeps 2sec chapters only</p>
|
||||
<p>And other storyId will succeed with the same output.</p>
|
||||
</div>
|
||||
'''
|
||||
else:
|
||||
text=u'''
|
||||
<div>
|
||||
<h3>Chapter</h3>
|
||||
<h3>Chapter title from site</h3>
|
||||
<p><center>Centered text</center></p>
|
||||
<p>Lorem '''+self.crazystring+''' <i>italics</i>, <b>bold</b>, <u>underline</u> consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
||||
br breaks<br><br>
|
||||
|
||||
<a href="http://code.google.com/p/fanficdownloader/wiki/FanFictionDownLoaderPluginWithReadingList" title="Tilt-a-Whirl by Jim & Sarah, on Flickr"><img src="http://i.imgur.com/bo8eD.png"></a><br/>
|
||||
br breaks<br><br>
|
||||
<hr>
|
||||
horizontal rules
|
||||
@@ -162,7 +192,7 @@ horizontal rules
|
||||
</div>
|
||||
'''
|
||||
soup = bs.BeautifulStoneSoup(text,selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
return utf8FromSoup(soup)
|
||||
return self.utf8FromSoup(url,soup)
|
||||
|
||||
def getClass():
|
||||
return TestSiteAdapter
|
||||
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class TheWritersCoffeeShopComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -123,6 +123,11 @@ class TheWritersCoffeeShopComSiteAdapter(BaseSiteAdapter):
|
||||
if "Access denied. This story has not been validated by the adminstrators of this site." in data:
|
||||
raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.")
|
||||
|
||||
# problems with some stories, but only in calibre. I suspect
|
||||
# issues with different SGML parsers in python. This is a
|
||||
# nasty hack, but it works.
|
||||
data = data[data.index("<body"):]
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
soup = bs.BeautifulSoup(data)
|
||||
|
||||
@@ -161,7 +166,7 @@ class TheWritersCoffeeShopComSiteAdapter(BaseSiteAdapter):
|
||||
while not defaultGetattr(value,'class') == 'label':
|
||||
svalue += str(value)
|
||||
value = value.nextSibling
|
||||
self.story.setMetadata('description',stripHTML(svalue))
|
||||
self.setDescription(url,svalue)
|
||||
|
||||
if 'Rated' in label:
|
||||
self.story.setMetadata('rating', value)
|
||||
@@ -175,6 +180,12 @@ class TheWritersCoffeeShopComSiteAdapter(BaseSiteAdapter):
|
||||
for cat in catstext:
|
||||
self.story.addToList('category',cat.string)
|
||||
|
||||
if 'Characters' in label:
|
||||
chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters'))
|
||||
charstext = [char.string for char in chars]
|
||||
for char in charstext:
|
||||
self.story.addToList('characters',char.string)
|
||||
|
||||
if 'Genre' in label:
|
||||
genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class'))
|
||||
genrestext = [genre.string for genre in genres]
|
||||
@@ -196,12 +207,37 @@ class TheWritersCoffeeShopComSiteAdapter(BaseSiteAdapter):
|
||||
#value = value[0:-1]
|
||||
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat))
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/library/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
|
||||
soup = bs.BeautifulStoneSoup(self._fetchUrl(url),
|
||||
data = self._fetchUrl(url)
|
||||
# problems with some stories, but only in calibre. I suspect
|
||||
# issues with different SGML parsers in python. This is a
|
||||
# nasty hack, but it works.
|
||||
data = data[data.index("<body"):]
|
||||
|
||||
soup = bs.BeautifulStoneSoup(data,
|
||||
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
|
||||
span = soup.find('div', {'id' : 'story'})
|
||||
@@ -209,7 +245,7 @@ class TheWritersCoffeeShopComSiteAdapter(BaseSiteAdapter):
|
||||
if None == span:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(span)
|
||||
return self.utf8FromSoup(url,span)
|
||||
|
||||
def getClass():
|
||||
return TheWritersCoffeeShopComSiteAdapter
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2011 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import time
|
||||
import logging
|
||||
import re
|
||||
import urllib2
|
||||
import time
|
||||
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class TwistingTheHellmouthSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
def __init__(self, config, url):
|
||||
BaseSiteAdapter.__init__(self, config, url)
|
||||
self.story.setMetadata('siteabbrev','tth')
|
||||
self.dateformat = "%d %b %y"
|
||||
self.is_adult=False
|
||||
self.username = None
|
||||
self.password = None
|
||||
# get storyId from url--url validation guarantees query correct
|
||||
m = re.match(self.getSiteURLPattern(),url)
|
||||
if m:
|
||||
self.story.setMetadata('storyId',m.group('id'))
|
||||
logging.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
|
||||
# normalized story URL.
|
||||
self._setURL("http://"+self.getSiteDomain()\
|
||||
+"/Story-"+self.story.getMetadata('storyId'))
|
||||
else:
|
||||
raise exceptions.InvalidStoryURL(url,
|
||||
self.getSiteDomain(),
|
||||
self.getSiteExampleURLs())
|
||||
|
||||
@staticmethod
|
||||
def getSiteDomain():
|
||||
return 'www.tthfanfic.org'
|
||||
|
||||
def getSiteExampleURLs(self):
|
||||
return "http://www.tthfanfic.org/Story-5583 http://www.tthfanfic.org/Story-5583/Greywizard+Marked+By+Kane.htm http://www.tthfanfic.org/T-526321777890480578489880055880/Story-26448-15/batzulger+Willow+Rosenberg+and+the+Mind+Riders.htm"
|
||||
|
||||
# http://www.tthfanfic.org/T-526321777848988007890480555880/Story-26448-15/batzulger+Willow+Rosenberg+and+the+Mind+Riders.htm
|
||||
# http://www.tthfanfic.org/Story-5583
|
||||
# http://www.tthfanfic.org/Story-5583/Greywizard+Marked+By+Kane.htm
|
||||
# http://www.tthfanfic.org/story.php?no=26093
|
||||
def getSiteURLPattern(self):
|
||||
return r"http://www.tthfanfic.org(/(T-\d+/)?Story-|/story.php\?no=)(?P<id>\d+)(-\d+)?(/.*)?$"
|
||||
|
||||
# tth won't send you future updates if you aren't 'caught up'
|
||||
# on the story. Login isn't required for F21, but logging in will
|
||||
# mark stories you've downloaded as 'read' on tth.
|
||||
def performLogin(self):
|
||||
params = {}
|
||||
|
||||
if self.password:
|
||||
params['urealname'] = self.username
|
||||
params['password'] = self.password
|
||||
else:
|
||||
params['urealname'] = self.getConfig("username")
|
||||
params['password'] = self.getConfig("password")
|
||||
params['loginsubmit'] = 'Login'
|
||||
|
||||
if not params['password']:
|
||||
return
|
||||
|
||||
loginUrl = 'http://' + self.getSiteDomain() + '/login.php'
|
||||
logging.debug("Will now login to URL (%s) as (%s)" % (loginUrl,
|
||||
params['urealname']))
|
||||
|
||||
## need to pull empty login page first to get ctkn and
|
||||
## password name, which are BUSs
|
||||
# <form method='post' action='/login.php' accept-charset="utf-8">
|
||||
# <input type='hidden' name='ctkn' value='4bdf761f5bea06bf4477072afcbd0f8d721d1a4f989c09945a9e87afb7a66de1'/>
|
||||
# <input type='text' id='urealname' name='urealname' value=''/>
|
||||
# <input type='password' id='password' name='6bb3fcd148d148629223690bf19733b8'/>
|
||||
# <input type='submit' value='Login' name='loginsubmit'/>
|
||||
soup = bs.BeautifulSoup(self._fetchUrl(loginUrl))
|
||||
params['ctkn']=soup.find('input', {'name':'ctkn'})['value']
|
||||
params[soup.find('input', {'id':'password'})['name']] = params['password']
|
||||
|
||||
d = self._fetchUrl(loginUrl, params)
|
||||
|
||||
if "Stories Published" not in d : #Member Account
|
||||
logging.info("Failed to login to URL %s as %s" % (loginUrl,
|
||||
params['penname']))
|
||||
raise exceptions.FailedToLogin(url,params['penname'])
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def extractChapterUrlsAndMetadata(self):
|
||||
# fetch the chapter. From that we will get almost all the
|
||||
# metadata and chapter list
|
||||
|
||||
url=self.url
|
||||
logging.debug("URL: "+url)
|
||||
|
||||
# tth won't send you future updates if you aren't 'caught up'
|
||||
# on the story. Login isn't required for F21, but logging in will
|
||||
# mark stories you've downloaded as 'read' on tth.
|
||||
self.performLogin()
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
try:
|
||||
data = self._fetchUrl(url)
|
||||
soup = bs.BeautifulSoup(data)
|
||||
except urllib2.HTTPError, e:
|
||||
if e.code == 404:
|
||||
raise exceptions.StoryDoesNotExist(url)
|
||||
else:
|
||||
raise e
|
||||
|
||||
descurl = url
|
||||
|
||||
if "<h2>Story Not Found</h2>" in data:
|
||||
raise exceptions.StoryDoesNotExist(url)
|
||||
|
||||
if "NOTE: This story is rated FR21 which is above your chosen filter level." in data:
|
||||
if self.is_adult or self.getConfig("is_adult"):
|
||||
form = soup.find('form', {'id':'sitemaxratingform'})
|
||||
params={'ctkn':form.find('input', {'name':'ctkn'})['value'],
|
||||
'sitemaxrating':'5'}
|
||||
logging.info("Attempting to get rating cookie for %s" % url)
|
||||
data = self._postUrl("http://"+self.getSiteDomain()+'/setmaxrating.php',params)
|
||||
# refetch story page.
|
||||
data = self._fetchUrl(url)
|
||||
soup = bs.BeautifulSoup(data)
|
||||
else:
|
||||
raise exceptions.AdultCheckRequired(self.url)
|
||||
|
||||
# http://www.tthfanfic.org/AuthorStories-3449/Greywizard.htm
|
||||
# Find authorid and URL from... author url.
|
||||
a = soup.find('a', href=re.compile(r"^/AuthorStories-\d+"))
|
||||
self.story.setMetadata('authorId',a['href'].split('/')[1].split('-')[1])
|
||||
self.story.setMetadata('authorUrl','http://'+self.host+a['href'])
|
||||
self.story.setMetadata('author',stripHTML(a))
|
||||
|
||||
try:
|
||||
# going to pull part of the meta data from author list page.
|
||||
logging.debug("**AUTHOR** URL: "+self.story.getMetadata('authorUrl'))
|
||||
authordata = self._fetchUrl(self.story.getMetadata('authorUrl'))
|
||||
descurl=self.story.getMetadata('authorUrl')
|
||||
authorsoup = bs.BeautifulSoup(authordata)
|
||||
# author can have several pages, scan until we find it.
|
||||
while( not authorsoup.find('a', href=re.compile(r"^/Story-"+self.story.getMetadata('storyId'))) ):
|
||||
nextpage = 'http://'+self.host+authorsoup.find('a', {'class':'arrowf'})['href']
|
||||
logging.debug("**AUTHOR** nextpage URL: "+nextpage)
|
||||
authordata = self._fetchUrl(nextpage)
|
||||
descurl=nextpage
|
||||
authorsoup = bs.BeautifulSoup(authordata)
|
||||
except urllib2.HTTPError, e:
|
||||
if e.code == 404:
|
||||
raise exceptions.StoryDoesNotExist(url)
|
||||
else:
|
||||
raise e
|
||||
|
||||
storydiv = authorsoup.find('div', {'id':'st'+self.story.getMetadata('storyId'), 'class':re.compile(r"storylistitem")})
|
||||
self.setDescription(descurl,storydiv.find('div',{'class':'storydesc'}))
|
||||
#self.story.setMetadata('description',stripHTML(storydiv.find('div',{'class':'storydesc'})))
|
||||
self.story.setMetadata('title',stripHTML(storydiv.find('a',{'class':'storylink'})))
|
||||
|
||||
verticaltable = soup.find('table', {'class':'verticaltable'})
|
||||
|
||||
BtVS = True
|
||||
for cat in verticaltable.findAll('a', href=re.compile(r"^/Category-")):
|
||||
if cat.string not in ['General', 'Non-BtVS/AtS Stories', 'BtVS/AtS Non-Crossover', 'Non-BtVS Crossovers']:
|
||||
self.story.addToList('category',cat.string)
|
||||
else:
|
||||
if 'Non-BtVS' in cat.string:
|
||||
BtVS = False
|
||||
if BtVS:
|
||||
self.story.addToList('category','Buffy: The Vampire Slayer')
|
||||
|
||||
verticaltabletds = verticaltable.findAll('td')
|
||||
self.story.setMetadata('rating', verticaltabletds[2].string)
|
||||
self.story.setMetadata('numWords', verticaltabletds[4].string)
|
||||
|
||||
# Complete--if completed.
|
||||
if 'Yes' in verticaltabletds[10].string:
|
||||
self.story.setMetadata('status', 'Completed')
|
||||
else:
|
||||
self.story.setMetadata('status', 'In-Progress')
|
||||
|
||||
self.story.setMetadata('datePublished',makeDate(stripHTML(verticaltabletds[8].string), self.dateformat))
|
||||
self.story.setMetadata('dateUpdated',makeDate(stripHTML(verticaltabletds[9].string), self.dateformat))
|
||||
|
||||
for icon in storydiv.find('span',{'class':'storyicons'}).findAll('img'):
|
||||
if( icon['title'] not in ['Non-Crossover'] ) :
|
||||
self.story.addToList('genre',icon['title'])
|
||||
|
||||
# Find the chapter selector
|
||||
select = soup.find('select', { 'name' : 'chapnav' } )
|
||||
|
||||
if select is None:
|
||||
# no selector found, so it's a one-chapter story.
|
||||
self.chapterUrls.append((self.story.getMetadata('title'),url))
|
||||
else:
|
||||
allOptions = select.findAll('option')
|
||||
for o in allOptions:
|
||||
url = "http://"+self.host+o['value']
|
||||
# just in case there's tags, like <i> in chapter titles.
|
||||
self.chapterUrls.append((stripHTML(o),url))
|
||||
|
||||
self.story.setMetadata('numChapters',len(self.chapterUrls))
|
||||
|
||||
pseries = soup.find('p', {'style':'margin-top:0px'})
|
||||
m = re.match('This story is No\. (?P<num>\d+) in the series "(?P<series>.+)"\.',
|
||||
pseries.text)
|
||||
if m:
|
||||
self.setSeries(m.group('series'),m.group('num'))
|
||||
|
||||
return
|
||||
|
||||
|
||||
def getChapterText(self, url):
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
soup = bs.BeautifulSoup(self._fetchUrl(url))
|
||||
|
||||
div = soup.find('div', {'id' : 'storyinnerbody'})
|
||||
|
||||
if None == div:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
# strip out included chapter title, if present, to avoid doubling up.
|
||||
try:
|
||||
div.find('h3').extract()
|
||||
except:
|
||||
pass
|
||||
return self.utf8FromSoup(url,div)
|
||||
|
||||
def getClass():
|
||||
return TwistingTheHellmouthSiteAdapter
|
||||
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class TwilightedNetSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -118,6 +118,12 @@ class TwilightedNetSiteAdapter(BaseSiteAdapter):
|
||||
if "Access denied. This story has not been validated by the adminstrators of this site." in data:
|
||||
raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.")
|
||||
|
||||
# problems with some stories, but only in calibre. I suspect
|
||||
# issues with different SGML parsers in python. This is a
|
||||
# nasty hack, but it works.
|
||||
# twilighted isn't writing <body> ??? wtf?
|
||||
data = "<html><body>"+data[data.index("</head>"):]
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
soup = bs.BeautifulSoup(data)
|
||||
|
||||
@@ -156,7 +162,7 @@ class TwilightedNetSiteAdapter(BaseSiteAdapter):
|
||||
while not defaultGetattr(value,'class') == 'label':
|
||||
svalue += str(value)
|
||||
value = value.nextSibling
|
||||
self.story.setMetadata('description',stripHTML(svalue))
|
||||
self.setDescription(url,svalue)
|
||||
|
||||
if 'Rated' in label:
|
||||
self.story.setMetadata('rating', value)
|
||||
@@ -170,6 +176,12 @@ class TwilightedNetSiteAdapter(BaseSiteAdapter):
|
||||
for cat in catstext:
|
||||
self.story.addToList('category',cat.string)
|
||||
|
||||
if 'Characters' in label:
|
||||
chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters'))
|
||||
charstext = [char.string for char in chars]
|
||||
for char in charstext:
|
||||
self.story.addToList('characters',char.string)
|
||||
|
||||
## twilighted.net doesn't use genre.
|
||||
# if 'Genre' in label:
|
||||
# genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class'))
|
||||
@@ -192,12 +204,38 @@ class TwilightedNetSiteAdapter(BaseSiteAdapter):
|
||||
#value = value[0:-1]
|
||||
self.story.setMetadata('dateUpdated', makeDate(value.strip(), "%B %d, %Y"))
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
|
||||
soup = bs.BeautifulStoneSoup(self._fetchUrl(url),
|
||||
data = self._fetchUrl(url)
|
||||
# problems with some stories, but only in calibre. I suspect
|
||||
# issues with different SGML parsers in python. This is a
|
||||
# nasty hack, but it works.
|
||||
# twilighted isn't writing <body> ??? wtf?
|
||||
data = "<html><body>"+data[data.index("</head>"):]
|
||||
|
||||
soup = bs.BeautifulStoneSoup(data,
|
||||
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
|
||||
span = soup.find('div', {'id' : 'story'})
|
||||
@@ -205,7 +243,7 @@ class TwilightedNetSiteAdapter(BaseSiteAdapter):
|
||||
if None == span:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(span)
|
||||
return self.utf8FromSoup(url,span)
|
||||
|
||||
def getClass():
|
||||
return TwilightedNetSiteAdapter
|
||||
|
||||
@@ -21,11 +21,11 @@ import re
|
||||
import urllib
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class TwiwriteNetSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -84,7 +84,7 @@ class TwiwriteNetSiteAdapter(BaseSiteAdapter):
|
||||
params['submit'] = 'Submit'
|
||||
|
||||
loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login'
|
||||
logging.debug("Will now login to URL (%s) as (%s)" % (loginUrl,
|
||||
logging.info("Will now login to URL (%s) as (%s)" % (loginUrl,
|
||||
params['penname']))
|
||||
|
||||
d = self._fetchUrl(loginUrl, params)
|
||||
@@ -117,7 +117,12 @@ class TwiwriteNetSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
if "Access denied. This story has not been validated by the adminstrators of this site." in data:
|
||||
raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.")
|
||||
|
||||
|
||||
# problems with some stories, but only in calibre. I suspect
|
||||
# issues with different SGML parsers in python. This is a
|
||||
# nasty hack, but it works.
|
||||
data = data[data.index("<body"):]
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
soup = bs.BeautifulSoup(data)
|
||||
|
||||
@@ -164,7 +169,8 @@ class TwiwriteNetSiteAdapter(BaseSiteAdapter):
|
||||
while not defaultGetattr(value,'class') == 'label':
|
||||
svalue += str(value)
|
||||
value = value.nextSibling
|
||||
self.story.setMetadata('description',stripHTML(svalue))
|
||||
self.setDescription(url,svalue)
|
||||
#self.story.setMetadata('description',stripHTML(svalue))
|
||||
|
||||
if 'Rated' in label:
|
||||
self.story.setMetadata('rating', value)
|
||||
@@ -178,6 +184,12 @@ class TwiwriteNetSiteAdapter(BaseSiteAdapter):
|
||||
for cat in catstext:
|
||||
self.story.addToList('category',cat.string)
|
||||
|
||||
if 'Characters' in label:
|
||||
chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters'))
|
||||
charstext = [char.string for char in chars]
|
||||
for char in charstext:
|
||||
self.story.addToList('characters',char.string)
|
||||
|
||||
if 'Genre' in label:
|
||||
genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=3'))
|
||||
genrestext = [genre.string for genre in genres]
|
||||
@@ -206,12 +218,37 @@ class TwiwriteNetSiteAdapter(BaseSiteAdapter):
|
||||
value = value[0:-1]
|
||||
self.story.setMetadata('dateUpdated', makeDate(value.strip(), "%B %d, %Y"))
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
|
||||
soup = bs.BeautifulStoneSoup(self._fetchUrl(url),
|
||||
data = self._fetchUrl(url)
|
||||
# problems with some stories, but only in calibre. I suspect
|
||||
# issues with different SGML parsers in python. This is a
|
||||
# nasty hack, but it works.
|
||||
data = data[data.index("<body"):]
|
||||
|
||||
soup = bs.BeautifulStoneSoup(data,
|
||||
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
|
||||
|
||||
span = soup.find('div', {'id' : 'story'})
|
||||
@@ -219,7 +256,7 @@ class TwiwriteNetSiteAdapter(BaseSiteAdapter):
|
||||
if None == span:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(span)
|
||||
return self.utf8FromSoup(url,span)
|
||||
|
||||
def getClass():
|
||||
return TwiwriteNetSiteAdapter
|
||||
|
||||
@@ -20,10 +20,10 @@ import logging
|
||||
import re
|
||||
import urllib2
|
||||
|
||||
import fanficdownloader.BeautifulSoup as bs
|
||||
import fanficdownloader.exceptions as exceptions
|
||||
from .. import BeautifulSoup as bs
|
||||
from .. import exceptions as exceptions
|
||||
|
||||
from base_adapter import BaseSiteAdapter, utf8FromSoup, makeDate
|
||||
from base_adapter import BaseSiteAdapter, makeDate
|
||||
|
||||
class WhoficComSiteAdapter(BaseSiteAdapter):
|
||||
|
||||
@@ -120,9 +120,10 @@ class WhoficComSiteAdapter(BaseSiteAdapter):
|
||||
# link instead to find the appropriate metadata.
|
||||
a = soup.find('a', href=re.compile(r'reviews.php\?sid='+self.story.getMetadata('storyId')))
|
||||
metadata = a.findParent('td')
|
||||
metadatachunks = utf8FromSoup(metadata).split('<br />')
|
||||
metadatachunks = self.utf8FromSoup(None,metadata).split('<br />')
|
||||
# process metadata for this story.
|
||||
self.story.setMetadata('description', metadatachunks[1])
|
||||
self.setDescription(url,metadatachunks[1])
|
||||
#self.story.setMetadata('description', metadatachunks[1])
|
||||
|
||||
# First line of the stuff with ' - ' separators
|
||||
moremeta = metadatachunks[2]
|
||||
@@ -158,6 +159,15 @@ class WhoficComSiteAdapter(BaseSiteAdapter):
|
||||
for g in genre.split(r', '):
|
||||
self.story.addToList('genre',g)
|
||||
|
||||
# line 3 is characters.
|
||||
chars = metadatachunks[3]
|
||||
charsearch="<i>Characters:</i>"
|
||||
if charsearch in chars:
|
||||
chars = chars[metadatachunks[3].index(charsearch)+len(charsearch):]
|
||||
for c in chars.split(','):
|
||||
if c.strip() != u'None':
|
||||
self.story.addToList('characters',c)
|
||||
|
||||
# the next line is stuff with ' - ' separators *and* names--with tags.
|
||||
moremeta = metadatachunks[5]
|
||||
moremeta = re.sub(r'<[^>]+>','',moremeta) # strip tags.
|
||||
@@ -180,6 +190,26 @@ class WhoficComSiteAdapter(BaseSiteAdapter):
|
||||
if name == 'Word Count':
|
||||
self.story.setMetadata('numWords', value)
|
||||
|
||||
try:
|
||||
# Find Series name from series URL.
|
||||
a = metadata.find('a', href=re.compile(r"series.php\?seriesid=\d+"))
|
||||
series_name = a.string
|
||||
series_url = 'http://'+self.host+'/'+a['href']
|
||||
|
||||
# use BeautifulSoup HTML parser to make everything easier to find.
|
||||
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
|
||||
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
|
||||
i=1
|
||||
for a in storyas:
|
||||
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
|
||||
self.setSeries(series_name, i)
|
||||
break
|
||||
i+=1
|
||||
|
||||
except:
|
||||
# I find it hard to care if the series parsing fails
|
||||
pass
|
||||
|
||||
def getChapterText(self, url):
|
||||
|
||||
logging.debug('Getting chapter text from: %s' % url)
|
||||
@@ -195,7 +225,7 @@ class WhoficComSiteAdapter(BaseSiteAdapter):
|
||||
if None == span:
|
||||
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
|
||||
|
||||
return utf8FromSoup(span)
|
||||
return self.utf8FromSoup(url,span)
|
||||
|
||||
def getClass():
|
||||
return WhoficComSiteAdapter
|
||||
|
||||
@@ -22,6 +22,10 @@ import logging
|
||||
import urllib
|
||||
import urllib2 as u2
|
||||
import urlparse as up
|
||||
from functools import partial
|
||||
|
||||
from .. import BeautifulSoup as bs
|
||||
from ..htmlcleanup import stripHTML
|
||||
|
||||
try:
|
||||
from google.appengine.api import apiproxy_stub_map
|
||||
@@ -39,13 +43,14 @@ except:
|
||||
pass
|
||||
#logging.info("Hook to make default deadline 10.0 NOT installed--not using appengine")
|
||||
|
||||
from fanficdownloader.story import Story
|
||||
from fanficdownloader.configurable import Configurable
|
||||
from fanficdownloader.htmlcleanup import removeEntities, removeAllEntities, stripHTML
|
||||
from fanficdownloader.exceptions import InvalidStoryURL
|
||||
from ..story import Story
|
||||
from ..gziphttp import GZipProcessor
|
||||
from ..configurable import Configurable
|
||||
from ..htmlcleanup import removeEntities, removeAllEntities, stripHTML
|
||||
from ..exceptions import InvalidStoryURL
|
||||
|
||||
try:
|
||||
import fanficdownloader.chardet as chardet
|
||||
from .. import chardet as chardet
|
||||
except ImportError:
|
||||
chardet = None
|
||||
|
||||
@@ -63,11 +68,17 @@ class BaseSiteAdapter(Configurable):
|
||||
return re.match(self.getSiteURLPattern(), self.url)
|
||||
|
||||
def __init__(self, config, url):
|
||||
self.config = config
|
||||
Configurable.__init__(self, config)
|
||||
self.addConfigSection(self.getSiteDomain())
|
||||
self.addConfigSection("overrides")
|
||||
self.setSectionOrder(self.getSiteDomain())
|
||||
# self.addConfigSection(self.getSiteDomain())
|
||||
# self.addConfigSection("overrides")
|
||||
|
||||
self.opener = u2.build_opener(u2.HTTPCookieProcessor())
|
||||
self.username = "NoneGiven" # if left empty, site doesn't return any message at all.
|
||||
self.password = ""
|
||||
self.is_adult=False
|
||||
|
||||
self.opener = u2.build_opener(u2.HTTPCookieProcessor(),GZipProcessor())
|
||||
self.storyDone = False
|
||||
self.metadataDone = False
|
||||
self.story = Story()
|
||||
@@ -76,6 +87,8 @@ class BaseSiteAdapter(Configurable):
|
||||
self.chapterUrls = [] # tuples of (chapter title,chapter url)
|
||||
self.chapterFirst = None
|
||||
self.chapterLast = None
|
||||
self.oldchapters = None
|
||||
self.oldimgs = None
|
||||
## order of preference for decoding.
|
||||
self.decode = ["utf8",
|
||||
"Windows-1252"] # 1252 is a superset of
|
||||
@@ -144,6 +157,12 @@ class BaseSiteAdapter(Configurable):
|
||||
headers=headers)
|
||||
return self._decode(self.opener.open(req).read())
|
||||
|
||||
def _fetchUrlRaw(self, url, parameters=None):
|
||||
if parameters != None:
|
||||
return self.opener.open(url,urllib.urlencode(parameters)).read()
|
||||
else:
|
||||
return self.opener.open(url).read()
|
||||
|
||||
# parameters is a dict()
|
||||
def _fetchUrl(self, url, parameters=None):
|
||||
if self.getConfig('slow_down_sleep_time'):
|
||||
@@ -153,10 +172,7 @@ class BaseSiteAdapter(Configurable):
|
||||
for sleeptime in [0, 0.5, 4, 9]:
|
||||
time.sleep(sleeptime)
|
||||
try:
|
||||
if parameters:
|
||||
return self._decode(self.opener.open(url,urllib.urlencode(parameters)).read())
|
||||
else:
|
||||
return self._decode(self.opener.open(url).read())
|
||||
return self._decode(self._fetchUrlRaw(url,parameters))
|
||||
except Exception, e:
|
||||
excpt=e
|
||||
logging.warn("Caught an exception reading URL: %s Exception %s."%(unicode(url),unicode(e)))
|
||||
@@ -176,15 +192,32 @@ class BaseSiteAdapter(Configurable):
|
||||
def getStory(self):
|
||||
if not self.storyDone:
|
||||
self.getStoryMetadataOnly()
|
||||
|
||||
for index, (title,url) in enumerate(self.chapterUrls):
|
||||
if (self.chapterFirst!=None and index < self.chapterFirst) or \
|
||||
(self.chapterLast!=None and index > self.chapterLast):
|
||||
self.story.addChapter(removeEntities(title),
|
||||
None)
|
||||
else:
|
||||
if self.oldchapters and index < len(self.oldchapters):
|
||||
data = self.utf8FromSoup(None,
|
||||
self.oldchapters[index],
|
||||
partial(cachedfetch,self._fetchUrlRaw,self.oldimgs))
|
||||
else:
|
||||
data = self.getChapterText(url)
|
||||
self.story.addChapter(removeEntities(title),
|
||||
removeEntities(self.getChapterText(url)))
|
||||
removeEntities(data))
|
||||
self.storyDone = True
|
||||
|
||||
# include image, but no cover from story, add default_cover_image cover.
|
||||
if self.getConfig('include_images') and \
|
||||
not self.story.cover and \
|
||||
self.getConfig('default_cover_image'):
|
||||
self.story.addImgUrl(self,
|
||||
None,
|
||||
self.getConfig('default_cover_image'),
|
||||
self._fetchUrlRaw,
|
||||
cover=True)
|
||||
return self.story
|
||||
|
||||
def getStoryMetadataOnly(self):
|
||||
@@ -223,28 +256,92 @@ class BaseSiteAdapter(Configurable):
|
||||
def getChapterText(self, url):
|
||||
"Needs to be overriden in each adapter class."
|
||||
pass
|
||||
|
||||
|
||||
# Just for series, in case we choose to change how it's stored or represented later.
|
||||
def setSeries(self,name,num):
|
||||
if self.getConfig('collect_series'):
|
||||
self.story.setMetadata('series','%s [%s]'%(name, num))
|
||||
|
||||
def setDescription(self,url,svalue):
|
||||
#print("\n\nsvalue:\n%s\n"%svalue)
|
||||
if self.getConfig('keep_summary_html'):
|
||||
if isinstance(svalue,str) or isinstance(svalue,unicode):
|
||||
svalue = bs.BeautifulSoup(svalue)
|
||||
self.story.setMetadata('description',self.utf8FromSoup(url,svalue))
|
||||
else:
|
||||
self.story.setMetadata('description',stripHTML(svalue))
|
||||
#print("\n\ndescription:\n"+self.story.getMetadata('description')+"\n\n")
|
||||
|
||||
# this gives us a unicode object, not just a string containing bytes.
|
||||
# (I gave soup a unicode string, you'd think it could give it back...)
|
||||
def utf8FromSoup(self,url,soup,fetch=None):
|
||||
if not fetch:
|
||||
fetch=self._fetchUrlRaw
|
||||
|
||||
acceptable_attributes = ['href','name']
|
||||
#print("include_images:"+self.getConfig('include_images'))
|
||||
if self.getConfig('include_images'):
|
||||
acceptable_attributes.extend(('src','alt','longdesc'))
|
||||
for img in soup.findAll('img'):
|
||||
img['longdesc']=img['src']
|
||||
img['src']=self.story.addImgUrl(self,url,img['src'],fetch)
|
||||
|
||||
for attr in soup._getAttrMap().keys():
|
||||
if attr not in acceptable_attributes:
|
||||
del soup[attr] ## strip all tag attributes except href and name
|
||||
|
||||
for t in soup.findAll(recursive=True):
|
||||
for attr in t._getAttrMap().keys():
|
||||
if attr not in acceptable_attributes:
|
||||
del t[attr] ## strip all tag attributes except href and name
|
||||
|
||||
# these are not acceptable strict XHTML. But we do already have
|
||||
# CSS classes of the same names defined in constants.py
|
||||
if t.name in ('u'):
|
||||
t['class']=t.name
|
||||
t.name='span'
|
||||
if t.name in ('center'):
|
||||
t['class']=t.name
|
||||
t.name='div'
|
||||
# removes paired, but empty tags.
|
||||
if t.string != None and len(t.string.strip()) == 0 :
|
||||
t.extract()
|
||||
|
||||
# Don't want body tags in chapter html--writers add them.
|
||||
# This is primarily for epub updates.
|
||||
return re.sub(r"</?body>\r?\n?","",soup.__str__('utf8').decode('utf-8'))
|
||||
|
||||
fullmon = {"January":"01", "February":"02", "March":"03", "April":"04", "May":"05",
|
||||
"June":"06","July":"07", "August":"08", "September":"09", "October":"10",
|
||||
"November":"11", "December":"12" }
|
||||
|
||||
def cachedfetch(realfetch,cache,url):
|
||||
if url in cache:
|
||||
print("cache hit")
|
||||
return cache[url]
|
||||
else:
|
||||
return realfetch(url)
|
||||
|
||||
|
||||
def makeDate(string,format):
|
||||
# Surprise! Abstracting this turned out to be more useful than
|
||||
# just saving bytes.
|
||||
|
||||
# fudge english month names for people who's locale is set to
|
||||
# non-english. All our current sites date in english, even if
|
||||
# there's non-english content. -- ficbook.net now makes that a
|
||||
# lie. It has to do something even more complicated to get
|
||||
# Russian month names correct everywhere.
|
||||
do_abbrev = "%b" in format
|
||||
|
||||
if "%B" in format or do_abbrev:
|
||||
format = format.replace("%B","%m").replace("%b","%m")
|
||||
for (name,num) in fullmon.items():
|
||||
if do_abbrev:
|
||||
name = name[:3] # first three for abbrev
|
||||
if name in string:
|
||||
string = string.replace(name,num)
|
||||
break
|
||||
|
||||
return datetime.datetime.strptime(string,format)
|
||||
|
||||
acceptable_attributes = ['href','name']
|
||||
|
||||
# this gives us a unicode object, not just a string containing bytes.
|
||||
# (I gave soup a unicode string, you'd think it could give it back...)
|
||||
def utf8FromSoup(soup):
|
||||
for t in soup.findAll(recursive=True):
|
||||
for attr in t._getAttrMap().keys():
|
||||
if attr not in acceptable_attributes:
|
||||
del t[attr] ## strip all tag attributes except href and name
|
||||
# these are not acceptable strict XHTML. But we do already have
|
||||
# CSS classes of the same names defined in constants.py
|
||||
if t.name in ('u'):
|
||||
t['class']=t.name
|
||||
t.name='span'
|
||||
if t.name in ('center'):
|
||||
t['class']=t.name
|
||||
t.name='div'
|
||||
# removes paired, but empty tags.
|
||||
if t.string != None and len(t.string.strip()) == 0 :
|
||||
t.extract()
|
||||
return soup.__str__('utf8').decode('utf-8')
|
||||
|
||||
@@ -21,16 +21,21 @@ import ConfigParser
|
||||
# inherit from Configurable. The config file(s) uses ini format:
|
||||
# [sections] with key:value settings.
|
||||
#
|
||||
# There's a [defaults] section which is overriden by the writer's
|
||||
# section [epub], which is overriden by the adapter's section for each
|
||||
# site.
|
||||
# writer does [defaults], [www.whofic.com], [epub], [www.whofic.com:epub], [overrides]
|
||||
#
|
||||
# Until a write is created, the adapter only has [defaults], [www.whofic.com], [overrides]
|
||||
#
|
||||
# [defaults]
|
||||
# titlepage_entries: category,genre, status
|
||||
# [epub]
|
||||
# titlepage_entries: category,genre, status,datePublished,dateUpdated,dateCreated
|
||||
# [www.whofic.com]
|
||||
# titlepage_entries: category,genre, status,dateUpdated,rating
|
||||
# [epub]
|
||||
# titlepage_entries: category,genre, status,datePublished,dateUpdated,dateCreated
|
||||
# [www.whofic.com:epub]
|
||||
# titlepage_entries: category,genre, status,datePublished
|
||||
# [overrides]
|
||||
# titlepage_entries: category
|
||||
|
||||
|
||||
class Configurable(object):
|
||||
|
||||
@@ -38,6 +43,14 @@ class Configurable(object):
|
||||
self.config = config
|
||||
self.sectionslist = ['defaults']
|
||||
|
||||
def setSectionOrder(self,site,fileform=None):
|
||||
self.sectionslist = ['defaults']
|
||||
self.addConfigSection(site)
|
||||
if fileform:
|
||||
self.addConfigSection(fileform)
|
||||
self.addConfigSection(site+":"+fileform)
|
||||
self.addConfigSection("overrides")
|
||||
|
||||
def addConfigSection(self,section):
|
||||
self.sectionslist.insert(0,section)
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2012, Jim Miller'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import re, os, traceback
|
||||
from zipfile import ZipFile
|
||||
from xml.dom.minidom import parseString
|
||||
|
||||
from . import BeautifulSoup as bs
|
||||
|
||||
def get_dcsource(inputio):
|
||||
return get_update_data(inputio,getfilecount=False,getsoups=False)[0]
|
||||
|
||||
def get_dcsource_chaptercount(inputio):
|
||||
return get_update_data(inputio,getfilecount=True,getsoups=False)[:2] # (source,filecount)
|
||||
|
||||
def get_update_data(inputio,
|
||||
getfilecount=True,
|
||||
getsoups=True):
|
||||
epub = ZipFile(inputio, 'r')
|
||||
|
||||
## Find the .opf file.
|
||||
container = epub.read("META-INF/container.xml")
|
||||
containerdom = parseString(container)
|
||||
rootfilenodelist = containerdom.getElementsByTagName("rootfile")
|
||||
rootfilename = rootfilenodelist[0].getAttribute("full-path")
|
||||
|
||||
contentdom = parseString(epub.read(rootfilename))
|
||||
firstmetadom = contentdom.getElementsByTagName("metadata")[0]
|
||||
try:
|
||||
source=firstmetadom.getElementsByTagName("dc:source")[0].firstChild.data.encode("utf-8")
|
||||
except:
|
||||
source=None
|
||||
|
||||
## Save the path to the .opf file--hrefs inside it are relative to it.
|
||||
relpath = get_path_part(rootfilename)
|
||||
|
||||
filecount = 0
|
||||
soups = [] # list of xhmtl blocks
|
||||
images = {} # dict() longdesc->data
|
||||
if getfilecount:
|
||||
# spin through the manifest--only place there are item tags.
|
||||
for item in contentdom.getElementsByTagName("item"):
|
||||
# First, count the 'chapter' files. FFDL uses file0000.xhtml,
|
||||
# but can also update epubs downloaded from Twisting the
|
||||
# Hellmouth, which uses chapter0.html.
|
||||
if( item.getAttribute("media-type") == "application/xhtml+xml" ):
|
||||
href=relpath+item.getAttribute("href")
|
||||
print("---- item href:%s path part: %s"%(href,get_path_part(href)))
|
||||
if re.match(r'.*/(file|chapter)\d+\.x?html',href):
|
||||
if getsoups:
|
||||
soup = bs.BeautifulSoup(epub.read(href).decode("utf-8"))
|
||||
for img in soup.findAll('img'):
|
||||
try:
|
||||
newsrc=get_path_part(href)+img['src']
|
||||
# remove all .. and the path part above it, if present.
|
||||
# Most for epubs edited by Sigil.
|
||||
newsrc = re.sub(r"([^/]+/\.\./)","",newsrc)
|
||||
longdesc=img['longdesc']
|
||||
data = epub.read(newsrc)
|
||||
images[longdesc] = data
|
||||
img['src'] = img['longdesc']
|
||||
except Exception as e:
|
||||
print("Image %s not found!\n(originally:%s)"%(newsrc,longdesc))
|
||||
print("Exception: %s"%(unicode(e)))
|
||||
traceback.print_exc()
|
||||
soup = soup.find('body')
|
||||
# ffdl epubs have chapter title h3
|
||||
h3 = soup.find('h3')
|
||||
if h3:
|
||||
h3.extract()
|
||||
# TtH epubs have chapter title h2
|
||||
h2 = soup.find('h2')
|
||||
if h2:
|
||||
h2.extract()
|
||||
|
||||
soups.append(soup)
|
||||
|
||||
filecount+=1
|
||||
|
||||
for k in images.keys():
|
||||
print("\tlongdesc:%s\n\tData len:%s\n"%(k,len(images[k])))
|
||||
return (source,filecount,soups,images)
|
||||
|
||||
def get_path_part(n):
|
||||
relpath = os.path.dirname(n)
|
||||
if( len(relpath) > 0 ):
|
||||
relpath=relpath+"/"
|
||||
return relpath
|
||||
@@ -0,0 +1,38 @@
|
||||
## Borrowed from http://techknack.net/python-urllib2-handlers/
|
||||
|
||||
import urllib2
|
||||
from gzip import GzipFile
|
||||
from StringIO import StringIO
|
||||
|
||||
class GZipProcessor(urllib2.BaseHandler):
|
||||
"""A handler to add gzip capabilities to urllib2 requests
|
||||
"""
|
||||
def http_request(self, req):
|
||||
req.add_header("Accept-Encoding", "gzip")
|
||||
return req
|
||||
https_request = http_request
|
||||
|
||||
def http_response(self, req, resp):
|
||||
#print("Content-Encoding:%s"%resp.headers.get("Content-Encoding"))
|
||||
if resp.headers.get("Content-Encoding") == "gzip":
|
||||
gz = GzipFile(
|
||||
fileobj=StringIO(resp.read()),
|
||||
mode="r"
|
||||
)
|
||||
# resp.read = gz.read
|
||||
# resp.readlines = gz.readlines
|
||||
# resp.readline = gz.readline
|
||||
# resp.next = gz.next
|
||||
old_resp = resp
|
||||
resp = urllib2.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code)
|
||||
resp.msg = old_resp.msg
|
||||
return resp
|
||||
https_response = http_response
|
||||
|
||||
# brave new world - 1:30 w/o, 1:10 with? 40 chapters, so 20s from sleeps.
|
||||
# with gzip, no sleep: 47.469
|
||||
# w/o gzip, no sleep: 47.736
|
||||
|
||||
# I Am What I Am 67 chapters
|
||||
# w/o gzip: 57.168
|
||||
# w/ gzip: 40.692
|
||||
@@ -27,7 +27,7 @@ def _unirepl(match):
|
||||
return unichr(value)
|
||||
|
||||
def _replaceNumberEntities(data):
|
||||
p = re.compile(r'&#(x?)(\d+);')
|
||||
p = re.compile(r'&#(x?)([0-9a-fA-F]+);')
|
||||
return p.sub(_unirepl, data)
|
||||
|
||||
def _replaceNotEntities(data):
|
||||
|
||||
+293
-8
@@ -15,9 +15,171 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import os
|
||||
import os, re
|
||||
import urlparse
|
||||
from math import floor
|
||||
|
||||
from htmlcleanup import conditionalRemoveEntities
|
||||
from htmlcleanup import conditionalRemoveEntities, removeAllEntities
|
||||
|
||||
# Create convert_image method depending on which graphics lib we can
|
||||
# load. Preferred: calibre, PIL, none
|
||||
try:
|
||||
from calibre.utils.magick import Image
|
||||
|
||||
def convert_image(url,data,sizes,grayscale):
|
||||
export = False
|
||||
img = Image()
|
||||
img.load(data)
|
||||
|
||||
owidth, oheight = img.size
|
||||
nwidth, nheight = sizes
|
||||
scaled, nwidth, nheight = fit_image(owidth, oheight, nwidth, nheight)
|
||||
if scaled:
|
||||
img.size = (nwidth, nheight)
|
||||
export = True
|
||||
|
||||
if grayscale and img.type != "GrayscaleType":
|
||||
img.type = "GrayscaleType"
|
||||
export = True
|
||||
|
||||
if normalize_format_name(img.format) != "jpg":
|
||||
export = True
|
||||
|
||||
if export:
|
||||
return (img.export('JPG'),'jpg','image/jpeg')
|
||||
else:
|
||||
print("image used unchanged")
|
||||
return (data,'jpg','image/jpeg')
|
||||
|
||||
except:
|
||||
|
||||
# No calibre routines, try for PIL for CLI.
|
||||
try:
|
||||
import Image
|
||||
from StringIO import StringIO
|
||||
def convert_image(url,data,sizes,grayscale):
|
||||
|
||||
export = False
|
||||
img = Image.open(StringIO(data))
|
||||
|
||||
owidth, oheight = img.size
|
||||
nwidth, nheight = sizes
|
||||
scaled, nwidth, nheight = fit_image(owidth, oheight, nwidth, nheight)
|
||||
if scaled:
|
||||
img = img.resize((nwidth, nheight),Image.ANTIALIAS)
|
||||
export = True
|
||||
|
||||
if grayscale and img.mode != "L":
|
||||
img = img.convert("L")
|
||||
export = True
|
||||
|
||||
if normalize_format_name(img.format) != "jpg":
|
||||
export = True
|
||||
|
||||
if export:
|
||||
outsio = StringIO()
|
||||
img.save(outsio,'JPEG')
|
||||
return (outsio.getvalue(),'jpg','image/jpeg')
|
||||
else:
|
||||
print("image used unchanged")
|
||||
return (data,'jpg','image/jpeg')
|
||||
|
||||
except:
|
||||
|
||||
# No calibre or PIL, simple pass through with mimetype.
|
||||
imagetypes = {
|
||||
'jpg':'image/jpeg',
|
||||
'jpeg':'image/jpeg',
|
||||
'png':'image/png',
|
||||
'gif':'image/gif',
|
||||
'svg':'image/svg+xml',
|
||||
}
|
||||
|
||||
def convert_image(url,data,sizes,grayscale):
|
||||
ext=url[url.rfind('.')+1:].lower()
|
||||
return (data,ext,imagetypes[ext])
|
||||
|
||||
def normalize_format_name(fmt):
|
||||
if fmt:
|
||||
fmt = fmt.lower()
|
||||
if fmt == 'jpeg':
|
||||
fmt = 'jpg'
|
||||
return fmt
|
||||
|
||||
def fit_image(width, height, pwidth, pheight):
|
||||
'''
|
||||
Fit image in box of width pwidth and height pheight.
|
||||
@param width: Width of image
|
||||
@param height: Height of image
|
||||
@param pwidth: Width of box
|
||||
@param pheight: Height of box
|
||||
@return: scaled, new_width, new_height. scaled is True iff new_width and/or new_height is different from width or height.
|
||||
'''
|
||||
scaled = height > pheight or width > pwidth
|
||||
if height > pheight:
|
||||
corrf = pheight/float(height)
|
||||
width, height = floor(corrf*width), pheight
|
||||
if width > pwidth:
|
||||
corrf = pwidth/float(width)
|
||||
width, height = pwidth, floor(corrf*height)
|
||||
if height > pheight:
|
||||
corrf = pheight/float(height)
|
||||
width, height = floor(corrf*width), pheight
|
||||
|
||||
return scaled, int(width), int(height)
|
||||
|
||||
try:
|
||||
# doesn't really matter what, just checking for appengine.
|
||||
from google.appengine.api import apiproxy_stub_map
|
||||
|
||||
is_appengine = True
|
||||
except:
|
||||
is_appengine = False
|
||||
|
||||
|
||||
# The list comes from ffnet, the only multi-language site we support
|
||||
# at the time of writing. Values are taken largely from pycountry,
|
||||
# but with some corrections and guesses.
|
||||
langs = {
|
||||
"English":"en",
|
||||
"Spanish":"es",
|
||||
"French":"fr",
|
||||
"German":"de",
|
||||
"Chinese":"zh",
|
||||
"Japanese":"ja",
|
||||
"Dutch":"nl",
|
||||
"Portuguese":"pt",
|
||||
"Russian":"ru",
|
||||
"Italian":"it",
|
||||
"Bulgarian":"bg",
|
||||
"Polish":"pl",
|
||||
"Hungarian":"hu",
|
||||
"Hebrew":"he",
|
||||
"Arabic":"ar",
|
||||
"Swedish":"sv",
|
||||
"Norwegian":"no",
|
||||
"Danish":"da",
|
||||
"Finnish":"fi",
|
||||
"Filipino":"fil",
|
||||
"Esperanto":"eo",
|
||||
"Hindi":"hi",
|
||||
"Punjabi":"pa",
|
||||
"Farsi":"fa",
|
||||
"Greek":"el",
|
||||
"Romanian":"ro",
|
||||
"Albanian":"sq",
|
||||
"Serbian":"sr",
|
||||
"Turkish":"tr",
|
||||
"Czech":"cs",
|
||||
"Indonesian":"id",
|
||||
"Croatian":"hr",
|
||||
"Catalan":"ca",
|
||||
"Latin":"la",
|
||||
"Korean":"ko",
|
||||
"Vietnamese":"vi",
|
||||
"Thai":"th",
|
||||
"Devanagari":"hi",
|
||||
}
|
||||
|
||||
class Story:
|
||||
|
||||
@@ -25,20 +187,37 @@ class Story:
|
||||
try:
|
||||
self.metadata = {'version':os.environ['CURRENT_VERSION_ID']}
|
||||
except:
|
||||
self.metadata = {'version':'4.0'}
|
||||
self.metadata = {'version':'4.3'}
|
||||
self.replacements = []
|
||||
self.chapters = [] # chapters will be tuples of (title,html)
|
||||
self.imgurls = []
|
||||
self.imgtuples = []
|
||||
self.listables = {} # some items (extratags, category, warnings & genres) are also kept as lists.
|
||||
self.cover=None
|
||||
|
||||
def setMetadata(self, key, value):
|
||||
## still keeps < < and &
|
||||
self.metadata[key]=conditionalRemoveEntities(value)
|
||||
if key == "language":
|
||||
try:
|
||||
self.metadata['langcode'] = langs[self.metadata[key]]
|
||||
except:
|
||||
self.metadata['langcode'] = 'en'
|
||||
|
||||
def getMetadataRaw(self,key):
|
||||
if self.metadata.has_key(key):
|
||||
return self.metadata[key]
|
||||
|
||||
def doReplacments(self,value):
|
||||
for (p,v) in self.replacements:
|
||||
if (isinstance(value,str) or isinstance(value,unicode)) and re.match(p,value):
|
||||
value = re.sub(p,v,value)
|
||||
return value;
|
||||
|
||||
def getMetadata(self, key):
|
||||
def getMetadata(self, key, removeallentities=False):
|
||||
value = None
|
||||
if self.getLists().has_key(key):
|
||||
return ', '.join(self.getList(key))
|
||||
value = ', '.join(self.getList(key))
|
||||
if self.metadata.has_key(key):
|
||||
value = self.metadata[key]
|
||||
if value:
|
||||
@@ -48,24 +227,46 @@ class Story:
|
||||
value = value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if key == "datePublished" or key == "dateUpdated":
|
||||
value = value.strftime("%Y-%m-%d")
|
||||
|
||||
value=self.doReplacments(value)
|
||||
if removeallentities and value != None:
|
||||
return removeAllEntities(value)
|
||||
else:
|
||||
return value
|
||||
|
||||
def getAllMetadata(self, removeallentities=False):
|
||||
'''
|
||||
All single value *and* list value metadata as strings.
|
||||
'''
|
||||
allmetadata = {}
|
||||
for k in self.metadata.keys():
|
||||
allmetadata[k] = self.getMetadata(k, removeallentities)
|
||||
for l in self.listables.keys():
|
||||
allmetadata[l] = self.getMetadata(l, removeallentities)
|
||||
|
||||
return allmetadata
|
||||
|
||||
def addToList(self,listname,value):
|
||||
if value==None:
|
||||
return
|
||||
value = conditionalRemoveEntities(value)
|
||||
if not self.listables.has_key(listname):
|
||||
self.listables[listname]=[]
|
||||
# prevent duplicates.
|
||||
if not value in self.listables[listname]:
|
||||
self.listables[listname].append(conditionalRemoveEntities(value))
|
||||
self.listables[listname].append(value)
|
||||
|
||||
def getList(self,listname):
|
||||
if not self.listables.has_key(listname):
|
||||
return []
|
||||
return self.listables[listname]
|
||||
return filter( lambda x : x!=None and x!='' ,
|
||||
map(self.doReplacments,self.listables[listname]) )
|
||||
|
||||
def getLists(self):
|
||||
return self.listables
|
||||
lsts = {}
|
||||
for ln in self.listables.keys():
|
||||
lsts[ln] = self.getList(ln)
|
||||
return lsts
|
||||
|
||||
def addChapter(self, title, html):
|
||||
self.chapters.append( (title,html) )
|
||||
@@ -73,10 +274,94 @@ class Story:
|
||||
def getChapters(self):
|
||||
"Chapters will be tuples of (title,html)"
|
||||
return self.chapters
|
||||
|
||||
# pass fetch in from adapter in case we need the cookies collected
|
||||
# as well as it's a base_story class method.
|
||||
def addImgUrl(self,configurable,parenturl,url,fetch,cover=False):
|
||||
|
||||
# appengine (web version) isn't allowed to do images--just
|
||||
# gets too big too fast and breaks things.
|
||||
if is_appengine:
|
||||
return
|
||||
|
||||
if url.startswith("http") or url.startswith("file") or parenturl == None:
|
||||
imgurl = url
|
||||
else:
|
||||
parsedUrl = urlparse.urlparse(parenturl)
|
||||
if url.startswith("/") :
|
||||
imgurl = urlparse.urlunparse(
|
||||
(parsedUrl.scheme,
|
||||
parsedUrl.netloc,
|
||||
url,
|
||||
'','',''))
|
||||
else:
|
||||
imgurl = urlparse.urlunparse(
|
||||
(parsedUrl.scheme,
|
||||
parsedUrl.netloc,
|
||||
parsedUrl.path + url,
|
||||
'','',''))
|
||||
|
||||
prefix='ffdl'
|
||||
if imgurl not in self.imgurls:
|
||||
parsedUrl = urlparse.urlparse(imgurl)
|
||||
sizes = [ int(x) for x in configurable.getConfigList('image_max_size') ]
|
||||
try:
|
||||
(data,ext,mime) = convert_image(imgurl,
|
||||
fetch(imgurl),
|
||||
sizes,
|
||||
configurable.getConfig('grayscale_images'))
|
||||
except Exception, e:
|
||||
print("Failed to load image, skipping:\n%s\nException: %s"%(imgurl,e))
|
||||
return "failedtoload"
|
||||
|
||||
# explicit cover, make the first image.
|
||||
if cover:
|
||||
if len(self.imgtuples) > 0 and 'cover' in self.imgtuples[0]['newsrc']:
|
||||
# remove existing cover, if there is one.
|
||||
del self.imgurls[0]
|
||||
del self.imgtuples[0]
|
||||
self.imgurls.insert(0,imgurl)
|
||||
newsrc = "images/cover.%s"%ext
|
||||
self.cover=newsrc
|
||||
self.imgtuples.insert(0,{'newsrc':newsrc,'mime':mime,'data':data})
|
||||
else:
|
||||
self.imgurls.append(imgurl)
|
||||
# First image, copy not link because calibre will replace with it's cover.
|
||||
if (len(self.imgurls)==1 and configurable.getConfig('make_firstimage_cover')):
|
||||
newsrc = "images/cover.%s"%ext
|
||||
self.cover=newsrc
|
||||
self.imgtuples.append({'newsrc':newsrc,'mime':mime,'data':data})
|
||||
self.imgurls.append(imgurl)
|
||||
|
||||
newsrc = "images/%s-%s.%s"%(
|
||||
prefix,
|
||||
self.imgurls.index(imgurl),
|
||||
ext)
|
||||
self.imgtuples.append({'newsrc':newsrc,'mime':mime,'data':data})
|
||||
|
||||
print("\nimgurl:%s\nnewsrc:%s\nimage size:%d\n"%(imgurl,newsrc,len(data)))
|
||||
else:
|
||||
newsrc = self.imgtuples[self.imgurls.index(imgurl)]['newsrc']
|
||||
|
||||
#print("===============\n%s\nimg url:%s\n============"%(newsrc,self.imgurls[-1]))
|
||||
|
||||
return newsrc
|
||||
|
||||
def getImgUrls(self):
|
||||
retlist = []
|
||||
for i, url in enumerate(self.imgurls):
|
||||
#parsedUrl = urlparse.urlparse(url)
|
||||
retlist.append(self.imgtuples[i])
|
||||
return retlist
|
||||
|
||||
def __str__(self):
|
||||
return "Metadata: " +str(self.metadata) + "\nListables: " +str(self.listables) #+ "\nChapters: "+str(self.chapters)
|
||||
|
||||
def setReplace(self,replace):
|
||||
for line in replace.splitlines():
|
||||
if "=>" in line:
|
||||
self.replacements.append(map( lambda x: x.strip(), line.split("=>") ))
|
||||
|
||||
def commaGroups(s):
|
||||
groups = []
|
||||
while s and s[-1].isdigit():
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#-*-coding:utf-8-*-
|
||||
# Code taken from http://python.su/forum/viewtopic.php?pid=66946
|
||||
import unicodedata
|
||||
def is_syllable(letter):
|
||||
syllables = ("A", "E", "I", "O", "U", "a", "e", "i", "o", "u")
|
||||
if letter in syllables:
|
||||
return True
|
||||
return False
|
||||
def is_consonant(letter):
|
||||
return not is_syllable(letter)
|
||||
def romanize(letter):
|
||||
try:
|
||||
str(letter)
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
else:
|
||||
return str(letter)
|
||||
unid = unicodedata.name(letter)
|
||||
exceptions = {"NUMERO SIGN": "No", "LEFT-POINTING DOUBLE ANGLE QUOTATION MARK": "\"", "RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK": "\"", "DASH": "-"}
|
||||
for name_contains in exceptions:
|
||||
if unid.find(name_contains)!=-1:
|
||||
return exceptions[name_contains]
|
||||
assert(unid.startswith("CYRILLIC"))# Not ready to romanize anything but cyrillics
|
||||
transformation_pairs = {"CYRILLIC CAPITAL LETTER ": str.capitalize, "CYRILLIC SMALL LETTER ": str.lower}
|
||||
func = str.lower
|
||||
for name_contains in transformation_pairs:
|
||||
if unid.find(name_contains)!=-1:
|
||||
func = transformation_pairs[name_contains]
|
||||
unid = unid.replace(name_contains, "")
|
||||
cyrillic_exceptions = {"YERU": "y", "SHORT I": "y", "HARD SIGN": "\'", "SOFT SIGN": "\'", "BYELORUSSIAN-UKRAINIAN I": "i", "GHE WITH UPTURN": "g", "UKRAINIAN IE": "ie", "YU": "yu", "YA": "ya"}
|
||||
for name_contains in cyrillic_exceptions:
|
||||
if unid.find(name_contains)!=-1:
|
||||
return cyrillic_exceptions[name_contains]
|
||||
if all(map(is_syllable, unid)):
|
||||
return func(unid)
|
||||
else:
|
||||
return func(filter(is_consonant, unid))
|
||||
def translit(text):
|
||||
output = ""
|
||||
for letter in text:
|
||||
output += romanize(letter)
|
||||
return output
|
||||
#def main():
|
||||
#text = u"русск.: Любя, съешь щипцы, — вздохнёт мэр, — кайф жгуч."
|
||||
#print translit(text)
|
||||
#text = u"укр.: Гей, хлопці, не вспію - на ґанку ваша файна їжа знищується бурундучком."
|
||||
#print translit(text)
|
||||
#text = u"болг.: Ах, чудна българска земьо, полюшквай цъфтящи жита."
|
||||
#print translit(text)
|
||||
#text = u"серб.: Неуредне ноћне даме досађивале су Џеку К."
|
||||
#print translit(text)
|
||||
#russk.: Lyubya, s'iesh' shchiptsy, - vzdohniot mer, - kayf zhghuch.
|
||||
#ukr.: Ghiey, hloptsi, nie vspiyu - na ganku vasha fayna yzha znishchuiet'sya burunduchkom.
|
||||
#bolgh.: Ah, chudna b'lgharska ziem'o, polyushkvay ts'ftyashchi zhita.
|
||||
#sierb.: Nieuriednie notshnie damie dosadjivalie su Dzhieku K.
|
||||
if __name__=="__main__":
|
||||
main()
|
||||
@@ -18,7 +18,7 @@
|
||||
## This could (should?) use a dynamic loader like adapters, but for
|
||||
## now, it's static, since there's so few of them.
|
||||
|
||||
from fanficdownloader.exceptions import FailedToDownload
|
||||
from ..exceptions import FailedToDownload
|
||||
|
||||
from writer_html import HTMLWriter
|
||||
from writer_txt import TextWriter
|
||||
|
||||
@@ -24,8 +24,8 @@ import zipfile
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
import logging
|
||||
|
||||
from fanficdownloader.configurable import Configurable
|
||||
from fanficdownloader.htmlcleanup import removeEntities, removeAllEntities, stripHTML
|
||||
from ..configurable import Configurable
|
||||
from ..htmlcleanup import removeEntities, removeAllEntities, stripHTML
|
||||
|
||||
class BaseStoryWriter(Configurable):
|
||||
|
||||
@@ -39,17 +39,23 @@ class BaseStoryWriter(Configurable):
|
||||
|
||||
def __init__(self, config, adapter):
|
||||
Configurable.__init__(self, config)
|
||||
self.addConfigSection(adapter.getSiteDomain())
|
||||
self.addConfigSection(self.getFormatName())
|
||||
self.addConfigSection(adapter.getSiteDomain()+":"+self.getFormatName())
|
||||
self.addConfigSection("overrides")
|
||||
self.setSectionOrder(adapter.getSiteDomain(),self.getFormatName())
|
||||
# self.addConfigSection(adapter.getSiteDomain())
|
||||
# self.addConfigSection(self.getFormatName())
|
||||
# self.addConfigSection(adapter.getSiteDomain()+":"+self.getFormatName())
|
||||
# self.addConfigSection("overrides")
|
||||
|
||||
self.adapter = adapter
|
||||
self.story = adapter.getStoryMetadataOnly() # only cache the metadata initially.
|
||||
|
||||
self.story.setReplace(self.getConfig('replace_metadata'))
|
||||
|
||||
self.validEntries = [
|
||||
'category',
|
||||
'genre',
|
||||
'language',
|
||||
'characters',
|
||||
'series',
|
||||
'status',
|
||||
'datePublished',
|
||||
'dateUpdated',
|
||||
@@ -76,7 +82,9 @@ class BaseStoryWriter(Configurable):
|
||||
self.titleLabels = {
|
||||
'category':'Category',
|
||||
'genre':'Genre',
|
||||
'language':'Language',
|
||||
'status':'Status',
|
||||
'series':'Series',
|
||||
'characters':'Characters',
|
||||
'datePublished':'Published',
|
||||
'dateUpdated':'Updated',
|
||||
@@ -97,10 +105,16 @@ class BaseStoryWriter(Configurable):
|
||||
'formatname':'File Format',
|
||||
'formatext':'File Extension',
|
||||
'siteabbrev':'Site Abbrev',
|
||||
'version':'FFD Version'
|
||||
'version':'FFDL Version'
|
||||
}
|
||||
self.story.setMetadata('formatname',self.getFormatName())
|
||||
self.story.setMetadata('formatext',self.getFormatExt())
|
||||
|
||||
for tag in self.getConfigList("extratags"):
|
||||
self.story.addToList("extratags",tag)
|
||||
|
||||
def getMetadata(self,key):
|
||||
return stripHTML(self.story.getMetadata(key))
|
||||
|
||||
def getOutputFileName(self):
|
||||
if self.getConfig('zip_output'):
|
||||
@@ -115,7 +129,7 @@ class BaseStoryWriter(Configurable):
|
||||
return self.formatFileName(self.getConfig('zip_filename'))
|
||||
|
||||
def formatFileName(self,template):
|
||||
values = self.story.metadata
|
||||
values = origvalues = self.story.getAllMetadata()
|
||||
# fall back default:
|
||||
if not template:
|
||||
template="${title}-${siteabbrev}_${storyId}${formatext}"
|
||||
@@ -123,7 +137,7 @@ class BaseStoryWriter(Configurable):
|
||||
if not self.getConfig('allow_unsafe_filename'):
|
||||
values={}
|
||||
pattern = re.compile(r"[^a-zA-Z0-9_\. \[\]\(\)&'-]+")
|
||||
for k in self.story.metadata.keys():
|
||||
for k in origvalues.keys():
|
||||
values[k]=re.sub(pattern,'_', removeAllEntities(self.story.getMetadata(k)))
|
||||
|
||||
return string.Template(template).substitute(values).encode('utf8')
|
||||
@@ -131,7 +145,7 @@ class BaseStoryWriter(Configurable):
|
||||
def _write(self, out, text):
|
||||
out.write(text.encode('utf8'))
|
||||
|
||||
def writeTitlePage(self, out, START, ENTRY, END, WIDE_ENTRY=None):
|
||||
def writeTitlePage(self, out, START, ENTRY, END, WIDE_ENTRY=None, NO_TITLE_ENTRY=None):
|
||||
"""
|
||||
Write the title page, but only include entries that there's
|
||||
metadata for. START, ENTRY and END are expected to already by
|
||||
@@ -158,6 +172,12 @@ class BaseStoryWriter(Configurable):
|
||||
label=self.getConfig(entry+"_label")
|
||||
else:
|
||||
label=self.titleLabels[entry]
|
||||
|
||||
# If the label for the title entry is empty, use the
|
||||
# 'no title' option if there is one.
|
||||
if label == "" and NO_TITLE_ENTRY:
|
||||
TEMPLATE= NO_TITLE_ENTRY
|
||||
|
||||
self._write(out,TEMPLATE.substitute({'label':label,
|
||||
'value':self.story.getMetadata(entry)}))
|
||||
|
||||
@@ -180,13 +200,18 @@ class BaseStoryWriter(Configurable):
|
||||
self._write(out,END.substitute(self.story.metadata))
|
||||
|
||||
# if no outstream is given, write to file.
|
||||
def writeStory(self,outstream=None,metaonly=False):
|
||||
for tag in self.getConfigList("extratags"):
|
||||
self.story.addToList("extratags",tag)
|
||||
def writeStory(self,outstream=None, metaonly=False, outfilename=None, forceOverwrite=False):
|
||||
|
||||
self.metaonly = metaonly
|
||||
outfilename=self.getOutputFileName()
|
||||
if outfilename == None:
|
||||
outfilename=self.getOutputFileName()
|
||||
|
||||
# minor cheat, tucking css into metadata.
|
||||
if self.getConfig("output_css"):
|
||||
self.story.metadata["output_css"] = self.getConfig("output_css")
|
||||
else:
|
||||
self.story.metadata["output_css"] = ''
|
||||
|
||||
if not outstream:
|
||||
close=True
|
||||
logging.debug("Save directly to file: %s" % outfilename)
|
||||
@@ -199,7 +224,7 @@ class BaseStoryWriter(Configurable):
|
||||
os.mkdir(path) ## os.makedirs() doesn't work in 2.5.2?
|
||||
|
||||
## Check for output file date vs updated date here
|
||||
if not self.getConfig('always_overwrite'):
|
||||
if not (self.getConfig('always_overwrite') or forceOverwrite):
|
||||
if os.path.exists(outfilename):
|
||||
## date() truncs off time, which files have, but sites don't report.
|
||||
lastupdated=self.story.getMetadataRaw('dateUpdated').date()
|
||||
@@ -242,6 +267,29 @@ class BaseStoryWriter(Configurable):
|
||||
if close:
|
||||
outstream.close()
|
||||
|
||||
def getTags(self):
|
||||
# set to avoid duplicates subject tags.
|
||||
subjectset = set()
|
||||
|
||||
if self.story.getMetadataRaw('dateUpdated'):
|
||||
# Last Update tags for Bill.
|
||||
self.story.addToList('lastupdate',self.story.getMetadataRaw('dateUpdated').strftime("Last Update Year/Month: %Y/%m"))
|
||||
self.story.addToList('lastupdate',self.story.getMetadataRaw('dateUpdated').strftime("Last Update: %Y/%m/%d"))
|
||||
|
||||
for entry in self.validEntries:
|
||||
if entry in self.getConfigList("include_subject_tags") and \
|
||||
entry not in self.story.getLists() and \
|
||||
self.story.getMetadata(entry):
|
||||
subjectset.add(self.getMetadata(entry))
|
||||
|
||||
# listables all go into dc:subject tags, but only if they are configured.
|
||||
for (name,lst) in self.story.getLists().iteritems():
|
||||
if name in self.getConfigList("include_subject_tags"):
|
||||
for tag in lst:
|
||||
subjectset.add(tag)
|
||||
|
||||
return list(subjectset)
|
||||
|
||||
def writeStoryImpl(self, out):
|
||||
"Must be overriden by sub classes."
|
||||
pass
|
||||
|
||||
@@ -20,13 +20,14 @@ import string
|
||||
import StringIO
|
||||
import zipfile
|
||||
from zipfile import ZipFile, ZIP_STORED, ZIP_DEFLATED
|
||||
import urllib
|
||||
|
||||
## XML isn't as forgiving as HTML, so rather than generate as strings,
|
||||
## use DOM to generate the XML files.
|
||||
from xml.dom.minidom import parse, parseString, getDOMImplementation
|
||||
|
||||
from base_writer import *
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
from ..htmlcleanup import stripHTML
|
||||
|
||||
class EpubWriter(BaseStoryWriter):
|
||||
|
||||
@@ -41,29 +42,7 @@ class EpubWriter(BaseStoryWriter):
|
||||
def __init__(self, config, story):
|
||||
BaseStoryWriter.__init__(self, config, story)
|
||||
|
||||
self.EPUB_CSS='''body { margin-left: 2%; margin-right: 2%; margin-top: 2%; margin-bottom: 2%; text-align: justify; }
|
||||
pre { font-size: x-small; }
|
||||
sml { font-size: small; }
|
||||
h1 { text-align: center; }
|
||||
h2 { text-align: center; }
|
||||
h3 { text-align: center; }
|
||||
h4 { text-align: center; }
|
||||
h5 { text-align: center; }
|
||||
h6 { text-align: center; }
|
||||
.CI {
|
||||
text-align:center;
|
||||
margin-top:0px;
|
||||
margin-bottom:0px;
|
||||
padding:0px;
|
||||
}
|
||||
.center {text-align: center;}
|
||||
.cover {text-align: center;}
|
||||
.full {width: 100%; }
|
||||
.quarter {width: 25%; }
|
||||
.smcap {font-variant: small-caps;}
|
||||
.u {text-decoration: underline;}
|
||||
.bold {font-weight: bold;}
|
||||
'''
|
||||
self.EPUB_CSS = string.Template('''${output_css}''')
|
||||
|
||||
self.EPUB_TITLE_PAGE_START = string.Template('''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
@@ -79,6 +58,10 @@ h6 { text-align: center; }
|
||||
|
||||
self.EPUB_TITLE_ENTRY = string.Template('''
|
||||
<b>${label}:</b> ${value}<br />
|
||||
''')
|
||||
|
||||
self.EPUB_NO_TITLE_ENTRY = string.Template('''
|
||||
${value}<br />
|
||||
''')
|
||||
|
||||
self.EPUB_TITLE_PAGE_END = string.Template('''
|
||||
@@ -106,6 +89,10 @@ h6 { text-align: center; }
|
||||
|
||||
self.EPUB_TABLE_TITLE_WIDE_ENTRY = string.Template('''
|
||||
<tr><td colspan="2"><b>${label}:</b> ${value}</td></tr>
|
||||
''')
|
||||
|
||||
self.EPUB_TABLE_NO_TITLE_ENTRY = string.Template('''
|
||||
<tr><td colspan="2">${label}${value}</td></tr>
|
||||
''')
|
||||
|
||||
self.EPUB_TABLE_TITLE_PAGE_END = string.Template('''
|
||||
@@ -153,9 +140,6 @@ h6 { text-align: center; }
|
||||
</html>
|
||||
''')
|
||||
|
||||
def getMetadata(self,key):
|
||||
return stripHTML(self.story.getMetadata(key))
|
||||
|
||||
def writeStoryImpl(self, out):
|
||||
|
||||
## Python 2.5 ZipFile is rather more primative than later
|
||||
@@ -228,7 +212,10 @@ h6 { text-align: center; }
|
||||
|
||||
metadata.appendChild(newTag(contentdom,"dc:contributor",text="fanficdownloader [http://fanficdownloader.googlecode.com]",attrs={"opf:role":"bkp"}))
|
||||
metadata.appendChild(newTag(contentdom,"dc:rights",text=""))
|
||||
metadata.appendChild(newTag(contentdom,"dc:language",text="en"))
|
||||
if self.story.getMetadata('langcode') != None:
|
||||
metadata.appendChild(newTag(contentdom,"dc:language",text=self.story.getMetadata('langcode')))
|
||||
else:
|
||||
metadata.appendChild(newTag(contentdom,"dc:language",text='en'))
|
||||
|
||||
# published, created, updated, calibre
|
||||
# Leave calling self.story.getMetadataRaw directly in case date format changes.
|
||||
@@ -249,27 +236,12 @@ h6 { text-align: center; }
|
||||
metadata.appendChild(newTag(contentdom,"meta",
|
||||
attrs={"name":"calibre:timestamp",
|
||||
"content":self.story.getMetadataRaw('dateUpdated').strftime("%Y-%m-%dT%H:%M:%S")}))
|
||||
# Last Update tags for Bill.
|
||||
self.story.addToList('lastupdate',self.story.getMetadataRaw('dateUpdated').strftime("Last Update Year/Month: %Y/%m"))
|
||||
self.story.addToList('lastupdate',self.story.getMetadataRaw('dateUpdated').strftime("Last Update: %Y/%m/%d"))
|
||||
|
||||
if self.getMetadata('description'):
|
||||
metadata.appendChild(newTag(contentdom,"dc:description",text=
|
||||
self.getMetadata('description')))
|
||||
|
||||
# set to avoid duplicates subject tags.
|
||||
subjectset = set()
|
||||
for entry in self.validEntries:
|
||||
if entry in self.getConfigList("include_subject_tags") and \
|
||||
entry not in self.story.getLists() and \
|
||||
self.story.getMetadata(entry):
|
||||
subjectset.add(self.getMetadata(entry))
|
||||
# listables all go into dc:subject tags, but only if they are configured.
|
||||
for (name,lst) in self.story.getLists().iteritems():
|
||||
if name in self.getConfigList("include_subject_tags"):
|
||||
for tag in lst:
|
||||
subjectset.add(tag)
|
||||
for subject in subjectset:
|
||||
for subject in self.getTags():
|
||||
metadata.appendChild(newTag(contentdom,"dc:subject",text=subject))
|
||||
|
||||
|
||||
@@ -289,7 +261,54 @@ h6 { text-align: center; }
|
||||
itemrefs = [] # list of strings -- idrefs from .opfs' spines
|
||||
items.append(("ncx","toc.ncx","application/x-dtbncx+xml",None)) ## we'll generate the toc.ncx file,
|
||||
## but it needs to be in the items manifest.
|
||||
|
||||
if self.getConfig('include_images'):
|
||||
imgcount=0
|
||||
for imgmap in self.story.getImgUrls():
|
||||
imgfile = "OEBPS/"+imgmap['newsrc']
|
||||
outputepub.writestr(imgfile,imgmap['data'])
|
||||
items.append(("image%04d"%imgcount,
|
||||
imgfile,
|
||||
imgmap['mime'],
|
||||
None))
|
||||
imgcount+=1
|
||||
|
||||
|
||||
items.append(("style","OEBPS/stylesheet.css","text/css",None))
|
||||
|
||||
guide = None
|
||||
coverIO = None
|
||||
|
||||
if self.story.cover:
|
||||
# Note that the id of the cover xhmtl *must* be 'cover'
|
||||
# for it to work on Nook.
|
||||
items.append(("cover","OEBPS/cover.xhtml","application/xhtml+xml",None))
|
||||
itemrefs.append("cover")
|
||||
#
|
||||
# <meta name="cover" content="cover.jpg"/>
|
||||
metadata.appendChild(newTag(contentdom,"meta",{"content":"image0000",
|
||||
"name":"cover"}))
|
||||
# cover stuff for later:
|
||||
# at end of <package>:
|
||||
# <guide>
|
||||
# <reference type="cover" title="Cover" href="Text/cover.xhtml"/>
|
||||
# </guide>
|
||||
guide = newTag(contentdom,"guide")
|
||||
guide.appendChild(newTag(contentdom,"reference",attrs={"type":"cover",
|
||||
"title":"Cover",
|
||||
"href":"OEBPS/cover.xhtml"}))
|
||||
|
||||
coverIO = StringIO.StringIO()
|
||||
coverIO.write('''
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"><head><title>Cover</title><style type="text/css" title="override_css">
|
||||
@page {padding: 0pt; margin:0pt}
|
||||
body { text-align: center; padding:0pt; margin: 0pt; }
|
||||
div { margin: 0pt; padding: 0pt; }
|
||||
</style></head><body><div>
|
||||
<img src="%s" alt="cover"/>
|
||||
</div></body></html>
|
||||
'''%self.story.cover)
|
||||
|
||||
if self.getConfig("include_titlepage"):
|
||||
items.append(("title_page","OEBPS/title_page.xhtml","application/xhtml+xml","Title Page"))
|
||||
itemrefs.append("title_page")
|
||||
@@ -320,8 +339,18 @@ h6 { text-align: center; }
|
||||
spine.appendChild(newTag(contentdom,"itemref",
|
||||
attrs={"idref":itemref,
|
||||
"linear":"yes"}))
|
||||
# guide only exists if there's a cover.
|
||||
if guide:
|
||||
package.appendChild(guide)
|
||||
|
||||
# write content.opf to zip.
|
||||
outputepub.writestr("content.opf",contentdom.toxml(encoding='utf-8'))
|
||||
contentxml = contentdom.toxml(encoding='utf-8')
|
||||
|
||||
# tweak for brain damaged Nook STR. Nook insists on name before content.
|
||||
contentxml = contentxml.replace('<meta content="image0000" name="cover"/>',
|
||||
'<meta name="cover" content="image0000"/>')
|
||||
outputepub.writestr("content.opf",contentxml)
|
||||
|
||||
contentdom.unlink()
|
||||
del contentdom
|
||||
|
||||
@@ -357,7 +386,7 @@ h6 { text-align: center; }
|
||||
index=0
|
||||
for item in items:
|
||||
(id,href,type,title)=item
|
||||
# only items to be skipped, toc.ncx, stylesheet.css, should have no title.
|
||||
# only items to be skipped, cover.xhtml, images, toc.ncx, stylesheet.css, should have no title.
|
||||
if title :
|
||||
navPoint = newTag(tocncxdom,"navPoint",
|
||||
attrs={'id':id,
|
||||
@@ -370,32 +399,39 @@ h6 { text-align: center; }
|
||||
navPoint.appendChild(newTag(tocncxdom,"content",attrs={"src":href}))
|
||||
index=index+1
|
||||
|
||||
# write toc.ncs to zip file
|
||||
# write toc.ncx to zip file
|
||||
outputepub.writestr("toc.ncx",tocncxdom.toxml(encoding='utf-8'))
|
||||
tocncxdom.unlink()
|
||||
del tocncxdom
|
||||
|
||||
# write stylesheet.css file.
|
||||
outputepub.writestr("OEBPS/stylesheet.css",self.EPUB_CSS)
|
||||
outputepub.writestr("OEBPS/stylesheet.css",self.EPUB_CSS.substitute(self.story.metadata))
|
||||
|
||||
# write title page.
|
||||
if self.getConfig("titlepage_use_table"):
|
||||
TITLE_PAGE_START = self.EPUB_TABLE_TITLE_PAGE_START
|
||||
TITLE_ENTRY = self.EPUB_TABLE_TITLE_ENTRY
|
||||
WIDE_TITLE_ENTRY = self.EPUB_TABLE_TITLE_WIDE_ENTRY
|
||||
NO_TITLE_ENTRY = self.EPUB_TABLE_NO_TITLE_ENTRY
|
||||
TITLE_PAGE_END = self.EPUB_TABLE_TITLE_PAGE_END
|
||||
else:
|
||||
TITLE_PAGE_START = self.EPUB_TITLE_PAGE_START
|
||||
TITLE_ENTRY = self.EPUB_TITLE_ENTRY
|
||||
WIDE_TITLE_ENTRY = self.EPUB_TITLE_ENTRY # same, only wide in tables.
|
||||
NO_TITLE_ENTRY = self.EPUB_NO_TITLE_ENTRY
|
||||
TITLE_PAGE_END = self.EPUB_TITLE_PAGE_END
|
||||
|
||||
|
||||
if coverIO:
|
||||
outputepub.writestr("OEBPS/cover.xhtml",coverIO.getvalue())
|
||||
coverIO.close()
|
||||
|
||||
titlepageIO = StringIO.StringIO()
|
||||
self.writeTitlePage(out=titlepageIO,
|
||||
START=TITLE_PAGE_START,
|
||||
ENTRY=TITLE_ENTRY,
|
||||
WIDE_ENTRY=WIDE_TITLE_ENTRY,
|
||||
END=TITLE_PAGE_END)
|
||||
END=TITLE_PAGE_END,
|
||||
NO_TITLE_ENTRY=NO_TITLE_ENTRY)
|
||||
if titlepageIO.getvalue(): # will be false if no title page.
|
||||
outputepub.writestr("OEBPS/title_page.xhtml",titlepageIO.getvalue())
|
||||
titlepageIO.close()
|
||||
@@ -421,7 +457,7 @@ h6 { text-align: center; }
|
||||
fullhtml = fullhtml.replace('</p>','</p>\n').replace('<br />','<br />\n')
|
||||
outputepub.writestr("OEBPS/file%04d.xhtml"%(index+1),fullhtml.encode('utf-8'))
|
||||
del fullhtml
|
||||
|
||||
|
||||
# declares all the files created by Windows. otherwise, when
|
||||
# it runs in appengine, windows unzips the files as 000 perms.
|
||||
for zf in outputepub.filelist:
|
||||
@@ -439,4 +475,4 @@ def newTag(dom,name,attrs=None,text=None):
|
||||
if( text is not None ):
|
||||
tag.appendChild(dom.createTextNode(text))
|
||||
return tag
|
||||
|
||||
|
||||
|
||||
@@ -39,19 +39,7 @@ class HTMLWriter(BaseStoryWriter):
|
||||
<head>
|
||||
<title>${title} by ${author}</title>
|
||||
<style type="text/css">
|
||||
.CI {
|
||||
text-align:center;
|
||||
margin-top:0px;
|
||||
margin-bottom:0px;
|
||||
padding:0px;
|
||||
}
|
||||
.center {text-align: center;}
|
||||
.cover {text-align: center;}
|
||||
.full {width: 100%; }
|
||||
.quarter {width: 25%; }
|
||||
.smcap {font-variant: small-caps;}
|
||||
.u {text-decoration: underline;}
|
||||
.bold {font-weight: bold;}
|
||||
${output_css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -20,8 +20,8 @@ import string
|
||||
import StringIO
|
||||
|
||||
from base_writer import *
|
||||
from fanficdownloader.htmlcleanup import stripHTML
|
||||
from fanficdownloader.mobi import Converter
|
||||
from ..htmlcleanup import stripHTML
|
||||
from ..mobi import Converter
|
||||
|
||||
class MobiWriter(BaseStoryWriter):
|
||||
|
||||
@@ -41,7 +41,6 @@ class MobiWriter(BaseStoryWriter):
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>${title} by ${author}</title>
|
||||
<link href="stylesheet.css" type="text/css" charset="UTF-8" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
<h3><a href="${storyUrl}">${title}</a> by <a href="${authorUrl}">${author}</a></h3>
|
||||
@@ -50,6 +49,10 @@ class MobiWriter(BaseStoryWriter):
|
||||
|
||||
self.MOBI_TITLE_ENTRY = string.Template('''
|
||||
<b>${label}:</b> ${value}<br />
|
||||
''')
|
||||
|
||||
self.MOBI_NO_TITLE_ENTRY = string.Template('''
|
||||
${value}<br />
|
||||
''')
|
||||
|
||||
self.MOBI_TITLE_PAGE_END = string.Template('''
|
||||
@@ -64,7 +67,6 @@ class MobiWriter(BaseStoryWriter):
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>${title} by ${author}</title>
|
||||
<link href="stylesheet.css" type="text/css" charset="UTF-8" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
<h3><a href="${storyUrl}">${title}</a> by <a href="${authorUrl}">${author}</a></h3>
|
||||
@@ -77,6 +79,10 @@ class MobiWriter(BaseStoryWriter):
|
||||
|
||||
self.MOBI_TABLE_TITLE_WIDE_ENTRY = string.Template('''
|
||||
<tr><td colspan="2"><b>${label}:</b> ${value}</td></tr>
|
||||
''')
|
||||
|
||||
self.MOBI_TABLE_NO_TITLE_WIDE_ENTRY = string.Template('''
|
||||
<tr><td colspan="2">${value}</td></tr>
|
||||
''')
|
||||
|
||||
self.MOBI_TABLE_TITLE_PAGE_END = string.Template('''
|
||||
@@ -91,7 +97,6 @@ class MobiWriter(BaseStoryWriter):
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>${title} by ${author}</title>
|
||||
<link href="stylesheet.css" type="text/css" charset="UTF-8" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
@@ -113,7 +118,6 @@ class MobiWriter(BaseStoryWriter):
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>${chapter}</title>
|
||||
<link href="stylesheet.css" type="text/css" charset="UTF-8" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
<h3>${chapter}</h3>
|
||||
@@ -124,9 +128,6 @@ class MobiWriter(BaseStoryWriter):
|
||||
</html>
|
||||
''')
|
||||
|
||||
def getMetadata(self,key):
|
||||
return stripHTML(self.story.getMetadata(key))
|
||||
|
||||
def writeStoryImpl(self, out):
|
||||
|
||||
files = []
|
||||
@@ -136,11 +137,13 @@ class MobiWriter(BaseStoryWriter):
|
||||
TITLE_PAGE_START = self.MOBI_TABLE_TITLE_PAGE_START
|
||||
TITLE_ENTRY = self.MOBI_TABLE_TITLE_ENTRY
|
||||
WIDE_TITLE_ENTRY = self.MOBI_TABLE_TITLE_WIDE_ENTRY
|
||||
NO_TITLE_ENTRY = self.MOBI_TABLE_NO_TITLE_ENTRY
|
||||
TITLE_PAGE_END = self.MOBI_TABLE_TITLE_PAGE_END
|
||||
else:
|
||||
TITLE_PAGE_START = self.MOBI_TITLE_PAGE_START
|
||||
TITLE_ENTRY = self.MOBI_TITLE_ENTRY
|
||||
WIDE_TITLE_ENTRY = self.MOBI_TITLE_ENTRY # same, only wide in tables.
|
||||
NO_TITLE_ENTRY = self.MOBI_NO_TITLE_ENTRY
|
||||
TITLE_PAGE_END = self.MOBI_TITLE_PAGE_END
|
||||
|
||||
titlepageIO = StringIO.StringIO()
|
||||
@@ -148,7 +151,8 @@ class MobiWriter(BaseStoryWriter):
|
||||
START=TITLE_PAGE_START,
|
||||
ENTRY=TITLE_ENTRY,
|
||||
WIDE_ENTRY=WIDE_TITLE_ENTRY,
|
||||
END=TITLE_PAGE_END)
|
||||
END=TITLE_PAGE_END,
|
||||
NO_TITLE_ENTRY=NO_TITLE_ENTRY)
|
||||
if titlepageIO.getvalue(): # will be false if no title page.
|
||||
files.append(titlepageIO.getvalue())
|
||||
titlepageIO.close()
|
||||
|
||||
@@ -21,7 +21,7 @@ from textwrap import wrap
|
||||
|
||||
from base_writer import *
|
||||
|
||||
from fanficdownloader.html2text import html2text, BODY_WIDTH
|
||||
from ..html2text import html2text, BODY_WIDTH
|
||||
|
||||
## In BaseStoryWriter, we define _write to encode <unicode> objects
|
||||
## back into <string> for true output. But txt needs to write the
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@
|
||||
<link href="css/index.css" rel="stylesheet" type="text/css">
|
||||
<link type="text/css" href="http://jqueryui.com/latest/themes/base/ui.all.css" rel="stylesheet" />
|
||||
|
||||
<title>Fanfiction Downloader (fanfiction.net, fictionalley, ficwad to epub and HTML)</title>
|
||||
<title>FanFictionDownLoader (fanfiction.net, fictionalley, ficwad to epub and HTML)</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<script src="/js/jquery-1.3.2.js"></script>
|
||||
<script src="/js/fdownloader.js"></script>
|
||||
@@ -16,7 +16,7 @@
|
||||
<body>
|
||||
<div id='main'>
|
||||
<h1>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFiction Downloader</a>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFictionDownLoader</a>
|
||||
</h1>
|
||||
|
||||
<!-- <form action="/fdown" method="post"> -->
|
||||
@@ -91,8 +91,8 @@
|
||||
<img src="http://code.google.com/appengine/images/appengine-silver-120x30.gif"
|
||||
alt="Powered by Google App Engine" />
|
||||
<br/><br/>
|
||||
FanfictionLoader is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">fanficdownloader</a><br/>
|
||||
Copyright © <a href="http://twitter.com/sigizmund">Roman Kirillov</a>
|
||||
This is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">FanFictionDownLoader</a><br/>
|
||||
Copyright © Fanficdownloader team
|
||||
</div>
|
||||
<!-- </form> -->
|
||||
</div>
|
||||
|
||||
+66
-36
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<link href="/css/index.css" rel="stylesheet" type="text/css">
|
||||
<title>Fanfiction Downloader - read fanfiction from twilighted.net, fanfiction.net, fictionpress.com, fictionalley.org, ficwad.com, potionsandsnitches.net, harrypotterfanfiction.com, mediaminer.org on Kindle, Nook, Sony Reader, iPad, iPhone, Android, Aldiko, Stanza</title>
|
||||
<title>FanFictionDownLoader - read fanfiction from twilighted.net, fanfiction.net, fictionpress.com, fictionalley.org, ficwad.com, potionsandsnitches.net, harrypotterfanfiction.com, mediaminer.org on Kindle, Nook, Sony Reader, iPad, iPhone, Android, Aldiko, Stanza</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="google-site-verification" content="kCFc-G4bka_pJN6Rv8CapPBcwmq0hbAUZPkKWqRsAYU" />
|
||||
<script type="text/javascript">
|
||||
@@ -26,7 +26,7 @@
|
||||
<body>
|
||||
<div id='main'>
|
||||
<h1>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFiction Downloader</a> <g:plusone size="medium"></g:plusone>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFictionDownLoader</a> <g:plusone size="medium"></g:plusone>
|
||||
</h1>
|
||||
|
||||
<div style="text-align: center">
|
||||
@@ -50,38 +50,19 @@
|
||||
<form action="/fdown" method="post">
|
||||
<div id='urlbox'>
|
||||
<div id='greeting'>
|
||||
<p>Hi, {{ nickname }}! This is a fan fiction downloader, which makes reading stories from various websites
|
||||
<p>Hi, {{ nickname }}! This is FanFictionDownLoader, which makes reading stories from various websites
|
||||
much easier. </p>
|
||||
</div>
|
||||
<!-- put announcements here, h3 is a good title size. -->
|
||||
<h3>Please Switch to <a href="http://fanfictiondownloader.appspot.com/">New Version</a></h3>
|
||||
<h3>New Site gayauthors.org</h3>
|
||||
<p>
|
||||
We have a new, more efficient, version of the system up now. Please start using the
|
||||
<a href="http://fanfictiondownloader.appspot.com/">New
|
||||
Version here</a>. If for some reason, the new version
|
||||
doesn't work for you, please let us know on
|
||||
the <a href="http://groups.google.com/group/fanfic-downloader">Fanfiction
|
||||
Downloader Google Group</a>.
|
||||
</p>
|
||||
<h3>New Google Quotas</h3>
|
||||
<p>
|
||||
Google has changed their quota limits for free
|
||||
applications using their AppEngine system, like this one.
|
||||
We expect that there will be times when the
|
||||
system exceeds it's permitted processing quota.
|
||||
</p>
|
||||
<p>
|
||||
You also have the option of running the downloader on your
|
||||
own computer if you have Python available.
|
||||
<a href="http://code.google.com/p/fanficdownloader/downloads/list">Download here.</a>
|
||||
Thanks to Ida Leter's hard work, we now support <a href="http://www.gayauthors.org">gayauthors.org</a>, a fanfiction site specializing in gay stories.
|
||||
</p>
|
||||
<p>
|
||||
If you have any problems with this application, please
|
||||
report them in
|
||||
the <a href="http://groups.google.com/group/fanfic-downloader">Fanfiction
|
||||
Downloader Google Group</a>. The
|
||||
<a href="http://4-0-6.fanfictionloader.appspot.com">Previous
|
||||
Version</a> is also available for you to use if necessary.
|
||||
the <a href="http://groups.google.com/group/fanfic-downloader">FanFictionDownLoader Google Group</a>. The
|
||||
<a href="http://4-3-1.fanfictiondownloader.appspot.com">Previous Version</a> is also available for you to use if necessary.
|
||||
</p>
|
||||
<div id='error'>
|
||||
{{ error_message }}
|
||||
@@ -117,14 +98,33 @@
|
||||
<div id='urlbox'>
|
||||
<div id='greeting'>
|
||||
<p>
|
||||
This is a fan fiction downloader, which makes reading stories from various websites much easier. Before you
|
||||
can start downloading fanfics, you need to login, so downloader can remember your fanfics and store them.
|
||||
This is a FanFictionDownLoader, which makes reading stories from various websites much easier. Before you
|
||||
can start downloading fanfics, you need to login, so FanFictionDownLoader can remember your fanfics and store them.
|
||||
</p>
|
||||
<p><a href="{{ login_url }}">Login using Google account</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id='typebox'>
|
||||
<p>
|
||||
<b>FanFictionDownLoader calibre Plugin</b>
|
||||
<br /><br />
|
||||
|
||||
There's now a version of this downloader that runs
|
||||
entirely inside the
|
||||
popular <a href="http://calibre-ebook.com/">calibre</a>
|
||||
ebook management package as a plugin.
|
||||
|
||||
<br /><br />
|
||||
|
||||
Once you have calibre installed and running, inside
|
||||
calibre, you can go to 'Get plugins to enhance calibre' or
|
||||
'Get new plugins' and
|
||||
install <a href="http://www.mobileread.com/forums/showthread.php?t=163261">FanFictionDownLoader</a>.
|
||||
|
||||
</p>
|
||||
</div>
|
||||
<div id='helpbox'>
|
||||
<dl>
|
||||
<dt>fictionalley.org</dt>
|
||||
@@ -201,10 +201,10 @@
|
||||
Use the URL of the story's chapter list, such as
|
||||
<br /><a href="http://fanfiction.tenhawkpresents.com/viewstory.php?sid=294">http://fanfiction.tenhawkpresents.com/viewstory.php?sid=294</a>.
|
||||
</dd>
|
||||
<dt>fanfic.castletv.net</dt>
|
||||
<dt>castlefans.org</dt>
|
||||
<dd>
|
||||
Use the URL of the story's chapter list, such as
|
||||
<br /><a href="http://fanfic.castletv.net/viewstory.php?sid=123">http://fanfic.castletv.net/viewstory.php?sid=123</a>.
|
||||
<br /><a href="http://castlefans.org/fanfic/viewstory.php?sid=123">http://castlefans.org/fanfic/viewstory.php?sid=123</a>.
|
||||
</dd>
|
||||
<dt>fimfiction.net</dt>
|
||||
<dd>
|
||||
@@ -213,10 +213,40 @@
|
||||
<br /> or the URL of any chapter, such as
|
||||
<br /><a href="http://www.fimfiction.com/story/123/1/">http://www.fimfiction.com/story/123/1/</a>.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
|
||||
A few additional things to know, which will make your life substantially easier:
|
||||
<dt>tthfanfic.org</dt>
|
||||
<dd>
|
||||
Use the URL of any story, with or without chapter, title and notice, such as
|
||||
<br /><a href="http://www.tthfanfic.org/Story-5583">http://www.tthfanfic.org/Story-5583</a>
|
||||
<br /><a href="http://www.tthfanfic.org/Story-5583/Greywizard+Marked+By+Kane.htm">http://www.tthfanfic.org/Story-5583/Greywizard+Marked+By+Kane.htm</a>.
|
||||
<br /><a href="http://www.tthfanfic.org/T-99999999/Story-26448-15/batzulger+Willow+Rosenberg+and+the+Mind+Riders.htm">http://www.tthfanfic.org/T-99999999/Story-26448-15/batzulger+Willow+Rosenberg+and+the+Mind+Riders.htm</a>.
|
||||
</dd>
|
||||
<dt>www.siye.co.uk</dt>
|
||||
<dd>
|
||||
Use the URL of the story's chapter list, such as
|
||||
<br /><a href="http://www.siye.co.uk/siye/viewstory.php?sid=123">http://www.siye.co.uk/siye/viewstory.php?sid=123</a>.
|
||||
</dd>
|
||||
<dt>archiveofourown.org</dt>
|
||||
<dd>
|
||||
Use the URL of the story, or one of it's chapters, such as
|
||||
<br /><a href="http://archiveofourown.org/works/76366">http://archiveofourown.org/works/76366</a>.
|
||||
<br /><a href="http://archiveofourown.org/works/76366/chapters/101584">http://archiveofourown.org/works/76366/chapters/101584</a>.
|
||||
</dd>
|
||||
<dt>ficbook.net(Russian)</dt>
|
||||
<dd>
|
||||
Use the URL of the story, or one of it's chapters, such as
|
||||
<br /><a href="http://ficbook.net/readfic/93626">http://ficbook.net/readfic/93626</a>.
|
||||
<br /><a href="http://ficbook.net/readfic/93626/246417#part_content">http://ficbook.net/readfic/93626/246417#part_content</a>.
|
||||
</dd>
|
||||
<dt>gayauthors.org</dt>
|
||||
<dd>
|
||||
Use the URL of the story, or one of it's chapters, such as
|
||||
<br /><a href="http://www.gayauthors.org/story/mark-arbour/stvincent">http://www.gayauthors.org/story/mark-arbour/stvincent</a>.
|
||||
<br /><a href="http://www.gayauthors.org/story/Mark%20Arbour/stvincent/7">http://www.gayauthors.org/story/Mark Arbour/stvincent/7</a>.
|
||||
</dd>
|
||||
</dl>
|
||||
<p>
|
||||
A few additional things to know, which will make your life substantially easier:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
First thing to know: I do not use your Google login and password. In fact, all I know about it is your ID – password
|
||||
@@ -249,8 +279,8 @@
|
||||
<img src="http://code.google.com/appengine/images/appengine-silver-120x30.gif"
|
||||
alt="Powered by Google App Engine" />
|
||||
<br/><br/>
|
||||
FanfictionLoader is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">fanficdownloader</a><br/>
|
||||
Copyright © Fanficdownloader team
|
||||
This is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">FanFictionDownLoader</a><br/>
|
||||
Copyright © FanFictionDownLoader team
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1em; text-align: center'">
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<link href="/css/index.css" rel="stylesheet" type="text/css">
|
||||
<title>Login Needed Fanfiction Downloader</title>
|
||||
<title>Login Needed FanFictionDownLoader</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="google-site-verification" content="kCFc-G4bka_pJN6Rv8CapPBcwmq0hbAUZPkKWqRsAYU" />
|
||||
<script type="text/javascript">
|
||||
@@ -22,7 +22,7 @@
|
||||
<body>
|
||||
<div id='main'>
|
||||
<h1>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFiction Downloader</a>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFictionDownLoader</a>
|
||||
</h1>
|
||||
|
||||
<div style="text-align: center">
|
||||
@@ -88,8 +88,8 @@
|
||||
<img src="http://code.google.com/appengine/images/appengine-silver-120x30.gif"
|
||||
alt="Powered by Google App Engine" />
|
||||
<br/><br/>
|
||||
FanfictionLoader is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">fanficdownloader</a><br/>
|
||||
Copyright © Fanficdownloader team
|
||||
This is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">FanFictionDownLoader</a><br/>
|
||||
Copyright © FanFictionDownLoader team
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1em; text-align: center'">
|
||||
|
||||
@@ -41,23 +41,24 @@ import ConfigParser
|
||||
## Console page first, you will get a django version mismatch error when you
|
||||
## to go hit one of the application pages. Just change a file again, and
|
||||
## make sure to hit an app page before the SDK page to clear it.
|
||||
os.environ['DJANGO_SETTINGS_MODULE'] = 'settings'
|
||||
from google.appengine.dist import use_library
|
||||
use_library('django', '1.2')
|
||||
#os.environ['DJANGO_SETTINGS_MODULE'] = 'settings'
|
||||
#from google.appengine.dist import use_library
|
||||
#use_library('django', '1.2')
|
||||
|
||||
from google.appengine.ext import db
|
||||
from google.appengine.api import taskqueue
|
||||
from google.appengine.api import users
|
||||
from google.appengine.ext import webapp
|
||||
#from google.appengine.ext import webapp
|
||||
import webapp2
|
||||
from google.appengine.ext.webapp import template
|
||||
from google.appengine.ext.webapp import util
|
||||
#from google.appengine.ext.webapp2 import util
|
||||
from google.appengine.runtime import DeadlineExceededError
|
||||
|
||||
from ffstorage import *
|
||||
|
||||
from fanficdownloader import adapters, writers, exceptions
|
||||
|
||||
class UserConfigServer(webapp.RequestHandler):
|
||||
class UserConfigServer(webapp2.RequestHandler):
|
||||
def getUserConfig(self,user):
|
||||
config = ConfigParser.SafeConfigParser()
|
||||
|
||||
@@ -73,7 +74,7 @@ class UserConfigServer(webapp.RequestHandler):
|
||||
|
||||
return config
|
||||
|
||||
class MainHandler(webapp.RequestHandler):
|
||||
class MainHandler(webapp2.RequestHandler):
|
||||
def get(self):
|
||||
user = users.get_current_user()
|
||||
if user:
|
||||
@@ -160,7 +161,7 @@ class EditConfigServer(UserConfigServer):
|
||||
self.response.out.write(template.render(path, template_values))
|
||||
|
||||
|
||||
class FileServer(webapp.RequestHandler):
|
||||
class FileServer(webapp2.RequestHandler):
|
||||
|
||||
def get(self):
|
||||
fileId = self.request.get('id')
|
||||
@@ -221,7 +222,7 @@ class FileServer(webapp.RequestHandler):
|
||||
path = os.path.join(os.path.dirname(__file__), 'status.html')
|
||||
self.response.out.write(template.render(path, template_values))
|
||||
|
||||
class FileStatusServer(webapp.RequestHandler):
|
||||
class FileStatusServer(webapp2.RequestHandler):
|
||||
def get(self):
|
||||
user = users.get_current_user()
|
||||
if not user:
|
||||
@@ -257,7 +258,7 @@ class FileStatusServer(webapp.RequestHandler):
|
||||
path = os.path.join(os.path.dirname(__file__), 'status.html')
|
||||
self.response.out.write(template.render(path, template_values))
|
||||
|
||||
class ClearRecentServer(webapp.RequestHandler):
|
||||
class ClearRecentServer(webapp2.RequestHandler):
|
||||
def get(self):
|
||||
user = users.get_current_user()
|
||||
if not user:
|
||||
@@ -282,7 +283,7 @@ class ClearRecentServer(webapp.RequestHandler):
|
||||
logging.info('Deleted %d instances download.' % num)
|
||||
self.redirect("/?error=recentcleared")
|
||||
|
||||
class RecentFilesServer(webapp.RequestHandler):
|
||||
class RecentFilesServer(webapp2.RequestHandler):
|
||||
def get(self):
|
||||
user = users.get_current_user()
|
||||
if not user:
|
||||
@@ -338,7 +339,7 @@ class FanfictionDownloader(UserConfigServer):
|
||||
self.redirect("/?error=custom&errtext=%s"%urlEscape("There's an error in your User Configuration: "+str(e)))
|
||||
return
|
||||
|
||||
adapter = adapters.getAdapter(config,url)
|
||||
adapter = adapters.getAdapter(config,url,format)
|
||||
logging.info('Created an adaper: %s' % adapter)
|
||||
|
||||
if len(login) > 1:
|
||||
@@ -441,7 +442,7 @@ class FanfictionDownloaderTask(UserConfigServer):
|
||||
|
||||
try:
|
||||
config = self.getUserConfig(user)
|
||||
adapter = adapters.getAdapter(config,url)
|
||||
adapter = adapters.getAdapter(config,url,format)
|
||||
|
||||
logging.info('Created an adapter: %s' % adapter)
|
||||
|
||||
@@ -556,20 +557,14 @@ def urlEscape(data):
|
||||
p = re.compile(r'([^\w])')
|
||||
return p.sub(toPercentDecimal, data.encode("utf-8"))
|
||||
|
||||
def main():
|
||||
application = webapp.WSGIApplication([('/', MainHandler),
|
||||
('/fdowntask', FanfictionDownloaderTask),
|
||||
('/fdown', FanfictionDownloader),
|
||||
(r'/file.*', FileServer),
|
||||
('/status', FileStatusServer),
|
||||
('/recent', RecentFilesServer),
|
||||
('/editconfig', EditConfigServer),
|
||||
('/clearrecent', ClearRecentServer),
|
||||
],
|
||||
debug=False)
|
||||
util.run_wsgi_app(application)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
main()
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
app = webapp2.WSGIApplication([('/', MainHandler),
|
||||
('/fdowntask', FanfictionDownloaderTask),
|
||||
('/fdown', FanfictionDownloader),
|
||||
(r'/file.*', FileServer),
|
||||
('/status', FileStatusServer),
|
||||
('/recent', RecentFilesServer),
|
||||
('/editconfig', EditConfigServer),
|
||||
('/clearrecent', ClearRecentServer),
|
||||
],
|
||||
debug=False)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# epubmerge.py 1.0
|
||||
|
||||
# Copyright 2011, Jim Miller
|
||||
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
from makezip import createZipFile
|
||||
|
||||
if __name__=="__main__":
|
||||
filename="FanFictionDownLoader.zip"
|
||||
exclude=['*.pyc','*~','*.xcf']
|
||||
# from top dir. 'w' for overwrite
|
||||
createZipFile(filename,"w",
|
||||
['plugin-defaults.ini','plugin-example.ini','epubmerge.py','fanficdownloader'],
|
||||
exclude=exclude)
|
||||
#from calibre-plugin dir. 'a' for append
|
||||
os.chdir('calibre-plugin')
|
||||
files=['about.txt','images',]
|
||||
files.extend(glob('*.py'))
|
||||
files.extend(glob('plugin-import-name-*.txt'))
|
||||
createZipFile("../"+filename,"a",
|
||||
files,exclude=exclude)
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# epubmerge.py 1.0
|
||||
|
||||
# Copyright 2011, Jim Miller
|
||||
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os, zipfile, sys
|
||||
from glob import glob
|
||||
|
||||
def addFolderToZip(myZipFile,folder,exclude=[]):
|
||||
folder = folder.encode('ascii') #convert path to ascii for ZipFile Method
|
||||
excludelist=[]
|
||||
for ex in exclude:
|
||||
excludelist.extend(glob(folder+"/"+ex))
|
||||
for file in glob(folder+"/*"):
|
||||
if file in excludelist:
|
||||
continue
|
||||
if os.path.isfile(file):
|
||||
#print file
|
||||
myZipFile.write(file, file, zipfile.ZIP_DEFLATED)
|
||||
elif os.path.isdir(file):
|
||||
addFolderToZip(myZipFile,file,exclude=exclude)
|
||||
|
||||
def createZipFile(filename,mode,files,exclude=[]):
|
||||
myZipFile = zipfile.ZipFile( filename, mode ) # Open the zip file for writing
|
||||
excludelist=[]
|
||||
for ex in exclude:
|
||||
excludelist.extend(glob(ex))
|
||||
for file in files:
|
||||
if file in excludelist:
|
||||
continue
|
||||
file = file.encode('ascii') #convert path to ascii for ZipFile Method
|
||||
if os.path.isfile(file):
|
||||
(filepath, filename) = os.path.split(file)
|
||||
#print file
|
||||
myZipFile.write( file, filename, zipfile.ZIP_DEFLATED )
|
||||
if os.path.isdir(file):
|
||||
addFolderToZip(myZipFile,file,exclude=exclude)
|
||||
myZipFile.close()
|
||||
return (1,filename)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# Copyright 2012 Fanficdownloader team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
[defaults]
|
||||
|
||||
## [defaults] section applies to all formats and sites but may be
|
||||
## overridden at several levels
|
||||
|
||||
## Some sites also require the user to confirm they are adult for
|
||||
## adult content. In commandline version, this should go in your
|
||||
## personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
## All available titlepage_entries and the label used for them:
|
||||
## <entryname>_label:<label>
|
||||
## Labels may be customized.
|
||||
title_label:Title
|
||||
storyUrl_label:Story URL
|
||||
description_label:Summary
|
||||
author_label:Author
|
||||
authorUrl_label:Author URL
|
||||
## epub, txt, html
|
||||
formatname_label:File Format
|
||||
## .epub, .txt, .html
|
||||
formatext_label:File Extension
|
||||
## Category and Genre have overlap, depending on the site.
|
||||
## Sometimes Harry Potter is a category and Fantasy a genre. (fanfiction.net)
|
||||
## Sometimes Fantasy is category *and* a genre (fictionpress.com)
|
||||
## Sometimes there are multiple categories and/or genres.
|
||||
category_label:Category
|
||||
genre_label:Genre
|
||||
language_label:Language
|
||||
characters_label:Characters
|
||||
series_label:Series
|
||||
## Completed/In-Progress
|
||||
status_label:Status
|
||||
## Dates story first published, last updated, and downloaded(last with time).
|
||||
datePublished_label:Published
|
||||
dateUpdated_label:Updated
|
||||
dateCreated_label:Packaged
|
||||
## Rating depends on the site. Some use K,T,M,etc, and some PG,R,NC-17
|
||||
rating_label:Rating
|
||||
## Also depends on the site.
|
||||
warnings_label:Warnings
|
||||
numChapters_label:Chapters
|
||||
numWords_label:Words
|
||||
## www.fanfiction.net, fictionalley.com, etc.
|
||||
site_label:Publisher
|
||||
## ffnet, fpcom, etc.
|
||||
siteabbrev_label:Site Abbrev
|
||||
## The site's unique story/author identifier. Usually a number.
|
||||
storyId_label:Story ID
|
||||
authorId_label:Author ID
|
||||
## Primarily to put specific values in dc:subject tags for epub. Will
|
||||
## show up in Calibre as tags. Also carried into mobi when converted.
|
||||
extratags_label:Extra Tags
|
||||
## The version of fanficdownloader
|
||||
##
|
||||
version_label:FFDL Version
|
||||
|
||||
## items to include in the title page
|
||||
## Empty entries will *not* appear, even if in the list.
|
||||
## All current formats already include title and author.
|
||||
titlepage_entries: series,category,genre,language,characters,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,numWords,site,description
|
||||
|
||||
## Try to collect series name and number of this story in series.
|
||||
## Some sites (ab)use 'series' for reading lists and personal
|
||||
## collections. This lets us turn it on and off by site without
|
||||
## keeping a lengthy titlepage_entries per site and prevents it
|
||||
## updating in the plugin.
|
||||
collect_series: true
|
||||
|
||||
## include title page as first page.
|
||||
include_titlepage: true
|
||||
|
||||
## include a TOC page before the story text
|
||||
include_tocpage: true
|
||||
|
||||
## website encoding(s) In theory, each website reports the character
|
||||
## encoding they use for each page. In practice, some sites report it
|
||||
## incorrectly. Each adapter has a default list, usually "utf8,
|
||||
## Windows-1252" or "Windows-1252, utf8", but this will let you
|
||||
## explicitly set the encoding and order if you need to. The special
|
||||
## value 'auto' will call chardet and use the encoding it reports if
|
||||
## it has +90% confidence. 'auto' is not reliable.
|
||||
#website_encodings: auto, utf8, Windows-1252
|
||||
|
||||
## entries to make epub subjects and calibre tags
|
||||
## lastupdate creates two tags: "Last Update Year/Month: %Y/%m" and "Last Update: %Y/%m/%d"
|
||||
include_subject_tags: extratags, genre, category, characters, status
|
||||
|
||||
## extra tags (comma separated) to include, primarily for epub.
|
||||
extratags: FanFiction
|
||||
|
||||
## number of seconds to sleep between calls to the story site. May by
|
||||
## useful if pulling large numbers of stories or if the site is slow.
|
||||
#slow_down_sleep_time:0.5
|
||||
|
||||
## Use regular expressions to find and replace (or remove) metadata.
|
||||
## For example, you could change Sci-Fi=>SF, remove *-Centered tags,
|
||||
## etc. See http://docs.python.org/library/re.html (look for re.sub)
|
||||
## for regexp details.
|
||||
## Make sure to keep at least one space at the start of each line and
|
||||
## to escape % to %%, if used.
|
||||
#replace_metadata:
|
||||
# Sci-Fi=>SF
|
||||
# Puella Magi Madoka Magica.* => Madoka
|
||||
# Comedy=>Humor
|
||||
# Crossover: (.*)=>\1
|
||||
# (.*)Great(.*)=>\1Moderate\2
|
||||
# .*-Centered=>
|
||||
|
||||
## Each output format has a section that overrides [defaults]
|
||||
[html]
|
||||
|
||||
## output background color--only used by html and epub (and ignored in
|
||||
## epub by many readers). Included below in output_css--will be
|
||||
## ignored if not in output_css.
|
||||
background_color: ffffff
|
||||
|
||||
## Allow customization of CSS. Make sure to keep at least one space
|
||||
## at the start of each line and to escape % to %%. Also need
|
||||
## background_color to be in the same section, if included in CSS.
|
||||
output_css:
|
||||
body { background-color: #%(background_color)s; }
|
||||
.CI {
|
||||
text-align:center;
|
||||
margin-top:0px;
|
||||
margin-bottom:0px;
|
||||
padding:0px;
|
||||
}
|
||||
.center {text-align: center;}
|
||||
.cover {text-align: center;}
|
||||
.full {width: 100%%; }
|
||||
.quarter {width: 25%%; }
|
||||
.smcap {font-variant: small-caps;}
|
||||
.u {text-decoration: underline;}
|
||||
.bold {font-weight: bold;}
|
||||
|
||||
[txt]
|
||||
## Add URLs since there aren't links.
|
||||
titlepage_entries: series,category,genre,language,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,numWords,site,storyUrl, authorUrl, description
|
||||
|
||||
## use \r\n for line endings, the windows convention. text output only.
|
||||
windows_eol: true
|
||||
|
||||
[epub]
|
||||
|
||||
## epub carries the TOC in metadata.
|
||||
## mobi generated from epub will have a TOC at the end.
|
||||
include_tocpage: false
|
||||
|
||||
## epub->mobi conversions typically don't like tables.
|
||||
titlepage_use_table: false
|
||||
|
||||
## When using tables, make these span both columns.
|
||||
wide_titlepage_entries: description, storyUrl, author URL
|
||||
|
||||
## output background color--only used by html and epub (and ignored in
|
||||
## epub by many readers). Included below in output_css--will be
|
||||
## ignored if not in output_css.
|
||||
background_color: ffffff
|
||||
|
||||
## Allow customization of CSS. Make sure to keep at least one space
|
||||
## at the start of each line and to escape % to %%. Also need
|
||||
## background_color to be in the same section, if included in CSS.
|
||||
output_css:
|
||||
body { background-color: #%(background_color)s;
|
||||
text-align: justify;
|
||||
margin: 2%%; }
|
||||
pre { font-size: x-small; }
|
||||
sml { font-size: small; }
|
||||
h1 { text-align: center; }
|
||||
h2 { text-align: center; }
|
||||
h3 { text-align: center; }
|
||||
h4 { text-align: center; }
|
||||
h5 { text-align: center; }
|
||||
h6 { text-align: center; }
|
||||
.CI {
|
||||
text-align:center;
|
||||
margin-top:0px;
|
||||
margin-bottom:0px;
|
||||
padding:0px;
|
||||
}
|
||||
.center {text-align: center;}
|
||||
.cover {text-align: center;}
|
||||
.full {width: 100%%; }
|
||||
.quarter {width: 25%%; }
|
||||
.smcap {font-variant: small-caps;}
|
||||
.u {text-decoration: underline;}
|
||||
.bold {font-weight: bold;}
|
||||
|
||||
## include images from img tags in the body and summary of
|
||||
## stories. Images will be converted to jpg for size if possible.
|
||||
#include_images:false
|
||||
|
||||
## If not set, the summary will have all html stripped for safety.
|
||||
## Both this and include_images must be true to get images in the
|
||||
## summary.
|
||||
#keep_summary_html:false
|
||||
|
||||
## If set, the first image found will be made the cover image. If
|
||||
## keep_summary_html is true, any images in summary will be before any
|
||||
## in chapters.
|
||||
#make_firstimage_cover: false
|
||||
|
||||
## If set, and there isn't already a cover image from the adapter or
|
||||
## from make_firstimage_cover, this image will be made the cover.
|
||||
## It can be either a 'file:' or 'http:' url.
|
||||
## Note that if you enable make_firstimage_cover in [epub], but want
|
||||
## to use default_cover_image for a specific site, use the site:format
|
||||
## section, for example: [www.ficwad.com:epub]
|
||||
#default_cover_image:file:///C:/Users/username/Desktop/nook/images/icon.png
|
||||
#default_cover_image:http://www.somesite.com/someimage.gif
|
||||
|
||||
## Resize images down to width, height, preserving aspect ratio.
|
||||
## Nook size, with margin.
|
||||
image_max_size: 580, 725
|
||||
|
||||
## Change image to grayscale, if graphics library allows, to save
|
||||
## space.
|
||||
#grayscale_images: false
|
||||
|
||||
[mobi]
|
||||
## mobi TOC cannot be turned off right now.
|
||||
#include_tocpage: true
|
||||
|
||||
## Each site has a section that overrides [defaults] *and* the format
|
||||
## sections test1.com specifically is not a real story site. Instead,
|
||||
## it is a fake site for testing configuration and output. It uses
|
||||
## URLs like: http://test1.com?sid=12345
|
||||
[test1.com]
|
||||
extratags: FanFiction,Testing
|
||||
|
||||
## If necessary, you can define [<site>:<format>] sections to
|
||||
## customize the formats differently for the same site. Overrides
|
||||
## defaults, format and site.
|
||||
[test1.com:txt]
|
||||
extratags: FanFiction,Testing,Text
|
||||
|
||||
[test1.com:html]
|
||||
extratags: FanFiction,Testing,HTML
|
||||
|
||||
[www.fanfiction.net]
|
||||
|
||||
[www.fictionpress.com]
|
||||
## Clear FanFiction from defaults, fictionpress.com is original fiction.
|
||||
extratags:
|
||||
|
||||
[www.ficwad.com]
|
||||
## Some sites require login (or login for some rated stories) The
|
||||
## program can prompt you, or you can save it in config. In
|
||||
## commandline version, this should go in your personal.ini, not
|
||||
## defaults.ini.
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
[www.twilighted.net]
|
||||
## Some sites require login (or login for some rated stories) The
|
||||
## program can prompt you, or you can save it in config. In
|
||||
## commandline version, this should go in your personal.ini, not
|
||||
## defaults.ini.
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
## twilighted.net (ab)uses series as personal reading lists.
|
||||
collect_series: false
|
||||
|
||||
[www.twiwrite.net]
|
||||
## Some sites require login (or login for some rated stories) The
|
||||
## program can prompt you, or you can save it in config. In
|
||||
## commandline version, this should go in your personal.ini, not
|
||||
## defaults.ini.
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
## twiwrite.net (ab)uses series as personal reading lists.
|
||||
collect_series: false
|
||||
|
||||
[www.whofic.com]
|
||||
|
||||
[www.mediaminer.org]
|
||||
|
||||
[www.thewriterscoffeeshop.com]
|
||||
## Some sites require login (or login for some rated stories) The
|
||||
## program can prompt you, or you can save it in config. In
|
||||
## commandline version, this should go in your personal.ini, not
|
||||
## defaults.ini.
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
## Some sites also require the user to confirm they are adult for
|
||||
## adult content. In commandline version, this should go in your
|
||||
## personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
## thewriterscoffeeshop.com (ab)uses series as personal reading lists.
|
||||
collect_series: false
|
||||
|
||||
[www.ficwad.com]
|
||||
## Some sites require login (or login for some rated stories) The
|
||||
## program can prompt you, or you can save it in config. In
|
||||
## commandline version, this should go in your personal.ini, not
|
||||
## defaults.ini.
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
[www.adastrafanfic.com]
|
||||
## Some sites do not require a login, but do require the user to
|
||||
## confirm they are adult for adult content. In commandline version,
|
||||
## this should go in your personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
[www.fictionalley.org]
|
||||
## Some sites do not require a login, but do require the user to
|
||||
## confirm they are adult for adult content. In commandline version,
|
||||
## this should go in your personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
[www.harrypotterfanfiction.com]
|
||||
## Some sites do not require a login, but do require the user to
|
||||
## confirm they are adult for adult content. In commandline version,
|
||||
## this should go in your personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
[www.fimfiction.net]
|
||||
## Some sites do not require a login, but do require the user to
|
||||
## confirm they are adult for adult content. In commandline version,
|
||||
## this should go in your personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
[www.tthfanfic.org]
|
||||
## Some sites do not require a login, but do require the user to
|
||||
## confirm they are adult for adult content. In commandline version,
|
||||
## this should go in your personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
## tth is a little unusual--it doesn't require user/pass, but the site
|
||||
## keeps track of which chapters you've read and won't send another
|
||||
## update until it thinks you're up to date. This way, on download,
|
||||
## it thinks you're up to date.
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
[overrides]
|
||||
## It may sometimes be useful to override all of the specific format,
|
||||
## site and site:format sections in your private configuration. For
|
||||
## example, this extratags param here would override all of the
|
||||
## extratags params in all other sections. Only commandline options
|
||||
## beat overrides.
|
||||
#extratags:fanficdownloader
|
||||
@@ -0,0 +1,74 @@
|
||||
## This is an example of what your personal configuration might look
|
||||
## like.
|
||||
|
||||
[defaults]
|
||||
## Some sites also require the user to confirm they are adult for
|
||||
## adult content. In commandline version, this should go in your
|
||||
## personal.ini, not defaults.ini.
|
||||
#is_adult:true
|
||||
|
||||
## Try to collect series name and number of this story in series.
|
||||
## Some sites (ab)use 'series' for reading lists and personal
|
||||
## collections. This lets us turn it on and off by site without
|
||||
## keeping a lengthy titlepage_entries per site and prevents it
|
||||
## updating in the plugin.
|
||||
## Turn off in [defaults] or [overrides] to prevent all sites from
|
||||
## updating series column.
|
||||
## default is true
|
||||
#collect_series: false
|
||||
|
||||
## Most common, I expect will be using this to save username/passwords
|
||||
## for different sites.
|
||||
[www.twilighted.net]
|
||||
#username:YourPenname
|
||||
#password:YourPassword
|
||||
## default is false
|
||||
#collect_series: true
|
||||
|
||||
[www.ficwad.com]
|
||||
#username:YourUsername
|
||||
#password:YourPassword
|
||||
|
||||
[www.twiwrite.net]
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
## default is false
|
||||
#collect_series: true
|
||||
|
||||
[www.adastrafanfic.com]
|
||||
## Some sites do not require a login, but do require the user to
|
||||
## confirm they are adult for adult content.
|
||||
#is_adult:true
|
||||
|
||||
[www.thewriterscoffeeshop.com]
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
#is_adult:true
|
||||
## default is false
|
||||
#collect_series: true
|
||||
|
||||
[www.fictionalley.org]
|
||||
#is_adult:true
|
||||
|
||||
[www.harrypotterfanfiction.com]
|
||||
#is_adult:true
|
||||
|
||||
[www.fimfiction.net]
|
||||
#is_adult:true
|
||||
|
||||
[www.tthfanfic.org]
|
||||
#is_adult:true
|
||||
## tth is a little unusual--it doesn't require user/pass, but the site
|
||||
## keeps track of which chapters you've read and won't send another
|
||||
## update until it thinks you're up to date. This way, on download,
|
||||
## it thinks you're up to date.
|
||||
#username:YourName
|
||||
#password:yourpassword
|
||||
|
||||
|
||||
## This section will override anything in the system defaults or other
|
||||
## sections here.
|
||||
[overrides]
|
||||
## default varies by site. Set true here to force all sites to
|
||||
## collect series.
|
||||
#collect_series: true
|
||||
+3
-12
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<link href="/css/index.css" rel="stylesheet" type="text/css">
|
||||
<title>Fanfiction Downloader (fanfiction.net, fanficauthors, fictionalley, ficwad to epub and HTML)</title>
|
||||
<title>FanFictionDownLoader (fanfiction.net, fanficauthors, fictionalley, ficwad to epub and HTML)</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<script type="text/javascript">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<body>
|
||||
<div id='main'>
|
||||
<h1>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFiction Downloader</a>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFictionDownLoader</a>
|
||||
</h1>
|
||||
|
||||
<script type="text/javascript"><!--
|
||||
@@ -43,18 +43,9 @@
|
||||
<div id='greeting'>
|
||||
<p>Hi, {{ nickname }}! These are the fanfics you've recently requested.</p>
|
||||
<p><a href="/clearrecent">Clear your Recent Downloads List</a></p>
|
||||
<h3>Please Switch to <a href="http://fanfictiondownloader.appspot.com/">New Version</a></h3>
|
||||
<p>
|
||||
We have a new, more efficient, version of the system up now. Please start using the
|
||||
<a href="http://fanfictiondownloader.appspot.com/">New
|
||||
Version here</a>. If for some reason, the new version
|
||||
doesn't work for you, please let us know on
|
||||
the <a href="http://groups.google.com/group/fanfic-downloader">Fanfiction
|
||||
Downloader Google Group</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id='helpbox'>
|
||||
{% for fic in fics %}
|
||||
<p>
|
||||
|
||||
+4
-12
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<link href="/css/index.css" rel="stylesheet" type="text/css">
|
||||
<title>{% if fic.completed %} Finished {% else %} {% if fic.failure %} Failed {% else %} Working... {% endif %} {% endif %} - Fanfiction Downloader</title>
|
||||
<title>{% if fic.completed %} Finished {% else %} {% if fic.failure %} Failed {% else %} Working... {% endif %} {% endif %} - FanFictionDownLoader</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="google-site-verification" content="kCFc-G4bka_pJN6Rv8CapPBcwmq0hbAUZPkKWqRsAYU" />
|
||||
{% if not fic.completed and not fic.failure %}
|
||||
@@ -25,7 +25,7 @@
|
||||
<body>
|
||||
<div id='main'>
|
||||
<h1>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFiction Downloader</a>
|
||||
<a href="/" style="text-decoration: none; color: black;">FanFictionDownLoader</a>
|
||||
</h1>
|
||||
<div style="text-align: center">
|
||||
<script type="text/javascript"><!--
|
||||
@@ -66,21 +66,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<p>See your personal list of <a href="/recent">previously downloaded fanfics</a>.</p>
|
||||
<h3>Please Switch to <a href="http://fanfictiondownloader.appspot.com/">New Version</a></h3>
|
||||
<p>
|
||||
We have a new, more efficient, version of the system up now. Please start using the
|
||||
<a href="http://fanfictiondownloader.appspot.com/">New
|
||||
Version here</a>. If for some reason, the new version
|
||||
doesn't work for you, please let us know on
|
||||
the <a href="http://groups.google.com/group/fanfic-downloader">Fanfiction
|
||||
Downloader Google Group</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style='text-align: center'>
|
||||
<img src="http://code.google.com/appengine/images/appengine-silver-120x30.gif"
|
||||
alt="Powered by Google App Engine" />
|
||||
<br/><br/>
|
||||
FanfictionLoader is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">fanficdownloader</a><br/>
|
||||
This is a web front-end to <A href="http://code.google.com/p/fanficdownloader/">FanFictionDownLoader</a><br/>
|
||||
Copyright © Fanficdownloader team
|
||||
</div>
|
||||
|
||||
|
||||
+11
-14
@@ -25,15 +25,16 @@ Copyright 2011 Fanficdownloader team
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from google.appengine.ext.webapp import util
|
||||
from google.appengine.ext import webapp
|
||||
#from google.appengine.ext.webapp import util
|
||||
import webapp2
|
||||
#from google.appengine.ext import webapp
|
||||
from google.appengine.api import users
|
||||
from google.appengine.api import taskqueue
|
||||
from google.appengine.api import memcache
|
||||
|
||||
from ffstorage import *
|
||||
|
||||
class Remover(webapp.RequestHandler):
|
||||
class Remover(webapp2.RequestHandler):
|
||||
def get(self):
|
||||
logging.debug("Starting r3m0v3r")
|
||||
user = users.get_current_user()
|
||||
@@ -56,9 +57,10 @@ class Remover(webapp.RequestHandler):
|
||||
logging.debug('Delete '+d.url)
|
||||
|
||||
logging.info('Deleted instances: %d' % num)
|
||||
self.response.headers['Content-Type'] = 'text/html'
|
||||
self.response.out.write('Deleted instances: %d<br>' % num)
|
||||
|
||||
class RemoveOrphanDataChunks(webapp.RequestHandler):
|
||||
class RemoveOrphanDataChunks(webapp2.RequestHandler):
|
||||
|
||||
def get(self):
|
||||
logging.debug("Starting RemoveOrphanDataChunks")
|
||||
@@ -98,15 +100,10 @@ class RemoveOrphanDataChunks(webapp.RequestHandler):
|
||||
memcache.set('orphan_search_cursor',chunks.cursor())
|
||||
|
||||
logging.info('Deleted %d orphan chunks from %d total.' % (deleted,num))
|
||||
self.response.headers['Content-Type'] = 'text/html'
|
||||
self.response.out.write('Deleted %d orphan chunks from %d total.' % (deleted,num))
|
||||
|
||||
def main():
|
||||
application = webapp.WSGIApplication([('/r3m0v3r', Remover),
|
||||
('/r3m0v3rOrphans', RemoveOrphanDataChunks)],
|
||||
debug=False)
|
||||
util.run_wsgi_app(application)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
main()
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
app = webapp2.WSGIApplication([('/r3m0v3r', Remover),
|
||||
('/r3m0v3rOrphans', RemoveOrphanDataChunks)],
|
||||
debug=False)
|
||||
|
||||
+8
-13
@@ -18,15 +18,16 @@
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from google.appengine.ext.webapp import util
|
||||
from google.appengine.ext import webapp
|
||||
#from google.appengine.ext.webapp import util
|
||||
import webapp2
|
||||
#from google.appengine.ext import webapp
|
||||
from google.appengine.api import users
|
||||
from google.appengine.api import taskqueue
|
||||
from google.appengine.api import memcache
|
||||
|
||||
from ffstorage import *
|
||||
|
||||
class Tally(webapp.RequestHandler):
|
||||
class Tally(webapp2.RequestHandler):
|
||||
def get(self):
|
||||
logging.debug("Starting Tally")
|
||||
user = users.get_current_user()
|
||||
@@ -57,13 +58,7 @@ class Tally(webapp.RequestHandler):
|
||||
logging.info('Tallied %d fics.' % num)
|
||||
self.response.out.write('<br/>Tallied %d fics.<br/>' % num)
|
||||
|
||||
def main():
|
||||
application = webapp.WSGIApplication([('/tally', Tally),
|
||||
],
|
||||
debug=False)
|
||||
util.run_wsgi_app(application)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
main()
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
app = webapp2.WSGIApplication([('/tally', Tally),
|
||||
],
|
||||
debug=False)
|
||||
|
||||
Reference in New Issue
Block a user