Compare commits

...
36 Commits
Author SHA1 Message Date
Jim Miller f181ff2c03 Bump versions 2015-09-17 10:50:46 -05:00
Jim Miller 389a04ce81 Update example URLs for mediaminer.org. 2015-09-17 10:46:56 -05:00
Jim Miller 787ebbdb53 Allow forum.questionablequesting.com now as well as questionablequesting.com. 2015-09-16 11:35:06 -05:00
Jim Miller cc6b31427e Update for mediaminer.org story URL changes. 2015-09-14 12:33:06 -05:00
Jim Miller af9679a290 Update translation files. 2015-09-14 10:22:20 -05:00
Jim Miller c18a972b12 Set 0/N as initial LoopProgressDialog display. 2015-09-09 09:02:18 -05:00
Jim Miller 10d297d77f Change plugin to deal adapters that can change story URL due to being given chapter url. 2015-09-09 08:44:57 -05:00
Jim Miller df10e539a9 Add test cases that change story URL. 2015-09-09 08:21:39 -05:00
Jim Miller 16a090c019 Change story URL when given chapter URL instead of story start URL. 2015-09-09 08:18:29 -05:00
Jim Miller 5472b21447 Fix mediaminer.org for site changes. 2015-09-08 09:31:59 -05:00
Jim Miller 0e17e15466 Stop logging a common html parsing error that we're ignoring anyway. 2015-09-08 09:31:06 -05:00
Jim Miller e30bd1adcc Add forumtags to [base_xenforoforum], include in title page, but not subjects by default. 2015-09-08 09:30:05 -05:00
Jim Miller c47bf27689 Skip output_filename_safepattern on formatext so .epub doesn't become _epub. 2015-09-08 08:47:25 -05:00
Jim Miller 84a308289c Change output_filename_safepattern to replace leading '.' for benefit of *nix OSes. 2015-09-08 08:03:10 -05:00
Jim Miller 94b44c9519 Record forum thread tags in base_xenforoforum. 2015-09-04 12:30:59 -05:00
Jim Miller 1c431cd972 Fix eFiction Base Author URLs. 2015-09-03 11:50:58 -05:00
Jim Miller 0233404429 Remove some debug output from epubutils.py 2015-09-02 08:36:21 -05:00
Jim Miller ca2d2ff0be Fix fall back for FimF cover image when there's a 'medium' but not a 'large' available. 2015-09-02 08:35:59 -05:00
Jim Miller 0b4fc8b138 On UnNew of an anthology, also update individiual toc.ncx files used for EpubMerge UnMerge. 2015-08-31 15:20:25 -05:00
Jim Miller 29598b1306 Convert dark-solace.org to eFiction Base adapter to fix user login problem. 2015-08-31 13:52:47 -05:00
Jim Miller 4990368ac5 Switch chapter #post-12345 URLs to permalinks. 2015-08-31 10:46:33 -05:00
Jim Miller ef3969b33b Merge pull request #106 from cryzed/master
Added adapter for quotev.com - thanks cryzed!
2015-08-31 08:34:00 -05:00
cryzed 4cb69a0a3c Parse additional metadata with the quotev adapter 2015-08-31 14:18:06 +02:00
cryzed 3488c54f3d Added adapter for quotev.com 2015-08-31 06:05:25 +02:00
Jim Miller ee211a362a Fix missing , so mark_new_chapters doesn't show red editing personal.ini. 2015-08-29 08:31:18 -05:00
Jim Miller ddc957d1b7 Explicitly set publisher=None to avoid occasional problems. 2015-08-29 08:30:37 -05:00
Jim Miller e677dc063d Update translations, bump versions. 2015-08-17 13:31:56 -05:00
Jim Miller 1079713432 Add another tweak to xenforum title/tag processing. 2015-08-17 13:27:14 -05:00
Jim Miller ff0ed8c9a2 Tweak xenforum tagsfromtitle 2015-08-11 11:09:43 -05:00
Jim Miller a5c731e471 Add additional check for check_next_chapter on ffnet when site it down. 2015-08-11 10:52:54 -05:00
Jim Miller dafbac5c9b Add minor fix for portkey.org chapter parsing. 2015-08-09 16:24:27 -05:00
Jim Miller 668fca6f6e Add DESCRIPTION.rst explicitly to pip package. Was in whl file, but not source zip. 2015-08-05 09:05:57 -05:00
Jim Miller e97f03a680 Add DESCRIPTION.rst explicitly to pip package. Was in whl file, but not source zip. 2015-08-05 09:03:16 -05:00
Jim Miller 0c272bcf36 Change dramione.org default encoding to utf8. 2015-08-05 08:58:24 -05:00
Jim Miller 58eca7efff Don't require trailing slash on base_xenforoforum. 2015-08-03 17:07:14 -05:00
Jim Miller 5b31f55b18 Add questionablequesting.com as another base_xenforoforum adapter. 2015-08-03 10:17:23 -05:00
35 changed files with 823 additions and 848 deletions
+1
View File
@@ -0,0 +1 @@
include DESCRIPTION.rst
+1 -1
View File
@@ -42,7 +42,7 @@ class FanFicFareBase(InterfaceActionBase):
description = _('UI plugin to download FanFiction stories from various sites.')
supported_platforms = ['windows', 'osx', 'linux']
author = 'Jim Miller'
version = (2, 2, 11)
version = (2, 2, 13)
minimum_calibre_version = (1, 48, 0)
#: This field defines the GUI plugin class that contains all the code
+101 -98
View File
@@ -117,7 +117,7 @@ save_collisions={
SAVE_CALIBREONLY:CALIBREONLY,
SAVE_CALIBREONLYSAVECOL:CALIBREONLYSAVECOL,
}
anthology_collision_order=[UPDATE,
UPDATEALWAYS,
OVERWRITEALWAYS]
@@ -127,7 +127,7 @@ gpstyle='QGroupBox {border:0; padding-top:10px; padding-bottom:0px; margin-botto
class RejectUrlEntry:
matchpat=re.compile(r"^(?P<url>[^,]+?)(,(?P<fullnote>(((?P<title>.+?) by (?P<auth>.+?)( - (?P<note>.+))?)|.*)))?$")
def __init__(self,url_or_line,note=None,title=None,auth=None,
addreasontext=None,fromline=False,book_id=None):
@@ -151,7 +151,7 @@ class RejectUrlEntry:
self.note=note
self.title=title
self.auth=auth
if not self.note:
if addreasontext:
self.note = addreasontext
@@ -160,14 +160,14 @@ class RejectUrlEntry:
else:
if addreasontext:
self.note = self.note + ' - ' + addreasontext
self.url = getNormalStoryURL(self.url)
self.valid = self.url != None
def to_line(self):
# always 'url,'
return self.url+","+self.fullnote()
def fullnote(self):
retval = ""
if self.title and self.auth:
@@ -175,10 +175,10 @@ class RejectUrlEntry:
retval = retval + "%s by %s"%(self.title,self.auth)
if self.note:
retval = retval + " - "
if self.note:
retval = retval + self.note
return retval
class NotGoingToDownload(Exception):
@@ -222,7 +222,7 @@ class DroppableQTextEdit(QTextEdit):
self.append("\n".join(urllist))
return None
return QTextEdit.dropEvent(self,event)
def canInsertFromMimeData(self, source):
if source.hasUrls():
return True
@@ -234,7 +234,7 @@ class DroppableQTextEdit(QTextEdit):
self.append(source.text())
else:
return QTextEdit.insertFromMimeData(self, source)
class AddNewDialog(SizePersistedDialog):
go_signal = pyqtSignal(object, object, object, object)
@@ -242,7 +242,7 @@ class AddNewDialog(SizePersistedDialog):
def __init__(self, gui, prefs, icon):
SizePersistedDialog.__init__(self, gui, 'fff:add new dialog')
self.prefs = prefs
self.setMinimumWidth(300)
self.l = QVBoxLayout()
self.setLayout(self.l)
@@ -259,7 +259,7 @@ class AddNewDialog(SizePersistedDialog):
self.merge = self.newmerge = False
self.extraoptions = {}
# elements to hide when doing merge.
self.mergehide = []
# elements to show again when doing *update* merge
@@ -282,11 +282,11 @@ class AddNewDialog(SizePersistedDialog):
self.gbf.setVisible(False)
self.groupbox.toggled.connect(self.gbf.setVisible)
horz = QHBoxLayout()
label = QLabel(_('Output &Format:'))
self.mergehide.append(label)
self.fileform = QComboBox(self)
self.fileform.addItem('epub')
self.fileform.addItem('mobi')
@@ -294,7 +294,7 @@ class AddNewDialog(SizePersistedDialog):
self.fileform.addItem('txt')
self.fileform.setToolTip(_('Choose output format to create. May set default from plugin configuration.'))
self.fileform.activated.connect(self.set_collisions)
horz.addWidget(label)
label.setBuddy(self.fileform)
horz.addWidget(self.fileform)
@@ -332,7 +332,7 @@ class AddNewDialog(SizePersistedDialog):
self.updateepubcover.setChecked(self.prefs['updateepubcover'])
horz.addWidget(self.updateepubcover)
self.mergehide.append(self.updateepubcover)
self.gbl.addLayout(horz)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
@@ -340,7 +340,7 @@ class AddNewDialog(SizePersistedDialog):
self.button_box.rejected.connect(self.reject)
self.l.addWidget(self.button_box)
# invoke the
# invoke the
def ok_clicked(self):
self.dialog_closing(None) # save persistent size.
self.hide()
@@ -376,7 +376,7 @@ class AddNewDialog(SizePersistedDialog):
self.extrapayload = extrapayload
self.groupbox.setVisible(not(self.merge and self.newmerge))
if self.merge:
self.toplabel.setText(_('Story URLs for anthology, one per line:'))
self.url.setToolTip(_('URLs for stories to include in the anthology, one per line.\nWill take URLs from clipboard, but only valid URLs.'))
@@ -398,7 +398,7 @@ class AddNewDialog(SizePersistedDialog):
# Need to re-able after hiding/showing
self.setAcceptDrops(True)
self.url.setFocus()
if self.prefs['adddialogstaysontop']:
QDialog.setWindowFlags ( self, Qt.Dialog | Qt.WindowStaysOnTopHint )
else:
@@ -415,18 +415,18 @@ class AddNewDialog(SizePersistedDialog):
i = self.collision.findText(save_collisions[self.prefs['collision']])
if i > -1:
self.collision.setCurrentIndex(i)
self.updatemeta.setChecked(self.prefs['updatemeta'])
if not self.merge:
self.updateepubcover.setChecked(self.prefs['updateepubcover'])
self.url.setText(url_list_text)
if url_list_text:
self.button_box.button(QDialogButtonBox.Ok).setFocus()
# restore saved size.
self.resize_dialog()
if show: # so anthology update can be modal still.
self.show()
#self.resize(self.sizeHint())
@@ -447,11 +447,11 @@ class AddNewDialog(SizePersistedDialog):
for o in order:
self.collision.addItem(o)
i = self.collision.findText(prev)
if i > -1:
self.collision.setCurrentIndex(i)
def get_fff_options(self):
retval = {
'fileform': unicode(self.fileform.currentText()),
@@ -460,14 +460,14 @@ class AddNewDialog(SizePersistedDialog):
'updateepubcover': self.updateepubcover.isChecked(),
'smarten_punctuation':self.prefs['smarten_punctuation']
}
if self.merge:
retval['fileform']=='epub'
retval['updateepubcover']=True
if self.newmerge:
retval['updatemeta']=True
retval['collision']=ADDNEW
return dict(retval.items() + self.extraoptions.items() )
def get_urlstext(self):
@@ -477,21 +477,21 @@ class AddNewDialog(SizePersistedDialog):
class FakeLineEdit():
def __init__(self):
pass
def text(self):
pass
class CollectURLDialog(SizePersistedDialog):
'''
Collect single url for get urls.
'''
def __init__(self, gui, title, url_text, anthology=False, indiv=True):
def __init__(self, gui, title, url_text, anthology=False, indiv=True):
SizePersistedDialog.__init__(self, gui, 'fff:get story urls')
self.status=False
self.anthology=False
self.setMinimumWidth(300)
self.l = QVBoxLayout()
self.setLayout(self.l)
@@ -561,16 +561,16 @@ class UserPassDialog(QDialog):
else:
self.setWindowTitle(_('User/Password'))
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)
@@ -616,6 +616,9 @@ class LoopProgressDialog(QProgressDialog):
from calibre_plugins.fanficfare_plugin.prefs import prefs
self.show_est_time = prefs['show_est_time']
self.setLabelText('%s %d / %d' % (self.status_prefix, self.i, len(self.book_list)))
self.setValue(self.i)
## 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)
@@ -642,7 +645,7 @@ class LoopProgressDialog(QProgressDialog):
## collision spec passed into getadapter by partial from fff_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)
@@ -653,10 +656,10 @@ class LoopProgressDialog(QProgressDialog):
book['comment']=unicode(e)
logger.error("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:
@@ -747,7 +750,7 @@ class UpdateExistingDialog(SizePersistedDialog):
self.prefs = prefs
self.setWindowTitle(header)
self.setWindowIcon(icon)
layout = QVBoxLayout(self)
self.setLayout(layout)
title_layout = ImageTitleLayout(self, 'images/icon.png',
@@ -790,7 +793,7 @@ class UpdateExistingDialog(SizePersistedDialog):
gbf.setVisible(False)
groupbox.toggled.connect(gbf.setVisible)
label = QLabel(_('Output &Format:'))
gbl.addWidget(label)
self.fileform = QComboBox(self)
@@ -803,7 +806,7 @@ class UpdateExistingDialog(SizePersistedDialog):
self.fileform.activated.connect(self.set_collisions)
label.setBuddy(self.fileform)
gbl.addWidget(self.fileform)
label = QLabel(_('Update Mode:'))
gbl.addWidget(label)
self.collision = QComboBox(self)
@@ -820,21 +823,21 @@ class UpdateExistingDialog(SizePersistedDialog):
self.updatemeta.setToolTip(_("Update metadata for existing stories in Calibre from web site?\n(Columns set to 'New Only' in the column tabs will only be set for new books.)"))
self.updatemeta.setChecked(self.prefs['updatemeta'])
gbl.addWidget(self.updatemeta)
self.updateepubcover = QCheckBox(_('Update EPUB Cover?'),self)
self.updateepubcover.setToolTip(_('Update book cover image from site or defaults (if found) <i>inside</i> the EPUB when EPUB is updated.'))
self.updateepubcover.setChecked(self.prefs['updateepubcover'])
gbl.addWidget(self.updateepubcover)
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)
@@ -850,14 +853,14 @@ class UpdateExistingDialog(SizePersistedDialog):
order.remove(UPDATEALWAYS)
if self.prefs['savemetacol'] == '':
order.remove(CALIBREONLYSAVECOL)
for o in order:
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()
@@ -924,16 +927,16 @@ class StoryListTableWidget(QTableWidget):
status_cell = IconWidgetItem(None,icon,val)
status_cell.setData(Qt.UserRole, val)
self.setItem(row, 0, status_cell)
title_cell = ReadOnlyTableWidgetItem(book['title'])
title_cell.setData(Qt.UserRole, row)
self.setItem(row, 1, title_cell)
self.setItem(row, 2, AuthorTableWidgetItem(", ".join(book['author']), ", ".join(book['author_sort'])))
url_cell = ReadOnlyTableWidgetItem(book['url'])
self.setItem(row, 3, url_cell)
comment_cell = ReadOnlyTableWidgetItem(book['comment'])
self.setItem(row, 4, comment_cell)
@@ -1011,9 +1014,9 @@ class RejectListTableWidget(QTableWidget):
self.setItem(row, 0, url_cell)
self.setItem(row, 1, ReadOnlyTableWidgetItem(rej.title))
self.setItem(row, 2, ReadOnlyTableWidgetItem(rej.auth))
note_cell = EditWithComplete(self,sort_func=lambda x:1)
items = [rej.note]+self.rejectreasons
note_cell.update_items_cache(items)
note_cell.show_initial_value(rej.note)
@@ -1021,7 +1024,7 @@ class RejectListTableWidget(QTableWidget):
note_cell.setToolTip(_('Select or Edit Reject Note.'))
self.setCellWidget(row, 3, note_cell)
note_cell.setCursorPosition(0)
def remove_selected_rows(self):
self.setFocus()
rows = self.selectionModel().selectedRows()
@@ -1053,10 +1056,10 @@ class RejectListDialog(SizePersistedDialog):
show_all_reasons=True,
save_size_name='fff:reject list dialog'):
SizePersistedDialog.__init__(self, gui, save_size_name)
self.setWindowTitle(header)
self.setWindowIcon(get_icon(icon))
layout = QVBoxLayout(self)
self.setLayout(layout)
title_layout = ImageTitleLayout(self, icon, header,
@@ -1072,7 +1075,7 @@ class RejectListDialog(SizePersistedDialog):
rejects_layout.addLayout(button_layout)
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 URLs from the list'))
self.remove_button.setIcon(get_icon('list_remove.png'))
@@ -1084,13 +1087,13 @@ class RejectListDialog(SizePersistedDialog):
if show_all_reasons:
self.reason_edit = EditWithComplete(self,sort_func=lambda x:1)
items = ['']+rejectreasons
self.reason_edit.update_items_cache(items)
self.reason_edit.show_initial_value('')
self.reason_edit.set_separator(None)
self.reason_edit.setToolTip(_("This will be added to whatever note you've set for each URL above."))
horz = QHBoxLayout()
label = QLabel(_("Add this reason to all URLs added:"))
label.setToolTip(_("This will be added to whatever note you've set for each URL above."))
@@ -1099,7 +1102,7 @@ class RejectListDialog(SizePersistedDialog):
self.reason_edit.setCursorPosition(0)
horz.insertStretch(-1)
layout.addLayout(horz)
options_layout = QHBoxLayout()
if show_delete:
@@ -1107,14 +1110,14 @@ class RejectListDialog(SizePersistedDialog):
self.deletebooks.setToolTip(_("Delete the selected books after adding them to the Rejected URLs list."))
self.deletebooks.setChecked(True)
options_layout.addWidget(self.deletebooks)
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.rejects_table.populate_table(reject_list)
@@ -1147,7 +1150,7 @@ class RejectListDialog(SizePersistedDialog):
except:
# doesn't have self.reason_edit when editing existing list.
return None
def get_deletebooks(self):
return self.deletebooks.isChecked()
@@ -1169,7 +1172,7 @@ class EditTextDialog(SizePersistedDialog):
if icon:
self.setWindowIcon(icon)
self.l.addWidget(self.label)
self.textedit = QTextEdit(self)
self.textedit.setLineWrapMode(QTextEdit.NoWrap)
self.textedit.setReadOnly(read_only)
@@ -1182,13 +1185,13 @@ class EditTextDialog(SizePersistedDialog):
if rejectreasons or reasonslabel:
self.reason_edit = EditWithComplete(self,sort_func=lambda x:1)
items = ['']+rejectreasons
self.reason_edit.update_items_cache(items)
self.reason_edit.show_initial_value('')
self.reason_edit.set_separator(None)
self.reason_edit.setToolTip(reasonslabel)
if reasonslabel:
horz = QHBoxLayout()
label = QLabel(reasonslabel)
@@ -1199,12 +1202,12 @@ class EditTextDialog(SizePersistedDialog):
else:
self.l.addWidget(self.reason_edit)
self.reason_edit.setCursorPosition(0)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
self.l.addWidget(button_box)
# Cause our dialog size to be restored from prefs or created on first usage
self.resize_dialog()
@@ -1213,7 +1216,7 @@ class EditTextDialog(SizePersistedDialog):
def get_reason_text(self):
return unicode(self.reason_edit.currentText()).strip()
class IniTextDialog(SizePersistedDialog):
def __init__(self, parent, text,
@@ -1223,9 +1226,9 @@ class IniTextDialog(SizePersistedDialog):
save_size_name='fff:ini text dialog',
):
SizePersistedDialog.__init__(self, parent, save_size_name)
self.keys=dict()
self.l = QVBoxLayout()
self.setLayout(self.l)
self.label = QLabel(label)
@@ -1234,7 +1237,7 @@ class IniTextDialog(SizePersistedDialog):
if icon:
self.setWindowIcon(icon)
self.l.addWidget(self.label)
self.textedit = QTextEdit(self)
highlighter = IniHighlighter(self.textedit,
@@ -1243,7 +1246,7 @@ class IniTextDialog(SizePersistedDialog):
entries=get_valid_entries(),
entry_keywords=get_valid_entry_keywords(),
)
self.textedit.setLineWrapMode(QTextEdit.NoWrap)
try:
self.textedit.setFont(QFont("Courier",
@@ -1261,41 +1264,41 @@ class IniTextDialog(SizePersistedDialog):
if use_find:
findtooltip=_('Search for string in edit box.')
horz = QHBoxLayout()
label = QLabel(_('Find:'))
label.setToolTip(findtooltip)
# Button to search the document for something
self.findButton = QtGui.QPushButton(_('Find'),self)
self.findButton.clicked.connect(self.find)
self.findButton.setToolTip(findtooltip)
# The field into which to type the query
self.findField = QLineEdit(self)
self.findField.setToolTip(findtooltip)
self.findField.returnPressed.connect(self.findButton.setFocus)
# Case Sensitivity option
self.caseSens = QtGui.QCheckBox(_('Case sensitive'),self)
self.caseSens.setToolTip(_("Search for case sensitive string; don't treat Harry, HARRY and harry all the same."))
horz.addWidget(label)
horz.addWidget(self.findField)
horz.addWidget(self.findButton)
horz.addWidget(self.caseSens)
self.l.addLayout(horz)
self.addCtrlKeyPress(QtCore.Qt.Key_F,self.findFocus)
self.addCtrlKeyPress(QtCore.Qt.Key_G,self.find)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
self.l.addWidget(button_box)
# Cause our dialog size to be restored from prefs or created on first usage
self.resize_dialog()
@@ -1311,9 +1314,9 @@ class IniTextDialog(SizePersistedDialog):
_('Go back to fix errors?'),
errors)
retry = d.exec_() == d.Accepted
# print("retry:%s"%retry)
if retry:
lineno=d.get_lineno()
if lineno:
@@ -1335,19 +1338,19 @@ class IniTextDialog(SizePersistedDialog):
return func()
else:
return SizePersistedDialog.keyPressEvent(self, event)
def get_plain_text(self):
return unicode(self.textedit.toPlainText())
def findFocus(self):
# print("findFocus called")
self.findField.setFocus()
self.findField.selectAll()
self.findField.selectAll()
def find(self):
#print("find self.lastStart:%s"%self.lastStart)
# Grab the parent's text
text = self.textedit.toPlainText()
@@ -1362,7 +1365,7 @@ class IniTextDialog(SizePersistedDialog):
# last starting position
self.lastStart = text.find(query,self.lastStart + 1)
# If the find() method didn't return -1 (not found)
if self.lastStart >= 0:
end = self.lastStart + len(query)
self.moveCursor(self.lastStart,end)
@@ -1370,7 +1373,7 @@ class IniTextDialog(SizePersistedDialog):
# Make the next search start from the begining again
self.lastStart = 0
self.textedit.moveCursor(self.textedit.textCursor().Start)
def moveCursor(self,start,end):
# We retrieve the QTextCursor object from the parent's QTextEdit
@@ -1387,7 +1390,7 @@ class IniTextDialog(SizePersistedDialog):
self.textedit.setTextCursor(cursor)
def select_line(self,lineno):
# We retrieve the QTextCursor object from the parent's QTextEdit
cursor = self.textedit.textCursor()
@@ -1413,8 +1416,8 @@ class ViewLog(SizePersistedDialog):
def get_lineno(self):
return self.lineno
def __init__(self, parent, title, errors,
def __init__(self, parent, title, errors,
save_size_name='fff:view log dialog',):
SizePersistedDialog.__init__(self, parent,save_size_name)
self.l = l = QVBoxLayout()
@@ -1435,9 +1438,9 @@ class ViewLog(SizePersistedDialog):
label.setToolTip(_('Click to go to line %s')%lineno)
label.mouseReleaseEvent = partial(self.label_clicked, lineno=lineno)
self.l.addWidget(label)
# html='<p>'+'</p><p>'.join([ '(lineno: %s) %s'%e for e in errors ])+'</p>'
# self.tb = QTextBrowser(self)
# self.tb.setFont(QFont("Courier",
# parent.font().pointSize()+1))
@@ -1455,13 +1458,13 @@ class ViewLog(SizePersistedDialog):
saveanyway = QPushButton(_('Save Anyway'), self)
saveanyway.clicked.connect(self.reject)
horz.addWidget(saveanyway)
l.addLayout(horz)
self.setModal(False)
self.setWindowTitle(title)
self.setWindowIcon(QIcon(I('debug.png')))
#self.show()
# Cause our dialog size to be restored from prefs or created on first usage
self.resize_dialog()
@@ -1482,7 +1485,7 @@ class EmailPassDialog(QDialog):
self.setWindowTitle(_('Password'))
self.l.addWidget(QLabel(_("Enter Email Password for %s:")%user),0,0,1,2)
# self.l.addWidget(QLabel(_("Password:")),1,0)
self.passwd = QLineEdit(self)
self.passwd.setEchoMode(QLineEdit.Password)
+58 -34
View File
@@ -454,6 +454,10 @@ class FanFicFarePlugin(InterfaceAction):
imap_pass,
prefs['imapfolder'],
prefs['imapmarkread'],)
## reject will now be redundant with reject check inside
## prep_downloads because of change-able story URLs.
## Keeping here to because it's the far more common case.
reject_list=set()
if prefs['auto_reject_from_email']:
# need to normalize for reject list.
@@ -931,6 +935,43 @@ class FanFicFarePlugin(InterfaceAction):
# LoopProgressDialog calls start_download_job at the end which goes
# into the BG, or shows list if no 'good' books.
def reject_url(self,merge,book):
url = book['url']
if not merge and rejecturllist.check(url): # skip reject list when merging.
rejnote = rejecturllist.get_full_note(url)
if prefs['reject_always'] or question_dialog(self.gui, _('Reject URL?'),'''
<h3>%s</h3>
<p>%s</p>
<p>"<b>%s</b>"</p>
<p>%s</p>
<p>%s</p>'''%(
_('Reject URL?'),
_('<b>%s</b> is on your Reject URL list:')%url,
rejnote,
_("Click '<b>Yes</b>' to Reject."),
_("Click '<b>No</b>' to download anyway.")),
show_copy_button=False):
book['comment'] = _("Story on Reject URLs list (%s).")%rejnote
book['good']=False
book['icon']='rotate-right.png'
book['status'] = _('Rejected')
return True
else:
if question_dialog(self.gui, _('Remove Reject URL?'),'''
<h3>%s</h3>
<p>%s</p>
<p>"<b>%s</b>"</p>
<p>%s</p>
<p>%s</p>'''%(
_("Remove URL from Reject List?"),
_('<b>%s</b> is on your Reject URL list:')%url,
rejnote,
_("Click '<b>Yes</b>' to remove it from the list,"),
_("Click '<b>No</b>' to leave it on the list.")),
show_copy_button=False):
rejecturllist.remove(url)
return False
def prep_download_loop(self,book,
options={'fileform':'epub',
'collision':ADDNEW,
@@ -947,40 +988,11 @@ class FanFicFarePlugin(InterfaceAction):
logger.debug("url:%s"%url)
mi = None
if not merge: # skip reject list when merging.
if rejecturllist.check(url):
rejnote = rejecturllist.get_full_note(url)
if prefs['reject_always'] or question_dialog(self.gui, _('Reject URL?'),'''
<h3>%s</h3>
<p>%s</p>
<p>"<b>%s</b>"</p>
<p>%s</p>
<p>%s</p>'''%(
_('Reject URL?'),
_('<b>%s</b> is on your Reject URL list:')%url,
rejnote,
_("Click '<b>Yes</b>' to Reject."),
_("Click '<b>No</b>' to download anyway.")),
show_copy_button=False):
book['comment'] = _("Story on Reject URLs list (%s).")%rejnote
book['good']=False
book['icon']='rotate-right.png'
book['status'] = _('Rejected')
return
else:
if question_dialog(self.gui, _('Remove Reject URL?'),'''
<h3>%s</h3>
<p>%s</p>
<p>"<b>%s</b>"</p>
<p>%s</p>
<p>%s</p>'''%(
_("Remove URL from Reject List?"),
_('<b>%s</b> is on your Reject URL list:')%url,
rejnote,
_("Click '<b>Yes</b>' to remove it from the list,"),
_("Click '<b>No</b>' to leave it on the list.")),
show_copy_button=False):
rejecturllist.remove(url)
## Check reject list. Redundant with below for when story URL
## changes, but also kept here to avoid network hit in most
## common case where given url is story url.
if self.reject_url(merge,book):
return
# The current database shown in the GUI
# db is an instance of the class LibraryDatabase2 from database.py
@@ -1074,7 +1086,16 @@ class FanFicFarePlugin(InterfaceAction):
# let other exceptions percolate up.
story = adapter.getStoryMetadataOnly(get_cover=False)
book['title'] = story.getMetadata('title')
book['author'] = [story.getMetadata('author')]
book['url'] = story.getMetadata('storyUrl')
## Check reject list. Redundant with below for when story
## URL changes, but also kept here to avoid network hit in
## most common case where given url is story url.
if self.reject_url(merge,book):
return
series = story.getMetadata('series')
if not merge and series and prefs['checkforseriesurlid']:
# try to find *series anthology* by *seriesUrl* identifier url or uri first.
@@ -1094,6 +1115,7 @@ class FanFicFarePlugin(InterfaceAction):
book['comment'] = _("Story in Series Anthology(%s).")%series
book['title'] = story.getMetadata('title')
book['author'] = [story.getMetadata('author')]
book['url'] = story.getMetadata('storyUrl')
book['good']=False
book['icon']='rotate-right.png'
book['status'] = _('Skipped')
@@ -1114,6 +1136,7 @@ class FanFicFarePlugin(InterfaceAction):
book['title'] = story.getMetadata("title", removeallentities=True)
book['author_sort'] = book['author'] = story.getList("author", removeallentities=True)
book['publisher'] = story.getMetadata("site")
book['url'] = story.getMetadata("storyUrl")
book['tags'] = story.getSubjectTags(removeallentities=True)
if story.getMetadata("description"):
book['comments'] = sanitize_comments_html(story.getMetadata("description"))
@@ -2134,6 +2157,7 @@ class FanFicFarePlugin(InterfaceAction):
book['site'] = ''
book['added'] = False
book['pubdate'] = None
book['publisher'] = None
return book
def convert_urls_to_books(self, urls):
+23 -4
View File
@@ -376,13 +376,14 @@ cover_exclusion_regexp:/clear.png
strip_chapter_numbers:false
## Copy title to tagsfromtitle for parsing tags.
add_to_extra_valid_entries:,tagsfromtitle
add_to_extra_valid_entries:,tagsfromtitle,forumtags
## '.NOREPL' tells the system to *not* apply title's
## in/exclude/replace_metadata -- Only works on include_in_ lines.
include_in_tagsfromtitle:title.NOREPL
tagsfromtitle_label:Tags from Title
forumtags_label:Tags from Forum
## might want to do this, maybe not. Will often include category, but
## also often include non-category stuff.
@@ -399,18 +400,20 @@ add_to_replace_metadata:
tagsfromtitle=>[\(\)\[\]]=>
# change (spaces)slash(or semicolon)(spaces) to comma
tagsfromtitle=> *[/;] *=>,
tagsfromtitle=> x =>,
tagsfromtitle=> [xX] =>,
# remove [] or () blocks and leading/trailing spaces/dashes/colons
title=>[-: ]*[\(\[]([^\]\)]+)[\)\]][-: ]*=>
# remove 'Thread' and the next word, usually "Thread 2", "Thread
# four", "Thread iv", etc
title=>[-: ]*[Tt]hread [^ ]+[-: ]*=>
title,tagsfromtitle=>[-: ]*[Tt]hread [^ ]+[-: ]*=>
add_to_extra_titlepage_entries:,tagsfromtitle
add_to_extra_titlepage_entries:,tagsfromtitle,forumtags
## '.SPLIT' tells the system to split by ','
add_to_include_subject_tags:,tagsfromtitle.SPLIT
## Or to also include forumtags add this to personal.ini:
#add_to_include_subject_tags:,forumtags
## base_xenforoforum reads Published and Updated datetimes from
## Threadmarks if used, or from the posted & updated times of the
@@ -1871,6 +1874,15 @@ rating_titles: R=RESTRICTED (16+), E=EXEMPT (18+), I=ART HOUSE, T=To every, A=IN
adult_ratings: E,R
[www.mediaminer.org]
dateUpdated_format:%%Y-%%m-%%d %%H:%%M:%%S
## Note that mediaminer doesn't give datePublished on the story's
## index page--it's collected from the earliest uploaded chapter. So
## it's not available when only fetching metadata.
datePublished_format:%%Y-%%m-%%d %%H:%%M:%%S
## some sites include images that we don't ever want becoming the
## cover image. This lets you exclude them.
cover_exclusion_regexp:/img/rss.png
[www.midnightwhispers.ca]
## Some sites do not require a login, but do require the user to
@@ -1938,6 +1950,13 @@ extracategories:Psych
## Site dedicated to these categories/characters/ships
extracategories:Queer as Folk
[quotev.com]
extra_valid_entries:pages,readers,reads,favorites
pages_label:Pages
readers_label:Readers
reads_label:Reads
favorites_label:Favorites
[www.restrictedsection.org]
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
+1 -1
View File
@@ -13,7 +13,7 @@ msgstr ""
"POT-Creation-Date: 2015-07-27 12:59+Central Daylight Time\n"
"PO-Revision-Date: 2015-07-30 08:46+0000\n"
"Last-Translator: Kovid Goyal <kovid@kovidgoyal.net>\n"
"Language-Team: German (http://www.transifex.com/projects/p/calibre-plugins/language/de/)\n"
"Language-Team: German (http://www.transifex.com/calibre/calibre-plugins/language/de/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: ENCODING\n"
+1 -1
View File
@@ -12,7 +12,7 @@ msgstr ""
"POT-Creation-Date: 2015-07-27 12:59+Central Daylight Time\n"
"PO-Revision-Date: 2015-07-30 09:06+0000\n"
"Last-Translator: Jellby <jellby@yahoo.com>\n"
"Language-Team: Spanish (http://www.transifex.com/projects/p/calibre-plugins/language/es/)\n"
"Language-Team: Spanish (http://www.transifex.com/calibre/calibre-plugins/language/es/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: ENCODING\n"
+9 -7
View File
@@ -5,14 +5,16 @@
# Xotes <alois.glibert@gmail.com>, 2015
# Franck, 2015
# Ptitprince <leporello1791@gmail.com>, 2014-2015
# Piconcely Yoann <yoanncoolazz@gmail.com>, 2015
# sengian <sengian1@gmail.com>, 2015
# Thibault San <diablodu02@gmail.com>, 2015
msgid ""
msgstr ""
"Project-Id-Version: calibre-plugins\n"
"POT-Creation-Date: 2015-07-27 12:59+Central Daylight Time\n"
"PO-Revision-Date: 2015-07-30 08:58+0000\n"
"Last-Translator: Ptitprince <leporello1791@gmail.com>\n"
"Language-Team: French (http://www.transifex.com/projects/p/calibre-plugins/language/fr/)\n"
"PO-Revision-Date: 2015-08-11 08:48+0000\n"
"Last-Translator: Piconcely Yoann <yoanncoolazz@gmail.com>\n"
"Language-Team: French (http://www.transifex.com/calibre/calibre-plugins/language/fr/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: ENCODING\n"
@@ -1556,19 +1558,19 @@ msgstr "Pas d'URLs de récit trouvée dans les livres sélectionnés"
#: fff_plugin.py:578
msgid "Can only UnNew books in library"
msgstr ""
msgstr "Seuls les livres dans la bibliothèque peuvent être marqués vu"
#: fff_plugin.py:595
msgid "UnNewing books..."
msgstr ""
msgstr "Anciens livres..."
#: fff_plugin.py:596
msgid "UnNew Books"
msgstr ""
msgstr "Anciens livres..."
#: fff_plugin.py:597
msgid "Books UnNewed"
msgstr ""
msgstr "Livre non vu"
#: fff_plugin.py:636 fff_plugin.py:1541
msgid "Starting auto conversion of %d books."
+185 -185
View File
@@ -5,7 +5,7 @@
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2015-07-27 12:59+Central Daylight Time\n"
"POT-Creation-Date: 2015-09-14 10:18+Central Daylight Time\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -390,7 +390,7 @@ msgid ""
"<b>http://...,title by author - note</b>"
msgstr ""
#: config.py:616 dialogs.py:1095
#: config.py:616 dialogs.py:1098
msgid "Add this reason to all URLs added:"
msgstr ""
@@ -654,7 +654,7 @@ msgstr ""
msgid "Language"
msgstr ""
#: config.py:1151 fff_plugin.py:1384 fff_plugin.py:1582 fff_plugin.py:1612
#: config.py:1151 fff_plugin.py:1409 fff_plugin.py:1607 fff_plugin.py:1637
msgid "Status"
msgstr ""
@@ -682,7 +682,7 @@ msgstr ""
msgid "Published"
msgstr ""
#: config.py:1158 fff_plugin.py:1695 fff_plugin.py:1714
#: config.py:1158 fff_plugin.py:1720 fff_plugin.py:1739
msgid "Updated"
msgstr ""
@@ -722,8 +722,8 @@ msgstr ""
msgid "Extra Tags"
msgstr ""
#: config.py:1168 config.py:1294 dialogs.py:886 dialogs.py:982
#: fff_plugin.py:1384 fff_plugin.py:1582 fff_plugin.py:1612
#: config.py:1168 config.py:1294 dialogs.py:889 dialogs.py:985
#: fff_plugin.py:1409 fff_plugin.py:1607 fff_plugin.py:1637
msgid "Title"
msgstr ""
@@ -735,8 +735,8 @@ msgstr ""
msgid "Description"
msgstr ""
#: config.py:1171 dialogs.py:886 dialogs.py:982 fff_plugin.py:1384
#: fff_plugin.py:1582 fff_plugin.py:1612
#: config.py:1171 dialogs.py:889 dialogs.py:985 fff_plugin.py:1409
#: fff_plugin.py:1607 fff_plugin.py:1637
msgid "Author"
msgstr ""
@@ -976,33 +976,33 @@ msgstr ""
msgid "Update Calibre Metadata from Saved Metadata Column"
msgstr ""
#: dialogs.py:268 dialogs.py:777
#: dialogs.py:268 dialogs.py:780
msgid "Show Download Options"
msgstr ""
#: dialogs.py:287 dialogs.py:794
#: dialogs.py:287 dialogs.py:797
msgid "Output &Format:"
msgstr ""
#: dialogs.py:295 dialogs.py:802
#: dialogs.py:295 dialogs.py:805
msgid "Choose output format to create. May set default from plugin configuration."
msgstr ""
#: dialogs.py:323 dialogs.py:819
#: dialogs.py:323 dialogs.py:822
msgid "Update Calibre &Metadata?"
msgstr ""
#: dialogs.py:324 dialogs.py:820
#: dialogs.py:324 dialogs.py:823
msgid ""
"Update metadata for existing stories in Calibre from web site?\n"
"(Columns set to 'New Only' in the column tabs will only be set for new books.)"
msgstr ""
#: dialogs.py:330 dialogs.py:824
#: dialogs.py:330 dialogs.py:827
msgid "Update EPUB Cover?"
msgstr ""
#: dialogs.py:331 dialogs.py:825
#: dialogs.py:331 dialogs.py:828
msgid "Update book cover image from site or defaults (if found) <i>inside</i> the EPUB when EPUB is updated."
msgstr ""
@@ -1061,11 +1061,11 @@ msgid ""
"Requires %s plugin."
msgstr ""
#: dialogs.py:524 dialogs.py:578 dialogs.py:605 dialogs.py:1495
#: dialogs.py:524 dialogs.py:578 dialogs.py:605 dialogs.py:1498
msgid "Cancel"
msgstr ""
#: dialogs.py:556 dialogs.py:1483
#: dialogs.py:556 dialogs.py:1486
msgid "Password"
msgstr ""
@@ -1089,7 +1089,7 @@ msgstr ""
msgid "Password:"
msgstr ""
#: dialogs.py:574 dialogs.py:719 dialogs.py:1491
#: dialogs.py:574 dialogs.py:722 dialogs.py:1494
msgid "OK"
msgstr ""
@@ -1105,155 +1105,155 @@ msgstr ""
msgid "Fetched metadata for"
msgstr ""
#: dialogs.py:629
#: dialogs.py:632
msgid " - %s estimated until done"
msgstr ""
#: dialogs.py:679
#: dialogs.py:682
msgid "%d day"
msgstr ""
#: dialogs.py:679
#: dialogs.py:682
msgid "%d days"
msgstr ""
#: dialogs.py:680
#: dialogs.py:683
msgid "%d hour"
msgstr ""
#: dialogs.py:680
#: dialogs.py:683
msgid "%d hours"
msgstr ""
#: dialogs.py:681
#: dialogs.py:684
msgid "%d minute"
msgstr ""
#: dialogs.py:681
#: dialogs.py:684
msgid "%d minutes"
msgstr ""
#: dialogs.py:682
#: dialogs.py:685
msgid "%d second"
msgstr ""
#: dialogs.py:682
#: dialogs.py:685
msgid "%d seconds"
msgstr ""
#: dialogs.py:697
#: dialogs.py:700
msgid "less than 1 second"
msgstr ""
#: dialogs.py:714 fff_plugin.py:365 fff_plugin.py:368
#: dialogs.py:717 fff_plugin.py:365 fff_plugin.py:368
msgid "About FanFicFare"
msgstr ""
#: dialogs.py:768
#: dialogs.py:771
msgid "Remove selected books from the list"
msgstr ""
#: dialogs.py:807
#: dialogs.py:810
msgid "Update Mode:"
msgstr ""
#: dialogs.py:810
#: dialogs.py:813
msgid "What sort of update to perform. May set default from plugin configuration."
msgstr ""
#: dialogs.py:886 fff_plugin.py:1384 fff_plugin.py:1582 fff_plugin.py:1612
#: dialogs.py:889 fff_plugin.py:1409 fff_plugin.py:1607 fff_plugin.py:1637
msgid "Comment"
msgstr ""
#: dialogs.py:954
#: dialogs.py:957
msgid "Are you sure you want to remove this book from the list?"
msgstr ""
#: dialogs.py:956
#: dialogs.py:959
msgid "Are you sure you want to remove the selected %d books from the list?"
msgstr ""
#: dialogs.py:982
#: dialogs.py:985
msgid "Note"
msgstr ""
#: dialogs.py:1021
#: dialogs.py:1024
msgid "Select or Edit Reject Note."
msgstr ""
#: dialogs.py:1030
#: dialogs.py:1033
msgid "Are you sure you want to remove this URL from the list?"
msgstr ""
#: dialogs.py:1032
#: dialogs.py:1035
msgid "Are you sure you want to remove the %d selected URLs from the list?"
msgstr ""
#: dialogs.py:1050
#: dialogs.py:1053
msgid "List of Books to Reject"
msgstr ""
#: dialogs.py:1063
#: dialogs.py:1066
msgid "FFF will remember these URLs and display the note and offer to reject them if you try to download them again later."
msgstr ""
#: dialogs.py:1077
#: dialogs.py:1080
msgid "Remove selected URLs from the list"
msgstr ""
#: dialogs.py:1092 dialogs.py:1096
#: dialogs.py:1095 dialogs.py:1099
msgid "This will be added to whatever note you've set for each URL above."
msgstr ""
#: dialogs.py:1106
#: dialogs.py:1109
msgid "Delete Books (including books without FanFiction URLs)?"
msgstr ""
#: dialogs.py:1107
#: dialogs.py:1110
msgid "Delete the selected books after adding them to the Rejected URLs list."
msgstr ""
#: dialogs.py:1263
#: dialogs.py:1266
msgid "Search for string in edit box."
msgstr ""
#: dialogs.py:1266
#: dialogs.py:1269
msgid "Find:"
msgstr ""
#: dialogs.py:1271
#: dialogs.py:1274
msgid "Find"
msgstr ""
#: dialogs.py:1281
#: dialogs.py:1284
msgid "Case sensitive"
msgstr ""
#: dialogs.py:1282
#: dialogs.py:1285
msgid "Search for case sensitive string; don't treat Harry, HARRY and harry all the same."
msgstr ""
#: dialogs.py:1311
#: dialogs.py:1314
msgid "Go back to fix errors?"
msgstr ""
#: dialogs.py:1423
#: dialogs.py:1426
msgid "Click an error below to return to Editing directly on that line:"
msgstr ""
#: dialogs.py:1435
#: dialogs.py:1438
msgid "Click to go to line %s"
msgstr ""
#: dialogs.py:1451
#: dialogs.py:1454
msgid "Return to Editing"
msgstr ""
#: dialogs.py:1455
#: dialogs.py:1458
msgid "Save Anyway"
msgstr ""
#: dialogs.py:1484
#: dialogs.py:1487
msgid "Enter Email Password for %s:"
msgstr ""
@@ -1281,7 +1281,7 @@ msgstr ""
msgid "Get Story URLs from &Email"
msgstr ""
#: fff_plugin.py:291 fff_plugin.py:495
#: fff_plugin.py:291 fff_plugin.py:499
msgid "Get Story URLs from Web Page"
msgstr ""
@@ -1365,515 +1365,515 @@ msgstr ""
msgid "Fetching Story URLs from Email..."
msgstr ""
#: fff_plugin.py:463 fff_plugin.py:478
#: fff_plugin.py:467 fff_plugin.py:482
msgid "No Valid Story URLs Found in Unread Emails."
msgstr ""
#: fff_plugin.py:472
#: fff_plugin.py:476
msgid "Finished Fetching Story URLs from Email."
msgstr ""
#: fff_plugin.py:480
#: fff_plugin.py:484
msgid "(%d Story URLs Skipped, on Rejected URL List)"
msgstr ""
#: fff_plugin.py:481
#: fff_plugin.py:485
msgid "Get Story URLs from Email"
msgstr ""
#: fff_plugin.py:504
#: fff_plugin.py:508
msgid "Fetching Story URLs from Page..."
msgstr ""
#: fff_plugin.py:508
#: fff_plugin.py:512
msgid "Finished Fetching Story URLs from Page."
msgstr ""
#: fff_plugin.py:514 fff_plugin.py:566
#: fff_plugin.py:518 fff_plugin.py:570
msgid "List of Story URLs"
msgstr ""
#: fff_plugin.py:515
#: fff_plugin.py:519
msgid "No Valid Story URLs found on given page."
msgstr ""
#: fff_plugin.py:530 fff_plugin.py:583
#: fff_plugin.py:534 fff_plugin.py:587
msgid "No Selected Books to Get URLs From"
msgstr ""
#: fff_plugin.py:548
#: fff_plugin.py:552
msgid "Collecting URLs for stories..."
msgstr ""
#: fff_plugin.py:549
#: fff_plugin.py:553
msgid "Get URLs for stories"
msgstr ""
#: fff_plugin.py:550 fff_plugin.py:661 fff_plugin.py:854
#: fff_plugin.py:554 fff_plugin.py:665 fff_plugin.py:858
msgid "URL retrieved"
msgstr ""
#: fff_plugin.py:570
#: fff_plugin.py:574
msgid "List of URLs"
msgstr ""
#: fff_plugin.py:571
#: fff_plugin.py:575
msgid "No Story URLs found in selected books."
msgstr ""
#: fff_plugin.py:578
#: fff_plugin.py:582
msgid "Can only UnNew books in library"
msgstr ""
#: fff_plugin.py:595
#: fff_plugin.py:599
msgid "UnNewing books..."
msgstr ""
#: fff_plugin.py:596
#: fff_plugin.py:600
msgid "UnNew Books"
msgstr ""
#: fff_plugin.py:597
#: fff_plugin.py:601
msgid "Books UnNewed"
msgstr ""
#: fff_plugin.py:636 fff_plugin.py:1541
#: fff_plugin.py:640 fff_plugin.py:1566
msgid "Starting auto conversion of %d books."
msgstr ""
#: fff_plugin.py:651
#: fff_plugin.py:655
msgid "No Selected Books have URLs to Reject"
msgstr ""
#: fff_plugin.py:659
#: fff_plugin.py:663
msgid "Collecting URLs for Reject List..."
msgstr ""
#: fff_plugin.py:660
#: fff_plugin.py:664
msgid "Get URLs for Reject List"
msgstr ""
#: fff_plugin.py:695
#: fff_plugin.py:699
msgid "Proceed to Remove?"
msgstr ""
#: fff_plugin.py:695
#: fff_plugin.py:699
msgid "Rejecting FanFicFare URLs: None of the books selected have FanFiction URLs."
msgstr ""
#: fff_plugin.py:717
#: fff_plugin.py:721
msgid "Cannot Make Anthologys without %s"
msgstr ""
#: fff_plugin.py:721 fff_plugin.py:831
#: fff_plugin.py:725 fff_plugin.py:835
msgid "Cannot Update Books from Device View"
msgstr ""
#: fff_plugin.py:725
#: fff_plugin.py:729
msgid "Can only update 1 anthology at a time"
msgstr ""
#: fff_plugin.py:734
#: fff_plugin.py:738
msgid "Can only Update Epub Anthologies"
msgstr ""
#: fff_plugin.py:752 fff_plugin.py:753
#: fff_plugin.py:756 fff_plugin.py:757
msgid "Cannot Update Anthology"
msgstr ""
#: fff_plugin.py:753
#: fff_plugin.py:757
msgid "Book isn't an FanFicFare Anthology or contains book(s) without valid Story URLs."
msgstr ""
#: fff_plugin.py:760
#: fff_plugin.py:764
msgid "Fetching Story URLs for Series..."
msgstr ""
#: fff_plugin.py:770
#: fff_plugin.py:774
msgid "Finished Fetching Story URLs for Series."
msgstr ""
#: fff_plugin.py:817
#: fff_plugin.py:821
msgid "There are %d stories in the current anthology that are <b>not</b> going to be kept if you go ahead."
msgstr ""
#: fff_plugin.py:818
#: fff_plugin.py:822
msgid "Story URLs that will be removed:"
msgstr ""
#: fff_plugin.py:820
#: fff_plugin.py:824
msgid "Update anyway?"
msgstr ""
#: fff_plugin.py:821
#: fff_plugin.py:825
msgid "Stories Removed"
msgstr ""
#: fff_plugin.py:838
#: fff_plugin.py:842
msgid "No Selected Books to Update"
msgstr ""
#: fff_plugin.py:852
#: fff_plugin.py:856
msgid "Collecting stories for update..."
msgstr ""
#: fff_plugin.py:853
#: fff_plugin.py:857
msgid "Get stories for updates"
msgstr ""
#: fff_plugin.py:863
#: fff_plugin.py:867
msgid "Update Existing List"
msgstr ""
#: fff_plugin.py:922
#: fff_plugin.py:926
msgid "Started fetching metadata for %s stories."
msgstr ""
#: fff_plugin.py:928
#: fff_plugin.py:932
msgid "No valid story URLs entered."
msgstr ""
#: fff_plugin.py:953 fff_plugin.py:959
#: fff_plugin.py:942 fff_plugin.py:948
msgid "Reject URL?"
msgstr ""
#: fff_plugin.py:960 fff_plugin.py:978
#: fff_plugin.py:949 fff_plugin.py:967
msgid "<b>%s</b> is on your Reject URL list:"
msgstr ""
#: fff_plugin.py:962
#: fff_plugin.py:951
msgid "Click '<b>Yes</b>' to Reject."
msgstr ""
#: fff_plugin.py:963 fff_plugin.py:1090
#: fff_plugin.py:952 fff_plugin.py:1113
msgid "Click '<b>No</b>' to download anyway."
msgstr ""
#: fff_plugin.py:965
#: fff_plugin.py:954
msgid "Story on Reject URLs list (%s)."
msgstr ""
#: fff_plugin.py:968
#: fff_plugin.py:957
msgid "Rejected"
msgstr ""
#: fff_plugin.py:971
#: fff_plugin.py:960
msgid "Remove Reject URL?"
msgstr ""
#: fff_plugin.py:977
#: fff_plugin.py:966
msgid "Remove URL from Reject List?"
msgstr ""
#: fff_plugin.py:980
#: fff_plugin.py:969
msgid "Click '<b>Yes</b>' to remove it from the list,"
msgstr ""
#: fff_plugin.py:981
#: fff_plugin.py:970
msgid "Click '<b>No</b>' to leave it on the list."
msgstr ""
#: fff_plugin.py:998
#: fff_plugin.py:1010
msgid "Cannot update non-epub format."
msgstr ""
#: fff_plugin.py:1068
#: fff_plugin.py:1082
msgid "Are You an Adult?"
msgstr ""
#: fff_plugin.py:1069
#: fff_plugin.py:1083
msgid "%s requires that you be an adult. Please confirm you are an adult in your locale:"
msgstr ""
#: fff_plugin.py:1081
#: fff_plugin.py:1104
msgid "Skip Story?"
msgstr ""
#: fff_plugin.py:1087
#: fff_plugin.py:1110
msgid "Skip Anthology Story?"
msgstr ""
#: fff_plugin.py:1088
#: fff_plugin.py:1111
msgid "\"<b>%s</b>\" is in series \"<b><a href=\"%s\">%s</a></b>\" that you have an anthology book for."
msgstr ""
#: fff_plugin.py:1089
#: fff_plugin.py:1112
msgid "Click '<b>Yes</b>' to Skip."
msgstr ""
#: fff_plugin.py:1092
#: fff_plugin.py:1115
msgid "Story in Series Anthology(%s)."
msgstr ""
#: fff_plugin.py:1097
#: fff_plugin.py:1121
msgid "Skipped"
msgstr ""
#: fff_plugin.py:1127
#: fff_plugin.py:1152
msgid "Add"
msgstr ""
#: fff_plugin.py:1140
#: fff_plugin.py:1165
msgid "Meta"
msgstr ""
#: fff_plugin.py:1171
#: fff_plugin.py:1196
msgid "Skipping duplicate story."
msgstr ""
#: fff_plugin.py:1174
#: fff_plugin.py:1199
msgid "More than one identical book by Identifer URL or title/author(s)--can't tell which book to update/overwrite."
msgstr ""
#: fff_plugin.py:1185
#: fff_plugin.py:1210
msgid "Update"
msgstr ""
#: fff_plugin.py:1193 fff_plugin.py:1200
#: fff_plugin.py:1218 fff_plugin.py:1225
msgid "Change Story URL?"
msgstr ""
#: fff_plugin.py:1201
#: fff_plugin.py:1226
msgid "<b>%s</b> by <b>%s</b> is already in your library with a different source URL:"
msgstr ""
#: fff_plugin.py:1202
#: fff_plugin.py:1227
msgid "In library: <a href=\"%(liburl)s\">%(liburl)s</a>"
msgstr ""
#: fff_plugin.py:1203 fff_plugin.py:1217
#: fff_plugin.py:1228 fff_plugin.py:1242
msgid "New URL: <a href=\"%(newurl)s\">%(newurl)s</a>"
msgstr ""
#: fff_plugin.py:1204
#: fff_plugin.py:1229
msgid "Click '<b>Yes</b>' to update/overwrite book with new URL."
msgstr ""
#: fff_plugin.py:1205
#: fff_plugin.py:1230
msgid "Click '<b>No</b>' to skip updating/overwriting this book."
msgstr ""
#: fff_plugin.py:1207 fff_plugin.py:1214
#: fff_plugin.py:1232 fff_plugin.py:1239
msgid "Download as New Book?"
msgstr ""
#: fff_plugin.py:1215
#: fff_plugin.py:1240
msgid "<b>%s</b> by <b>%s</b> is already in your library with a different source URL."
msgstr ""
#: fff_plugin.py:1216
#: fff_plugin.py:1241
msgid "You chose not to update the existing book. Do you want to add a new book for this URL?"
msgstr ""
#: fff_plugin.py:1218
#: fff_plugin.py:1243
msgid "Click '<b>Yes</b>' to a new book with new URL."
msgstr ""
#: fff_plugin.py:1219
#: fff_plugin.py:1244
msgid "Click '<b>No</b>' to skip URL."
msgstr ""
#: fff_plugin.py:1225
#: fff_plugin.py:1250
msgid "Update declined by user due to differing story URL(%s)"
msgstr ""
#: fff_plugin.py:1228
#: fff_plugin.py:1253
msgid "Different URL"
msgstr ""
#: fff_plugin.py:1233
#: fff_plugin.py:1258
msgid "Metadata collected."
msgstr ""
#: fff_plugin.py:1249
#: fff_plugin.py:1274
msgid "Already contains %d chapters."
msgstr ""
#: fff_plugin.py:1251 jobs.py:211
#: fff_plugin.py:1276 jobs.py:211
msgid "Existing epub contains %d chapters, web site only has %d. Use Overwrite to force update."
msgstr ""
#: fff_plugin.py:1253
#: fff_plugin.py:1278
msgid "FanFicFare doesn't recognize chapters in existing epub, epub is probably from a different source. Use Overwrite to force update."
msgstr ""
#: fff_plugin.py:1265
#: fff_plugin.py:1290
msgid "Not Overwriting, web site is not newer."
msgstr ""
#: fff_plugin.py:1380
#: fff_plugin.py:1405
msgid "None of the <b>%d</b> URLs/stories given can be/need to be downloaded."
msgstr ""
#: fff_plugin.py:1381 fff_plugin.py:1578 fff_plugin.py:1608
#: fff_plugin.py:1406 fff_plugin.py:1603 fff_plugin.py:1633
msgid "See log for details."
msgstr ""
#: fff_plugin.py:1382
#: fff_plugin.py:1407
msgid "Proceed with updating your library(Error Column, if configured)?"
msgstr ""
#: fff_plugin.py:1389 fff_plugin.py:1590
#: fff_plugin.py:1414 fff_plugin.py:1615
msgid "Bad"
msgstr ""
#: fff_plugin.py:1397
#: fff_plugin.py:1422
msgid "FanFicFare download ended"
msgstr ""
#: fff_plugin.py:1397 fff_plugin.py:1633
#: fff_plugin.py:1422 fff_plugin.py:1658
msgid "FanFicFare log"
msgstr ""
#: fff_plugin.py:1417
#: fff_plugin.py:1442
msgid "Download FanFiction Book"
msgstr ""
#: fff_plugin.py:1424
#: fff_plugin.py:1449
msgid "Starting %d FanFicFare Downloads"
msgstr ""
#: fff_plugin.py:1454
#: fff_plugin.py:1479
msgid "Story Details:"
msgstr ""
#: fff_plugin.py:1457
#: fff_plugin.py:1482
msgid "Error Updating Metadata"
msgstr ""
#: fff_plugin.py:1458
#: fff_plugin.py:1483
msgid "An error has occurred while FanFicFare was updating calibre's metadata for <a href='%s'>%s</a>."
msgstr ""
#: fff_plugin.py:1459
#: fff_plugin.py:1484
msgid "The ebook has been updated, but the metadata has not."
msgstr ""
#: fff_plugin.py:1511
#: fff_plugin.py:1536
msgid "Finished Adding/Updating %d books."
msgstr ""
#: fff_plugin.py:1562
#: fff_plugin.py:1587
msgid "No Good Stories for Anthology"
msgstr ""
#: fff_plugin.py:1563
#: fff_plugin.py:1588
msgid "No good stories/updates where downloaded, Anthology creation/update aborted."
msgstr ""
#: fff_plugin.py:1568 fff_plugin.py:1607
#: fff_plugin.py:1593 fff_plugin.py:1632
msgid "FanFicFare found <b>%s</b> good and <b>%s</b> bad updates."
msgstr ""
#: fff_plugin.py:1575
#: fff_plugin.py:1600
msgid "Are you sure you want to continue with creating/updating this Anthology?"
msgstr ""
#: fff_plugin.py:1576
#: fff_plugin.py:1601
msgid "Any updates that failed will <b>not</b> be included in the Anthology."
msgstr ""
#: fff_plugin.py:1577
#: fff_plugin.py:1602
msgid "However, if there's an older version, it will still be included."
msgstr ""
#: fff_plugin.py:1580
#: fff_plugin.py:1605
msgid "Proceed with updating this anthology and your library?"
msgstr ""
#: fff_plugin.py:1588
#: fff_plugin.py:1613
msgid "Good"
msgstr ""
#: fff_plugin.py:1609
#: fff_plugin.py:1634
msgid "Proceed with updating your library?"
msgstr ""
#: fff_plugin.py:1633
#: fff_plugin.py:1658
msgid "FanFicFare download complete"
msgstr ""
#: fff_plugin.py:1646
#: fff_plugin.py:1671
msgid "Merging %s books."
msgstr ""
#: fff_plugin.py:1686
#: fff_plugin.py:1711
msgid "FanFicFare Adding/Updating books."
msgstr ""
#: fff_plugin.py:1693
#: fff_plugin.py:1718
msgid "Updating calibre for FanFiction stories..."
msgstr ""
#: fff_plugin.py:1694
#: fff_plugin.py:1719
msgid "Update calibre for FanFiction stories"
msgstr ""
#: fff_plugin.py:1703
#: fff_plugin.py:1728
msgid "Adding/Updating %s BAD books."
msgstr ""
#: fff_plugin.py:1712
#: fff_plugin.py:1737
msgid "Updating calibre for BAD FanFiction stories..."
msgstr ""
#: fff_plugin.py:1713
#: fff_plugin.py:1738
msgid "Update calibre for BAD FanFiction stories"
msgstr ""
#: fff_plugin.py:1739
#: fff_plugin.py:1764
msgid "Adding format to book failed for some reason..."
msgstr ""
#: fff_plugin.py:1742
#: fff_plugin.py:1767
msgid "Error"
msgstr ""
#: fff_plugin.py:2055
#: fff_plugin.py:2080
msgid "You configured FanFicFare to automatically update Reading Lists, but you don't have the %s plugin installed anymore?"
msgstr ""
#: fff_plugin.py:2067
#: fff_plugin.py:2092
msgid "You configured FanFicFare to automatically update \"To Read\" Reading Lists, but you don't have any lists set?"
msgstr ""
#: fff_plugin.py:2077 fff_plugin.py:2095
#: fff_plugin.py:2102 fff_plugin.py:2120
msgid "You configured FanFicFare to automatically update Reading List '%s', but you don't have a list of that name?"
msgstr ""
#: fff_plugin.py:2083
#: fff_plugin.py:2108
msgid "You configured FanFicFare to automatically update \"Send to Device\" Reading Lists, but you don't have any lists set?"
msgstr ""
#: fff_plugin.py:2204
#: fff_plugin.py:2230
msgid "No story URL found."
msgstr ""
#: fff_plugin.py:2207
#: fff_plugin.py:2233
msgid "Not Found"
msgstr ""
#: fff_plugin.py:2213
#: fff_plugin.py:2239
msgid "URL is not a valid story URL."
msgstr ""
#: fff_plugin.py:2216
#: fff_plugin.py:2242
msgid "Bad URL"
msgstr ""
#: fff_plugin.py:2355 fff_plugin.py:2358
#: fff_plugin.py:2381 fff_plugin.py:2384
msgid "Anthology containing:"
msgstr ""
#: fff_plugin.py:2356
#: fff_plugin.py:2382
msgid "%s by %s"
msgstr ""
#: fff_plugin.py:2378
#: fff_plugin.py:2404
msgid " Anthology"
msgstr ""
#: fff_plugin.py:2421
#: fff_plugin.py:2447
msgid "(was set, removed for security)"
msgstr ""
+1 -1
View File
@@ -11,7 +11,7 @@ msgstr ""
"POT-Creation-Date: 2015-07-27 12:59+Central Daylight Time\n"
"PO-Revision-Date: 2015-07-30 08:46+0000\n"
"Last-Translator: Kovid Goyal <kovid@kovidgoyal.net>\n"
"Language-Team: Dutch (http://www.transifex.com/projects/p/calibre-plugins/language/nl/)\n"
"Language-Team: Dutch (http://www.transifex.com/calibre/calibre-plugins/language/nl/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: ENCODING\n"
+1 -1
View File
@@ -10,7 +10,7 @@ msgstr ""
"POT-Creation-Date: 2015-07-27 12:59+Central Daylight Time\n"
"PO-Revision-Date: 2015-07-30 08:46+0000\n"
"Last-Translator: Kovid Goyal <kovid@kovidgoyal.net>\n"
"Language-Team: Portuguese (Brazil) (http://www.transifex.com/projects/p/calibre-plugins/language/pt_BR/)\n"
"Language-Team: Portuguese (Brazil) (http://www.transifex.com/calibre/calibre-plugins/language/pt_BR/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: ENCODING\n"
+2 -2
View File
@@ -2,7 +2,7 @@
# Copyright (C) YEAR ORGANIZATION
#
# Translators:
# Dex <dexteritymaster@gmail.com>, 2014-2015
# Andrii <dexteritymaster@gmail.com>, 2014-2015
# Yuri Chornoivan <yurchor@ukr.net>, 2014
msgid ""
msgstr ""
@@ -10,7 +10,7 @@ msgstr ""
"POT-Creation-Date: 2015-07-27 12:59+Central Daylight Time\n"
"PO-Revision-Date: 2015-07-30 08:46+0000\n"
"Last-Translator: Kovid Goyal <kovid@kovidgoyal.net>\n"
"Language-Team: Ukrainian (http://www.transifex.com/projects/p/calibre-plugins/language/uk/)\n"
"Language-Team: Ukrainian (http://www.transifex.com/calibre/calibre-plugins/language/uk/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: ENCODING\n"
+2
View File
@@ -137,8 +137,10 @@ import adapter_tgstorytimecom
import adapter_itcouldhappennet
import adapter_forumsspacebattlescom
import adapter_forumssufficientvelocitycom
import adapter_questionablequestingcom
import adapter_ninelivesdarksolaceorg
import adapter_masseffect2in
import adapter_quotevcom
## This bit of complexity allows adapters to be added by just adding
## importing. It eliminates the long if/else clauses we used to need
+17 -312
View File
@@ -16,321 +16,26 @@
#
# Software: eFiction
import time
import logging
logger = logging.getLogger(__name__)
import re
import urllib2
from base_efiction_adapter import BaseEfictionAdapter
from .. import BeautifulSoup as bs
from ..htmlcleanup import stripHTML
from .. import exceptions as exceptions
class DarkSolaceOrgAdapter(BaseEfictionAdapter):
from base_adapter import BaseSiteAdapter, makeDate
@staticmethod
def getSiteDomain():
return 'dark-solace.org'
@classmethod
def getPathToArchive(self):
return '/elysian'
@classmethod
def getSiteAbbrev(self):
return 'dksl'
@classmethod
def getDateFormat(self):
return "%B %d, %Y"
def getClass():
return DarkSolaceOrgAdapter
# Class name has to be unique. Our convention is camel case the
# sitename with Adapter at the end. www is skipped.
class DarkSolaceOrgAdapter(BaseSiteAdapter):
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])
# normalized story URL.
self._setURL('http://' + self.getSiteDomain() + '/elysian/viewstory.php?sid='+self.story.getMetadata('storyId'))
# Each adapter needs to have a unique site abbreviation.
self.story.setMetadata('siteabbrev','dksl')
# The date format will vary from site to site.
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
self.dateformat = "%B %d, %Y"
@staticmethod # must be @staticmethod, don't remove it.
def getSiteDomain():
# The site domain. Does have www here, if it uses it.
return 'dark-solace.org'
@classmethod
def getAcceptDomains(cls):
return ['www.dark-solace.org','dark-solace.org']
@classmethod
def getSiteExampleURLs(cls):
return "http://"+cls.getSiteDomain()+"/elysian/viewstory.php?sid=1234"
def getSiteURLPattern(self):
return re.escape("http://")+"(www\.)?"+re.escape(self.getSiteDomain()+"/elysian/viewstory.php?sid=")+r"\d+$"
## Login seems to be reasonably standard across eFiction sites.
def needToLoginCheck(self, data):
if 'This story contains adult content not suitable for children' in data \
or "That password doesn't match the one in our database" in data \
or "Registered Users Only" 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['action'] = 'login'
params['submit'] = 'Submit'
loginUrl = 'http://www.' + self.getSiteDomain() + '/elysian/user.php'
logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl,
params['penname']))
d = self._postUrl(loginUrl, params)
if "Member Account" not in d : #User Account Page
logger.info("Failed to login to URL %s as %s, or have no authorization to access the story" % (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=5"
else:
addurl=""
# index=1 makes sure we see the story chapter index. Some
# sites skip that for one-chapter stories.
url = self.url+'&index=1'+addurl
logger.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)
m = re.search(r"'viewstory.php\?sid=\d+((?:&amp;ageconsent=ok)?&amp;warning=\d+)'",data)
if m != None:
if self.is_adult or self.getConfig("is_adult"):
# We tried the default and still got a warning, so
# let's pull the warning number from the 'continue'
# link and reload data.
addurl = m.group(1)
# correct stupid &amp; error in url.
addurl = addurl.replace("&amp;","&")
url = self.url+'&index=1'+addurl
logger.debug("URL 2nd try: "+url)
try:
data = self._fetchUrl(url)
except urllib2.HTTPError, e:
if e.code == 404:
raise exceptions.StoryDoesNotExist(self.url)
else:
raise e
else:
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.
## Title and author
div = soup.find('div', {'id' : 'pagetitle'})
aut = div.find('a', href=re.compile(r"viewuser.php\?uid=\d+"))
self.story.setMetadata('authorId',aut['href'].split('=')[1])
self.story.setMetadata('authorUrl','http://'+self.host+'/elysian/'+aut['href'])
self.story.setMetadata('author',aut.string)
aut.extract()
# first a tag in pagetitle is title
self.story.setMetadata('title',stripHTML(div.find('a')))
div.find('a').extract()
# only thing left in div(pagetitle) now should be 'by' and rating.
rating = stripHTML(div)
if '[' in rating:
self.story.setMetadata('rating', rating[rating.index('[')+1:-1])
for chapa in soup.findAll('a', href=re.compile(r'viewstory.php\?sid='+
self.story.getMetadata('storyId')+'&chapter=\d+')):
self.chapterUrls.append((stripHTML(chapa),'http://'+self.host+'/elysian/'+chapa['href']))
self.story.setMetadata('numChapters',len(self.chapterUrls))
asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl')))
storylink = asoup.find('a', href=re.compile(r'viewstory.php\?sid='+
self.story.getMetadata('storyId')+'($|[^\d])'))
# author's story list is paginated if there's a pagelinks div.
# Only need to look in it if the story wasn't on the first page.
pagelinks = asoup.find('div',{'id':'pagelinks'})
if pagelinks and storylink==None:
authpageslist = pagelinks.findAll('a',href=re.compile(r'action=storiesby'))
for page in authpageslist[1:]: # skip first, already checked above.
asoup = bs.BeautifulSoup(self._fetchUrl('http://'+self.host+'/elysian/'+page['href']))
storylink = asoup.find('a', href=re.compile(r'viewstory.php\?sid='+
self.story.getMetadata('storyId')+'($|[^\d])'))
if storylink:
break
if not storylink:
raise exceptions.FailedToDownload("Unable to find story metadata on author's page(s)")
metalist = storylink.parent.parent
# eFiction sites don't help us out a lot with their meta data
# formating, so it's a little ugly.
# utility method
def defaultGetattr(d,k):
try:
return d[k]
except:
return ""
# <span class="label">Rated:</span> NC-17<br /> etc
labels = metalist.findAll('span', {'class' : 'label'})
for labelspan in labels:
label = labelspan.text
value = labelspan.nextSibling
if 'Summary' in label:
## Everything until the next span class='label'
svalue = ""
while value and not (defaultGetattr(value,'class') == 'label' or "Chapters: " in stripHTML(value)):
svalue += str(value)
value = value.nextSibling
self.setDescription(url,svalue)
#self.story.setMetadata('description',stripHTML(svalue))
if 'Word count' in label:
self.story.setMetadata('numWords', value)
if 'Categories' in label:
cats = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=categories'))
for cat in cats:
self.story.addToList('category',cat.string)
if 'Characters' in label:
chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters'))
for char in chars:
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'))
for genre in genres:
self.story.addToList('genre',genre.string)
if 'Warnings' in label:
warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2'))
for warning in warnings:
self.story.addToList('warnings',warning.string)
if 'Completed' in label:
if 'Yes' in value:
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))
if 'Updated' in label:
# there's a stray [ at the end.
#value = value[0:-1]
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat))
try:
# Find Series name from series URL.
a = metalist.find('a', href=re.compile(r"series.php\?seriesid=\d+"))
series_name = a.string
series_url = 'http://'+self.host+'/elysian/'+a['href']
# use BeautifulSoup HTML parser to make everything easier to find.
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
# can't use ^viewstory...$ in case of higher rated stories with javascript href.
storylink = seriessoup.find('a', href=re.compile(r'viewstory.php\?sid='+
self.story.getMetadata('storyId')+'($|[^\d])'))
if storylink and storylink.parent and storylink.parent['class'] != 'title': # in case of links inside story summaries.
storylink = None
offset = 0
# series story list is paginated if there's a pagelinks div.
# Only need to look in it if the story wasn't on the first page.
pagelinks = seriessoup.find('div',{'id':'pagelinks'})
if pagelinks and storylink==None:
authpageslist = pagelinks.findAll('a',href=re.compile(r'offset='))
for page in authpageslist[1:]: # skip first, already checked above.
seriessoup = bs.BeautifulSoup(self._fetchUrl('http://'+self.host+'/elysian/'+page['href']))
storylink = seriessoup.find('a', href=re.compile(r'viewstory.php\?sid='+
self.story.getMetadata('storyId')+'($|[^\d])'))
if storylink and storylink.parent and storylink.parent['class'] != 'title': # in case of links inside story summaries.
storylink = None
if storylink:
offset = int(page['href'].split('=')[-1]) # offset is last.
break
# for reasons I don't understand, searching for story
# links by regex wasn't working reliably. It was missing
# the javascript links sometimes. This is cleaner anyway.
for i, div in enumerate(seriessoup.findAll('div', {'class':'title'})):
a = div.find('a') # first a is story link.
# skip 'report this' and 'TOC' links
if a == storylink:
self.setSeries(series_name, 1+i+offset)
self.story.setMetadata('seriesUrl',series_url)
break
except Exception, e:
logger.debug("Series parsing failed: %s"%e)
# grab the text for an individual chapter.
def getChapterText(self, url):
logger.debug('Getting chapter text from: %s' % url)
soup = bs.BeautifulSoup(self._fetchUrl(url))
div = soup.find('div', {'id' : 'story'})
if None == div:
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
return self.utf8FromSoup(url,div)
+3 -2
View File
@@ -38,8 +38,9 @@ class DramioneOrgAdapter(BaseSiteAdapter):
def __init__(self, config, url):
BaseSiteAdapter.__init__(self, config, url)
self.decode = ["Windows-1252",
"utf8"] # 1252 is a superset of iso-8859-1.
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.
+8 -4
View File
@@ -104,7 +104,7 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
data = self._fetchUrl(url)
#logger.debug("\n===================\n%s\n===================\n"%data)
soup = self.make_soup(data)
except urllib2.HTTPError, e:
except urllib2.HTTPError as e:
if e.code == 404:
raise exceptions.StoryDoesNotExist(url)
else:
@@ -135,11 +135,15 @@ class FanFictionNetSiteAdapter(BaseSiteAdapter):
chapcount+1)
logger.debug('=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:
if "not found. Please check to see you are not using an outdated url." not in newdata \
and "This request takes too long to process, it is timed out by the server." not in newdata:
logger.debug('=======Found newer chapter: %s' % tryurl)
soup = self.make_soup(newdata)
except:
except urllib2.HTTPError as e:
if e.code == 503:
raise e
except e:
logger.warn("Caught an exception reading URL: %s sleeptime(%s) Exception %s."%(unicode(url),sleeptime,unicode(e)))
pass
# Find authorid and URL from... author url.
+5 -2
View File
@@ -178,8 +178,11 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
if get_cover:
# try setting from href, if fails, try using the img src
if self.setCoverImage(self.url,coverurl)[0] == "failedtoload":
coverurl = storyImage.find('img')['src']
self.setCoverImage(self.url,coverurl)
img = storyImage.find('img')
# try src, then data-src, then leave None.
coverurl = img.get('src',img.get('data-src',None))
if coverurl:
self.setCoverImage(self.url,coverurl)
coverSource = storyImage.find('a', {'class':'source'})
if coverSource:
@@ -15,15 +15,6 @@
# limitations under the License.
#
import time
import logging
logger = logging.getLogger(__name__)
import re
import urllib2
from ..htmlcleanup import stripHTML
from .. import exceptions as exceptions
from base_xenforoforum_adapter import BaseXenForoForumAdapter
def getClass():
@@ -42,7 +33,3 @@ class ForumsSpacebattlesComAdapter(BaseXenForoForumAdapter):
# The site domain. Does have www here, if it uses it.
return 'forums.spacebattles.com'
@classmethod
def getURLPrefix(cls):
return 'https://' + cls.getSiteDomain()
@@ -32,7 +32,3 @@ class ForumsSufficientVelocityComAdapter(BaseXenForoForumAdapter):
def getSiteDomain():
# The site domain. Does have www here, if it uses it.
return 'forums.sufficientvelocity.com'
@classmethod
def getURLPrefix(cls):
return 'http://' + cls.getSiteDomain()
@@ -98,6 +98,26 @@ class HPFandomNetAdapterAdapter(BaseSiteAdapter): # XXX
# Now go hunting for all the meta data and the chapter list.
# set first URL
a = soup.find('a', href=re.compile(r"viewstory.php\?sid=\d+"))
self._setURL('http://' + self.getSiteDomain() + '/eff/'+a['href'])
if self.parsedUrl.query.split('=',)[1] != self.story.getMetadata('storyId'):
self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1])
url = self.url
logger.debug("reset 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)
# self.story.setMetadata('storyId', re.compile(self.getSiteURLPattern()).match(a).group('storyId'))
# 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])
+102 -94
View File
@@ -40,39 +40,53 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
# get storyId from url--url validation guarantees query correct
m = re.match(self.getSiteURLPattern(),url)
urltitle='urltitle'
if m:
if m.group('id'):
self.story.setMetadata('storyId',m.group('id'))
if m.group('id1'):
self.story.setMetadata('storyId',m.group('id1'))
urltitle=m.group('urltitle1')
elif m.group('id2'):
self.story.setMetadata('storyId',m.group('id2'))
urltitle=m.group('urltitle2')
elif m.group('id3'):
self.story.setMetadata('storyId',m.group('id2'))
self.story.setMetadata('storyId',m.group('id3'))
else:
raise InvalidStoryURL(url,
self.getSiteDomain(),
self.getSiteExampleURLs())
# normalized story URL.
self._setURL('http://' + self.getSiteDomain() + '/fanfic/view_st.php/'+self.story.getMetadata('storyId'))
self._setURL('http://' + self.getSiteDomain() + '/fanfic/s/'+urltitle+'/'+self.story.getMetadata('storyId'))
else:
raise exceptions.InvalidStoryURL(url,
self.getSiteDomain(),
self.getSiteExampleURLs())
# The date format will vary from site to site.
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
self.dateformat = "%B %d, %Y %H:%M"
@staticmethod
def getSiteDomain():
return 'www.mediaminer.org'
@classmethod
def getSiteExampleURLs(cls):
return "http://"+cls.getSiteDomain()+"/fanfic/view_st.php/123456 http://"+cls.getSiteDomain()+"/fanfic/view_ch.php/1234123/123444#fic_c"
return "http://"+cls.getSiteDomain()+"/fanfic/s/story-title/123456 http://"+cls.getSiteDomain()+"/fanfic/c/story-title/chapter-title/123456/987612"
def getSiteURLPattern(self):
## http://www.mediaminer.org/fanfic/view_st.php/76882
## http://www.mediaminer.org/fanfic/view_ch.php/167618/594087#fic_c
## http://www.mediaminer.org/fanfic/view_ch.php?submit=View+Chapter&id=105816&cid=357151
## http://www.mediaminer.org/fanfic/view_ch.php?cid=612153&submit=View+Chapter&id=171668
return re.escape("http://"+self.getSiteDomain())+\
r"/fanfic/view_(st|ch)\.php"+\
r"(/(?P<id>\d+)(/\d+(#fic_c)?)?/?|"+\
r"\?((submit=View(\+| )Chapter|id=(?P<id2>\d+)|cid=\d+)&?)+)"
## old urls
## http://www.mediaminer.org/fanfic/view_st.php/76882
## new urls
## http://www.mediaminer.org/fanfic/s/ghosts-from-the-past/72
## http://www.mediaminer.org/fanfic/c/ghosts-from-the-past/chapter-2/72/174
## http://www.mediaminer.org/fanfic/s/robtech-final-missions/61553
## http://www.mediaminer.org/fanfic/c/robtech-final-missions/robotech-final-missions-oneshot/61553/189830
return re.escape("http://"+self.getSiteDomain())+r"/fanfic/"+\
r"((s/(?P<urltitle1>[^/]+)/(?P<id1>\d+))|"+\
r"((c/(?P<urltitle2>[^/]+)/[^/]+/(?P<id2>\d+))/\d+)|"+\
r"(view_st\.php/(?P<id3>\d+)))"
# Override stripURLParameters so the id parameter won't get stripped
@classmethod
def stripURLParameters(cls, url):
@@ -84,9 +98,10 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
logger.debug("URL: "+url)
try:
data = self._fetchUrl(url+'/') # trailing / gets 'chapter list' page even for one-shots.
data = self._fetchUrl(url) # w/o trailing / gets 'chapter list' page even for one-shots.
except urllib2.HTTPError, e:
if e.code == 404:
logger.error("404 on %s"%url)
raise exceptions.StoryDoesNotExist(self.url)
else:
raise e
@@ -96,11 +111,13 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
# [ A - All Readers ], strip '[' ']'
## Above title because we remove the smtxt font to get title.
smtxt = soup.find("font",{"class":"smtxt"})
smtxt = soup.find("h3",{"id":"post-rating"})
if not smtxt:
logger.error("can't find rating")
raise exceptions.StoryDoesNotExist(self.url)
rating = smtxt.string[1:-1]
self.story.setMetadata('rating',rating)
else:
rating = smtxt.string[1:-1]
self.story.setMetadata('rating',rating)
# Find authorid and URL from... author url.
a = soup.find('a', href=re.compile(r"/fanfic/src.php/u/\d+"))
@@ -116,37 +133,24 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
## <td class="ffh">The Kraut, The Bartender, and The Drunkard: Chapter 1</b> <font class="smtxt">[ P - Pre-Teen ]</font></td>
## <td class="ffh">Betrayal and Justice: A Cold Heart</b> <font size="-1">( Chapter 1 )</font> <font class="smtxt">[ A - All Readers ]</font></td>
## <td class="ffh">Question and Answer: Question and Answer</b> <font size="-1">( One-Shot )</font> <font class="smtxt">[ A - All Readers ]</font></td>
title = soup.find('td',{'class':'ffh'})
for font in title.findAll('font'):
font.extract() # removes 'font' tags from inside the td.
if title.has_attr('colspan'):
titlet = stripHTML(title)
else:
## No colspan, it's part chapter title--even if it's a one-shot.
titlet = ':'.join(stripHTML(title).split(':')[:-1]) # strip trailing 'Chapter X' or chapter title
self.story.setMetadata('title',titlet)
# title = soup.find('td',{'class':'ffh'})
# for font in title.findAll('font'):
# font.extract() # removes 'font' tags from inside the td.
# if title.has_attr('colspan'):
# titlet = stripHTML(title)
# else:
# ## No colspan, it's part chapter title--even if it's a one-shot.
# titlet = ':'.join(stripHTML(title).split(':')[:-1]) # strip trailing 'Chapter X' or chapter title
self.story.setMetadata('title',stripHTML(soup.find('h1',{'id':'post-title'})))
# save date from first for later.
firstdate=None
# Find the chapters
select = soup.find('select',{'name':'cid'})
if not select:
self.chapterUrls.append(( self.story.getMetadata('title'),self.url))
else:
for option in select.findAll("option"):
chapter = stripHTML(option.string)
## chapter can be: Chapter 7 [Jan 23, 2011]
## or: Vigilant Moonlight ( Chapter 1 ) [Jan 30, 2004]
## or even: Prologue ( Prologue ) [Jul 31, 2010]
m = re.match(r'^(.*?) (\( .*? \) )?\[(.*?)\]$',chapter)
chapter = m.group(1)
# save date from first for later.
if not firstdate:
firstdate = m.group(3)
# http://www.mediaminer.org/fanfic/view_ch.php?cid=376587&submit=View+Chapter&id=105816
# self.chapterUrls.append((chapter,'http://'+self.host+'/fanfic/view_ch.php/'+self.story.getMetadata('storyId')+'/'+option['value']))
self.chapterUrls.append((chapter,'http://'+self.host+'/fanfic/view_ch.php?submit=View Chapter&id='+self.story.getMetadata('storyId')+'&cid='+option['value']))
# Find the chapters - one-shot now have chapter list, too.
chap_p = soup.find('p',{'style':'margin-left:10px;'})
for (atag,aurl,name) in [ (x,x['href'],stripHTML(x)) for x in chap_p.find_all('a') ]:
self.chapterUrls.append((name,'http://'+self.host+aurl))
self.story.setMetadata('numChapters',len(self.chapterUrls))
# category
@@ -155,27 +159,20 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
self.story.addToList('category',a.string)
# genre
# <a href="/fanfic/src.php/a/567">Ranma 1/2</a>
# <a href="/fanfic/src.php/g/567">Ranma 1/2</a>
for a in soup.findAll('a',href=re.compile(r"^/fanfic/src.php/g/")):
self.story.addToList('genre',a.string)
# if firstdate, then the block below will only have last updated.
if firstdate:
self.story.setMetadata('datePublished', makeDate(firstdate, "%b %d, %Y"))
# Everything else is in <tr bgcolor="#EEEED4">
metastr = stripHTML(soup.find("tr",{"bgcolor":"#EEEED4"})).replace('\n',' ').replace('\r',' ').replace('\t',' ')
# Latest Revision: August 03, 2010
m = re.match(r".*?(?:Latest Revision|Uploaded On): ([a-zA-Z]+ \d\d, \d\d\d\d)",metastr)
metastr = stripHTML(soup.find("div",{"class":"post-meta"}))
# Latest Revision: February 07, 2015 15:21 PST
m = re.match(r".*?(?:Latest Revision|Uploaded On): ([a-zA-Z]+ \d\d, \d\d\d\d \d\d:\d\d)",metastr)
if m:
self.story.setMetadata('dateUpdated', makeDate(m.group(1), "%B %d, %Y"))
if not firstdate:
self.story.setMetadata('datePublished',
self.story.getMetadataRaw('dateUpdated'))
else:
self.story.setMetadata('dateUpdated',
self.story.getMetadataRaw('datePublished'))
self.story.setMetadata('dateUpdated', makeDate(m.group(1), self.dateformat))
# site doesn't give date published on index page.
# set to updated, change in chapters below.
# self.story.setMetadata('datePublished',
# self.story.getMetadataRaw('dateUpdated'))
# Words: 123456
m = re.match(r".*?\| Words: (\d+) \|",metastr)
@@ -201,43 +198,54 @@ class MediaMinerOrgSiteAdapter(BaseSiteAdapter):
logger.debug('Getting chapter text from: %s' % url)
data=self._fetchUrl(url)
data = self._fetchUrl(url)
soup = self.make_soup(data)
header = soup.find('div',{'class':'post-meta clearfix '})
headerstr = stripHTML(soup.find('div',{'class':'post-meta clearfix '}))
# print("data:%s"%data)
#header.extract()
chapter=self.make_soup('<div class="story"></div>').find('div')
if None == header:
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
## find divs with align=left, those are paragraphs in newer stories.
divlist = header.findAllNext('div',{'align':'left'})
if divlist:
for div in divlist:
div.name='p' # convert to <p> mediaminer uses div with
# a margin for paragraphs.
chapter.append(div)
del div['style']
del div['align']
return self.utf8FromSoup(url,chapter)
else:
logger.debug('Using kludgey text find for older mediaminer story.')
## Some older mediaminer stories are unparsable with BeautifulSoup.
## Really nasty formatting. Sooo... Cheat! Parse it ourselves a bit first.
## Story stuff falls between:
data = "<div id='HERE'>" + data[data.find('<div class="adWrap">'):data.find('<div class="addthis_sharing_toolbox">')] +"</div>"
soup = self.make_soup(data)
for tag in soup.findAll('td',{'class':'ffh'}) + \
soup.findAll('div',{'class':'acl'}) + \
soup.findAll('div',{'class':'adWrap'}) + \
soup.findAll('div',{'class':'footer smtxt'}) + \
soup.findAll('table',{'class':'tbbrdr'}):
tag.extract() # remove tag from soup.
m = re.match(r".*?Uploaded On: ([a-zA-Z]+ \d\d, \d\d\d\d \d\d:\d\d)",headerstr)
if m:
date = makeDate(m.group(1), self.dateformat)
if not self.story.getMetadataRaw('datePublished') or date < self.story.getMetadataRaw('datePublished'):
self.story.setMetadata('datePublished', date)
return self.utf8FromSoup(url,soup)
chapter = soup.find('div',{'id':'fanfic-text'})
return self.utf8FromSoup(url,chapter)
# chapter=self.make_soup('<div class="story"></div>').find('div')
# if None == header:
# raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
# ## find divs with align=left, those are paragraphs in newer stories.
# divlist = header.findAllNext('div',{'align':'left'})
# if divlist:
# for div in divlist:
# div.name='p' # convert to <p> mediaminer uses div with
# # a margin for paragraphs.
# chapter.append(div)
# del div['style']
# del div['align']
# return self.utf8FromSoup(url,chapter)
# else:
# logger.debug('Using kludgey text find for older mediaminer story.')
# ## Some older mediaminer stories are unparsable with BeautifulSoup.
# ## Really nasty formatting. Sooo... Cheat! Parse it ourselves a bit first.
# ## Story stuff falls between:
# data = "<div id='HERE'>" + data[data.find('<div class="adWrap">'):data.find('<div class="addthis_sharing_toolbox">')] +"</div>"
# soup = self.make_soup(data)
# for tag in soup.findAll('td',{'class':'ffh'}) + \
# soup.findAll('div',{'class':'acl'}) + \
# soup.findAll('div',{'class':'adWrap'}) + \
# soup.findAll('div',{'class':'footer smtxt'}) + \
# soup.findAll('table',{'class':'tbbrdr'}):
# tag.extract() # remove tag from soup.
# return self.utf8FromSoup(url,soup)
def getClass():
@@ -258,6 +258,7 @@ class PortkeyOrgAdapter(BaseSiteAdapter): # XXX
data = self._fetchUrl(url)
data = data.replace("HTML>","div>")
data = data.replace("html>","div>")
soup = bs.BeautifulSoup(data)
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Copyright 2015 FanFicFare 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 re
from base_xenforoforum_adapter import BaseXenForoForumAdapter
def getClass():
return QuestionablequestingComAdapter
class QuestionablequestingComAdapter(BaseXenForoForumAdapter):
def __init__(self, config, url):
BaseXenForoForumAdapter.__init__(self, config, url)
# Each adapter needs to have a unique site abbreviation.
self.story.setMetadata('siteabbrev','qq')
@staticmethod # must be @staticmethod, don't remove it.
def getSiteDomain():
# The site domain. Does have www here, if it uses it.
return 'questionablequesting.com'
@classmethod
def getAcceptDomains(cls):
return ["forum."+cls.getSiteDomain(),cls.getSiteDomain()]
@classmethod
def getConfigSections(cls):
"Only needs to be overriden if has additional ini sections."
return ['base_xenforoforum','forum.'+cls.getSiteDomain(),cls.getSiteDomain()]
def getSiteURLPattern(self):
return r'https?://(forum.)?'+re.escape(self.getSiteDomain())+r'/(?P<tp>threads|posts)/(.+\.)?(?P<id>\d+)/?'
+108
View File
@@ -0,0 +1,108 @@
import re
import urlparse
import urllib2
import datetime
from .. import exceptions
from base_adapter import BaseSiteAdapter
SITE_DOMAIN = 'quotev.com'
STORY_URL_TEMPLATE = 'http://www.quotev.com/story/%s'
def getClass():
return QuotevComAdapter
def get_url_path_segments(url):
return tuple(filter(None, url.split('/')[3:]))
class QuotevComAdapter(BaseSiteAdapter):
def __init__(self, config, url):
BaseSiteAdapter.__init__(self, config, url)
story_id = get_url_path_segments(url)[1]
self._setURL(STORY_URL_TEMPLATE % story_id)
self.story.setMetadata('storyId', story_id)
self.story.setMetadata('siteabbrev', SITE_DOMAIN)
@staticmethod
def getSiteDomain():
return SITE_DOMAIN
@classmethod
def getSiteExampleURLs(cls):
return STORY_URL_TEMPLATE % '1234'
def getSiteURLPattern(self):
pattern = re.escape(STORY_URL_TEMPLATE.rsplit('%', 1)[0]) + r'(.+?)($|&|/)'
pattern = pattern.replace(r'http\:', r'https?\:')
pattern = pattern.replace(r'https?\:\/\/www\.', r'https?\:\/\/(www\.)?')
return pattern
def use_pagecache(self):
return True
def extractChapterUrlsAndMetadata(self):
try:
data = self._fetchUrl(self.url)
except urllib2.HTTPError:
raise exceptions.FailedToDownload(self.url)
soup = self.make_soup(data)
element = soup.find('div', {'class': 'result_head'})
if not element:
raise exceptions.StoryDoesNotExist(self.url)
self.story.setMetadata('title', element.find('span', recursive=False).get_text())
element = soup.find('div', {'class': 'desc_creator'})('a')[1]
self.story.setMetadata('author', element.get_text())
self.story.setMetadata('authorId', get_url_path_segments(element['href'])[0])
self.story.setMetadata('authorUrl', urlparse.urljoin(self.url, element['href']))
self.setDescription(self.url, soup.find('div', id='qdesct'))
self.setCoverImage(self.url, urlparse.urljoin(self.url, soup.find('img', {'class': 'logo'})['src']))
for a in soup.find('div', {'class': 'tag'})('a'):
if a['href'] == '#':
continue
self.story.addToList('category', a.get_text())
elements = soup('span', {'class': 'q_time'})
self.story.setMetadata('datePublished', datetime.datetime.fromtimestamp(float(elements[0]['ts'])))
self.story.setMetadata('dateUpdated', datetime.datetime.fromtimestamp(float(elements[1]['ts'])))
for a in soup.find('div', id='rselect')('a'):
self.chapterUrls.append((a.get_text(), urlparse.urljoin(self.url, a['href'])))
self.story.setMetadata('numChapters', len(self.chapterUrls))
element = soup.find('div', {'class': 't'})('div', recursive=False)[1].div
data = filter(None, (x.strip() for x in element('span')[1].next_sibling.split(u'\xb7')))
if 'completed' in data:
self.story.setMetadata('status', 'Completed')
data.remove('completed')
else:
self.story.setMetadata('status', 'In-Progress')
for datum in data:
value, key = datum.split()
self.story.setMetadata(key, value.replace(',', '').replace('.', ''))
self.story.setMetadata('favorites', soup.find('div', id='favqn').get_text())
self.story.setMetadata('comments', soup.find('a', id='comment_btn').span.get_text())
def getChapterText(self, url):
data = self._fetchUrl(url)
soup = self.make_soup(data)
element = soup.find('div', id='restxt')
for a in element('a'):
a.unwrap()
return self.utf8FromSoup(url, element)
+6
View File
@@ -81,6 +81,12 @@ class TestSiteAdapter(BaseSiteAdapter):
return
if idnum >= 700 and idnum <= 710:
self._setURL('http://test1.com?sid=%s'%(idnum+100))
self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1])
idstr = self.story.getMetadata('storyId')
idnum = int(idstr)
if idstr == '665' and not (self.is_adult or self.getConfig("is_adult")):
logger.warn("self.is_adult:%s"%self.is_adult)
raise exceptions.AdultCheckRequired(self.url)
+2 -1
View File
@@ -600,7 +600,8 @@ class BaseSiteAdapter(Configurable):
if t.name not in ('p') and t.string != None and len(t.string.strip()) == 0 :
t.extract()
except AttributeError, ae:
logger.error("Error parsing HTML, probably poor input HTML. %s"%ae)
if "%s"%ae != "'NoneType' object has no attribute 'next_element'":
logger.error("Error parsing HTML, probably poor input HTML. %s"%ae)
retval = unicode(soup)
+1 -1
View File
@@ -148,7 +148,7 @@ class BaseEfictionAdapter(BaseSiteAdapter):
"""
Get the URL to a user page on this site.
"""
return "%s?sid=%s" % (self.getUrlForPhp(self.getViewUserPhpName()), userId)
return "%s?uid=%s" % (self.getUrlForPhp(self.getViewUserPhpName()), userId)
@classmethod
def getLoginUrl(self):
@@ -77,7 +77,7 @@ class BaseXenForoForumAdapter(BaseSiteAdapter):
return cls.getURLPrefix()+"/threads/some-story-name.123456/"
def getSiteURLPattern(self):
return r"https?://"+re.escape(self.getSiteDomain())+r"/(?P<tp>threads|posts)/(.+\.)?(?P<id>\d+)/"
return r"https?://"+re.escape(self.getSiteDomain())+r"/(?P<tp>threads|posts)/(.+\.)?(?P<id>\d+)/?"
def use_pagecache(self):
'''
@@ -131,7 +131,12 @@ class BaseXenForoForumAdapter(BaseSiteAdapter):
self.story.setMetadata('dateUpdated', date)
self.chapterUrls.append((name,self.getURLPrefix()+'/'+url))
## only use tags if threadmarks for chapters.
## a bit arbitrary, but likely.
for tag in soup.findAll('a',{'class':'tag'}):
self.story.addToList('forumtags',stripHTML(tag))
soup = soup.find('li',{'class':'message'}) # limit first post for date stuff below. ('#' posts above)
# Now go hunting for the 'chapter list'.
@@ -204,6 +209,15 @@ class BaseXenForoForumAdapter(BaseSiteAdapter):
def getChapterText(self, url):
logger.debug('Getting chapter text from: %s' % url)
## there's some history of stories with links to the wrong
## page. This changes page#post URLs to perma-link URLs.
## Which will be redirected back to page#posts, but the
## *correct* ones.
# http://forums.sufficientvelocity.com/threads/harry-potter-and-the-not-fatal-at-all-cultural-exchange-program.330/page-4#post-39915
# https://forums.sufficientvelocity.com/posts/39915/
if '#post-' in url:
url = self.getURLPrefix()+'/posts/'+url.split('#post-')[1]+'/'
origurl = url
(data,opened) = self._fetchUrlOpened(url)
url = opened.geturl()
@@ -216,6 +230,7 @@ class BaseXenForoForumAdapter(BaseSiteAdapter):
if '#' in url:
anchorid = url.split('#')[1]
soup = soup.find('li',id=anchorid)
bq = soup.find('blockquote')
bq.name='div'
+1
View File
@@ -220,6 +220,7 @@ def get_valid_keywords():
'chapter_title_add_pattern',
'chapter_title_new_pattern',
'chapter_title_addnew_pattern',
'title_chapter_range_pattern',
'mark_new_chapters',
'check_next_chapter',
'skip_author_cover',
+27 -6
View File
@@ -148,8 +148,10 @@ zip_filename: ${title}-${siteabbrev}_${storyId}${formatext}.zip
## zip_filename.
allow_unsafe_filename: false
## The regex pattern of 'unsafe' filename chars for above.
#output_filename_safepattern:[^a-zA-Z0-9_\. \[\]\(\)&'-]+
## The regex pattern of 'unsafe' filename chars for above. First
## character . OR any one or more characters that are NOT a letter,
## number, or one of _. []()&'-
output_filename_safepattern:(^\.|/\.|[^a-zA-Z0-9_\. \[\]\(\)&'-]+)
## entries to make epub subjects and calibre tags
## lastupdate creates two tags: "Last Update Year/Month: %Y/%m" and "Last Update: %Y/%m/%d"
@@ -373,13 +375,14 @@ cover_exclusion_regexp:/clear.png
strip_chapter_numbers:false
## Copy title to tagsfromtitle for parsing tags.
add_to_extra_valid_entries:,tagsfromtitle
add_to_extra_valid_entries:,tagsfromtitle,forumtags
## '.NOREPL' tells the system to *not* apply title's
## in/exclude/replace_metadata -- Only works on include_in_ lines.
include_in_tagsfromtitle:title.NOREPL
tagsfromtitle_label:Tags from Title
forumtags_label:Tags from Forum
## might want to do this, maybe not. Will often include category, but
## also often include non-category stuff.
@@ -396,18 +399,20 @@ add_to_replace_metadata:
tagsfromtitle=>[\(\)\[\]]=>
# change (spaces)slash(or semicolon)(spaces) to comma
tagsfromtitle=> *[/;] *=>,
tagsfromtitle=> x =>,
tagsfromtitle=> [xX] =>,
# remove [] or () blocks and leading/trailing spaces/dashes/colons
title=>[-: ]*[\(\[]([^\]\)]+)[\)\]][-: ]*=>
# remove 'Thread' and the next word, usually "Thread 2", "Thread
# four", "Thread iv", etc
title=>[-: ]*[Tt]hread [^ ]+[-: ]*=>
title,tagsfromtitle=>[-: ]*[Tt]hread [^ ]+[-: ]*=>
add_to_extra_titlepage_entries:,tagsfromtitle
add_to_extra_titlepage_entries:,tagsfromtitle,forumtags
## '.SPLIT' tells the system to split by ','
add_to_include_subject_tags:,tagsfromtitle.SPLIT
## Or to also include forumtags add this to personal.ini:
#add_to_include_subject_tags:,forumtags
## base_xenforoforum reads Published and Updated datetimes from
## Threadmarks if used, or from the posted & updated times of the
@@ -1851,6 +1856,15 @@ rating_titles: R=RESTRICTED (16+), E=EXEMPT (18+), I=ART HOUSE, T=To every, A=IN
adult_ratings: E,R
[www.mediaminer.org]
dateUpdated_format:%%Y-%%m-%%d %%H:%%M:%%S
## Note that mediaminer doesn't give datePublished on the story's
## index page--it's collected from the earliest uploaded chapter. So
## it's not available when only fetching metadata.
datePublished_format:%%Y-%%m-%%d %%H:%%M:%%S
## some sites include images that we don't ever want becoming the
## cover image. This lets you exclude them.
cover_exclusion_regexp:/img/rss.png
[www.midnightwhispers.ca]
## Some sites do not require a login, but do require the user to
@@ -1918,6 +1932,13 @@ extracategories:Psych
## Site dedicated to these categories/characters/ships
extracategories:Queer as Folk
[quotev.com]
extra_valid_entries:pages,readers,reads,favorites
pages_label:Pages
readers_label:Readers
reads_label:Reads
favorites_label:Favorites
[www.restrictedsection.org]
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
+52 -28
View File
@@ -41,7 +41,7 @@ def get_update_data(inputio,
## Save the path to the .opf file--hrefs inside it are relative to it.
relpath = get_path_part(rootfilename)
oldcover = None
calibrebookmark = None
logfile = None
@@ -158,20 +158,20 @@ def get_update_data(inputio,
chapterorigtitle = soup.find('meta',{'name':'chapterorigtitle'})
if chapterorigtitle:
datamaps[currenturl]['chapterorigtitle'] = chapterorigtitle['content']
chaptertitle = soup.find('meta',{'name':'chaptertitle'})
if chaptertitle:
datamaps[currenturl]['chaptertitle'] = chaptertitle['content']
soups.append(bodysoup)
filecount+=1
try:
calibrebookmark = epub.read("META-INF/calibre_bookmarks.txt")
except:
pass
#for k in images.keys():
#print("\tlongdesc:%s\n\tData len:%s\n"%(k,len(images[k])))
# print("datamaps:%s"%datamaps)
@@ -199,7 +199,7 @@ def get_story_url_from_html(inputio,_is_good_url=None):
## Save the path to the .opf file--hrefs inside it are relative to it.
relpath = get_path_part(rootfilename)
# spin through the manifest--only place there are item tags.
for item in contentdom.getElementsByTagName("item"):
# First, count the 'chapter' files. FFF uses file0000.xhtml,
@@ -225,7 +225,7 @@ def reset_orig_chapters_epub(inputio,outfile):
## build zip in memory in case updating in place(CLI).
zipio = StringIO()
## 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.
@@ -240,19 +240,28 @@ def reset_orig_chapters_epub(inputio,outfile):
outputepub.debug = 3
changed = False
unmerge_tocncxdoms = {}
## spin through file contents, saving any unmerge toc.ncx files.
for zf in inputepub.namelist():
## logger.debug("zf:%s"%zf)
if zf.endswith('/toc.ncx'):
## logger.debug("toc.ncx zf:%s"%zf)
unmerge_tocncxdoms[zf] = parseString(inputepub.read(zf))
tocncxdom = parseString(inputepub.read('toc.ncx'))
## spin through file contents.
for zf in inputepub.namelist():
if zf not in ['mimetype','toc.ncx'] :
if zf not in ['mimetype','toc.ncx'] and not zf.endswith('/toc.ncx'):
entrychanged = False
data = inputepub.read(zf)
# if isinstance(data,unicode):
# logger.debug("\n\n\ndata is unicode\n\n\n")
if re.match(r'.*/file\d+\.xhtml',zf):
#logger.debug("zf:%s"%zf)
data = data.decode('utf-8')
soup = bs.BeautifulSoup(data,"html5lib")
chapterorigtitle = None
tag = soup.find('meta',{'name':'chapterorigtitle'})
if tag:
@@ -265,7 +274,7 @@ def reset_orig_chapters_epub(inputio,outfile):
chaptertoctitle = tag['content']
elif chapterorigtitle:
chaptertoctitle = chapterorigtitle
chaptertitle = None
tag = soup.find('meta',{'name':'chaptertitle'})
if tag:
@@ -281,34 +290,33 @@ def reset_orig_chapters_epub(inputio,outfile):
entrychanged = ( origdata != data )
changed = changed or entrychanged
if entrychanged:
## go after the TOC entry, too.
# <navPoint id="file0005" playOrder="6">
# <navLabel>
# <text>5. (new) Chapter 4</text>
# </navLabel>
# <content src="OEBPS/file0005.xhtml"/>
# </navPoint>
for contenttag in tocncxdom.getElementsByTagName("content"):
if contenttag.getAttribute('src') == zf:
texttag = contenttag.parentNode.getElementsByTagName('navLabel')[0].getElementsByTagName('text')[0]
texttag.childNodes[0].replaceWholeText(chaptertoctitle)
# logger.debug("text label:%s"%texttag.toxml())
continue
_replace_tocncx(tocncxdom,zf,chaptertoctitle)
## Also look for and update individual
## book toc.ncx files for anthology in case
## it's unmerged.
zf_toc = zf[:zf.rfind('/OEBPS/')]+'/toc.ncx'
mergedprefix_len = len(zf[:zf.rfind('/OEBPS/')])+1
if zf_toc in unmerge_tocncxdoms:
_replace_tocncx(unmerge_tocncxdoms[zf_toc],zf[mergedprefix_len:],chaptertoctitle)
outputepub.writestr(zf,data.encode('utf-8'))
else:
# possibly binary data, thus no .encode().
outputepub.writestr(zf,data)
for tocnm, tocdom in unmerge_tocncxdoms.items():
outputepub.writestr(tocnm,tocdom.toxml(encoding='utf-8'))
outputepub.writestr('toc.ncx',tocncxdom.toxml(encoding='utf-8'))
outputepub.close()
# 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
# only *actually* write if changed.
if changed:
if isinstance(outfile,basestring):
@@ -319,5 +327,21 @@ def reset_orig_chapters_epub(inputio,outfile):
inputepub.close()
zipio.close()
return changed
def _replace_tocncx(tocncxdom,zf,chaptertoctitle):
## go after the TOC entry, too.
# <navPoint id="file0005" playOrder="6">
# <navLabel>
# <text>5. (new) Chapter 4</text>
# </navLabel>
# <content src="OEBPS/file0005.xhtml"/>
# </navPoint>
for contenttag in tocncxdom.getElementsByTagName("content"):
if contenttag.getAttribute('src') == zf:
texttag = contenttag.parentNode.getElementsByTagName('navLabel')[0].getElementsByTagName('text')[0]
texttag.childNodes[0].replaceWholeText(chaptertoctitle)
#logger.debug("text label:%s"%texttag.toxml())
continue
+5 -2
View File
@@ -927,9 +927,12 @@ class Story(Configurable):
if not allowunsafefilename:
values={}
pattern = re_compile(self.getConfig("output_filename_safepattern",r"[^a-zA-Z0-9_\. \[\]\(\)&'-]+"),"output_filename_safepattern")
pattern = re_compile(self.getConfig("output_filename_safepattern",r"(^\.|/\.|[^a-zA-Z0-9_\. \[\]\(\)&'-]+)"),"output_filename_safepattern")
for k in origvalues.keys():
values[k]=re.sub(pattern,'_', removeAllEntities(self.getMetadata(k)))
if k == 'formatext': # don't do file extension--we set it anyway.
values[k]=self.getMetadata(k)
else:
values[k]=re.sub(pattern,'_', removeAllEntities(self.getMetadata(k)))
return string.Template(template).substitute(values).encode('utf8')
+3 -5
View File
@@ -10,13 +10,11 @@ https://github.com/pypa/sampleproject
# Always prefer setuptools over distutils
from setuptools import setup, find_packages
# To use a consistent encoding
from codecs import open
import codecs
from os import path
here = path.abspath(path.dirname(__file__))
# Get the long description from the relevant file
with open(path.join(here, 'DESCRIPTION.rst'), encoding='utf-8') as f:
with codecs.open('DESCRIPTION.rst', encoding='utf-8') as f:
long_description = f.read()
setup(
@@ -25,7 +23,7 @@ setup(
# Versions should comply with PEP440. For a discussion on single-sourcing
# the version across setup.py and the project code, see
# https://packaging.python.org/en/latest/single_source_version.html
version="2.2.11",
version="2.2.13",
description='A tool for downloading fanfiction to eBook formats',
long_description=long_description,
+1 -1
View File
@@ -1,6 +1,6 @@
# ffd-retief-hrd fanficfare
application: fanficfare
version: 2-2-11
version: 2-2-13
runtime: python27
api_version: 1
threadsafe: true
+10 -36
View File
@@ -27,43 +27,17 @@
much easier. </p>
</div>
<h3>New Name and URL</h3>
<p>
This version changes the name of the project from
FanFictionDownLoader(FFDL) to FanFicFare. Along with
the name change, there are changes to the internal
structuring of the project code, but not to the functionality.
</p>
<p>
If you had saved configuration (including your
username/password for fic sites) in FFDL, you will need to
copy it over to FanFicFare. Visit
the <a href="http://fanfictiondownloader.appspot.com/editconfig">FFDL
User Config</a> page, copy your settings and then paste
them on
the <a href="http://fanficfare.appspot.com/editconfig">FanFicFare
User Config</a> page.
</p>
<h3>Changes:</h3>
<ul>
<li>Add chapter limits with URL by giving chapter range.<br>
Examples:<br>
<ul>
<li>https://www.fanfiction.net/s/2565609/1/[4] <i>Chapter 4 only</i></li>
<li>https://www.fanfiction.net/s/2565609/1/[6-10] <i>Chapters 6, 7, 8, 9 &amp; 10 only</i></li>
<li>https://www.fanfiction.net/s/2565609/1/[-10] <i>Chapters 1-10 only</i></li>
<li>https://www.fanfiction.net/s/2565609/1/[150-] <i>Chapters 150 and up only</i></li>
</ul>
The chapter range will be included in the ebook title. Can be changed or disabled in config with title_chapter_range_pattern.
</li>
<li>New base_xenforoforum adapter type. The details are complex enough that I've started a <a href="https://github.com/JimmXinu/FanFicFare/wiki/BaseXenForoForumAdapters">new wiki page</a> for future reference. Please refer to it for more details.</li>
<li>New [base_efiction] and [base_xenforoforum] sections for common settings for eFiction Base and XenForoForum Base adapters respectively.</li>
<li>New site: <a href="https://forums.spacebattles.com/forums/creative-writing.18/">forums.spacebattles.com</a> base_xenforoforum adapter</li>
<li>New site: <a href="https://forums.sufficientvelocity.com/forums/user-fiction.2/">forums.sufficientvelocity.com</a> base_xenforoforum adapter</li>
<li>New site: <a href="http://ninelives.dark-solace.org/">ninelives.dark-solace.org</a> base_efiction adapter</li>
<li>New description_limit feature to explicitly limit the allowable length of the description.</li>
<li>Fixes for http://spikeluver.com</li>
<li>New Russian language site: masseffect2.in -- Thanks to PlushBeaver for adding this.</li>
<li>New site: <a href="http://www.quotev.com/">quotev.com</a>, thanks cryzed!</li>
<li>Convert dark-solace.org(Elysium fields) to eFiction Base adapter to correct for site changes.</li>
<li>Fix eFiction Base Author URLs.</li>
<li>Switch chapter #post-12345 chapter URLs to permalinks for XenForoForum Base adapters.</li>
<li>Record forum thread tags in XenForoForum Base adapters. Appears on title page by default, but not calibre tags.</li>
<li>Change output_filename_safepattern to replace leading '.' for benefit of *nix OSes.</li>
<li>Fix mediaminer.org for site changes.</li>
<li>Fix to hpfandom.net change story URL when given chapter URL instead of story start URL.</li>
<li>Allow forum.questionablequesting.com for questionablequesting.com.</li>
</ul>
<p>
Questions? Check out our
@@ -73,7 +47,7 @@
If you have any problems with this application, please
report them in
the <a href="http://groups.google.com/group/fanfic-downloader">FanFicFare Google Group</a>. The
<a href="http://2-2-10.fanficfare.appspot.com">previous version
<a href="http://2-2-12.fanficfare.appspot.com">previous version
</a> is also available for you to use if necessary.
</p>
<div id='error'>