Compare commits

...
Author SHA1 Message Date
Jim Miller 0528128a32 Bump PI version. 2013-01-05 16:26:59 -06:00
Jim Miller 8e6f23ab3f Don't allow chapter URLs for multi-chapter stories on restrictedsection.org. 2013-01-05 11:56:43 -06:00
Jim Miller af01875d46 restrictedsection.org in plugin-defaults.ini 2013-01-05 11:48:20 -06:00
Jim Miller 4a228e08a9 Add site restrictedsection.org, update web version/comments 2013-01-05 11:47:53 -06:00
Jim Miller e5ecdcda73 Workarounds and fixes for fimfic API bugs. 2013-01-04 21:03:45 -06:00
Ida f83e03af05 Allow pulling stories from sds section of pommedesang.com as well. 2013-01-04 17:54:15 -05:00
Ida 09a962ddf5 Added adapters for www.dotmoon.net,
efiction.esteliel.de, and pommedesang.com
2013-01-03 23:37:41 -05:00
Jim Miller 38267a6b5a Bump previous version link. 2013-01-02 10:45:38 -06:00
Jim Miller 9789e26df4 Added tag FanFictionDownLoader-4.4.36 for changeset 3fb26ce4c1eb 2013-01-02 10:40:25 -06:00
Jim Miller 6dae268003 Added tag calibre-plugin-1.7.02 for changeset 3fb26ce4c1eb 2013-01-02 10:40:11 -06:00
Jim Miller af09ac59a0 Bump versions, etc. 2013-01-02 10:39:55 -06:00
Dan 9c245af0fd New adapter for www.potterfics.com 2013-01-02 07:20:05 +00:00
Jim Miller 3a76d65396 Don't include adapter_potterficscom yet--not checked in. 2013-01-01 13:24:41 -06:00
Jim Miller 5c53c8f135 Remove defunct www.yourfanfiction.com, Correct ao3 extra metadata freefromtags to freeformtags. 2013-01-01 13:24:09 -06:00
Jim Miller 5fd88e661b Add feature to set reason for several Reject URLs at once. PI only. 2012-12-29 22:37:53 -06:00
Jim Miller 8419ef4ad0 Workaround for fimf's API issue with non-viewable chapters given. 2012-12-29 22:36:59 -06:00
Jim Miller 0e8a552e8d Added tag calibre-plugin-1.7.01 for changeset 3ab70c152436 2012-12-15 10:04:57 -06:00
16 changed files with 1270 additions and 119 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# ffd-retief-hrd fanfictiondownloader
application: fanfictiondownloader
version: 4-4-35
version: 4-4-37
runtime: python27
api_version: 1
threadsafe: true
+1 -1
View File
@@ -27,7 +27,7 @@ class FanFictionDownLoaderBase(InterfaceActionBase):
description = 'UI plugin to download FanFiction stories from various sites.'
supported_platforms = ['windows', 'osx', 'linux']
author = 'Jim Miller'
version = (1, 7, 1)
version = (1, 7, 3)
minimum_calibre_version = (0, 8, 57)
#: This field defines the GUI plugin class that contains all the code
+14 -7
View File
@@ -153,7 +153,7 @@ class RejectURLList:
self.sync_lock = threading.RLock()
self.listcache = None
def _read_list_from_text(self,text):
def _read_list_from_text(self,text,addreasontext=None):
cache = {}
for line in text.splitlines():
if ',' in line:
@@ -162,6 +162,10 @@ class RejectURLList:
(rejurl,note) = (line,'')
rejurl = getNormalStoryURL(rejurl)
if rejurl:
if addreasontext and note:
note = note +" - "+addreasontext
elif addreasontext:
note = addreasontext
cache[rejurl] = note
return cache
@@ -200,8 +204,8 @@ class RejectURLList:
del listcache[url]
self._save_list(listcache)
def add_text(self,rejecttext):
self.add(self._read_list_from_text(rejecttext).items())
def add_text(self,rejecttext,addreasontext):
self.add(self._read_list_from_text(rejecttext,addreasontext).items())
def add(self,rejectlist,clear=False):
# rejectlist=list of (url,note) tuples.
@@ -524,7 +528,8 @@ class BasicTab(QWidget):
rejectlist,
rejectreasons=rejecturllist.get_reject_reasons(),
header="Edit Reject URLs List",
show_delete=False)
show_delete=False,
show_all_reasons=False)
d.exec_()
if d.result() != d.Accepted:
@@ -552,11 +557,13 @@ class BasicTab(QWidget):
"http://example.com?story.php?sid=5,Reason why I rejected it",
icon=self.windowIcon(),
title="Add Reject URLs",
label="Add Reject URLs. Use: <b>http://...,note</b>",
tooltip="One URL per line, everything after <b>,</b> will be put in the note.")
label="Add Reject URLs. Use: <b>http://...,note</b><br>Invalid story URLs will be ignored.",
tooltip="One URL per line, everything after <b>,</b> will be put in the note.",
rejectreasons=rejecturllist.get_reject_reasons(),
reasonslabel='Add this reason to all URLs added:')
d.exec_()
if d.result() == d.Accepted:
rejecturllist.add_text(d.get_plain_text())
rejecturllist.add_text(d.get_plain_text(),d.get_reason_text())
class PersonalIniTab(QWidget):
+67 -14
View File
@@ -45,6 +45,19 @@ collision_order=[SKIP,
OVERWRITEALWAYS,
CALIBREONLY,]
# This is a more than slightly kludgey way to get
# EditWithComplete to *not* alpha-order the reasons, but leave
# them in the order entered. If
# calibre.gui2.complete2.CompleteModel.set_items ever changes,
# this function will need to also.
def complete_model_set_items_kludge(self, items):
items = [unicode(x.strip()) for x in items]
items = [x for x in items if x]
items = tuple(items)
self.all_items = self.current_items = items
self.current_prefix = ''
self.reset()
class NotGoingToDownload(Exception):
def __init__(self,error,icon='dialog_error.png'):
self.error=error
@@ -779,19 +792,6 @@ class RejectListTableWidget(QTableWidget):
note_cell = EditWithComplete(self)
# This is a more than slightly kludgey way to get
# EditWithComplete to *not* alpha-order the reasons, but leave
# them in the order entered. If
# calibre.gui2.complete2.CompleteModel.set_items ever changes,
# this function will need to also.
def complete_model_set_items_kludge(self, items):
items = [unicode(x.strip()) for x in items]
items = [x for x in items if x]
items = tuple(items)
self.all_items = self.current_items = items
self.current_prefix = ''
self.reset()
note_cell.lineEdit().mcompleter.model().set_items = \
partial(complete_model_set_items_kludge,
note_cell.lineEdit().mcompleter.model())
@@ -895,6 +895,7 @@ class RejectListDialog(SizePersistedDialog):
header="List of Books to Reject",
icon='rotate-right.png',
show_delete=True,
show_all_reasons=True,
save_size_name='ffdl:reject list dialog'):
SizePersistedDialog.__init__(self, gui, save_size_name)
self.gui = gui
@@ -935,6 +936,26 @@ class RejectListDialog(SizePersistedDialog):
spacerItem1 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding)
button_layout.addItem(spacerItem1)
if show_all_reasons:
self.reason_edit = EditWithComplete(self)
self.reason_edit.lineEdit().mcompleter.model().set_items = \
partial(complete_model_set_items_kludge,
self.reason_edit.lineEdit().mcompleter.model())
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.")
horz.addWidget(label)
horz.addWidget(self.reason_edit)
horz.insertStretch(-1)
layout.addLayout(horz)
options_layout = QHBoxLayout()
if show_delete:
@@ -960,13 +981,18 @@ class RejectListDialog(SizePersistedDialog):
def get_reject_list(self):
return self.rejects_table.get_reject_list()
def get_reason_text(self):
return unicode(self.reason_edit.currentText()).strip()
def get_deletebooks(self):
return self.deletebooks.isChecked()
class EditTextDialog(QDialog):
def __init__(self, parent, text,
icon=None, title=None, label=None, tooltip=None):
icon=None, title=None, label=None, tooltip=None,
rejectreasons=[],reasonslabel=None
):
QDialog.__init__(self, parent)
self.resize(600, 500)
self.l = QVBoxLayout()
@@ -987,6 +1013,29 @@ class EditTextDialog(QDialog):
self.label.setToolTip(tooltip)
self.textedit.setToolTip(tooltip)
if rejectreasons or reasonslabel:
self.reason_edit = EditWithComplete(self)
self.reason_edit.lineEdit().mcompleter.model().set_items = \
partial(complete_model_set_items_kludge,
self.reason_edit.lineEdit().mcompleter.model())
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)
label.setToolTip(reasonslabel)
horz.addWidget(label)
horz.addWidget(self.reason_edit)
self.l.addLayout(horz)
else:
self.l.addWidget(self.reason_edit)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
@@ -994,3 +1043,7 @@ class EditTextDialog(QDialog):
def get_plain_text(self):
return unicode(self.textedit.toPlainText())
def get_reason_text(self):
return unicode(self.reason_edit.currentText()).strip()
+5
View File
@@ -404,8 +404,13 @@ class FanFictionDownLoaderPlugin(InterfaceAction):
bookids=[]
rejectlist=[]
addreasontext=d.get_reason_text()
for (bookid,url,note) in d.get_reject_list():
bookids.append(bookid)
if addreasontext and note:
note = note +" - "+addreasontext
elif addreasontext:
note = addreasontext
rejectlist.append((url,note))
print("Adding (%s) to Reject List: %s"%(url,note))
+50 -9
View File
@@ -469,8 +469,9 @@ extratags: FanFiction,Testing,HTML
#is_adult:true
## AO3 adapter defines a few extra metadata entries.
extra_valid_entries:fandoms,freefromtags,ao3categories,comments,kudos,hits,bookmarks,collections
extra_valid_entries:fandoms,freeformtags,freefromtags,ao3categories,comments,kudos,hits,bookmarks,collections
fandoms_label:Fandoms
freeformtags_label:Freeform Tags
freefromtags_label:Freeform Tags
ao3categories_label:AO3 Categories
comments_label:Comments
@@ -479,11 +480,15 @@ hits_label:Hits
collections_label:Collections
bookmarks_label:Bookmarks
## freeformtags was previously typo'ed as freefromtags. This way,
## freefromtags will still work for people who've used it.
include_in_freefromtags:freeformtags
## adds to titlepage_entries instead of replacing it.
#extra_titlepage_entries: fandoms,freefromtags,ao3categories,comments,kudos,hits,bookmarks
#extra_titlepage_entries: fandoms,freeformtags,ao3categories,comments,kudos,hits,bookmarks
## adds to include_subject_tags instead of replacing it.
#extra_subject_tags:fandoms,freefromtags,ao3categories
#extra_subject_tags:fandoms,freeformtags,ao3categories
[ashwinder.sycophanthex.com]
## Site dedicated to these categories/characters/ships
@@ -608,6 +613,10 @@ cliches_label:Character Cliches
#extra_logpage_entries: themes,timeline,cliches
#extra_subject_tags: themes,timeline,cliches
[efiction.esteliel.de]
## Site dedicated to these categories/characters/ships
extracategories:Lord of the Rings
[erosnsappho.sycophanthex.com]
## Site dedicated to these categories/characters/ships
extracategories:Harry Potter
@@ -769,6 +778,22 @@ extracategories:One Direction
## personal.ini, not defaults.ini.
#is_adult:true
[pommedesang.com]
## Site dedicated to these categories/characters/ships
extracategories:Anita Blake Vampire Hunter
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
## commandline version, this should go in your personal.ini, not
## defaults.ini.
#username:YourName
#password:yourpassword
## Some sites also require the user to confirm they are adult for
## adult content. In commandline version, this should go in your
## personal.ini, not defaults.ini.
#is_adult:true
[ponyfictionarchive.net]
## Site dedicated to these categories/characters/ships
extracategories:My Little Pony: Friendship is Magic
@@ -921,6 +946,14 @@ extracategories:InuYasha
extracharacters:Sesshoumaru,Kagome
extraships:Sesshoumaru/Kagome
[www.dotmoon.net]
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
## commandline version, this should go in your personal.ini, not
## defaults.ini.
#username:YourName
#password:yourpassword
[www.efpfanfic.net]
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
@@ -1094,6 +1127,10 @@ extraships:Harry Potter/Ginny Weasley
## Site dedicated to these categories/characters/ships
extracategories:Harry Potter
[www.potterfics.com]
## Site dedicated to these categories/characters/ships
extracategories:Harry Potter
[www.prisonbreakfic.net]
## Site dedicated to these categories/characters/ships
extracategories:Prison Break
@@ -1107,6 +1144,16 @@ extracategories:Queer as Folk
## this should go in your personal.ini, not defaults.ini.
#is_adult:true
[www.restrictedsection.org]
extracategories:Harry Potter
extragenres:Erotica
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
## commandline version, this should go in your personal.ini, not
## defaults.ini.
#username:YourName
#password:yourpassword
[www.scarvesandcoffee.net]
## Site dedicated to these categories/characters/ships
extracategories:Glee
@@ -1256,12 +1303,6 @@ extracategories:Stargate: Atlantis
## this should go in your personal.ini, not defaults.ini.
#is_adult:true
[www.yourfanfiction.com]
## Some sites do not require a login, but do require the user to
## confirm they are adult for adult content. In commandline version,
## this should go in your personal.ini, not defaults.ini.
#is_adult:true
[overrides]
## It may sometimes be useful to override all of the specific format,
## site and site:format sections in your private configuration. For
+5 -1
View File
@@ -73,7 +73,6 @@ import adapter_iketernalnet
import adapter_onedirectionfanfictioncom
import adapter_prisonbreakficnet
import adapter_storiesofardacom
import adapter_yourfanfictioncom
import adapter_samdeanarchivenu
import adapter_destinysgatewaycom
import adapter_ncisfictionnet
@@ -107,6 +106,11 @@ import adapter_indeathnet
import adapter_jlaunlimitedcom
import adapter_qafficcom
import adapter_efpfanficnet
import adapter_potterficscom
import adapter_efictionestelielde
import adapter_dotmoonnet
import adapter_pommedesangcom
import adapter_restrictedsectionorg
## This bit of complexity allows adapters to be added by just adding
## importing. It eliminates the long if/else clauses we used to need
@@ -217,7 +217,7 @@ class ArchiveOfOurOwnOrgAdapter(BaseSiteAdapter):
if a != None:
genres = a.findAll('a',{'class':"tag"})
for genre in genres:
self.story.addToList('freefromtags',genre.string)
self.story.addToList('freeformtags',genre.string)
self.story.addToList('genre',genre.string)
a = metasoup.find('dd',{'class':"category tags"})
@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# Copyright 2013 Fanficdownloader team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import time
import logging
logger = logging.getLogger(__name__)
import re
import urllib2
from .. import BeautifulSoup as bs
from ..htmlcleanup import stripHTML
from .. import exceptions as exceptions
from base_adapter import BaseSiteAdapter, makeDate
def getClass():
return DotMoonNetAdapter
# Class name has to be unique. Our convention is camel case the
# sitename with Adapter at the end. www is skipped.
class DotMoonNetAdapter(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])
logger.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
# normalized story URL. www.dotmoon.net/library_view.php?storyid=3
self._setURL('http://' + self.getSiteDomain() + '/library_view.php?storyid='+self.story.getMetadata('storyId'))
# Each adapter needs to have a unique site abbreviation.
self.story.setMetadata('siteabbrev','dotm')
# The date format will vary from site to site.
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
self.dateformat = "%Y-%m-%d"
@staticmethod # must be @staticmethod, don't remove it.
def getSiteDomain():
# The site domain. Does have www here, if it uses it.
return 'www.dotmoon.net'
def getSiteExampleURLs(self):
return "http://"+self.getSiteDomain()+"/library_view.php?storyid=1234"
def getSiteURLPattern(self):
return re.escape("http://"+self.getSiteDomain()+"/library_view.php?storyid=")+r"\d+$"
## Login seems to be reasonably standard across eFiction sites.
def needToLoginCheck(self, data):
if 'You must be logged in to read adult-rated stories' in data \
or 'Password incorrect' in data \
or "That username does not exist" in data:
return True
else:
return False
def performLogin(self, url):
params = {}
if self.password:
params['user'] = self.username
params['passwrd'] = self.password
else:
params['user'] = self.getConfig("username")
params['passwrd'] = self.getConfig("password")
loginUrl = 'http://' + self.getSiteDomain() + '/board/index.php'
logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl,
params['user']))
d = self._fetchUrl(loginUrl+'?action=login2&user='+params['user']+'&passwrd='+params['passwrd'])
d = self._fetchUrl(loginUrl)
if "Show unread posts since last visit" not in d : #Member Account
logger.info("Failed to login to URL %s as %s" % (loginUrl,
params['user']))
raise exceptions.FailedToLogin(url,params['user'])
return False
else:
return True
## Getting the chapter list and the meta data, plus 'is adult' checking.
def extractChapterUrlsAndMetadata(self):
# index=1 makes sure we see the story chapter index. Some
# sites skip that for one-chapter stories.
url = self.url
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)
if "Invalid story ID" in data:
raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Invalid story ID.")
# 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.
body=soup.findAll('body')[1]
body.find('table').extract()
## Title
a = body.find('b')
self.story.setMetadata('title',a.string)
# Find authorid and URL from... author url. http://www.dotmoon.net/board/index.php?action=profile;u=1'
a = body.find('a', href=re.compile(r"index.php\?action=profile;u=\d+"))
self.story.setMetadata('authorId',a['href'].split('=')[2])
self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href'])
self.story.setMetadata('author',a.string)
# Find the chapters: 'library_storyview.php?chapterid=3
chapters=body.findAll('a', href=re.compile(r"library_storyview.php\?chapterid=\d+$"))
if len(chapters)==0:
raise exceptions.FailedToDownload(self.getSiteDomain() +" says: No php/html chapters found.")
if len(chapters)==1:
self.chapterUrls.append((self.story.getMetadata('title'),'http://'+self.host+'/'+chapters[0]['href']))
else:
for chapter in chapters:
# just in case there's tags, like <i> in chapter titles.
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['href']))
self.story.setMetadata('numChapters',len(self.chapterUrls))
# other tags
labels = body.find('table', {'width':'390'}).findAll('td')
for labelspan in labels:
value = labelspan.nextSibling
label = labelspan.string
if label != None:
if 'Fandom' in label:
self.story.addToList('category',value.string)
if 'Setting' in label:
self.story.addToList('genre',value.string)
if 'Genre' in label:
self.story.addToList('genre',value.string)
if 'Style' in label:
self.story.addToList('genre',value.string)
if 'Rating' in label:
self.story.addToList('rating',value.string)
if 'Created' in label:
self.story.setMetadata('datePublished', makeDate(stripHTML(value), self.dateformat))
if 'Updated' in label:
self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat))
if 'Status' in label:
if 'Completed' in value.string:
self.story.setMetadata('status', 'Completed')
else:
self.story.setMetadata('status', 'In-Progress')
table=body.findAll('table', {'width':'400'})[1].find('td')
self.setDescription(url,stripHTML(table).split('Summary: ')[1])
# grab the text for an individual chapter.
def getChapterText(self, url):
logger.debug('Getting chapter text from: %s' % url)
soup = bs.BeautifulStoneSoup(self._fetchUrl(url),
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
div = soup.find('blockquote')
div.name='div'
if None == div:
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
return self.utf8FromSoup(url,div)
@@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
# Copyright 2013 Fanficdownloader team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import time
import logging
logger = logging.getLogger(__name__)
import re
import urllib2
from .. import BeautifulSoup as bs
from ..htmlcleanup import stripHTML
from .. import exceptions as exceptions
from base_adapter import BaseSiteAdapter, makeDate
def getClass():
return EfictionEstelielDeAdapter
# Class name has to be unique. Our convention is camel case the
# sitename with Adapter at the end. www is skipped.
class EfictionEstelielDeAdapter(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])
logger.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
# normalized story URL.
self._setURL('http://' + self.getSiteDomain() + '/viewstory.php?sid='+self.story.getMetadata('storyId'))
# Each adapter needs to have a unique site abbreviation.
self.story.setMetadata('siteabbrev','eesd')
# 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 'efiction.esteliel.de'
def getSiteExampleURLs(self):
return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234"
def getSiteURLPattern(self):
return re.escape("http://"+self.getSiteDomain()+"/viewstory.php?sid=")+r"\d+$"
## Getting the chapter list and the meta data, plus 'is adult' checking.
def extractChapterUrlsAndMetadata(self):
# index=1 makes sure we see the story chapter index. Some
# sites skip that for one-chapter stories.
url = self.url+'&index=1'
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 "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.")
# Now go hunting for all the meta data and the chapter list.
## Title and author
# 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.
pagetitle = soup.find('div',{'id':'pagetitle'})
## Title
a = pagetitle.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$"))
self.story.setMetadata('title',a.string)
# Find authorid and URL from... author url.
a = pagetitle.find('a', href=re.compile(r"viewuser.php\?uid=\d+"))
self.story.setMetadata('authorId',a['href'].split('=')[1])
self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href'])
self.story.setMetadata('author',a.string)
# Find the chapters:
for chapter in soup.findAll('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"&chapter=\d+$")):
# just in case there's tags, like <i> in chapter titles.
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['href']))
self.story.setMetadata('numChapters',len(self.chapterUrls))
# 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
list = soup.find('div', {'class':'listbox'})
labelspan=list.find('span',{'class':'label'})
value = labelspan.nextSibling
label = labelspan.string
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)
labels = list.findAll('b')
for labelspan in labels:
value = labelspan.nextSibling
label = labelspan.string
if 'Summary' in label:
## Everything until the next span class='label'
svalue = ""
while 'Rating' not in str(value):
svalue += str(value)
value = value.nextSibling
self.setDescription(url,svalue)
#self.story.setMetadata('description',stripHTML(svalue))
if 'Rating' in label:
self.story.setMetadata('rating', value)
if 'Words' in label:
self.story.setMetadata('numWords', value)
if 'Category' 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 '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:
if list.find('a', href=re.compile(r"series.php")) != None:
for series in asoup.findAll('a', href=re.compile(r"series.php\?seriesid=\d+")):
# Find Series name from series URL.
series_url = 'http://'+self.host+'/'+series['href']
# use BeautifulSoup HTML parser to make everything easier to find.
seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url))
storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$'))
i=1
for a in storyas:
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
name=seriessoup.find('div', {'id' : 'pagetitle'})
name.find('a').extract()
self.setSeries(name.text.split(' by[')[0], i)
i=0
break
i+=1
if i == 0:
break
except:
# I find it hard to care if the series parsing fails
pass
# 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),
selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags.
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)
@@ -81,8 +81,10 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
apiResponse = urllib2.urlopen("http://www.fimfiction.net/api/story.php?story=%s" % (self.story.getMetadata("storyId"))).read()
apiData = json.loads(apiResponse)
# Unfortunately, we still need to load the story index page to parse the characters
# Unfortunately, we still need to load the story index
# page to parse the characters. And chapters, now, too.
data = self._fetchUrl(self.url)
soup = bs.BeautifulSoup(data)
except urllib2.HTTPError, e:
if e.code == 404:
raise exceptions.StoryDoesNotExist(self.url)
@@ -114,16 +116,34 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
storyMetadata = apiData["story"]
self.story.setMetadata("title", storyMetadata["title"])
## Title
a = soup.find('a', href=re.compile(r'^/story/'+self.story.getMetadata('storyId')))
self.story.setMetadata('title',a.string)
# self.story.setMetadata("title", storyMetadata["title"])
# if not storyMetadata["title"]:
# raise exceptions.FailedToDownload("%s doesn't have a title in the API. This is a known fimfiction.net bug with titles containing ."%self.url)
self.story.setMetadata("author", storyMetadata["author"]["name"])
self.story.setMetadata("authorId", storyMetadata["author"]["id"])
self.story.setMetadata("authorUrl", "http://%s/user/%s" % (self.getSiteDomain(), storyMetadata["author"]["name"]))
# chapters = [{"chapterTitle": chapter["title"], "chapterURL": chapter["link"]} for chapter in storyMetadata["chapters"]]
chapters = [{"chapterTitle": chapter["title"], "chapterURL": chapter["link"]} for chapter in storyMetadata["chapters"]]
for chapter in chapters:
self.chapterUrls.append((chapter["chapterTitle"], chapter["chapterURL"]))
self.story.setMetadata("numChapters", len(self.chapterUrls))
# ## this is bit of a kludge based on the assumption all the
# ## 'bad' chapters will be at the end.
# ## limit down to the number of chapters reported by chapter_count.
# chapters = chapters[:storyMetadata["chapter_count"]]
# for chapter in chapters:
# self.chapterUrls.append((chapter["chapterTitle"], chapter["chapterURL"]))
# self.story.setMetadata("numChapters", len(self.chapterUrls))
for chapter in soup.findAll('a',{'class':'chapter_link'}):
self.chapterUrls.append((stripHTML(chapter), 'http://'+self.host+chapter['href']))
self.story.setMetadata('numChapters',len(self.chapterUrls))
# In the case of fimfiction.net, possible statuses are 'Completed', 'Incomplete', 'On Hiatus' and 'Cancelled'
# For the sake of bringing it in line with the other adapters, 'Incomplete' becomes 'In-Progress'
# and 'Complete' beomes 'Completed'. 'Cancelled' seems an important enough (not to mention more strictly true)
@@ -133,7 +153,17 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
status = storyMetadata["status"].replace("Incomplete", "In-Progress").replace("Complete", "Completed")
self.story.setMetadata("status", status)
self.story.setMetadata("rating", storyMetadata["content_rating_text"])
## Warnings aren't included in the API.
bottomli = soup.find('li',{'class':'bottom'})
if bottomli:
bottomspans = bottomli.findAll('span')
# the first span in bottom is the rating, obtained above.
if bottomspans and len(bottomspans) > 1:
for warning in bottomspans[1:]:
self.story.addToList('warnings',warning.string)
for category in storyMetadata["categories"]:
if storyMetadata["categories"][category]:
self.story.addToList("genre", category)
@@ -163,11 +193,11 @@ class FimFictionNetSiteAdapter(BaseSiteAdapter):
rawDateUpdated = storyMetadata["date_modified"]
self.story.setMetadata("dateUpdated", datetime.fromtimestamp(rawDateUpdated))
soup = bs.BeautifulSoup(data).find("div", {"class":"story"})
chars = soup.find("div", {"class":"story"})
# fimfic stopped putting the char name on or around the char
# icon now for some reason. Pull it from the image name with
# some heuristics.
for character in [character_icon["src"] for character_icon in soup.findAll("img", {"class":"character_icon"})]:
for character in [character_icon["src"] for character_icon in chars.findAll("img", {"class":"character_icon"})]:
# //static.fimfiction.net/images/characters/twilight_sparkle.png
# 5th split /, remove last four, replace _, capitolize every word(title())
char = character.split('/')[5][:-4].replace('_',' ').title()
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2012 Fanficdownloader team
# Copyright 2013 Fanficdownloader team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -28,22 +28,15 @@ from .. import exceptions as exceptions
from base_adapter import BaseSiteAdapter, makeDate
def getClass():
return YourFanfictionComAdapter
return PommeDeSangComAdapter
# Class name has to be unique. Our convention is camel case the
# sitename with Adapter at the end. www is skipped.
class YourFanfictionComAdapter(BaseSiteAdapter):
class PommeDeSangComAdapter(BaseSiteAdapter):
def __init__(self, config, url):
BaseSiteAdapter.__init__(self, config, url)
# yourfanfiction.com blocks the default user-agent. However,
# when asked, they said it was just general anti-spam, not
# targeted as us and offered to 'whitelist our IP'. Clearly,
# that wouldn't work, but it does let me do this in good
# conscience:
self.opener.addheaders = [('User-agent', 'FFDL/1.6')]
self.decode = ["Windows-1252",
"utf8"] # 1252 is a superset of iso-8859-1.
# Most sites that claim to be
@@ -57,26 +50,71 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1])
logger.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
# pommedesang.com has two 'sections', shown in URL as
# 'efiction' and 'sds' that change how things should be
# handled.
# http://pommedesang.com/efiction/viewstory.php?sid=1234
# http://pommedesang.com/sds/viewstory.php?sid=1234
self.section=self.parsedUrl.path.split('/',)[1]
# normalized story URL.
self._setURL('http://' + self.getSiteDomain() + '/viewstory.php?sid='+self.story.getMetadata('storyId'))
self._setURL('http://' + self.getSiteDomain() + '/'+self.section+'/viewstory.php?sid='+self.story.getMetadata('storyId'))
# Each adapter needs to have a unique site abbreviation.
self.story.setMetadata('siteabbrev','yff')
self.story.setMetadata('siteabbrev','pmds')
# The date format will vary from site to site.
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
self.dateformat = "%d %b %Y"
if 'efiction' in self.section:
self.dateformat = "%b %d, %Y"
else:
self.dateformat = "%m/%d/%y"
@staticmethod # must be @staticmethod, don't remove it.
def getSiteDomain():
# The site domain. Does have www here, if it uses it.
return 'www.yourfanfiction.com'
return 'pommedesang.com'
def getSiteExampleURLs(self):
return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234"
return "http://"+self.getSiteDomain()+"/efiction/viewstory.php?sid=1234 http://"+self.getSiteDomain()+"/sds/viewstory.php?sid=1234"
def getSiteURLPattern(self):
return re.escape("http://"+self.getSiteDomain()+"/viewstory.php?sid=")+r"\d+$"
return r"http://"+self.getSiteDomain()+"/(efiction|sds)?/viewstory.php\?sid=\d+$"
## Login seems to be reasonably standard across eFiction sites.
def needToLoginCheck(self, data):
if 'Registered Users Only' in data \
or 'There is no such account on our website' in data \
or "That password doesn't match the one in our database" in data:
return True
else:
return False
def performLogin(self, url):
params = {}
if self.password:
params['penname'] = self.username
params['password'] = self.password
else:
params['penname'] = self.getConfig("username")
params['password'] = self.getConfig("password")
params['cookiecheck'] = '1'
params['submit'] = 'Submit'
loginUrl = 'http://' + self.getSiteDomain() + '/'+self.section+'/user.php?action=login'
logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl,
params['penname']))
d = self._fetchUrl(loginUrl, params)
if "Member Account" not in d : #Member Account
logger.info("Failed to login to URL %s as %s" % (loginUrl,
params['penname']))
raise exceptions.FailedToLogin(url,params['penname'])
return False
else:
return True
## Getting the chapter list and the meta data, plus 'is adult' checking.
def extractChapterUrlsAndMetadata(self):
@@ -86,7 +124,7 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
# If the title search below fails, there's a good chance
# you need a different number. print data at that point
# and see what the 'click here to continue' url says.
addurl = "&ageconsent=ok&warning=4"
addurl = "&ageconsent=ok&warning=5"
else:
addurl=""
@@ -103,20 +141,12 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
else:
raise e
# The actual text that is used to announce you need to be an
# adult varies from site to site. Again, print data before
# the title search to troubleshoot.
# Since the warning text can change by warning level, let's
# look for the warning pass url. ksarchive uses
# &amp;warning= -- actually, so do other sites. Must be an
# eFiction book.
# viewstory.php?sid=1882&amp;warning=4
# viewstory.php?sid=1654&amp;ageconsent=ok&amp;warning=5
#print data
#m = re.search(r"'viewstory.php\?sid=1882(&amp;warning=4)'",data)
m = re.search(r"'viewstory.php\?sid=\d+((&amp;ageconsent=ok)?&amp;warning=\d+)'",data)
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
@@ -124,8 +154,7 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
# link and reload data.
addurl = m.group(1)
# correct stupid &amp; error in url.
# explicitly put ageconsent because google appengine regexp doesn't include it for some reason.
addurl = addurl.replace("&amp;","&")+'&ageconsent=ok'
addurl = addurl.replace("&amp;","&")
url = self.url+'&index=1'+addurl
logger.debug("URL 2nd try: "+url)
@@ -142,22 +171,14 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
if "Access denied. This story has not been validated by the adminstrators of this site." in data:
raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.")
# because for some reason, this works while simple 'print data' errors on ascii conversion.
# loopdata = data
# chklen=5000
# while len(loopdata) > 0:
# if len(loopdata) < 5000:
# chklen = len(loopdata)
# logger.info("loopdata: %s" % loopdata[:chklen])
# loopdata = loopdata[chklen:]
# 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
a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$"))
a = soup.find('a', href=re.compile('viewstory.php\?sid=\d+'))
self.story.setMetadata('title',a.string)
# Find authorid and URL from... author url.
@@ -169,7 +190,7 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
# Find the chapters:
for chapter in soup.findAll('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"&chapter=\d+$")):
# just in case there's tags, like <i> in chapter titles.
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['href']+addurl))
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+self.section+'/'+chapter['href']+addurl))
self.story.setMetadata('numChapters',len(self.chapterUrls))
@@ -182,6 +203,8 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
return d[k]
except:
return ""
# summary, rated, word count, categories, characters, genre, warnings, completed, published, updated, seires
# <span class="label">Rated:</span> NC-17<br /> etc
labels = soup.findAll('span',{'class':'label'})
@@ -192,11 +215,9 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
if 'Summary' in label:
## Everything until the next span class='label'
svalue = ""
while value and not defaultGetattr(value,'class') == 'label':
while not defaultGetattr(value,'class') == 'label':
svalue += str(value)
value = value.nextSibling
# sometimes poorly formated desc (<p> w/o </p>) leads
# to all labels being included.
self.setDescription(url,svalue)
#self.story.setMetadata('description',stripHTML(svalue))
@@ -217,17 +238,12 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
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=5'))
for genre in genres:
self.story.addToList('genre',genre.string)
if 'Tags' in label:
genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=7'))
genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')) # XXX
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=6'))
warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX
for warning in warnings:
self.story.addToList('warnings',warning.string)
@@ -241,25 +257,23 @@ class YourFanfictionComAdapter(BaseSiteAdapter):
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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+"))
series_name = a.string
series_url = 'http://'+self.host+'/'+a['href']
series_url = 'http://'+self.host+'/'+self.section+'/'+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.
storyas = seriessoup.findAll('a', href=re.compile(r'viewstory.php\?sid=\d+'))
storyas = seriessoup.findAll('a', href=re.compile('viewstory.php\?sid=\d+'))
i=1
for a in storyas:
# skip 'report this' and 'TOC' links
if 'contact.php' not in a['href'] and 'index' not in a['href']:
if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')):
if ('viewstory.php?sid='+self.story.getMetadata('storyId')) in a['href']:
self.setSeries(series_name, i)
break
i+=1
@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
# Copyright 2013 Fanficdownloader team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import datetime
import logging
logger = logging.getLogger(__name__)
import re
import urllib2
from .. import BeautifulSoup as bs
from ..htmlcleanup import stripHTML
from .. import exceptions as exceptions
from base_adapter import BaseSiteAdapter
# This function is called by the downloader in all adapter_*.py files
# in this dir to register the adapter class. So it needs to be
# updated to reflect the class below it. That, plus getSiteDomain()
# take care of 'Registering'.
def getClass():
return PotterFicsComAdapter
# Class name has to be unique. Our convention is camel case the
# sitename with Adapter at the end. www is skipped.
class PotterFicsComAdapter(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 correct
m = re.match(self.getSiteURLPattern(),url)
if m:
self.story.setMetadata('storyId',m.group('id'))
logger.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
# normalized story URL. gets rid of chapter if there, left with chapter index URL
nurl = "http://"+self.getSiteDomain()+"/historias/"+self.story.getMetadata('storyId')
self._setURL(nurl)
else:
raise exceptions.InvalidStoryURL(url,
self.getSiteDomain(),
self.getSiteExampleURLs())
logger.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
# Each adapter needs to have a unique site abbreviation.
self.story.setMetadata('siteabbrev','potficscom')
@staticmethod # must be @staticmethod, don't remove it.
def getSiteDomain():
# The site domain. Does have www here, if it uses it.
return 'www.potterfics.com'
def getSiteExampleURLs(self):
return \
"http://www.potterfics.com/historias/127583 "\
"http://www.potterfics.com/historias/127583/capitulo-1 "\
"http://www.potterfics.com/historias/127583/capitulo-4 "\
"http://www.potterfics.com/historias/92810 "\
"http://www.potterfics.com/historias/111194"
def getSiteURLPattern(self):
#http://www.potterfics.com/historias/127583
#http://www.potterfics.com/historias/127583/capitulo-1
#http://www.potterfics.com/historias/127583/capitulo-4
#http://www.potterfics.com/historias/92810 -> Complete story
#http://www.potterfics.com/historias/111194 -> Complete, single chap
p = re.escape("http://"+self.getSiteDomain()+"/historias/")+\
r"(?P<id>\d+)(/capitulo-(?P<ch>\d+))?/?$"
return p
def extractChapterUrlsAndMetadata(self):
#this converts '/historias/12345' to 'http://www.potterfics.com/historias/12345'
def makeAbsoluteURL(url):
if url[0] == '/':
url = 'http://'+self.getSiteDomain()+url
return url
#use this to get month numbers from Spanish months
SpanishMonths = {
'enero' : '01',
'febrero' : '02',
'marzo' : '03',
'abril' : '04',
'mayo' : '05',
'junio' : '06',
'julio' : '07',
'agosto' : '08',
'septiembre' : '09',
'octubre' : '10',
'noviembre' : '11',
'diciembre' : '12'
}
url = self.url
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
#print data
#deal with adult content warnings - doesn't seem to apply to this site
#set constant meta for this site:
#Set Language = Spanish
self.story.setMetadata('language', 'Spanish')
#Set Category = Harry Potter
# This is better done in plugin-defaults.ini and defaults.ini
# by adding a section for this site with the line:
# extracategories:Harry Potter
#self.story.addToList('category','Harry Potter')
#get the rest of the meta
# use BeautifulSoup HTML parser to make everything easier to find.
#self closing br and img present!
soup = bs.BeautifulSoup(data,selfClosingTags=('br','img'))
#we want the second table directly under the body, contains all the metadata
table = soup.html.body.findAll('table', recursive=False)[1]
#within that, we want the second row, first cell
cell = table.tr.findNextSibling('tr').td
#find first metadata block
mb = cell.div.findNextSibling('div')
#Get meta...
self.story.setMetadata('title', mb.b.string)
#strip out brackets on rating
self.story.setMetadata('rating', mb.span.string[1:-1])
#Completion status is denoted by the presence of this image:
if mb.find('img',title="Historia terminada"):
self.story.setMetadata('status', 'Completed')
else:
self.story.setMetadata('status', 'In-Progress')
#find next metadata block
#author details
mb = mb.findNextSibling('div')
self.story.setMetadata('author', mb.b.a.string.strip())
self.story.setMetadata('authorUrl', makeAbsoluteURL(mb.b.a['href']))
self.story.setMetadata('authorId', self.story.getMetadata('authorUrl').split('/')[4])
#dates and times
mb = mb.find('span')
#posted/published = Escrita
date = mb.find(text=re.compile('Escrita el ')).strip().split()
year = int(date[7][:-1]) # need to remove the last char from year, it is a comma
month = int(SpanishMonths[date[5].lower()])
day = int(date[3])
time = date[8].split(':')
hour = int(time[0])
minute = int(time[1])
self.story.setMetadata('datePublished', datetime.datetime(year, month, day, hour, minute))
#updated = Actualizada
date = mb.find(text=re.compile('Actualizada el ')).strip().split()
year = int(date[7][:-1]) # need to remove the last char from year, it is a comma
month = int(SpanishMonths[date[5].lower()])
day = int(date[3])
time = date[8].split(':')
hour = int(time[0])
minute = int(time[1])
self.story.setMetadata('dateUpdated', datetime.datetime(year, month, day, hour, minute))
mb = mb.span.findNextSibling('span').findNextSibling('span')
wc = mb.find(text=re.compile(' palabras en total')).strip()
self.story.setMetadata('numWords', wc.split()[0])
#then we come to categories and genres. Oh dear. On this site, categories hold everything from genre, to ships, to crossovers.
#To make things worse, there is also another genre field, which often holds similar/duplicate info. Links to genre pages do not work
#though, so perhaps those will be phased out?
#for now, put them all into the genre list
links = mb.findAll('a',href=re.compile('/(categorias|generos)/\d+'))
genlist = [i.string.strip() for i in links]
self.story.extendList('genre',genlist)
#get the chapter urls
#we can go back to the table cell we found before
#get its last element and work backwards to find the last ordered list on the page
list = cell.contents[len(cell)-1].findPrevious('ol')
chapters = []
revs = 0
chnum = 0
for li in list:
chnum += 1
chTitle = str(chnum) + '. ' + li.a.b.string.strip()
chURL = makeAbsoluteURL(li.a['href'])
chapters.append((chTitle,chURL))
#Get reviews, add to total
revs += int(li.div.a.string.split()[0])
self.chapterUrls.extend(chapters)
self.story.setMetadata('numChapters', len(chapters))
self.story.setMetadata('reviews', revs)
#Now for the description... this may be tricky...
#if it is there (doesn't have to be), it will be before the chapter list,
#separated by a horizontal rule, and after the google ad bar
#get list's parent div
mb = list.parent
#get the div before that, will either be the description, or the google ad bar
mb = mb.findPreviousSibling('div')
if 'google_ad_client' in str(mb):
#couldn't find description, leaving it blank
pass
else:
self.setDescription(url,mb)
# 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),
selfClosingTags=('br','hr','img'))
div = soup.find('div', {'id' : 'cuerpoHistoria'})
if None == div:
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
return self.utf8FromSoup(url,div)
@@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
# Copyright 2013 Fanficdownloader team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import time
import logging
logger = logging.getLogger(__name__)
import re
import urllib2
import cookielib as cl
from datetime import datetime
import json
from .. import BeautifulSoup as bs
from ..htmlcleanup import stripHTML
from .. import exceptions as exceptions
from base_adapter import BaseSiteAdapter, makeDate
def getClass():
return RestrictedSectionOrgSiteAdapter
class RestrictedSectionOrgSiteAdapter(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 = ""
# normalized story URL.
# get story/file and storyId from url--url validation guarantees query correct
m = re.match(self.getSiteURLPattern(),url)
if m:
self.story.setMetadata('storyId',m.group('id'))
logger.debug("storyId: (%s)"%self.story.getMetadata('storyId'))
# normalized story URL.
self._setURL('http://' + self.getSiteDomain() + '/' + m.group('filestory') + '.php?' + m.group('filestory') + '=' + self.story.getMetadata('storyId'))
logger.debug("storyUrl: (%s)"%self.story.getMetadata('storyUrl'))
else:
raise exceptions.InvalidStoryURL(url,
self.getSiteDomain(),
self.getSiteExampleURLs())
self.story.setMetadata('siteabbrev','ressec')
# The date format will vary from site to site.
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
self.dateformat = "%d %b %Y" # 20 Nov 2005
@staticmethod # must be @staticmethod, don't remove it.
def getSiteDomain():
return 'www.restrictedsection.org'
def getSiteExampleURLs(self):
return "http://"+self.getSiteDomain()+"/story.php?story=1234 http://"+self.getSiteDomain()+"/file.php?file=1234"
def getSiteURLPattern(self):
return re.escape("http://"+self.getSiteDomain())+r"/(?P<filestory>file|story).php\?(file|story)=(?P<id>\d+)$"
## Getting the chapter list and the meta data, plus 'is adult' checking.
def extractChapterUrlsAndMetadata(self):
url = self.url
logger.debug("URL: "+url)
# one-shot stories use file url instead of story. 'Luckily',
# we don't have to worry about one-shots becoming
# multi-chapter because ressec is frozen. Still need 'story'
# url for metadata, however.
try:
if 'file' in url:
data = self._postUrlUP(url)
soup = bs.BeautifulSoup(data)
storya = soup.find('a',href=re.compile(r"^story.php\?story=\d+"))
url = 'http://'+self.host+'/'+storya['href'].split('&')[0] # strip rs_session
fileas = soup.find('a',href=re.compile(r"^file.php\?file=\d+"))
if fileas:
for filea in fileas:
if 'Previous Chapter' in filea.string or 'Next Chapter' in filea.string:
raise exceptions.FailedToDownload(self.getSiteDomain() +" Cannot use chapter url with multi-chapter stories on this site.")
logger.debug("metadata URL: "+url)
data = self._fetchUrl(url)
# print data
except urllib2.HTTPError, e:
if e.code == 404:
raise exceptions.StoryDoesNotExist(self.url)
else:
raise e
if "Story not found" in data:
raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Story not found.")
# use BeautifulSoup HTML parser to make everything easier to find.
soup = bs.BeautifulSoup(data)
# check user/pass on a chapter for multi-chapter
if 'file' not in self.url:
self._postUrlUP('http://'+self.host+'/'+soup.find('a', href=re.compile(r"^file.php\?file=\d+"))['href'])
## Title
h2 = soup.find('h2')
# Find authorid and URL from... author url.
a = h2.find('a')
ahref = a['href'].split('&')[0] # strip rs_session
self.story.setMetadata('authorId',ahref.split('=')[1])
self.story.setMetadata('authorUrl','http://'+self.host+'/'+ahref)
self.story.setMetadata('author',a.string)
# title, remove byauthorname.
self.story.setMetadata('title',h2.text[:h2.text.index("by"+a.string)])
dates = soup.findAll('span', {'class':'date'})
if dates: # only for multi-chapter
self.story.setMetadata('datePublished', makeDate(stripHTML(dates[0]), self.dateformat))
self.story.setMetadata('dateUpdated', makeDate(stripHTML(dates[-1]), self.dateformat))
words = soup.findAll('span', {'class':'size'})
wordcount=0
for w in words:
wordcount = wordcount + int(w.string[:-6].replace(',',''))
self.story.setMetadata('numWords',"%s"%wordcount)
self.story.setMetadata('rating', soup.find('a',href=re.compile(r"^rating.php\?rating=\d+")).string)
# other tags
labels = soup.find('table', {'class':'info'}).findAll('th')
for labelspan in labels:
value = labelspan.nextSibling
label = labelspan.string
if label != None:
if 'Categories' in label:
for g in stripHTML(value).split('\n'):
self.story.addToList('genre',g)
if 'Pairings' in label:
for g in stripHTML(value).split('\n'):
self.story.addToList('ships',g)
if 'Summary' in label:
self.setDescription(url,stripHTML(value).replace("\n"," ").replace("\r",""))
value.extract() # remove summary incase it contains file URLs.
if 'Updated' in label: # one-shots only.
print "value:%s"%value
value.find('sup').extract() # remove 'st', 'nd', 'th' ordinals
print "value:%s"%value
date = makeDate(stripHTML(value), '%d %B %Y') # full month name
self.story.setMetadata('datePublished', date)
if 'Length' in label: # one-shots only.
self.story.setMetadata('numWords',value.string[:-6])
# one-shot.
if 'file' in self.url:
self.chapterUrls.append((self.story.getMetadata('title'),self.url))
else: # multi-chapter
# Find the chapters: 'library_storyview.php?chapterid=3
chapters=soup.findAll('a', href=re.compile(r"^file.php\?file=\d+"))
if len(chapters)==0:
raise exceptions.FailedToDownload(self.getSiteDomain() +" says: No chapters found.")
else:
for chapter in chapters:
chhref = chapter['href'].split('&')[0] # strip rs_session
# just in case there's tags, like <i> in chapter titles.
self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chhref))
self.story.setMetadata('numChapters',len(self.chapterUrls))
def _postUrlUP(self, url):
params = {}
if self.password:
params['username'] = self.username
params['password'] = self.password
else:
params['username'] = self.getConfig("username")
params['password'] = self.getConfig("password")
params['accept.x'] = 1
params['accept.y'] = 1
data = self._postUrl(url, params)
if "I certify that I am over the age of 18 and that accessing the following story will not violate the laws of my country or local ordinances." in data:
raise exceptions.FailedToLogin(url,params['username'])
return data
# grab the text for an individual chapter.
def getChapterText(self, url):
logger.debug('Getting chapter text from: %s' % url)
data = self._postUrlUP(url)
soup = bs.BeautifulSoup(data)
div = soup.find('td',{'id':'page_content'})
div.name='div'
if None == div:
raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url)
## Remove stuff from page_content
# Remove all tags before the first <hr> after class=info table (including hr)
hr = div.find('table',{'class':'info'}).findNext('hr')
for tag in hr.findAllPrevious():
tag.extract()
hr.extract()
# Remove all tags after the last <hr> (including hr)
hr = div.findAll('hr')[-1]
for tag in hr.findAllNext():
tag.extract()
hr.extract()
return self.utf8FromSoup(url,div)
+39 -8
View File
@@ -54,9 +54,17 @@
much easier. </p>
</div>
<!-- put announcements here, h3 is a good title size. -->
<h3>Fixes:</h3>
<h3>Changes:</h3>
<p>
Set language to Italian for efpfanfic.net, allow replace_metadata to effect language metadata.
<ul>
<li>New site: www.dotmoon.net (Thanks Ida!)</li>
<li>New site: efiction.esteliel.de (Thanks Ida!)</li>
<li>New site: pommedesang.com (Thanks Ida!)</li>
<li>New Spanish language site supported: www.potterfics.com (Thanks Dan!)</li>
<li>New site: www.restrictedsection.org (Yes, this site has been frozen since March 2009.)</li>
<li>More workarounds for fimfiction.net's API issues.</li>
</ul>
FFDL now supports over 80 different sites.
</p>
<p>
Questions? Check out our
@@ -66,7 +74,7 @@
If you have any problems with this application, please
report them in
the <a href="http://groups.google.com/group/fanfic-downloader">FanFictionDownLoader Google Group</a>. The
<a href="http://4-4-33.fanfictiondownloader.appspot.com">Previous Version</a> is also available for you to use if necessary.
<a href="http://4-4-36.fanfictiondownloader.appspot.com">Previous Version</a> is also available for you to use if necessary.
</p>
<div id='error'>
{{ error_message }}
@@ -389,11 +397,6 @@
Use the URL of the story's first chapter, such as
<br /><a href="http://samdean.archive.nu/viewstory.php?sid=1234">http://samdean.archive.nu/viewstory.php?sid=1234</a>
</dd>
<dt>www.yourfanfiction.com</dt>
<dd>
Use the URL of the story's first chapter, such as
<br /><a href="http://www.yourfanfiction.com/viewstory.php?sid=1234">http://www.yourfanfiction.com/viewstory.php?sid=1234</a>
</dd>
<dt>www.destinysgateway.com</dt>
<dd>
Use the URL of the story's first chapter, such as
@@ -563,6 +566,34 @@
Use the URL of any story chapter, such as
<br /><a href="http://www.efpfanfic.net/viewstory.php?sid=12345">http://www.efpfanfic.net/viewstory.php?sid=12345</a>
</dd>
<dt>www.potterfics.com</dt>
<dd>
Use the URL of the story's chapter list, such as
<br /><a href="http://www.potterfics.com/historias/127583">http://www.potterfics.com/historias/127583</a>
</dd>
<dt>www.dotmoon.net</dt>
<dd>
Use the URL of the story's chapter list, such as
<br /><a href="http://www.dotmoon.net/library_view.php?storyid=1234">http://www.dotmoon.net/library_view.php?storyid=1234</a>
</dd>
<dt>efiction.esteliel.de</dt>
<dd>
Use the URL of the story's chapter list, such as
<br /><a href="http://efiction.esteliel.de/viewstory.php?sid=1234">http://efiction.esteliel.de/viewstory.php?sid=1234</a>
</dd>
<dt>pommedesang.com</dt>
<dd>
Use the URL of the story's chapter list, such as
<br /><a href="http://pommedesang.com/efiction/viewstory.php?sid=1234">http://pommedesang.com/efiction/viewstory.php?sid=1234</a>
<br /><a href="http://pommedesang.com/sds/viewstory.php?sid=1234">http://pommedesang.com/sds/viewstory.php?sid=1234</a>
</dd>
<dt>www.restrictedsection.org</dt>
<dd>
Use the URL of the story's chapter list, such as
<br /><a href="http://www.restrictedsection.org/story.php?story=1234">http://www.restrictedsection.org/story.php?story=1234</a>
<br />Or the story URL for one-shots, such as
<br /><a href="http://www.restrictedsection.org/file.php?file=1234">http://www.restrictedsection.org/file.php?file=1234</a>
</dd>
</dl>
<p>
+50 -9
View File
@@ -433,8 +433,9 @@ extratags: FanFiction,Testing,HTML
#is_adult:true
## AO3 adapter defines a few extra metadata entries.
extra_valid_entries:fandoms,freefromtags,ao3categories,comments,kudos,hits,bookmarks,collections
extra_valid_entries:fandoms,freeformtags,freefromtags,ao3categories,comments,kudos,hits,bookmarks,collections
fandoms_label:Fandoms
freeformtags_label:Freeform Tags
freefromtags_label:Freeform Tags
ao3categories_label:AO3 Categories
comments_label:Comments
@@ -443,11 +444,15 @@ hits_label:Hits
collections_label:Collections
bookmarks_label:Bookmarks
## freeformtags was previously typo'ed as freefromtags. This way,
## freefromtags will still work for people who've used it.
include_in_freefromtags:freeformtags
## adds to titlepage_entries instead of replacing it.
#extra_titlepage_entries: fandoms,freefromtags,ao3categories,comments,kudos,hits,bookmarks
#extra_titlepage_entries: fandoms,freeformtags,ao3categories,comments,kudos,hits,bookmarks
## adds to include_subject_tags instead of replacing it.
#extra_subject_tags:fandoms,freefromtags,ao3categories
#extra_subject_tags:fandoms,freeformtags,ao3categories
[ashwinder.sycophanthex.com]
## Site dedicated to these categories/characters/ships
@@ -588,6 +593,10 @@ cliches_label:Character Cliches
# themes=>#bcolumn,a
# timeline=>#ccolumn,n
[efiction.esteliel.de]
## Site dedicated to these categories/characters/ships
extracategories:Lord of the Rings
[erosnsappho.sycophanthex.com]
## Site dedicated to these categories/characters/ships
extracategories:Harry Potter
@@ -749,6 +758,22 @@ extracategories:One Direction
## personal.ini, not defaults.ini.
#is_adult:true
[pommedesang.com]
## Site dedicated to these categories/characters/ships
extracategories:Anita Blake Vampire Hunter
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
## commandline version, this should go in your personal.ini, not
## defaults.ini.
#username:YourName
#password:yourpassword
## Some sites also require the user to confirm they are adult for
## adult content. In commandline version, this should go in your
## personal.ini, not defaults.ini.
#is_adult:true
[ponyfictionarchive.net]
## Site dedicated to these categories/characters/ships
extracategories:My Little Pony: Friendship is Magic
@@ -901,6 +926,14 @@ extracategories:InuYasha
extracharacters:Sesshoumaru,Kagome
extraships:Sesshoumaru/Kagome
[www.dotmoon.net]
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
## commandline version, this should go in your personal.ini, not
## defaults.ini.
#username:YourName
#password:yourpassword
[www.efpfanfic.net]
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
@@ -1071,6 +1104,10 @@ extraships:Harry Potter/Ginny Weasley
## Site dedicated to these categories/characters/ships
extracategories:Harry Potter
[www.potterfics.com]
## Site dedicated to these categories/characters/ships
extracategories:Harry Potter
[www.prisonbreakfic.net]
## Site dedicated to these categories/characters/ships
extracategories:Prison Break
@@ -1084,6 +1121,16 @@ extracategories:Queer as Folk
## this should go in your personal.ini, not defaults.ini.
#is_adult:true
[www.restrictedsection.org]
extracategories:Harry Potter
extragenres:Erotica
## Some sites require login (or login for some rated stories) The
## program can prompt you, or you can save it in config. In
## commandline version, this should go in your personal.ini, not
## defaults.ini.
#username:YourName
#password:yourpassword
[www.scarvesandcoffee.net]
## Site dedicated to these categories/characters/ships
extracategories:Glee
@@ -1233,12 +1280,6 @@ extracategories:Stargate: Atlantis
## this should go in your personal.ini, not defaults.ini.
#is_adult:true
[www.yourfanfiction.com]
## Some sites do not require a login, but do require the user to
## confirm they are adult for adult content. In commandline version,
## this should go in your personal.ini, not defaults.ini.
#is_adult:true
[overrides]
## It may sometimes be useful to override all of the specific format,
## site and site:format sections in your private configuration. For