From cd342bb352ee8d8bf93d5406bc32b2304fe20da1 Mon Sep 17 00:00:00 2001 From: Jim Miller Date: Tue, 5 Aug 2014 16:43:47 -0500 Subject: [PATCH] Fix for getSiteExampleURLs--it's expected to be space separated strings. --- allrecent.html | 78 + app.yaml | 46 + calibre-plugin/__init__.py | 123 + calibre-plugin/about.txt | 28 + calibre-plugin/common_utils.py | 553 +++ calibre-plugin/config.py | 1094 +++++ calibre-plugin/dialogs.py | 1125 +++++ calibre-plugin/ffdl_plugin.py | 2089 ++++++++ calibre-plugin/ffdl_util.py | 43 + calibre-plugin/images/icon.png | Bin 0 -> 24649 bytes calibre-plugin/images/icon.xcf | Bin 0 -> 63927 bytes calibre-plugin/jobs.py | 245 + ...mport-name-fanfictiondownloader_plugin.txt | 0 calibre-plugin/prefs.py | 150 + calibre-plugin/translations/de.po | 1601 ++++++ calibre-plugin/translations/es.po | 1601 ++++++ calibre-plugin/translations/fr.po | 1600 ++++++ calibre-plugin/translations/messages.pot | 1495 ++++++ calibre-plugin/translations/zz.po | 1794 +++++++ cron.yaml | 10 + css/index.css | 73 + defaults.ini | 1878 +++++++ delete_fic.py | 59 + downloader.py | 319 ++ editconfig.html | 89 + epubmerge.py | 25 + example.ini | 103 + fanficdownloader.zip | Bin 0 -> 673167 bytes fanficdownloader/BeautifulSoup.py | 2014 ++++++++ fanficdownloader/HtmlTagStack.py | 57 + fanficdownloader/__init__.py | 19 + fanficdownloader/adapters/__init__.py | 247 + .../adapters/adapter_adastrafanficcom.py | 230 + .../adapters/adapter_archiveofourownorg.py | 376 ++ .../adapters/adapter_archiveskyehawkecom.py | 193 + .../adapter_ashwindersycophanthexcom.py | 253 + .../adapters/adapter_asr3slashzoneorg.py | 226 + .../adapters/adapter_bdsmgeschichten.py | 174 + .../adapters/adapter_bloodshedversecom.py | 193 + .../adapters/adapter_bloodtiesfancom.py | 336 ++ .../adapters/adapter_buffynfaithnet.py | 291 ++ .../adapters/adapter_castlefansorg.py | 309 ++ .../adapters/adapter_chaossycophanthexcom.py | 237 + .../adapters/adapter_checkmatedcom.py | 238 + .../adapters/adapter_darksolaceorg.py | 335 ++ .../adapters/adapter_destinysgatewaycom.py | 243 + .../adapters/adapter_dokugacom.py | 278 ++ .../adapters/adapter_dotmoonnet.py | 217 + .../adapters/adapter_dracoandginnycom.py | 301 ++ .../adapters/adapter_dramioneorg.py | 310 ++ .../adapters/adapter_efictionestelielde.py | 223 + .../adapters/adapter_efpfanficnet.py | 315 ++ .../adapter_erosnsapphosycophanthexcom.py | 255 + fanficdownloader/adapters/adapter_fanfichu.py | 185 + .../adapters/adapter_fanfictioncsodaidokhu.py | 218 + .../adapters/adapter_fanfictionnet.py | 329 ++ .../adapters/adapter_fanfiktionde.py | 202 + .../adapters/adapter_ficbooknet.py | 226 + .../adapters/adapter_fictionalleyorg.py | 241 + .../adapters/adapter_fictionmaniatv.py | 178 + .../adapters/adapter_fictionpadcom.py | 194 + .../adapters/adapter_fictionpresscom.py | 51 + .../adapters/adapter_ficwadcom.py | 223 + .../adapters/adapter_fimfictionnet.py | 304 ++ .../adapters/adapter_finestoriescom.py | 288 ++ .../adapters/adapter_grangerenchantedcom.py | 310 ++ .../adapter_harrypotterfanfictioncom.py | 202 + .../adapters/adapter_hennethannunnet.py | 172 + .../adapters/adapter_hlfictionnet.py | 231 + .../adapters/adapter_hpfandomnet.py | 232 + .../adapters/adapter_hpfanficarchivecom.py | 223 + .../adapters/adapter_iketernalnet.py | 282 ++ .../adapters/adapter_imagineeficcom.py | 289 ++ .../adapters/adapter_indeathnet.py | 200 + .../adapters/adapter_ksarchivecom.py | 314 ++ .../adapters/adapter_libraryofmoriacom.py | 250 + .../adapters/adapter_literotica.py | 258 + .../adapters/adapter_lumossycophanthexcom.py | 237 + .../adapters/adapter_mediaminerorg.py | 237 + .../adapters/adapter_merlinficdtwinscouk.py | 293 ++ .../adapters/adapter_midnightwhispersca.py | 289 ++ .../adapters/adapter_mugglenetcom.py | 335 ++ .../adapters/adapter_nationallibrarynet.py | 212 + .../adapters/adapter_ncisficcom.py | 218 + .../adapters/adapter_ncisfictionnet.py | 210 + .../adapters/adapter_netraptororg.py | 212 + .../adapters/adapter_nfacommunitycom.py | 289 ++ .../adapters/adapter_nhamagicalworldsus.py | 214 + .../adapters/adapter_nickandgregnet.py | 176 + .../adapters/adapter_nocturnallightnet.py | 177 + .../adapter_occlumencysycophanthexcom.py | 262 + .../adapter_onedirectionfanfictioncom.py | 269 + .../adapters/adapter_phoenixsongnet.py | 240 + .../adapters/adapter_pommedesangcom.py | 300 ++ .../adapters/adapter_ponyfictionarchivenet.py | 248 + .../adapters/adapter_portkeyorg.py | 277 ++ .../adapters/adapter_potionsandsnitchesnet.py | 210 + .../adapters/adapter_potterficscom.py | 243 + .../adapter_potterheadsanonymouscom.py | 298 ++ .../adapters/adapter_pretendercentrecom.py | 253 + .../adapters/adapter_psychficcom.py | 248 + .../adapters/adapter_qafficcom.py | 264 + .../adapters/adapter_restrictedsectionorg.py | 265 + .../adapters/adapter_samdeanarchivenu.py | 232 + .../adapters/adapter_scarheadnet.py | 299 ++ .../adapters/adapter_scarvesandcoffeenet.py | 247 + .../adapters/adapter_sg1heliopoliscom.py | 258 + .../adapters/adapter_simplyundeniablecom.py | 219 + .../adapters/adapter_sinfuldesireorg.py | 251 + fanficdownloader/adapters/adapter_siyecouk.py | 246 + .../adapters/adapter_spikeluvercom.py | 207 + .../adapters/adapter_squidgeorgpeja.py | 242 + .../adapters/adapter_stargateatlantisorg.py | 229 + .../adapters/adapter_storiesofardacom.py | 161 + .../adapters/adapter_storiesonlinenet.py | 405 ++ .../adapters/adapter_tenhawkpresentscom.py | 249 + fanficdownloader/adapters/adapter_test1.py | 368 ++ .../adapters/adapter_thealphagatecom.py | 214 + .../adapters/adapter_thehexfilesnet.py | 206 + .../adapters/adapter_thehookupzonenet.py | 308 ++ .../adapters/adapter_themaplebookshelf.py | 129 + .../adapters/adapter_themasquenet.py | 273 + .../adapters/adapter_thepetulantpoetesscom.py | 241 + .../adapters/adapter_thequidditchpitchorg.py | 291 ++ .../adapter_thewriterscoffeeshopcom.py | 268 + .../adapters/adapter_tokrafandomnetcom.py | 237 + .../adapters/adapter_tolkienfanfiction.py | 255 + .../adapters/adapter_trekiverseorg.py | 325 ++ .../adapters/adapter_tthfanficorg.py | 298 ++ .../adapters/adapter_twilightarchivescom.py | 188 + .../adapters/adapter_twilightednet.py | 252 + .../adapters/adapter_twiwritenet.py | 280 ++ .../adapters/adapter_voracity2eficcom.py | 232 + .../adapters/adapter_walkingtheplankorg.py | 231 + .../adapters/adapter_whoficcom.py | 237 + .../adapters/adapter_wizardtalesnet.py | 302 ++ .../adapters/adapter_wolverineandroguecom.py | 219 + .../adapters/adapter_wraithbaitcom.py | 234 + fanficdownloader/adapters/base_adapter.py | 431 ++ fanficdownloader/chardet/__init__.py | 26 + fanficdownloader/chardet/big5freq.py | 923 ++++ fanficdownloader/chardet/big5prober.py | 41 + fanficdownloader/chardet/chardistribution.py | 200 + .../chardet/charsetgroupprober.py | 96 + fanficdownloader/chardet/charsetprober.py | 60 + .../chardet/codingstatemachine.py | 56 + fanficdownloader/chardet/constants.py | 47 + fanficdownloader/chardet/escprober.py | 79 + fanficdownloader/chardet/escsm.py | 240 + fanficdownloader/chardet/eucjpprober.py | 85 + fanficdownloader/chardet/euckrfreq.py | 594 +++ fanficdownloader/chardet/euckrprober.py | 41 + fanficdownloader/chardet/euctwfreq.py | 426 ++ fanficdownloader/chardet/euctwprober.py | 41 + fanficdownloader/chardet/gb2312freq.py | 471 ++ fanficdownloader/chardet/gb2312prober.py | 41 + fanficdownloader/chardet/hebrewprober.py | 269 + fanficdownloader/chardet/jisfreq.py | 567 +++ fanficdownloader/chardet/jpcntx.py | 210 + .../chardet/langbulgarianmodel.py | 228 + fanficdownloader/chardet/langcyrillicmodel.py | 329 ++ fanficdownloader/chardet/langgreekmodel.py | 225 + fanficdownloader/chardet/langhebrewmodel.py | 201 + .../chardet/langhungarianmodel.py | 225 + fanficdownloader/chardet/langthaimodel.py | 200 + fanficdownloader/chardet/latin1prober.py | 136 + fanficdownloader/chardet/mbcharsetprober.py | 82 + fanficdownloader/chardet/mbcsgroupprober.py | 50 + fanficdownloader/chardet/mbcssm.py | 514 ++ fanficdownloader/chardet/sbcharsetprober.py | 106 + fanficdownloader/chardet/sbcsgroupprober.py | 64 + fanficdownloader/chardet/sjisprober.py | 85 + fanficdownloader/chardet/test.py | 20 + fanficdownloader/chardet/universaldetector.py | 154 + fanficdownloader/chardet/utf8prober.py | 76 + fanficdownloader/configurable.py | 196 + fanficdownloader/epubutils.py | 194 + fanficdownloader/exceptions.py | 77 + fanficdownloader/geturls.py | 172 + fanficdownloader/gziphttp.py | 38 + fanficdownloader/html.py | 126 + fanficdownloader/html2text.py | 453 ++ fanficdownloader/htmlcleanup.py | 482 ++ fanficdownloader/htmlheuristics.py | 348 ++ fanficdownloader/mobi.py | 386 ++ fanficdownloader/story.py | 865 ++++ fanficdownloader/translit.py | 57 + fanficdownloader/writers/__init__.py | 38 + fanficdownloader/writers/base_writer.py | 285 ++ fanficdownloader/writers/writer_epub.py | 690 +++ fanficdownloader/writers/writer_html.py | 144 + fanficdownloader/writers/writer_mobi.py | 197 + fanficdownloader/writers/writer_txt.py | 190 + ffstorage.py | 63 + index-ajax.html | 109 + index.html | 210 + index.yaml | 28 + js/fdownloader.js | 116 + js/jquery-1.3.2.js | 4376 +++++++++++++++++ login.html | 110 + main.py | 633 +++ makeplugin.py | 38 + makezip.py | 52 + plugin-defaults.ini | 1878 +++++++ plugin-example.ini | 122 + queue.yaml | 7 + readme.txt | 37 + recent.html | 85 + settings.py | 25 + simplejson/__init__.py | 318 ++ simplejson/_speedups.c | 2329 +++++++++ simplejson/decoder.py | 354 ++ simplejson/encoder.py | 440 ++ simplejson/scanner.py | 65 + simplejson/tests/__init__.py | 23 + simplejson/tests/test_check_circular.py | 30 + simplejson/tests/test_decode.py | 22 + simplejson/tests/test_default.py | 9 + simplejson/tests/test_dump.py | 21 + .../tests/test_encode_basestring_ascii.py | 38 + simplejson/tests/test_fail.py | 76 + simplejson/tests/test_float.py | 15 + simplejson/tests/test_indent.py | 41 + simplejson/tests/test_pass1.py | 76 + simplejson/tests/test_pass2.py | 14 + simplejson/tests/test_pass3.py | 20 + simplejson/tests/test_recursion.py | 67 + simplejson/tests/test_scanstring.py | 111 + simplejson/tests/test_separators.py | 42 + simplejson/tests/test_unicode.py | 64 + simplejson/tool.py | 37 + static/ajax-loader.gif | Bin 0 -> 10819 bytes static/favicon.ico | Bin 0 -> 21792 bytes status.html | 94 + utils/__init__.py | 1 + utils/remover.py | 109 + utils/tally.py | 64 + 237 files changed, 69948 insertions(+) create mode 100644 allrecent.html create mode 100644 app.yaml create mode 100644 calibre-plugin/__init__.py create mode 100644 calibre-plugin/about.txt create mode 100644 calibre-plugin/common_utils.py create mode 100644 calibre-plugin/config.py create mode 100644 calibre-plugin/dialogs.py create mode 100644 calibre-plugin/ffdl_plugin.py create mode 100644 calibre-plugin/ffdl_util.py create mode 100644 calibre-plugin/images/icon.png create mode 100644 calibre-plugin/images/icon.xcf create mode 100644 calibre-plugin/jobs.py create mode 100644 calibre-plugin/plugin-import-name-fanfictiondownloader_plugin.txt create mode 100644 calibre-plugin/prefs.py create mode 100644 calibre-plugin/translations/de.po create mode 100644 calibre-plugin/translations/es.po create mode 100644 calibre-plugin/translations/fr.po create mode 100644 calibre-plugin/translations/messages.pot create mode 100644 calibre-plugin/translations/zz.po create mode 100644 cron.yaml create mode 100644 css/index.css create mode 100644 defaults.ini create mode 100644 delete_fic.py create mode 100644 downloader.py create mode 100644 editconfig.html create mode 100644 epubmerge.py create mode 100644 example.ini create mode 100644 fanficdownloader.zip create mode 100644 fanficdownloader/BeautifulSoup.py create mode 100644 fanficdownloader/HtmlTagStack.py create mode 100644 fanficdownloader/__init__.py create mode 100644 fanficdownloader/adapters/__init__.py create mode 100644 fanficdownloader/adapters/adapter_adastrafanficcom.py create mode 100644 fanficdownloader/adapters/adapter_archiveofourownorg.py create mode 100644 fanficdownloader/adapters/adapter_archiveskyehawkecom.py create mode 100644 fanficdownloader/adapters/adapter_ashwindersycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_asr3slashzoneorg.py create mode 100644 fanficdownloader/adapters/adapter_bdsmgeschichten.py create mode 100644 fanficdownloader/adapters/adapter_bloodshedversecom.py create mode 100644 fanficdownloader/adapters/adapter_bloodtiesfancom.py create mode 100644 fanficdownloader/adapters/adapter_buffynfaithnet.py create mode 100644 fanficdownloader/adapters/adapter_castlefansorg.py create mode 100644 fanficdownloader/adapters/adapter_chaossycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_checkmatedcom.py create mode 100644 fanficdownloader/adapters/adapter_darksolaceorg.py create mode 100644 fanficdownloader/adapters/adapter_destinysgatewaycom.py create mode 100644 fanficdownloader/adapters/adapter_dokugacom.py create mode 100644 fanficdownloader/adapters/adapter_dotmoonnet.py create mode 100644 fanficdownloader/adapters/adapter_dracoandginnycom.py create mode 100644 fanficdownloader/adapters/adapter_dramioneorg.py create mode 100644 fanficdownloader/adapters/adapter_efictionestelielde.py create mode 100644 fanficdownloader/adapters/adapter_efpfanficnet.py create mode 100644 fanficdownloader/adapters/adapter_erosnsapphosycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_fanfichu.py create mode 100644 fanficdownloader/adapters/adapter_fanfictioncsodaidokhu.py create mode 100644 fanficdownloader/adapters/adapter_fanfictionnet.py create mode 100644 fanficdownloader/adapters/adapter_fanfiktionde.py create mode 100644 fanficdownloader/adapters/adapter_ficbooknet.py create mode 100644 fanficdownloader/adapters/adapter_fictionalleyorg.py create mode 100644 fanficdownloader/adapters/adapter_fictionmaniatv.py create mode 100644 fanficdownloader/adapters/adapter_fictionpadcom.py create mode 100644 fanficdownloader/adapters/adapter_fictionpresscom.py create mode 100644 fanficdownloader/adapters/adapter_ficwadcom.py create mode 100644 fanficdownloader/adapters/adapter_fimfictionnet.py create mode 100644 fanficdownloader/adapters/adapter_finestoriescom.py create mode 100644 fanficdownloader/adapters/adapter_grangerenchantedcom.py create mode 100644 fanficdownloader/adapters/adapter_harrypotterfanfictioncom.py create mode 100644 fanficdownloader/adapters/adapter_hennethannunnet.py create mode 100644 fanficdownloader/adapters/adapter_hlfictionnet.py create mode 100644 fanficdownloader/adapters/adapter_hpfandomnet.py create mode 100644 fanficdownloader/adapters/adapter_hpfanficarchivecom.py create mode 100644 fanficdownloader/adapters/adapter_iketernalnet.py create mode 100644 fanficdownloader/adapters/adapter_imagineeficcom.py create mode 100644 fanficdownloader/adapters/adapter_indeathnet.py create mode 100644 fanficdownloader/adapters/adapter_ksarchivecom.py create mode 100644 fanficdownloader/adapters/adapter_libraryofmoriacom.py create mode 100644 fanficdownloader/adapters/adapter_literotica.py create mode 100644 fanficdownloader/adapters/adapter_lumossycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_mediaminerorg.py create mode 100644 fanficdownloader/adapters/adapter_merlinficdtwinscouk.py create mode 100644 fanficdownloader/adapters/adapter_midnightwhispersca.py create mode 100644 fanficdownloader/adapters/adapter_mugglenetcom.py create mode 100644 fanficdownloader/adapters/adapter_nationallibrarynet.py create mode 100644 fanficdownloader/adapters/adapter_ncisficcom.py create mode 100644 fanficdownloader/adapters/adapter_ncisfictionnet.py create mode 100644 fanficdownloader/adapters/adapter_netraptororg.py create mode 100644 fanficdownloader/adapters/adapter_nfacommunitycom.py create mode 100644 fanficdownloader/adapters/adapter_nhamagicalworldsus.py create mode 100644 fanficdownloader/adapters/adapter_nickandgregnet.py create mode 100644 fanficdownloader/adapters/adapter_nocturnallightnet.py create mode 100644 fanficdownloader/adapters/adapter_occlumencysycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_onedirectionfanfictioncom.py create mode 100644 fanficdownloader/adapters/adapter_phoenixsongnet.py create mode 100644 fanficdownloader/adapters/adapter_pommedesangcom.py create mode 100644 fanficdownloader/adapters/adapter_ponyfictionarchivenet.py create mode 100644 fanficdownloader/adapters/adapter_portkeyorg.py create mode 100644 fanficdownloader/adapters/adapter_potionsandsnitchesnet.py create mode 100644 fanficdownloader/adapters/adapter_potterficscom.py create mode 100644 fanficdownloader/adapters/adapter_potterheadsanonymouscom.py create mode 100644 fanficdownloader/adapters/adapter_pretendercentrecom.py create mode 100644 fanficdownloader/adapters/adapter_psychficcom.py create mode 100644 fanficdownloader/adapters/adapter_qafficcom.py create mode 100644 fanficdownloader/adapters/adapter_restrictedsectionorg.py create mode 100644 fanficdownloader/adapters/adapter_samdeanarchivenu.py create mode 100644 fanficdownloader/adapters/adapter_scarheadnet.py create mode 100644 fanficdownloader/adapters/adapter_scarvesandcoffeenet.py create mode 100644 fanficdownloader/adapters/adapter_sg1heliopoliscom.py create mode 100644 fanficdownloader/adapters/adapter_simplyundeniablecom.py create mode 100644 fanficdownloader/adapters/adapter_sinfuldesireorg.py create mode 100644 fanficdownloader/adapters/adapter_siyecouk.py create mode 100644 fanficdownloader/adapters/adapter_spikeluvercom.py create mode 100644 fanficdownloader/adapters/adapter_squidgeorgpeja.py create mode 100644 fanficdownloader/adapters/adapter_stargateatlantisorg.py create mode 100644 fanficdownloader/adapters/adapter_storiesofardacom.py create mode 100644 fanficdownloader/adapters/adapter_storiesonlinenet.py create mode 100644 fanficdownloader/adapters/adapter_tenhawkpresentscom.py create mode 100644 fanficdownloader/adapters/adapter_test1.py create mode 100644 fanficdownloader/adapters/adapter_thealphagatecom.py create mode 100644 fanficdownloader/adapters/adapter_thehexfilesnet.py create mode 100644 fanficdownloader/adapters/adapter_thehookupzonenet.py create mode 100644 fanficdownloader/adapters/adapter_themaplebookshelf.py create mode 100644 fanficdownloader/adapters/adapter_themasquenet.py create mode 100644 fanficdownloader/adapters/adapter_thepetulantpoetesscom.py create mode 100644 fanficdownloader/adapters/adapter_thequidditchpitchorg.py create mode 100644 fanficdownloader/adapters/adapter_thewriterscoffeeshopcom.py create mode 100644 fanficdownloader/adapters/adapter_tokrafandomnetcom.py create mode 100644 fanficdownloader/adapters/adapter_tolkienfanfiction.py create mode 100644 fanficdownloader/adapters/adapter_trekiverseorg.py create mode 100644 fanficdownloader/adapters/adapter_tthfanficorg.py create mode 100644 fanficdownloader/adapters/adapter_twilightarchivescom.py create mode 100644 fanficdownloader/adapters/adapter_twilightednet.py create mode 100644 fanficdownloader/adapters/adapter_twiwritenet.py create mode 100644 fanficdownloader/adapters/adapter_voracity2eficcom.py create mode 100644 fanficdownloader/adapters/adapter_walkingtheplankorg.py create mode 100644 fanficdownloader/adapters/adapter_whoficcom.py create mode 100644 fanficdownloader/adapters/adapter_wizardtalesnet.py create mode 100644 fanficdownloader/adapters/adapter_wolverineandroguecom.py create mode 100644 fanficdownloader/adapters/adapter_wraithbaitcom.py create mode 100644 fanficdownloader/adapters/base_adapter.py create mode 100644 fanficdownloader/chardet/__init__.py create mode 100644 fanficdownloader/chardet/big5freq.py create mode 100644 fanficdownloader/chardet/big5prober.py create mode 100644 fanficdownloader/chardet/chardistribution.py create mode 100644 fanficdownloader/chardet/charsetgroupprober.py create mode 100644 fanficdownloader/chardet/charsetprober.py create mode 100644 fanficdownloader/chardet/codingstatemachine.py create mode 100644 fanficdownloader/chardet/constants.py create mode 100644 fanficdownloader/chardet/escprober.py create mode 100644 fanficdownloader/chardet/escsm.py create mode 100644 fanficdownloader/chardet/eucjpprober.py create mode 100644 fanficdownloader/chardet/euckrfreq.py create mode 100644 fanficdownloader/chardet/euckrprober.py create mode 100644 fanficdownloader/chardet/euctwfreq.py create mode 100644 fanficdownloader/chardet/euctwprober.py create mode 100644 fanficdownloader/chardet/gb2312freq.py create mode 100644 fanficdownloader/chardet/gb2312prober.py create mode 100644 fanficdownloader/chardet/hebrewprober.py create mode 100644 fanficdownloader/chardet/jisfreq.py create mode 100644 fanficdownloader/chardet/jpcntx.py create mode 100644 fanficdownloader/chardet/langbulgarianmodel.py create mode 100644 fanficdownloader/chardet/langcyrillicmodel.py create mode 100644 fanficdownloader/chardet/langgreekmodel.py create mode 100644 fanficdownloader/chardet/langhebrewmodel.py create mode 100644 fanficdownloader/chardet/langhungarianmodel.py create mode 100644 fanficdownloader/chardet/langthaimodel.py create mode 100644 fanficdownloader/chardet/latin1prober.py create mode 100644 fanficdownloader/chardet/mbcharsetprober.py create mode 100644 fanficdownloader/chardet/mbcsgroupprober.py create mode 100644 fanficdownloader/chardet/mbcssm.py create mode 100644 fanficdownloader/chardet/sbcharsetprober.py create mode 100644 fanficdownloader/chardet/sbcsgroupprober.py create mode 100644 fanficdownloader/chardet/sjisprober.py create mode 100644 fanficdownloader/chardet/test.py create mode 100644 fanficdownloader/chardet/universaldetector.py create mode 100644 fanficdownloader/chardet/utf8prober.py create mode 100644 fanficdownloader/configurable.py create mode 100644 fanficdownloader/epubutils.py create mode 100644 fanficdownloader/exceptions.py create mode 100644 fanficdownloader/geturls.py create mode 100644 fanficdownloader/gziphttp.py create mode 100644 fanficdownloader/html.py create mode 100644 fanficdownloader/html2text.py create mode 100644 fanficdownloader/htmlcleanup.py create mode 100644 fanficdownloader/htmlheuristics.py create mode 100644 fanficdownloader/mobi.py create mode 100644 fanficdownloader/story.py create mode 100644 fanficdownloader/translit.py create mode 100644 fanficdownloader/writers/__init__.py create mode 100644 fanficdownloader/writers/base_writer.py create mode 100644 fanficdownloader/writers/writer_epub.py create mode 100644 fanficdownloader/writers/writer_html.py create mode 100644 fanficdownloader/writers/writer_mobi.py create mode 100644 fanficdownloader/writers/writer_txt.py create mode 100644 ffstorage.py create mode 100644 index-ajax.html create mode 100644 index.html create mode 100644 index.yaml create mode 100644 js/fdownloader.js create mode 100644 js/jquery-1.3.2.js create mode 100644 login.html create mode 100644 main.py create mode 100644 makeplugin.py create mode 100644 makezip.py create mode 100644 plugin-defaults.ini create mode 100644 plugin-example.ini create mode 100644 queue.yaml create mode 100644 readme.txt create mode 100644 recent.html create mode 100644 settings.py create mode 100644 simplejson/__init__.py create mode 100644 simplejson/_speedups.c create mode 100644 simplejson/decoder.py create mode 100644 simplejson/encoder.py create mode 100644 simplejson/scanner.py create mode 100644 simplejson/tests/__init__.py create mode 100644 simplejson/tests/test_check_circular.py create mode 100644 simplejson/tests/test_decode.py create mode 100644 simplejson/tests/test_default.py create mode 100644 simplejson/tests/test_dump.py create mode 100644 simplejson/tests/test_encode_basestring_ascii.py create mode 100644 simplejson/tests/test_fail.py create mode 100644 simplejson/tests/test_float.py create mode 100644 simplejson/tests/test_indent.py create mode 100644 simplejson/tests/test_pass1.py create mode 100644 simplejson/tests/test_pass2.py create mode 100644 simplejson/tests/test_pass3.py create mode 100644 simplejson/tests/test_recursion.py create mode 100644 simplejson/tests/test_scanstring.py create mode 100644 simplejson/tests/test_separators.py create mode 100644 simplejson/tests/test_unicode.py create mode 100644 simplejson/tool.py create mode 100644 static/ajax-loader.gif create mode 100644 static/favicon.ico create mode 100644 status.html create mode 100644 utils/__init__.py create mode 100644 utils/remover.py create mode 100644 utils/tally.py diff --git a/allrecent.html b/allrecent.html new file mode 100644 index 00000000..477b17b7 --- /dev/null +++ b/allrecent.html @@ -0,0 +1,78 @@ + + + + + FanFictionDownLoader (fanfiction.net, fanficauthors, fictionalley, ficwad to epub and HTML) + + + + +
+

+ FanFictionDownLoader +

+ + + + + {{yourfile}} + + +
+ {% for fic in fics %} +

+ {{ fic.title }} + by {{ fic.author }} Download Count: {{ fic.count }}
+ Word Count: {{ fic.numWords }} Chapter Count: {{ fic.numChapters }}
+ {% if fic.category %} Categories: {{ fic.category }}
{% endif %} + {% if fic.genre %} Genres: {{ fic.genre }}
{% endif %} + {% if fic.language %} Language: {{ fic.language }}
{% endif %} + {% if fic.series %} Series: {{ fic.series }}
{% endif %} + {% if fic.characters %} Characters: {{ fic.characters }}
{% endif %} + {% if fic.status %} Status: {{ fic.status }}
{% endif %} + {% if fic.datePublished %} Published: {{ fic.datePublished }}
{% endif %} + {% if fic.dateUpdated %} Last Updated: {{ fic.dateUpdated }}
{% endif %} + {% if fic.dateCreated %} Last Downloaded: {{ fic.dateCreated }}
{% endif %} + {% if fic.rating %} Rating: {{ fic.rating }}
{% endif %} + {% if fic.warnings %} Warnings: {{ fic.warnings }}
{% endif %} + {% if fic.description %} Summary: {{ fic.description }}
{% endif %} +

+ {% endfor %} +
+ + + + +
+ + diff --git a/app.yaml b/app.yaml new file mode 100644 index 00000000..93deb253 --- /dev/null +++ b/app.yaml @@ -0,0 +1,46 @@ +# ffd-retief-hrd fanfictiondownloader +application: fanfictiondownloader +version: 2-0-01 +runtime: python27 +api_version: 1 +threadsafe: true + +handlers: + +- url: /r3m0v3r.* + script: utils.remover.app + login: admin + +- url: /tally.* + script: utils.tally.app + login: admin + +- url: /fdownloadtask + script: main.app + login: admin + +- url: /css + static_dir: css + +- url: /js + static_dir: js + +- url: /static + static_dir: static + +- url: /favicon\.ico + static_files: static/favicon.ico + upload: static/favicon\.ico + +- url: /.* + script: main.app + +#builtins: +#- datastore_admin: on + +libraries: +- name: django + version: "1.2" + +- name: PIL + version: "1.1.7" diff --git a/calibre-plugin/__init__.py b/calibre-plugin/__init__.py new file mode 100644 index 00000000..ad1f324d --- /dev/null +++ b/calibre-plugin/__init__.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Jim Miller' +__docformat__ = 'restructuredtext en' + +import sys +if sys.version_info >= (2, 7): + import logging + logger = logging.getLogger(__name__) + loghandler=logging.StreamHandler() + loghandler.setFormatter(logging.Formatter("FFDL:%(levelname)s:%(filename)s(%(lineno)d):%(message)s")) + logger.addHandler(loghandler) + loghandler.setLevel(logging.DEBUG) + logger.setLevel(logging.DEBUG) + +# pulls in translation files for _() strings +try: + load_translations() +except NameError: + pass # load_translations() added in calibre 1.9 + +# The class that all Interface Action plugin wrappers must inherit from +from calibre.customize import InterfaceActionBase + +## Apparently the name for this class doesn't matter--it was still +## 'demo' for the first few versions. +class FanFictionDownLoaderBase(InterfaceActionBase): + ''' + This class is a simple wrapper that provides information about the + actual plugin class. The actual interface plugin class is called + InterfacePlugin and is defined in the ffdl_plugin.py file, as + specified in the actual_plugin field below. + + The reason for having two classes is that it allows the command line + calibre utilities to run without needing to load the GUI libraries. + ''' + name = 'FanFictionDownLoader' + description = _('UI plugin to download FanFiction stories from various sites.') + supported_platforms = ['windows', 'osx', 'linux'] + author = 'Jim Miller' + version = (2, 0, 1) + minimum_calibre_version = (1, 13, 0) + + #: This field defines the GUI plugin class that contains all the code + #: that actually does something. Its format is module_path:class_name + #: The specified class must be defined in the specified module. + actual_plugin = 'calibre_plugins.fanfictiondownloader_plugin.ffdl_plugin:FanFictionDownLoaderPlugin' + + def is_customizable(self): + ''' + This method must return True to enable customization via + Preferences->Plugins + ''' + return True + + def config_widget(self): + ''' + Implement this method and :meth:`save_settings` in your plugin to + use a custom configuration dialog. + + This method, if implemented, must return a QWidget. The widget can have + an optional method validate() that takes no arguments and is called + immediately after the user clicks OK. Changes are applied if and only + if the method returns True. + + If for some reason you cannot perform the configuration at this time, + return a tuple of two strings (message, details), these will be + displayed as a warning dialog to the user and the process will be + aborted. + + The base class implementation of this method raises NotImplementedError + so by default no user configuration is possible. + ''' + # It is important to put this import statement here rather than at the + # top of the module as importing the config class will also cause the + # GUI libraries to be loaded, which we do not want when using calibre + # from the command line + from calibre_plugins.fanfictiondownloader_plugin.config import ConfigWidget + return ConfigWidget(self.actual_plugin_) + + def save_settings(self, config_widget): + ''' + Save the settings specified by the user with config_widget. + + :param config_widget: The widget returned by :meth:`config_widget`. + ''' + config_widget.save_settings() + + # Apply the changes + ac = self.actual_plugin_ + if ac is not None: + ac.apply_settings() + + def cli_main(self,argv): + # I believe there's no performance hit loading these here when + # CLI--it would load everytime anyway. + from StringIO import StringIO + from calibre.library import db + from calibre_plugins.fanfictiondownloader_plugin.downloader import main as ffdl_main + from calibre_plugins.fanfictiondownloader_plugin.prefs import PrefsFacade + from calibre.utils.config import prefs as calibre_prefs + from optparse import OptionParser + + parser = OptionParser('%prog --run-plugin '+self.name+' -- [options] ') + parser.add_option('--library-path', '--with-library', default=None, help=_('Path to the calibre library. Default is to use the path stored in the settings.')) + # parser.add_option('--dont-notify-gui', default=False, action='store_true', + # help=_('Do not notify the running calibre GUI (if any) that the database has' + # ' changed. Use with care, as it can lead to database corruption!')) + + pargs = [x for x in argv if x.startswith('--with-library') or x.startswith('--library-path') + or not x.startswith('-')] + opts, args = parser.parse_args(pargs) + + ffdl_prefs = PrefsFacade(db(path=opts.library_path, + read_only=True)) + ffdl_main(argv[1:], + parser=parser, + passed_defaultsini=StringIO(get_resources("defaults.ini")), + passed_personalini=StringIO(ffdl_prefs["personal.ini"])) diff --git a/calibre-plugin/about.txt b/calibre-plugin/about.txt new file mode 100644 index 00000000..9ea9cd05 --- /dev/null +++ b/calibre-plugin/about.txt @@ -0,0 +1,28 @@ +
+ +

Plugin created by Jim Miller, borrowing heavily from Grant Drake's +'Reading List', +'Extract ISBN' and +'Count Pages' +plugins.

+ +

+Calibre officially distributes plugins from the mobileread.com forum site. +The official distro channel for this plugin is there: FanFictionDownLoader +

+ +

I also monitor the +general users +group for the downloader. That covers the web application and CLI, too. +

+ +The source for this plugin is available at it's +project home. +
+ +

+See the list of supported sites. +

+

+Read the FAQs. +

diff --git a/calibre-plugin/common_utils.py b/calibre-plugin/common_utils.py new file mode 100644 index 00000000..73ac823c --- /dev/null +++ b/calibre-plugin/common_utils.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Grant Drake ' +__docformat__ = 'restructuredtext en' + +import os +try: + from PyQt5 import QtWidgets as QtGui + from PyQt5.Qt import (Qt, QIcon, QPixmap, QLabel, QDialog, QHBoxLayout, + QTableWidgetItem, QFont, QLineEdit, QComboBox, + QVBoxLayout, QDialogButtonBox, QStyledItemDelegate, QDateTime, + QTextEdit, QListWidget, QAbstractItemView) +except ImportError as e: + from PyQt4 import QtGui + from PyQt4.Qt import (Qt, QIcon, QPixmap, QLabel, QDialog, QHBoxLayout, + QTableWidgetItem, QFont, QLineEdit, QComboBox, + QVBoxLayout, QDialogButtonBox, QStyledItemDelegate, QDateTime, + QTextEdit, QListWidget, QAbstractItemView) + +from calibre.constants import iswindows +from calibre.gui2 import gprefs, error_dialog, UNDEFINED_QDATETIME, info_dialog +from calibre.gui2.actions import menu_action_unique_name +from calibre.gui2.keyboard import ShortcutConfig +from calibre.utils.config import config_dir +from calibre.utils.date import now, format_date, qt_to_dt, UNDEFINED_DATE + +# Global definition of our plugin name. Used for common functions that require this. +plugin_name = None +# Global definition of our plugin resources. Used to share between the xxxAction and xxxBase +# classes if you need any zip images to be displayed on the configuration dialog. +plugin_icon_resources = {} + + +def set_plugin_icon_resources(name, resources): + ''' + Set our global store of plugin name and icon resources for sharing between + the InterfaceAction class which reads them and the ConfigWidget + if needed for use on the customization dialog for this plugin. + ''' + global plugin_icon_resources, plugin_name + plugin_name = name + plugin_icon_resources = resources + + +def get_icon(icon_name): + ''' + Retrieve a QIcon for the named image from the zip file if it exists, + or if not then from Calibre's image cache. + ''' + if icon_name: + pixmap = get_pixmap(icon_name) + if pixmap is None: + # Look in Calibre's cache for the icon + return QIcon(I(icon_name)) + else: + return QIcon(pixmap) + return QIcon() + + +def get_pixmap(icon_name): + ''' + Retrieve a QPixmap for the named image + Any icons belonging to the plugin must be prefixed with 'images/' + ''' + global plugin_icon_resources, plugin_name + + if not icon_name.startswith('images/'): + # We know this is definitely not an icon belonging to this plugin + pixmap = QPixmap() + pixmap.load(I(icon_name)) + return pixmap + + # Check to see whether the icon exists as a Calibre resource + # This will enable skinning if the user stores icons within a folder like: + # ...\AppData\Roaming\calibre\resources\images\Plugin Name\ + if plugin_name: + local_images_dir = get_local_images_dir(plugin_name) + local_image_path = os.path.join(local_images_dir, icon_name.replace('images/', '')) + if os.path.exists(local_image_path): + pixmap = QPixmap() + pixmap.load(local_image_path) + return pixmap + + # As we did not find an icon elsewhere, look within our zip resources + if icon_name in plugin_icon_resources: + pixmap = QPixmap() + pixmap.loadFromData(plugin_icon_resources[icon_name]) + return pixmap + return None + + +def get_local_images_dir(subfolder=None): + ''' + Returns a path to the user's local resources/images folder + If a subfolder name parameter is specified, appends this to the path + ''' + images_dir = os.path.join(config_dir, 'resources/images') + if subfolder: + images_dir = os.path.join(images_dir, subfolder) + if iswindows: + images_dir = os.path.normpath(images_dir) + return images_dir + + +def create_menu_item(ia, parent_menu, menu_text, image=None, tooltip=None, + shortcut=(), triggered=None, is_checked=None): + ''' + Create a menu action with the specified criteria and action + Note that if no shortcut is specified, will not appear in Preferences->Keyboard + This method should only be used for actions which either have no shortcuts, + or register their menus only once. Use create_menu_action_unique for all else. + ''' + if shortcut is not None: + if len(shortcut) == 0: + shortcut = () + else: + shortcut = _(shortcut) + ac = ia.create_action(spec=(menu_text, None, tooltip, shortcut), + attr=menu_text) + if image: + ac.setIcon(get_icon(image)) + if triggered is not None: + ac.triggered.connect(triggered) + if is_checked is not None: + ac.setCheckable(True) + if is_checked: + ac.setChecked(True) + + parent_menu.addAction(ac) + return ac + + +def create_menu_action_unique(ia, parent_menu, menu_text, image=None, tooltip=None, + shortcut=None, triggered=None, is_checked=None, shortcut_name=None, + unique_name=None): + ''' + Create a menu action with the specified criteria and action, using the new + InterfaceAction.create_menu_action() function which ensures that regardless of + whether a shortcut is specified it will appear in Preferences->Keyboard + ''' + orig_shortcut = shortcut + kb = ia.gui.keyboard + if unique_name is None: + unique_name = menu_text + if not shortcut == False: + full_unique_name = menu_action_unique_name(ia, unique_name) + if full_unique_name in kb.shortcuts: + shortcut = False + else: + if shortcut is not None and not shortcut == False: + if len(shortcut) == 0: + shortcut = None + else: + shortcut = _(shortcut) + + if shortcut_name is None: + shortcut_name = menu_text.replace('&','') + + ac = ia.create_menu_action(parent_menu, unique_name, menu_text, icon=None, shortcut=shortcut, + description=tooltip, triggered=triggered, shortcut_name=shortcut_name) + if shortcut == False and not orig_shortcut == False: + if ac.calibre_shortcut_unique_name in ia.gui.keyboard.shortcuts: + kb.replace_action(ac.calibre_shortcut_unique_name, ac) + if image: + ac.setIcon(get_icon(image)) + if is_checked is not None: + ac.setCheckable(True) + if is_checked: + ac.setChecked(True) + return ac + + +def swap_author_names(author): + if author.find(',') == -1: + return author + name_parts = author.strip().partition(',') + return name_parts[2].strip() + ' ' + name_parts[0] + + +def get_library_uuid(db): + try: + library_uuid = db.library_id + except: + library_uuid = '' + return library_uuid + + +class ImageLabel(QLabel): + + def __init__(self, parent, icon_name, size=16): + QLabel.__init__(self, parent) + pixmap = get_pixmap(icon_name) + self.setPixmap(pixmap) + self.setMaximumSize(size, size) + self.setScaledContents(True) + + +class ImageTitleLayout(QHBoxLayout): + ''' + A reusable layout widget displaying an image followed by a title + ''' + def __init__(self, parent, icon_name, title, tooltip=None): + QHBoxLayout.__init__(self) + title_image_label = QLabel(parent) + pixmap = get_pixmap(icon_name) + if pixmap is None: + pixmap = get_pixmap('library.png') + # error_dialog(parent, _('Restart required'), + # _('You must restart Calibre before using this plugin!'), show=True) + else: + title_image_label.setPixmap(pixmap) + title_image_label.setMaximumSize(32, 32) + title_image_label.setScaledContents(True) + self.addWidget(title_image_label) + + title_font = QFont() + title_font.setPointSize(16) + shelf_label = QLabel(title, parent) + shelf_label.setFont(title_font) + self.addWidget(shelf_label) + self.insertStretch(-1) + + if tooltip: + title_image_label.setToolTip(tooltip) + shelf_label.setToolTip(tooltip) + +class SizePersistedDialog(QDialog): + ''' + This dialog is a base class for any dialogs that want their size/position + restored when they are next opened. + ''' + def __init__(self, parent, unique_pref_name): + QDialog.__init__(self, parent) + self.unique_pref_name = unique_pref_name + self.geom = gprefs.get(unique_pref_name, None) + self.finished.connect(self.dialog_closing) + + def resize_dialog(self): + if self.geom is None: + self.resize(self.sizeHint()) + else: + self.restoreGeometry(self.geom) + + def dialog_closing(self, result): + self.geom = bytearray(self.saveGeometry()) + gprefs[self.unique_pref_name] = self.geom + + +class ReadOnlyTableWidgetItem(QTableWidgetItem): + + def __init__(self, text): + if text is None: + text = '' + QTableWidgetItem.__init__(self, text, QtGui.QTableWidgetItem.UserType) + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + + +class RatingTableWidgetItem(QTableWidgetItem): + + def __init__(self, rating, is_read_only=False): + QTableWidgetItem.__init__(self, '', QtGui.QTableWidgetItem.UserType) + self.setData(Qt.DisplayRole, rating) + if is_read_only: + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + + +class DateTableWidgetItem(QTableWidgetItem): + + def __init__(self, date_read, is_read_only=False, default_to_today=False): + if date_read == UNDEFINED_DATE and default_to_today: + date_read = now() + if is_read_only: + QTableWidgetItem.__init__(self, format_date(date_read, None), QtGui.QTableWidgetItem.UserType) + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + else: + QTableWidgetItem.__init__(self, '', QtGui.QTableWidgetItem.UserType) + self.setData(Qt.DisplayRole, QDateTime(date_read)) + + +class NoWheelComboBox(QComboBox): + + def wheelEvent (self, event): + # Disable the mouse wheel on top of the combo box changing selection as plays havoc in a grid + event.ignore() + + +class CheckableTableWidgetItem(QTableWidgetItem): + + def __init__(self, checked=False, is_tristate=False): + QTableWidgetItem.__init__(self, '') + self.setFlags(Qt.ItemFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled )) + if is_tristate: + self.setFlags(self.flags() | Qt.ItemIsTristate) + if checked: + self.setCheckState(Qt.Checked) + else: + if is_tristate and checked is None: + self.setCheckState(Qt.PartiallyChecked) + else: + self.setCheckState(Qt.Unchecked) + + def get_boolean_value(self): + ''' + Return a boolean value indicating whether checkbox is checked + If this is a tristate checkbox, a partially checked value is returned as None + ''' + if self.checkState() == Qt.PartiallyChecked: + return None + else: + return self.checkState() == Qt.Checked + + +class TextIconWidgetItem(QTableWidgetItem): + + def __init__(self, text, icon): + QTableWidgetItem.__init__(self, text) + if icon: + self.setIcon(icon) + + +class ReadOnlyTextIconWidgetItem(ReadOnlyTableWidgetItem): + + def __init__(self, text, icon): + ReadOnlyTableWidgetItem.__init__(self, text) + if icon: + self.setIcon(icon) + + +class ReadOnlyLineEdit(QLineEdit): + + def __init__(self, text, parent): + if text is None: + text = '' + QLineEdit.__init__(self, text, parent) + self.setEnabled(False) + + +class KeyValueComboBox(QComboBox): + + def __init__(self, parent, values, selected_key): + QComboBox.__init__(self, parent) + self.values = values + self.populate_combo(selected_key) + + def populate_combo(self, selected_key): + self.clear() + selected_idx = idx = -1 + for key, value in self.values.iteritems(): + idx = idx + 1 + self.addItem(value) + if key == selected_key: + selected_idx = idx + self.setCurrentIndex(selected_idx) + + def selected_key(self): + for key, value in self.values.iteritems(): + if value == unicode(self.currentText()).strip(): + return key + + +class CustomColumnComboBox(QComboBox): + + def __init__(self, parent, custom_columns, selected_column, initial_items=['']): + QComboBox.__init__(self, parent) + self.populate_combo(custom_columns, selected_column, initial_items) + + def populate_combo(self, custom_columns, selected_column, initial_items=['']): + self.clear() + self.column_names = initial_items + if len(initial_items) > 0: + self.addItems(initial_items) + selected_idx = 0 + for idx, value in enumerate(initial_items): + if value == selected_column: + selected_idx = idx + for key in sorted(custom_columns.keys()): + self.column_names.append(key) + self.addItem('%s (%s)'%(key, custom_columns[key]['name'])) + if key == selected_column: + selected_idx = len(self.column_names) - 1 + self.setCurrentIndex(selected_idx) + + def get_selected_column(self): + return self.column_names[self.currentIndex()] + + +class KeyboardConfigDialog(SizePersistedDialog): + ''' + This dialog is used to allow editing of keyboard shortcuts. + ''' + def __init__(self, gui, group_name): + SizePersistedDialog.__init__(self, gui, 'Keyboard shortcut dialog') + self.gui = gui + self.setWindowTitle('Keyboard shortcuts') + layout = QVBoxLayout(self) + self.setLayout(layout) + + self.keyboard_widget = ShortcutConfig(self) + layout.addWidget(self.keyboard_widget) + self.group_name = group_name + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.commit) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + # Cause our dialog size to be restored from prefs or created on first usage + self.resize_dialog() + self.initialize() + + def initialize(self): + self.keyboard_widget.initialize(self.gui.keyboard) + self.keyboard_widget.highlight_group(self.group_name) + + def commit(self): + self.keyboard_widget.commit() + self.accept() + + +class DateDelegate(QStyledItemDelegate): + ''' + Delegate for dates. Because this delegate stores the + format as an instance variable, a new instance must be created for each + column. This differs from all the other delegates. + ''' + def __init__(self, parent): + QStyledItemDelegate.__init__(self, parent) + self.format = 'dd MMM yyyy' + + def displayText(self, val, locale): + d = val.toDateTime() + if d <= UNDEFINED_QDATETIME: + return '' + return format_date(qt_to_dt(d, as_utc=False), self.format) + + def createEditor(self, parent, option, index): + qde = QStyledItemDelegate.createEditor(self, parent, option, index) + qde.setDisplayFormat(self.format) + qde.setMinimumDateTime(UNDEFINED_QDATETIME) + qde.setSpecialValueText(_('Undefined')) + qde.setCalendarPopup(True) + return qde + + def setEditorData(self, editor, index): + val = index.model().data(index, Qt.DisplayRole).toDateTime() + if val is None or val == UNDEFINED_QDATETIME: + val = now() + editor.setDateTime(val) + + def setModelData(self, editor, model, index): + val = editor.dateTime() + if val <= UNDEFINED_QDATETIME: + model.setData(index, UNDEFINED_QDATETIME, Qt.EditRole) + else: + model.setData(index, QDateTime(val), Qt.EditRole) + +class PrefsViewerDialog(SizePersistedDialog): + + def __init__(self, gui, namespace): + SizePersistedDialog.__init__(self, gui, 'Prefs Viewer dialog') + self.setWindowTitle('Preferences for: '+namespace) + + self.gui = gui + self.db = gui.current_db + self.namespace = namespace + self._init_controls() + self.resize_dialog() + + self._populate_settings() + + if self.keys_list.count(): + self.keys_list.setCurrentRow(0) + + def _init_controls(self): + layout = QVBoxLayout(self) + self.setLayout(layout) + + ml = QHBoxLayout() + layout.addLayout(ml, 1) + + self.keys_list = QListWidget(self) + self.keys_list.setSelectionMode(QAbstractItemView.SingleSelection) + self.keys_list.setFixedWidth(150) + self.keys_list.setAlternatingRowColors(True) + ml.addWidget(self.keys_list) + self.value_text = QTextEdit(self) + self.value_text.setTabStopWidth(24) + self.value_text.setReadOnly(True) + ml.addWidget(self.value_text, 1) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok) + button_box.accepted.connect(self.accept) + self.clear_button = button_box.addButton('Clear', QDialogButtonBox.ResetRole) + self.clear_button.setIcon(get_icon('trash.png')) + self.clear_button.setToolTip('Clear all settings for this plugin') + self.clear_button.clicked.connect(self._clear_settings) + layout.addWidget(button_box) + + def _populate_settings(self): + self.keys_list.clear() + ns_prefix = self._get_ns_prefix() + keys = sorted([k[len(ns_prefix):] for k in self.db.prefs.iterkeys() + if k.startswith(ns_prefix)]) + for key in keys: + self.keys_list.addItem(key) + self.keys_list.setMinimumWidth(self.keys_list.sizeHintForColumn(0)) + self.keys_list.currentRowChanged[int].connect(self._current_row_changed) + + def _current_row_changed(self, new_row): + if new_row < 0: + self.value_text.clear() + return + key = unicode(self.keys_list.currentItem().text()) + val = self.db.prefs.get_namespaced(self.namespace, key, '') + self.value_text.setPlainText(self.db.prefs.to_raw(val)) + + def _get_ns_prefix(self): + return 'namespaced:%s:'% self.namespace + + def _clear_settings(self): + from calibre.gui2.dialogs.confirm_delete import confirm + message = '

Are you sure you want to clear your settings in this library for this plugin?

' \ + '

Any settings in other libraries or stored in a JSON file in your calibre plugins ' \ + 'folder will not be touched.

' \ + '

You must restart calibre afterwards.

' + if not confirm(message, self.namespace+'_clear_settings', self): + return + ns_prefix = self._get_ns_prefix() + keys = [k for k in self.db.prefs.iterkeys() if k.startswith(ns_prefix)] + for k in keys: + del self.db.prefs[k] + self._populate_settings() + d = info_dialog(self, 'Settings deleted', + '

All settings for this plugin in this library have been cleared.

' + '

Please restart calibre now.

', + show_copy_button=False) + b = d.bb.addButton(_('Restart calibre now'), d.bb.AcceptRole) + b.setIcon(QIcon(I('lt.png'))) + d.do_restart = False + def rf(): + d.do_restart = True + b.clicked.connect(rf) + d.set_details('') + d.exec_() + b.clicked.disconnect() + self.close() + if d.do_restart: + self.gui.quit(restart=True) + diff --git a/calibre-plugin/config.py b/calibre-plugin/config.py new file mode 100644 index 00000000..5b1b6586 --- /dev/null +++ b/calibre-plugin/config.py @@ -0,0 +1,1094 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Jim Miller' +__docformat__ = 'restructuredtext en' + +import logging +logger = logging.getLogger(__name__) + +import traceback, copy, threading +from collections import OrderedDict + +try: + from PyQt5.Qt import (QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QFont, QWidget, QTextEdit, QComboBox, + QCheckBox, QPushButton, QTabWidget, QScrollArea, + QDialogButtonBox, QGroupBox ) +except ImportError as e: + from PyQt4.Qt import (QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QFont, QWidget, QTextEdit, QComboBox, + QCheckBox, QPushButton, QTabWidget, QScrollArea, + QDialogButtonBox, QGroupBox ) +try: + from calibre.gui2 import QVariant + del QVariant +except ImportError: + is_qt4 = False + convert_qvariant = lambda x: x +else: + is_qt4 = True + def convert_qvariant(x): + vt = x.type() + if vt == x.String: + return unicode(x.toString()) + if vt == x.List: + return [convert_qvariant(i) for i in x.toList()] + return x.toPyObject() + +from calibre.gui2.ui import get_gui +from calibre.gui2 import dynamic, info_dialog +from calibre.constants import numeric_version as calibre_version + +# pulls in translation files for _() strings +try: + load_translations() +except NameError: + pass # load_translations() added in calibre 1.9 + +# There are a number of things used several times that shouldn't be +# translated. This is just a way to make that easier by keeping them +# out of the _() strings. +# I'm tempted to override _() to include them... +no_trans = { 'pini':'personal.ini', + 'imgset':'\n\n[epub]\ninclude_images:true\nkeep_summary_html:true\nmake_firstimage_cover:true\n\n', + 'gcset':'generate_cover_settings', + 'ccset':'custom_columns_settings', + 'gc':'Generate Cover', + 'rl':'Reading List', + 'cp':'Count Pages', + 'cmplt':'Completed', + 'inprog':'In-Progress', + 'lul':'Last Updated', + 'lus':'lastupdate', + 'is':'include_subject', + 'isa':'is_adult', + 'u':'username', + 'p':'password', + } + +from calibre_plugins.fanfictiondownloader_plugin.prefs import prefs, PREFS_NAMESPACE +from calibre_plugins.fanfictiondownloader_plugin.dialogs \ + import (UPDATE, UPDATEALWAYS, collision_order, save_collisions, RejectListDialog, + EditTextDialog, RejectUrlEntry) + +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.adapters \ + import getConfigSections + +from calibre_plugins.fanfictiondownloader_plugin.common_utils \ + import ( KeyboardConfigDialog, PrefsViewerDialog ) + +from calibre.gui2.complete2 import EditWithComplete #MultiCompleteLineEdit + +class RejectURLList: + def __init__(self,prefs): + self.prefs = prefs + self.sync_lock = threading.RLock() + self.listcache = None + + def _read_list_from_text(self,text,addreasontext=''): + cache = OrderedDict() + + #print("_read_list_from_text") + for line in text.splitlines(): + rue = RejectUrlEntry(line,addreasontext=addreasontext,fromline=True) + #print("rue.url:%s"%rue.url) + if rue.valid: + cache[rue.url] = rue + return cache + + def _get_listcache(self): + if self.listcache == None: + self.listcache = self._read_list_from_text(prefs['rejecturls']) + return self.listcache + + def _save_list(self,listcache): + #print("_save_list") + self.prefs['rejecturls'] = '\n'.join([x.to_line() for x in listcache.values()]) + self.prefs.save_to_db() + self.listcache = None + + def clear_cache(self): + self.listcache = None + + # true if url is in list. + def check(self,url): + with self.sync_lock: + listcache = self._get_listcache() + return url in listcache + + def get_note(self,url): + with self.sync_lock: + listcache = self._get_listcache() + if url in listcache: + return listcache[url].note + # not found + return '' + + def get_full_note(self,url): + with self.sync_lock: + listcache = self._get_listcache() + if url in listcache: + return listcache[url].fullnote() + # not found + return '' + + def remove(self,url): + with self.sync_lock: + listcache = self._get_listcache() + if url in listcache: + del listcache[url] + self._save_list(listcache) + + def add_text(self,rejecttext,addreasontext): + self.add(self._read_list_from_text(rejecttext,addreasontext).values()) + + def add(self,rejectlist,clear=False): + with self.sync_lock: + if clear: + listcache=OrderedDict() + else: + listcache = self._get_listcache() + for l in rejectlist: + listcache[l.url]=l + self._save_list(listcache) + + def get_list(self): + return self._get_listcache().values() + + def get_reject_reasons(self): + return self.prefs['rejectreasons'].splitlines() + +rejecturllist = RejectURLList(prefs) + +class ConfigWidget(QWidget): + + def __init__(self, plugin_action): + QWidget.__init__(self) + self.plugin_action = plugin_action + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel(''+_('List of Supported Sites')+' -- '+_('FAQs')+'') + label.setOpenExternalLinks(True) + self.l.addWidget(label) + + + self.scroll_area = QScrollArea(self) + self.scroll_area.setFrameShape(QScrollArea.NoFrame) + self.scroll_area.setWidgetResizable(True) + self.l.addWidget(self.scroll_area) + + tab_widget = QTabWidget(self) + self.scroll_area.setWidget(tab_widget) + + self.basic_tab = BasicTab(self, plugin_action) + tab_widget.addTab(self.basic_tab, _('Basic')) + + self.personalini_tab = PersonalIniTab(self, plugin_action) + tab_widget.addTab(self.personalini_tab, 'personal.ini') + + self.readinglist_tab = ReadingListTab(self, plugin_action) + tab_widget.addTab(self.readinglist_tab, 'Reading Lists') + if 'Reading List' not in plugin_action.gui.iactions: + self.readinglist_tab.setEnabled(False) + + self.generatecover_tab = GenerateCoverTab(self, plugin_action) + tab_widget.addTab(self.generatecover_tab, 'Generate Cover') + if 'Generate Cover' not in plugin_action.gui.iactions: + self.generatecover_tab.setEnabled(False) + + self.countpages_tab = CountPagesTab(self, plugin_action) + tab_widget.addTab(self.countpages_tab, 'Count Pages') + if 'Count Pages' not in plugin_action.gui.iactions: + self.countpages_tab.setEnabled(False) + + self.std_columns_tab = StandardColumnsTab(self, plugin_action) + tab_widget.addTab(self.std_columns_tab, _('Standard Columns')) + + self.cust_columns_tab = CustomColumnsTab(self, plugin_action) + tab_widget.addTab(self.cust_columns_tab, _('Custom Columns')) + + self.other_tab = OtherTab(self, plugin_action) + tab_widget.addTab(self.other_tab, _('Other')) + + + def save_settings(self): + + # basic + prefs['fileform'] = unicode(self.basic_tab.fileform.currentText()) + prefs['collision'] = save_collisions[unicode(self.basic_tab.collision.currentText())] + prefs['updatemeta'] = self.basic_tab.updatemeta.isChecked() + prefs['updatecover'] = self.basic_tab.updatecover.isChecked() + prefs['updateepubcover'] = self.basic_tab.updateepubcover.isChecked() + prefs['keeptags'] = self.basic_tab.keeptags.isChecked() + prefs['suppressauthorsort'] = self.basic_tab.suppressauthorsort.isChecked() + prefs['suppresstitlesort'] = self.basic_tab.suppresstitlesort.isChecked() + prefs['mark'] = self.basic_tab.mark.isChecked() + prefs['showmarked'] = self.basic_tab.showmarked.isChecked() + prefs['autoconvert'] = self.basic_tab.autoconvert.isChecked() + prefs['urlsfromclip'] = self.basic_tab.urlsfromclip.isChecked() + prefs['updatedefault'] = self.basic_tab.updatedefault.isChecked() + prefs['deleteotherforms'] = self.basic_tab.deleteotherforms.isChecked() + prefs['adddialogstaysontop'] = self.basic_tab.adddialogstaysontop.isChecked() + prefs['includeimages'] = self.basic_tab.includeimages.isChecked() + prefs['lookforurlinhtml'] = self.basic_tab.lookforurlinhtml.isChecked() + prefs['checkforseriesurlid'] = self.basic_tab.checkforseriesurlid.isChecked() + prefs['checkforurlchange'] = self.basic_tab.checkforurlchange.isChecked() + prefs['injectseries'] = self.basic_tab.injectseries.isChecked() + prefs['smarten_punctuation'] = self.basic_tab.smarten_punctuation.isChecked() + prefs['reject_always'] = self.basic_tab.reject_always.isChecked() + + if self.readinglist_tab: + # lists + prefs['send_lists'] = ', '.join(map( lambda x : x.strip(), filter( lambda x : x.strip() != '', unicode(self.readinglist_tab.send_lists_box.text()).split(',')))) + prefs['read_lists'] = ', '.join(map( lambda x : x.strip(), filter( lambda x : x.strip() != '', unicode(self.readinglist_tab.read_lists_box.text()).split(',')))) + # print("send_lists: %s"%prefs['send_lists']) + # print("read_lists: %s"%prefs['read_lists']) + prefs['addtolists'] = self.readinglist_tab.addtolists.isChecked() + prefs['addtoreadlists'] = self.readinglist_tab.addtoreadlists.isChecked() + prefs['addtolistsonread'] = self.readinglist_tab.addtolistsonread.isChecked() + + # personal.ini + ini = unicode(self.personalini_tab.ini.toPlainText()) + if ini: + prefs['personal.ini'] = ini + else: + # if they've removed everything, reset to default. + prefs['personal.ini'] = get_resources('plugin-example.ini') + + # Generate Covers tab + prefs['gcnewonly'] = self.generatecover_tab.gcnewonly.isChecked() + gc_site_settings = {} + for (site,combo) in self.generatecover_tab.gc_dropdowns.iteritems(): + val = unicode(convert_qvariant(combo.itemData(combo.currentIndex()))) + if val != 'none': + gc_site_settings[site] = val + #print("gc_site_settings[%s]:%s"%(site,gc_site_settings[site])) + prefs['gc_site_settings'] = gc_site_settings + prefs['allow_gc_from_ini'] = self.generatecover_tab.allow_gc_from_ini.isChecked() + prefs['gc_polish_cover'] = self.generatecover_tab.gc_polish_cover.isChecked() + + # Count Pages tab + countpagesstats = [] + + if self.countpages_tab.pagecount.isChecked(): + countpagesstats.append('PageCount') + if self.countpages_tab.wordcount.isChecked(): + countpagesstats.append('WordCount') + if self.countpages_tab.fleschreading.isChecked(): + countpagesstats.append('FleschReading') + if self.countpages_tab.fleschgrade.isChecked(): + countpagesstats.append('FleschGrade') + if self.countpages_tab.gunningfog.isChecked(): + countpagesstats.append('GunningFog') + + prefs['countpagesstats'] = countpagesstats + + # Standard Columns tab + colsnewonly = {} + for (col,checkbox) in self.std_columns_tab.stdcol_newonlycheck.iteritems(): + colsnewonly[col] = checkbox.isChecked() + prefs['std_cols_newonly'] = colsnewonly + + # Custom Columns tab + # error column + prefs['errorcol'] = unicode(convert_qvariant(self.cust_columns_tab.errorcol.itemData(self.cust_columns_tab.errorcol.currentIndex()))) + + # cust cols tab + colsmap = {} + for (col,combo) in self.cust_columns_tab.custcol_dropdowns.iteritems(): + val = unicode(convert_qvariant(combo.itemData(combo.currentIndex()))) + if val != 'none': + colsmap[col] = val + #print("colsmap[%s]:%s"%(col,colsmap[col])) + prefs['custom_cols'] = colsmap + + colsnewonly = {} + for (col,checkbox) in self.cust_columns_tab.custcol_newonlycheck.iteritems(): + colsnewonly[col] = checkbox.isChecked() + prefs['custom_cols_newonly'] = colsnewonly + + prefs['allow_custcol_from_ini'] = self.cust_columns_tab.allow_custcol_from_ini.isChecked() + + prefs.save_to_db() + + def edit_shortcuts(self): + self.save_settings() + # Force the menus to be rebuilt immediately, so we have all our actions registered + self.plugin_action.rebuild_menus() + d = KeyboardConfigDialog(self.plugin_action.gui, self.plugin_action.action_spec[0]) + if d.exec_() == d.Accepted: + self.plugin_action.gui.keyboard.finalize() + +class BasicTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + topl = QVBoxLayout() + self.setLayout(topl) + + label = QLabel(_('These settings control the basic features of the plugin--downloading FanFiction.')) + label.setWordWrap(True) + topl.addWidget(label) + + defs_gb = groupbox = QGroupBox(_("Defaults Options on Download")) + self.l = QVBoxLayout() + groupbox.setLayout(self.l) + + tooltip = _("On each download, FFDL offers an option to select the output format.
This sets what that option will default to.") + horz = QHBoxLayout() + label = QLabel(_('Default Output &Format:')) + label.setToolTip(tooltip) + horz.addWidget(label) + self.fileform = QComboBox(self) + self.fileform.addItem('epub') + self.fileform.addItem('mobi') + self.fileform.addItem('html') + self.fileform.addItem('txt') + self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform'])) + self.fileform.setToolTip(tooltip) + self.fileform.activated.connect(self.set_collisions) + label.setBuddy(self.fileform) + horz.addWidget(self.fileform) + self.l.addLayout(horz) + + tooltip = _("On each download, FFDL offers an option of what happens if that story already exists.
This sets what that option will default to.") + horz = QHBoxLayout() + label = QLabel(_('Default If Story Already Exists?')) + label.setToolTip(tooltip) + horz.addWidget(label) + self.collision = QComboBox(self) + # add collision options + self.set_collisions() + i = self.collision.findText(save_collisions[prefs['collision']]) + if i > -1: + self.collision.setCurrentIndex(i) + self.collision.setToolTip(tooltip) + label.setBuddy(self.collision) + horz.addWidget(self.collision) + self.l.addLayout(horz) + + self.updatemeta = QCheckBox(_('Default Update Calibre &Metadata?'),self) + self.updatemeta.setToolTip(_("On each download, FFDL offers an option to update Calibre's metadata (title, author, URL, tags, custom columns, etc) from the web site.
This sets whether that will default to on or off.
Columns set to 'New Only' in the column tabs will only be set for new books.")) + self.updatemeta.setChecked(prefs['updatemeta']) + self.l.addWidget(self.updatemeta) + + self.updateepubcover = QCheckBox(_('Default Update EPUB Cover when Updating EPUB?'),self) + self.updateepubcover.setToolTip(_("On each download, FFDL offers an option to update the book cover image inside the EPUB from the web site when the EPUB is updated.
This sets whether that will default to on or off.")) + self.updateepubcover.setChecked(prefs['updateepubcover']) + self.l.addWidget(self.updateepubcover) + + self.smarten_punctuation = QCheckBox(_('Smarten Punctuation (EPUB only)'),self) + self.smarten_punctuation.setToolTip(_("Run Smarten Punctuation from Calibre's Polish Book feature on each EPUB download and update.")) + self.smarten_punctuation.setChecked(prefs['smarten_punctuation']) + if calibre_version >= (0, 9, 39): + self.l.addWidget(self.smarten_punctuation) + + cali_gb = groupbox = QGroupBox(_("Updating Calibre Options")) + self.l = QVBoxLayout() + groupbox.setLayout(self.l) + + self.deleteotherforms = QCheckBox(_('Delete other existing formats?'),self) + self.deleteotherforms.setToolTip(_('Check this to automatically delete all other ebook formats when updating an existing book.\nHandy if you have both a Nook(epub) and Kindle(mobi), for example.')) + self.deleteotherforms.setChecked(prefs['deleteotherforms']) + self.l.addWidget(self.deleteotherforms) + + self.updatecover = QCheckBox(_('Update Calibre Cover when Updating Metadata?'),self) + self.updatecover.setToolTip(_("Update calibre book cover image from EPUB when metadata is updated. (EPUB only.)\nDoesn't go looking for new images on 'Update Calibre Metadata Only'.")) + self.updatecover.setChecked(prefs['updatecover']) + self.l.addWidget(self.updatecover) + + self.keeptags = QCheckBox(_('Keep Existing Tags when Updating Metadata?'),self) + self.keeptags.setToolTip(_("Existing tags will be kept and any new tags added.\n%(cmplt)s and %(inprog)s tags will be still be updated, if known.\n%(lul)s tags will be updated if %(lus)s in %(is)s.\n(If Tags is set to 'New Only' in the Standard Columns tab, this has no effect.)")%no_trans) + self.keeptags.setChecked(prefs['keeptags']) + self.l.addWidget(self.keeptags) + + self.suppressauthorsort = QCheckBox(_('Force Author into Author Sort?'),self) + self.suppressauthorsort.setToolTip(_("If checked, the author(s) as given will be used for the Author Sort, too.\nIf not checked, calibre will apply it's built in algorithm which makes 'Bob Smith' sort as 'Smith, Bob', etc.")) + self.suppressauthorsort.setChecked(prefs['suppressauthorsort']) + self.l.addWidget(self.suppressauthorsort) + + self.suppresstitlesort = QCheckBox(_('Force Title into Title Sort?'),self) + self.suppresstitlesort.setToolTip(_("If checked, the title as given will be used for the Title Sort, too.\nIf not checked, calibre will apply it's built in algorithm which makes 'The Title' sort as 'Title, The', etc.")) + self.suppresstitlesort.setChecked(prefs['suppresstitlesort']) + self.l.addWidget(self.suppresstitlesort) + + self.checkforseriesurlid = QCheckBox(_("Check for existing Series Anthology books?"),self) + self.checkforseriesurlid.setToolTip(_("Check for existings Series Anthology books using each new story's series URL before downloading.\nOffer to skip downloading if a Series Anthology is found.")) + self.checkforseriesurlid.setChecked(prefs['checkforseriesurlid']) + self.l.addWidget(self.checkforseriesurlid) + + self.checkforurlchange = QCheckBox(_("Check for changed Story URL?"),self) + self.checkforurlchange.setToolTip(_("Warn you if an update will change the URL of an existing book.\nfanfiction.net URLs will change from http to https silently.")) + self.checkforurlchange.setChecked(prefs['checkforurlchange']) + self.l.addWidget(self.checkforurlchange) + + self.lookforurlinhtml = QCheckBox(_("Search EPUB text for Story URL?"),self) + self.lookforurlinhtml.setToolTip(_("Look for first valid story URL inside EPUB text if not found in metadata.\nSomewhat risky, could find wrong URL depending on EPUB content.\nAlso finds and corrects bad ffnet URLs from ficsaver.com files.")) + self.lookforurlinhtml.setChecked(prefs['lookforurlinhtml']) + self.l.addWidget(self.lookforurlinhtml) + + self.mark = QCheckBox(_("Mark added/updated books when finished?"),self) + self.mark.setToolTip(_("Mark added/updated books when finished. Use with option below.\nYou can also manually search for 'marked:ffdl_success'.\n'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both.")) + self.mark.setChecked(prefs['mark']) + self.l.addWidget(self.mark) + + self.showmarked = QCheckBox(_("Show Marked books when finished?"),self) + self.showmarked.setToolTip(_("Show Marked added/updated books only when finished.\nYou can also manually search for 'marked:ffdl_success'.\n'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both.")) + self.showmarked.setChecked(prefs['showmarked']) + self.l.addWidget(self.showmarked) + + self.autoconvert = QCheckBox(_("Automatically Convert new/update books?"),self) + self.autoconvert.setToolTip(_("Automatically call calibre's Convert for new/update books.\nConverts to the current output format as chosen in calibre's\nPreferences->Behavior settings.")) + self.autoconvert.setChecked(prefs['autoconvert']) + self.l.addWidget(self.autoconvert) + + gui_gb = groupbox = QGroupBox(_("GUI Options")) + self.l = QVBoxLayout() + groupbox.setLayout(self.l) + + self.urlsfromclip = QCheckBox(_('Take URLs from Clipboard?'),self) + self.urlsfromclip.setToolTip(_('Prefill URLs from valid URLs in Clipboard when Adding New.')) + self.urlsfromclip.setChecked(prefs['urlsfromclip']) + self.l.addWidget(self.urlsfromclip) + + self.updatedefault = QCheckBox(_('Default to Update when books selected?'),self) + self.updatedefault.setToolTip(_('The top FanFictionDownLoader plugin button will start Update if\nbooks are selected. If unchecked, it will always bring up \'Add New\'.')) + self.updatedefault.setChecked(prefs['updatedefault']) + self.l.addWidget(self.updatedefault) + + self.adddialogstaysontop = QCheckBox(_("Keep 'Add New from URL(s)' dialog on top?"),self) + self.adddialogstaysontop.setToolTip(_("Instructs the OS and Window Manager to keep the 'Add New from URL(s)'\ndialog on top of all other windows. Useful for dragging URLs onto it.")) + self.adddialogstaysontop.setChecked(prefs['adddialogstaysontop']) + self.l.addWidget(self.adddialogstaysontop) + + misc_gb = groupbox = QGroupBox(_("Misc Options")) + self.l = QVBoxLayout() + groupbox.setLayout(self.l) + + # this is a cheat to make it easier for users to realize there's a new include_images features. + self.includeimages = QCheckBox(_("Include images in EPUBs?"),self) + self.includeimages.setToolTip(_("Download and include images in EPUB stories. This is equivalent to adding:%(imgset)s ...to the top of %(pini)s. Your settings in %(pini)s will override this.")%no_trans) + self.includeimages.setChecked(prefs['includeimages']) + self.l.addWidget(self.includeimages) + + self.injectseries = QCheckBox(_("Inject calibre Series when none found?"),self) + self.injectseries.setToolTip(_("If no series is found, inject the calibre series (if there is one) so it appears on the FFDL title page(not cover).")) + self.injectseries.setChecked(prefs['injectseries']) + self.l.addWidget(self.injectseries) + + rej_gb = groupbox = QGroupBox(_("Reject List")) + self.l = QVBoxLayout() + groupbox.setLayout(self.l) + + self.rejectlist = QPushButton(_('Edit Reject URL List'), self) + self.rejectlist.setToolTip(_("Edit list of URLs FFDL will automatically Reject.")) + self.rejectlist.clicked.connect(self.show_rejectlist) + self.l.addWidget(self.rejectlist) + + self.reject_urls = QPushButton(_('Add Reject URLs'), self) + self.reject_urls.setToolTip(_("Add additional URLs to Reject as text.")) + self.reject_urls.clicked.connect(self.add_reject_urls) + self.l.addWidget(self.reject_urls) + + self.reject_reasons = QPushButton(_('Edit Reject Reasons List'), self) + self.reject_reasons.setToolTip(_("Customize the Reasons presented when Rejecting URLs")) + self.reject_reasons.clicked.connect(self.show_reject_reasons) + self.l.addWidget(self.reject_reasons) + + self.reject_always = QCheckBox(_('Reject Without Confirmation?'),self) + self.reject_always.setToolTip(_("Always reject URLs on the Reject List without stopping and asking.")) + self.reject_always.setChecked(prefs['reject_always']) + self.l.addWidget(self.reject_always) + + topl.addWidget(defs_gb) + + horz = QHBoxLayout() + + horz.addWidget(cali_gb) + + vert = QVBoxLayout() + vert.addWidget(gui_gb) + vert.addWidget(misc_gb) + vert.addWidget(rej_gb) + + horz.addLayout(vert) + + topl.addLayout(horz) + topl.insertStretch(-1) + + def set_collisions(self): + prev=self.collision.currentText() + self.collision.clear() + for o in collision_order: + if self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]: + self.collision.addItem(o) + i = self.collision.findText(prev) + if i > -1: + self.collision.setCurrentIndex(i) + + def show_defaults(self): + text = get_resources('plugin-defaults.ini') + ShowDefaultsIniDialog(self.windowIcon(),text,self).exec_() + + def show_rejectlist(self): + d = RejectListDialog(self, + rejecturllist.get_list(), + rejectreasons=rejecturllist.get_reject_reasons(), + header=_("Edit Reject URLs List"), + show_delete=False, + show_all_reasons=False) + d.exec_() + + if d.result() != d.Accepted: + return + + rejecturllist.add(d.get_reject_list(),clear=True) + + def show_reject_reasons(self): + d = EditTextDialog(self, + prefs['rejectreasons'], + icon=self.windowIcon(), + title=_("Reject Reasons"), + label=_("Customize Reject List Reasons"), + tooltip=_("Customize the Reasons presented when Rejecting URLs")) + d.exec_() + if d.result() == d.Accepted: + prefs['rejectreasons'] = d.get_plain_text() + + def add_reject_urls(self): + d = EditTextDialog(self, + "http://example.com/story.php?sid=5,"+_("Reason why I rejected it")+"\nhttp://example.com/story.php?sid=6,"+_("Title by Author")+" - "+_("Reason why I rejected it"), + icon=self.windowIcon(), + title=_("Add Reject URLs"), + label=_("Add Reject URLs. Use: http://...,note or http://...,title by author - note
Invalid story URLs will be ignored."), + tooltip=_("One URL per line:\nhttp://...,note\nhttp://...,title by author - 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(),d.get_reason_text()) + +class PersonalIniTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel(_('These settings provide more detailed control over what metadata will be displayed inside the ebook as well as let you set %(isa)s and %(u)s/%(p)s for different sites.')%no_trans) + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + self.label = QLabel('personal.ini:') + self.l.addWidget(self.label) + + self.ini = QTextEdit(self) + try: + self.ini.setFont(QFont("Courier", + self.plugin_action.gui.font().pointSize()+1)) + except Exception as e: + logger.error("Couldn't get font: %s"%e) + self.ini.setLineWrapMode(QTextEdit.NoWrap) + self.ini.setText(prefs['personal.ini']) + self.l.addWidget(self.ini) + + self.defaults = QPushButton(_('View Defaults')+' (plugin-defaults.ini)', self) + self.defaults.setToolTip(_("View all of the plugin's configurable settings\nand their default settings.")) + self.defaults.clicked.connect(self.show_defaults) + self.l.addWidget(self.defaults) + + # self.l.insertStretch(-1) + # let edit box fill the space. + + def show_defaults(self): + text = get_resources('plugin-defaults.ini') + ShowDefaultsIniDialog(self.windowIcon(),text,self).exec_() + +class ShowDefaultsIniDialog(QDialog): + + def __init__(self, icon, text, parent=None): + QDialog.__init__(self, parent) + self.resize(600, 500) + self.l = QVBoxLayout() + self.setLayout(self.l) + self.label = QLabel(_("Plugin Defaults (%s) (Read-Only)")%'plugin-defaults.ini') + self.label.setToolTip(_("These are all of the plugin's configurable options\nand their default settings.")) + self.setWindowTitle(_('Plugin Defaults')) + self.setWindowIcon(icon) + self.l.addWidget(self.label) + + self.ini = QTextEdit(self) + self.ini.setToolTip(_("These are all of the plugin's configurable options\nand their default settings.")) + try: + self.ini.setFont(QFont("Courier", + get_gui().font().pointSize()+1)) + except Exception as e: + logger.error("Couldn't get font: %s"%e) + self.ini.setLineWrapMode(QTextEdit.NoWrap) + self.ini.setText(text) + self.ini.setReadOnly(True) + self.l.addWidget(self.ini) + + self.ok_button = QPushButton(_('OK'), self) + self.ok_button.clicked.connect(self.hide) + self.l.addWidget(self.ok_button) + +class ReadingListTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + try: + rl_plugin = plugin_action.gui.iactions['Reading List'] + reading_lists = rl_plugin.get_list_names() + except KeyError: + reading_lists= [] + + label = QLabel(_('These settings provide integration with the %(rl)s Plugin. %(rl)s can automatically send to devices and change custom columns. You have to create and configure the lists in %(rl)s to be useful.')%no_trans) + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + self.addtolists = QCheckBox(_('Add new/updated stories to "Send to Device" Reading List(s).'),self) + self.addtolists.setToolTip(_('Automatically add new/updated stories to these lists in the %(rl)s plugin.')%no_trans) + self.addtolists.setChecked(prefs['addtolists']) + self.l.addWidget(self.addtolists) + + horz = QHBoxLayout() + label = QLabel(_('"Send to Device" Reading Lists')) + label.setToolTip(_("When enabled, new/updated stories will be automatically added to these lists.")) + horz.addWidget(label) + self.send_lists_box = EditWithComplete(self) + self.send_lists_box.setToolTip(_("When enabled, new/updated stories will be automatically added to these lists.")) + self.send_lists_box.update_items_cache(reading_lists) + self.send_lists_box.setText(prefs['send_lists']) + horz.addWidget(self.send_lists_box) + self.l.addLayout(horz) + + self.addtoreadlists = QCheckBox(_('Add new/updated stories to "To Read" Reading List(s).'),self) + self.addtoreadlists.setToolTip(_('Automatically add new/updated stories to these lists in the %(rl)s plugin.\nAlso offers menu option to remove stories from the "To Read" lists.')%no_trans) + self.addtoreadlists.setChecked(prefs['addtoreadlists']) + self.l.addWidget(self.addtoreadlists) + + horz = QHBoxLayout() + label = QLabel(_('"To Read" Reading Lists')) + label.setToolTip(_("When enabled, new/updated stories will be automatically added to these lists.")) + horz.addWidget(label) + self.read_lists_box = EditWithComplete(self) + self.read_lists_box.setToolTip(_("When enabled, new/updated stories will be automatically added to these lists.")) + self.read_lists_box.update_items_cache(reading_lists) + self.read_lists_box.setText(prefs['read_lists']) + horz.addWidget(self.read_lists_box) + self.l.addLayout(horz) + + self.addtolistsonread = QCheckBox(_('Add stories back to "Send to Device" Reading List(s) when marked "Read".'),self) + self.addtolistsonread.setToolTip(_('Menu option to remove from "To Read" lists will also add stories back to "Send to Device" Reading List(s)')) + self.addtolistsonread.setChecked(prefs['addtolistsonread']) + self.l.addWidget(self.addtolistsonread) + + self.l.insertStretch(-1) + +class GenerateCoverTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + try: + gc_plugin = plugin_action.gui.iactions['Generate Cover'] + gc_settings = gc_plugin.get_saved_setting_names() + except KeyError: + gc_settings= [] + + label = QLabel(_('The %(gc)s plugin can create cover images for books using various metadata and configurations. If you have GC installed, FFDL can run GC on new downloads and metadata updates. Pick a GC setting by site or Default.')%no_trans) + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + scrollable = QScrollArea() + scrollcontent = QWidget() + scrollable.setWidget(scrollcontent) + scrollable.setWidgetResizable(True) + self.l.addWidget(scrollable) + + self.sl = QVBoxLayout() + scrollcontent.setLayout(self.sl) + + self.gc_dropdowns = {} + + sitelist = getConfigSections() + sitelist.sort() + sitelist.insert(0,_("Default")) + for site in sitelist: + horz = QHBoxLayout() + label = QLabel(site) + if site == _("Default"): + s = _("On Metadata update, run %(gc)s with this setting, if not selected for specific site.")%no_trans + else: + no_trans['site']=site # not ideal, but, meh. + s = _("On Metadata update, run %(gc)s with this setting for %(site)s stories.")%no_trans + + label.setToolTip(s) + horz.addWidget(label) + dropdown = QComboBox(self) + dropdown.setToolTip(s) + dropdown.addItem('','none') + for setting in gc_settings: + dropdown.addItem(setting,setting) + if site == _("Default"): + self.gc_dropdowns["Default"] = dropdown + if 'Default' in prefs['gc_site_settings']: + dropdown.setCurrentIndex(dropdown.findData(prefs['gc_site_settings']['Default'])) + else: + self.gc_dropdowns[site] = dropdown + if site in prefs['gc_site_settings']: + dropdown.setCurrentIndex(dropdown.findData(prefs['gc_site_settings'][site])) + + horz.addWidget(dropdown) + self.sl.addLayout(horz) + + self.sl.insertStretch(-1) + + self.gcnewonly = QCheckBox(_("Run %(gc)s Only on New Books")%no_trans,self) + self.gcnewonly.setToolTip(_("Default is to run GC any time the calibre metadata is updated.")) + self.gcnewonly.setChecked(prefs['gcnewonly']) + self.l.addWidget(self.gcnewonly) + + self.allow_gc_from_ini = QCheckBox(_('Allow %(gcset)s from %(pini)s to override')%no_trans,self) + self.allow_gc_from_ini.setToolTip(_("The %(pini)s parameter %(gcset)s allows you to choose a GC setting based on metadata rather than site, but it's much more complex.
%(gcset)s is ignored when this is off.")%no_trans) + self.allow_gc_from_ini.setChecked(prefs['allow_gc_from_ini']) + self.l.addWidget(self.allow_gc_from_ini) + + self.gc_polish_cover = QCheckBox(_("Use calibre's Polish feature to inject/update the cover"),self) + self.gc_polish_cover.setToolTip(_("Calibre's Polish feature will be used to inject or update the generated cover into the ebook, EPUB only.")) + self.gc_polish_cover.setChecked(prefs['gc_polish_cover']) + self.l.addWidget(self.gc_polish_cover) + +class CountPagesTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel(_('These settings provide integration with the %(cp)s Plugin. %(cp)s can automatically update custom columns with page, word and reading level statistics. You have to create and configure the columns in %(cp)s first.')%no_trans) + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + label = QLabel(_('If any of the settings below are checked, when stories are added or updated, the %(cp)s Plugin will be called to update the checked statistics.')%no_trans) + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + # the same for all settings. Mostly. + tooltip = _('Which column and algorithm to use are configured in %(cp)s.')%no_trans + # 'PageCount', 'WordCount', 'FleschReading', 'FleschGrade', 'GunningFog' + self.pagecount = QCheckBox('Page Count',self) + self.pagecount.setToolTip(tooltip) + self.pagecount.setChecked('PageCount' in prefs['countpagesstats']) + self.l.addWidget(self.pagecount) + + self.wordcount = QCheckBox('Word Count',self) + self.wordcount.setToolTip(tooltip+"\n"+_('Will overwrite word count from FFDL metadata if set to update the same custom column.')) + self.wordcount.setChecked('WordCount' in prefs['countpagesstats']) + self.l.addWidget(self.wordcount) + + self.fleschreading = QCheckBox('Flesch Reading Ease',self) + self.fleschreading.setToolTip(tooltip) + self.fleschreading.setChecked('FleschReading' in prefs['countpagesstats']) + self.l.addWidget(self.fleschreading) + + self.fleschgrade = QCheckBox('Flesch-Kincaid Grade Level',self) + self.fleschgrade.setToolTip(tooltip) + self.fleschgrade.setChecked('FleschGrade' in prefs['countpagesstats']) + self.l.addWidget(self.fleschgrade) + + self.gunningfog = QCheckBox('Gunning Fog Index',self) + self.gunningfog.setToolTip(tooltip) + self.gunningfog.setChecked('GunningFog' in prefs['countpagesstats']) + self.l.addWidget(self.gunningfog) + + self.l.insertStretch(-1) + +class OtherTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel(_("These controls aren't plugin settings as such, but convenience buttons for setting Keyboard shortcuts and getting all the FanFictionDownLoader confirmation dialogs back again.")) + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + keyboard_shortcuts_button = QPushButton(_('Keyboard shortcuts...'), self) + keyboard_shortcuts_button.setToolTip(_('Edit the keyboard shortcuts associated with this plugin')) + keyboard_shortcuts_button.clicked.connect(parent_dialog.edit_shortcuts) + self.l.addWidget(keyboard_shortcuts_button) + + reset_confirmation_button = QPushButton(_('Reset disabled &confirmation dialogs'), self) + reset_confirmation_button.setToolTip(_('Reset all show me again dialogs for the FanFictionDownLoader plugin')) + reset_confirmation_button.clicked.connect(self.reset_dialogs) + self.l.addWidget(reset_confirmation_button) + + view_prefs_button = QPushButton(_('&View library preferences...'), self) + view_prefs_button.setToolTip(_('View data stored in the library database for this plugin')) + view_prefs_button.clicked.connect(self.view_prefs) + self.l.addWidget(view_prefs_button) + + self.l.insertStretch(-1) + + def reset_dialogs(self): + for key in dynamic.keys(): + if key.startswith('fanfictiondownloader_') and key.endswith('_again') \ + and dynamic[key] is False: + dynamic[key] = True + info_dialog(self, _('Done'), + _('Confirmation dialogs have all been reset'), + show=True, + show_copy_button=False) + + def view_prefs(self): + d = PrefsViewerDialog(self.plugin_action.gui, PREFS_NAMESPACE) + d.exec_() + +permitted_values = { + 'int' : ['numWords','numChapters'], + 'float' : ['numWords','numChapters'], + 'bool' : ['status-C','status-I'], + 'datetime' : ['datePublished', 'dateUpdated', 'dateCreated'], + 'series' : ['series'], + 'enumeration' : ['category', + 'genre', + 'language', + 'series', + 'characters', + 'ships', + 'status', + 'datePublished', + 'dateUpdated', + 'dateCreated', + 'rating', + 'warnings', + 'numChapters', + 'numWords', + 'site', + 'storyId', + 'authorId', + 'extratags', + 'title', + 'storyUrl', + 'description', + 'author', + 'authorUrl', + 'formatname', + 'version' + #,'formatext' # not useful information. + #,'siteabbrev' + ] + } +# no point copying the whole list. +permitted_values['text'] = permitted_values['enumeration'] +permitted_values['comments'] = permitted_values['enumeration'] + +titleLabels = { + 'category':_('Category'), + 'genre':_('Genre'), + 'language':_('Language'), + 'status':_('Status'), + 'status-C':_('Status:%(cmplt)s')%no_trans, + 'status-I':_('Status:%(inprog)s')%no_trans, + 'series':_('Series'), + 'characters':_('Characters'), + 'ships':_('Relationships'), + 'datePublished':_('Published'), + 'dateUpdated':_('Updated'), + 'dateCreated':_('Created'), + 'rating':_('Rating'), + 'warnings':_('Warnings'), + 'numChapters':_('Chapters'), + 'numWords':_('Words'), + 'site':_('Site'), + 'storyId':_('Story ID'), + 'authorId':_('Author ID'), + 'extratags':_('Extra Tags'), + 'title':_('Title'), + 'storyUrl':_('Story URL'), + 'description':_('Description'), + 'author':_('Author'), + 'authorUrl':_('Author URL'), + 'formatname':_('File Format'), + 'formatext':_('File Extension'), + 'siteabbrev':_('Site Abbrev'), + 'version':_('FFDL Version') + } + +class CustomColumnsTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + custom_columns = self.plugin_action.gui.library_view.model().custom_columns + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel(_("If you have custom columns defined, they will be listed below. Choose a metadata value type to fill your columns automatically.")) + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + self.custcol_dropdowns = {} + self.custcol_newonlycheck = {} + + scrollable = QScrollArea() + scrollcontent = QWidget() + scrollable.setWidget(scrollcontent) + scrollable.setWidgetResizable(True) + self.l.addWidget(scrollable) + + self.sl = QVBoxLayout() + scrollcontent.setLayout(self.sl) + + for key, column in custom_columns.iteritems(): + + if column['datatype'] in permitted_values: + # print("\n============== %s ===========\n"%key) + # for (k,v) in column.iteritems(): + # print("column['%s'] => %s"%(k,v)) + horz = QHBoxLayout() + label = QLabel(column['name']) + label.setToolTip(_("Update this %s column(%s) with...")%(key,column['datatype'])) + horz.addWidget(label) + dropdown = QComboBox(self) + dropdown.addItem('','none') + for md in permitted_values[column['datatype']]: + dropdown.addItem(titleLabels[md],md) + self.custcol_dropdowns[key] = dropdown + if key in prefs['custom_cols']: + dropdown.setCurrentIndex(dropdown.findData(prefs['custom_cols'][key])) + if column['datatype'] == 'enumeration': + dropdown.setToolTip(_("Metadata values valid for this type of column.")+"\n"+_("Values that aren't valid for this enumeration column will be ignored.")) + else: + dropdown.setToolTip(_("Metadata values valid for this type of column.")) + horz.addWidget(dropdown) + + newonlycheck = QCheckBox(_("New Only"),self) + newonlycheck.setToolTip(_("Write to %s(%s) only for new\nbooks, not updates to existing books.")%(column['name'],key)) + self.custcol_newonlycheck[key] = newonlycheck + if key in prefs['custom_cols_newonly']: + newonlycheck.setChecked(prefs['custom_cols_newonly'][key]) + horz.addWidget(newonlycheck) + + self.sl.addLayout(horz) + + self.sl.insertStretch(-1) + + self.l.addSpacing(5) + self.allow_custcol_from_ini = QCheckBox(_('Allow %(ccset)s from %(pini)s to override')%no_trans,self) + self.allow_custcol_from_ini.setToolTip(_("The %(pini)s parameter %(ccset)s allows you to set custom columns to site specific values that aren't common to all sites.
%(ccset)s is ignored when this is off.")%no_trans) + self.allow_custcol_from_ini.setChecked(prefs['allow_custcol_from_ini']) + self.l.addWidget(self.allow_custcol_from_ini) + + self.l.addSpacing(5) + label = QLabel(_("Special column:")) + label.setWordWrap(True) + self.l.addWidget(label) + + horz = QHBoxLayout() + label = QLabel(_("Update/Overwrite Error Column:")) + tooltip=_("When an update or overwrite of an existing story fails, record the reason in this column.\n(Text and Long Text columns only.)") + label.setToolTip(tooltip) + horz.addWidget(label) + self.errorcol = QComboBox(self) + self.errorcol.setToolTip(tooltip) + self.errorcol.addItem('','none') + for key, column in custom_columns.iteritems(): + if column['datatype'] in ('text','comments'): + self.errorcol.addItem(column['name'],key) + self.errorcol.setCurrentIndex(self.errorcol.findData(prefs['errorcol'])) + horz.addWidget(self.errorcol) + self.l.addLayout(horz) + + #print("prefs['custom_cols'] %s"%prefs['custom_cols']) + + +class StandardColumnsTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + columns=OrderedDict() + + columns["title"]=_("Title") + columns["authors"]=_("Author(s)") + columns["publisher"]=_("Publisher") + columns["tags"]=_("Tags") + columns["languages"]=_("Languages") + columns["pubdate"]=_("Published Date") + columns["timestamp"]=_("Date") + columns["comments"]=_("Comments") + columns["series"]=_("Series") + columns["identifiers"]=_("Ids(url id only)") + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel(_("The standard calibre metadata columns are listed below. You may choose whether FFDL will fill each column automatically on updates or only for new books.")) + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + self.stdcol_newonlycheck = {} + + for key, column in columns.iteritems(): + horz = QHBoxLayout() + label = QLabel(column) + #label.setToolTip("Update this %s column(%s) with..."%(key,column['datatype'])) + horz.addWidget(label) + + newonlycheck = QCheckBox(_("New Only"),self) + newonlycheck.setToolTip(_("Write to %s only for new\nbooks, not updates to existing books.")%column) + self.stdcol_newonlycheck[key] = newonlycheck + if key in prefs['std_cols_newonly']: + newonlycheck.setChecked(prefs['std_cols_newonly'][key]) + horz.addWidget(newonlycheck) + + self.l.addLayout(horz) + + self.l.insertStretch(-1) + diff --git a/calibre-plugin/dialogs.py b/calibre-plugin/dialogs.py new file mode 100644 index 00000000..c4c784f9 --- /dev/null +++ b/calibre-plugin/dialogs.py @@ -0,0 +1,1125 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Jim Miller' +__docformat__ = 'restructuredtext en' + +import logging +logger = logging.getLogger(__name__) + +import traceback, re +from functools import partial + +import logging +logger = logging.getLogger(__name__) + +import urllib +import email + +try: + from PyQt5 import QtWidgets as QtGui + from PyQt5.Qt import (QDialog, QTableWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QPushButton, QLabel, QCheckBox, QIcon, QLineEdit, + QComboBox, QProgressDialog, QTimer, QDialogButtonBox, + QPixmap, Qt, QAbstractItemView, QTextEdit, pyqtSignal, + QGroupBox, QFrame) +except ImportError as e: + from PyQt4 import QtGui + from PyQt4.Qt import (QDialog, QTableWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QPushButton, QLabel, QCheckBox, QIcon, QLineEdit, + QComboBox, QProgressDialog, QTimer, QDialogButtonBox, + QPixmap, Qt, QAbstractItemView, QTextEdit, pyqtSignal, + QGroupBox, QFrame) + +try: + from calibre.gui2 import QVariant + del QVariant +except ImportError: + is_qt4 = False + convert_qvariant = lambda x: x +else: + is_qt4 = True + def convert_qvariant(x): + vt = x.type() + if vt == x.String: + return unicode(x.toString()) + if vt == x.List: + return [convert_qvariant(i) for i in x.toList()] + return x.toPyObject() + +from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.gui2.complete2 import EditWithComplete + +# pulls in translation files for _() strings +try: + load_translations() +except NameError: + pass # load_translations() added in calibre 1.9 + +from calibre_plugins.fanfictiondownloader_plugin.common_utils \ + import (ReadOnlyTableWidgetItem, ReadOnlyTextIconWidgetItem, SizePersistedDialog, + ImageTitleLayout, get_icon) + +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.geturls import get_urls_from_html, get_urls_from_text +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.adapters import getNormalStoryURL + +SKIP=_('Skip') +ADDNEW=_('Add New Book') +UPDATE=_('Update EPUB if New Chapters') +UPDATEALWAYS=_('Update EPUB Always') +OVERWRITE=_('Overwrite if Newer') +OVERWRITEALWAYS=_('Overwrite Always') +CALIBREONLY=_('Update Calibre Metadata Only') +collision_order=[SKIP, + ADDNEW, + UPDATE, + UPDATEALWAYS, + OVERWRITE, + OVERWRITEALWAYS, + CALIBREONLY,] + +# best idea I've had for how to deal with config/pref saving the +# collision name in english. +SAVE_SKIP='Skip' +SAVE_ADDNEW='Add New Book' +SAVE_UPDATE='Update EPUB if New Chapters' +SAVE_UPDATEALWAYS='Update EPUB Always' +SAVE_OVERWRITE='Overwrite if Newer' +SAVE_OVERWRITEALWAYS='Overwrite Always' +SAVE_CALIBREONLY='Update Calibre Metadata Only' +save_collisions={ + SKIP:SAVE_SKIP, + ADDNEW:SAVE_ADDNEW, + UPDATE:SAVE_UPDATE, + UPDATEALWAYS:SAVE_UPDATEALWAYS, + OVERWRITE:SAVE_OVERWRITE, + OVERWRITEALWAYS:SAVE_OVERWRITEALWAYS, + CALIBREONLY:SAVE_CALIBREONLY, + SAVE_SKIP:SKIP, + SAVE_ADDNEW:ADDNEW, + SAVE_UPDATE:UPDATE, + SAVE_UPDATEALWAYS:UPDATEALWAYS, + SAVE_OVERWRITE:OVERWRITE, + SAVE_OVERWRITEALWAYS:OVERWRITEALWAYS, + SAVE_CALIBREONLY:CALIBREONLY, + } + +anthology_collision_order=[UPDATE, + UPDATEALWAYS, + OVERWRITEALWAYS] + +gpstyle='QGroupBox {border:0; padding-top:10px; padding-bottom:0px; margin-bottom:0px;}' # background-color:red; + +class RejectUrlEntry: + + matchpat=re.compile(r"^(?P[^,]+)(,(?P(((?P.+) by (?P<auth>.+?)( - (?P<note>.+))?)|.*)))?$") + + def __init__(self,url_or_line,note=None,title=None,auth=None, + addreasontext=None,fromline=False,book_id=None): + + self.url=url_or_line + self.note=note + self.title=title + self.auth=auth + self.valid=False + self.book_id=book_id + + if fromline: + mc = re.match(self.matchpat,url_or_line) + if mc: + #print("mc:%s"%mc.groupdict()) + (url,title,auth,note) = mc.group('url','title','auth','note') + if not mc.group('title'): + title='' + auth='' + note=mc.group('fullnote') + self.url=url + self.note=note + self.title=title + self.auth=auth + + if not self.note: + if addreasontext: + self.note = addreasontext + else: + self.note = '' + 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: + # don't translate--ends up being saved and confuses regex above. + 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): + def __init__(self,error,icon='dialog_error.png'): + self.error=error + self.icon=icon + + def __str__(self): + return self.error + +class DroppableQTextEdit(QTextEdit): + def __init__(self,parent): + QTextEdit.__init__(self,parent) + + def dropEvent(self,event): + # print("event:%s"%event) + + mimetype='text/uri-list' + + urllist=[] + filelist="%s"%event.mimeData().data(mimetype) + for f in filelist.splitlines(): + #print("filename:%s"%f) + if f.endswith(".eml"): + fhandle = urllib.urlopen(f) + #print("file:\n%s\n\n"%fhandle.read()) + msg = email.message_from_file(fhandle) + if msg.is_multipart(): + for part in msg.walk(): + #print("part type:%s"%part.get_content_type()) + if part.get_content_type() == "text/html": + #print("URL list:%s"%get_urls_from_data(part.get_payload(decode=True))) + urllist.extend(get_urls_from_html(part.get_payload(decode=True))) + if part.get_content_type() == "text/plain": + #print("part content:text/plain") + # print("part content:%s"%part.get_payload(decode=True)) + urllist.extend(get_urls_from_text(part.get_payload(decode=True))) + else: + urllist.extend(get_urls_from_text("%s"%msg)) + if urllist: + self.append("\n".join(urllist)) + return None + return QTextEdit.dropEvent(self,event) + + def canInsertFromMimeData(self, source): + if source.hasUrls(): + return True + else: + return QTextEdit.canInsertFromMimeData(self,source) + + def insertFromMimeData(self, source): + if source.hasText(): + self.append(source.text()) + else: + return QTextEdit.insertFromMimeData(self, source) + +class AddNewDialog(SizePersistedDialog): + + go_signal = pyqtSignal(object, object, object, object) + + def __init__(self, gui, prefs, icon): + SizePersistedDialog.__init__(self, gui, 'FanFictionDownLoader plugin:add new dialog') + self.prefs = prefs + + self.setMinimumWidth(300) + self.l = QVBoxLayout() + self.setLayout(self.l) + + self.setWindowTitle(_('FanFictionDownLoader')) + self.setWindowIcon(icon) + + self.toplabel=QLabel("Toplabel") + self.l.addWidget(self.toplabel) + self.url = DroppableQTextEdit(self) + self.url.setToolTip("UrlTooltip") + self.url.setLineWrapMode(QTextEdit.NoWrap) + self.l.addWidget(self.url) + + self.merge = self.newmerge = False + + # elements to hide when doing merge. + self.mergehide = [] + # elements to show again when doing *update* merge + self.mergeupdateshow = [] + + self.groupbox = QGroupBox(_("Show Download Options")) + self.groupbox.setCheckable(True) + self.groupbox.setChecked(False) + self.groupbox.setFlat(True) + #print("style:%s"%self.groupbox.styleSheet()) + self.groupbox.setStyleSheet(gpstyle) + + self.gbf = QFrame() + self.gbl = QVBoxLayout() + self.gbl.addWidget(self.gbf) + self.groupbox.setLayout(self.gbl) + self.gbl = QVBoxLayout() + self.gbf.setLayout(self.gbl) + self.l.addWidget(self.groupbox) + + 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') + self.fileform.addItem('html') + 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) + self.gbl.addLayout(horz) + self.mergehide.append(self.fileform) + + horz = QHBoxLayout() + self.collisionlabel = QLabel("CollisionLabel") + horz.addWidget(self.collisionlabel) + self.collision = QComboBox(self) + self.collision.setToolTip("CollisionToolTip") + # add collision options + self.set_collisions() + i = self.collision.findText(save_collisions[prefs['collision']]) + if i > -1: + self.collision.setCurrentIndex(i) + self.collisionlabel.setBuddy(self.collision) + horz.addWidget(self.collision) + self.gbl.addLayout(horz) + self.mergehide.append(self.collisionlabel) + self.mergehide.append(self.collision) + self.mergeupdateshow.append(self.collisionlabel) + self.mergeupdateshow.append(self.collision) + + horz = QHBoxLayout() + self.updatemeta = QCheckBox(_('Update Calibre &Metadata?'),self) + 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(prefs['updatemeta']) + horz.addWidget(self.updatemeta) + self.mergehide.append(self.updatemeta) + self.mergeupdateshow.append(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(prefs['updateepubcover']) + horz.addWidget(self.updateepubcover) + self.mergehide.append(self.updateepubcover) + + self.gbl.addLayout(horz) + + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.button_box.accepted.connect(self.ok_clicked) + self.button_box.rejected.connect(self.reject) + self.l.addWidget(self.button_box) + + # invoke the + def ok_clicked(self): + self.dialog_closing(None) # save persistent size. + self.hide() + self.go_signal.emit( self.get_ffdl_options(), + self.get_urlstext(), + self.merge, + self.extrapayload ) + + def show_dialog(self, + url_list_text, + callback, + show=True, + merge=False, + newmerge=True, + extraoptions={}, + extrapayload=None): + # rather than mutex in ffdl_plugin, just bail here if it's + # already in use. + if self.isVisible(): return + + try: + self.go_signal.disconnect() + except: + pass # if not already connected. + self.go_signal.connect(callback) + + self.merge = merge + self.newmerge = newmerge + self.extraoptions = extraoptions + self.extrapayload = extrapayload + + self.groupbox.setVisible(not(self.merge and self.newmerge)) + + if self.merge: + self.toplabel.setText(_('Story URL(s) 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.')) + self.collisionlabel.setText(_('If Story Already Exists in Anthology?')) + self.collision.setToolTip(_("What to do if there's already an existing story with the same URL in the anthology.")) + for widget in self.mergehide: + widget.setVisible(False) + if not self.newmerge: + for widget in self.mergeupdateshow: + widget.setVisible(True) + else: + for widget in self.mergehide: + widget.setVisible(True) + self.toplabel.setText(_('Story URL(s), one per line:')) + self.url.setToolTip(_('URLs for stories, one per line.\nWill take URLs from clipboard, but only valid URLs.\nAdd [1,5] after the URL to limit the download to chapters 1-5.')) + self.collisionlabel.setText(_('If Story Already Exists?')) + self.collision.setToolTip(_("What to do if there's already an existing story with the same URL or title and author.")) + + # 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: + QDialog.setWindowFlags ( self, Qt.Dialog ) + + if not self.merge: + self.fileform.setCurrentIndex(self.fileform.findText(self.prefs['fileform'])) + + # add collision options + self.set_collisions() + 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()) + + def set_collisions(self): + prev=self.collision.currentText() + self.collision.clear() + if self.merge: + order = anthology_collision_order + else: + order = collision_order + for o in order: + if self.merge or self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]: + self.collision.addItem(o) + i = self.collision.findText(prev) + if i > -1: + self.collision.setCurrentIndex(i) + + def get_ffdl_options(self): + retval = { + 'fileform': unicode(self.fileform.currentText()), + 'collision': unicode(self.collision.currentText()), + 'updatemeta': self.updatemeta.isChecked(), + '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): + return unicode(self.url.toPlainText()) + + +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, epubmerge_plugin=None): + SizePersistedDialog.__init__(self, gui, 'FanFictionDownLoader plugin:get story urls') + self.status=False + self.anthology=False + + self.setMinimumWidth(300) + + self.l = QGridLayout() + self.setLayout(self.l) + + self.setWindowTitle(title) + self.l.addWidget(QLabel(title),0,0,1,3) + + self.l.addWidget(QLabel("URL:"),1,0) + self.url = QLineEdit(self) + self.url.setText(url_text) + self.l.addWidget(self.url,1,1,1,2) + + self.indiv_button = QPushButton(_('For Individual Books'), self) + self.indiv_button.setToolTip(_('Get URLs and go to dialog for individual story downloads.')) + self.indiv_button.clicked.connect(self.indiv) + self.l.addWidget(self.indiv_button,2,0) + + self.merge_button = QPushButton(_('For Anthology Epub'), self) + self.merge_button.setToolTip(_('Get URLs and go to dialog for Anthology download.\nRequires %s plugin.')%'EpubMerge 1.3.1+') + self.merge_button.clicked.connect(self.merge) + self.l.addWidget(self.merge_button,2,1) + self.merge_button.setEnabled(epubmerge_plugin!=None) + + self.cancel_button = QPushButton(_('Cancel'), self) + self.cancel_button.clicked.connect(self.cancel) + self.l.addWidget(self.cancel_button,2,2) + + # restore saved size. + self.resize_dialog() + + def indiv(self): + self.status=True + self.accept() + + def merge(self): + self.status=True + self.anthology=True + self.accept() + + def cancel(self): + self.status=False + self.reject() + +class UserPassDialog(QDialog): + ''' + Need to collect User/Pass for some sites. + ''' + def __init__(self, gui, site, exception=None): + QDialog.__init__(self, gui) + self.status=False + + self.l = QGridLayout() + self.setLayout(self.l) + + if exception.passwdonly: + self.setWindowTitle(_('Password')) + self.l.addWidget(QLabel(_("Author requires a password for this story(%s).")%exception.url),0,0,1,2) + # user isn't used, but it's easier to still have it for + # post processing. + self.user = FakeLineEdit() + 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) + + self.cancel_button = QPushButton(_('Cancel'), self) + self.cancel_button.clicked.connect(self.cancel) + self.l.addWidget(self.cancel_button,3,1) + + self.resize(self.sizeHint()) + + def ok(self): + self.status=True + self.hide() + + def cancel(self): + self.status=False + self.hide() + +class LoopProgressDialog(QProgressDialog): + ''' + ProgressDialog displayed while fetching metadata for each story. + ''' + def __init__(self, gui, + book_list, + foreach_function, + finish_function, + init_label=_("Fetching metadata for stories..."), + win_title=_("Downloading metadata for stories"), + status_prefix=_("Fetched metadata for")): + QProgressDialog.__init__(self, + init_label, + _('Cancel'), 0, len(book_list), gui) + self.setWindowTitle(win_title) + self.setMinimumWidth(500) + self.book_list = book_list + self.foreach_function = foreach_function + self.finish_function = finish_function + self.status_prefix = status_prefix + self.i = 0 + + ## self.do_loop does QTimer.singleShot on self.do_loop also. + ## A weird way to do a loop, but that was the example I had. + QTimer.singleShot(0, self.do_loop) + self.exec_() + + def updateStatus(self): + self.setLabelText("%s %d / %d"%(self.status_prefix,self.i+1,len(self.book_list))) + self.setValue(self.i+1) + #print(self.labelText()) + + def do_loop(self): + + if self.i == 0: + self.setValue(0) + + book = self.book_list[self.i] + try: + ## collision spec passed into getadapter by partial from ffdl_plugin + ## no retval only if it exists, but collision is SKIP + self.foreach_function(book) + + except NotGoingToDownload as d: + book['good']=False + book['comment']=unicode(d) + book['icon'] = d.icon + + except Exception as e: + book['good']=False + book['comment']=unicode(e) + 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: + QTimer.singleShot(0, self.do_loop) + + def do_when_finished(self): + self.hide() + # Queues a job to process these books in the background. + self.finish_function(self.book_list) + +class AboutDialog(QDialog): + + def __init__(self, parent, icon, text): + QDialog.__init__(self, parent) + self.resize(400, 250) + self.l = QGridLayout() + self.setLayout(self.l) + self.logo = QLabel() + self.logo.setMaximumWidth(110) + self.logo.setPixmap(QPixmap(icon.pixmap(100,100))) + self.label = QLabel(text) + self.label.setOpenExternalLinks(True) + self.label.setWordWrap(True) + self.setWindowTitle(_('About FanFictionDownLoader')) + self.setWindowIcon(icon) + self.l.addWidget(self.logo, 0, 0) + self.l.addWidget(self.label, 0, 1) + self.bb = QDialogButtonBox(self) + b = self.bb.addButton(_('OK'), self.bb.AcceptRole) + b.setDefault(True) + self.l.addWidget(self.bb, 2, 0, 1, -1) + self.bb.accepted.connect(self.accept) + +class IconWidgetItem(ReadOnlyTextIconWidgetItem): + def __init__(self, text, icon, sort_key): + ReadOnlyTextIconWidgetItem.__init__(self, text, icon) + self.sort_key = sort_key + + #Qt uses a simple < check for sorting items, override this to use the sortKey + def __lt__(self, other): + return self.sort_key < other.sort_key + +class AuthorTableWidgetItem(ReadOnlyTableWidgetItem): + def __init__(self, text, sort_key): + ReadOnlyTableWidgetItem.__init__(self, text) + self.sort_key = sort_key + + #Qt uses a simple < check for sorting items, override this to use the sortKey + def __lt__(self, other): + return self.sort_key.lower() < other.sort_key.lower() + +class UpdateExistingDialog(SizePersistedDialog): + def __init__(self, gui, header, prefs, icon, books, + save_size_name='fanfictiondownloader_plugin:update list dialog'): + SizePersistedDialog.__init__(self, gui, save_size_name) + + self.prefs = prefs + self.setWindowTitle(header) + self.setWindowIcon(icon) + + layout = QVBoxLayout(self) + self.setLayout(layout) + title_layout = ImageTitleLayout(self, 'images/icon.png', + header) + layout.addLayout(title_layout) + books_layout = QHBoxLayout() + layout.addLayout(books_layout) + + self.books_table = StoryListTableWidget(self) + books_layout.addWidget(self.books_table) + + button_layout = QVBoxLayout() + books_layout.addLayout(button_layout) + + spacerItem = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + button_layout.addItem(spacerItem) + self.remove_button = QtGui.QToolButton(self) + self.remove_button.setToolTip(_('Remove selected books from the list')) + self.remove_button.setIcon(get_icon('list_remove.png')) + self.remove_button.clicked.connect(self.remove_from_list) + button_layout.addWidget(self.remove_button) + spacerItem1 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + button_layout.addItem(spacerItem1) + + options_layout = QHBoxLayout() + + groupbox = QGroupBox(_("Show Download Options")) + groupbox.setCheckable(True) + groupbox.setChecked(False) + groupbox.setFlat(True) + groupbox.setStyleSheet(gpstyle) + + gbf = QFrame() + gbl = QVBoxLayout() + gbl.addWidget(gbf) + groupbox.setLayout(gbl) + gbl = QHBoxLayout() + gbf.setLayout(gbl) + options_layout.addWidget(groupbox) + + gbf.setVisible(False) + groupbox.toggled.connect(gbf.setVisible) + + label = QLabel(_('Output &Format:')) + gbl.addWidget(label) + self.fileform = QComboBox(self) + self.fileform.addItem('epub') + self.fileform.addItem('mobi') + self.fileform.addItem('html') + self.fileform.addItem('txt') + self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform'])) + self.fileform.setToolTip(_('Choose output format to create. May set default from plugin configuration.')) + self.fileform.activated.connect(self.set_collisions) + label.setBuddy(self.fileform) + gbl.addWidget(self.fileform) + + label = QLabel(_('Update Mode:')) + gbl.addWidget(label) + self.collision = QComboBox(self) + self.collision.setToolTip(_("What sort of update to perform. May set default from plugin configuration.")) + # add collision options + self.set_collisions() + i = self.collision.findText(save_collisions[prefs['collision']]) + if i > -1: + self.collision.setCurrentIndex(i) + label.setBuddy(self.collision) + gbl.addWidget(self.collision) + + self.updatemeta = QCheckBox(_('Update Calibre &Metadata?'),self) + 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(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(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) + + def set_collisions(self): + prev=self.collision.currentText() + self.collision.clear() + for o in collision_order: + if o not in [ADDNEW,SKIP] and \ + (self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]): + self.collision.addItem(o) + i = self.collision.findText(prev) + if i > -1: + self.collision.setCurrentIndex(i) + + def remove_from_list(self): + self.books_table.remove_selected_rows() + + def get_books(self): + return self.books_table.get_books() + + def get_ffdl_options(self): + return { + 'fileform': unicode(self.fileform.currentText()), + 'collision': unicode(self.collision.currentText()), + 'updatemeta': self.updatemeta.isChecked(), + 'updateepubcover': self.updateepubcover.isChecked(), + 'smarten_punctuation':self.prefs['smarten_punctuation'] + } + +class StoryListTableWidget(QTableWidget): + + def __init__(self, parent): + QTableWidget.__init__(self, parent) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + + def populate_table(self, books): + self.clear() + self.setAlternatingRowColors(True) + self.setRowCount(len(books)) + header_labels = ['',_('Title'), _('Author'), 'URL', _('Comment')] + self.setColumnCount(len(header_labels)) + self.setHorizontalHeaderLabels(header_labels) + self.horizontalHeader().setStretchLastSection(True) + #self.verticalHeader().setDefaultSectionSize(24) + self.verticalHeader().hide() + + self.books={} + for row, book in enumerate(books): + self.populate_table_row(row, book) + self.books[row] = book + + # turning True breaks up/down. Do we need either sorting or up/down? + self.setSortingEnabled(True) + self.resizeColumnsToContents() + self.setMinimumColumnWidth(1, 100) + self.setMinimumColumnWidth(2, 100) + self.setMinimumColumnWidth(3, 100) + self.setMinimumSize(300, 0) + # if len(books) > 0: + # self.selectRow(0) + self.sortItems(1) + self.sortItems(0) + + def setMinimumColumnWidth(self, col, minimum): + if self.columnWidth(col) < minimum: + self.setColumnWidth(col, minimum) + + def populate_table_row(self, row, book): + if book['good']: + icon = get_icon('ok.png') + val = 0 + else: + icon = get_icon('minus.png') + val = 1 + if 'icon' in book: + icon = get_icon(book['icon']) + + status_cell = IconWidgetItem(None,icon,val) + status_cell.setData(Qt.UserRole, 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) + + def get_books(self): + books = [] + #print("=========================\nbooks:%s"%self.books) + for row in range(self.rowCount()): + rnum = convert_qvariant(self.item(row, 1).data(Qt.UserRole)) + book = self.books[rnum] + books.append(book) + return books + + def remove_selected_rows(self): + self.setFocus() + rows = self.selectionModel().selectedRows() + if len(rows) == 0: + return + message = '<p>'+_('Are you sure you want to remove this book from the list?') + if len(rows) > 1: + message = '<p>'+_('Are you sure you want to remove the selected %d books from the list?')%len(rows) + if not confirm(message,'fanfictiondownloader_delete_item', self): + return + first_sel_row = self.currentRow() + for selrow in reversed(rows): + self.removeRow(selrow.row()) + if first_sel_row < self.rowCount(): + self.select_and_scroll_to_row(first_sel_row) + elif self.rowCount() > 0: + self.select_and_scroll_to_row(first_sel_row - 1) + + def select_and_scroll_to_row(self, row): + self.selectRow(row) + self.scrollToItem(self.currentItem()) + +class RejectListTableWidget(QTableWidget): + + def __init__(self, parent,rejectreasons=[]): + QTableWidget.__init__(self, parent) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.rejectreasons = rejectreasons + + def populate_table(self, reject_list): + self.clear() + self.setAlternatingRowColors(True) + self.setRowCount(len(reject_list)) + header_labels = ['URL', _('Title'), _('Author'), _('Note')] + self.setColumnCount(len(header_labels)) + self.setHorizontalHeaderLabels(header_labels) + self.horizontalHeader().setStretchLastSection(True) + #self.verticalHeader().setDefaultSectionSize(24) + self.verticalHeader().hide() + + # it's generally recommended to enable sort after pop, not + # before. But then it needs to be sorted on a column and I'd + # rather keep the order given. + self.setSortingEnabled(True) + # row is just row number. + for row, rejectrow in enumerate(reject_list): + #print("populating table:%s"%rejectrow.to_line()) + self.populate_table_row(row,rejectrow) + + self.resizeColumnsToContents() + self.setMinimumColumnWidth(0, 100) + self.setMinimumColumnWidth(3, 100) + self.setMinimumSize(300, 0) + + def setMinimumColumnWidth(self, col, minimum): + if self.columnWidth(col) < minimum: + self.setColumnWidth(col, minimum) + + def populate_table_row(self, row, rej): + + url_cell = ReadOnlyTableWidgetItem(rej.url) + url_cell.setData(Qt.UserRole, rej.book_id) + 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) + note_cell.set_separator(None) + note_cell.setToolTip(_('Select or Edit Reject Note.')) + self.setCellWidget(row, 3, note_cell) + + def remove_selected_rows(self): + self.setFocus() + rows = self.selectionModel().selectedRows() + if len(rows) == 0: + return + message = '<p>'+_('Are you sure you want to remove this URL from the list?') + if len(rows) > 1: + message = '<p>'+_('Are you sure you want to remove the %d selected URLs from the list?')%len(rows) + if not confirm(message,'ffdl_rejectlist_delete_item_again', self): + return + first_sel_row = self.currentRow() + for selrow in reversed(rows): + self.removeRow(selrow.row()) + if first_sel_row < self.rowCount(): + self.select_and_scroll_to_row(first_sel_row) + elif self.rowCount() > 0: + self.select_and_scroll_to_row(first_sel_row - 1) + + def select_and_scroll_to_row(self, row): + self.selectRow(row) + self.scrollToItem(self.currentItem()) + +class RejectListDialog(SizePersistedDialog): + def __init__(self, gui, reject_list, + rejectreasons=[], + 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.setWindowTitle(header) + self.setWindowIcon(get_icon(icon)) + + layout = QVBoxLayout(self) + self.setLayout(layout) + title_layout = ImageTitleLayout(self, icon, header, + '<i></i>'+_('FFDL will remember these URLs and display the note and offer to reject them if you try to download them again later.')) + layout.addLayout(title_layout) + rejects_layout = QHBoxLayout() + layout.addLayout(rejects_layout) + + self.rejects_table = RejectListTableWidget(self,rejectreasons=rejectreasons) + rejects_layout.addWidget(self.rejects_table) + + button_layout = QVBoxLayout() + 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 URL(s) from the list')) + self.remove_button.setIcon(get_icon('list_remove.png')) + self.remove_button.clicked.connect(self.remove_from_list) + button_layout.addWidget(self.remove_button) + + spacerItem1 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + button_layout.addItem(spacerItem1) + + 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.")) + horz.addWidget(label) + horz.addWidget(self.reason_edit) + horz.insertStretch(-1) + layout.addLayout(horz) + + options_layout = QHBoxLayout() + + if show_delete: + self.deletebooks = QCheckBox(_('Delete Books (including books without FanFiction URLs)?'),self) + 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) + + def remove_from_list(self): + self.rejects_table.remove_selected_rows() + + def get_reject_list(self): + rejectrows = [] + for row in range(self.rejects_table.rowCount()): + url = unicode(self.rejects_table.item(row, 0).text()).strip() + book_id =convert_qvariant(self.rejects_table.item(row, 0).data(Qt.UserRole)) + title = unicode(self.rejects_table.item(row, 1).text()).strip() + auth = unicode(self.rejects_table.item(row, 2).text()).strip() + note = unicode(self.rejects_table.cellWidget(row, 3).currentText()).strip() + rejectrows.append(RejectUrlEntry(url,note,title,auth,self.get_reason_text(),book_id=book_id)) + return rejectrows + + def get_reject_list_ids(self): + rejectrows = [] + for row in range(self.rejects_table.rowCount()): + book_id = convert_qvariant(self.rejects_table.item(row, 0).data(Qt.UserRole)) + if book_id: + rejectrows.append(book_id) + return rejectrows + + def get_reason_text(self): + try: + return unicode(self.reason_edit.currentText()).strip() + except: + # doesn't have self.reason_edit when editing existing list. + return None + + def get_deletebooks(self): + return self.deletebooks.isChecked() + +class EditTextDialog(QDialog): + + def __init__(self, parent, text, + icon=None, title=None, label=None, tooltip=None, + rejectreasons=[],reasonslabel=None + ): + QDialog.__init__(self, parent) + self.resize(600, 500) + self.l = QVBoxLayout() + self.setLayout(self.l) + self.label = QLabel(label) + if title: + self.setWindowTitle(title) + if icon: + self.setWindowIcon(icon) + self.l.addWidget(self.label) + + self.textedit = QTextEdit(self) + self.textedit.setLineWrapMode(QTextEdit.NoWrap) + self.textedit.setText(text) + self.l.addWidget(self.textedit) + + if tooltip: + self.label.setToolTip(tooltip) + self.textedit.setToolTip(tooltip) + + 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) + 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) + self.l.addWidget(button_box) + + def get_plain_text(self): + return unicode(self.textedit.toPlainText()) + + def get_reason_text(self): + return unicode(self.reason_edit.currentText()).strip() + diff --git a/calibre-plugin/ffdl_plugin.py b/calibre-plugin/ffdl_plugin.py new file mode 100644 index 00000000..76e6f9d3 --- /dev/null +++ b/calibre-plugin/ffdl_plugin.py @@ -0,0 +1,2089 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Jim Miller' +__docformat__ = 'restructuredtext en' + +import logging +logger = logging.getLogger(__name__) + +import time, os, copy, threading, re, platform, sys +from StringIO import StringIO +from functools import partial +from datetime import datetime, time +from string import Template +import urllib +import email +import traceback + +try: + from PyQt5.Qt import (QApplication, QMenu, QTimer) + from PyQt5.QtCore import QBuffer +except ImportError as e: + from PyQt4.Qt import (QApplication, QMenu, QTimer) + from PyQt4.QtCore import QBuffer + +from calibre.constants import numeric_version as calibre_version + +from calibre.ptempfile import PersistentTemporaryFile, PersistentTemporaryDirectory, remove_dir +from calibre.ebooks.metadata import MetaInformation +from calibre.ebooks.metadata.meta import get_metadata +from calibre.gui2 import error_dialog, warning_dialog, question_dialog, info_dialog +from calibre.gui2.dialogs.message_box import ViewLog +from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.utils.config import prefs as calibre_prefs +from calibre.utils.date import local_tz +from calibre.library.comments import sanitize_comments_html +from calibre.constants import config_dir as calibre_config_dir + +# The class that all interface action plugins must inherit from +from calibre.gui2.actions import InterfaceAction + +# pulls in translation files for _() strings +try: + load_translations() +except NameError: + pass # load_translations() added in calibre 1.9 + +from calibre_plugins.fanfictiondownloader_plugin.common_utils import (set_plugin_icon_resources, get_icon, + create_menu_action_unique, get_library_uuid) + +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters, exceptions +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.epubutils import get_dcsource, get_dcsource_chaptercount, get_story_url_from_html +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.geturls import get_urls_from_page, get_urls_from_html, get_urls_from_text + +from calibre_plugins.fanfictiondownloader_plugin.ffdl_util import (get_ffdl_adapter, get_ffdl_config, get_ffdl_personalini) +from calibre_plugins.fanfictiondownloader_plugin.config import (permitted_values, rejecturllist) +from calibre_plugins.fanfictiondownloader_plugin.prefs import prefs +from calibre_plugins.fanfictiondownloader_plugin.dialogs import ( + AddNewDialog, UpdateExistingDialog, + LoopProgressDialog, UserPassDialog, AboutDialog, CollectURLDialog, RejectListDialog, + OVERWRITE, OVERWRITEALWAYS, UPDATE, UPDATEALWAYS, ADDNEW, SKIP, CALIBREONLY, + NotGoingToDownload, RejectUrlEntry ) + +# because calibre immediately transforms html into zip and don't want +# to have an 'if html'. db.has_format is cool with the case mismatch, +# but if I'm doing it anyway... +formmapping = { + 'epub':'EPUB', + 'mobi':'MOBI', + 'html':'ZIP', + 'txt':'TXT' + } + +PLUGIN_ICONS = ['images/icon.png'] + +class FanFictionDownLoaderPlugin(InterfaceAction): + + name = 'FanFictionDownLoader' + + # Declare the main action associated with this plugin + # The keyboard shortcut can be None if you dont want to use a keyboard + # shortcut. Remember that currently calibre has no central management for + # keyboard shortcuts, so try to use an unusual/unused shortcut. + # (text, icon_path, tooltip, keyboard shortcut) + # icon_path isn't in the zip--icon loaded below. + action_spec = (_('FanFictionDownLoader'), None, + _('Download FanFiction stories from various web sites'), ()) + # None for keyboard shortcut doesn't allow shortcut. () does, there just isn't one yet + + action_type = 'global' + # make button menu drop down only + #popup_type = QToolButton.InstantPopup + + def genesis(self): + + # This method is called once per plugin, do initial setup here + + # Read the plugin icons and store for potential sharing with the config widget + icon_resources = self.load_resources(PLUGIN_ICONS) + set_plugin_icon_resources(self.name, icon_resources) + + base = self.interface_action_base_plugin + self.version = base.name+" v%d.%d.%d"%base.version + + # Set the icon for this interface action + # The get_icons function is a builtin function defined for all your + # plugin code. It loads icons from the plugin zip file. It returns + # QIcon objects, if you want the actual data, use the analogous + # get_resources builtin function. + + # Note that if you are loading more than one icon, for performance, you + # should pass a list of names to get_icons. In this case, get_icons + # will return a dictionary mapping names to QIcons. Names that + # are not found in the zip file will result in null QIcons. + icon = get_icon('images/icon.png') + + self.qaction.setText(_('FanFictionDL')) + + # The qaction is automatically created from the action_spec defined + # above + self.qaction.setIcon(icon) + + # Call function when plugin triggered. + self.qaction.triggered.connect(self.plugin_button) + + # Assign our menu to this action + self.menu = QMenu(self.gui) + self.old_actions_unique_map = {} + # menu_actions is just to keep a live reference to the menu + # items to prevent GC removing it. + self.menu_actions = [] + self.qaction.setMenu(self.menu) + self.menus_lock = threading.RLock() + self.menu.aboutToShow.connect(self.about_to_show_menu) + + def initialization_complete(self): + # otherwise configured hot keys won't work until the menu's + # been displayed once. + self.rebuild_menus() + + self.add_new_dialog = AddNewDialog(self.gui, + prefs, + self.qaction.icon()) + + ## Kludgey, yes, but with the real configuration inside the + ## library now, how else would a user be able to change this + ## setting if it's crashing calibre? + def check_macmenuhack(self): + try: + return self.macmenuhack + except: + file_path = os.path.join(calibre_config_dir, + *("plugins/fanfictiondownloader_macmenuhack.txt".split('/'))) + file_path = os.path.abspath(file_path) + logger.debug("Plugin %s macmenuhack file_path:%s"%(self.name,file_path)) + self.macmenuhack = os.access(file_path, os.F_OK) + return self.macmenuhack + + accepts_drops = True + + def accept_enter_event(self, event, mime_data): + if mime_data.hasFormat("application/calibre+from_library") or \ + mime_data.hasFormat("text/plain") or \ + mime_data.hasFormat("text/uri-list"): + return True + + return False + + def accept_drag_move_event(self, event, mime_data): + return self.accept_enter_event(event, mime_data) + + def drop_event(self, event, mime_data): + + dropped_ids=None + urllist=[] + + mime = 'application/calibre+from_library' + if mime_data.hasFormat(mime): + dropped_ids = tuple(map(int, str(mime_data.data(mime)).split())) + + mimetype='text/uri-list' + filelist="%s"%event.mimeData().data(mimetype) + if filelist: + for f in filelist.splitlines(): + #print("filename:%s"%f) + if f.endswith(".eml"): + fhandle = urllib.urlopen(f) + msg = email.message_from_file(fhandle) + if msg.is_multipart(): + for part in msg.walk(): + #print("part type:%s"%part.get_content_type()) + if part.get_content_type() == "text/html": + #print("URL list:%s"%get_urls_from_data(part.get_payload(decode=True))) + urllist.extend(get_urls_from_html(part.get_payload(decode=True))) + if part.get_content_type() == "text/plain": + #print("part content:text/plain") + #print("part content:%s"%part.get_payload(decode=True)) + urllist.extend(get_urls_from_text(part.get_payload(decode=True))) + else: + urllist.extend(get_urls_from_text("%s"%msg)) + else: + urllist.extend(get_urls_from_text(f)) + else: + mimetype='text/plain' + if mime_data.hasFormat(mimetype): + #print("text/plain:%s"%event.mimeData().data(mimetype)) + urllist.extend(get_urls_from_text(event.mimeData().data(mimetype))) + + # print("urllist:%s\ndropped_ids:%s"%(urllist,dropped_ids)) + if urllist or dropped_ids: + QTimer.singleShot(1, partial(self.do_drop, + dropped_ids=dropped_ids, + urllist=urllist)) + return True + + return False + + def do_drop(self,dropped_ids=None,urllist=None): + # shouldn't ever be both. + if dropped_ids: + self.update_dialog(dropped_ids) + elif urllist: + self.add_dialog("\n".join(urllist)) + + def about_to_show_menu(self): + self.rebuild_menus() + + def library_changed(self, db): + # We need to reset our menus after switching libraries + self.rebuild_menus() + rejecturllist.clear_cache() + + def rebuild_menus(self): + with self.menus_lock: + #self.qaction.setText("FFDL") + do_user_config = self.interface_action_base_plugin.do_user_config + self.menu.clear() + self.actions_unique_map = {} + self.menu_actions = [] + self.add_action = self.create_menu_item_ex(self.menu, _('&Add New from URL(s)'), image='plus.png', + unique_name='Add New FanFiction Book(s) from URL(s)', + shortcut_name=_('Add New FanFiction Book(s) from URL(s)'), + triggered=self.add_dialog ) + + self.update_action = self.create_menu_item_ex(self.menu, _('&Update Existing FanFiction Book(s)'), image='plusplus.png', + unique_name='&Update Existing FanFiction Book(s)', + triggered=self.update_dialog) + + if self.get_epubmerge_plugin(): + self.menu.addSeparator() + self.get_list_url_action = self.create_menu_item_ex(self.menu, _('Get Story URLs to Download from Web Page'), image='view.png', + unique_name='Get Story URLs from Web Page', + triggered=self.get_urls_from_page_menu) + + self.makeanth_action = self.create_menu_item_ex(self.menu, _('&Make Anthology Epub Manually from URL(s)'), image='plusplus.png', + unique_name='Make FanFiction Anthology Epub Manually from URL(s)', + shortcut_name=_('Make FanFiction Anthology Epub Manually from URL(s)'), + triggered=partial(self.add_dialog,merge=True) ) + + self.updateanth_action = self.create_menu_item_ex(self.menu, _('&Update Anthology Epub'), image='plusplus.png', + unique_name='Update FanFiction Anthology Epub', + shortcut_name=_('Update FanFiction Anthology Epub'), + triggered=self.update_anthology) + + if 'Reading List' in self.gui.iactions and (prefs['addtolists'] or prefs['addtoreadlists']) : + + self.menu.addSeparator() + addmenutxt, rmmenutxt = None, None + if prefs['addtolists'] and prefs['addtoreadlists'] : + addmenutxt = _('Add to "To Read" and "Send to Device" Lists') + if prefs['addtolistsonread']: + rmmenutxt = _('Remove from "To Read" and add to "Send to Device" Lists') + else: + rmmenutxt = _('Remove from "To Read" Lists') + elif prefs['addtolists'] : + addmenutxt = _('Add Selected to "Send to Device" Lists') + elif prefs['addtoreadlists']: + addmenutxt = _('Add to "To Read" Lists') + rmmenutxt = _('Remove from "To Read" Lists') + + if addmenutxt: + self.add_send_action = self.create_menu_item_ex(self.menu, addmenutxt, + unique_name='Add to "To Read" and "Send to Device" Lists', + image='plusplus.png', + triggered=partial(self.update_lists,add=True)) + + if rmmenutxt: + self.add_remove_action = self.create_menu_item_ex(self.menu, rmmenutxt, + unique_name='Remove from "To Read" and add to "Send to Device" Lists', + image='minusminus.png', + triggered=partial(self.update_lists,add=False)) + + self.menu.addSeparator() + self.get_list_action = self.create_menu_item_ex(self.menu, _('Get URLs from Selected Books'), + unique_name='Get URLs from Selected Books', + image='bookmarks.png', + triggered=self.list_story_urls) + + if not self.get_epubmerge_plugin(): + self.get_list_url_action = self.create_menu_item_ex(self.menu, _('Get Story URLs from Web Page'), + unique_name='Get Story URLs from Web Page', + image='view.png', + triggered=self.get_urls_from_page_menu) + + self.reject_list_action = self.create_menu_item_ex(self.menu, _('Reject Selected Books'), + unique_name='Reject Selected Books', image='rotate-right.png', + triggered=self.reject_list_urls) + + # print("platform.system():%s"%platform.system()) + # print("platform.mac_ver()[0]:%s"%platform.mac_ver()[0]) + if not self.check_macmenuhack(): # not platform.mac_ver()[0]: # Some macs crash on these menu items for unknown reasons. + self.menu.addSeparator() + self.config_action = self.create_menu_item_ex(self.menu, _('&Configure Plugin'), + image= 'config.png', + unique_name='Configure FanFictionDownLoader', + shortcut_name=_('Configure FanFictionDownLoader'), + triggered=partial(do_user_config,parent=self.gui)) + + self.about_action = self.create_menu_item_ex(self.menu, _('About Plugin'), + image= 'images/icon.png', + unique_name='About FanFictionDownLoader', + shortcut_name=_('About FanFictionDownLoader'), + triggered=self.about) + + # Before we finalize, make sure we delete any actions for menus that are no longer displayed + for menu_id, unique_name in self.old_actions_unique_map.iteritems(): + if menu_id not in self.actions_unique_map: + self.gui.keyboard.unregister_shortcut(unique_name) + self.old_actions_unique_map = self.actions_unique_map + self.gui.keyboard.finalize() + + def about(self): + # Get the about text from a file inside the plugin zip file + # The get_resources function is a builtin function defined for all your + # plugin code. It loads files from the plugin zip file. It returns + # the bytes from the specified file. + # + # Note that if you are loading more than one file, for performance, you + # should pass a list of names to get_resources. In this case, + # get_resources will return a dictionary mapping names to bytes. Names that + # are not found in the zip file will not be in the returned dictionary. + + text = get_resources('about.txt') + AboutDialog(self.gui,self.qaction.icon(),self.version + text).exec_() + + def create_menu_item_ex(self, parent_menu, menu_text, image=None, tooltip=None, + shortcut=None, triggered=None, is_checked=None, shortcut_name=None, + unique_name=None): + #print("create_menu_item_ex before %s"%menu_text) + ac = create_menu_action_unique(self, parent_menu, menu_text, image, tooltip, + shortcut, triggered, is_checked, shortcut_name, unique_name) + self.actions_unique_map[ac.calibre_shortcut_unique_name] = ac.calibre_shortcut_unique_name + self.menu_actions.append(ac) + #print("create_menu_item_ex after %s"%menu_text) + return ac + + def is_library_view(self): + # 0 = library, 1 = main, 2 = card_a, 3 = card_b + return self.gui.stack.currentIndex() == 0 + + def plugin_button(self): + if self.is_library_view() and \ + len(self.gui.library_view.get_selected_ids()) > 0 and \ + prefs['updatedefault']: + self.update_dialog() + else: + self.add_dialog() + + def get_epubmerge_plugin(self): + if 'EpubMerge' in self.gui.iactions and self.gui.iactions['EpubMerge'].interface_action_base_plugin.version >= (1,3,1): + return self.gui.iactions['EpubMerge'] + + def update_lists(self,add=True): + if prefs['addtolists'] or prefs['addtoreadlists']: + if not self.is_library_view(): + self.gui.status_bar.show_message(_('Cannot Update Reading Lists from Device View'), 3000) + return + + if len(self.gui.library_view.get_selected_ids()) == 0: + self.gui.status_bar.show_message(_('No Selected Books to Update Reading Lists'), 3000) + return + + self.update_reading_lists(self.gui.library_view.get_selected_ids(),add) + + def get_urls_from_page_menu(self): + + if prefs['urlsfromclip']: + try: + urltxt = self.get_urls_clip(storyurls=False)[0] + except: + urltxt = "" + + d = CollectURLDialog(self.gui,_("Get Story URLs from Web Page"),urltxt,self.get_epubmerge_plugin()) + d.exec_() + if not d.status: + return + url = u"%s"%d.url.text() + + url_list = self.get_urls_from_page(url) + + if url_list: + self.add_dialog("\n".join(url_list),merge=d.anthology,anthology_url=url) + else: + info_dialog(self.gui, _('List of Story URLs'), + _('No Valid Story URLs found on given page.'), + show=True, + show_copy_button=False) + + def get_urls_from_page(self,url): + logger.debug("get_urls_from_page URL:%s"%url) + if 'archiveofourown.org' in url: + configuration = get_ffdl_config(url) + else: + configuration = None + return get_urls_from_page(url,configuration) + + def list_story_urls(self): + '''Get list of URLs from existing books.''' + if self.gui.current_view().selectionModel().selectedRows() == 0 : + self.gui.status_bar.show_message(_('No Selected Books to Get URLs From'), + 3000) + return + + if self.is_library_view(): + book_list = map( partial(self.make_book_id_only), + self.gui.library_view.get_selected_ids() ) + + else: # device view, get from epubs on device. + view = self.gui.current_view() + rows = view.selectionModel().selectedRows() + # paths = view.model().paths(rows) + book_list = map( partial(self.make_book_from_device_row), rows ) + + LoopProgressDialog(self.gui, + book_list, + partial(self.get_list_story_urls_loop, db=self.gui.current_db), + self.get_list_story_urls_finish, + init_label=_("Collecting URLs for stories..."), + win_title=_("Get URLs for stories"), + status_prefix=_("URL retrieved")) + + def get_list_story_urls_loop(self,book,db=None): + if book['calibre_id']: + book['url'] = self.get_story_url(db,book_id=book['calibre_id']) + elif book['path']: + book['url'] = self.get_story_url(db,path=book['path']) + + if book['url'] == None: + book['good']=False + else: + book['good']=True + + def get_list_story_urls_finish(self, book_list): + url_list = [ x['url'] for x in book_list if x['good'] ] + if url_list: + d = ViewLog(_("List of Story URLs"),"\n".join(url_list),parent=self.gui) + d.setWindowIcon(get_icon('bookmarks.png')) + d.exec_() + else: + info_dialog(self.gui, _('List of URLs'), + _('No Story URLs found in selected books.'), + show=True, + show_copy_button=False) + + def reject_list_urls(self): + if self.is_library_view(): + book_list = map( partial(self.make_book_id_only), + self.gui.library_view.get_selected_ids() ) + + else: # device view, get from epubs on device. + view = self.gui.current_view() + rows = view.selectionModel().selectedRows() + #paths = view.model().paths(rows) + book_list = map( partial(self.make_book_from_device_row), rows ) + + if len(book_list) == 0 : + self.gui.status_bar.show_message(_('No Selected Books have URLs to Reject'), 3000) + return + + # Progbar because fetching urls from device epubs can be slow. + LoopProgressDialog(self.gui, + book_list, + partial(self.reject_list_urls_loop, db=self.gui.current_db), + self.reject_list_urls_finish, + init_label=_("Collecting URLs for Reject List..."), + win_title=_("Get URLs for Reject List"), + status_prefix=_("URL retrieved")) + + def reject_list_urls_loop(self,book,db=None): + self.get_list_story_urls_loop(book,db) # common with get_list_story_urls_loop + if book['calibre_id']: + # want title/author, too, for rejects. + self.populate_book_from_calibre_id(book,db) + if book['url']: + # get existing note, if on rejected list. + book['oldrejnote']=rejecturllist.get_note(book['url']) + + def reject_list_urls_finish(self, book_list): + + # construct reject list of objects + reject_list = [ RejectUrlEntry(x['url'], + x['oldrejnote'], + x['title'], + ', '.join(x['author']), + book_id=x['calibre_id']) + for x in book_list if x['good'] ] + if reject_list: + d = RejectListDialog(self.gui,reject_list, + rejectreasons=rejecturllist.get_reject_reasons()) + d.exec_() + + if d.result() != d.Accepted: + return + + rejecturllist.add(d.get_reject_list()) + + if d.get_deletebooks(): + self.gui.iactions['Remove Books'].do_library_delete(d.get_reject_list_ids()) + + else: + message="<p>"+_("Rejecting FFDL URLs: None of the books selected have FanFiction URLs.")+"</p><p>"+_("Proceed to Remove?")+"</p>" + if confirm(message,'fanfictiondownloader_reject_non_fanfiction', self.gui): + self.gui.iactions['Remove Books'].delete_books() + + def add_dialog(self,url_list_text=None,merge=False,anthology_url=None): + 'Both new individual stories and new anthologies are created here.' + + if not url_list_text: + url_list = self.get_urls_clip() + url_list_text = "\n".join(url_list) + + # AddNewDialog collects URLs, format and presents buttons. + # add_new_dialog is modeless and reused, both for new stories + # and anthologies, and for updating existing anthologies. + self.add_new_dialog.show_dialog(url_list_text, + self.prep_downloads, + merge=merge, + newmerge=True, + extraoptions={'anthology_url':anthology_url}) + + def update_anthology(self): + if not self.get_epubmerge_plugin(): + self.gui.status_bar.show_message(_('Cannot Make Anthologys without %s')%'EpubMerge 1.3.1+', 3000) + return + + if not self.is_library_view(): + self.gui.status_bar.show_message(_('Cannot Update Books from Device View'), 3000) + return + + if len(self.gui.library_view.get_selected_ids()) != 1: + self.gui.status_bar.show_message(_('Can only update 1 anthology at a time'), 3000) + return + + db = self.gui.current_db + book_id = self.gui.library_view.get_selected_ids()[0] + mergebook = self.make_book_id_only(book_id) + self.populate_book_from_calibre_id(mergebook, db) + + if not db.has_format(book_id,'EPUB',index_is_id=True): + self.gui.status_bar.show_message(_('Can only Update Epub Anthologies'), 3000) + return + + tdir = PersistentTemporaryDirectory(prefix='ffdl_anthology_') + logger.debug("tdir:\n%s"%tdir) + + bookepubio = StringIO(db.format(book_id,'EPUB',index_is_id=True)) + + filenames = self.get_epubmerge_plugin().do_unmerge(bookepubio,tdir) + urlmapfile = {} + url_list = [] + for f in filenames: + url = get_dcsource(f) + if url: + urlmapfile[url]=f + url_list.append(url) + + if not filenames or len(filenames) != len (url_list): + info_dialog(self.gui, _("Cannot Update Anthology"), + "<p>"+_("Cannot Update Anthology")+"</p><p>"+_("Book isn't an FFDL Anthology or contains book(s) without valid FFDL URLs."), + show=True, + show_copy_button=False) + remove_dir(tdir) + return + + # get list from identifiers:url/uri if present, but only if + # it's *not* a valid story URL. + mergeurl = self.get_story_url(db,book_id) + if mergeurl and not self.is_good_downloader_url(mergeurl): + url_list = self.get_urls_from_page(mergeurl) + + url_list_text = "\n".join(url_list) + + #print("urlmapfile:%s"%urlmapfile) + + # AddNewDialog collects URLs, format and presents buttons. + # add_new_dialog is modeless and reused, both for new stories + # and anthologies, and for updating existing anthologies. + self.add_new_dialog.show_dialog(url_list_text, + self.prep_anthology_downloads, + show=False, + merge=True, + newmerge=False, + extrapayload=urlmapfile, + extraoptions={'tdir':tdir, + 'mergebook':mergebook}) + # Need to use AddNewDialog modal here because it's an update + # of an existing book. Don't want the user deleting it or + # switching libraries on us. + self.add_new_dialog.exec_() + + + def prep_anthology_downloads(self, options, update_books, + merge=False, urlmapfile=None): + + if isinstance(update_books,basestring): + url_list = split_text_to_urls(update_books) + update_books = self.convert_urls_to_books(url_list) + + for j, book in enumerate(update_books): + url = book['url'] + book['listorder'] = j + if url in urlmapfile: + #print("found epub for %s"%url) + book['epub_for_update']=urlmapfile[url] + del urlmapfile[url] + #else: + #print("didn't found epub for %s"%url) + + if urlmapfile: + text = ''' + <p>%s</p> + <p>%s</p> + <ul> + <li>%s</li> + </ul> + <p>%s</p>'''%( + _('There are %d stories in the current anthology that are <b>not</b> going to be kept if you go ahead.')%len(urlmapfile), + _('Story URLs that will be removed:'), + "</li><li>".join(urlmapfile.keys()), + _('Update anyway?')) + if not question_dialog(self.gui, _('Stories Removed'), + text, show_copy_button=False): + logger.debug("Canceling anthology update due to removed stories.") + return + + # Now that we've + self.prep_downloads( options, update_books, merge=True ) + + def update_dialog(self, id_list=None): + if not self.is_library_view(): + self.gui.status_bar.show_message(_('Cannot Update Books from Device View'), 3000) + return + + if not id_list: + id_list = self.gui.library_view.get_selected_ids() + + if len(id_list) == 0: + self.gui.status_bar.show_message(_('No Selected Books to Update'), 3000) + return + #print("update_dialog()") + + db = self.gui.current_db + books = map( self.make_book_id_only, id_list ) + + for j, book in enumerate(books): + book['listorder'] = j + + LoopProgressDialog(self.gui, + books, + partial(self.populate_book_from_calibre_id, db=self.gui.current_db), + self.update_dialog_finish, + init_label=_("Collecting stories for update..."), + win_title=_("Get stories for updates"), + status_prefix=_("URL retrieved")) + + #books = self.convert_calibre_ids_to_books(db, book_ids) + #print("update books:%s"%books) + + def update_dialog_finish(self,book_list): + '''Present list to update and head to prep when done.''' + + d = UpdateExistingDialog(self.gui, + _('Update Existing List'), + prefs, + self.qaction.icon(), + book_list, + ) + d.exec_() + if d.result() != d.Accepted: + return + + update_books = d.get_books() + + #print("update_books:%s"%update_books) + #print("options:%s"%d.get_ffdl_options()) + # only if there's some good ones. + if 0 < len(filter(lambda x : x['good'], update_books)): + options = d.get_ffdl_options() + self.prep_downloads( options, update_books ) + + def get_urls_clip(self,storyurls=True): + url_list = [] + if prefs['urlsfromclip']: + for url in unicode(QApplication.instance().clipboard().text()).split(): + if not storyurls or self.is_good_downloader_url(url): + url_list.append(url) + + return url_list + + def apply_settings(self): + # No need to do anything with perfs here, but we could. + prefs + + def make_id_searchstr(self,url): + # older idents can be uri vs url and have | instead of : after + # http, plus many sites are now switching to https. + return 'identifiers:"~ur(i|l):~^%s$"'%re.sub(r'https?\\\:','https?(\:|\|)',re.escape(url)) + + def prep_downloads(self, options, books, merge=False, extrapayload=None): + '''Fetch metadata for stories from servers, launch BG job when done.''' + + if isinstance(books,basestring): + url_list = split_text_to_urls(books) + books = self.convert_urls_to_books(url_list) + + ## for tweak_fg_sleep + options['ffnetcount']=len(filter(lambda x : x['site']=='www.fanfiction.net', books)) + + options['version'] = self.version + logger.debug(self.version) + + #print("prep_downloads:%s"%books) + + if 'tdir' not in options: # if merging an anthology, there's alread a tdir. + # create and pass temp dir. + tdir = PersistentTemporaryDirectory(prefix='fanfictiondownloader_') + options['tdir']=tdir + + if 0 < len(filter(lambda x : x['good'], books)): + self.gui.status_bar.show_message(_('Started fetching metadata for %s stories.')%len(books), 3000) + LoopProgressDialog(self.gui, + books, + partial(self.prep_download_loop, options = options, merge=merge), + partial(self.start_download_job, options = options, merge=merge)) + else: + self.gui.status_bar.show_message(_('No valid story URLs entered.'), 3000) + # LoopProgressDialog calls prep_download_loop for each 'good' story, + # prep_download_loop updates book object for each with metadata from site, + # LoopProgressDialog calls start_download_job at the end which goes + # into the BG, or shows list if no 'good' books. + + def prep_download_loop(self,book, + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True, + 'updateepubcover':True}, + merge=False): + ''' + Update passed in book dict with metadata from website and + necessary data. To be called from LoopProgressDialog + 'loop'. Also pops dialogs for is adult, user/pass. + ''' + + url = book['url'] + 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) + + # The current database shown in the GUI + # db is an instance of the class LibraryDatabase2 from database.py + # This class has many, many methods that allow you to do a lot of + # things. + db = self.gui.current_db + + fileform = options['fileform'] + collision = options['collision'] + updatemeta= options['updatemeta'] + updateepubcover= options['updateepubcover'] + + # Dialogs should prevent this case now. + if collision in (UPDATE,UPDATEALWAYS) and fileform != 'epub': + raise NotGoingToDownload(_("Cannot update non-epub format.")) + + if not book['good']: + # book has already been flagged bad for whatever reason. + return + + skip_date_update = False + + options['personal.ini'] = get_ffdl_personalini() + adapter = get_ffdl_adapter(url,fileform) + # reduce foreground sleep time for ffnet when few books. + if 'ffnetcount' in options and \ + adapter.getConfig('tweak_fg_sleep') and \ + adapter.getSiteDomain() == 'www.fanfiction.net': + minslp = float(adapter.getConfig('min_fg_sleep')) + maxslp = float(adapter.getConfig('max_fg_sleep')) + dwnlds = float(adapter.getConfig('max_fg_sleep_at_downloads')) + m = (maxslp-minslp) / (dwnlds-1) + b = minslp - m + slp = min(maxslp,m*float(options['ffnetcount'])+b) + #print("m:%s b:%s = %s"%(m,b,slp)) + adapter.set_sleep(slp) + + ## three tries, that's enough if both user/pass & is_adult needed, + ## or a couple tries of one or the other + for x in range(0,2): + try: + adapter.getStoryMetadataOnly() + except exceptions.FailedToLogin, f: + logger.warn("Login Failed, Need Username/Password.") + userpass = UserPassDialog(self.gui,url,f) + userpass.exec_() # exec_ will make it act modal + if userpass.status: + adapter.username = userpass.user.text() + adapter.password = userpass.passwd.text() + + except exceptions.AdultCheckRequired: + if question_dialog(self.gui, _('Are You an Adult?'), '<p>'+ + _("%s requires that you be an adult. Please confirm you are an adult in your locale:")%url, + show_copy_button=False): + adapter.is_adult=True + + # let other exceptions percolate up. + story = adapter.getStoryMetadataOnly() + + series = story.getMetadata('series') + if not merge and series and prefs['checkforseriesurlid']: + # try to find *series anthology* by *seriesUrl* identifier url or uri first. + searchstr = self.make_id_searchstr(story.getMetadata('seriesUrl')) + identicalbooks = db.search_getting_ids(searchstr, None) + # print("searchstr:%s"%searchstr) + # print("identicalbooks:%s"%identicalbooks) + if len(identicalbooks) > 0 and question_dialog(self.gui, _('Skip Story?'),''' + <h3>%s</h3> + <p>%s</p> + <p>%s</p> + <p>%s</p> + '''%( + _('Skip Anthology Story?'), + _('"<b>%s</b>" is in series "<b><a href="%s">%s</a></b>" that you have an anthology book for.')%(story.getMetadata('title'),story.getMetadata('seriesUrl'),series[:series.index(' [')]), + _("Click '<b>Yes</b>' to Skip."), + _("Click '<b>No</b>' to download anyway.")), + show_copy_button=False): + book['comment'] = _("Story in Series Anthology(%s).")%series + book['title'] = story.getMetadata('title') + book['author'] = [story.getMetadata('author')] + book['good']=False + book['icon']='rotate-right.png' + book['status'] = _('Skipped') + return + + + ################################################################################################################################################33 + + # set PI version instead of default. + if 'version' in options: + story.setMetadata('version',options['version']) + + # all_metadata duplicates some data, but also includes extra_entries, etc. + book['all_metadata'] = story.getAllMetadata(removeallentities=True) + + book['title'] = story.getMetadata("title", removeallentities=True) + book['author_sort'] = book['author'] = story.getList("author", removeallentities=True) + book['publisher'] = story.getMetadata("site") + book['tags'] = story.getSubjectTags(removeallentities=True) + if story.getMetadata("description"): + book['comments'] = sanitize_comments_html(story.getMetadata("description")) + else: + book['comments']='' + book['series'] = story.getMetadata("series", removeallentities=True) + + book['is_adult'] = adapter.is_adult + book['username'] = adapter.username + book['password'] = adapter.password + + book['icon'] = 'plus.png' + book['status'] = _('Add') + if story.getMetadataRaw('datePublished'): + book['pubdate'] = story.getMetadataRaw('datePublished').replace(tzinfo=local_tz) + if story.getMetadataRaw('dateUpdated'): + book['updatedate'] = story.getMetadataRaw('dateUpdated').replace(tzinfo=local_tz) + if story.getMetadataRaw('dateCreated'): + book['timestamp'] = story.getMetadataRaw('dateCreated').replace(tzinfo=local_tz) + else: + book['timestamp'] = None # need *something* there for calibre. + + if not merge:# skip all the collision code when d/ling for merging. + if collision in (CALIBREONLY): + book['icon'] = 'metadata.png' + book['status'] = _('Meta') + + book_id = None + + if book['calibre_id'] != None: + # updating an existing book. Update mode applies. + logger.debug("update existing id:%s"%book['calibre_id']) + book_id = book['calibre_id'] + # No handling needed: OVERWRITEALWAYS,CALIBREONLY + + # only care about collisions when not ADDNEW + elif collision != ADDNEW: + # 'new' book from URL. collision handling applies. + logger.debug("from URL(%s)"%url) + + # try to find by identifier url or uri first. + searchstr = self.make_id_searchstr(url) + identicalbooks = db.search_getting_ids(searchstr, None) + # print("searchstr:%s"%searchstr) + # print("identicalbooks:%s"%identicalbooks) + if len(identicalbooks) < 1: + # find dups + authlist = story.getList("author", removeallentities=True) + mi = MetaInformation(story.getMetadata("title", removeallentities=True), + authlist) + identicalbooks = db.find_identical_books(mi) + if len(identicalbooks) > 0: + logger.debug("existing found by title/author(s)") + + else: + logger.debug("existing found by identifier URL") + + if collision == SKIP and identicalbooks: + raise NotGoingToDownload(_("Skipping duplicate story."),"list_remove.png") + + if len(identicalbooks) > 1: + raise NotGoingToDownload(_("More than one identical book by Identifer URL or title/author(s)--can't tell which book to update/overwrite."),"minusminus.png") + + ## changed: add new book when CALIBREONLY if none found. + if collision == CALIBREONLY and not identicalbooks: + collision = ADDNEW + options['collision'] = ADDNEW + + if len(identicalbooks)>0: + book_id = identicalbooks.pop() + book['calibre_id'] = book_id + book['icon'] = 'edit-redo.png' + book['status'] = _('Update') + + if book_id and mi: # book_id and mi only set if matched by title/author. + liburl = self.get_story_url(db,book_id) + if book['url'] != liburl and prefs['checkforurlchange'] and \ + not (book['url'].replace('https','http') == liburl): # several sites have been changing to + # https now. Don't flag when that's the only change. + # special case for ffnet urls change to https. + if not question_dialog(self.gui, _('Change Story URL?'),''' + <h3>%s</h3> + <p>%s</p> + <p>%s</p> + <p>%s</p> + <p>%s</p> + <p>%s</p>'''%( + _('Change Story URL?'), + _('<b>%s</b> by <b>%s</b> is already in your library with a different source URL:')%(mi.title,', '.join(mi.author)), + _('In library: <a href="%(liburl)s">%(liburl)s</a>')%{'liburl':liburl}, + _('New URL: <a href="%(newurl)s">%(newurl)s</a>')%{'newurl':book['url']}, + _("Click '<b>Yes</b>' to update/overwrite book with new URL."), + _("Click '<b>No</b>' to skip updating/overwriting this book.")), + show_copy_button=False): + if question_dialog(self.gui, _('Download as New Book?'),''' + <h3>%s</h3> + <p>%s</p> + <p>%s</p> + <p>%s</p> + <p>%s</p> + <p>%s</p>'''%( + _('Download as New Book?'), + _('<b>%s</b> by <b>%s</b> is already in your library with a different source URL.')%(mi.title,', '.join(mi.author)), + _('You chose not to update the existing book. Do you want to add a new book for this URL?'), + _('New URL: <a href="%(newurl)s">%(newurl)s</a>')%{'newurl':book['url']}, + _("Click '<b>Yes</b>' to a new book with new URL."), + _("Click '<b>No</b>' to skip URL.")), + show_copy_button=False): + book_id = None + mi = None + book['calibre_id'] = None + else: + book['comment'] = _("Update declined by user due to differing story URL(%s)")%liburl + book['good']=False + book['icon']='rotate-right.png' + book['status'] = _('Different URL') + return + + if book_id != None and collision != ADDNEW: + if collision in (CALIBREONLY): + book['comment'] = _('Metadata collected.') + # don't need temp file created below. + return + + ## newer/chaptercount checks are the same for both: + # Update epub, but only if more chapters. + if collision in (UPDATE,UPDATEALWAYS): # collision == UPDATE + # 'book' can exist without epub. If there's no existing epub, + # let it go and it will download it. + if db.has_format(book_id,fileform,index_is_id=True): + (epuburl,chaptercount) = \ + get_dcsource_chaptercount(StringIO(db.format(book_id,'EPUB', + index_is_id=True))) + urlchaptercount = int(story.getMetadata('numChapters').replace(',','')) + if chaptercount == urlchaptercount: + if collision == UPDATE: + raise NotGoingToDownload(_("Already contains %d chapters.")%chaptercount,'edit-undo.png') + else: + # UPDATEALWAYS + skip_date_update = True + elif chaptercount > urlchaptercount: + raise NotGoingToDownload(_("Existing epub contains %d chapters, web site only has %d. Use Overwrite to force update.") % (chaptercount,urlchaptercount),'dialog_error.png') + elif chaptercount == 0: + raise NotGoingToDownload(_("FFDL doesn't recognize chapters in existing epub, epub is probably from a different source. Use Overwrite to force update."),'dialog_error.png') + + if collision == OVERWRITE and \ + db.has_format(book_id,formmapping[fileform],index_is_id=True): + # check make sure incoming is newer. + lastupdated=story.getMetadataRaw('dateUpdated') + fileupdated=datetime.fromtimestamp(os.stat(db.format_abspath(book_id, formmapping[fileform], index_is_id=True))[8]) + + # updated doesn't have time (or is midnight), use dates only. + # updated does have time, use full timestamps. + if (lastupdated.time() == time.min and fileupdated.date() > lastupdated.date()) or \ + (lastupdated.time() != time.min and fileupdated > lastupdated): + raise NotGoingToDownload(_("Not Overwriting, web site is not newer."),'edit-undo.png') + + + + # For update, provide a tmp file copy of the existing epub so + # it can't change underneath us. Now also overwrite for logpage preserve. + if collision in (UPDATE,UPDATEALWAYS,OVERWRITE,OVERWRITEALWAYS) and \ + fileform == 'epub' and \ + db.has_format(book['calibre_id'],'EPUB',index_is_id=True): + tmp = PersistentTemporaryFile(prefix='old-%s-'%book['calibre_id'], + suffix='.epub', + dir=options['tdir']) + db.copy_format_to(book_id,fileform,tmp,index_is_id=True) + logger.debug("existing epub tmp:"+tmp.name) + book['epub_for_update'] = tmp.name + + if book_id != None and prefs['injectseries']: + mi = db.get_metadata(book_id,index_is_id=True) + if not book['series'] and mi.series != None: + book['calibre_series'] = (mi.series,mi.series_index) + #print("calibre_series:%s [%s]"%book['calibre_series']) + + if book['good']: # there shouldn't be any !'good' books at this point. + # if still 'good', make a temp file to write the output to. + # For HTML format users, make the filename inside the zip something reasonable. + # For crazy long titles/authors, limit it to 200chars. + # For weird/OS-unsafe characters, use file safe only. + tmp = PersistentTemporaryFile(prefix=story.formatFileName("${title}-${author}-",allowunsafefilename=False)[:100], + suffix='.'+options['fileform'], + dir=options['tdir']) + logger.debug("title:"+book['title']) + logger.debug("outfile:"+tmp.name) + book['outfile'] = tmp.name + + return + + def start_download_job(self,book_list, + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True, + 'updateepubcover':True}, + merge=False): + ''' + Called by LoopProgressDialog to start story downloads BG processing. + adapter_list is a list of tuples of (url,adapter) + ''' + #print("start_download_job:book_list:%s"%book_list) + + ## No need to BG process when CALIBREONLY! Fake it. + #print("options:%s"%options) + if options['collision'] == CALIBREONLY: + class NotJob(object): + def __init__(self,result): + self.failed=False + self.result=result + notjob = NotJob(book_list) + self.download_list_completed(notjob,options=options) + return + + for book in book_list: + if book['good']: + break + else: + ## No good stories to try to download, go straight to + ## updating error col. + msg = ''' + <p>%s</p> + <p>%s</p> + <p>%s</p>'''%( + _('None of the <b>%d</b> URLs/stories given can be/need to be downloaded.')%len(book_list), + _('See log for details.'), + _('Proceed with updating your library(Error Column, if configured)?')) + + htmllog='<html><body><table border="1"><tr><th>'+_('Status')+'</th><th>'+_('Title')+'</th><th>'+_('Author')+'</th><th>'+_('Comment')+'</th><th>URL</th></tr>' + for book in book_list: + if 'status' in book: + status = book['status'] + else: + status = _('Bad') + htmllog = htmllog + '<tr><td>' + '</td><td>'.join([escapehtml(status),escapehtml(book['title']),escapehtml(", ".join(book['author'])),escapehtml(book['comment']),book['url']]) + '</td></tr>' + + htmllog = htmllog + '</table></body></html>' + + payload = ([], book_list, options) + self.gui.proceed_question(self.update_error_column, + payload, htmllog, + _('FFDL log'), _('FFDL download ended'), msg, + show_copy_button=False) + return + + func = 'arbitrary_n' + cpus = self.gui.job_manager.server.pool_size + args = ['calibre_plugins.fanfictiondownloader_plugin.jobs', 'do_download_worker', + (book_list, options, cpus)] + desc = _('Download FanFiction Book') + job = self.gui.job_manager.run_job( + self.Dispatcher(partial(self.download_list_completed,options=options,merge=merge)), + func, args=args, + description=desc) + + self.gui.jobs_pointer.start() + self.gui.status_bar.show_message(_('Starting %d FanFictionDownLoads')%len(book_list),3000) + + def update_books_loop(self,book,db=None, + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True, + 'updateepubcover':True}): + custom_columns = self.gui.library_view.model().custom_columns + if book['calibre_id'] and prefs['errorcol'] != '' and prefs['errorcol'] in custom_columns: + label = custom_columns[prefs['errorcol']]['label'] + if not book['good']: + logger.debug("record/update error message column %s %s"%(book['title'],book['url'])) + db.set_custom(book['calibre_id'], book['comment'], label=label, commit=True) # book['comment'] + else: + db.set_custom(book['calibre_id'], '', label=label, commit=True) # book['comment'] + + if not book['good']: + return # only update errorcol on error. + + logger.debug("add/update %s %s"%(book['title'],book['url'])) + mi = self.make_mi_from_book(book) + + if options['collision'] != CALIBREONLY: + self.add_book_or_update_format(book,options,prefs,mi) + + if options['collision'] == CALIBREONLY or \ + ( (options['updatemeta'] or book['added']) and book['good'] ): + try: + self.update_metadata(db, book['calibre_id'], book, mi, options) + except: + det_msg = "".join(traceback.format_exception(*sys.exc_info()))+"\n"+_("Story Details:")+pretty_book(book) + logger.error("Error Updating Metadata:\n%s"%det_msg) + error_dialog(self.gui, + _("Error Updating Metadata"), + "<p>"+_("An error has occurred while FFDL was updating calibre's metadata for <a href='%s'>%s</a>.")%(book['url'],book['title'])+"</p>"+ + _("The ebook has been updated, but the metadata has not."), + det_msg=det_msg, + show=True) + + def update_books_finish(self, book_list, options={}, showlist=True): + '''Notify calibre about updated rows, update external plugins + (Reading Lists & Count Pages) as configured''' + + add_list = filter(lambda x : x['good'] and x['added'], book_list) + add_ids = [ x['calibre_id'] for x in add_list ] + update_list = filter(lambda x : x['good'] and not x['added'], book_list) + update_ids = [ x['calibre_id'] for x in update_list ] + all_ids = add_ids + update_ids + + failed_list = filter(lambda x : not x['good'] , book_list) + failed_ids = [ x['calibre_id'] for x in failed_list ] + + if options['collision'] != CALIBREONLY and \ + (prefs['addtolists'] or prefs['addtoreadlists']): + self.update_reading_lists(all_ids,add=True) + + if len(add_list): + self.gui.library_view.model().books_added(len(add_list)) + self.gui.library_view.model().refresh_ids(add_ids) + + if len(update_list): + self.gui.library_view.model().refresh_ids(update_ids) + + current = self.gui.library_view.currentIndex() + self.gui.library_view.model().current_changed(current, self.previous) + self.gui.tags_view.recount() + + if self.gui.cover_flow: + self.gui.cover_flow.dataChanged() + + if showlist and prefs['mark']: # don't use with anthology + db = self.gui.current_db + marked_ids = dict() + marked_text = "ffdl_success" + for index, book_id in enumerate(all_ids): + marked_ids[book_id] = '%s_%04d' % (marked_text, index) + for index, book_id in enumerate(failed_ids): + marked_ids[book_id] = 'ffdl_failed_%04d' % index + # Mark the results in our database + db.set_marked_ids(marked_ids) + + if prefs['showmarked']: # show add/update + # Search to display the list contents + self.gui.search.set_search_string('marked:' + marked_text) + # Sort by our marked column to display the books in order + self.gui.library_view.sort_by_named_field('marked', True) + + self.gui.status_bar.show_message(_('Finished Adding/Updating %d books.')%(len(update_list) + len(add_list)), 3000) + remove_dir(options['tdir']) + + if 'Count Pages' in self.gui.iactions and len(prefs['countpagesstats']) and len(all_ids): + cp_plugin = self.gui.iactions['Count Pages'] + cp_plugin.count_statistics(all_ids,prefs['countpagesstats']) + + if prefs['autoconvert'] and options['collision'] != CALIBREONLY: + self.gui.status_bar.show_message(_('Starting auto conversion of %d books.')%(len(all_ids)), 3000) + self.gui.iactions['Convert Books'].auto_convert_auto_add(all_ids) + + def download_list_completed(self, job, options={},merge=False): + if job.failed: + self.gui.job_exception(job, dialog_title='Failed to Download Stories') + return + + self.previous = self.gui.library_view.currentIndex() + db = self.gui.current_db + + book_list = job.result + good_list = filter(lambda x : x['good'], book_list) + bad_list = filter(lambda x : not x['good'], book_list) + good_list = sorted(good_list,key=lambda x : x['listorder']) + bad_list = sorted(bad_list,key=lambda x : x['listorder']) + #print("book_list:%s"%book_list) + payload = (good_list, bad_list, options) + + if merge: + if len(good_list) < 1: + info_dialog(self.gui, _('No Good Stories for Anthology'), + _('No good stories/updates where downloaded, Anthology creation/update aborted.'), + show=True, + show_copy_button=False) + return + + msg = '<p>'+_('FFDL found <b>%s</b> good and <b>%s</b> bad updates.')%(len(good_list),len(bad_list))+'</p>' + if len(bad_list) > 0: + msg = msg + ''' + <p>%s</p> + <p>%s</p> + <p>%s</p> + <p>%s</p>'''%( + _('Are you sure you want to continue with creating/updating this Anthology?'), + _('Any updates that failed will <b>not</b> be included in the Anthology.'), + _("However, if there's an older version, it will still be included."), + _('See log for details.')) + + msg = msg + '<p>'+_('Proceed with updating this anthology and your library?')+ '</p>' + + htmllog='<html><body><table border="1"><tr><th>'+_('Status')+'</th><th>'+_('Title')+'</th><th>'+_('Author')+'</th><th>'+_('Comment')+'</th><th>URL</th></tr>' + for book in sorted(good_list+bad_list,key=lambda x : x['listorder']): + if 'status' in book: + status = book['status'] + else: + if book in good_list: + status = _('Good') + else: + status = _('Bad') + htmllog = htmllog + '<tr><td>' + '</td><td>'.join([escapehtml(status),escapehtml(book['title']),escapehtml(", ".join(book['author'])),escapehtml(book['comment']),book['url']]) + '</td></tr>' + + htmllog = htmllog + '</table></body></html>' + + for book in bad_list: + if 'epub_for_update' in book: + book['good']=True + book['outfile'] = book['epub_for_update'] + good_list.append(book) + + do_update_func = self.do_download_merge_update + else: + msg = ''' + <p>%s</p> + <p>%s</p> + <p>%s</p>'''%( + _('FFDL found <b>%s</b> good and <b>%s</b> bad updates.')%(len(good_list),len(bad_list)), + _('See log for details.'), + _('Proceed with updating your library?') + ) + + htmllog='<html><body><table border="1"><tr><th>'+_('Status')+'</th><th>'+_('Title')+'</th><th>'+_('Author')+'</th><th>'+_('Comment')+'</th><th>URL</th></tr>' + for book in good_list: + if 'status' in book: + status = book['status'] + else: + status = 'Good' + htmllog = htmllog + '<tr><td>' + '</td><td>'.join([escapehtml(status),escapehtml(book['title']),escapehtml(", ".join(book['author'])),escapehtml(book['comment']),book['url']]) + '</td></tr>' + + for book in bad_list: + if 'status' in book: + status = book['status'] + else: + status = 'Bad' + htmllog = htmllog + '<tr><td>' + '</td><td>'.join([escapehtml(status),escapehtml(book['title']),escapehtml(", ".join(book['author'])),escapehtml(book['comment']),book['url']]) + '</td></tr>' + + htmllog = htmllog + '</table></body></html>' + + do_update_func = self.do_download_list_update + + self.gui.proceed_question(do_update_func, + payload, htmllog, + _('FFDL log'), _('FFDL download complete'), msg, + show_copy_button=False) + + def do_download_merge_update(self, payload): + + (good_list,bad_list,options) = payload + total_good = len(good_list) + + logger.debug("merge titles:\n%s"%"\n".join([ "%s %s"%(x['title'],x['listorder']) for x in good_list ])) + + good_list = sorted(good_list,key=lambda x : x['listorder']) + bad_list = sorted(bad_list,key=lambda x : x['listorder']) + + self.gui.status_bar.show_message(_('Merging %s books.')%total_good) + + existingbook = None + if 'mergebook' in options: + existingbook = options['mergebook'] + #print("existingbook:\n%s"%existingbook) + mergebook = self.merge_meta_books(existingbook,good_list,options['fileform']) + + if 'mergebook' in options: + mergebook['calibre_id'] = options['mergebook']['calibre_id'] + + if 'anthology_url' in options: + mergebook['url'] = options['anthology_url'] + + #print("mergebook:\n%s"%mergebook) + + if mergebook['good']: # there shouldn't be any !'good' books at this point. + # if still 'good', make a temp file to write the output to. + tmp = PersistentTemporaryFile(suffix='.'+options['fileform'], + dir=options['tdir']) + logger.debug("title:"+mergebook['title']) + logger.debug("outfile:"+tmp.name) + mergebook['outfile'] = tmp.name + + self.get_epubmerge_plugin().do_merge(tmp.name, + [ x['outfile'] for x in good_list ], + titleopt=mergebook['title'], + keepmetadatafiles=True, + source=mergebook['url']) + + options['collision']=OVERWRITEALWAYS + self.update_books_loop(mergebook,self.gui.current_db,options) + self.update_books_finish([mergebook], options=options, showlist=False) + + def do_download_list_update(self, payload): + + (good_list,bad_list,options) = payload + good_list = sorted(good_list,key=lambda x : x['listorder']) + bad_list = sorted(bad_list,key=lambda x : x['listorder']) + + self.gui.status_bar.show_message(_('FFDL Adding/Updating books.')) + + if good_list or prefs['mark'] or (bad_list and prefs['errorcol'] != '' and prefs['errorcol'] in self.gui.library_view.model().custom_columns): + LoopProgressDialog(self.gui, + good_list+bad_list, + partial(self.update_books_loop, options=options, db=self.gui.current_db), + partial(self.update_books_finish, options=options), + init_label=_("Updating calibre for FanFiction stories..."), + win_title=_("Update calibre for FanFiction stories"), + status_prefix=_("Updated")) + + def update_error_column(self,payload): + '''Update custom error column if configured.''' + (empty_list,book_list,options)=payload + custom_columns = self.gui.library_view.model().custom_columns + if prefs['mark'] or (prefs['errorcol'] != '' and prefs['errorcol'] in custom_columns): + self.previous = self.gui.library_view.currentIndex() # used by update_books_finish. + self.gui.status_bar.show_message(_('Adding/Updating %s BAD books.')%len(book_list)) + if (prefs['errorcol'] != '' and prefs['errorcol'] in custom_columns): + label = custom_columns[prefs['errorcol']]['label'] + else: + label = None + LoopProgressDialog(self.gui, + book_list, + partial(self.update_error_column_loop, db=self.gui.current_db, label=label), + partial(self.update_books_finish, options=options), + init_label=_("Updating calibre for BAD FanFiction stories..."), + win_title=_("Update calibre for BAD FanFiction stories"), + status_prefix=_("Updated")) + + def update_error_column_loop(self,book,db=None,label=None): + if book['calibre_id'] and label: + logger.debug("add/update bad %s %s %s"%(book['title'],book['url'],book['comment'])) + db.set_custom(book['calibre_id'], book['comment'], label=label, commit=True) + + def add_book_or_update_format(self,book,options,prefs,mi=None): + db = self.gui.current_db + + if mi == None: + mi = self.make_mi_from_book(book) + + book_id = book['calibre_id'] + if book_id == None: + book_id = db.create_book_entry(mi, + add_duplicates=True) + book['calibre_id'] = book_id + book['added'] = True + else: + book['added'] = False + + if not db.add_format_with_hooks(book_id, + options['fileform'], + book['outfile'], index_is_id=True): + book['comment'] = _("Adding format to book failed for some reason...") + book['good']=False + book['icon']='dialog_error.png' + book['status'] = _('Error') + + if prefs['deleteotherforms']: + fmts = db.formats(book['calibre_id'], index_is_id=True).split(',') + for fmt in fmts: + if fmt != formmapping[options['fileform']]: + logger.debug("deleteotherforms remove f:"+fmt) + db.remove_format(book['calibre_id'], fmt, index_is_id=True)#, notify=False + elif prefs['autoconvert']: + ## 'Convert Book'.auto_convert_auto_add doesn't convert if + ## the format is already there. + fmt = calibre_prefs['output_format'] + # delete if there, but not if the format we just made. + if fmt != formmapping[options['fileform']] and \ + db.has_format(book_id,fmt,index_is_id=True): + logger.debug("autoconvert remove f:"+fmt) + db.remove_format(book['calibre_id'], fmt, index_is_id=True)#, notify=False + + + return book_id + + def update_metadata(self, db, book_id, book, mi, options): + oldmi = db.get_metadata(book_id,index_is_id=True) + if prefs['keeptags']: + old_tags = db.get_tags(book_id) + #print("old_tags:%s"%old_tags) + #print("mi.tags:%s"%mi.tags) + # remove old Completed/In-Progress only if there's a new one. + if 'Completed' in mi.tags or 'In-Progress' in mi.tags: + old_tags = filter( lambda x : x not in ('Completed', 'In-Progress'), old_tags) + # remove old Last Update tags if there are new ones. + if len(filter( lambda x : not x.startswith("Last Update"), mi.tags)) > 0: + old_tags = filter( lambda x : not x.startswith("Last Update"), old_tags) + + # mi.tags needs to be list, but set kills dups. + # this way also removes case-mismatched dups, keeping old_tags version. + foldedcase_tags = dict() + for t in list(mi.tags) + list(old_tags): + foldedcase_tags[t.lower()] = t + + mi.tags = foldedcase_tags.values() + #print("mi.tags:%s"%mi.tags) + + if book['all_metadata']['langcode']: + mi.languages=[book['all_metadata']['langcode']] + else: + # Set language english, but only if not already set. + if not oldmi.languages: + mi.languages=['en'] + + if options['fileform'] == 'epub' and prefs['updatecover']: + existingepub = db.format(book_id,'EPUB',index_is_id=True, as_file=True) + epubmi = get_metadata(existingepub,'EPUB') + if epubmi.cover_data[1] is not None: + try: + db.set_cover(book_id, epubmi.cover_data[1]) + except: + logger.info("Failed to set_cover, skipping") + + # implement 'newonly' flags here by setting to the current + # value again. + if not book['added']: + for (col,newonly) in prefs['std_cols_newonly'].iteritems(): + if newonly: + if col == "identifiers": + mi.set_identifiers(oldmi.get_identifiers()) + else: + try: + mi.__setattr__(col,oldmi.__getattribute__(col)) + except AttributeError: + logger.warn("AttributeError? %s"%col) + pass + + db.set_metadata(book_id,mi) + # mi.authors gets run through the string_to_authors and split on '&' ',' 'and' and 'with' + db.set_authors(book_id,book['author']) # author is a list. + + # do configured column updates here. + #print("all_metadata: %s"%book['all_metadata']) + custom_columns = self.gui.library_view.model().custom_columns + + #print("prefs['custom_cols'] %s"%prefs['custom_cols']) + for col, meta in prefs['custom_cols'].iteritems(): + #print("setting %s to %s"%(col,meta)) + if col not in custom_columns: + logger.debug("%s not an existing column, skipping."%col) + continue + coldef = custom_columns[col] + if col in prefs['custom_cols_newonly'] and prefs['custom_cols_newonly'][col] and not book['added']: + logger.debug("Skipping custom column(%s) update, set to New Books Only"%coldef['name']) + continue + if not meta.startswith('status-') and meta not in book['all_metadata'] or \ + meta.startswith('status-') and 'status' not in book['all_metadata']: + logger.debug("No value for %s, skipping custom column(%s) update."%(meta,coldef['name'])) + continue + if meta not in permitted_values[coldef['datatype']]: + logger.debug("%s not a valid column type for %s, skipping."%(col,meta)) + continue + label = coldef['label'] + if coldef['datatype'] in ('enumeration','text','comments','datetime','series'): + db.set_custom(book_id, book['all_metadata'][meta], label, commit=False) + elif coldef['datatype'] in ('int','float'): + num = unicode(book['all_metadata'][meta]).replace(",","") + if num != '': + db.set_custom(book_id, num, label=label, commit=False) + elif coldef['datatype'] == 'bool' and meta.startswith('status-'): + if meta == 'status-C': + val = book['all_metadata']['status'] == 'Completed' + if meta == 'status-I': + val = book['all_metadata']['status'] == 'In-Progress' + db.set_custom(book_id, val, label=label, commit=False) + + configuration = None + if prefs['allow_custcol_from_ini']: + configuration = get_ffdl_config(book['url'],options['fileform']) + # meta => custcol[,a|n|r] + # cliches=>\#acolumn,r + for line in configuration.getConfig('custom_columns_settings').splitlines(): + if "=>" in line: + (meta,custcol) = map( lambda x: x.strip(), line.split("=>") ) + flag='r' + if "," in custcol: + (custcol,flag) = map( lambda x: x.strip(), custcol.split(",") ) + + if meta not in book['all_metadata']: + # if double quoted, use as a literal value. + if meta[0] == '"' and meta[-1] == '"': + val = meta[1:-1] + logger.debug("No metadata value for %s, setting custom column(%s) literally to %s."%(meta,custcol,val)) + else: + logger.debug("No value for %s, skipping custom column(%s) update."%(meta,custcol)) + continue + else: + val = book['all_metadata'][meta] + + if custcol not in custom_columns: + continue + else: + coldef = custom_columns[custcol] + label = coldef['label'] + + if flag == 'r' or book['added']: # flag 'n' isn't actually needed--*always* set if configured and new book. + if coldef['datatype'] in ('int','float'): # for favs, etc--site specific metadata. + if 'anthology_meta_list' in book and meta in book['anthology_meta_list']: + # re-split list, strip commas, convert to floats, sum up. + val = sum([ float(x.replace(",","")) for x in val.split(", ") ]) + else: + val = unicode(val).replace(",","") + else: + val = val + if val != '': + if coldef['datatype'] == 'bool': + if val.lower() in ('t','true','1','yes','y'): + val = True + elif val.lower() in ('f','false','0','no','n'): + val = False + else: + val = None # for tri-state 'booleans'. Yes/No/Null + db.set_custom(book_id, val, label=label, commit=False) + + if flag == 'a': + vallist = [] + try: + existing=db.get_custom(book_id,label=label,index_is_id=True) + if isinstance(existing,list): + vallist = existing + elif existing: + vallist = [existing] + except: + pass + + if val: + vallist.append(val) + + db.set_custom(book_id, ", ".join(vallist), label=label, commit=False) + + # set author link if found. All current adapters have authorUrl, except anonymous on AO3. + # Moved down so author's already in the DB. + if 'authorUrl' in book['all_metadata']: + authurls = book['all_metadata']['authorUrl'].split(", ") + authorlist = [ a.replace('&',';') for a in book['author'] ] + authorids = db.new_api.get_item_ids('authors',authorlist) + authordata = db.new_api.author_data(authorids.values()) + # print("\n\nauthorids:%s"%authorids) + # print("authordata:%s"%authordata) + + author_id_to_link_map = dict() + for i, author in enumerate(authorlist): + author_id_to_link_map[authorids[author]] = authurls[i] + + # print("author_id_to_link_map:%s\n\n"%author_id_to_link_map) + db.new_api.set_link_for_authors(author_id_to_link_map) + + db.commit() + + if 'Generate Cover' in self.gui.iactions and (book['added'] or not prefs['gcnewonly']): + + #logger.debug("Do Generate Cover added:%s gcnewonly:%s"%(book['added'],prefs['gcnewonly'])) + + # force a refresh if generating cover so complex composite + # custom columns are current and correct + db.refresh_ids([book_id]) + + gc_plugin = self.gui.iactions['Generate Cover'] + setting_name = None + if prefs['allow_gc_from_ini']: + if not configuration: # might already have it from allow_custcol_from_ini + configuration = get_ffdl_config(book['url'],options['fileform']) + + # template => regexp to match => GC Setting to use. + # generate_cover_settings: + # ${category} => Buffy:? the Vampire Slayer => Buffy + for line in configuration.getConfig('generate_cover_settings').splitlines(): + if "=>" in line: + (template,regexp,setting) = map( lambda x: x.strip(), line.split("=>") ) + value = Template(template).safe_substitute(book['all_metadata']).encode('utf8') + # print("%s(%s) => %s => %s"%(template,value,regexp,setting)) + if re.search(regexp,value): + setting_name = setting + break + + if setting_name: + logger.debug("Generate Cover Setting from generate_cover_settings(%s)"%line) + if setting_name not in gc_plugin.get_saved_setting_names(): + logger.info("GC Name %s not found, discarding! (check personal.ini for typos)"%setting_name) + setting_name = None + + if not setting_name and book['all_metadata']['site'] in prefs['gc_site_settings']: + setting_name = prefs['gc_site_settings'][book['all_metadata']['site']] + logger.debug("Generate Cover Setting from site(%s)"%setting_name) + + if not setting_name and 'Default' in prefs['gc_site_settings']: + setting_name = prefs['gc_site_settings']['Default'] + logger.debug("Generate Cover Setting from Default(%s)"%setting_name) + + if setting_name: + logger.debug("Running Generate Cover with settings %s."%setting_name) + realmi = db.get_metadata(book_id, index_is_id=True) + gc_plugin.generate_cover_for_book(realmi,saved_setting_name=setting_name) + + if prefs['gc_polish_cover'] and \ + options['fileform'] == "epub": + # set cover inside epub from calibre's polish feature + from calibre.ebooks.oeb.polish.main import polish, ALL_OPTS + from calibre.utils.logging import Log + from collections import namedtuple + + # Couldn't find a better way to get the cover path. + cover_path = os.path.join(db.library_path, db.path(book_id, index_is_id=True), 'cover.jpg') + data = {'cover':cover_path} + #print("cover_path:%s"%cover_path) + opts = ALL_OPTS.copy() + opts.update(data) + O = namedtuple('Options', ' '.join(ALL_OPTS.iterkeys())) + opts = O(**opts) + + log = Log(level=Log.DEBUG) + outfile = db.format_abspath(book_id, formmapping[options['fileform']], index_is_id=True) + #print("polish cover outfile:%s"%outfile) + polish({outfile:outfile}, opts, log, logger.info) + + + def get_clean_reading_lists(self,lists): + if lists == None or lists.strip() == "" : + return [] + else: + return filter( lambda x : x, map( lambda x : x.strip(), lists.split(',') ) ) + + def update_reading_lists(self,book_ids,add=True): + try: + rl_plugin = self.gui.iactions['Reading List'] + except: + if prefs['addtolists'] or prefs['addtoreadlists']: + message="<p>"+_("You configured FanFictionDownLoader to automatically update Reading Lists, but you don't have the %s plugin installed anymore?")%'Reading List'+"</p>" + confirm(message,'fanfictiondownloader_no_reading_list_plugin', self.gui) + return + + if prefs['addtoreadlists']: + if add: + addremovefunc = rl_plugin.add_books_to_list + else: + addremovefunc = rl_plugin.remove_books_from_list + + lists = self.get_clean_reading_lists(prefs['read_lists']) + if len(lists) < 1 : + message="<p>"+_("You configured FanFictionDownLoader to automatically update \"To Read\" Reading Lists, but you don't have any lists set?")+"</p>" + confirm(message,'fanfictiondownloader_no_read_lists', self.gui) + for l in lists: + if l in rl_plugin.get_list_names(): + #print("add good read l:(%s)"%l) + addremovefunc(l, + book_ids, + display_warnings=False) + else: + if l != '': + message="<p>"+_("You configured FanFictionDownLoader to automatically update Reading List '%s', but you don't have a list of that name?")%l+"</p>" + confirm(message,'fanfictiondownloader_no_reading_list_%s'%l, self.gui) + + if prefs['addtolists'] and (add or (prefs['addtolistsonread'] and prefs['addtoreadlists']) ): + lists = self.get_clean_reading_lists(prefs['send_lists']) + if len(lists) < 1 : + message="<p>"+_("You configured FanFictionDownLoader to automatically update \"Send to Device\" Reading Lists, but you don't have any lists set?")+"</p>" + confirm(message,'fanfictiondownloader_no_send_lists', self.gui) + + for l in lists: + if l in rl_plugin.get_list_names(): + #print("good send l:(%s)"%l) + rl_plugin.add_books_to_list(l, + #add_book_ids, + book_ids, + display_warnings=False) + else: + if l != '': + message="<p>"+_("You configured FanFictionDownLoader to automatically update Reading List '%s', but you don't have a list of that name?")%l+"</p>" + confirm(message,'fanfictiondownloader_no_reading_list_%s'%l, self.gui) + + def make_mi_from_book(self,book): + mi = MetaInformation(book['title'],book['author']) # author is a list. + if prefs['suppressauthorsort']: + # otherwise author names will have calibre's sort algs + # applied automatically. + mi.author_sort = ' & '.join(book['author']) + if prefs['suppresstitlesort']: + # otherwise titles will have calibre's sort algs applied + # automatically. + mi.title_sort = book['title'] + mi.set_identifiers({'url':book['url']}) + mi.publisher = book['publisher'] + mi.tags = book['tags'] + #mi.languages = ['en'] # handled in update_metadata so it can check for existing lang. + mi.pubdate = book['pubdate'] + mi.timestamp = book['timestamp'] + mi.comments = book['comments'] + mi.series = book['series'] + return mi + + # Can't make book a class because it needs to be passed into the + # bg jobs and only serializable things can be. + def make_book(self): + book = {} + book['title'] = 'Unknown' + book['author_sort'] = book['author'] = ['Unknown'] # list + book['comments'] = '' # note this is the book comments. + + book['good'] = True + book['calibre_id'] = None + book['begin'] = None + book['end'] = None + book['comment'] = '' # note this is a comment on the d/l or update. + book['url'] = '' + book['site'] = '' + book['added'] = False + book['pubdate'] = None + return book + + def convert_urls_to_books(self, urls): + books = [] + uniqueurls = set() + for i, url in enumerate(urls): + book = self.convert_url_to_book(url) + if book['url'] in uniqueurls: + book['good'] = False + book['comment'] = "Same story already included." + uniqueurls.add(book['url']) + book['listorder']=i # BG d/l jobs don't come back in order. + # Didn't matter until anthologies & 'marked' successes + books.append(book) + return books + + def convert_url_to_book(self, url): + book = self.make_book() + # look here for [\d,\d] at end of url, and remove? + mc = re.match(r"^(?P<url>.*?)(?:\[(?P<begin>\d+)?(?P<comma>[,-])?(?P<end>\d+)?\])?$",url) + #print("url:(%s) begin:(%s) end:(%s)"%(mc.group('url'),mc.group('begin'),mc.group('end'))) + url = mc.group('url') + book['begin'] = mc.group('begin') + book['end'] = mc.group('end') + if book['begin'] and not mc.group('comma'): + book['end'] = book['begin'] + + self.set_book_url_and_comment(book,url) + return book + + # basic book, plus calibre_id. Assumed bad until proven + # otherwise. + def make_book_id_only(self, idval): + book = self.make_book() + book['good'] = False + book['calibre_id'] = idval + return book + + def populate_book_from_mi(self,book,mi): + book['title'] = mi.title + book['author'] = mi.authors + book['author_sort'] = mi.author_sort + if hasattr(mi,'publisher'): + book['publisher'] = mi.publisher + if hasattr(mi,'path'): + book['path'] = mi.path + if hasattr(mi,'id'): + book['calibre_id'] = mi.id + + # book data from device. Assumed bad until proven otherwise. + def make_book_from_device_row(self, row): + book = self.make_book() + mi = self.gui.current_view().model().get_book_display_info(row.row()) + self.populate_book_from_mi(book,mi) + book['good'] = False + return book + + def populate_book_from_calibre_id(self, book, db=None): + mi = db.get_metadata(book['calibre_id'], index_is_id=True) + #book = {} + book['good'] = True + self.populate_book_from_mi(book,mi) + + url = self.get_story_url(db,book['calibre_id']) + self.set_book_url_and_comment(book,url) + #return book - populated passed in book. + + def set_book_url_and_comment(self,book,url): + if not url: + book['comment'] = _("No story URL found.") + book['good'] = False + book['icon'] = 'search_delete_saved.png' + book['status'] = _('Not Found') + else: + # get normalized url or None. + urlsitetuple = adapters.getNormalStoryURLSite(url) + if urlsitetuple == None: + book['url'] = url + book['comment'] = _("URL is not a valid story URL.") + book['good'] = False + book['icon']='dialog_error.png' + book['status'] = _('Bad URL') + else: + (book['url'],book['site'])=urlsitetuple + + def get_story_url(self, db, book_id=None, path=None): + if book_id == None: + identifiers={} + else: + identifiers = db.get_identifiers(book_id,index_is_id=True) + if 'url' in identifiers: + # identifiers have :->| in url. + # print("url from ident url:%s"%identifiers['url'].replace('|',':')) + return identifiers['url'].replace('|',':') + elif 'uri' in identifiers: + # identifiers have :->| in uri. + # print("uri from ident uri:%s"%identifiers['uri'].replace('|',':')) + return identifiers['uri'].replace('|',':') + else: + existingepub = None + if path == None and db.has_format(book_id,'EPUB',index_is_id=True): + existingepub = db.format(book_id,'EPUB',index_is_id=True, as_file=True) + mi = get_metadata(existingepub,'EPUB') + identifiers = mi.get_identifiers() + if 'url' in identifiers: + # print("url from get_metadata:%s"%identifiers['url'].replace('|',':')) + return identifiers['url'].replace('|',':') + elif 'uri' in identifiers: + # identifiers have :->| in uri. + # print("uri from ident uri:%s"%identifiers['uri'].replace('|',':')) + return identifiers['uri'].replace('|',':') + elif path and path.lower().endswith('.epub'): + existingepub = path + + ## only epub has URL in it--at least where I can easily find it. + if existingepub: + # look for dc:source first, then scan HTML if lookforurlinhtml + link = get_dcsource(existingepub) + if link: + # print("url from get_dcsource:%s"%link) + return link + elif prefs['lookforurlinhtml']: + link = get_story_url_from_html(existingepub,self.is_good_downloader_url) + # print("url from get_story_url_from_html:%s"%link) + return link + return None + + def is_good_downloader_url(self,url): + return adapters.getNormalStoryURL(url) + + def merge_meta_books(self,existingbook,book_list,fileform): + book = self.make_book() + book['author'] = [] + book['tags'] = [] + book['url'] = '' + book['all_metadata'] = {} + book['anthology_meta_list'] = {} + book['comment'] = '' + book['added'] = True + book['good'] = True + book['calibre_id'] = None + book['series'] = None + + serieslist=[] + + # copy list top level + for b in book_list: + if b['series']: + serieslist.append(b['series'][:b['series'].index(" [")]) + #print("book series:%s"%serieslist[-1]) + + if b['publisher']: + if 'publisher' not in book: + book['publisher']=b['publisher'] + elif book['publisher']!=b['publisher']: + book['publisher']=None # if any are different, don't use. + + # copy authors & tags. + for k in ('author','tags'): + for v in b[k]: + if v not in book[k]: + book[k].append(v) + + # fill from first of each if not already present: + for k in ('pubdate', 'timestamp', 'updatedate'): + if k not in b or not b[k]: # not in this book? Skip it. + continue + if k not in book or not book[k]: # first is good enough for publisher. + book[k]=b[k] + + # Do these even on first to get the all_metadata settings. + # pubdate should be earliest date. + if k == 'pubdate' and book[k] >= b[k]: + book[k]=b[k] + book['all_metadata']['datePublished'] = b['all_metadata']['datePublished'] + # timestamp should be latest date. + if k == 'timestamp' and book[k] <= b[k]: + book[k]=b[k] + book['all_metadata']['dateCreated'] = b['all_metadata']['dateCreated'] + # updated should be latest date. + if k == 'updatedate' and book[k] <= b[k]: + book[k]=b[k] + book['all_metadata']['dateUpdated'] = b['all_metadata']['dateUpdated'] + + # copy list all_metadata + for (k,v) in b['all_metadata'].iteritems(): + #print("merge_meta_books v:%s k:%s"%(v,k)) + if k in ('numChapters','numWords'): + if k in b['all_metadata'] and b['all_metadata'][k]: + if k not in book['all_metadata']: + book['all_metadata'][k] = b['all_metadata'][k] + else: + # lot of work for a simple add. + book['all_metadata'][k] = unicode(int(book['all_metadata'][k].replace(',',''))+int(b['all_metadata'][k].replace(',',''))) + elif k in ('dateUpdated','datePublished','dateCreated', + 'series','status','title'): + pass # handled above, below or skip these for now, not going to do anything with them. + elif k not in book['all_metadata'] or not book['all_metadata'][k]: + book['all_metadata'][k]=v + elif v: + if k == 'description': + book['all_metadata'][k]=book['all_metadata'][k]+"\n\n"+v + else: + book['all_metadata'][k]=book['all_metadata'][k]+", "+v + # flag psuedo list element. Used so numeric + # cust cols can convert back to numbers and + # add. + book['anthology_meta_list'][k]=True + + print("book['url']:%s"%book['url']) + configuration = get_ffdl_config(book['url'],fileform) + if existingbook: + book['title'] = deftitle = existingbook['title'] + book['comments'] = existingbook['comments'] + else: + book['title'] = deftitle = book_list[0]['title'] + if len(book['author']) > 1: + book['comments'] = _("Anthology containing:")+"\n" + \ + "\n".join([ _("%s by %s")%(b['title'],', '.join(b['author'])) for b in book_list ]) + else: + book['comments'] = _("Anthology containing:")+"\n" + \ + "\n".join([ b['title'] for b in book_list ]) + # book['all_metadata']['description'] + + # if all same series, use series for name. But only if all and not previous named + if len(serieslist) == len(book_list): + series = serieslist[0] + book['title'] = series + for sr in serieslist: + if series != sr: + book['title'] = deftitle + break + + logger.debug("anthology_title_pattern:%s"%configuration.getConfig('anthology_title_pattern')) + if configuration.getConfig('anthology_title_pattern'): + tmplt = Template(configuration.getConfig('anthology_title_pattern')) + book['title'] = tmplt.safe_substitute({'title':book['title']}).encode('utf8') + else: + # No setting, do fall back default. Shouldn't happen, + # should always have a version in defaults. + book['title'] = book['title']+_(" Anthology") + + book['all_metadata']['title'] = book['title'] # because custom columns are set from all_metadata + book['all_metadata']['author'] = ", ".join(book['author']) + book['author_sort']=book['author'] + for v in ['Completed','In-Progress']: + if v in book['tags']: + book['tags'].remove(v) + book['tags'].extend(configuration.getConfigList('anthology_tags')) + book['all_metadata']['anthology'] = "true" + + return book + +def split_text_to_urls(urls): + # remove dups while preserving order. + dups=set() + def f(x): + x=x.strip() + if x and x not in dups: + dups.add(x) + return True + else: + return False + return filter(f,urls.strip().splitlines()) + +def escapehtml(txt): + return txt.replace("&","&").replace(">",">").replace("<","<") + +def pretty_book(d, indent=0, spacer=' '): + kindent = spacer * indent + + # if isinstance(d, list): + # return '\n'.join([(pretty_book(v, indent, spacer)) for v in d]) + + if isinstance(d, dict): + for k in ('password','username'): + if k in d and d[k]: + d[k]=_('(was set, removed for security)') + return '\n'.join(['%s%s:\n%s' % (kindent, k, pretty_book(v, indent + 1, spacer)) + for k, v in d.items()]) + return "%s%s"%(kindent, d) + diff --git a/calibre-plugin/ffdl_util.py b/calibre-plugin/ffdl_util.py new file mode 100644 index 00000000..c169d80c --- /dev/null +++ b/calibre-plugin/ffdl_util.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Jim Miller' +__docformat__ = 'restructuredtext en' + +from StringIO import StringIO + +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters, exceptions +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.configurable import Configuration +from calibre_plugins.fanfictiondownloader_plugin.prefs import (prefs) + +def get_ffdl_personalini(): + if prefs['includeimages']: + # this is a cheat to make it easier for users. + return '''[epub] +include_images:true +keep_summary_html:true +make_firstimage_cover:true +''' + prefs['personal.ini'] + else: + return prefs['personal.ini'] + +def get_ffdl_config(url,fileform="epub",personalini=None): + if not personalini: + personalini = get_ffdl_personalini() + site='unknown' + try: + site = adapters.getConfigSectionFor(url) + except Exception as e: + print("Failed trying to get ini config for url(%s): %s, using section [%s] instead"%(url,e,site)) + configuration = Configuration(site,fileform) + configuration.readfp(StringIO(get_resources("plugin-defaults.ini"))) + configuration.readfp(StringIO(personalini)) + + return configuration + +def get_ffdl_adapter(url,fileform="epub",personalini=None): + return adapters.getAdapter(get_ffdl_config(url,fileform,personalini),url) + diff --git a/calibre-plugin/images/icon.png b/calibre-plugin/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e9715307dd4fe35c686b222262b796828856ff22 GIT binary patch literal 24649 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 literal 0 HcmV?d00001 diff --git a/calibre-plugin/images/icon.xcf b/calibre-plugin/images/icon.xcf new file mode 100644 index 0000000000000000000000000000000000000000..76d7c0c9aeb81d6cf0b6df17f8bc7e6c60a59e00 GIT binary patch literal 63927 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 literal 0 HcmV?d00001 diff --git a/calibre-plugin/jobs.py b/calibre-plugin/jobs.py new file mode 100644 index 00000000..ee8a5f2b --- /dev/null +++ b/calibre-plugin/jobs.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Jim Miller' +__copyright__ = '2011, Grant Drake <grant.drake@gmail.com>' +__docformat__ = 'restructuredtext en' + +import logging +logger = logging.getLogger(__name__) + +import time, os, traceback + +from StringIO import StringIO + +from calibre.utils.ipc.server import Server +from calibre.utils.ipc.job import ParallelJob +from calibre.constants import numeric_version as calibre_version + +from calibre_plugins.fanfictiondownloader_plugin.dialogs import (NotGoingToDownload, + OVERWRITE, OVERWRITEALWAYS, UPDATE, UPDATEALWAYS, ADDNEW, SKIP, CALIBREONLY) +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters, writers, exceptions +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.epubutils import get_update_data + +from calibre_plugins.fanfictiondownloader_plugin.ffdl_util import (get_ffdl_adapter, get_ffdl_config) +# ------------------------------------------------------------------------------ +# +# Functions to perform downloads using worker jobs +# +# ------------------------------------------------------------------------------ + +def do_download_worker(book_list, options, + cpus, notification=lambda x,y:x): + ''' + Master job, to launch child jobs to extract ISBN for a set of books + This is run as a worker job in the background to keep the UI more + responsive and get around the memory leak issues as it will launch + a child job for each book as a worker process + ''' + server = Server(pool_size=cpus) + + logger.info(options['version']) + total = 0 + alreadybad = [] + # Queue all the jobs + logger.info("Adding jobs for URLs:") + for book in book_list: + logger.info("%s"%book['url']) + if book['good']: + total += 1 + args = ['calibre_plugins.fanfictiondownloader_plugin.jobs', + 'do_download_for_worker', + (book,options)] + job = ParallelJob('arbitrary_n', + "url:(%s) id:(%s)"%(book['url'],book['calibre_id']), + done=None, + args=args) + job._book = book + # job._book_id = book_id + # job._title = title + # job._modified_date = modified_date + # job._existing_isbn = existing_isbn + server.add_job(job) + else: + # was already bad before the subprocess ever started. + alreadybad.append(book) + + # This server is an arbitrary_n job, so there is a notifier available. + # Set the % complete to a small number to avoid the 'unavailable' indicator + notification(0.01, 'Downloading FanFiction Stories') + + # dequeue the job results as they arrive, saving the results + count = 0 + while True: + job = server.changed_jobs_queue.get() + # A job can 'change' when it is not finished, for example if it + # produces a notification. Ignore these. + job.update() + if not job.is_finished: + continue + # A job really finished. Get the information. + output_book = job.result + #print("output_book:%s"%output_book) + book_list.remove(job._book) + book_list.append(job.result) + book_id = job._book['calibre_id'] + #title = job._title + count = count + 1 + notification(float(count)/total, '%d of %d stories finished downloading'%(count,total)) + # Add this job's output to the current log + logger.info('Logfile for book ID %s (%s)'%(book_id, job._book['title'])) + logger.info(job.details) + + + + if count >= total: + logger.info("\nSuccessful:\n%s\n"%("\n".join([book['url'] for book in + filter(lambda x: x['good'], book_list) ] ) ) ) + logger.info("\nUnsuccessful:\n%s\n"%("\n".join([book['url'] for book in + filter(lambda x: not x['good'], book_list) ] ) ) ) + break + + server.close() + + # return the book list as the job result + return book_list + +def do_download_for_worker(book,options,notification=lambda x,y:x): + ''' + Child job, to extract isbn from formats for this specific book, + when run as a worker job + ''' + try: + book['comment'] = 'Download started...' + + configuration = get_ffdl_config(book['url'], + options['fileform'], + options['personal.ini']) + + if not options['updateepubcover'] and 'epub_for_update' in book and options['collision'] in (UPDATE, UPDATEALWAYS): + configuration.set("overrides","never_make_cover","true") + + # images only for epub, even if the user mistakenly turned it + # on else where. + if options['fileform'] not in ("epub","html"): + configuration.set("overrides","include_images","false") + + adapter = adapters.getAdapter(configuration,book['url']) + adapter.is_adult = book['is_adult'] + adapter.username = book['username'] + adapter.password = book['password'] + adapter.setChaptersRange(book['begin'],book['end']) + + story = adapter.getStoryMetadataOnly() + if 'calibre_series' in book: + adapter.setSeries(book['calibre_series'][0],book['calibre_series'][1]) + + # set PI version instead of default. + if 'version' in options: + story.setMetadata('version',options['version']) + + writer = writers.getWriter(options['fileform'],configuration,adapter) + + outfile = book['outfile'] + + ## No need to download at all. Shouldn't ever get down here. + if options['collision'] in (CALIBREONLY): + logger.info("Skipping CALIBREONLY 'update' down inside worker--this shouldn't be happening...") + book['comment'] = 'Metadata collected.' + + ## checks were done earlier, it's new or not dup or newer--just write it. + elif options['collision'] in (ADDNEW, SKIP, OVERWRITE, OVERWRITEALWAYS) or \ + ('epub_for_update' not in book and options['collision'] in (UPDATE, UPDATEALWAYS)): + + # preserve logfile even on overwrite. + if 'epub_for_update' in book: + (urlignore, + chaptercountignore, + oldchaptersignore, + oldimgsignore, + oldcoverignore, + calibrebookmarkignore, + # only logfile set in adapter, so others aren't used. + adapter.logfile) = get_update_data(book['epub_for_update']) + + # change the existing entries id to notid so + # write_epub writes a whole new set to indicate overwrite. + if adapter.logfile: + adapter.logfile = adapter.logfile.replace("span id","span notid") + + logger.info("write to %s"%outfile) + writer.writeStory(outfilename=outfile, forceOverwrite=True) + book['comment'] = 'Download %s completed, %s chapters.'%(options['fileform'],story.getMetadata("numChapters")) + + ## checks were done earlier, just update it. + elif 'epub_for_update' in book and options['collision'] in (UPDATE, UPDATEALWAYS): + + # update now handled by pre-populating the old images and + # chapters in the adapter rather than merging epubs. + urlchaptercount = int(story.getMetadata('numChapters').replace(',','')) + (url, + chaptercount, + adapter.oldchapters, + adapter.oldimgs, + adapter.oldcover, + adapter.calibrebookmark, + adapter.logfile) = get_update_data(book['epub_for_update']) + + # dup handling from ffdl_plugin needed for anthology updates. + if options['collision'] == UPDATE: + if chaptercount == urlchaptercount: + book['comment']="Already contains %d chapters. Reuse as is."%chaptercount + book['outfile'] = book['epub_for_update'] # for anthology merge ops. + return book + + # dup handling from ffdl_plugin needed for anthology updates. + if chaptercount > urlchaptercount: + raise NotGoingToDownload("Existing epub contains %d chapters, web site only has %d. Use Overwrite to force update." % (chaptercount,urlchaptercount),'dialog_error.png') + + if not (options['collision'] == UPDATEALWAYS and chaptercount == urlchaptercount) \ + and adapter.getConfig("do_update_hook"): + chaptercount = adapter.hookForUpdates(chaptercount) + + logger.info("Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount)) + logger.info("write to %s"%outfile) + + writer.writeStory(outfilename=outfile, forceOverwrite=True) + + book['comment'] = 'Update %s completed, added %s chapters for %s total.'%\ + (options['fileform'],(urlchaptercount-chaptercount),urlchaptercount) + + if options['smarten_punctuation'] and options['fileform'] == "epub" \ + and calibre_version >= (0, 9, 39): + # do smarten_punctuation from calibre's polish feature + from calibre.ebooks.oeb.polish.main import polish, ALL_OPTS + from calibre.utils.logging import Log + from collections import namedtuple + + data = {'smarten_punctuation':True} + opts = ALL_OPTS.copy() + opts.update(data) + O = namedtuple('Options', ' '.join(ALL_OPTS.iterkeys())) + opts = O(**opts) + + log = Log(level=Log.DEBUG) + # report = [] + polish({outfile:outfile}, opts, log, logger.info) # report.append + + except NotGoingToDownload as d: + book['good']=False + book['comment']=unicode(d) + book['icon'] = d.icon + + except Exception as e: + book['good']=False + book['comment']=unicode(e) + book['icon']='dialog_error.png' + book['status'] = 'Error' + logger.info("Exception: %s:%s"%(book,unicode(e))) + traceback.print_exc() + + #time.sleep(10) + return book diff --git a/calibre-plugin/plugin-import-name-fanfictiondownloader_plugin.txt b/calibre-plugin/plugin-import-name-fanfictiondownloader_plugin.txt new file mode 100644 index 00000000..e69de29b diff --git a/calibre-plugin/prefs.py b/calibre-plugin/prefs.py new file mode 100644 index 00000000..90af87bf --- /dev/null +++ b/calibre-plugin/prefs.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Jim Miller' +__docformat__ = 'restructuredtext en' + +import copy + +from calibre.utils.config import JSONConfig +from calibre.gui2.ui import get_gui + +from calibre_plugins.fanfictiondownloader_plugin.dialogs import OVERWRITE +from calibre_plugins.fanfictiondownloader_plugin.common_utils import get_library_uuid +PREFS_NAMESPACE = 'FanFictionDownLoaderPlugin' +PREFS_KEY_SETTINGS = 'settings' + +# Set defaults used by all. Library specific settings continue to +# take from here. +default_prefs = {} +default_prefs['personal.ini'] = get_resources('plugin-example.ini') +default_prefs['rejecturls'] = '' +default_prefs['rejectreasons'] = '''Sucked +Boring +Dup from another site''' +default_prefs['reject_always'] = False + +default_prefs['updatemeta'] = True +default_prefs['updatecover'] = False +default_prefs['updateepubcover'] = False +default_prefs['keeptags'] = False +default_prefs['suppressauthorsort'] = False +default_prefs['suppresstitlesort'] = False +default_prefs['mark'] = False +default_prefs['showmarked'] = False +default_prefs['autoconvert'] = False +default_prefs['urlsfromclip'] = True +default_prefs['updatedefault'] = True +default_prefs['fileform'] = 'epub' +default_prefs['collision'] = OVERWRITE +default_prefs['deleteotherforms'] = False +default_prefs['adddialogstaysontop'] = False +default_prefs['includeimages'] = False +default_prefs['lookforurlinhtml'] = False +default_prefs['checkforseriesurlid'] = True +default_prefs['checkforurlchange'] = True +default_prefs['injectseries'] = False +default_prefs['smarten_punctuation'] = False + +default_prefs['send_lists'] = '' +default_prefs['read_lists'] = '' +default_prefs['addtolists'] = False +default_prefs['addtoreadlists'] = False +default_prefs['addtolistsonread'] = False + +default_prefs['gcnewonly'] = False +default_prefs['gc_site_settings'] = {} +default_prefs['allow_gc_from_ini'] = True +default_prefs['gc_polish_cover'] = False + +default_prefs['countpagesstats'] = [] + +default_prefs['errorcol'] = '' +default_prefs['custom_cols'] = {} +default_prefs['custom_cols_newonly'] = {} +default_prefs['allow_custcol_from_ini'] = True + +default_prefs['std_cols_newonly'] = {} + +# This is where all preferences for this plugin *were* stored +# Remember that this name (i.e. plugins/fanfictiondownloader_plugin) is also +# in a global namespace, so make it as unique as possible. +# You should always prefix your config file name with plugins/, +# so as to ensure you dont accidentally clobber a calibre config file +old_prefs = JSONConfig('plugins/fanfictiondownloader_plugin') + +def set_library_config(library_config,db): + db.prefs.set_namespaced(PREFS_NAMESPACE, + PREFS_KEY_SETTINGS, + library_config) + +def get_library_config(db): + library_id = get_library_uuid(db) + library_config = None + # Check whether this is a configuration needing to be migrated + # from json into database. If so: get it, set it, rename it in json. + if library_id in old_prefs: + #print("get prefs from old_prefs") + library_config = old_prefs[library_id] + set_library_config(library_config,db) + old_prefs["migrated to library db %s"%library_id] = old_prefs[library_id] + del old_prefs[library_id] + + if library_config is None: + #print("get prefs from db") + library_config = db.prefs.get_namespaced(PREFS_NAMESPACE, PREFS_KEY_SETTINGS, + copy.deepcopy(default_prefs)) + return library_config + +# fake out so I don't have to change the prefs calls anywhere. The +# Java programmer in me is offended by op-overloading, but it's very +# tidy. +class PrefsFacade(): + def _get_db(self): + if self.passed_db: + return self.passed_db + else: + # In the GUI plugin we want current db so we detect when + # it's changed. CLI plugin calls need to pass db in. + return get_gui().current_db + + def __init__(self,passed_db=None): + self.default_prefs = default_prefs + self.libraryid = None + self.current_prefs = None + self.passed_db=passed_db + + def _get_prefs(self): + libraryid = get_library_uuid(self._get_db()) + if self.current_prefs == None or self.libraryid != libraryid: + #print("self.current_prefs == None(%s) or self.libraryid != libraryid(%s)"%(self.current_prefs == None,self.libraryid != libraryid)) + self.libraryid = libraryid + self.current_prefs = get_library_config(self._get_db()) + return self.current_prefs + + def __getitem__(self,k): + prefs = self._get_prefs() + if k not in prefs: + # pulls from default_prefs.defaults automatically if not set + # in default_prefs + return self.default_prefs[k] + return prefs[k] + + def __setitem__(self,k,v): + prefs = self._get_prefs() + prefs[k]=v + # self._save_prefs(prefs) + + def __delitem__(self,k): + prefs = self._get_prefs() + if k in prefs: + del prefs[k] + + def save_to_db(self): + set_library_config(self._get_prefs(),self._get_db()) + +prefs = PrefsFacade() + diff --git a/calibre-plugin/translations/de.po b/calibre-plugin/translations/de.po new file mode 100644 index 00000000..5c57509f --- /dev/null +++ b/calibre-plugin/translations/de.po @@ -0,0 +1,1601 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# +# Translators: +# ILB, 2014 +# Simon_Schuette <simonschuette@arcor.de>, 2014 +msgid "" +msgstr "" +"Project-Id-Version: calibre-plugins\n" +"POT-Creation-Date: 2014-07-14 10:52+Central Daylight Time\n" +"PO-Revision-Date: 2014-07-17 19:21+0000\n" +"Last-Translator: ILB\n" +"Language-Team: German (http://www.transifex.com/projects/p/calibre-plugins/language/de/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: ENCODING\n" +"Generated-By: pygettext.py 1.5\n" +"Language: de\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: __init__.py:42 +msgid "UI plugin to download FanFiction stories from various sites." +msgstr "UI Plugin um FanFicition-Stories von verschiedenen Seiten herunterzuladen." + +#: __init__.py:109 +msgid "" +"Path to the calibre library. Default is to use the path stored in the " +"settings." +msgstr "Pfad für die Calibre-Bibliothek. Als Standardeinstellung ist der Pfad gesetzt, der in den Einstellungen gespeichert ist." + +#: config.py:161 +msgid "FAQs" +msgstr "FAQ´s" + +#: config.py:161 +msgid "List of Supported Sites" +msgstr "Liste der unterstützten Seiten" + +#: config.py:175 +msgid "Basic" +msgstr "Basis" + +#: config.py:196 +msgid "Standard Columns" +msgstr "Standard-Spalten" + +#: config.py:199 +msgid "Custom Columns" +msgstr "Benutzerdefinierte Spalten" + +#: config.py:202 +msgid "Other" +msgstr "Andere" + +#: config.py:323 +msgid "" +"These settings control the basic features of the plugin--downloading " +"FanFiction." +msgstr "Diese Einstellungen steuern die grundlegenden Funktionen des Plugins -- FanFictions herunterladen." + +#: config.py:327 +msgid "Defaults Options on Download" +msgstr "Standardeinstellung für das Herunterladen" + +#: config.py:331 +msgid "" +"On each download, FFDL offers an option to select the output format. <br " +"/>This sets what that option will default to." +msgstr "Bei jedem Download bietet FFDL die Option, das Ausgabeformat auszuwählen. <br />Dies legt fest, welche Standardeinstellung gesetzt werden." + +#: config.py:333 +msgid "Default Output &Format:" +msgstr "Standardeinstellung Ausgabe-Format:" + +#: config.py:348 +msgid "" +"On each download, FFDL offers an option of what happens if that story " +"already exists. <br />This sets what that option will default to." +msgstr "Bei jedem Download bietet FFDL die Option, was geschehen soll, wenn diese Story bereits vorhanden ist. <br />Dies legt fest, welche Standardeinstellung gesetzt werden." + +#: config.py:350 +msgid "Default If Story Already Exists?" +msgstr "Standardeinstellung, wenn die Story bereits vorhanden ist?" + +#: config.py:364 +msgid "Default Update Calibre &Metadata?" +msgstr "Standardeinstellung für die Aktualisierung der Calibre-Metadaten?" + +#: config.py:365 +msgid "" +"On each download, FFDL offers an option to update Calibre's metadata (title," +" author, URL, tags, custom columns, etc) from the web site. <br />This sets " +"whether that will default to on or off. <br />Columns set to 'New Only' in " +"the column tabs will only be set for new books." +msgstr "Bei jedem Download bietet FFDL die Option, die Calibre-Metadaten (Titel, Autor, URL, Schlagworte, benutzerdefinierte Spalten usw.) von der Web-Seite zu aktualisieren.<br />Dies legt fest, ob die Standardeinstellung auf an oder aus gesetzt ist.<br />\nSpalten, die mit \"Nur neue\" in den benutzerdefinierten Spalten gesetzt sind, werden nur bei neuen Büchern gefüllt." + +#: config.py:369 +msgid "Default Update EPUB Cover when Updating EPUB?" +msgstr "Als Standardeinstellung das EPUB-Cover aktualisieren, wenn das EPUB aktualisiert wird?" + +#: config.py:370 +msgid "" +"On each download, FFDL offers an option to update the book cover image " +"<i>inside</i> the EPUB from the web site when the EPUB is updated.<br />This" +" sets whether that will default to on or off." +msgstr "Bei jedem Download bietet FFDL die Option, das Buch-Cover-Image <i>im</i> EPUB mit den Daten der Web-Seite zu aktualisieren, wenn ein EPUB aktualisiert wird.<br />Dies legt fest, ob die Standardeinstellung auf an oder aus gesetzt ist." + +#: config.py:374 +msgid "Smarten Punctuation (EPUB only)" +msgstr "Intelligente Zeichensetzung (nur EPUB)" + +#: config.py:375 +msgid "" +"Run Smarten Punctuation from Calibre's Polish Book feature on each EPUB " +"download and update." +msgstr "Ausführen der Zeichensetzung von Calibre´s Polish Book feature (eBook-Feinabstimmung) bei jedem EPUB Download und Aktualisierung." + +#: config.py:380 +msgid "Updating Calibre Options" +msgstr "Calibre Optionen beim Aktualisieren" + +#: config.py:384 +msgid "Delete other existing formats?" +msgstr "Andere vorhandene Formate löschen?" + +#: config.py:385 +msgid "" +"Check this to automatically delete all other ebook formats when updating an existing book.\n" +"Handy if you have both a Nook(epub) and Kindle(mobi), for example." +msgstr "Markieren sie dies um automatisch alle anderen eBook-Formate zu löschen, wenn ein vorhandenes Buch aktualisiert wird.<br />Praktisch, wenn sie zum Beispiel sowohl ein Nook (epub) und ein Kindle (mobi) haben." + +#: config.py:389 +msgid "Update Calibre Cover when Updating Metadata?" +msgstr "Calibre Cover aktualisieren, wenn die Metadaten aktualiert werden?" + +#: config.py:390 +msgid "" +"Update calibre book cover image from EPUB when metadata is updated. (EPUB only.)\n" +"Doesn't go looking for new images on 'Update Calibre Metadata Only'." +msgstr "Aktualisiert das Calibre-Buch-Cover des EPUB´s, wenn die Metadaten aktualisiert werden (nur EPUB).\nBei \"Nur Calibre-Medaten akualisieren\" werden keine neuen Bilder gesucht." + +#: config.py:394 +msgid "Keep Existing Tags when Updating Metadata?" +msgstr "Vorhandene Schlagworte behalten, wenn die Metadaten aktualisiert werden?" + +#: config.py:395 +msgid "" +"Existing tags will be kept and any new tags added.\n" +"%(cmplt)s and %(inprog)s tags will be still be updated, if known.\n" +"%(lul)s tags will be updated if %(lus)s in %(is)s.\n" +"(If Tags is set to 'New Only' in the Standard Columns tab, this has no effect.)" +msgstr "Vorhanden Schlagworte werden beibehalten und alle neuen hinzugefügt.\n%(cmplt)s (fertiggestellt) und %(inprog)s (in Arbeit) werden trotzdem aktualisiert, wenn bekannt.\n%(lul)s (zuletzt aktualisiert) wird aktualisiert, wenn %(lus)s in %(is)s enthalten ist.\n(Wenn die Spalte mit \"Nur neue\" in den benutzerdefinierten Spalten gesetzt sind, hat dies keinen Effekt.)" + +#: config.py:399 +msgid "Force Author into Author Sort?" +msgstr "Autor in Autoren-Sortierung übernehmen?" + +#: config.py:400 +msgid "" +"If checked, the author(s) as given will be used for the Author Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'Bob Smith' sort as 'Smith, Bob', etc." +msgstr "Wenn markiert, wird der Autor (die Autoren) wie vorgegeben auch in die Autoren-Sortierung übernommen.\nWenn nicht markiert, wird Calibre den eingebauten Algorithmus verwenden, was 'Bob Smith' in 'Smith, Bob' usw. umwandelt." + +#: config.py:404 +msgid "Force Title into Title Sort?" +msgstr "Titel in Titel-Sortierung übernehmen?" + +#: config.py:405 +msgid "" +"If checked, the title as given will be used for the Title Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'The Title' sort as 'Title, The', etc." +msgstr "Wenn markiert, wird der Titel wie vorgegeben auch in die Titel-Sortierung übernommen.\n\nWenn nicht markiert, wird Calibre den eingebauten Algorithmus verwenden, was 'Der Titel' in 'Titel, Der' usw. umwandelt." + +#: config.py:409 +msgid "Check for existing Series Anthology books?" +msgstr "Auf vorhandene Serien-Sammelband-Bücher prüfen?" + +#: config.py:410 +msgid "" +"Check for existings Series Anthology books using each new story's series URL before downloading.\n" +"Offer to skip downloading if a Series Anthology is found." +msgstr "Unter Verwendung jeder neuen Stories-Serien-URL vor dem Herunterladen auf vorhandene Serien-Sammelband-Bücher prüfen.\nVorschlag, das Herunterladen zu überspringen, wenn ein Serien-Sammelband gefunden wird." + +#: config.py:414 +msgid "Check for changed Story URL?" +msgstr "Auf geänderte Story-URL prüfen?" + +#: config.py:415 +msgid "" +"Warn you if an update will change the URL of an existing book.\n" +"fanfiction.net URLs will change from http to https silently." +msgstr "Sie werden gewarnt, wenn eine Aktualisierung die URL eines vorhandenen Buches ändern wird.\n\nfanfiction.net URL´s werden von http auf https ohne Hinweis geändert." + +#: config.py:419 +msgid "Search EPUB text for Story URL?" +msgstr "Durchsuche den EPUB-Text nach einer Story-URL?" + +#: config.py:420 +msgid "" +"Look for first valid story URL inside EPUB text if not found in metadata.\n" +"Somewhat risky, could find wrong URL depending on EPUB content.\n" +"Also finds and corrects bad ffnet URLs from ficsaver.com files." +msgstr "Wenn in den Metadaten keine Story-URL gefunden wird, wird nach der ersten gültigen gesucht.\n\nEtwas riskant, könnte - abhängig vom EPUB-Inhalt - die falsche URL finden.\n\nFindet und korrigiert ebenfalls ungeeignete ffnet URL´s von ficsaver.com-Datein." + +#: config.py:424 +msgid "Mark added/updated books when finished?" +msgstr "Hinzugefügte/aktualisierte Bücher markieren, wenn fertiggestellt?" + +#: config.py:425 +msgid "" +"Mark added/updated books when finished. Use with option below.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "Hinzugefügte/aktualisierte Bücher markieren, wenn fertiggestellt. Mit nachfolgender Option nutzen.\nEs kann ebenso manuell für 'marked:ffdl_success' (als \"erfolgreich\" markierte) gesucht werden.\n\nmarked:ffdl_failed' (als \"fehlgeschlagen\" markierte) ist ebenfalls verfügbar oder die Suche 'marked:ffdl' für beides." + +#: config.py:429 +msgid "Show Marked books when finished?" +msgstr "Hinzugefügte/aktualisierte Bücher anzeigen, wenn fertiggestellt?" + +#: config.py:430 +msgid "" +"Show Marked added/updated books only when finished.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "Hinzugefügte/aktualisierte Bücher nur anzeigen, wenn erledigt.\nEs kann ebenso manuell für 'marked:ffdl_success' (als \"erfolgreich\" markierte) gesucht werden.\nmarked:ffdl_failed' (als \"fehlgeschlagen\" markierte) ist ebenfalls verfügbar oder die Suche 'marked:ffdl' für beides." + +#: config.py:434 +msgid "Automatically Convert new/update books?" +msgstr "Neue/aktualisierte Bücher automatisch konvertieren?" + +#: config.py:435 +msgid "" +"Automatically call calibre's Convert for new/update books.\n" +"Converts to the current output format as chosen in calibre's\n" +"Preferences->Behavior settings." +msgstr "Automatisches Aufrufen von Calibre´s Konvertierung von neuen/aktualisierten Büchern.\nWandelt in das bevorzugte Ausgabeformat um, das aktuell in den Calibre Einstellungen-->Verhalten gewählt ist." + +#: config.py:439 +msgid "GUI Options" +msgstr "GUI Optionen:" + +#: config.py:443 +msgid "Take URLs from Clipboard?" +msgstr "URL´s aus der Zwischenablage nehmen?" + +#: config.py:444 +msgid "Prefill URLs from valid URLs in Clipboard when Adding New." +msgstr "Es werden automatisch die gültigen URL´s aus der Zwischenablage eingefügt, bei \"Neu hinzufügen\"." + +#: config.py:448 +msgid "Default to Update when books selected?" +msgstr "Standardmäßig aktualisieren, wenn Bücher ausgewählt sind?" + +#: config.py:449 +msgid "" +"The top FanFictionDownLoader plugin button will start Update if\n" +"books are selected. If unchecked, it will always bring up 'Add New'." +msgstr "Die oberste Taste im FFDL-Plugin wird, wenn Bücher ausgewählt sind, diese aktualisieren. Wenn nicht markiert, kommt immer zuerst \"Neu hinzufügen\"." + +#: config.py:453 +msgid "Keep 'Add New from URL(s)' dialog on top?" +msgstr "\"Neu von URL´s hinzufügen\" immer an erster Stelle behalten?" + +#: config.py:454 +msgid "" +"Instructs the OS and Window Manager to keep the 'Add New from URL(s)'\n" +"dialog on top of all other windows. Useful for dragging URLs onto it." +msgstr "Weist das Betriebssystem und den Fenstermanager an \"Neu von URL´s hinzufügen\" immer an erster Stelle zu halten. Nützlich um URL´s darauf zu ziehen." + +#: config.py:458 +msgid "Misc Options" +msgstr "Verschiedene Optionen" + +#: config.py:463 +msgid "Include images in EPUBs?" +msgstr "Bilder in EPUB´s einfügen?" + +#: config.py:464 +msgid "" +"Download and include images in EPUB stories. This is equivalent to " +"adding:%(imgset)s ...to the top of %(pini)s. Your settings in %(pini)s will" +" override this." +msgstr "Herunterladen und einfügen von Bildern im EPUB-Stories. Dies entspricht dem hinzufügen von: %(imgset)s... an den Anfang von %(pini)s. Ihre Einstellungen in %(pini)s werden dies überschreiben." + +#: config.py:468 +msgid "Inject calibre Series when none found?" +msgstr "Calibre-Serie einfügen, wenn keine gefunden wurde?" + +#: config.py:469 +msgid "" +"If no series is found, inject the calibre series (if there is one) so it " +"appears on the FFDL title page(not cover)." +msgstr "Wenn keine Serie gefunden wurde, die Calibre-Serie einfügen, damit sie auf der FFDL-Titelseite erscheint (nicht auf dem Cover)." + +#: config.py:473 +msgid "Reject List" +msgstr "Ablehnungsliste" + +#: config.py:477 +msgid "Edit Reject URL List" +msgstr "Ablehnungsliste bearbeiten" + +#: config.py:478 +msgid "Edit list of URLs FFDL will automatically Reject." +msgstr "Liste von URL´s bearbeiten, die FFDL automatisch ablehnen wird." + +#: config.py:482 config.py:556 +msgid "Add Reject URLs" +msgstr "Abzulehnende URL´s hinzufügen" + +#: config.py:483 +msgid "Add additional URLs to Reject as text." +msgstr "Zusätzliche URL´s als Text zur Ablehnungsliste hinzufügen." + +#: config.py:487 +msgid "Edit Reject Reasons List" +msgstr "Ablehnungsgründeliste bearbeiten" + +#: config.py:488 config.py:547 +msgid "Customize the Reasons presented when Rejecting URLs" +msgstr "Gründe für die Ablehnung von URL´s benutzerdefinieren" + +#: config.py:492 +msgid "Reject Without Confirmation?" +msgstr "Ohne Bestätigung zurückweisen?" + +#: config.py:493 +msgid "Always reject URLs on the Reject List without stopping and asking." +msgstr "Generell URL´s die auf Ablehnungsliste stehen zurückweisen, ohne nachzufragen." + +#: config.py:531 +msgid "Edit Reject URLs List" +msgstr "Ablehnungsliste bearbeiten" + +#: config.py:545 +msgid "Reject Reasons" +msgstr "Ablehnungsgründe" + +#: config.py:546 +msgid "Customize Reject List Reasons" +msgstr "Ablehnungsgründeliste benutzerdefinieren" + +#: config.py:554 +msgid "Reason why I rejected it" +msgstr "Grund der Ablehnung" + +#: config.py:554 +msgid "Title by Author" +msgstr "Titel von Autor" + +#: config.py:557 +msgid "" +"Add Reject URLs. Use: <b>http://...,note</b> or <b>http://...,title by " +"author - note</b><br>Invalid story URLs will be ignored." +msgstr "Abzulehnende URL´s hinzufügen. Verwende: <b>http://...,Notiz</b> oder <b>http://...,Titel von Autor - Notiz</b><br>\nUngültige URL´s werden ignoriert." + +#: config.py:558 +msgid "" +"One URL per line:\n" +"<b>http://...,note</b>\n" +"<b>http://...,title by author - note</b>" +msgstr "Eine URL pro Zeile\n<b>http://...,Notiz</b>\n<b>http://...,Titel von Autor - Notiz</b>" + +#: config.py:560 dialogs.py:1031 +msgid "Add this reason to all URLs added:" +msgstr "Bei allen oben angegebenen URL`s diesen Grund anfügen:" + +#: config.py:575 +msgid "" +"These settings provide more detailed control over what metadata will be " +"displayed inside the ebook as well as let you set %(isa)s and %(u)s/%(p)s " +"for different sites." +msgstr "Diese Einstellungen bieten eine detailliertere Kontrolle darüber, welche Metadaten im eBook angezeigt werden und es kann auch die Altersverifikation (\"%(isa)s\") und Benutzer/Passwort für verschiedene Seiten gesetzt werden." + +#: config.py:593 +msgid "View Defaults" +msgstr "Ansicht der Standardeinstellungen" + +#: config.py:594 +msgid "" +"View all of the plugin's configurable settings\n" +"and their default settings." +msgstr "Anzeige aller konfigurierbaren Einstellungen des Plugins und deren Standardeinstellung." + +#: config.py:612 +msgid "Plugin Defaults (%s) (Read-Only)" +msgstr "Plugin Standardeinstellungen (%s) (nur lesen)" + +#: config.py:613 config.py:619 +msgid "" +"These are all of the plugin's configurable options\n" +"and their default settings." +msgstr "Dies ist die Anzeige aller konfigurierbaren Einstellungen des Plugins und deren Standardeinstellung." + +#: config.py:614 +msgid "Plugin Defaults" +msgstr "Plugin Standardeinstellungen" + +#: config.py:630 dialogs.py:555 dialogs.py:658 +msgid "OK" +msgstr "OK" + +#: config.py:650 +msgid "" +"These settings provide integration with the %(rl)s Plugin. %(rl)s can " +"automatically send to devices and change custom columns. You have to create" +" and configure the lists in %(rl)s to be useful." +msgstr "Diese Einstellungen bieten die Integration mit dem %(rl)s Plugin. Die %(rl)s kann automatisch an das Gerät senden und benutzerdefinierte Spalten ändern. Sie müssen die Listen im %(rl)s Plugin erstellen und konfigurieren, um sie verwenden zu können." + +#: config.py:655 +msgid "Add new/updated stories to \"Send to Device\" Reading List(s)." +msgstr "Neue/aktualiserte Stories auf \"ans Gerät senden\" Leseliste(n) hinzufügen." + +#: config.py:656 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin." +msgstr "Automatisch neue/aktualisierte Stories zu diesen Listen im %(rl)s Plugin hinzufügen." + +#: config.py:661 +msgid "\"Send to Device\" Reading Lists" +msgstr "\"ans Gerät senden\" Leselisten" + +#: config.py:662 config.py:665 config.py:678 config.py:681 +msgid "" +"When enabled, new/updated stories will be automatically added to these " +"lists." +msgstr "Wenn markiert, werden neue/aktualisierte Stories automatisch zu diesen Listen hinzugefügt." + +#: config.py:671 +msgid "Add new/updated stories to \"To Read\" Reading List(s)." +msgstr "Neue/aktualiserte Stories auf \"zu lesen\" Leseliste(n) hinzufügen." + +#: config.py:672 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin.\n" +"Also offers menu option to remove stories from the \"To Read\" lists." +msgstr "Automatisch neue/aktualisierte Stories zu diesen Listen im %(rl)s Plugin hinzufügen.\nBietet auch die Menü-Option, Stories von den \"zu lesen\" Leseliste(n) zu entfernen." + +#: config.py:677 +msgid "\"To Read\" Reading Lists" +msgstr "\"zu lesen\" Leseliste(n)" + +#: config.py:687 +msgid "Add stories back to \"Send to Device\" Reading List(s) when marked \"Read\"." +msgstr "Stories wieder auf \"ans Gerät senden\" Leseliste(n) hinzufügen, wenn mit \"gelesen\" markiert." + +#: config.py:688 +msgid "" +"Menu option to remove from \"To Read\" lists will also add stories back to " +"\"Send to Device\" Reading List(s)" +msgstr "Die Menü-Option, Bücher von der \"zu lesen\" Liste zu entfernen, wird gleichzeitig die Stories zurück auf die \"ans Gerät senden\" Leseliste(n) fügen." + +#: config.py:710 +msgid "" +"The %(gc)s plugin can create cover images for books using various metadata " +"and configurations. If you have GC installed, FFDL can run GC on new " +"downloads and metadata updates. Pick a GC setting by site or Default." +msgstr "Das %(gc)s Plugin kann Cover-Bilder erzeugen, mit Hilfe verschiedener Metadaten und Konfigurationen. Wenn sie GC installiert haben, kann FFDL dies auf neuen Downloads und Metadaten-Aktualisierungen anwenden. Wählen Sie eine GC-Einstellung pro Seite oder als Standardeinstellung." + +#: config.py:728 config.py:732 config.py:745 +msgid "Default" +msgstr "Standardeinstellung" + +#: config.py:733 +msgid "" +"On Metadata update, run %(gc)s with this setting, if not selected for " +"specific site." +msgstr "Beim Aktualisieren der Metadaten soll %(gc)s mit dieser Einstellung verwendet werden, wenn für diese bestimmte Seite keine Auswahl festgelegt wurde." + +#: config.py:736 +msgid "On Metadata update, run %(gc)s with this setting for %(site)s stories." +msgstr "Beim Aktualisieren der Metadaten soll %(gc)s für die %(site)s Stories diese Einstellung verwenden." + +#: config.py:759 +msgid "Run %(gc)s Only on New Books" +msgstr "%(gc)s nur bei neuen Bücher anwenden" + +#: config.py:760 +msgid "Default is to run GC any time the calibre metadata is updated." +msgstr "Als Standardeinstellung GC jedesmal anwenden, wenn die Calibre-Metadaten aktualisiert werden." + +#: config.py:764 +msgid "Allow %(gcset)s from %(pini)s to override" +msgstr "Ermöglicht es %(gcset)s von %(pini)s diese Einstellungen zu überschreiben." + +#: config.py:765 +msgid "" +"The %(pini)s parameter %(gcset)s allows you to choose a GC setting based on " +"metadata rather than site, but it's much more complex.<br \\>%(gcset)s is " +"ignored when this is off." +msgstr "Die %(gcset)s Einstellungen in %(pini)s erlauben es ihnen eine GC-Einstellung wählen, die auf Metadaten statt der Web-Seite basiert, aber dies ist sehr viel komplexer.<br \\>%(gcset)s Einstellungen werden ignoriert, wenn dies deaktiviert ist." + +#: config.py:769 +msgid "Use calibre's Polish feature to inject/update the cover" +msgstr "Verwenden Sie Calibre´s \"Bücher Perfektionieren\" Funktion zum Einfügen/Aktualisieren des Coverbildes." + +#: config.py:770 +msgid "" +"Calibre's Polish feature will be used to inject or update the generated " +"cover into the ebook, EPUB only." +msgstr "Calibre´s \"Bücher Perfektionieren\" Funktion wird verwendet, um das erstellte Cover zu aktualisieren oder in das Buch einzufügen. Es werden nur AZW3 und EPUB unterstützt." + +#: config.py:784 +msgid "" +"These settings provide integration with the %(cp)s Plugin. %(cp)s can " +"automatically update custom columns with page, word and reading level " +"statistics. You have to create and configure the columns in %(cp)s first." +msgstr "Diese Einstellungen können mit dem %(cp)s-Plugin verwendet werden. %(cp)s kann automatisch benutzerdefinierte Spalte mit Seiten-, Wort- und Leselevel-Statistiken aktualisieren. Sie müssen diese Spalten zuerst in %(cp)s erstellen und gestalten." + +#: config.py:789 +msgid "" +"If any of the settings below are checked, when stories are added or updated," +" the %(cp)s Plugin will be called to update the checked statistics." +msgstr "Wenn Stories hinzugefügt oder aktualisiert werden, wird bei allen markierten Einstellungen das %(cp)s-Plugin die markierten Statistiken aktualisieren." + +#: config.py:795 +msgid "Which column and algorithm to use are configured in %(cp)s." +msgstr "Welche Spalte und Algorithmus verwendet werden, ist in %(cp)s festgelegt." + +#: config.py:803 +msgid "" +"Will overwrite word count from FFDL metadata if set to update the same " +"custom column." +msgstr "Überschreibt die Wortanzahl von den FFDL-Metadaten, wenn die Aktualisierung für die gleiche benutzerdefinierte Spalte gesetzt ist." + +#: config.py:834 +msgid "" +"These controls aren't plugin settings as such, but convenience buttons for " +"setting Keyboard shortcuts and getting all the FanFictionDownLoader " +"confirmation dialogs back again." +msgstr "Diese Werte sind nicht Plugin-Einstellungen als solche, sondern Komfort-Schaltflächen, um Tastenkombination festzulegen und alle FFDL-Bestätigungsdialoge wieder zurück zu setzen." + +#: config.py:839 +msgid "Keyboard shortcuts..." +msgstr "Tastenkombinationen..." + +#: config.py:840 +msgid "Edit the keyboard shortcuts associated with this plugin" +msgstr "Bearbeiten Sie die Tastenkombinationen, die mit diesem Plugin verbunden sind." + +#: config.py:844 +msgid "Reset disabled &confirmation dialogs" +msgstr "Setzt alle deaktivierten Bestätigungsdialoge zurück." + +#: config.py:845 +msgid "Reset all show me again dialogs for the FanFictionDownLoader plugin" +msgstr "Setzt alle Dialoge, bei denen der Haken für \"Diese Meldung nicht wieder anzeigen\" gesetzt wurde, wieder auf den Standard zurück." + +#: config.py:849 +msgid "&View library preferences..." +msgstr "Anzeige der Bibliotheks-Einstellungen" + +#: config.py:850 +msgid "View data stored in the library database for this plugin" +msgstr "In der Bibliotheksdatenbank für dieses Plugin gespeicherten Daten anzeigen" + +#: config.py:861 +msgid "Done" +msgstr "Fertig" + +#: config.py:862 +msgid "Confirmation dialogs have all been reset" +msgstr "Die Bestätigungsdialoge wurden alle zurückgesetzt" + +#: config.py:910 +msgid "Category" +msgstr "Kategorie" + +#: config.py:911 +msgid "Genre" +msgstr "Genre" + +#: config.py:912 +msgid "Language" +msgstr "Sprache" + +#: config.py:913 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Status" +msgstr "Status" + +#: config.py:914 +msgid "Status:%(cmplt)s" +msgstr "Status: fertiggestellt" + +#: config.py:915 +msgid "Status:%(inprog)s" +msgstr "Status: in Arbeit" + +#: config.py:916 config.py:1050 +msgid "Series" +msgstr "Serie" + +#: config.py:917 +msgid "Characters" +msgstr "Charaktere" + +#: config.py:918 +msgid "Relationships" +msgstr "Beziehungen" + +#: config.py:919 +msgid "Published" +msgstr "Veröffentlicht" + +#: config.py:920 ffdl_plugin.py:1403 ffdl_plugin.py:1422 +msgid "Updated" +msgstr "Aktualisierung" + +#: config.py:921 +msgid "Created" +msgstr "Erstellt" + +#: config.py:922 +msgid "Rating" +msgstr "Wertung" + +#: config.py:923 +msgid "Warnings" +msgstr "Warnungen" + +#: config.py:924 +msgid "Chapters" +msgstr "Kapitel" + +#: config.py:925 +msgid "Words" +msgstr "Worte" + +#: config.py:926 +msgid "Site" +msgstr "Seite" + +#: config.py:927 +msgid "Story ID" +msgstr "Story ID" + +#: config.py:928 +msgid "Author ID" +msgstr "Autor ID" + +#: config.py:929 +msgid "Extra Tags" +msgstr "zusätzliche Schlagworte" + +#: config.py:930 config.py:1042 dialogs.py:817 dialogs.py:913 +#: ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Title" +msgstr "Titel" + +#: config.py:931 +msgid "Story URL" +msgstr "Story URL" + +#: config.py:932 +msgid "Description" +msgstr "Beschreibung" + +#: config.py:933 dialogs.py:817 dialogs.py:913 ffdl_plugin.py:1126 +#: ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Author" +msgstr "Autor" + +#: config.py:934 +msgid "Author URL" +msgstr "Autor URL" + +#: config.py:935 +msgid "File Format" +msgstr "Datei-Format" + +#: config.py:936 +msgid "File Extension" +msgstr "Dateierweiterung" + +#: config.py:937 +msgid "Site Abbrev" +msgstr "Seiten-Kürzel" + +#: config.py:938 +msgid "FFDL Version" +msgstr "FFDL-Version" + +#: config.py:953 +msgid "" +"If you have custom columns defined, they will be listed below. Choose a " +"metadata value type to fill your columns automatically." +msgstr "Wenn Sie benutzerdefinierte Spalten definiert haben, werden sie unten aufgeführt werden. Wählen Sie einen Metadatenwert-Typ um Spalten automatisch zu füllen." + +#: config.py:978 +msgid "Update this %s column(%s) with..." +msgstr "Aktualisiere diese %s Spalte (%s) mit ..." + +#: config.py:988 +msgid "Values that aren't valid for this enumeration column will be ignored." +msgstr "Werte, die nicht für diese Aufzählungs-Spalte gültig sind, werden ignoriert." + +#: config.py:988 config.py:990 +msgid "Metadata values valid for this type of column." +msgstr "Metadaten-Werte, die für diese Art der Spalte gültig sind" + +#: config.py:993 config.py:1069 +msgid "New Only" +msgstr "Nur neue" + +#: config.py:994 +msgid "" +"Write to %s(%s) only for new\n" +"books, not updates to existing books." +msgstr "% s (% s) werden nur für neue Bücher gefüllt, nicht bei Aktualisierungen vorhandener Bücher." + +#: config.py:1005 +msgid "Allow %(ccset)s from %(pini)s to override" +msgstr "Erlaubt es %(ccset)s von %(pini)s dies zu überschreiben" + +#: config.py:1006 +msgid "" +"The %(pini)s parameter %(ccset)s allows you to set custom columns to site " +"specific values that aren't common to all sites.<br />%(ccset)s is ignored " +"when this is off." +msgstr "Die %(pini)s-Parameter %(ccset)s erlaubt es ihnen, benutzerdefinierte Spalten festzulegen, die mit seitenspezifischen Werten gefüllt werden, die nicht auf allen Web-Seiten gleich sind.<br />%(ccset)s wird ignoriert, wenn dies deaktiviert ist." + +#: config.py:1011 +msgid "Special column:" +msgstr "Sonder-Spalte:" + +#: config.py:1016 +msgid "Update/Overwrite Error Column:" +msgstr "Aktualisieren/Überschreiben der Fehlerspalte:" + +#: config.py:1017 +msgid "" +"When an update or overwrite of an existing story fails, record the reason in this column.\n" +"(Text and Long Text columns only.)" +msgstr "Wenn eine Aktualisierung oder Überschreibung einer existierenden Story fehlschlägt, erfasse den Grund in dieser Spalte.\n(Nur Text-und Langtext-Spalten.)" + +#: config.py:1043 +msgid "Author(s)" +msgstr "Autor(en)" + +#: config.py:1044 +msgid "Publisher" +msgstr "Herausgeber" + +#: config.py:1045 +msgid "Tags" +msgstr "Schlagworte" + +#: config.py:1046 +msgid "Languages" +msgstr "Sprachen" + +#: config.py:1047 +msgid "Published Date" +msgstr "Veröffentlichungsdatum" + +#: config.py:1048 +msgid "Date" +msgstr "Datum" + +#: config.py:1049 +msgid "Comments" +msgstr "Kommentare" + +#: config.py:1051 +msgid "Ids(url id only)" +msgstr "Ids(nur url id)" + +#: config.py:1056 +msgid "" +"The standard calibre metadata columns are listed below. You may choose " +"whether FFDL will fill each column automatically on updates or only for new " +"books." +msgstr "Die Standard-Calibre-Metadatenspalten sind unten aufgeführt. Sie können wählen, ob FFDL jede Spalte automatisch bei Aktualisierungen oder nur bei neuen Büchern füllen soll." + +#: config.py:1070 +msgid "" +"Write to %s only for new\n" +"books, not updates to existing books." +msgstr "Fülle %s nur bei neuen Bücher, nicht bei Aktualisierungen vorhandener Bücher." + +#: dialogs.py:69 +msgid "Skip" +msgstr "Überspringen" + +#: dialogs.py:70 +msgid "Add New Book" +msgstr "Neues Buch hinzufügen" + +#: dialogs.py:71 +msgid "Update EPUB if New Chapters" +msgstr "EPUB aktualisieren, wenn neue Kapitel vorhanden sind" + +#: dialogs.py:72 +msgid "Update EPUB Always" +msgstr "EPUB immer aktualisieren" + +#: dialogs.py:73 +msgid "Overwrite if Newer" +msgstr "Überschreiben, wenn neuer" + +#: dialogs.py:74 +msgid "Overwrite Always" +msgstr "Immer überschreiben" + +#: dialogs.py:75 +msgid "Update Calibre Metadata Only" +msgstr "Nur Calibre-Metadaten aktualisieren" + +#: dialogs.py:252 ffdl_plugin.py:89 +msgid "FanFictionDownLoader" +msgstr "FanFictionDownLoader" + +#: dialogs.py:269 dialogs.py:716 +msgid "Show Download Options" +msgstr "Optionen zum Herunterladen anzeigen" + +#: dialogs.py:288 dialogs.py:733 +msgid "Output &Format:" +msgstr "Ausgabe-Format:" + +#: dialogs.py:296 dialogs.py:741 +msgid "" +"Choose output format to create. May set default from plugin configuration." +msgstr "Wählen Sie das zu erstellende Ausgabeformat. Kann als Voreinstellung in der Plugin-Konfiguration gesetzt werden." + +#: dialogs.py:324 dialogs.py:758 +msgid "Update Calibre &Metadata?" +msgstr "Calibre-Metadaten aktualisieren?" + +#: dialogs.py:325 dialogs.py:759 +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 " für vorhandene Stories in Calibre von der Web-Seite aktualisieren?\n(Gesetzte Spalten für \"Nur neue\" in der Spalte Registerkarte wird nur für neue Bücher berücksichtigt.)" + +#: dialogs.py:331 dialogs.py:763 +msgid "Update EPUB Cover?" +msgstr "EPUB Cover aktualisieren?" + +#: dialogs.py:332 dialogs.py:764 +msgid "" +"Update book cover image from site or defaults (if found) <i>inside</i> the " +"EPUB when EPUB is updated." +msgstr "Aktualisiert das Buch-Cover-Image von der Seite oder den Standardeinstellungen (wenn vorhanden) <i>im</i> EPUB, wenn das EPUB aktualisiert wird." + +#: dialogs.py:379 +msgid "Story URL(s) for anthology, one per line:" +msgstr "Story URL(´s) für Sammelbände, eine pro Zeile:" + +#: dialogs.py:380 +msgid "" +"URLs for stories to include in the anthology, one per line.\n" +"Will take URLs from clipboard, but only valid URLs." +msgstr "URL´s für Stories, die im Sammelband enthalten sein sollen, eine pro Zeile.\nURL´s werden aus der Zwischenablage genommen, aber nur gültige URL´s." + +#: dialogs.py:381 +msgid "If Story Already Exists in Anthology?" +msgstr "Wenn die Story bereits in einem Sammelband enthalten ist?" + +#: dialogs.py:382 +msgid "" +"What to do if there's already an existing story with the same URL in the " +"anthology." +msgstr "Was soll geschehen, wenn es bereits eine vorhandene Story mit der gleichen URL in dem Sammelband gibt." + +#: dialogs.py:391 +msgid "Story URL(s), one per line:" +msgstr "Story URL(´s), eine pro Zeile:" + +#: dialogs.py:392 +msgid "" +"URLs for stories, one per line.\n" +"Will take URLs from clipboard, but only valid URLs.\n" +"Add [1,5] after the URL to limit the download to chapters 1-5." +msgstr "URL´s für Stories, eine pro Zeile.\nURL´s werden aus der Zwischenablage genommen, aber nur gültige URL´s.\nFügen Sie [1,5] nach der URL hinzu, um nur die Kapitel 1-5 herunterzuladen." + +#: dialogs.py:393 +msgid "If Story Already Exists?" +msgstr "Wenn die Story bereits vorhanden ist?" + +#: dialogs.py:394 +msgid "" +"What to do if there's already an existing story with the same URL or title " +"and author." +msgstr "Was soll geschehen, wenn es bereits eine vorhandene Story mit der gleichen URL oder Titel und Autor gibt." + +#: dialogs.py:494 +msgid "For Individual Books" +msgstr "Für einzelne Bücher" + +#: dialogs.py:495 +msgid "Get URLs and go to dialog for individual story downloads." +msgstr "URL´s holen und zum Download-Dialog für einzelne Stories gehen." + +#: dialogs.py:499 +msgid "For Anthology Epub" +msgstr "Für Sammelband-EPUB" + +#: dialogs.py:500 +msgid "" +"Get URLs and go to dialog for Anthology download.\n" +"Requires %s plugin." +msgstr "URL´s holen und zum Download-Dialog für Sammelbände gehen.\nErfordert %s Plugin." + +#: dialogs.py:505 dialogs.py:559 dialogs.py:586 +msgid "Cancel" +msgstr "Abbrechen" + +#: dialogs.py:537 +msgid "Password" +msgstr "Passwort" + +#: dialogs.py:538 +msgid "Author requires a password for this story(%s)." +msgstr "Der Autor benötigt ein Passwort für diese Story(%s)." + +#: dialogs.py:543 +msgid "User/Password" +msgstr "Benutzer/Passwort" + +#: dialogs.py:544 +msgid "%s requires you to login to download this story." +msgstr "%s erfordert, dass sie sich einloggen, um diese Geschichte herunterzuladen." + +#: dialogs.py:546 +msgid "User:" +msgstr "Benutzer:" + +#: dialogs.py:550 +msgid "Password:" +msgstr "Paswort:" + +#: dialogs.py:581 +msgid "Fetching metadata for stories..." +msgstr "Metadaten für folgende Stories abrufen..." + +#: dialogs.py:582 +msgid "Downloading metadata for stories" +msgstr "Metadaten für folgende Stories herunterladen" + +#: dialogs.py:583 +msgid "Fetched metadata for" +msgstr "Metadaten abgerufen für" + +#: dialogs.py:653 ffdl_plugin.py:325 +msgid "About FanFictionDownLoader" +msgstr "Über FanFictionDownLoader" + +#: dialogs.py:707 +msgid "Remove selected books from the list" +msgstr "Ausgewählte Bücher von der List löschen" + +#: dialogs.py:746 +msgid "Update Mode:" +msgstr "Aktualisierungsmodus:" + +#: dialogs.py:749 +msgid "" +"What sort of update to perform. May set default from plugin configuration." +msgstr "Welche Art von Aktualisierung ausgeführt werden soll. Eine Standardeinstellung kann in den Plugin Konfigurationen festgelegt werden." + +#: dialogs.py:817 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Comment" +msgstr "Kommentar" + +#: dialogs.py:885 +msgid "Are you sure you want to remove this book from the list?" +msgstr "Sind sie sicher, dass sie dieses Buch von der Liste löschen wollen?" + +#: dialogs.py:887 +msgid "Are you sure you want to remove the selected %d books from the list?" +msgstr "Sind sie sicher, dass sie die ausgewählten Bücher von der Liste löschen wollen?" + +#: dialogs.py:913 +msgid "Note" +msgstr "Notiz" + +#: dialogs.py:955 +msgid "Select or Edit Reject Note." +msgstr "Ablehnungsnotiz auswählen oder bearbeiten." + +#: dialogs.py:963 +msgid "Are you sure you want to remove this URL from the list?" +msgstr "Sind sie sicher, dass sie diese URL von der Liste löschen möchten?" + +#: dialogs.py:965 +msgid "Are you sure you want to remove the %d selected URLs from the list?" +msgstr "Sind sie sicher, dass sie die ausgewählten URL´s von der Liste löschen möchten?" + +#: dialogs.py:983 +msgid "List of Books to Reject" +msgstr "Liste der Bücher zur Ablehnung" + +#: dialogs.py:996 +msgid "" +"FFDL will remember these URLs and display the note and offer to reject them " +"if you try to download them again later." +msgstr "FFDL merkt sich diese URL´s und gibt die Notiz wieder und bietet an abzulehnen, wenn sie nochmal versuchen, diese herunterzuladen." + +#: dialogs.py:1010 +msgid "Remove selected URL(s) from the list" +msgstr "Ausgewählte URL(´s) von der Liste löschen" + +#: dialogs.py:1028 dialogs.py:1032 +msgid "This will be added to whatever note you've set for each URL above." +msgstr "Dies wird für jede der oben angegebenen URL´s zusätzlich an die Notiz angefügt, die sie oben angegeben haben." + +#: dialogs.py:1041 +msgid "Delete Books (including books without FanFiction URLs)?" +msgstr "Bücher löschen (inklusive Bücher ohne FanFiction-URL´s)?" + +#: dialogs.py:1042 +msgid "Delete the selected books after adding them to the Rejected URLs list." +msgstr "Die ausgewählten Bücher werden zur URL-Ablehnungsliste hinzugefügt und danach gelöscht." + +#: ffdl_plugin.py:90 +msgid "Download FanFiction stories from various web sites" +msgstr "FanFiction-Stories von verschiedenen Web-Seiten herunterladen" + +#: ffdl_plugin.py:120 +msgid "FanFictionDL" +msgstr "FanFictionDL" + +#: ffdl_plugin.py:243 +msgid "&Add New from URL(s)" +msgstr "Neu von URL hinzufügen" + +#: ffdl_plugin.py:245 +msgid "Add New FanFiction Book(s) from URL(s)" +msgstr "Neue FanFiction-Bücher von URL´s herunterladen" + +#: ffdl_plugin.py:248 +msgid "&Update Existing FanFiction Book(s)" +msgstr "Markierte FanFiction-Bücher aktualisieren" + +#: ffdl_plugin.py:254 +msgid "Get Story URLs to Download from Web Page" +msgstr "Story-URL´s von einer Web-Seite erfassen" + +#: ffdl_plugin.py:258 +msgid "&Make Anthology Epub Manually from URL(s)" +msgstr "Sammelband-EPUB manuell aus URL(´s) erstellen" + +#: ffdl_plugin.py:260 +msgid "Make FanFiction Anthology Epub Manually from URL(s)" +msgstr "FanFiction-Sammelband-EPUB manuell aus URL(´s) erstellen" + +#: ffdl_plugin.py:263 +msgid "&Update Anthology Epub" +msgstr "Sammelband-EPUB aktualisieren" + +#: ffdl_plugin.py:265 +msgid "Update FanFiction Anthology Epub" +msgstr "FanFicition-Sammelband-EPUB aktualisieren" + +#: ffdl_plugin.py:273 +msgid "Add to \"To Read\" and \"Send to Device\" Lists" +msgstr "Zu \"zu lesen\" und \"ans Gerät senden\" Liste hinzufügen" + +#: ffdl_plugin.py:275 +msgid "Remove from \"To Read\" and add to \"Send to Device\" Lists" +msgstr "Von \"zu lesen\" entfernen und zu \"ans Gerät senden\" Liste hinzufügen" + +#: ffdl_plugin.py:277 ffdl_plugin.py:282 +msgid "Remove from \"To Read\" Lists" +msgstr "Von \"zu lesen\" Liste entfernen" + +#: ffdl_plugin.py:279 +msgid "Add Selected to \"Send to Device\" Lists" +msgstr "Zu \"ans Gerät senden\" Liste hinzufügen" + +#: ffdl_plugin.py:281 +msgid "Add to \"To Read\" Lists" +msgstr "Zu \"zu lesen\" Liste hinzufügen" + +#: ffdl_plugin.py:297 +msgid "Get URLs from Selected Books" +msgstr "Die URL´s der ausgewählten Bücher holen" + +#: ffdl_plugin.py:303 ffdl_plugin.py:396 +msgid "Get Story URLs from Web Page" +msgstr "Story-URL´s von der Web-Seite holen" + +#: ffdl_plugin.py:308 +msgid "Reject Selected Books" +msgstr "Ausgewählte Bücher zurückweisen" + +#: ffdl_plugin.py:316 +msgid "&Configure Plugin" +msgstr "Plugin konfigurieren" + +#: ffdl_plugin.py:319 +msgid "Configure FanFictionDownLoader" +msgstr "FanFictionDownLoader konfigurieren" + +#: ffdl_plugin.py:322 +msgid "About Plugin" +msgstr "Über das Plugin" + +#: ffdl_plugin.py:379 +msgid "Cannot Update Reading Lists from Device View" +msgstr "Leseliste kann vom Gerät nicht aktualisiert werden" + +#: ffdl_plugin.py:383 +msgid "No Selected Books to Update Reading Lists" +msgstr "Mit den ausgewählten Bücher konnten keine Leselisten aktualisiert werden." + +#: ffdl_plugin.py:407 ffdl_plugin.py:459 +msgid "List of Story URLs" +msgstr "Liste der Story-URL´s" + +#: ffdl_plugin.py:408 +msgid "No Valid Story URLs found on given page." +msgstr "Auf der angegebene Seite wurde keine gültige URL gefunden." + +#: ffdl_plugin.py:423 +msgid "No Selected Books to Get URLs From" +msgstr "Es wurden keine Bücher ausgewählt um eine URL zu holen" + +#: ffdl_plugin.py:441 +msgid "Collecting URLs for stories..." +msgstr "Stories für ... werden gesammelt" + +#: ffdl_plugin.py:442 +msgid "Get URLs for stories" +msgstr "URL für Stories holen" + +#: ffdl_plugin.py:443 ffdl_plugin.py:490 ffdl_plugin.py:677 +msgid "URL retrieved" +msgstr "URL abgerufen" + +#: ffdl_plugin.py:463 +msgid "List of URLs" +msgstr "Liste der URL´s" + +#: ffdl_plugin.py:464 +msgid "No Story URLs found in selected books." +msgstr "In den ausgewählten Büchern wurden keine Story-URL´s gefunden." + +#: ffdl_plugin.py:480 +msgid "No Selected Books have URLs to Reject" +msgstr "Keine der gewählten Bücher haben URL´s zum Ablehnen" + +#: ffdl_plugin.py:488 +msgid "Collecting URLs for Reject List..." +msgstr "URL´s für die Ablehnungsliste werden gesammelt..." + +#: ffdl_plugin.py:489 +msgid "Get URLs for Reject List" +msgstr "URL´s für die Ablehnungsliste holen" + +#: ffdl_plugin.py:524 +msgid "Proceed to Remove?" +msgstr "Fortfahren mit der Entfernung?" + +#: ffdl_plugin.py:524 +msgid "Rejecting FFDL URLs: None of the books selected have FanFiction URLs." +msgstr "Ablehnung der FFDL URL´s: Keines der ausgewählten Bücher hat eine FanFiction-URL." + +#: ffdl_plugin.py:546 +msgid "Cannot Make Anthologys without %s" +msgstr "Sammelbände können nicht ohne %s erstellt werden." + +#: ffdl_plugin.py:550 ffdl_plugin.py:654 +msgid "Cannot Update Books from Device View" +msgstr "Bücher können nicht von der Geräte-Sicht aktualisiert werden" + +#: ffdl_plugin.py:554 +msgid "Can only update 1 anthology at a time" +msgstr "Kann nur einen Sammelband auf einmal aktualisieren" + +#: ffdl_plugin.py:563 +msgid "Can only Update Epub Anthologies" +msgstr "Es können nur EPUB-Anthologien aktualisiert werden" + +#: ffdl_plugin.py:581 ffdl_plugin.py:582 +msgid "Cannot Update Anthology" +msgstr "Sammelband kann nicht aktualisiert werden" + +#: ffdl_plugin.py:582 +msgid "" +"Book isn't an FFDL Anthology or contains book(s) without valid FFDL URLs." +msgstr "Das Buch ist kein FFDL-Sammelband oder enhält ein Buch (mehrere Bücher) ohne gültige FFDL-URL´s." + +#: ffdl_plugin.py:640 +msgid "" +"There are %d stories in the current anthology that are <b>not</b> going to " +"be kept if you go ahead." +msgstr "Es gibt %d Stories im aktuellen Sammelband, die <b>nicht</b> behalten werden, wenn sie fortfahren." + +#: ffdl_plugin.py:641 +msgid "Story URLs that will be removed:" +msgstr "Story URL´s, die entfernt werden:" + +#: ffdl_plugin.py:643 +msgid "Update anyway?" +msgstr "Trotzdem aktualisieren?" + +#: ffdl_plugin.py:644 +msgid "Stories Removed" +msgstr "Stories entfernt" + +#: ffdl_plugin.py:661 +msgid "No Selected Books to Update" +msgstr "Keines der gewählten Bücher wird aktualisiert" + +#: ffdl_plugin.py:675 +msgid "Collecting stories for update..." +msgstr "Stories für die Aktualisierung sammlen..." + +#: ffdl_plugin.py:676 +msgid "Get stories for updates" +msgstr "Stories für die Aktualisierung werden gesammelt..." + +#: ffdl_plugin.py:686 +msgid "Update Existing List" +msgstr "Vorhandene Liste aktualisieren" + +#: ffdl_plugin.py:738 +msgid "Started fetching metadata for %s stories." +msgstr "Hole die Metadaten für %s-Stories." + +#: ffdl_plugin.py:744 +msgid "No valid story URLs entered." +msgstr "Es wurde keine gültige URL eingegeben." + +#: ffdl_plugin.py:769 ffdl_plugin.py:775 +msgid "Reject URL?" +msgstr "URL ablehnen?" + +#: ffdl_plugin.py:776 ffdl_plugin.py:794 +msgid "<b>%s</b> is on your Reject URL list:" +msgstr "<b>%s</b> ist auf ihrer URL-Ablehnungsliste:" + +#: ffdl_plugin.py:778 +msgid "Click '<b>Yes</b>' to Reject." +msgstr "Klicken Sie '<b>Yes</b>' um abzulehnen." + +#: ffdl_plugin.py:779 ffdl_plugin.py:875 +msgid "Click '<b>No</b>' to download anyway." +msgstr "Klicken Sie '<b>No</b>' um trotzdem herunterzuladen." + +#: ffdl_plugin.py:781 +msgid "Story on Reject URLs list (%s)." +msgstr "Die Story ist auf der URL-Ablehnungsliste (%s)." + +#: ffdl_plugin.py:784 +msgid "Rejected" +msgstr "Abgelehnt" + +#: ffdl_plugin.py:787 +msgid "Remove Reject URL?" +msgstr "Entferne die abgelehnte URL?" + +#: ffdl_plugin.py:793 +msgid "Remove URL from Reject List?" +msgstr "Entferne die URL von der Ablehnungsliste?" + +#: ffdl_plugin.py:796 +msgid "Click '<b>Yes</b>' to remove it from the list," +msgstr "Klicken Sie '<b>Yes</b>' um es von der Liste zu entfernen," + +#: ffdl_plugin.py:797 +msgid "Click '<b>No</b>' to leave it on the list." +msgstr "Klicken Sie '<b>No</b>' um es auf der Liste zu lassen." + +#: ffdl_plugin.py:814 +msgid "Cannot update non-epub format." +msgstr "Nicht-EPUB-Format kann nicht aktualisiert werden." + +#: ffdl_plugin.py:851 +msgid "Are You an Adult?" +msgstr "Sind sie volljährig?" + +#: ffdl_plugin.py:852 +msgid "" +"%s requires that you be an adult. Please confirm you are an adult in your " +"locale:" +msgstr "%s erfordert, dass sie volljährig sind. Bitte bestätigen Sie, dass Sie ein Erwachsener in ihrem Land sind:" + +#: ffdl_plugin.py:866 +msgid "Skip Story?" +msgstr "Story überspringen?" + +#: ffdl_plugin.py:872 +msgid "Skip Anthology Story?" +msgstr "Sammelband-Story überspringen?" + +#: ffdl_plugin.py:873 +msgid "" +"\"<b>%s</b>\" is in series \"<b><a href=\"%s\">%s</a></b>\" that you have an" +" anthology book for." +msgstr "\"<b>%s</b>\" ist in der Serie \"<b><a href=\"%s\">%s</a></b>\" enthalten, die sie in einem Sammelband haben." + +#: ffdl_plugin.py:874 +msgid "Click '<b>Yes</b>' to Skip." +msgstr "Klicken Sie '<b>Yes</b>' um zu überspringen." + +#: ffdl_plugin.py:877 +msgid "Story in Series Anthology(%s)." +msgstr "Story in Serien-Sammelband (%s)." + +#: ffdl_plugin.py:882 +msgid "Skipped" +msgstr "Übersprungen" + +#: ffdl_plugin.py:910 +msgid "Add" +msgstr "Hinzufügen" + +#: ffdl_plugin.py:923 +msgid "Meta" +msgstr "Meta" + +#: ffdl_plugin.py:956 +msgid "Skipping duplicate story." +msgstr "Doppelte Story überspringen." + +#: ffdl_plugin.py:959 +msgid "" +"More than one identical book by Identifer URL or title/author(s)--can't tell" +" which book to update/overwrite." +msgstr "Mehr als ein identisches Buch mit der gleichen URL oder Titel/Autor(en) - es kann nicht festgestellt werden, welches Buch aktualisiert/überschrieben werden soll." + +#: ffdl_plugin.py:970 +msgid "Update" +msgstr "Aktualisieren" + +#: ffdl_plugin.py:978 ffdl_plugin.py:985 +msgid "Change Story URL?" +msgstr "Story-URL ändern?" + +#: ffdl_plugin.py:986 +msgid "" +"<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL:" +msgstr "<b>%s</b> von <b>%s</b> ist bereits in ihrer Bibliothek mit einer anderen URL:" + +#: ffdl_plugin.py:987 +msgid "In library: <a href=\"%(liburl)s\">%(liburl)s</a>" +msgstr "In der Bibliothek: <a href=\"%(liburl)s\">%(liburl)s</a>" + +#: ffdl_plugin.py:988 ffdl_plugin.py:1002 +msgid "New URL: <a href=\"%(newurl)s\">%(newurl)s</a>" +msgstr "Neue URL: <a href=\"%(newurl)s\">%(newurl)s</a>" + +#: ffdl_plugin.py:989 +msgid "Click '<b>Yes</b>' to update/overwrite book with new URL." +msgstr "Klicken Sie '<b>Yes</b>' um das Buch mit der neuen URL zu aktualisieren/überschreiben." + +#: ffdl_plugin.py:990 +msgid "Click '<b>No</b>' to skip updating/overwriting this book." +msgstr "Klicken Sie '<b>No</b>' um das Aktualisieren/Überschreiben dieses Buches abzubrechen." + +#: ffdl_plugin.py:992 ffdl_plugin.py:999 +msgid "Download as New Book?" +msgstr "Als neues Buch herunterladen?" + +#: ffdl_plugin.py:1000 +msgid "" +"<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL." +msgstr "<b>%s</b> von <b>%s</b> ist bereits in ihrer Bibliothek mit einer anderen URL:" + +#: ffdl_plugin.py:1001 +msgid "" +"You chose not to update the existing book. Do you want to add a new book " +"for this URL?" +msgstr "Sie haben sich entschieden, das vorhandene Buch nicht zu aktualisieren. Wollen Sie ein neues Buch mit diese URL hinzufügen?" + +#: ffdl_plugin.py:1003 +msgid "Click '<b>Yes</b>' to a new book with new URL." +msgstr "Klicken Sie '<b>Yes</b>' um eine neues Buch mit dieser neuen URL hinzuzufügen." + +#: ffdl_plugin.py:1004 +msgid "Click '<b>No</b>' to skip URL." +msgstr "Klicken Sie '<b>No</b>' um die URL überspringen." + +#: ffdl_plugin.py:1010 +msgid "Update declined by user due to differing story URL(%s)" +msgstr "Aktualisierung wurde vom Benutzer abgelehnt aufgrund unterschiedlicher Story-URL (%s)" + +#: ffdl_plugin.py:1013 +msgid "Different URL" +msgstr "Andere URL" + +#: ffdl_plugin.py:1018 +msgid "Metadata collected." +msgstr "Metadaten gesammelt." + +#: ffdl_plugin.py:1034 +msgid "Already contains %d chapters." +msgstr "Enthält bereits %d Kapitel." + +#: ffdl_plugin.py:1039 +msgid "" +"Existing epub contains %d chapters, web site only has %d. Use Overwrite to " +"force update." +msgstr "Das existierende EPUB hat %d Kapitel, die Web-Seite nur %d. Benutzen Sie überschreiben, um eine Aktualisierung zu erzwingen." + +#: ffdl_plugin.py:1041 +msgid "" +"FFDL doesn't recognize chapters in existing epub, epub is probably from a " +"different source. Use Overwrite to force update." +msgstr "FFDL kann im existierenden EPUT die Kapitel nicht erkennen, das EPUB ist vermutlich aus einer anderen Quelle. Benutzen Sie überschreiben, um eine Aktualisierung zu erzwingen." + +#: ffdl_plugin.py:1053 +msgid "Not Overwriting, web site is not newer." +msgstr "Keine Überschreibung, die Web-Seite ist nicht aktueller." + +#: ffdl_plugin.py:1122 +msgid "None of the <b>%d</b> URLs/stories given can be/need to be downloaded." +msgstr "Keine der angegebenen <b>%d</b> URL´s kann/muss heruntergeladen werden." + +#: ffdl_plugin.py:1123 ffdl_plugin.py:1286 ffdl_plugin.py:1316 +msgid "See log for details." +msgstr "Siehe Protokoll." + +#: ffdl_plugin.py:1124 +msgid "Proceed with updating your library(Error Column, if configured)?" +msgstr "Fortfahren mit der Aktualisierung ihrer Bibliothek (Fehler-Spalte, wenn konfiguriert)?" + +#: ffdl_plugin.py:1131 ffdl_plugin.py:1298 +msgid "Bad" +msgstr "Ungeeignet" + +#: ffdl_plugin.py:1139 +msgid "FFDL download ended" +msgstr "FFDL Herunterladen beendet" + +#: ffdl_plugin.py:1139 ffdl_plugin.py:1341 +msgid "FFDL log" +msgstr "FFDL Protokoll" + +#: ffdl_plugin.py:1147 +msgid "Download FanFiction Book" +msgstr "FanFiction-Buch herunterladen" + +#: ffdl_plugin.py:1154 +msgid "Starting %d FanFictionDownLoads" +msgstr "%d FanFictionDownLoads starten" + +#: ffdl_plugin.py:1184 +msgid "Story Details:" +msgstr "Story-Einzelheiten:" + +#: ffdl_plugin.py:1187 +msgid "Error Updating Metadata" +msgstr "Fehler beim Herunterladen der Metadaten" + +#: ffdl_plugin.py:1188 +msgid "" +"An error has occurred while FFDL was updating calibre's metadata for <a " +"href='%s'>%s</a>." +msgstr "Während FFDL die Calibre-Metadaten für <a href='%s'>%s</a> aktualisierte, trat ein Fehler auf." + +#: ffdl_plugin.py:1189 +msgid "The ebook has been updated, but the metadata has not." +msgstr "Das eBook wurde aktualisiert, aber die Metadaten nicht." + +#: ffdl_plugin.py:1241 +msgid "Finished Adding/Updating %d books." +msgstr "Hinzufügen/Aktualisierung von %d Büchern abgeschlossen." + +#: ffdl_plugin.py:1249 +msgid "Starting auto conversion of %d books." +msgstr "Starte automatische Konvertierung von %d Büchern." + +#: ffdl_plugin.py:1270 +msgid "No Good Stories for Anthology" +msgstr "Ungültige Story für einen Sammelband" + +#: ffdl_plugin.py:1271 +msgid "" +"No good stories/updates where downloaded, Anthology creation/update aborted." +msgstr "Ungültige Stories/Aktualisierungen wurden heruntergeladen, Sammelband-Erstellung/Aktualisierung wurde abgebrochen." + +#: ffdl_plugin.py:1276 ffdl_plugin.py:1315 +msgid "FFDL found <b>%s</b> good and <b>%s</b> bad updates." +msgstr "FFDL hat <b>%s</b> gute und <b>%s</b> ungeeignete Updates gefunden." + +#: ffdl_plugin.py:1283 +msgid "" +"Are you sure you want to continue with creating/updating this Anthology?" +msgstr "Sind sie sicher, dass sie fortfahren wollen, diesen Sammelband zu erstellen/aktualisieren?" + +#: ffdl_plugin.py:1284 +msgid "Any updates that failed will <b>not</b> be included in the Anthology." +msgstr "Jede Aktualisierung, die fehlgeschlagen ist, wird <b>nicht</b> in den Sammelband eingefügt." + +#: ffdl_plugin.py:1285 +msgid "However, if there's an older version, it will still be included." +msgstr "Allerdings, wenn es eine ältere Version gibt, wird es dennoch aufgenommen werden." + +#: ffdl_plugin.py:1288 +msgid "Proceed with updating this anthology and your library?" +msgstr "Mit der Aktualiserung dieses Sammelbandes und ihrer Bibliothek fortfahren?" + +#: ffdl_plugin.py:1296 +msgid "Good" +msgstr "Geeignet" + +#: ffdl_plugin.py:1317 +msgid "Proceed with updating your library?" +msgstr "Mit der Aktualisierung der Bibliothek fortfahren?" + +#: ffdl_plugin.py:1341 +msgid "FFDL download complete" +msgstr "FFDL Herunterladen abgeschlossen" + +#: ffdl_plugin.py:1354 +msgid "Merging %s books." +msgstr "Zusammenführung von %s Büchern." + +#: ffdl_plugin.py:1394 +msgid "FFDL Adding/Updating books." +msgstr "FFDL Hinzufügen/Aktualisieren der Bücher." + +#: ffdl_plugin.py:1401 +msgid "Updating calibre for FanFiction stories..." +msgstr "Calibre mit FanFiction-Stories aktualisieren..." + +#: ffdl_plugin.py:1402 +msgid "Update calibre for FanFiction stories" +msgstr "Calibre mit FanFiction-Stories aktualisieren." + +#: ffdl_plugin.py:1411 +msgid "Adding/Updating %s BAD books." +msgstr "Hinzufügen/aktualisieren %s ungeeigneter Bücher." + +#: ffdl_plugin.py:1420 +msgid "Updating calibre for BAD FanFiction stories..." +msgstr "Calibre mit ungeeigneten FanFiction-Stories aktualisieren..." + +#: ffdl_plugin.py:1421 +msgid "Update calibre for BAD FanFiction stories" +msgstr "Calibre mit ungeeigneten FanFiction-Stories aktualisieren." + +#: ffdl_plugin.py:1447 +msgid "Adding format to book failed for some reason..." +msgstr "Das Hinzufügen eines Formates zum Buch ist aus irgendeinem Grund fehlgeschlagen..." + +#: ffdl_plugin.py:1450 +msgid "Error" +msgstr "Fehler" + +#: ffdl_plugin.py:1723 +msgid "" +"You configured FanFictionDownLoader to automatically update Reading Lists, " +"but you don't have the %s plugin installed anymore?" +msgstr "Sie haben FFDL konfiguriert, die Leselisten automatisch zu aktualisieren, aber sie haben das %s-Plugin nicht mehr installiert?" + +#: ffdl_plugin.py:1735 +msgid "" +"You configured FanFictionDownLoader to automatically update \"To Read\" " +"Reading Lists, but you don't have any lists set?" +msgstr "Sie haben FFDL konfiguriert, um die \"zu lesen\" Leselisten automatisch zu aktualisieren, aber sie haben keinen Listen gesetzt?" + +#: ffdl_plugin.py:1745 ffdl_plugin.py:1763 +msgid "" +"You configured FanFictionDownLoader to automatically update Reading List " +"'%s', but you don't have a list of that name?" +msgstr "Sie haben FFDL konfiguriert, um die Leseliste '%s' automatisch zu aktualisieren, aber sie haben keine Liste dieses Namens?" + +#: ffdl_plugin.py:1751 +msgid "" +"You configured FanFictionDownLoader to automatically update \"Send to " +"Device\" Reading Lists, but you don't have any lists set?" +msgstr "Sie haben FFDL konfiguriert, um die \"ans Gerät senden\"-Leselisten automatisch zu aktualisieren, aber sie haben keinen Listen gesetzt?" + +#: ffdl_plugin.py:1871 +msgid "No story URL found." +msgstr "Keine URL wurde gefunden." + +#: ffdl_plugin.py:1874 +msgid "Not Found" +msgstr "Nicht gefunden" + +#: ffdl_plugin.py:1880 +msgid "URL is not a valid story URL." +msgstr "URL ist keine gültige Story-URL." + +#: ffdl_plugin.py:1883 +msgid "Bad URL" +msgstr "Bad URL" + +#: ffdl_plugin.py:2018 +msgid "Anthology containing:" +msgstr "Sammelband enthält:" + +#: ffdl_plugin.py:2019 +msgid "%s by %s" +msgstr "%s von %s" + +#: ffdl_plugin.py:2038 +msgid " Anthology" +msgstr "Sammelband" + +#: ffdl_plugin.py:2075 +msgid "(was set, removed for security)" +msgstr "(wurde eingestellt, aus Sicherheitsgründen entfernt)" diff --git a/calibre-plugin/translations/es.po b/calibre-plugin/translations/es.po new file mode 100644 index 00000000..0e8c0cc5 --- /dev/null +++ b/calibre-plugin/translations/es.po @@ -0,0 +1,1601 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# +# Translators: +# Adolfo Jayme Barrientos <fitoschido@ubuntu.com>, 2014 +# Jellby <jellby@yahoo.com>, 2014 +msgid "" +msgstr "" +"Project-Id-Version: calibre-plugins\n" +"POT-Creation-Date: 2014-07-14 10:52+Central Daylight Time\n" +"PO-Revision-Date: 2014-07-17 09:47+0000\n" +"Last-Translator: Kovid Goyal <kovid@kovidgoyal.net>\n" +"Language-Team: Spanish (http://www.transifex.com/projects/p/calibre-plugins/language/es/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: ENCODING\n" +"Generated-By: pygettext.py 1.5\n" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: __init__.py:42 +msgid "UI plugin to download FanFiction stories from various sites." +msgstr "Complemento de interfaz de usuario para descargar historias de «fanfiction» desde distintos sitios web." + +#: __init__.py:109 +msgid "" +"Path to the calibre library. Default is to use the path stored in the " +"settings." +msgstr "Ruta de acceso a la biblioteca de calibre. De manera predeterminada se utiliza la ruta definida en la configuración." + +#: config.py:161 +msgid "FAQs" +msgstr "Preguntas frecuentes" + +#: config.py:161 +msgid "List of Supported Sites" +msgstr "Lista de sitios compatibles" + +#: config.py:175 +msgid "Basic" +msgstr "Básico" + +#: config.py:196 +msgid "Standard Columns" +msgstr "Columnas estándar" + +#: config.py:199 +msgid "Custom Columns" +msgstr "Columnas personalizadas" + +#: config.py:202 +msgid "Other" +msgstr "Otros" + +#: config.py:323 +msgid "" +"These settings control the basic features of the plugin--downloading " +"FanFiction." +msgstr "Estas opciones controlan las funciones básicas del complemento, como descargar «fanfiction»." + +#: config.py:327 +msgid "Defaults Options on Download" +msgstr "Opciones predeterminadas al descargar" + +#: config.py:331 +msgid "" +"On each download, FFDL offers an option to select the output format. <br " +"/>This sets what that option will default to." +msgstr "En cada descarga, FFDL ofrece la opción de seleccionar el formato de salida.<br/>Esto establece el valor predeterminado para esta opción." + +#: config.py:333 +msgid "Default Output &Format:" +msgstr "&Formato de salida predeterminado:" + +#: config.py:348 +msgid "" +"On each download, FFDL offers an option of what happens if that story " +"already exists. <br />This sets what that option will default to." +msgstr "En cada descarga, FFDL ofrece la opción de elegir qué hacer si la historia ya existe.<br/>Esto establece el valor predeterminado para esta opción." + +#: config.py:350 +msgid "Default If Story Already Exists?" +msgstr "Acción predeterminada si la historia ya existe" + +#: config.py:364 +msgid "Default Update Calibre &Metadata?" +msgstr "Actualización predeterminada de metadatos" + +#: config.py:365 +msgid "" +"On each download, FFDL offers an option to update Calibre's metadata (title," +" author, URL, tags, custom columns, etc) from the web site. <br />This sets " +"whether that will default to on or off. <br />Columns set to 'New Only' in " +"the column tabs will only be set for new books." +msgstr "En cada descarga, FFDL ofrece la opción de actualizar los metadatos de calibre (título, autor, URL, etiquetas, columnas personalizadas, etc.) desde un sitio web.<br/>Esto establece si la opción está activada o desactivada de manera predeterminada.<br/>Las columnas establecidas en «Sólo nuevo» en la pestaña de columnas sólo se rellenan para los libros nuevos." + +#: config.py:369 +msgid "Default Update EPUB Cover when Updating EPUB?" +msgstr "Actualización predeterminada de portada de EPUB al actualizar EPUB" + +#: config.py:370 +msgid "" +"On each download, FFDL offers an option to update the book cover image " +"<i>inside</i> the EPUB from the web site when the EPUB is updated.<br />This" +" sets whether that will default to on or off." +msgstr "En cada descarga, FFDL ofrece la opción de actualizar la imagen de portada <i>del archivo</i> EPUB a partir del sitio web cuando se actualiza el archivo EPUB.<br/>Esto establece si la opción está activada o desactivada de manera predeterminada." + +#: config.py:374 +msgid "Smarten Punctuation (EPUB only)" +msgstr "Corregir puntuación (sólo EPUB)" + +#: config.py:375 +msgid "" +"Run Smarten Punctuation from Calibre's Polish Book feature on each EPUB " +"download and update." +msgstr "Ejecutar la corrección de puntuación de la función para pulir libros de calibre en cada descarga y actualización de archivos EPUB." + +#: config.py:380 +msgid "Updating Calibre Options" +msgstr "Actualizando opciones de calibre" + +#: config.py:384 +msgid "Delete other existing formats?" +msgstr "¿Borrar otros formatos existentes?" + +#: config.py:385 +msgid "" +"Check this to automatically delete all other ebook formats when updating an existing book.\n" +"Handy if you have both a Nook(epub) and Kindle(mobi), for example." +msgstr "Marque esta opción para borrar automáticamente todos los otros formatos al actualizar un libro existente.\nEs útil si tiene un Nook (epub) y un Kindle (mobi), por ejemplo." + +#: config.py:389 +msgid "Update Calibre Cover when Updating Metadata?" +msgstr "¿Actualizar la portada de calibre al actualizar metadatos?" + +#: config.py:390 +msgid "" +"Update calibre book cover image from EPUB when metadata is updated. (EPUB only.)\n" +"Doesn't go looking for new images on 'Update Calibre Metadata Only'." +msgstr "Actualizar la imagen de portada del libro desde el archivo EPUB al actualizar los metadatos. (Sólo EPUB).\nNo busca nuevas imágenes si se usa «Actualizar sólo los metadatos de calibre»." + +#: config.py:394 +msgid "Keep Existing Tags when Updating Metadata?" +msgstr "¿Mantener las etiquetas existentes al actualizar metadatos?" + +#: config.py:395 +msgid "" +"Existing tags will be kept and any new tags added.\n" +"%(cmplt)s and %(inprog)s tags will be still be updated, if known.\n" +"%(lul)s tags will be updated if %(lus)s in %(is)s.\n" +"(If Tags is set to 'New Only' in the Standard Columns tab, this has no effect.)" +msgstr "Las etiquetas existentes se mantendrán y se añadiran las etiquetas nuevas.\nLas etiquetas %(cmplt)s y %(inprog)s se actualizarán en todo caso, si se conocen.\nLas etiquetas %(lul)s se actualizarán si %(lus)s en %(is)s.\n(Si las etiquetas se establecen en «Sólo nuevo» en la pestaña de columnas estándar, esto no tiene efecto)." + +#: config.py:399 +msgid "Force Author into Author Sort?" +msgstr "¿Forzar autor en orden de autor?" + +#: config.py:400 +msgid "" +"If checked, the author(s) as given will be used for the Author Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'Bob Smith' sort as 'Smith, Bob', etc." +msgstr "Si se activa, el campo de autor(es), tal como esté dado, se usa para orden de autor también.\nSi no se activa, calibre aplicará su algoritmo predefinido, que hace que «Juan Pérez» se ordene como «Pérez, Juan», etc." + +#: config.py:404 +msgid "Force Title into Title Sort?" +msgstr "¿Forzar título en orden de título?" + +#: config.py:405 +msgid "" +"If checked, the title as given will be used for the Title Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'The Title' sort as 'Title, The', etc." +msgstr "Si se activa, el campo de título, tal como esté dado, se usa para orden de título también.\nSi no se activa, calibre aplicará su algoritmo predefinido, que hace que «El título» se ordene como «título, El», etc." + +#: config.py:409 +msgid "Check for existing Series Anthology books?" +msgstr "¿Comprobar si existen antologías de serie?" + +#: config.py:410 +msgid "" +"Check for existings Series Anthology books using each new story's series URL before downloading.\n" +"Offer to skip downloading if a Series Anthology is found." +msgstr "Comprobar si existen antologías de serie usando el URL de la serie de cada nueva historia antes de descargar.\nOfrece la posibilidad de no descargar si se encuentra una antología de serie." + +#: config.py:414 +msgid "Check for changed Story URL?" +msgstr "¿Comprobar cambio de URL de historia?" + +#: config.py:415 +msgid "" +"Warn you if an update will change the URL of an existing book.\n" +"fanfiction.net URLs will change from http to https silently." +msgstr "Avisar si una actualización cambiará el URL de un libro existente.\nLos URL de fanfiction.net cambiarán de http a https sin avisar." + +#: config.py:419 +msgid "Search EPUB text for Story URL?" +msgstr "¿Buscar el URL de historia en el texto del archivo EPUB?" + +#: config.py:420 +msgid "" +"Look for first valid story URL inside EPUB text if not found in metadata.\n" +"Somewhat risky, could find wrong URL depending on EPUB content.\n" +"Also finds and corrects bad ffnet URLs from ficsaver.com files." +msgstr "Buscar el primer URL de historia válido en el texto del archivo EPUB si no se encuentra en los metadatos.\nEsto es algo arriesgado, ya que según el contenido del archivo puede encontrarse un URL incorrecto.\nTambién encuentra y corrige URL de ffnet erróneos en archivos de ficsaver.com." + +#: config.py:424 +msgid "Mark added/updated books when finished?" +msgstr "¿Marcar los libros añadidos o actualizados al terminar?" + +#: config.py:425 +msgid "" +"Mark added/updated books when finished. Use with option below.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "Marcar los libros añadidos o actualizados al terminar. Usar con la siguiente opción.\nTambién puede buscar manualmente: «marked:ffdl_success» (éxito) o «marked:ffdl_failed» (fallo). «marked:ffdl» incluye ambos casos." + +#: config.py:429 +msgid "Show Marked books when finished?" +msgstr "¿Mostrar los libros marcados al terminar?" + +#: config.py:430 +msgid "" +"Show Marked added/updated books only when finished.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "Mostrar los libros añadidos o actualizados marcados únicamente al terminar.\nTambién puede buscar manualmente: «marked:ffdl_success» (éxito) o «marked:ffdl_failed» (fallo). «marked:ffdl» incluye ambos casos." + +#: config.py:434 +msgid "Automatically Convert new/update books?" +msgstr "¿Convertir automáticamente los libros nuevos o actualizados?" + +#: config.py:435 +msgid "" +"Automatically call calibre's Convert for new/update books.\n" +"Converts to the current output format as chosen in calibre's\n" +"Preferences->Behavior settings." +msgstr "Utilizar automáticamente la función de conversión de calibre para los libros nuevos o actualizados.\nConvertir al formato de salida actual, definido en Preferencias->Comportamiento" + +#: config.py:439 +msgid "GUI Options" +msgstr "Opciones de la interfaz gráfica" + +#: config.py:443 +msgid "Take URLs from Clipboard?" +msgstr "¿Tomar los URL del portapapeles?" + +#: config.py:444 +msgid "Prefill URLs from valid URLs in Clipboard when Adding New." +msgstr "Rellenar los URL con URL válidos del portapapeles al añadir nuevo." + +#: config.py:448 +msgid "Default to Update when books selected?" +msgstr "¿Actualizar de manera predeterminada al seleccionar libros?" + +#: config.py:449 +msgid "" +"The top FanFictionDownLoader plugin button will start Update if\n" +"books are selected. If unchecked, it will always bring up 'Add New'." +msgstr "El botón principal del complemento FanFictionDownLoader efectuará una actualización si hay libros seleccionados. Si se desactiva, siempre funcionará como «Añadir nuevo»." + +#: config.py:453 +msgid "Keep 'Add New from URL(s)' dialog on top?" +msgstr "¿Mantener la ventana «Añadir nuevo de URL» en primer plano?" + +#: config.py:454 +msgid "" +"Instructs the OS and Window Manager to keep the 'Add New from URL(s)'\n" +"dialog on top of all other windows. Useful for dragging URLs onto it." +msgstr "Soliciar al sistema operativo y al gestor de ventanas que mantenga la ventana «Añadir nuevo de URL» por encima de todas las otras ventanas. Es útil para arrastrar URL sobre ella." + +#: config.py:458 +msgid "Misc Options" +msgstr "Opciones varias" + +#: config.py:463 +msgid "Include images in EPUBs?" +msgstr "¿Incluir imágenes en archivos EPUB?" + +#: config.py:464 +msgid "" +"Download and include images in EPUB stories. This is equivalent to " +"adding:%(imgset)s ...to the top of %(pini)s. Your settings in %(pini)s will" +" override this." +msgstr "Descargar e incluir imágenes en historias en formato EPUB. Esto es equivalente a añadir:%(imgset)s ... al principio de %(pini)s. La configuración de %(pini)s tiene prioridad sobr esta opción." + +#: config.py:468 +msgid "Inject calibre Series when none found?" +msgstr "¿Incluir la serie de calibre si no se encuentra ninguna?" + +#: config.py:469 +msgid "" +"If no series is found, inject the calibre series (if there is one) so it " +"appears on the FFDL title page(not cover)." +msgstr "Si no se encuentra ninguna serie, incluir la de calibre (si hay alguna) para que aparezca en la página de título de FFDL (no en la portada)." + +#: config.py:473 +msgid "Reject List" +msgstr "Lista de rechazos" + +#: config.py:477 +msgid "Edit Reject URL List" +msgstr "Modificar la lista de URL rechazados" + +#: config.py:478 +msgid "Edit list of URLs FFDL will automatically Reject." +msgstr "Modificar la lista de URL que FFDL rechazará automáticamente" + +#: config.py:482 config.py:556 +msgid "Add Reject URLs" +msgstr "Añadir URL rechazados" + +#: config.py:483 +msgid "Add additional URLs to Reject as text." +msgstr "Añadir URL adicionales para rechazar como texto." + +#: config.py:487 +msgid "Edit Reject Reasons List" +msgstr "Modificar la lista de motivos de rechazo" + +#: config.py:488 config.py:547 +msgid "Customize the Reasons presented when Rejecting URLs" +msgstr "Personalizar los motivos mostrados al rechazar URL" + +#: config.py:492 +msgid "Reject Without Confirmation?" +msgstr "" + +#: config.py:493 +msgid "Always reject URLs on the Reject List without stopping and asking." +msgstr "" + +#: config.py:531 +msgid "Edit Reject URLs List" +msgstr "Modificar la lista de URL rechazados" + +#: config.py:545 +msgid "Reject Reasons" +msgstr "Motivos de rechazo" + +#: config.py:546 +msgid "Customize Reject List Reasons" +msgstr "Personalizar la lista de motivos de rechazo" + +#: config.py:554 +msgid "Reason why I rejected it" +msgstr "Motivo por el que se rechaza" + +#: config.py:554 +msgid "Title by Author" +msgstr "Título por autor" + +#: config.py:557 +msgid "" +"Add Reject URLs. Use: <b>http://...,note</b> or <b>http://...,title by " +"author - note</b><br>Invalid story URLs will be ignored." +msgstr "Añadir URL rechazados. <b>http://...,nota</b> o <b>http://...,título por autor - nota</b><br>No se tendrán en cuenta los URL de historia no válidos." + +#: config.py:558 +msgid "" +"One URL per line:\n" +"<b>http://...,note</b>\n" +"<b>http://...,title by author - note</b>" +msgstr "Un URL por línea.\n<b>http://...,nota</b>\n<b>http://...,título por autor - nota</b>" + +#: config.py:560 dialogs.py:1031 +msgid "Add this reason to all URLs added:" +msgstr "Añadir este motivo a todos los URL añadidos:" + +#: config.py:575 +msgid "" +"These settings provide more detailed control over what metadata will be " +"displayed inside the ebook as well as let you set %(isa)s and %(u)s/%(p)s " +"for different sites." +msgstr "Estas configuraciones proporcionan un control más fino sobre qué metadatos se muestran dentro del libro, y permiten establecer%(isa)s y %(u)s o %(p)s para distintos sitios." + +#: config.py:593 +msgid "View Defaults" +msgstr "Ver opciones predeterminadas" + +#: config.py:594 +msgid "" +"View all of the plugin's configurable settings\n" +"and their default settings." +msgstr "Ver todas las opciones configurables del complemento y sus valores predeterminados." + +#: config.py:612 +msgid "Plugin Defaults (%s) (Read-Only)" +msgstr "Valores predeterminados (%s) (sólo lectura)" + +#: config.py:613 config.py:619 +msgid "" +"These are all of the plugin's configurable options\n" +"and their default settings." +msgstr "Éstas son todas las opciones configurables del complemento y sus valores predeterminados." + +#: config.py:614 +msgid "Plugin Defaults" +msgstr "Opciones predeterminadas del complemento" + +#: config.py:630 dialogs.py:555 dialogs.py:658 +msgid "OK" +msgstr "Aceptar" + +#: config.py:650 +msgid "" +"These settings provide integration with the %(rl)s Plugin. %(rl)s can " +"automatically send to devices and change custom columns. You have to create" +" and configure the lists in %(rl)s to be useful." +msgstr "Estas configuraciones permiten la integración con el complemento %(rl)s. %(rl)s puede enviar automáticamente a dispositivos y cambiar columnas personalizadas. Debe crear y configurar las listas en %(rl)s para que sea útil." + +#: config.py:655 +msgid "Add new/updated stories to \"Send to Device\" Reading List(s)." +msgstr "Añadir historias nuevas o actualizadas a la(s) lista(s) de «Enviar a dispositivo»." + +#: config.py:656 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin." +msgstr "Añadir automáticamente las historias nuevas o actualizadas a estas listas en el complemento %(rl)s." + +#: config.py:661 +msgid "\"Send to Device\" Reading Lists" +msgstr "Listas de «Enviar a dispositivo»" + +#: config.py:662 config.py:665 config.py:678 config.py:681 +msgid "" +"When enabled, new/updated stories will be automatically added to these " +"lists." +msgstr "Si se activa, las historias nuevas o actualizadas se añadirán automáticamente a estas listas." + +#: config.py:671 +msgid "Add new/updated stories to \"To Read\" Reading List(s)." +msgstr "Añadir las historias nuevas o actualizadas a las listas «Para leer»." + +#: config.py:672 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin.\n" +"Also offers menu option to remove stories from the \"To Read\" lists." +msgstr "Añadir automáticamente las historias nuevas o actualizadas a estas listas en el complemento %(rl)s.\nTambién hay una opción de menú para eliminar historias de las listas «Para leer»." + +#: config.py:677 +msgid "\"To Read\" Reading Lists" +msgstr "Listas «Para leer»" + +#: config.py:687 +msgid "Add stories back to \"Send to Device\" Reading List(s) when marked \"Read\"." +msgstr "Volver a añadir las historias a la(s) lista(s) de «Enviar a dispositivo» al marcarlas como leídas." + +#: config.py:688 +msgid "" +"Menu option to remove from \"To Read\" lists will also add stories back to " +"\"Send to Device\" Reading List(s)" +msgstr "La opción de menú para eliminar de las listas «Para leer» también vuelve a añadir las historias a la(s) lista(s) de «Enviar a dispositivo»." + +#: config.py:710 +msgid "" +"The %(gc)s plugin can create cover images for books using various metadata " +"and configurations. If you have GC installed, FFDL can run GC on new " +"downloads and metadata updates. Pick a GC setting by site or Default." +msgstr "El complemento %(gc)s puede crear imágenes de portada para los libros usando distintos metadatos y configuraciones. Si tiene GC instalado, FFDL puede ejecutar GC par las nuevas descargas y las actualizaciones de metadatos. Elija una configuración de GC o «Predeterminada» para cada sitio." + +#: config.py:728 config.py:732 config.py:745 +msgid "Default" +msgstr "Predeterminada" + +#: config.py:733 +msgid "" +"On Metadata update, run %(gc)s with this setting, if not selected for " +"specific site." +msgstr "Al actualizar metadatos, ejecutar %(gc)s con esta configuración, si no hay una seleccionada para el sitio específico." + +#: config.py:736 +msgid "On Metadata update, run %(gc)s with this setting for %(site)s stories." +msgstr "Al actualizar metadatos, ejecutar %(gc)s con esta configuración para las historias de %(site)s." + +#: config.py:759 +msgid "Run %(gc)s Only on New Books" +msgstr "Ejecutar %(gc)s sólo para libros nuevos" + +#: config.py:760 +msgid "Default is to run GC any time the calibre metadata is updated." +msgstr "De manera predeterminada GC se ejecuta cada vez que se actualizan los metadatos de calibre." + +#: config.py:764 +msgid "Allow %(gcset)s from %(pini)s to override" +msgstr "Permitir que %(gcset)s de %(pini)s tenga prioridad" + +#: config.py:765 +msgid "" +"The %(pini)s parameter %(gcset)s allows you to choose a GC setting based on " +"metadata rather than site, but it's much more complex.<br \\>%(gcset)s is " +"ignored when this is off." +msgstr "El parámetro %(gcset)s de %(pini) le permite elegir una configuración de GC según los metadatos en vez del sitio, pero es mucho más complejo.<br>%(gcset)s no se tiene en cuenta si esta opción está desactivada." + +#: config.py:769 +msgid "Use calibre's Polish feature to inject/update the cover" +msgstr "Usar la función pulir de calibre para incluir o actualizar la portada" + +#: config.py:770 +msgid "" +"Calibre's Polish feature will be used to inject or update the generated " +"cover into the ebook, EPUB only." +msgstr "La función pulir de calibre se usará para incluir o actualizar la portada generada en el libro, sólo en formato EPUB." + +#: config.py:784 +msgid "" +"These settings provide integration with the %(cp)s Plugin. %(cp)s can " +"automatically update custom columns with page, word and reading level " +"statistics. You have to create and configure the columns in %(cp)s first." +msgstr "Estas configuraciones permiten la integración con el complemento %(cp)s. %(cp)s puede actualizar automáticamente columnas personalizadas con estadísticas de páginas, palabras y progreso de lectura. Debe crear y configurar primero las columnas en %(cp)s." + +#: config.py:789 +msgid "" +"If any of the settings below are checked, when stories are added or updated," +" the %(cp)s Plugin will be called to update the checked statistics." +msgstr "Si alguna de las siguientes opciones está marcada, cuando se añaden o actualizan historias el complemento %(cp)s se ejecutará para actualizar las estadísticas marcadas." + +#: config.py:795 +msgid "Which column and algorithm to use are configured in %(cp)s." +msgstr "Las columnas y algoritmos que se utilizarán se configuran en %(cp)s." + +#: config.py:803 +msgid "" +"Will overwrite word count from FFDL metadata if set to update the same " +"custom column." +msgstr "Reemplazará la cuenta de palabras de los metadatos de FFDL si se configura para actualizar la misma columna personalizada." + +#: config.py:834 +msgid "" +"These controls aren't plugin settings as such, but convenience buttons for " +"setting Keyboard shortcuts and getting all the FanFictionDownLoader " +"confirmation dialogs back again." +msgstr "Estos controles no son opciones de configuración del complemento como tales, sino botones útiles para configurar los atajos de teclado y volver a mostrar todos los diálogos de confirmación de FanFictionDownLoader." + +#: config.py:839 +msgid "Keyboard shortcuts..." +msgstr "Atajos de teclado..." + +#: config.py:840 +msgid "Edit the keyboard shortcuts associated with this plugin" +msgstr "Modificar los atajos de teclado asociados con este complemento" + +#: config.py:844 +msgid "Reset disabled &confirmation dialogs" +msgstr "Restablecer ventanas de &confirmación desactivadas" + +#: config.py:845 +msgid "Reset all show me again dialogs for the FanFictionDownLoader plugin" +msgstr "Restablecer todas las ventanas «Mostrar otra vez» para el complemento FanFictionDownLoader" + +#: config.py:849 +msgid "&View library preferences..." +msgstr "&Mostrar preferencias de la biblioteca..." + +#: config.py:850 +msgid "View data stored in the library database for this plugin" +msgstr "Ver los datos almacenados en la base de datos de la biblioteca para este complemento" + +#: config.py:861 +msgid "Done" +msgstr "Hecho" + +#: config.py:862 +msgid "Confirmation dialogs have all been reset" +msgstr "Se han restablecido todas las ventanas de confirmación" + +#: config.py:910 +msgid "Category" +msgstr "Categoría" + +#: config.py:911 +msgid "Genre" +msgstr "Género" + +#: config.py:912 +msgid "Language" +msgstr "Idioma" + +#: config.py:913 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Status" +msgstr "Estado" + +#: config.py:914 +msgid "Status:%(cmplt)s" +msgstr "Estado:%(cmplt)s" + +#: config.py:915 +msgid "Status:%(inprog)s" +msgstr "Estado:%(inprog)s" + +#: config.py:916 config.py:1050 +msgid "Series" +msgstr "Serie" + +#: config.py:917 +msgid "Characters" +msgstr "Personajes" + +#: config.py:918 +msgid "Relationships" +msgstr "Relaciones" + +#: config.py:919 +msgid "Published" +msgstr "Publicado" + +#: config.py:920 ffdl_plugin.py:1403 ffdl_plugin.py:1422 +msgid "Updated" +msgstr "Actualizado" + +#: config.py:921 +msgid "Created" +msgstr "Creado" + +#: config.py:922 +msgid "Rating" +msgstr "Valoración" + +#: config.py:923 +msgid "Warnings" +msgstr "Avisos" + +#: config.py:924 +msgid "Chapters" +msgstr "Capítulos" + +#: config.py:925 +msgid "Words" +msgstr "Palabras" + +#: config.py:926 +msgid "Site" +msgstr "Sitio" + +#: config.py:927 +msgid "Story ID" +msgstr "ID de la historia" + +#: config.py:928 +msgid "Author ID" +msgstr "ID del autor" + +#: config.py:929 +msgid "Extra Tags" +msgstr "Etiquetas adicionales" + +#: config.py:930 config.py:1042 dialogs.py:817 dialogs.py:913 +#: ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Title" +msgstr "Título" + +#: config.py:931 +msgid "Story URL" +msgstr "URL de la historia" + +#: config.py:932 +msgid "Description" +msgstr "Descripción" + +#: config.py:933 dialogs.py:817 dialogs.py:913 ffdl_plugin.py:1126 +#: ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Author" +msgstr "Autor" + +#: config.py:934 +msgid "Author URL" +msgstr "URL del autor" + +#: config.py:935 +msgid "File Format" +msgstr "Formato de archivo" + +#: config.py:936 +msgid "File Extension" +msgstr "Extensión del archivo" + +#: config.py:937 +msgid "Site Abbrev" +msgstr "Abreviatura de sitio" + +#: config.py:938 +msgid "FFDL Version" +msgstr "Versión de FFDL" + +#: config.py:953 +msgid "" +"If you have custom columns defined, they will be listed below. Choose a " +"metadata value type to fill your columns automatically." +msgstr "Si tiene columnas personalizadas, aparecerán a continuación. Elija un tipo de valor de metadatos para rellenar las columnas automáticamente." + +#: config.py:978 +msgid "Update this %s column(%s) with..." +msgstr "Actualizar esta columna %s (%s) con..." + +#: config.py:988 +msgid "Values that aren't valid for this enumeration column will be ignored." +msgstr "Los valores que no sean válidos par esta columna de enumeración serán ignorados." + +#: config.py:988 config.py:990 +msgid "Metadata values valid for this type of column." +msgstr "Valores de metadatos válidos para este tipo de columna." + +#: config.py:993 config.py:1069 +msgid "New Only" +msgstr "Sólo nuevo" + +#: config.py:994 +msgid "" +"Write to %s(%s) only for new\n" +"books, not updates to existing books." +msgstr "Escribir a %s(%s) sólo para libros nuevos,\nno en actualizaciones de libros existentes." + +#: config.py:1005 +msgid "Allow %(ccset)s from %(pini)s to override" +msgstr "Permitir que %(ccset)s de %(pini)s tenga prioridad" + +#: config.py:1006 +msgid "" +"The %(pini)s parameter %(ccset)s allows you to set custom columns to site " +"specific values that aren't common to all sites.<br />%(ccset)s is ignored " +"when this is off." +msgstr "El parámetro %(ccset)s de %(pini) le permite asignar a las columnas personalizadas valores específicos para cada sitio.<br>%(ccset)s no se tiene en cuenta si esta opción está desactivada." + +#: config.py:1011 +msgid "Special column:" +msgstr "Columna especial:" + +#: config.py:1016 +msgid "Update/Overwrite Error Column:" +msgstr "Actualizar o reemplazar columna de error:" + +#: config.py:1017 +msgid "" +"When an update or overwrite of an existing story fails, record the reason in this column.\n" +"(Text and Long Text columns only.)" +msgstr "Cuando una actualización o reemplazo de una historia existente falla, registrar el motivo en esta columna.\n(Sólo columnas de texto y texto largo)." + +#: config.py:1043 +msgid "Author(s)" +msgstr "Autor(es)" + +#: config.py:1044 +msgid "Publisher" +msgstr "Editorial" + +#: config.py:1045 +msgid "Tags" +msgstr "Etiquetas" + +#: config.py:1046 +msgid "Languages" +msgstr "Idiomas" + +#: config.py:1047 +msgid "Published Date" +msgstr "Fecha de publicación" + +#: config.py:1048 +msgid "Date" +msgstr "Fecha" + +#: config.py:1049 +msgid "Comments" +msgstr "Comentarios" + +#: config.py:1051 +msgid "Ids(url id only)" +msgstr "ID (sólo identificador de URL)" + +#: config.py:1056 +msgid "" +"The standard calibre metadata columns are listed below. You may choose " +"whether FFDL will fill each column automatically on updates or only for new " +"books." +msgstr "Las columnas de metadatos estándar de calibre se muestran a continuación. Puede elegir si FFDL rellenará cada columna automáticamente para las actualizaciones o sólo para los libros nuevos." + +#: config.py:1070 +msgid "" +"Write to %s only for new\n" +"books, not updates to existing books." +msgstr "Escribir a %s sólo para libros nuevos, no en actualizaciones de libros existentes." + +#: dialogs.py:69 +msgid "Skip" +msgstr "Omitir" + +#: dialogs.py:70 +msgid "Add New Book" +msgstr "Añadir libro nuevo" + +#: dialogs.py:71 +msgid "Update EPUB if New Chapters" +msgstr "Actualizar EPUB cuando haya capítulos nuevos" + +#: dialogs.py:72 +msgid "Update EPUB Always" +msgstr "Actualizar EPUB siempre" + +#: dialogs.py:73 +msgid "Overwrite if Newer" +msgstr "Reemplazar si es más reciente" + +#: dialogs.py:74 +msgid "Overwrite Always" +msgstr "Reemplazar siempre" + +#: dialogs.py:75 +msgid "Update Calibre Metadata Only" +msgstr "Actualizar sólo los metadatos de calibre" + +#: dialogs.py:252 ffdl_plugin.py:89 +msgid "FanFictionDownLoader" +msgstr "FanFictionDownLoader" + +#: dialogs.py:269 dialogs.py:716 +msgid "Show Download Options" +msgstr "Mostrar opciones de descarga" + +#: dialogs.py:288 dialogs.py:733 +msgid "Output &Format:" +msgstr "&Formato de salida:" + +#: dialogs.py:296 dialogs.py:741 +msgid "" +"Choose output format to create. May set default from plugin configuration." +msgstr "Elija un formato de salida para crear. Puede establecer el predeterminado en la configuración del complemento." + +#: dialogs.py:324 dialogs.py:758 +msgid "Update Calibre &Metadata?" +msgstr "¿Actualizar los metadatos de calibre?" + +#: dialogs.py:325 dialogs.py:759 +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 "¿Actualizar los metadatos de las historias existentes en calibre a partir del sitio web?\n(Las columnas establecidas en «Sólo nuevo» en la pestaña de columnas sólo se cambiarán para libros nuevos)." + +#: dialogs.py:331 dialogs.py:763 +msgid "Update EPUB Cover?" +msgstr "¿Actualizar portada del archivo EPUB?" + +#: dialogs.py:332 dialogs.py:764 +msgid "" +"Update book cover image from site or defaults (if found) <i>inside</i> the " +"EPUB when EPUB is updated." +msgstr "Actualizar la imagen de portada a partir del sitio o de la configuración predeterminada (si se encuentra) <i>dentro</i> del archivo EPUB al actualizarlo." + +#: dialogs.py:379 +msgid "Story URL(s) for anthology, one per line:" +msgstr "URL de historias en la antología, uno por línea:" + +#: dialogs.py:380 +msgid "" +"URLs for stories to include in the anthology, one per line.\n" +"Will take URLs from clipboard, but only valid URLs." +msgstr "URL de las historias que se incluyen en la antología, uno por línea.\nSe tomarán URL del portapapeles, pero sólo los que sean válidos." + +#: dialogs.py:381 +msgid "If Story Already Exists in Anthology?" +msgstr "¿Si la historia ya existe en la antología?" + +#: dialogs.py:382 +msgid "" +"What to do if there's already an existing story with the same URL in the " +"anthology." +msgstr "Qué hacer si ya existe una historia con el mismo URL en la antología." + +#: dialogs.py:391 +msgid "Story URL(s), one per line:" +msgstr "URL de historias, uno por línea:" + +#: dialogs.py:392 +msgid "" +"URLs for stories, one per line.\n" +"Will take URLs from clipboard, but only valid URLs.\n" +"Add [1,5] after the URL to limit the download to chapters 1-5." +msgstr "URL para las historias, uno por línea.\nSe tomarán URL del portapapeles, pero sólo los que sean válidos.\nAñada [1,5] después del URL para restringir la descarga a los capítulos 1 a 5." + +#: dialogs.py:393 +msgid "If Story Already Exists?" +msgstr "¿Si la historia ya existe?" + +#: dialogs.py:394 +msgid "" +"What to do if there's already an existing story with the same URL or title " +"and author." +msgstr "Qué hacer si ya existe una historia con el mismo URL o título y autor." + +#: dialogs.py:494 +msgid "For Individual Books" +msgstr "Para libros individuales" + +#: dialogs.py:495 +msgid "Get URLs and go to dialog for individual story downloads." +msgstr "Obtener URL e ir a la ventana de descarga para historias individuales." + +#: dialogs.py:499 +msgid "For Anthology Epub" +msgstr "Para epub de antología" + +#: dialogs.py:500 +msgid "" +"Get URLs and go to dialog for Anthology download.\n" +"Requires %s plugin." +msgstr "Obtener URL e ir a la ventana de descarga para antologías.\nRequiere el complemento %s." + +#: dialogs.py:505 dialogs.py:559 dialogs.py:586 +msgid "Cancel" +msgstr "Cancelar" + +#: dialogs.py:537 +msgid "Password" +msgstr "Contraseña" + +#: dialogs.py:538 +msgid "Author requires a password for this story(%s)." +msgstr "El autor solicita una contraseña para esta historia (%s)." + +#: dialogs.py:543 +msgid "User/Password" +msgstr "Usuario y contraseña" + +#: dialogs.py:544 +msgid "%s requires you to login to download this story." +msgstr "%s solicita que se registre para descargar esta historia." + +#: dialogs.py:546 +msgid "User:" +msgstr "Usuario:" + +#: dialogs.py:550 +msgid "Password:" +msgstr "Contraseña:" + +#: dialogs.py:581 +msgid "Fetching metadata for stories..." +msgstr "Obteniendo metadatos par las historias..." + +#: dialogs.py:582 +msgid "Downloading metadata for stories" +msgstr "Descargando metadatos para las historias" + +#: dialogs.py:583 +msgid "Fetched metadata for" +msgstr "Metadatos obtenidos para" + +#: dialogs.py:653 ffdl_plugin.py:325 +msgid "About FanFictionDownLoader" +msgstr "Acerca de FanFictionDownLoader" + +#: dialogs.py:707 +msgid "Remove selected books from the list" +msgstr "Eliminar los libros seleccionados de la lista" + +#: dialogs.py:746 +msgid "Update Mode:" +msgstr "Modo de actualización:" + +#: dialogs.py:749 +msgid "" +"What sort of update to perform. May set default from plugin configuration." +msgstr "Qué tipo de actualización se realizará. Puede definirse el valor predeterminado en la configuración del complemento." + +#: dialogs.py:817 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Comment" +msgstr "Comentario" + +#: dialogs.py:885 +msgid "Are you sure you want to remove this book from the list?" +msgstr "¿Está seguro de querer eliminar este libro de la lista?" + +#: dialogs.py:887 +msgid "Are you sure you want to remove the selected %d books from the list?" +msgstr "¿Está seguro de querer eliminar los %d libros seleccionados de la lista?" + +#: dialogs.py:913 +msgid "Note" +msgstr "Nota" + +#: dialogs.py:955 +msgid "Select or Edit Reject Note." +msgstr "Seleccionar o modificar nota de rechazo." + +#: dialogs.py:963 +msgid "Are you sure you want to remove this URL from the list?" +msgstr "¿Está seguro de querer eliminar este URL de la lista?" + +#: dialogs.py:965 +msgid "Are you sure you want to remove the %d selected URLs from the list?" +msgstr "¿Está seguro de querer eliminar los %d URL seleccionados de la lista?" + +#: dialogs.py:983 +msgid "List of Books to Reject" +msgstr "Lista de libros para rechazar" + +#: dialogs.py:996 +msgid "" +"FFDL will remember these URLs and display the note and offer to reject them " +"if you try to download them again later." +msgstr "FFDL recordará estos URL, mostrará una nota y le permitirá rechazarlos si vuelve a intentar descargarlos más adelante." + +#: dialogs.py:1010 +msgid "Remove selected URL(s) from the list" +msgstr "Eliminar los URL seleccionados de la lista" + +#: dialogs.py:1028 dialogs.py:1032 +msgid "This will be added to whatever note you've set for each URL above." +msgstr "Se añadirá a cualquier nota que haya establecido para cada URL anterior." + +#: dialogs.py:1041 +msgid "Delete Books (including books without FanFiction URLs)?" +msgstr "¿Eliminar libros (incluyendo libros sin URL de «fanfiction»)?" + +#: dialogs.py:1042 +msgid "Delete the selected books after adding them to the Rejected URLs list." +msgstr "Eliminar los libros seleccionados después de añadirlos a la lista de URL rechazados." + +#: ffdl_plugin.py:90 +msgid "Download FanFiction stories from various web sites" +msgstr "Descargar historias de «fanfiction» desde distintos sitios web" + +#: ffdl_plugin.py:120 +msgid "FanFictionDL" +msgstr "FanFictionDL" + +#: ffdl_plugin.py:243 +msgid "&Add New from URL(s)" +msgstr "&Añadir nuevo(s) a partir de URL" + +#: ffdl_plugin.py:245 +msgid "Add New FanFiction Book(s) from URL(s)" +msgstr "Añadir nuevo(s) libro(s) de «fanfiction» a partir de URL" + +#: ffdl_plugin.py:248 +msgid "&Update Existing FanFiction Book(s)" +msgstr "&Actualizar libro(s) de «fanfiction» existente(s)" + +#: ffdl_plugin.py:254 +msgid "Get Story URLs to Download from Web Page" +msgstr "Obtener URL de historia para descargar de una página web" + +#: ffdl_plugin.py:258 +msgid "&Make Anthology Epub Manually from URL(s)" +msgstr "&Crear manualmente un epub de antología a partir de URL" + +#: ffdl_plugin.py:260 +msgid "Make FanFiction Anthology Epub Manually from URL(s)" +msgstr "Crear un epub de antología a partir de URL manualmente" + +#: ffdl_plugin.py:263 +msgid "&Update Anthology Epub" +msgstr "Ac&tualizar epub de antología" + +#: ffdl_plugin.py:265 +msgid "Update FanFiction Anthology Epub" +msgstr "Actualizar un epub de antología de «fanfiction»" + +#: ffdl_plugin.py:273 +msgid "Add to \"To Read\" and \"Send to Device\" Lists" +msgstr "Añadir a las listas «Para leer» o de «Enviar a dispositivo»" + +#: ffdl_plugin.py:275 +msgid "Remove from \"To Read\" and add to \"Send to Device\" Lists" +msgstr "Eliminar de las listas «Para leer» y añadir a las de «Enviar a dispositivo»" + +#: ffdl_plugin.py:277 ffdl_plugin.py:282 +msgid "Remove from \"To Read\" Lists" +msgstr "Eliminar de las listas «Para leer»" + +#: ffdl_plugin.py:279 +msgid "Add Selected to \"Send to Device\" Lists" +msgstr "Añadir seleccionados a las listas de «Enviar a dispositivo»" + +#: ffdl_plugin.py:281 +msgid "Add to \"To Read\" Lists" +msgstr "Añadir a las listas «Para leer»" + +#: ffdl_plugin.py:297 +msgid "Get URLs from Selected Books" +msgstr "Obtener URL de los libros seleccionados" + +#: ffdl_plugin.py:303 ffdl_plugin.py:396 +msgid "Get Story URLs from Web Page" +msgstr "Obtener URL de historias a partir de una página web" + +#: ffdl_plugin.py:308 +msgid "Reject Selected Books" +msgstr "Rechazar los libros seleccionados" + +#: ffdl_plugin.py:316 +msgid "&Configure Plugin" +msgstr "&Configurar complemento" + +#: ffdl_plugin.py:319 +msgid "Configure FanFictionDownLoader" +msgstr "Configurar " + +#: ffdl_plugin.py:322 +msgid "About Plugin" +msgstr "Acerca del complemento" + +#: ffdl_plugin.py:379 +msgid "Cannot Update Reading Lists from Device View" +msgstr "No se pueden actualizar las listas de lectura en la vista de dispositivo" + +#: ffdl_plugin.py:383 +msgid "No Selected Books to Update Reading Lists" +msgstr "No hay libros seleccionados para actualizar las listas de lectura" + +#: ffdl_plugin.py:407 ffdl_plugin.py:459 +msgid "List of Story URLs" +msgstr "Lista de URL de historias" + +#: ffdl_plugin.py:408 +msgid "No Valid Story URLs found on given page." +msgstr "No se encontró ningún URL de historia válida en la página." + +#: ffdl_plugin.py:423 +msgid "No Selected Books to Get URLs From" +msgstr "No hay ningún libro seleccionado del que obtener URL" + +#: ffdl_plugin.py:441 +msgid "Collecting URLs for stories..." +msgstr "Recopilando URL para las historias..." + +#: ffdl_plugin.py:442 +msgid "Get URLs for stories" +msgstr "Obtener URL para las historias" + +#: ffdl_plugin.py:443 ffdl_plugin.py:490 ffdl_plugin.py:677 +msgid "URL retrieved" +msgstr "URL obtenido" + +#: ffdl_plugin.py:463 +msgid "List of URLs" +msgstr "Lista de URL" + +#: ffdl_plugin.py:464 +msgid "No Story URLs found in selected books." +msgstr "No se encontró ningún URL de historia en los libros seleccionados." + +#: ffdl_plugin.py:480 +msgid "No Selected Books have URLs to Reject" +msgstr "Ningún libro seleccionado tiene URL para rechazar" + +#: ffdl_plugin.py:488 +msgid "Collecting URLs for Reject List..." +msgstr "Recopilando URL para la lista de rechazos..." + +#: ffdl_plugin.py:489 +msgid "Get URLs for Reject List" +msgstr "Obtener URL para la lista de rechazos" + +#: ffdl_plugin.py:524 +msgid "Proceed to Remove?" +msgstr "¿Eliminar?" + +#: ffdl_plugin.py:524 +msgid "Rejecting FFDL URLs: None of the books selected have FanFiction URLs." +msgstr "Rechazo de URL en FFDL: Ninguno de los libros seleccionades tiene URL de «fanfiction»." + +#: ffdl_plugin.py:546 +msgid "Cannot Make Anthologys without %s" +msgstr "No se pueden hacer antologías sin %s" + +#: ffdl_plugin.py:550 ffdl_plugin.py:654 +msgid "Cannot Update Books from Device View" +msgstr "No se pueden actualizar libros en la vista de dispositivo" + +#: ffdl_plugin.py:554 +msgid "Can only update 1 anthology at a time" +msgstr "Sólo se puede actualizar 1 antología cada vez" + +#: ffdl_plugin.py:563 +msgid "Can only Update Epub Anthologies" +msgstr "Sólo se pueden actualizar antologías en formato EPUB" + +#: ffdl_plugin.py:581 ffdl_plugin.py:582 +msgid "Cannot Update Anthology" +msgstr "No se puede actualizar la antología" + +#: ffdl_plugin.py:582 +msgid "" +"Book isn't an FFDL Anthology or contains book(s) without valid FFDL URLs." +msgstr "El libro no es una antología de FFDL o contiene libros sin URL de FFDL válidos." + +#: ffdl_plugin.py:640 +msgid "" +"There are %d stories in the current anthology that are <b>not</b> going to " +"be kept if you go ahead." +msgstr "Hay %d historias en la antología actual que <b>no</b> se mantendrán si continúa." + +#: ffdl_plugin.py:641 +msgid "Story URLs that will be removed:" +msgstr "URL de historias que se eliminarán:" + +#: ffdl_plugin.py:643 +msgid "Update anyway?" +msgstr "¿Actualizar de todas formas?" + +#: ffdl_plugin.py:644 +msgid "Stories Removed" +msgstr "Historias eliminadas" + +#: ffdl_plugin.py:661 +msgid "No Selected Books to Update" +msgstr "No se han seleccionado libros para actualizar" + +#: ffdl_plugin.py:675 +msgid "Collecting stories for update..." +msgstr "Recopilando historias para actualizar..." + +#: ffdl_plugin.py:676 +msgid "Get stories for updates" +msgstr "Obtener historias para actualizar" + +#: ffdl_plugin.py:686 +msgid "Update Existing List" +msgstr "Actualizando la lista existente" + +#: ffdl_plugin.py:738 +msgid "Started fetching metadata for %s stories." +msgstr "Obtención de metadatos iniciada para %s historias." + +#: ffdl_plugin.py:744 +msgid "No valid story URLs entered." +msgstr "No se ha introducido ningún URL de historia válido." + +#: ffdl_plugin.py:769 ffdl_plugin.py:775 +msgid "Reject URL?" +msgstr "¿Rechazar URL?" + +#: ffdl_plugin.py:776 ffdl_plugin.py:794 +msgid "<b>%s</b> is on your Reject URL list:" +msgstr "<b>%s</b> está en la lista de rechazos:" + +#: ffdl_plugin.py:778 +msgid "Click '<b>Yes</b>' to Reject." +msgstr "Pulse en «<b>Sí</b>» para rechazar." + +#: ffdl_plugin.py:779 ffdl_plugin.py:875 +msgid "Click '<b>No</b>' to download anyway." +msgstr "Pulse en «<b>No</b>» para descargar de todos modos." + +#: ffdl_plugin.py:781 +msgid "Story on Reject URLs list (%s)." +msgstr "Historia en la lista de rechazos (%s)." + +#: ffdl_plugin.py:784 +msgid "Rejected" +msgstr "Rechazado" + +#: ffdl_plugin.py:787 +msgid "Remove Reject URL?" +msgstr "¿Eliminar URL rechazado?" + +#: ffdl_plugin.py:793 +msgid "Remove URL from Reject List?" +msgstr "¿Eliminar URL de la lista de rechazos?" + +#: ffdl_plugin.py:796 +msgid "Click '<b>Yes</b>' to remove it from the list," +msgstr "Pulse en «<b>Sí</b>» para eliminarlo de la lista." + +#: ffdl_plugin.py:797 +msgid "Click '<b>No</b>' to leave it on the list." +msgstr "Pulse en «<b>No</b>» para dejarlo en la lista." + +#: ffdl_plugin.py:814 +msgid "Cannot update non-epub format." +msgstr "No se puede actualizar un formato que no sea EPUB." + +#: ffdl_plugin.py:851 +msgid "Are You an Adult?" +msgstr "¿Es usted adulto?" + +#: ffdl_plugin.py:852 +msgid "" +"%s requires that you be an adult. Please confirm you are an adult in your " +"locale:" +msgstr "%s requiere que usted sea adulto. Por favor confirme que es usted adulto en su jurisdicción:" + +#: ffdl_plugin.py:866 +msgid "Skip Story?" +msgstr "¿Omitir historia?" + +#: ffdl_plugin.py:872 +msgid "Skip Anthology Story?" +msgstr "¿Omitir historia de antología?" + +#: ffdl_plugin.py:873 +msgid "" +"\"<b>%s</b>\" is in series \"<b><a href=\"%s\">%s</a></b>\" that you have an" +" anthology book for." +msgstr "«<b>%s</b>» está en la serie «<b><a href=\"%s\">%s</a></b>», para la que tiene una antología." + +#: ffdl_plugin.py:874 +msgid "Click '<b>Yes</b>' to Skip." +msgstr "Pulse en «<b>Sí</b>» para omitir." + +#: ffdl_plugin.py:877 +msgid "Story in Series Anthology(%s)." +msgstr "Historia en antología de serie (%s)." + +#: ffdl_plugin.py:882 +msgid "Skipped" +msgstr "Omitida" + +#: ffdl_plugin.py:910 +msgid "Add" +msgstr "Añadir" + +#: ffdl_plugin.py:923 +msgid "Meta" +msgstr "Meta" + +#: ffdl_plugin.py:956 +msgid "Skipping duplicate story." +msgstr "Omitiendo historia duplicada." + +#: ffdl_plugin.py:959 +msgid "" +"More than one identical book by Identifer URL or title/author(s)--can't tell" +" which book to update/overwrite." +msgstr "Hay más de un libro idéntico según el URL identificador o el título y autor(es). No se puede saberse cuál hay que actualizar o reemplazar." + +#: ffdl_plugin.py:970 +msgid "Update" +msgstr "Actualizar" + +#: ffdl_plugin.py:978 ffdl_plugin.py:985 +msgid "Change Story URL?" +msgstr "¿Cambiar el URL de historia?" + +#: ffdl_plugin.py:986 +msgid "" +"<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL:" +msgstr "<b>%s</b> por <b>%s</b> ya está en la biblioteca con un URL de origen diferente:" + +#: ffdl_plugin.py:987 +msgid "In library: <a href=\"%(liburl)s\">%(liburl)s</a>" +msgstr "En la biblioteca: <a href=\"%(liburl)s\">%(liburl)s</a>" + +#: ffdl_plugin.py:988 ffdl_plugin.py:1002 +msgid "New URL: <a href=\"%(newurl)s\">%(newurl)s</a>" +msgstr "Nuevo URL: <a href=\"%(newurl)s\">%(newurl)s</a>" + +#: ffdl_plugin.py:989 +msgid "Click '<b>Yes</b>' to update/overwrite book with new URL." +msgstr "Pulse en «<b>Sí</b>» para actualizar o reemplazar el libro con el URL nuevo." + +#: ffdl_plugin.py:990 +msgid "Click '<b>No</b>' to skip updating/overwriting this book." +msgstr "Pulse en «<b>No</b>» para omitir la actualización o reemplazo de este libro." + +#: ffdl_plugin.py:992 ffdl_plugin.py:999 +msgid "Download as New Book?" +msgstr "¿Descargar como un libro nuevo?" + +#: ffdl_plugin.py:1000 +msgid "" +"<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL." +msgstr "<b>%s</b> por <b>%s</b> ya está en la biblioteca con un URL de origen diferente." + +#: ffdl_plugin.py:1001 +msgid "" +"You chose not to update the existing book. Do you want to add a new book " +"for this URL?" +msgstr "Ha elegido no actualizar el libro existente. ¿Quiere añadir un nuevo libro con este URL?" + +#: ffdl_plugin.py:1003 +msgid "Click '<b>Yes</b>' to a new book with new URL." +msgstr "Pulse en «<b>Sí</b>» para añadir un nuevo libro con el nuevo URL." + +#: ffdl_plugin.py:1004 +msgid "Click '<b>No</b>' to skip URL." +msgstr "Pulse en «<b>No</b>» para omitir el URL." + +#: ffdl_plugin.py:1010 +msgid "Update declined by user due to differing story URL(%s)" +msgstr "Actualización anulada por el usuario debido a un conflicto de URL de historia (%s)" + +#: ffdl_plugin.py:1013 +msgid "Different URL" +msgstr "URL diferente" + +#: ffdl_plugin.py:1018 +msgid "Metadata collected." +msgstr "Metadatos recopilados." + +#: ffdl_plugin.py:1034 +msgid "Already contains %d chapters." +msgstr "Ya contiene %d capítulos." + +#: ffdl_plugin.py:1039 +msgid "" +"Existing epub contains %d chapters, web site only has %d. Use Overwrite to " +"force update." +msgstr "El epub existente contiene %d capítulos, el sitio web sólo tiene %d. Use Reemplazar para forzar la actualización." + +#: ffdl_plugin.py:1041 +msgid "" +"FFDL doesn't recognize chapters in existing epub, epub is probably from a " +"different source. Use Overwrite to force update." +msgstr "FFDL no encuentra capítulos en el epub existente, probablemente procede de un origen distinto. Use Reemplazar para forzar la actualización." + +#: ffdl_plugin.py:1053 +msgid "Not Overwriting, web site is not newer." +msgstr "No se reemplaza, el sitio web no es más reciente." + +#: ffdl_plugin.py:1122 +msgid "None of the <b>%d</b> URLs/stories given can be/need to be downloaded." +msgstr "Ninguna de las <b>%d</b> historias o URL dados pueden o necesitan descargarse." + +#: ffdl_plugin.py:1123 ffdl_plugin.py:1286 ffdl_plugin.py:1316 +msgid "See log for details." +msgstr "Vea el registro para más detalles." + +#: ffdl_plugin.py:1124 +msgid "Proceed with updating your library(Error Column, if configured)?" +msgstr "¿Continuar actualizando la biblioteca (columna de error, si está configurada)?" + +#: ffdl_plugin.py:1131 ffdl_plugin.py:1298 +msgid "Bad" +msgstr "Incorrecta" + +#: ffdl_plugin.py:1139 +msgid "FFDL download ended" +msgstr "Descarga de FFDL finalizada" + +#: ffdl_plugin.py:1139 ffdl_plugin.py:1341 +msgid "FFDL log" +msgstr "Registro de FFDL" + +#: ffdl_plugin.py:1147 +msgid "Download FanFiction Book" +msgstr "Descargar libro de «fanfiction»" + +#: ffdl_plugin.py:1154 +msgid "Starting %d FanFictionDownLoads" +msgstr "Iniciando %d descargas de FanFictionDownLoader" + +#: ffdl_plugin.py:1184 +msgid "Story Details:" +msgstr "Detalles de la historia:" + +#: ffdl_plugin.py:1187 +msgid "Error Updating Metadata" +msgstr "Error actualizando metadatos" + +#: ffdl_plugin.py:1188 +msgid "" +"An error has occurred while FFDL was updating calibre's metadata for <a " +"href='%s'>%s</a>." +msgstr "Ha ocurrido un error mientras FFDL actualizaba la base de datos de calibre para " + +#: ffdl_plugin.py:1189 +msgid "The ebook has been updated, but the metadata has not." +msgstr "El libro ha sido actualizado, pero no los metadatos." + +#: ffdl_plugin.py:1241 +msgid "Finished Adding/Updating %d books." +msgstr "Finalizada la adición o actualización de %d libros." + +#: ffdl_plugin.py:1249 +msgid "Starting auto conversion of %d books." +msgstr "Iniciando la conversión automática de %d libros." + +#: ffdl_plugin.py:1270 +msgid "No Good Stories for Anthology" +msgstr "No hay historias válidas para la antología" + +#: ffdl_plugin.py:1271 +msgid "" +"No good stories/updates where downloaded, Anthology creation/update aborted." +msgstr "No se han descargado historias o actualizaciones válidas, la creación o actualización de la antología se cancela." + +#: ffdl_plugin.py:1276 ffdl_plugin.py:1315 +msgid "FFDL found <b>%s</b> good and <b>%s</b> bad updates." +msgstr "FFDL ha encontrado <b>%s</b> actualizaciones válidas y <b>%s</b> incorrectas." + +#: ffdl_plugin.py:1283 +msgid "" +"Are you sure you want to continue with creating/updating this Anthology?" +msgstr "¿Está seguro de querer continuar creando o actualizando la antología?" + +#: ffdl_plugin.py:1284 +msgid "Any updates that failed will <b>not</b> be included in the Anthology." +msgstr "Las actualizaciones fallidas <b>no</b> se incluirán en la antología." + +#: ffdl_plugin.py:1285 +msgid "However, if there's an older version, it will still be included." +msgstr "Sin embargo, si hay una versión más antigua, se incluirá." + +#: ffdl_plugin.py:1288 +msgid "Proceed with updating this anthology and your library?" +msgstr "¿Continuar actualizando esta antología y la biblioteca?" + +#: ffdl_plugin.py:1296 +msgid "Good" +msgstr "Válida" + +#: ffdl_plugin.py:1317 +msgid "Proceed with updating your library?" +msgstr "¿Continuar actualizando la biblioteca?" + +#: ffdl_plugin.py:1341 +msgid "FFDL download complete" +msgstr "Descarga de FFDL completa" + +#: ffdl_plugin.py:1354 +msgid "Merging %s books." +msgstr "Combinando %s libros." + +#: ffdl_plugin.py:1394 +msgid "FFDL Adding/Updating books." +msgstr "FFDL está añadiendo o actualizando libros." + +#: ffdl_plugin.py:1401 +msgid "Updating calibre for FanFiction stories..." +msgstr "Actualizando calibre para las historias de «fanfiction»..." + +#: ffdl_plugin.py:1402 +msgid "Update calibre for FanFiction stories" +msgstr "Actualizar calibre para las historias de «fanfiction»" + +#: ffdl_plugin.py:1411 +msgid "Adding/Updating %s BAD books." +msgstr "Añadiendo o actualizando %s libros INCORRECTOS." + +#: ffdl_plugin.py:1420 +msgid "Updating calibre for BAD FanFiction stories..." +msgstr "Actualizando calibre para las historias de «fanfiction» INCORRECTAS..." + +#: ffdl_plugin.py:1421 +msgid "Update calibre for BAD FanFiction stories" +msgstr "Actualizar calibre para las historias de «fanfiction» INCORRECTAS" + +#: ffdl_plugin.py:1447 +msgid "Adding format to book failed for some reason..." +msgstr "Hubo un fallo al añadir un formato al libro por algún motivo..." + +#: ffdl_plugin.py:1450 +msgid "Error" +msgstr "Error" + +#: ffdl_plugin.py:1723 +msgid "" +"You configured FanFictionDownLoader to automatically update Reading Lists, " +"but you don't have the %s plugin installed anymore?" +msgstr "Ha configurado FanFictionDownLoader para actualizar automáticamente las listas de lectura, pero ya no tiene instalado el complemento %s" + +#: ffdl_plugin.py:1735 +msgid "" +"You configured FanFictionDownLoader to automatically update \"To Read\" " +"Reading Lists, but you don't have any lists set?" +msgstr "Ha configurado FanFictionDownLoader para actualizar automáticamente las listas «Para leer», pero no tiene ninguna lista configurada." + +#: ffdl_plugin.py:1745 ffdl_plugin.py:1763 +msgid "" +"You configured FanFictionDownLoader to automatically update Reading List " +"'%s', but you don't have a list of that name?" +msgstr "Ha configurado FanFictionDownLoader para actualizar automáticamente las listas «%s», pero no tiene ninguna lista con ese nombre." + +#: ffdl_plugin.py:1751 +msgid "" +"You configured FanFictionDownLoader to automatically update \"Send to " +"Device\" Reading Lists, but you don't have any lists set?" +msgstr "Ha configurado FanFictionDownLoader para actualizar automáticamente las listas de «Enviar a dispositivo», pero no tiene ninguna lista configurada." + +#: ffdl_plugin.py:1871 +msgid "No story URL found." +msgstr "No se ha encontrado ningún URL de historia." + +#: ffdl_plugin.py:1874 +msgid "Not Found" +msgstr "No se ha encontrado" + +#: ffdl_plugin.py:1880 +msgid "URL is not a valid story URL." +msgstr "El URL no es un URL de historia válido." + +#: ffdl_plugin.py:1883 +msgid "Bad URL" +msgstr "El URL es incorrecto" + +#: ffdl_plugin.py:2018 +msgid "Anthology containing:" +msgstr "La antología contiene:" + +#: ffdl_plugin.py:2019 +msgid "%s by %s" +msgstr "%s por %s" + +#: ffdl_plugin.py:2038 +msgid " Anthology" +msgstr "Antología" + +#: ffdl_plugin.py:2075 +msgid "(was set, removed for security)" +msgstr "(estaba activado, eliminado por seguridad)" diff --git a/calibre-plugin/translations/fr.po b/calibre-plugin/translations/fr.po new file mode 100644 index 00000000..f8eccb48 --- /dev/null +++ b/calibre-plugin/translations/fr.po @@ -0,0 +1,1600 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# +# Translators: +# Ptitprince <leporello1791@gmail.com>, 2014 +msgid "" +msgstr "" +"Project-Id-Version: calibre-plugins\n" +"POT-Creation-Date: 2014-07-14 10:52+Central Daylight Time\n" +"PO-Revision-Date: 2014-07-17 09:47+0000\n" +"Last-Translator: Kovid Goyal <kovid@kovidgoyal.net>\n" +"Language-Team: French (http://www.transifex.com/projects/p/calibre-plugins/language/fr/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: ENCODING\n" +"Generated-By: pygettext.py 1.5\n" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: __init__.py:42 +msgid "UI plugin to download FanFiction stories from various sites." +msgstr "Greffon à interface utilisateur pour télécharger des récits FanFiction de différents sites." + +#: __init__.py:109 +msgid "" +"Path to the calibre library. Default is to use the path stored in the " +"settings." +msgstr "Chemin vers la bibliothèque calibre. Par défaut utilise le chemin stocké dans les paramètres." + +#: config.py:161 +msgid "FAQs" +msgstr "FAQs" + +#: config.py:161 +msgid "List of Supported Sites" +msgstr "Liste des Sites Supportés" + +#: config.py:175 +msgid "Basic" +msgstr "De base" + +#: config.py:196 +msgid "Standard Columns" +msgstr "Colonnes standards" + +#: config.py:199 +msgid "Custom Columns" +msgstr "Colonnes personnalisées" + +#: config.py:202 +msgid "Other" +msgstr "Autre" + +#: config.py:323 +msgid "" +"These settings control the basic features of the plugin--downloading " +"FanFiction." +msgstr "Ces paramètres contrôlent les caractéristiques de base du greffon--téléchargement FanFiction" + +#: config.py:327 +msgid "Defaults Options on Download" +msgstr "Options par défaut au téléchargement" + +#: config.py:331 +msgid "" +"On each download, FFDL offers an option to select the output format. <br " +"/>This sets what that option will default to." +msgstr "A chaque téléchargement, FFDL propose une option pour sélectionner le format de sortie. <br/>Ces réglages sont ce que cette option fera par défaut." + +#: config.py:333 +msgid "Default Output &Format:" +msgstr "&Format de sortie par défaut : " + +#: config.py:348 +msgid "" +"On each download, FFDL offers an option of what happens if that story " +"already exists. <br />This sets what that option will default to." +msgstr "A chaque téléchargement, FFDL propose une option sur ce qu'il arrive si ce récit existe déjà.<br/> Ces réglages sont ce que cette option fera par défaut." + +#: config.py:350 +msgid "Default If Story Already Exists?" +msgstr "Par défaut si le récit existe déjà ?" + +#: config.py:364 +msgid "Default Update Calibre &Metadata?" +msgstr "Par défaut met à jour les &métadonnées calibre ?" + +#: config.py:365 +msgid "" +"On each download, FFDL offers an option to update Calibre's metadata (title," +" author, URL, tags, custom columns, etc) from the web site. <br />This sets " +"whether that will default to on or off. <br />Columns set to 'New Only' in " +"the column tabs will only be set for new books." +msgstr "A chaque téléchargement, FFDL propose une option pour mettre les métadonnées de calibre à jour (titre, auteur, URL, étiquettes, colonnes personnalisées etc.) depuis le site web. <br />Ces paramétrages se placeront sur marche ou arrêt par défaut. <br />Les colonnes définies à \"Nouveau uniquement\" dans l'étiquette de colonne seront uniquement définies pour les nouveaux livres." + +#: config.py:369 +msgid "Default Update EPUB Cover when Updating EPUB?" +msgstr "Par défaut mettre à jour la couverture de l'ePub quand mise à jour de l'ePub ?" + +#: config.py:370 +msgid "" +"On each download, FFDL offers an option to update the book cover image " +"<i>inside</i> the EPUB from the web site when the EPUB is updated.<br />This" +" sets whether that will default to on or off." +msgstr "A chaque téléchargement, FFDL propose une option pour mettre l'image de couverture du livre <i>à l'intérieur</i> de l'ePub depuis le site web quand l'ePub est mis à jour. <br />Ces paramétrages se placeront sur marche ou arrêt par défaut." + +#: config.py:374 +msgid "Smarten Punctuation (EPUB only)" +msgstr "Ponctuation intelligente (ePub uniquement)" + +#: config.py:375 +msgid "" +"Run Smarten Punctuation from Calibre's Polish Book feature on each EPUB " +"download and update." +msgstr "Exécuter Ponctuation Intelligente depuis la caractéristique Polish Book de calibre sur chaque ePub téléchargé et mis à jour." + +#: config.py:380 +msgid "Updating Calibre Options" +msgstr "Mise à jour des options de calibre" + +#: config.py:384 +msgid "Delete other existing formats?" +msgstr "Supprimer les autres formats existants ?" + +#: config.py:385 +msgid "" +"Check this to automatically delete all other ebook formats when updating an existing book.\n" +"Handy if you have both a Nook(epub) and Kindle(mobi), for example." +msgstr "Cocher ceci pour supprimer automatiquement tous les autres formats d'ebook quand vous mettez à jour un ebook existant.\nPratique si vous avez en même temps un Nook (epub) et une Kindle (mobi), par exemple. " + +#: config.py:389 +msgid "Update Calibre Cover when Updating Metadata?" +msgstr "Mettre à jour les couvertures calibre lors de la mise à jour des métadonnées ?" + +#: config.py:390 +msgid "" +"Update calibre book cover image from EPUB when metadata is updated. (EPUB only.)\n" +"Doesn't go looking for new images on 'Update Calibre Metadata Only'." +msgstr "Met à jour les images de couverture calibre depuis l'ePub quand les métadonnées sont mises à jour. (Uniquement ePub)\n Ne va pas rechercher de nouvelles images sur \"Mettre seulement les métadonnées de calibre à jour\"." + +#: config.py:394 +msgid "Keep Existing Tags when Updating Metadata?" +msgstr "Garder les étiquettes existantes quand mise à jour des métadonnées ?" + +#: config.py:395 +msgid "" +"Existing tags will be kept and any new tags added.\n" +"%(cmplt)s and %(inprog)s tags will be still be updated, if known.\n" +"%(lul)s tags will be updated if %(lus)s in %(is)s.\n" +"(If Tags is set to 'New Only' in the Standard Columns tab, this has no effect.)" +msgstr "Les étiquettes existantes seront gardées et toutes les nouvelles étiquettes ajoutées.\nLes étiquettes %(cmplt)s et %(inprog) seront quand même mise à jour, si connues.\nLes étiquettes %(lul)s seront mises à jour si %(lus)s dans %(is)s.\n(Si les étiquettes sont définies à 'Nouveau uniquement\" dans l'onglet colonnes standards, ceci n'a pas d'effet.)" + +#: config.py:399 +msgid "Force Author into Author Sort?" +msgstr "Forcer auteur dans tri par auteur ?" + +#: config.py:400 +msgid "" +"If checked, the author(s) as given will be used for the Author Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'Bob Smith' sort as 'Smith, Bob', etc." +msgstr "Si coché, le(s) auteur(s) comme indiqué seront utilisés pour le tri par auteur, également.\nSi non coché, calibre appliquera sont algorithme intégré qui fait que \"Bob Smith\" sorte comme \"Smith, Bob\" etc." + +#: config.py:404 +msgid "Force Title into Title Sort?" +msgstr "Forcer le titre dans le tri par titre ?" + +#: config.py:405 +msgid "" +"If checked, the title as given will be used for the Title Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'The Title' sort as 'Title, The', etc." +msgstr "Si coché, le titre comme indiqué sera utilisé pour le tri par titre, également.\nSi non coché, calibre appliquera sont algorithme intégré qui fait que \"Le Titre\" sorte comme \"Titre, Le\" etc." + +#: config.py:409 +msgid "Check for existing Series Anthology books?" +msgstr "Cocher pour les livres Séries Anthologies existantes ?" + +#: config.py:410 +msgid "" +"Check for existings Series Anthology books using each new story's series URL before downloading.\n" +"Offer to skip downloading if a Series Anthology is found." +msgstr "Cocher pour les livres Série Anthologie utilisant l'URL de chaque nouvelles séries d'histoires avant de télécharger.\nPropose d'ignorer le téléchargement si une Série Anthologie est trouvée " + +#: config.py:414 +msgid "Check for changed Story URL?" +msgstr "Vérifier le changement d'URL du récit ?" + +#: config.py:415 +msgid "" +"Warn you if an update will change the URL of an existing book.\n" +"fanfiction.net URLs will change from http to https silently." +msgstr "Vous prévient si une mise à jour changera l'URL d'un livre existant.\nLes URLs fanfiction.net changent de http à https silencieusement." + +#: config.py:419 +msgid "Search EPUB text for Story URL?" +msgstr "Rechercher un texte ePub pour une URL de récit ? " + +#: config.py:420 +msgid "" +"Look for first valid story URL inside EPUB text if not found in metadata.\n" +"Somewhat risky, could find wrong URL depending on EPUB content.\n" +"Also finds and corrects bad ffnet URLs from ficsaver.com files." +msgstr "Recherche après la première URL de récit valide dans le texte de l'ePub si non trouvée dans les métadonnées.\nQuelque peu risqué, pourrait trouvé une mauvaise URL dépendant du contenu de l'URL.\nTrouve et corrige également les mauvaises URLs ffnet de ficsaver.com." + +#: config.py:424 +msgid "Mark added/updated books when finished?" +msgstr "Marquer les livres ajoutés/mis à jour quand terminé ?" + +#: config.py:425 +msgid "" +"Mark added/updated books when finished. Use with option below.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "Marque les livres ajoutés/mis à jour quand terminé. Utilisé avec l'option ci-dessous.\nVous pouvez également chercher manuellement après 'marked:ffdl_success'.\n'marked:ffdl_failed' est également disponible, ou chercher 'marked:ffdl' pour les deux." + +#: config.py:429 +msgid "Show Marked books when finished?" +msgstr "Montrer les livres marqués quand terminés ?" + +#: config.py:430 +msgid "" +"Show Marked added/updated books only when finished.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "Montre les livres marqués ajoutés/mis à jour uniquement quans ils sont terminés/\nVous pouvez également rechercher manuellement après 'marked:ffdl_success'./n'marked:ffdl_failed' est aussi disponible, ou cherchez 'marked:ffdl' pour les deux." + +#: config.py:434 +msgid "Automatically Convert new/update books?" +msgstr "Converti automatiquement les livres nouveaux/mis à jour ?" + +#: config.py:435 +msgid "" +"Automatically call calibre's Convert for new/update books.\n" +"Converts to the current output format as chosen in calibre's\n" +"Preferences->Behavior settings." +msgstr "Appele automatiquement le convertisseur de calibre pour les livres nouveaux/mis à jour.\nConverti au format de sortie courant tel que choisi dans les paramètres de calibre\nPréférences->Comportement" + +#: config.py:439 +msgid "GUI Options" +msgstr "Options de l'Interface Graphique Utilisateur" + +#: config.py:443 +msgid "Take URLs from Clipboard?" +msgstr "Prendre les URLs du presse-papier ?" + +#: config.py:444 +msgid "Prefill URLs from valid URLs in Clipboard when Adding New." +msgstr "Pré-rempli les URLs depuis les URLs valides dans le presse-papier lorsque vous utilisez Ajoutez Nouveau" + +#: config.py:448 +msgid "Default to Update when books selected?" +msgstr "Mise à jour par défaut quand les livres sont sélectionnés ?" + +#: config.py:449 +msgid "" +"The top FanFictionDownLoader plugin button will start Update if\n" +"books are selected. If unchecked, it will always bring up 'Add New'." +msgstr "Le bouton supérieur du greffon FanFictionDownloader démarrera la mise à jour si\ndes livres sont sélectionnés. Si décoché, ceci prendra toujours 'Ajouter nouveau\"" + +#: config.py:453 +msgid "Keep 'Add New from URL(s)' dialog on top?" +msgstr "Garder le dialogue 'Ajouter nouveau' au dessus ?" + +#: config.py:454 +msgid "" +"Instructs the OS and Window Manager to keep the 'Add New from URL(s)'\n" +"dialog on top of all other windows. Useful for dragging URLs onto it." +msgstr "Informe l'OS et le gestionnaire de fenêtre de garder le dialogue\n'Ajouter nouveau depuis les URLs' au dessus de toutes les autres fenêtres. Utile pour glisser dessus des URLs." + +#: config.py:458 +msgid "Misc Options" +msgstr "Options diverses" + +#: config.py:463 +msgid "Include images in EPUBs?" +msgstr "Inclure les images dans les ePubs ?" + +#: config.py:464 +msgid "" +"Download and include images in EPUB stories. This is equivalent to " +"adding:%(imgset)s ...to the top of %(pini)s. Your settings in %(pini)s will" +" override this." +msgstr "Télécharge et inclus les images dans les récits ePub. Ceci est équivalent à ajouté :  %(imgset)s ... au dessus de %(pini)s. Vos paramètres %(pini)s outrepassent cela." + +#: config.py:468 +msgid "Inject calibre Series when none found?" +msgstr "Injecter la Série calibre quand aucune n'est trouvée ?" + +#: config.py:469 +msgid "" +"If no series is found, inject the calibre series (if there is one) so it " +"appears on the FFDL title page(not cover)." +msgstr "Si aucune série n'est trouvée, injecte la série calibre (s'il y en a une) aussi ceci apparaît sur la page de titre de FFDL (pas la couverture)" + +#: config.py:473 +msgid "Reject List" +msgstr "Liste des rejets" + +#: config.py:477 +msgid "Edit Reject URL List" +msgstr "Editer la Liste de Rejet URL" + +#: config.py:478 +msgid "Edit list of URLs FFDL will automatically Reject." +msgstr "Edite la liste des URLs FFDL qui seront automatiquement rejetées." + +#: config.py:482 config.py:556 +msgid "Add Reject URLs" +msgstr "Ajouter des URLs rejetées" + +#: config.py:483 +msgid "Add additional URLs to Reject as text." +msgstr "Ajoute des URLs additionnelle à rejeter comme texte." + +#: config.py:487 +msgid "Edit Reject Reasons List" +msgstr "Editer la liste des raisons de rejet" + +#: config.py:488 config.py:547 +msgid "Customize the Reasons presented when Rejecting URLs" +msgstr "Personnalise les raisons présentées quand URLs rejettées" + +#: config.py:492 +msgid "Reject Without Confirmation?" +msgstr "" + +#: config.py:493 +msgid "Always reject URLs on the Reject List without stopping and asking." +msgstr "" + +#: config.py:531 +msgid "Edit Reject URLs List" +msgstr "Editer la liste des URLs rejetées" + +#: config.py:545 +msgid "Reject Reasons" +msgstr "Raisons du rejet" + +#: config.py:546 +msgid "Customize Reject List Reasons" +msgstr "Personnaliser la liste des raisons du rejet" + +#: config.py:554 +msgid "Reason why I rejected it" +msgstr "Raison pour laquelle je la rejette" + +#: config.py:554 +msgid "Title by Author" +msgstr "Titre par auteur" + +#: config.py:557 +msgid "" +"Add Reject URLs. Use: <b>http://...,note</b> or <b>http://...,title by " +"author - note</b><br>Invalid story URLs will be ignored." +msgstr "Ajoute des URLs rejetées. Utilise : <b>http://...,note</b> ou <b>http://...,titre par auteur - note</b><br>Les URLs de récit invalides seront ignorées." + +#: config.py:558 +msgid "" +"One URL per line:\n" +"<b>http://...,note</b>\n" +"<b>http://...,title by author - note</b>" +msgstr "Une URL par ligne : \n<b>http://...,note</b>\n<b>http://...,titre par auteur - note</b>" + +#: config.py:560 dialogs.py:1031 +msgid "Add this reason to all URLs added:" +msgstr "Ajouter cette raison pour toutes les URLs ajoutée : " + +#: config.py:575 +msgid "" +"These settings provide more detailed control over what metadata will be " +"displayed inside the ebook as well as let you set %(isa)s and %(u)s/%(p)s " +"for different sites." +msgstr "Ces paramètres donnent un contrôle plus détaillé sur quelles métadonnées seront affichées dans le le livre aussi bien que si vous régliez %(isa)s et %(u)s pour les différents sites." + +#: config.py:593 +msgid "View Defaults" +msgstr "Afficher les paramètres par défaut" + +#: config.py:594 +msgid "" +"View all of the plugin's configurable settings\n" +"and their default settings." +msgstr "Affiche tous les paramètres configurables du greffon\net leurs paramètres par défaut." + +#: config.py:612 +msgid "Plugin Defaults (%s) (Read-Only)" +msgstr "Paramètres par défaut du greffon (%s) (Lecture seule)" + +#: config.py:613 config.py:619 +msgid "" +"These are all of the plugin's configurable options\n" +"and their default settings." +msgstr "Ceci sont toutes les options configurables du greffon\net leurs paramètres par défaut" + +#: config.py:614 +msgid "Plugin Defaults" +msgstr "Paramètres par défaut du greffon" + +#: config.py:630 dialogs.py:555 dialogs.py:658 +msgid "OK" +msgstr "OK" + +#: config.py:650 +msgid "" +"These settings provide integration with the %(rl)s Plugin. %(rl)s can " +"automatically send to devices and change custom columns. You have to create" +" and configure the lists in %(rl)s to be useful." +msgstr "Ces paramètres fournissent une intégration avec le greffon %(rl)s. %(rl)s peut envoyer et changer les colonnes personnalisées vers les les appareils. Vous avez à créer et configuer les listes dans %(rl)s pour être utilisables." + +#: config.py:655 +msgid "Add new/updated stories to \"Send to Device\" Reading List(s)." +msgstr "Ajouter des récits nouveaux/mis à jour à la/aux liste(s) de lecture de \"Envoyer vers le dispositif\"." + +#: config.py:656 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin." +msgstr "Ajoute automatiquement des récits nouveaux/mis à jour à ces listes dans le greffon %(rl)s." + +#: config.py:661 +msgid "\"Send to Device\" Reading Lists" +msgstr "Listes de lecture de \"Envoyer vers le dispositif\"" + +#: config.py:662 config.py:665 config.py:678 config.py:681 +msgid "" +"When enabled, new/updated stories will be automatically added to these " +"lists." +msgstr "Quand activé, les récits nouveaux/mis à jours seront ajoutés automatiquement à ces listes." + +#: config.py:671 +msgid "Add new/updated stories to \"To Read\" Reading List(s)." +msgstr "Ajouter des récits nouveaux/mis à jour à la/aux liste(s) de lecture \" A lire \"." + +#: config.py:672 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin.\n" +"Also offers menu option to remove stories from the \"To Read\" lists." +msgstr "Ajoute automatiquement des récits nouveau/mis à jour à ces listes dans le greffon %(rl)s.\nPropose également un menu option pour enlever des récits depuis les listes \"A lire\"." + +#: config.py:677 +msgid "\"To Read\" Reading Lists" +msgstr "Listes de lecture \"A lire\"" + +#: config.py:687 +msgid "Add stories back to \"Send to Device\" Reading List(s) when marked \"Read\"." +msgstr "Ajouter à nouveau des récits à/aux Liste(s) de lecture \"Envoyer vers le dispositif\" quand marqué \"Lu\"." + +#: config.py:688 +msgid "" +"Menu option to remove from \"To Read\" lists will also add stories back to " +"\"Send to Device\" Reading List(s)" +msgstr "Option du menu pour retirer des listes de \"A lire\" ajoutera également à nouveau des récits à/aux Liste(s) de lecture \"Envoyer vers le dispositif\"" + +#: config.py:710 +msgid "" +"The %(gc)s plugin can create cover images for books using various metadata " +"and configurations. If you have GC installed, FFDL can run GC on new " +"downloads and metadata updates. Pick a GC setting by site or Default." +msgstr "Le greffon %(gc)s peut créer des images de couverture pour les livres en utilisant diverses métadonnées et configurations. Si vous avez installé Generate Cover, FFDL peut exécuter GC lors de nouveaux téléchargements et des mises à jour de métadonnées" + +#: config.py:728 config.py:732 config.py:745 +msgid "Default" +msgstr "Par défaut" + +#: config.py:733 +msgid "" +"On Metadata update, run %(gc)s with this setting, if not selected for " +"specific site." +msgstr "A la mise à jour des métadonnées, exécute %(gc)s avec ces paramètres, s'ils ne sont pas sélectionnés pour un site spécifique." + +#: config.py:736 +msgid "On Metadata update, run %(gc)s with this setting for %(site)s stories." +msgstr "A la mise à jour de métadonnées, exécute %(gc)s avec ces paramètres pour des %(site)s de récits." + +#: config.py:759 +msgid "Run %(gc)s Only on New Books" +msgstr "Exécuter %(gc)s uniquement pour les nouveaux livres" + +#: config.py:760 +msgid "Default is to run GC any time the calibre metadata is updated." +msgstr "L'option par défaut est d'exécuter GC chaque fois que les métadonnées de calibre sont mises à jour." + +#: config.py:764 +msgid "Allow %(gcset)s from %(pini)s to override" +msgstr "Permettre à %(gcset)s depuis %(pini)s d'outrepasser" + +#: config.py:765 +msgid "" +"The %(pini)s parameter %(gcset)s allows you to choose a GC setting based on " +"metadata rather than site, but it's much more complex.<br \\>%(gcset)s is " +"ignored when this is off." +msgstr "Le paramètre %(pini)s %(gcset)s vous permet de choisir un paramètre Generate Cover basé sur les métadonnées plutôt que le site, mais c'est beaucoup plus complexe.<br\\>%(gcset)s est ignoré quand ceci est à l'arrêt." + +#: config.py:769 +msgid "Use calibre's Polish feature to inject/update the cover" +msgstr "Utiliser la fonction Polish de calibre pour insérer/mettre à jour la couverture" + +#: config.py:770 +msgid "" +"Calibre's Polish feature will be used to inject or update the generated " +"cover into the ebook, EPUB only." +msgstr "L'option Polish de calibre sera utilisée pour insérer ou mettre à jour la couverture générée dans l'e-livre. EPUB uniquement." + +#: config.py:784 +msgid "" +"These settings provide integration with the %(cp)s Plugin. %(cp)s can " +"automatically update custom columns with page, word and reading level " +"statistics. You have to create and configure the columns in %(cp)s first." +msgstr "Ces paramètres permettent l'intégration avec le greffon %(cp)s. %(cp)s peut automatiquement mettre à jour les colonnes personnalisées avec la page, le mot et les statistiques de lecture. Vous devez tout d'abord créer et configurer les colonnes dans %(cp)s." + +#: config.py:789 +msgid "" +"If any of the settings below are checked, when stories are added or updated," +" the %(cp)s Plugin will be called to update the checked statistics." +msgstr "Si n'importe lequel des paramètres ci-dessous est activé, quand les récits sont ajoutés ou mis à jour, le greffon %(cp)s sera appelé pour mettre à jour les statistiques activées." + +#: config.py:795 +msgid "Which column and algorithm to use are configured in %(cp)s." +msgstr "Quels colonne et algorithme à utiliser sont configurés dans %(cp)s." + +#: config.py:803 +msgid "" +"Will overwrite word count from FFDL metadata if set to update the same " +"custom column." +msgstr "Outrepassera le nombre de mots depuis les métadonnées FFDL si réglé sur mettre à jour la même colonne personnalisée." + +#: config.py:834 +msgid "" +"These controls aren't plugin settings as such, but convenience buttons for " +"setting Keyboard shortcuts and getting all the FanFictionDownLoader " +"confirmation dialogs back again." +msgstr "Ces contrôles ne sont pas des paramètres du greffon en soi, mais des boutons de convenance pour paramétrer les raccourcis clavier et l'obtention du rétablissement de tous les dialogues de confirmation de FanFictionDownLoader." + +#: config.py:839 +msgid "Keyboard shortcuts..." +msgstr "Raccourcis clavier..." + +#: config.py:840 +msgid "Edit the keyboard shortcuts associated with this plugin" +msgstr "Editer les raccourcis clavier associés avec ce greffon" + +#: config.py:844 +msgid "Reset disabled &confirmation dialogs" +msgstr "Réinitialiser &les dialogues de confirmation désactivés" + +#: config.py:845 +msgid "Reset all show me again dialogs for the FanFictionDownLoader plugin" +msgstr "Réinitialiser tous les dialogues afficher moi du greffon FanFictionDownLoader" + +#: config.py:849 +msgid "&View library preferences..." +msgstr "&Voir les préférences de la bibliothèque..." + +#: config.py:850 +msgid "View data stored in the library database for this plugin" +msgstr "Voir les données stockées pour ce greffon dans la base de donnée de la bibliothèque" + +#: config.py:861 +msgid "Done" +msgstr "Terminé" + +#: config.py:862 +msgid "Confirmation dialogs have all been reset" +msgstr "Les dialogues de confirmation ont tous été réinitialisés" + +#: config.py:910 +msgid "Category" +msgstr "Catégorie" + +#: config.py:911 +msgid "Genre" +msgstr "Genre" + +#: config.py:912 +msgid "Language" +msgstr "Langue" + +#: config.py:913 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Status" +msgstr "Statut" + +#: config.py:914 +msgid "Status:%(cmplt)s" +msgstr "Statut : %(cmplt)s" + +#: config.py:915 +msgid "Status:%(inprog)s" +msgstr "Statut : %(inprog)s" + +#: config.py:916 config.py:1050 +msgid "Series" +msgstr "Séries" + +#: config.py:917 +msgid "Characters" +msgstr "Caractères" + +#: config.py:918 +msgid "Relationships" +msgstr "Relations" + +#: config.py:919 +msgid "Published" +msgstr "Publié" + +#: config.py:920 ffdl_plugin.py:1403 ffdl_plugin.py:1422 +msgid "Updated" +msgstr "Mis à jour" + +#: config.py:921 +msgid "Created" +msgstr "Créé" + +#: config.py:922 +msgid "Rating" +msgstr "Note" + +#: config.py:923 +msgid "Warnings" +msgstr "Avertissements" + +#: config.py:924 +msgid "Chapters" +msgstr "Chapitres" + +#: config.py:925 +msgid "Words" +msgstr "Mots" + +#: config.py:926 +msgid "Site" +msgstr "Site" + +#: config.py:927 +msgid "Story ID" +msgstr "ID du récit" + +#: config.py:928 +msgid "Author ID" +msgstr "ID de l'auteur" + +#: config.py:929 +msgid "Extra Tags" +msgstr "Etiquettes additionnelles" + +#: config.py:930 config.py:1042 dialogs.py:817 dialogs.py:913 +#: ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Title" +msgstr "Titre" + +#: config.py:931 +msgid "Story URL" +msgstr "URL du récit" + +#: config.py:932 +msgid "Description" +msgstr "Description" + +#: config.py:933 dialogs.py:817 dialogs.py:913 ffdl_plugin.py:1126 +#: ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Author" +msgstr "Auteur" + +#: config.py:934 +msgid "Author URL" +msgstr "URL de l'auteur" + +#: config.py:935 +msgid "File Format" +msgstr "Format de fichier" + +#: config.py:936 +msgid "File Extension" +msgstr "Extension de fichier" + +#: config.py:937 +msgid "Site Abbrev" +msgstr "Site abrégé" + +#: config.py:938 +msgid "FFDL Version" +msgstr "Version FFDL" + +#: config.py:953 +msgid "" +"If you have custom columns defined, they will be listed below. Choose a " +"metadata value type to fill your columns automatically." +msgstr "Si vous avez défini des colonnes personnalisées, elles seront listées ci-dessous. Choisissez un type de valeur de métadonnées pour remplir vos colonnes automatiquement." + +#: config.py:978 +msgid "Update this %s column(%s) with..." +msgstr "Mettre à jour cette %s colonne(%s) avec..." + +#: config.py:988 +msgid "Values that aren't valid for this enumeration column will be ignored." +msgstr "Les valeurs qui ne sont pas valides pour cette énumération de colonne seront ignorées." + +#: config.py:988 config.py:990 +msgid "Metadata values valid for this type of column." +msgstr "Valeurs de métadonnées valides pour ce type de colonne." + +#: config.py:993 config.py:1069 +msgid "New Only" +msgstr "Nouveau uniquement" + +#: config.py:994 +msgid "" +"Write to %s(%s) only for new\n" +"books, not updates to existing books." +msgstr "Ecrire %s(%s) uniquement pour les nouveaux\nlivres, pas de mises à jour aux livres existants." + +#: config.py:1005 +msgid "Allow %(ccset)s from %(pini)s to override" +msgstr "Permettre à %(ccset)s depuis %(pini)s d'outrepasser" + +#: config.py:1006 +msgid "" +"The %(pini)s parameter %(ccset)s allows you to set custom columns to site " +"specific values that aren't common to all sites.<br />%(ccset)s is ignored " +"when this is off." +msgstr "Le paramètre %(pini)s %(ccset)s vous permet de régler des colonnes personnalisées à des valeurs spécifiques d'un site qui ne sont pas communes à tous les sites.<br\\>%(ccset)s est ignoré quand ceci est à l'arrêt." + +#: config.py:1011 +msgid "Special column:" +msgstr "Colonne spéciale :" + +#: config.py:1016 +msgid "Update/Overwrite Error Column:" +msgstr "Mettre à jour/outrepasser la colonne d'erreur :" + +#: config.py:1017 +msgid "" +"When an update or overwrite of an existing story fails, record the reason in this column.\n" +"(Text and Long Text columns only.)" +msgstr "Lorsque la mise à jour ou l'écrasement d'un récit existant échoue, enregistrer la raison dans cette colonne.\n(Colonnes de texte et de texte descriptif uniquement.) " + +#: config.py:1043 +msgid "Author(s)" +msgstr "Auteur(s)" + +#: config.py:1044 +msgid "Publisher" +msgstr "Editeur" + +#: config.py:1045 +msgid "Tags" +msgstr "Etiquettes" + +#: config.py:1046 +msgid "Languages" +msgstr "Langue(s)" + +#: config.py:1047 +msgid "Published Date" +msgstr "Date de publication" + +#: config.py:1048 +msgid "Date" +msgstr "Date" + +#: config.py:1049 +msgid "Comments" +msgstr "Commentaires" + +#: config.py:1051 +msgid "Ids(url id only)" +msgstr "Ids(id url seulement)" + +#: config.py:1056 +msgid "" +"The standard calibre metadata columns are listed below. You may choose " +"whether FFDL will fill each column automatically on updates or only for new " +"books." +msgstr "Les colonnes de métadonnées stantards de calibre sont listées ci-dessous. Vous pouvez choisir si FFDL remplira chaque colonne automatiquement lors des mises à jour ou seulement pour de nouveaux livres." + +#: config.py:1070 +msgid "" +"Write to %s only for new\n" +"books, not updates to existing books." +msgstr "Ecrire sur %s uniquement pour les nouveaux\nlivres, pas de mises à jour pour les livres existants." + +#: dialogs.py:69 +msgid "Skip" +msgstr "Ignorer" + +#: dialogs.py:70 +msgid "Add New Book" +msgstr "Ajouter Nouveau Livre" + +#: dialogs.py:71 +msgid "Update EPUB if New Chapters" +msgstr "Mettre à jour l'ePub s'il y a de nouveaux chapitres" + +#: dialogs.py:72 +msgid "Update EPUB Always" +msgstr "Mettre toujours l'ePub à jour" + +#: dialogs.py:73 +msgid "Overwrite if Newer" +msgstr "Ecraser si nouveau" + +#: dialogs.py:74 +msgid "Overwrite Always" +msgstr "Ecraser toujours" + +#: dialogs.py:75 +msgid "Update Calibre Metadata Only" +msgstr "Mettre uniquement à jour les métadonnées de calibre" + +#: dialogs.py:252 ffdl_plugin.py:89 +msgid "FanFictionDownLoader" +msgstr "FanFictionDownLoader" + +#: dialogs.py:269 dialogs.py:716 +msgid "Show Download Options" +msgstr "Afficher les options de téléchargement" + +#: dialogs.py:288 dialogs.py:733 +msgid "Output &Format:" +msgstr "&Format de sortie :" + +#: dialogs.py:296 dialogs.py:741 +msgid "" +"Choose output format to create. May set default from plugin configuration." +msgstr "Choisir le format de sortie à créer. Peut être réglé par défaut depuis la configuration du greffon." + +#: dialogs.py:324 dialogs.py:758 +msgid "Update Calibre &Metadata?" +msgstr "Mettre à jour les &métadonnées calibre ?" + +#: dialogs.py:325 dialogs.py:759 +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 "Mettre à jour les métadonnées pour les récits existants dans calibre depuis le site web ?\n(Les colonnes définies à \"Nouveau uniquement\" dans les étiquettes de colonne seront uniquement définies pour les nouveaux livres.)" + +#: dialogs.py:331 dialogs.py:763 +msgid "Update EPUB Cover?" +msgstr "Mettre à jour la couverture de l'ePub ?" + +#: dialogs.py:332 dialogs.py:764 +msgid "" +"Update book cover image from site or defaults (if found) <i>inside</i> the " +"EPUB when EPUB is updated." +msgstr "Met à jour l'image de couverture du livre depuis le site ou par défaut (si trouvée) <i>à l'intérieur</i> de l'ePub quand l'ePub est mis à jour." + +#: dialogs.py:379 +msgid "Story URL(s) for anthology, one per line:" +msgstr "URL(s) de récit pour anthologie, une par ligne :" + +#: dialogs.py:380 +msgid "" +"URLs for stories to include in the anthology, one per line.\n" +"Will take URLs from clipboard, but only valid URLs." +msgstr "URLs pour les récits à inclure dans l'anthologie, une par ligne.\nPrendra les URLs depuis le presse papier, mais seulement les URLs valides." + +#: dialogs.py:381 +msgid "If Story Already Exists in Anthology?" +msgstr "Si le récit existe déjà dans Anthologie ?" + +#: dialogs.py:382 +msgid "" +"What to do if there's already an existing story with the same URL in the " +"anthology." +msgstr "Que faire s'il y déjà un récit existant avec la même URL dans l'anthologie." + +#: dialogs.py:391 +msgid "Story URL(s), one per line:" +msgstr "URL(s) de récit, une par ligne :" + +#: dialogs.py:392 +msgid "" +"URLs for stories, one per line.\n" +"Will take URLs from clipboard, but only valid URLs.\n" +"Add [1,5] after the URL to limit the download to chapters 1-5." +msgstr "URLs pour les récits, une par ligne.\nPrendra les URLs depuis le presse papier, mais seulement les URLs valides.\nAjoute [1,5] après l'URL pour limiter le téléchargement aux chapitres 1-5" + +#: dialogs.py:393 +msgid "If Story Already Exists?" +msgstr "Si le récit existe déjà ?" + +#: dialogs.py:394 +msgid "" +"What to do if there's already an existing story with the same URL or title " +"and author." +msgstr "Que faire s'il y a déjà un récit existant avec la même URL ou titre et auteur." + +#: dialogs.py:494 +msgid "For Individual Books" +msgstr "Pour des livres individuels" + +#: dialogs.py:495 +msgid "Get URLs and go to dialog for individual story downloads." +msgstr "Obtenir les URLs et se rendre dans la boîte de dialogue pour les téléchargments de récit individuel." + +#: dialogs.py:499 +msgid "For Anthology Epub" +msgstr "Pour un ePub Anthologie" + +#: dialogs.py:500 +msgid "" +"Get URLs and go to dialog for Anthology download.\n" +"Requires %s plugin." +msgstr "Obtenir les URLs et se rendre dans la boîte de dialogue pour le téléchargement d'une Anthologie.\nRequiert le greffon %s." + +#: dialogs.py:505 dialogs.py:559 dialogs.py:586 +msgid "Cancel" +msgstr "Annuler" + +#: dialogs.py:537 +msgid "Password" +msgstr "Mot de passe" + +#: dialogs.py:538 +msgid "Author requires a password for this story(%s)." +msgstr "L'Auteur requiert un mot de passe pour ce récit(%s)." + +#: dialogs.py:543 +msgid "User/Password" +msgstr "Utilisateur/Mot de passe" + +#: dialogs.py:544 +msgid "%s requires you to login to download this story." +msgstr "%s requiert que vous vous identifiez pour télécharger ce récit" + +#: dialogs.py:546 +msgid "User:" +msgstr "Utilisateur :" + +#: dialogs.py:550 +msgid "Password:" +msgstr "Mot de passe :" + +#: dialogs.py:581 +msgid "Fetching metadata for stories..." +msgstr "Occupé à rechercher des métadonnées pour les récits..." + +#: dialogs.py:582 +msgid "Downloading metadata for stories" +msgstr "Téléchargement des métadonnées pour les récits" + +#: dialogs.py:583 +msgid "Fetched metadata for" +msgstr "Métadonnées recherchées pour" + +#: dialogs.py:653 ffdl_plugin.py:325 +msgid "About FanFictionDownLoader" +msgstr "Á propos de FanFictionDownLoader" + +#: dialogs.py:707 +msgid "Remove selected books from the list" +msgstr "Retirer les livres sélectionnés de la liste" + +#: dialogs.py:746 +msgid "Update Mode:" +msgstr "Mode de mise à jour : " + +#: dialogs.py:749 +msgid "" +"What sort of update to perform. May set default from plugin configuration." +msgstr "Quel type de mise à jour à effectuer. Peut être réglé par défaut dans la configuration du greffon." + +#: dialogs.py:817 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Comment" +msgstr "Commentaire" + +#: dialogs.py:885 +msgid "Are you sure you want to remove this book from the list?" +msgstr "Êtes-vous sûr de vouloir retirer ce livre de la liste ?" + +#: dialogs.py:887 +msgid "Are you sure you want to remove the selected %d books from the list?" +msgstr "Êtes-vous sûr de vouloir retirer le livre sélectionné %d de la liste ?" + +#: dialogs.py:913 +msgid "Note" +msgstr "Note" + +#: dialogs.py:955 +msgid "Select or Edit Reject Note." +msgstr "Sélectionner ou éditer la note de rejet" + +#: dialogs.py:963 +msgid "Are you sure you want to remove this URL from the list?" +msgstr "Êtes-vous sûr de vouloir retirer cette URL de la liste ?" + +#: dialogs.py:965 +msgid "Are you sure you want to remove the %d selected URLs from the list?" +msgstr "Êtes-vous sûr de vouloir retirer les URLs sélectionnées %d de la liste ?" + +#: dialogs.py:983 +msgid "List of Books to Reject" +msgstr "Liste des livres à rejetter" + +#: dialogs.py:996 +msgid "" +"FFDL will remember these URLs and display the note and offer to reject them " +"if you try to download them again later." +msgstr "FFDL se souviendra de ces URLs, affichera la note et proposera de rejetter celles-ci si vous essayer de les télécharger à nouveau par après." + +#: dialogs.py:1010 +msgid "Remove selected URL(s) from the list" +msgstr "Retire les URLs sélectionnées de la liste" + +#: dialogs.py:1028 dialogs.py:1032 +msgid "This will be added to whatever note you've set for each URL above." +msgstr "Ceci sera ajouté à n'importe quelle note que vous avez composée pour chaque URL ci-dessus." + +#: dialogs.py:1041 +msgid "Delete Books (including books without FanFiction URLs)?" +msgstr "Supprimer les livres (incluant les livres sans URL(s) de FanFiction) ?" + +#: dialogs.py:1042 +msgid "Delete the selected books after adding them to the Rejected URLs list." +msgstr "Supprime les livres sélectionnés après les avoir ajoutés à la liste des URLs rejetées." + +#: ffdl_plugin.py:90 +msgid "Download FanFiction stories from various web sites" +msgstr "Télécharger des récits FanFiction de différents sites" + +#: ffdl_plugin.py:120 +msgid "FanFictionDL" +msgstr "FanFictionDL" + +#: ffdl_plugin.py:243 +msgid "&Add New from URL(s)" +msgstr "&Ajouter nouveau depuis l'URL(s)" + +#: ffdl_plugin.py:245 +msgid "Add New FanFiction Book(s) from URL(s)" +msgstr "Ajouter un /des nouveau(x) livre(s) de Fanfiction depuis l'URL(s)" + +#: ffdl_plugin.py:248 +msgid "&Update Existing FanFiction Book(s)" +msgstr "&Mettre à jour le(s) livre(s) FanFiction existant(s)" + +#: ffdl_plugin.py:254 +msgid "Get Story URLs to Download from Web Page" +msgstr "Prendre les URLs de récit à télécharger depuis la page web" + +#: ffdl_plugin.py:258 +msgid "&Make Anthology Epub Manually from URL(s)" +msgstr "Faire manuellement un ePub d'anthologie depuis depuis l'/les URL(s)" + +#: ffdl_plugin.py:260 +msgid "Make FanFiction Anthology Epub Manually from URL(s)" +msgstr "&Faire manuellement un ePub d'anthologie FanFiction depuis depuis l'/les URL(s)" + +#: ffdl_plugin.py:263 +msgid "&Update Anthology Epub" +msgstr "&Mettre à jour un ePub Anthologie" + +#: ffdl_plugin.py:265 +msgid "Update FanFiction Anthology Epub" +msgstr "Mettre à jour un ePub Anthologie FanFiction" + +#: ffdl_plugin.py:273 +msgid "Add to \"To Read\" and \"Send to Device\" Lists" +msgstr "Ajouter aux listes \"A lire\" et \"Envoyer vers le dispositif\"" + +#: ffdl_plugin.py:275 +msgid "Remove from \"To Read\" and add to \"Send to Device\" Lists" +msgstr "Retirer des listes de \"A lire\" et ajouter à \"Envoyer vers le dispositif\"" + +#: ffdl_plugin.py:277 ffdl_plugin.py:282 +msgid "Remove from \"To Read\" Lists" +msgstr "Retirer des listes \"A lire\"." + +#: ffdl_plugin.py:279 +msgid "Add Selected to \"Send to Device\" Lists" +msgstr "Ajouter sélectionné aux listes \"Envoyer vers le dispositif\"" + +#: ffdl_plugin.py:281 +msgid "Add to \"To Read\" Lists" +msgstr "Ajouter aux listes \"A lire\"" + +#: ffdl_plugin.py:297 +msgid "Get URLs from Selected Books" +msgstr "Prendre les URLs depuis les livres sélectionnés" + +#: ffdl_plugin.py:303 ffdl_plugin.py:396 +msgid "Get Story URLs from Web Page" +msgstr "Prendre les URLs de récit depuis la page web" + +#: ffdl_plugin.py:308 +msgid "Reject Selected Books" +msgstr "Rejeter les livres sélectionnés" + +#: ffdl_plugin.py:316 +msgid "&Configure Plugin" +msgstr "&Configurer le greffon" + +#: ffdl_plugin.py:319 +msgid "Configure FanFictionDownLoader" +msgstr "Configurer FanFictionDownLoader" + +#: ffdl_plugin.py:322 +msgid "About Plugin" +msgstr "Á propos du greffon" + +#: ffdl_plugin.py:379 +msgid "Cannot Update Reading Lists from Device View" +msgstr "Ne peut mettre à jour Les listes de lecture depuis la Vue Dispositif" + +#: ffdl_plugin.py:383 +msgid "No Selected Books to Update Reading Lists" +msgstr "Pas de livres sélectionnés pour mettre à jour les Listes de Lecture" + +#: ffdl_plugin.py:407 ffdl_plugin.py:459 +msgid "List of Story URLs" +msgstr "Liste des URLs de Récit" + +#: ffdl_plugin.py:408 +msgid "No Valid Story URLs found on given page." +msgstr "Pas d'URL de récit valide trouvée sur la page donnée" + +#: ffdl_plugin.py:423 +msgid "No Selected Books to Get URLs From" +msgstr "Pas de livres sélectionnés pour y prendre des URLs" + +#: ffdl_plugin.py:441 +msgid "Collecting URLs for stories..." +msgstr "Occupé à collecter des URLs pour des récits" + +#: ffdl_plugin.py:442 +msgid "Get URLs for stories" +msgstr "Prend des URLs pour des récits" + +#: ffdl_plugin.py:443 ffdl_plugin.py:490 ffdl_plugin.py:677 +msgid "URL retrieved" +msgstr "URL récupérée" + +#: ffdl_plugin.py:463 +msgid "List of URLs" +msgstr "Liste ds URLs" + +#: ffdl_plugin.py:464 +msgid "No Story URLs found in selected books." +msgstr "Pas d'URLs de récit trouvée dans les livres sélectionnés" + +#: ffdl_plugin.py:480 +msgid "No Selected Books have URLs to Reject" +msgstr "Aucun des livres sélectionnés n'ont d'URLs à rejeter" + +#: ffdl_plugin.py:488 +msgid "Collecting URLs for Reject List..." +msgstr "Occupé à collecter les URLs pour la liste de rejet..." + +#: ffdl_plugin.py:489 +msgid "Get URLs for Reject List" +msgstr "Récupère les URLs pour la liste de rejet" + +#: ffdl_plugin.py:524 +msgid "Proceed to Remove?" +msgstr "Procéder à la suppression ?" + +#: ffdl_plugin.py:524 +msgid "Rejecting FFDL URLs: None of the books selected have FanFiction URLs." +msgstr "Occupé de rejeter les URLs FFDL : aucun des livres sélectionnés n'ont d'URLs FanFiction" + +#: ffdl_plugin.py:546 +msgid "Cannot Make Anthologys without %s" +msgstr "Ne peut faire d'Anthologies sans %s" + +#: ffdl_plugin.py:550 ffdl_plugin.py:654 +msgid "Cannot Update Books from Device View" +msgstr "Ne peut mettre à jour les livres depuis la Vue Dispositif" + +#: ffdl_plugin.py:554 +msgid "Can only update 1 anthology at a time" +msgstr "Peut seulement mettre à jour 1 Anthologie à la fois" + +#: ffdl_plugin.py:563 +msgid "Can only Update Epub Anthologies" +msgstr "Peut seulement mettre à jour des anthologies ePub" + +#: ffdl_plugin.py:581 ffdl_plugin.py:582 +msgid "Cannot Update Anthology" +msgstr "Ne peut mettre à jour Anthologie" + +#: ffdl_plugin.py:582 +msgid "" +"Book isn't an FFDL Anthology or contains book(s) without valid FFDL URLs." +msgstr "Le livre n'est pas une Anthologie FFDL ou contient un/des livre(s) sans URLs FFDL valides." + +#: ffdl_plugin.py:640 +msgid "" +"There are %d stories in the current anthology that are <b>not</b> going to " +"be kept if you go ahead." +msgstr "Il y a des récits %d dans l'actuelle Anthologie qui ne vont <b>pas</b> être gardées si vous continuer plus avant." + +#: ffdl_plugin.py:641 +msgid "Story URLs that will be removed:" +msgstr "URLs de Récit qui seront supprimées :" + +#: ffdl_plugin.py:643 +msgid "Update anyway?" +msgstr "Toujours mettre à jour ?" + +#: ffdl_plugin.py:644 +msgid "Stories Removed" +msgstr "Récits supprimés" + +#: ffdl_plugin.py:661 +msgid "No Selected Books to Update" +msgstr "Pas de livres sélectionnés à mettre à jour" + +#: ffdl_plugin.py:675 +msgid "Collecting stories for update..." +msgstr "Occupé à collecter des récits pour la mise à jour..." + +#: ffdl_plugin.py:676 +msgid "Get stories for updates" +msgstr "Prend des récits pour la mise à jour" + +#: ffdl_plugin.py:686 +msgid "Update Existing List" +msgstr "Mettre à jour la liste existante" + +#: ffdl_plugin.py:738 +msgid "Started fetching metadata for %s stories." +msgstr "A démarrré la recherche des métadonnées pour les récits %s." + +#: ffdl_plugin.py:744 +msgid "No valid story URLs entered." +msgstr "Pas d'URLs de récit valides entrées." + +#: ffdl_plugin.py:769 ffdl_plugin.py:775 +msgid "Reject URL?" +msgstr "Rejeter l'URL ?" + +#: ffdl_plugin.py:776 ffdl_plugin.py:794 +msgid "<b>%s</b> is on your Reject URL list:" +msgstr "<b>%s</b> est sur votre liste d'URL Rejetées :" + +#: ffdl_plugin.py:778 +msgid "Click '<b>Yes</b>' to Reject." +msgstr "Cliquer '<b>Oui</b>' pour rejeter." + +#: ffdl_plugin.py:779 ffdl_plugin.py:875 +msgid "Click '<b>No</b>' to download anyway." +msgstr "Cliquer '<b>Non</b>' pour télécharger quand même." + +#: ffdl_plugin.py:781 +msgid "Story on Reject URLs list (%s)." +msgstr "Récit sur la liste des URLs rejetées (%s)" + +#: ffdl_plugin.py:784 +msgid "Rejected" +msgstr "Rejeté" + +#: ffdl_plugin.py:787 +msgid "Remove Reject URL?" +msgstr "Retirer l'URL rejetée ?" + +#: ffdl_plugin.py:793 +msgid "Remove URL from Reject List?" +msgstr "Retirer l'URL de la Liste de Rejets ?" + +#: ffdl_plugin.py:796 +msgid "Click '<b>Yes</b>' to remove it from the list," +msgstr "Cliquer '<b>Oui</b>' pour retirer de la liste," + +#: ffdl_plugin.py:797 +msgid "Click '<b>No</b>' to leave it on the list." +msgstr "Cliquer '<b>Non</b>' pour laisser dans la liste." + +#: ffdl_plugin.py:814 +msgid "Cannot update non-epub format." +msgstr "Ne peut mettre à jour un format non-ePub." + +#: ffdl_plugin.py:851 +msgid "Are You an Adult?" +msgstr "Êtes-vous un adulte ?" + +#: ffdl_plugin.py:852 +msgid "" +"%s requires that you be an adult. Please confirm you are an adult in your " +"locale:" +msgstr "%s requiert que vous soyez un adulte, Veuillez confirmer que vous êtes un adulte dans votre situation :" + +#: ffdl_plugin.py:866 +msgid "Skip Story?" +msgstr "Ignorer le récit ?" + +#: ffdl_plugin.py:872 +msgid "Skip Anthology Story?" +msgstr "Ignorer le récit d'Anthologie ?" + +#: ffdl_plugin.py:873 +msgid "" +"\"<b>%s</b>\" is in series \"<b><a href=\"%s\">%s</a></b>\" that you have an" +" anthology book for." +msgstr "\"<b>%s</b>\" est dans la série \"<b><a href=\"%s\">%s</a></b>\" dont vous avez un livre d'anthologie." + +#: ffdl_plugin.py:874 +msgid "Click '<b>Yes</b>' to Skip." +msgstr "Cliquer '<b>Oui</b>' pour ignorer." + +#: ffdl_plugin.py:877 +msgid "Story in Series Anthology(%s)." +msgstr "Récit dans la série Anthologie(%s)." + +#: ffdl_plugin.py:882 +msgid "Skipped" +msgstr "Ignoré" + +#: ffdl_plugin.py:910 +msgid "Add" +msgstr "Ajouter" + +#: ffdl_plugin.py:923 +msgid "Meta" +msgstr "Meta" + +#: ffdl_plugin.py:956 +msgid "Skipping duplicate story." +msgstr "Ignore les récits en doublons." + +#: ffdl_plugin.py:959 +msgid "" +"More than one identical book by Identifer URL or title/author(s)--can't tell" +" which book to update/overwrite." +msgstr "Plus d'un livre identique par Identifiant URL ou titre/auteur(s)--ne peut pas dire quel livre mettre à jour/écraser." + +#: ffdl_plugin.py:970 +msgid "Update" +msgstr "Mettre à jour" + +#: ffdl_plugin.py:978 ffdl_plugin.py:985 +msgid "Change Story URL?" +msgstr "Changer l'URL de Récit ?" + +#: ffdl_plugin.py:986 +msgid "" +"<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL:" +msgstr "<b>%s</b> par <b>%s</b> est déjà dans votre bibliothèque avec une source URL différente :" + +#: ffdl_plugin.py:987 +msgid "In library: <a href=\"%(liburl)s\">%(liburl)s</a>" +msgstr "Dans la bibliothèque : <a href=\"%(liburl)s\">%(liburl)s</a>" + +#: ffdl_plugin.py:988 ffdl_plugin.py:1002 +msgid "New URL: <a href=\"%(newurl)s\">%(newurl)s</a>" +msgstr "Nouvelle URL : <a href=\"%(newurl)s\">%(newurl)s</a>" + +#: ffdl_plugin.py:989 +msgid "Click '<b>Yes</b>' to update/overwrite book with new URL." +msgstr "Cliquer '<b>Oui</b>' pour mettre à jour/écraser le livre avec la nouvelle URL." + +#: ffdl_plugin.py:990 +msgid "Click '<b>No</b>' to skip updating/overwriting this book." +msgstr "Cliquer '<b>Non</b>' pour ignorer la mise à jour/l'écrasement de ce livre." + +#: ffdl_plugin.py:992 ffdl_plugin.py:999 +msgid "Download as New Book?" +msgstr "Télécharger comme un Nouveau Livre ?" + +#: ffdl_plugin.py:1000 +msgid "" +"<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL." +msgstr "<b>%s</b> par <b>%s</b> est déjà dans votre bibliothèque avec une source URL différente." + +#: ffdl_plugin.py:1001 +msgid "" +"You chose not to update the existing book. Do you want to add a new book " +"for this URL?" +msgstr "Vous choisissez de ne pas mettre à jour le livre existant. Voulez-vous ajouter un nouveau livre pour cette URL ?" + +#: ffdl_plugin.py:1003 +msgid "Click '<b>Yes</b>' to a new book with new URL." +msgstr "Cliquer '<b>Oui</b>' pour un nouveau livre avec une nouvelle URL." + +#: ffdl_plugin.py:1004 +msgid "Click '<b>No</b>' to skip URL." +msgstr "Cliquer '<b>Non</b>' pour ignorer l'URL." + +#: ffdl_plugin.py:1010 +msgid "Update declined by user due to differing story URL(%s)" +msgstr "Mise à jour déclinée par l'utilisateur en raison d'une URL(%s) de récit différente" + +#: ffdl_plugin.py:1013 +msgid "Different URL" +msgstr "URL différente" + +#: ffdl_plugin.py:1018 +msgid "Metadata collected." +msgstr "Métadonnées collectées" + +#: ffdl_plugin.py:1034 +msgid "Already contains %d chapters." +msgstr "Contient déjà des chapitres %d." + +#: ffdl_plugin.py:1039 +msgid "" +"Existing epub contains %d chapters, web site only has %d. Use Overwrite to " +"force update." +msgstr "L'ePub existant contient des chapitres %d, le site web a seulement %d. Utiliser Ecraser pour forcer la mise à jour." + +#: ffdl_plugin.py:1041 +msgid "" +"FFDL doesn't recognize chapters in existing epub, epub is probably from a " +"different source. Use Overwrite to force update." +msgstr "FFDL ne reconnait pas les chapitres dans l'ePub existant, l'ePub est probablement d'une source différente. Utiliser Ecraser pour forcer la mise à jour." + +#: ffdl_plugin.py:1053 +msgid "Not Overwriting, web site is not newer." +msgstr "Ne pas écraser, le site web n'est pas plus récent." + +#: ffdl_plugin.py:1122 +msgid "None of the <b>%d</b> URLs/stories given can be/need to be downloaded." +msgstr "Aucun des URLs/récits <b>%d</b> donnés ne peut être/n'a besoin d'être téléchargé." + +#: ffdl_plugin.py:1123 ffdl_plugin.py:1286 ffdl_plugin.py:1316 +msgid "See log for details." +msgstr "Voir le journal pour les détails." + +#: ffdl_plugin.py:1124 +msgid "Proceed with updating your library(Error Column, if configured)?" +msgstr "Procéder à la mise à jour de votre bibliothèque (Erreur de Colonne, si configuré) ?" + +#: ffdl_plugin.py:1131 ffdl_plugin.py:1298 +msgid "Bad" +msgstr "Mauvais(e)" + +#: ffdl_plugin.py:1139 +msgid "FFDL download ended" +msgstr "Téléchargement FFDL effectué" + +#: ffdl_plugin.py:1139 ffdl_plugin.py:1341 +msgid "FFDL log" +msgstr "Journal de FFDL" + +#: ffdl_plugin.py:1147 +msgid "Download FanFiction Book" +msgstr "Télécharger des livres FanFiction" + +#: ffdl_plugin.py:1154 +msgid "Starting %d FanFictionDownLoads" +msgstr "Démarrage FanFictionDownloads %d" + +#: ffdl_plugin.py:1184 +msgid "Story Details:" +msgstr "Détails du récit : " + +#: ffdl_plugin.py:1187 +msgid "Error Updating Metadata" +msgstr "Erreur de mise à jour des Métadonnées" + +#: ffdl_plugin.py:1188 +msgid "" +"An error has occurred while FFDL was updating calibre's metadata for <a " +"href='%s'>%s</a>." +msgstr "Une erreur s'est produite pendant que FFDL était occupé à mettre à jour les métadonnes de calibre pour <a href='%s'>%s</a>." + +#: ffdl_plugin.py:1189 +msgid "The ebook has been updated, but the metadata has not." +msgstr "Le livre a été mis à jour, mais les métadonnées ne l'ont pas été." + +#: ffdl_plugin.py:1241 +msgid "Finished Adding/Updating %d books." +msgstr "Ajout/mise à jour des livres %d terminé." + +#: ffdl_plugin.py:1249 +msgid "Starting auto conversion of %d books." +msgstr "Démarre l'auto conversion des livres %d." + +#: ffdl_plugin.py:1270 +msgid "No Good Stories for Anthology" +msgstr "Pas de bons récits pour l'Anthologie" + +#: ffdl_plugin.py:1271 +msgid "" +"No good stories/updates where downloaded, Anthology creation/update aborted." +msgstr "Aucun bon récit/mise à jour n'a été téléchargé, la création/mise à jour de l'Anthologie a été abandonnée." + +#: ffdl_plugin.py:1276 ffdl_plugin.py:1315 +msgid "FFDL found <b>%s</b> good and <b>%s</b> bad updates." +msgstr "FFDL trouve la mise à jour <b>%s</b> bonne et <b>%s</b> mauvaise" + +#: ffdl_plugin.py:1283 +msgid "" +"Are you sure you want to continue with creating/updating this Anthology?" +msgstr "Etes-vous certain(e) de vouloir continuer avec la création/mise à jour de cette Anthologie ?" + +#: ffdl_plugin.py:1284 +msgid "Any updates that failed will <b>not</b> be included in the Anthology." +msgstr "Toute mise à jour qui échoue ne sera <b>pas</b> incluse dans l'Anthologie" + +#: ffdl_plugin.py:1285 +msgid "However, if there's an older version, it will still be included." +msgstr "Cependant, s'il y a une version plus ancienne, celle-ci sera quand même incluse." + +#: ffdl_plugin.py:1288 +msgid "Proceed with updating this anthology and your library?" +msgstr "Procéder à la mise à jour de cette anthologie et de votre librairie ?" + +#: ffdl_plugin.py:1296 +msgid "Good" +msgstr "Bon" + +#: ffdl_plugin.py:1317 +msgid "Proceed with updating your library?" +msgstr "Procéder à la mise à jour de votre bibliothèque ?" + +#: ffdl_plugin.py:1341 +msgid "FFDL download complete" +msgstr "Téléchargement FFDL effectué" + +#: ffdl_plugin.py:1354 +msgid "Merging %s books." +msgstr "Fusionnement des livres %s." + +#: ffdl_plugin.py:1394 +msgid "FFDL Adding/Updating books." +msgstr "FFDL ajoute/met à jour des livres." + +#: ffdl_plugin.py:1401 +msgid "Updating calibre for FanFiction stories..." +msgstr "Met à jour calibre depuis des récits FanFiction..." + +#: ffdl_plugin.py:1402 +msgid "Update calibre for FanFiction stories" +msgstr "Mettre à jour calibre depuis des récits FanFiction" + +#: ffdl_plugin.py:1411 +msgid "Adding/Updating %s BAD books." +msgstr "Ajoute/met à jour %s de MAUVAIS livres" + +#: ffdl_plugin.py:1420 +msgid "Updating calibre for BAD FanFiction stories..." +msgstr "Met à jour calibre depuis de MAUVAIS récits Fanfiction..." + +#: ffdl_plugin.py:1421 +msgid "Update calibre for BAD FanFiction stories" +msgstr "Mettre à jour calibre depuis de MAUVAIS récits FanFiction" + +#: ffdl_plugin.py:1447 +msgid "Adding format to book failed for some reason..." +msgstr "Le format ajouté au livre a échoué pour une raison quelconque..." + +#: ffdl_plugin.py:1450 +msgid "Error" +msgstr "Erreur" + +#: ffdl_plugin.py:1723 +msgid "" +"You configured FanFictionDownLoader to automatically update Reading Lists, " +"but you don't have the %s plugin installed anymore?" +msgstr "Vous avec configuré FanFictionDownloader pour mettre à jour automatiquent les Listes de Lecture, mais vous n'avez pas le greffon %s installé ?" + +#: ffdl_plugin.py:1735 +msgid "" +"You configured FanFictionDownLoader to automatically update \"To Read\" " +"Reading Lists, but you don't have any lists set?" +msgstr "Vous avec configuré FanFictionDownloader pour mettre à jour automatiquent les Listes de Lecture \"A lire\", mais vous n'avez paramétré aucunes listes ?" + +#: ffdl_plugin.py:1745 ffdl_plugin.py:1763 +msgid "" +"You configured FanFictionDownLoader to automatically update Reading List " +"'%s', but you don't have a list of that name?" +msgstr "Vous avec configuré FanFictionDownloader pour mettre à jour automatiquent la Liste de Lecture '%s', mais vous n'avez pas de liste à ce nom ?" + +#: ffdl_plugin.py:1751 +msgid "" +"You configured FanFictionDownLoader to automatically update \"Send to " +"Device\" Reading Lists, but you don't have any lists set?" +msgstr "Vous avec configuré FanFictionDownloader pour mette à jour automatiquement les Listes de Lecture \"Envoyé vers le dispositif\", mais vous n'avez paramétré aucune liste ?" + +#: ffdl_plugin.py:1871 +msgid "No story URL found." +msgstr "Pas d'URL de récit trouvée." + +#: ffdl_plugin.py:1874 +msgid "Not Found" +msgstr "Non trouvé(e)" + +#: ffdl_plugin.py:1880 +msgid "URL is not a valid story URL." +msgstr "L'URL n'est pas une URL de récit valide." + +#: ffdl_plugin.py:1883 +msgid "Bad URL" +msgstr "Mauvaise URL" + +#: ffdl_plugin.py:2018 +msgid "Anthology containing:" +msgstr "Anthologie contenant : " + +#: ffdl_plugin.py:2019 +msgid "%s by %s" +msgstr "%s par %s" + +#: ffdl_plugin.py:2038 +msgid " Anthology" +msgstr "Anthologie" + +#: ffdl_plugin.py:2075 +msgid "(was set, removed for security)" +msgstr "(a été paramétré, retiré par sécurité)" diff --git a/calibre-plugin/translations/messages.pot b/calibre-plugin/translations/messages.pot new file mode 100644 index 00000000..2683cbbc --- /dev/null +++ b/calibre-plugin/translations/messages.pot @@ -0,0 +1,1495 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2014-07-14 10:52+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" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: ENCODING\n" +"Generated-By: pygettext.py 1.5\n" + + +#: __init__.py:42 +msgid "UI plugin to download FanFiction stories from various sites." +msgstr "" + +#: __init__.py:109 +msgid "Path to the calibre library. Default is to use the path stored in the settings." +msgstr "" + +#: config.py:161 +msgid "FAQs" +msgstr "" + +#: config.py:161 +msgid "List of Supported Sites" +msgstr "" + +#: config.py:175 +msgid "Basic" +msgstr "" + +#: config.py:196 +msgid "Standard Columns" +msgstr "" + +#: config.py:199 +msgid "Custom Columns" +msgstr "" + +#: config.py:202 +msgid "Other" +msgstr "" + +#: config.py:323 +msgid "These settings control the basic features of the plugin--downloading FanFiction." +msgstr "" + +#: config.py:327 +msgid "Defaults Options on Download" +msgstr "" + +#: config.py:331 +msgid "On each download, FFDL offers an option to select the output format. <br />This sets what that option will default to." +msgstr "" + +#: config.py:333 +msgid "Default Output &Format:" +msgstr "" + +#: config.py:348 +msgid "On each download, FFDL offers an option of what happens if that story already exists. <br />This sets what that option will default to." +msgstr "" + +#: config.py:350 +msgid "Default If Story Already Exists?" +msgstr "" + +#: config.py:364 +msgid "Default Update Calibre &Metadata?" +msgstr "" + +#: config.py:365 +msgid "On each download, FFDL offers an option to update Calibre's metadata (title, author, URL, tags, custom columns, etc) from the web site. <br />This sets whether that will default to on or off. <br />Columns set to 'New Only' in the column tabs will only be set for new books." +msgstr "" + +#: config.py:369 +msgid "Default Update EPUB Cover when Updating EPUB?" +msgstr "" + +#: config.py:370 +msgid "On each download, FFDL offers an option to update the book cover image <i>inside</i> the EPUB from the web site when the EPUB is updated.<br />This sets whether that will default to on or off." +msgstr "" + +#: config.py:374 +msgid "Smarten Punctuation (EPUB only)" +msgstr "" + +#: config.py:375 +msgid "Run Smarten Punctuation from Calibre's Polish Book feature on each EPUB download and update." +msgstr "" + +#: config.py:380 +msgid "Updating Calibre Options" +msgstr "" + +#: config.py:384 +msgid "Delete other existing formats?" +msgstr "" + +#: config.py:385 +msgid "" +"Check this to automatically delete all other ebook formats when updating an existing book.\n" +"Handy if you have both a Nook(epub) and Kindle(mobi), for example." +msgstr "" + +#: config.py:389 +msgid "Update Calibre Cover when Updating Metadata?" +msgstr "" + +#: config.py:390 +msgid "" +"Update calibre book cover image from EPUB when metadata is updated. (EPUB only.)\n" +"Doesn't go looking for new images on 'Update Calibre Metadata Only'." +msgstr "" + +#: config.py:394 +msgid "Keep Existing Tags when Updating Metadata?" +msgstr "" + +#: config.py:395 +msgid "" +"Existing tags will be kept and any new tags added.\n" +"%(cmplt)s and %(inprog)s tags will be still be updated, if known.\n" +"%(lul)s tags will be updated if %(lus)s in %(is)s.\n" +"(If Tags is set to 'New Only' in the Standard Columns tab, this has no effect.)" +msgstr "" + +#: config.py:399 +msgid "Force Author into Author Sort?" +msgstr "" + +#: config.py:400 +msgid "" +"If checked, the author(s) as given will be used for the Author Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'Bob Smith' sort as 'Smith, Bob', etc." +msgstr "" + +#: config.py:404 +msgid "Force Title into Title Sort?" +msgstr "" + +#: config.py:405 +msgid "" +"If checked, the title as given will be used for the Title Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'The Title' sort as 'Title, The', etc." +msgstr "" + +#: config.py:409 +msgid "Check for existing Series Anthology books?" +msgstr "" + +#: config.py:410 +msgid "" +"Check for existings Series Anthology books using each new story's series URL before downloading.\n" +"Offer to skip downloading if a Series Anthology is found." +msgstr "" + +#: config.py:414 +msgid "Check for changed Story URL?" +msgstr "" + +#: config.py:415 +msgid "" +"Warn you if an update will change the URL of an existing book.\n" +"fanfiction.net URLs will change from http to https silently." +msgstr "" + +#: config.py:419 +msgid "Search EPUB text for Story URL?" +msgstr "" + +#: config.py:420 +msgid "" +"Look for first valid story URL inside EPUB text if not found in metadata.\n" +"Somewhat risky, could find wrong URL depending on EPUB content.\n" +"Also finds and corrects bad ffnet URLs from ficsaver.com files." +msgstr "" + +#: config.py:424 +msgid "Mark added/updated books when finished?" +msgstr "" + +#: config.py:425 +msgid "" +"Mark added/updated books when finished. Use with option below.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "" + +#: config.py:429 +msgid "Show Marked books when finished?" +msgstr "" + +#: config.py:430 +msgid "" +"Show Marked added/updated books only when finished.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "" + +#: config.py:434 +msgid "Automatically Convert new/update books?" +msgstr "" + +#: config.py:435 +msgid "" +"Automatically call calibre's Convert for new/update books.\n" +"Converts to the current output format as chosen in calibre's\n" +"Preferences->Behavior settings." +msgstr "" + +#: config.py:439 +msgid "GUI Options" +msgstr "" + +#: config.py:443 +msgid "Take URLs from Clipboard?" +msgstr "" + +#: config.py:444 +msgid "Prefill URLs from valid URLs in Clipboard when Adding New." +msgstr "" + +#: config.py:448 +msgid "Default to Update when books selected?" +msgstr "" + +#: config.py:449 +msgid "" +"The top FanFictionDownLoader plugin button will start Update if\n" +"books are selected. If unchecked, it will always bring up 'Add New'." +msgstr "" + +#: config.py:453 +msgid "Keep 'Add New from URL(s)' dialog on top?" +msgstr "" + +#: config.py:454 +msgid "" +"Instructs the OS and Window Manager to keep the 'Add New from URL(s)'\n" +"dialog on top of all other windows. Useful for dragging URLs onto it." +msgstr "" + +#: config.py:458 +msgid "Misc Options" +msgstr "" + +#: config.py:463 +msgid "Include images in EPUBs?" +msgstr "" + +#: config.py:464 +msgid "Download and include images in EPUB stories. This is equivalent to adding:%(imgset)s ...to the top of %(pini)s. Your settings in %(pini)s will override this." +msgstr "" + +#: config.py:468 +msgid "Inject calibre Series when none found?" +msgstr "" + +#: config.py:469 +msgid "If no series is found, inject the calibre series (if there is one) so it appears on the FFDL title page(not cover)." +msgstr "" + +#: config.py:473 +msgid "Reject List" +msgstr "" + +#: config.py:477 +msgid "Edit Reject URL List" +msgstr "" + +#: config.py:478 +msgid "Edit list of URLs FFDL will automatically Reject." +msgstr "" + +#: config.py:482 config.py:556 +msgid "Add Reject URLs" +msgstr "" + +#: config.py:483 +msgid "Add additional URLs to Reject as text." +msgstr "" + +#: config.py:487 +msgid "Edit Reject Reasons List" +msgstr "" + +#: config.py:488 config.py:547 +msgid "Customize the Reasons presented when Rejecting URLs" +msgstr "" + +#: config.py:492 +msgid "Reject Without Confirmation?" +msgstr "" + +#: config.py:493 +msgid "Always reject URLs on the Reject List without stopping and asking." +msgstr "" + +#: config.py:531 +msgid "Edit Reject URLs List" +msgstr "" + +#: config.py:545 +msgid "Reject Reasons" +msgstr "" + +#: config.py:546 +msgid "Customize Reject List Reasons" +msgstr "" + +#: config.py:554 +msgid "Reason why I rejected it" +msgstr "" + +#: config.py:554 +msgid "Title by Author" +msgstr "" + +#: config.py:557 +msgid "Add Reject URLs. Use: <b>http://...,note</b> or <b>http://...,title by author - note</b><br>Invalid story URLs will be ignored." +msgstr "" + +#: config.py:558 +msgid "" +"One URL per line:\n" +"<b>http://...,note</b>\n" +"<b>http://...,title by author - note</b>" +msgstr "" + +#: config.py:560 dialogs.py:1031 +msgid "Add this reason to all URLs added:" +msgstr "" + +#: config.py:575 +msgid "These settings provide more detailed control over what metadata will be displayed inside the ebook as well as let you set %(isa)s and %(u)s/%(p)s for different sites." +msgstr "" + +#: config.py:593 +msgid "View Defaults" +msgstr "" + +#: config.py:594 +msgid "" +"View all of the plugin's configurable settings\n" +"and their default settings." +msgstr "" + +#: config.py:612 +msgid "Plugin Defaults (%s) (Read-Only)" +msgstr "" + +#: config.py:613 config.py:619 +msgid "" +"These are all of the plugin's configurable options\n" +"and their default settings." +msgstr "" + +#: config.py:614 +msgid "Plugin Defaults" +msgstr "" + +#: config.py:630 dialogs.py:555 dialogs.py:658 +msgid "OK" +msgstr "" + +#: config.py:650 +msgid "These settings provide integration with the %(rl)s Plugin. %(rl)s can automatically send to devices and change custom columns. You have to create and configure the lists in %(rl)s to be useful." +msgstr "" + +#: config.py:655 +msgid "Add new/updated stories to \"Send to Device\" Reading List(s)." +msgstr "" + +#: config.py:656 +msgid "Automatically add new/updated stories to these lists in the %(rl)s plugin." +msgstr "" + +#: config.py:661 +msgid "\"Send to Device\" Reading Lists" +msgstr "" + +#: config.py:662 config.py:665 config.py:678 config.py:681 +msgid "When enabled, new/updated stories will be automatically added to these lists." +msgstr "" + +#: config.py:671 +msgid "Add new/updated stories to \"To Read\" Reading List(s)." +msgstr "" + +#: config.py:672 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin.\n" +"Also offers menu option to remove stories from the \"To Read\" lists." +msgstr "" + +#: config.py:677 +msgid "\"To Read\" Reading Lists" +msgstr "" + +#: config.py:687 +msgid "Add stories back to \"Send to Device\" Reading List(s) when marked \"Read\"." +msgstr "" + +#: config.py:688 +msgid "Menu option to remove from \"To Read\" lists will also add stories back to \"Send to Device\" Reading List(s)" +msgstr "" + +#: config.py:710 +msgid "The %(gc)s plugin can create cover images for books using various metadata and configurations. If you have GC installed, FFDL can run GC on new downloads and metadata updates. Pick a GC setting by site or Default." +msgstr "" + +#: config.py:728 config.py:732 config.py:745 +msgid "Default" +msgstr "" + +#: config.py:733 +msgid "On Metadata update, run %(gc)s with this setting, if not selected for specific site." +msgstr "" + +#: config.py:736 +msgid "On Metadata update, run %(gc)s with this setting for %(site)s stories." +msgstr "" + +#: config.py:759 +msgid "Run %(gc)s Only on New Books" +msgstr "" + +#: config.py:760 +msgid "Default is to run GC any time the calibre metadata is updated." +msgstr "" + +#: config.py:764 +msgid "Allow %(gcset)s from %(pini)s to override" +msgstr "" + +#: config.py:765 +msgid "The %(pini)s parameter %(gcset)s allows you to choose a GC setting based on metadata rather than site, but it's much more complex.<br \\>%(gcset)s is ignored when this is off." +msgstr "" + +#: config.py:769 +msgid "Use calibre's Polish feature to inject/update the cover" +msgstr "" + +#: config.py:770 +msgid "Calibre's Polish feature will be used to inject or update the generated cover into the ebook, EPUB only." +msgstr "" + +#: config.py:784 +msgid "These settings provide integration with the %(cp)s Plugin. %(cp)s can automatically update custom columns with page, word and reading level statistics. You have to create and configure the columns in %(cp)s first." +msgstr "" + +#: config.py:789 +msgid "If any of the settings below are checked, when stories are added or updated, the %(cp)s Plugin will be called to update the checked statistics." +msgstr "" + +#: config.py:795 +msgid "Which column and algorithm to use are configured in %(cp)s." +msgstr "" + +#: config.py:803 +msgid "Will overwrite word count from FFDL metadata if set to update the same custom column." +msgstr "" + +#: config.py:834 +msgid "These controls aren't plugin settings as such, but convenience buttons for setting Keyboard shortcuts and getting all the FanFictionDownLoader confirmation dialogs back again." +msgstr "" + +#: config.py:839 +msgid "Keyboard shortcuts..." +msgstr "" + +#: config.py:840 +msgid "Edit the keyboard shortcuts associated with this plugin" +msgstr "" + +#: config.py:844 +msgid "Reset disabled &confirmation dialogs" +msgstr "" + +#: config.py:845 +msgid "Reset all show me again dialogs for the FanFictionDownLoader plugin" +msgstr "" + +#: config.py:849 +msgid "&View library preferences..." +msgstr "" + +#: config.py:850 +msgid "View data stored in the library database for this plugin" +msgstr "" + +#: config.py:861 +msgid "Done" +msgstr "" + +#: config.py:862 +msgid "Confirmation dialogs have all been reset" +msgstr "" + +#: config.py:910 +msgid "Category" +msgstr "" + +#: config.py:911 +msgid "Genre" +msgstr "" + +#: config.py:912 +msgid "Language" +msgstr "" + +#: config.py:913 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Status" +msgstr "" + +#: config.py:914 +msgid "Status:%(cmplt)s" +msgstr "" + +#: config.py:915 +msgid "Status:%(inprog)s" +msgstr "" + +#: config.py:916 config.py:1050 +msgid "Series" +msgstr "" + +#: config.py:917 +msgid "Characters" +msgstr "" + +#: config.py:918 +msgid "Relationships" +msgstr "" + +#: config.py:919 +msgid "Published" +msgstr "" + +#: config.py:920 ffdl_plugin.py:1403 ffdl_plugin.py:1422 +msgid "Updated" +msgstr "" + +#: config.py:921 +msgid "Created" +msgstr "" + +#: config.py:922 +msgid "Rating" +msgstr "" + +#: config.py:923 +msgid "Warnings" +msgstr "" + +#: config.py:924 +msgid "Chapters" +msgstr "" + +#: config.py:925 +msgid "Words" +msgstr "" + +#: config.py:926 +msgid "Site" +msgstr "" + +#: config.py:927 +msgid "Story ID" +msgstr "" + +#: config.py:928 +msgid "Author ID" +msgstr "" + +#: config.py:929 +msgid "Extra Tags" +msgstr "" + +#: config.py:930 config.py:1042 dialogs.py:817 dialogs.py:913 +#: ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Title" +msgstr "" + +#: config.py:931 +msgid "Story URL" +msgstr "" + +#: config.py:932 +msgid "Description" +msgstr "" + +#: config.py:933 dialogs.py:817 dialogs.py:913 ffdl_plugin.py:1126 +#: ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Author" +msgstr "" + +#: config.py:934 +msgid "Author URL" +msgstr "" + +#: config.py:935 +msgid "File Format" +msgstr "" + +#: config.py:936 +msgid "File Extension" +msgstr "" + +#: config.py:937 +msgid "Site Abbrev" +msgstr "" + +#: config.py:938 +msgid "FFDL Version" +msgstr "" + +#: config.py:953 +msgid "If you have custom columns defined, they will be listed below. Choose a metadata value type to fill your columns automatically." +msgstr "" + +#: config.py:978 +msgid "Update this %s column(%s) with..." +msgstr "" + +#: config.py:988 +msgid "Values that aren't valid for this enumeration column will be ignored." +msgstr "" + +#: config.py:988 config.py:990 +msgid "Metadata values valid for this type of column." +msgstr "" + +#: config.py:993 config.py:1069 +msgid "New Only" +msgstr "" + +#: config.py:994 +msgid "" +"Write to %s(%s) only for new\n" +"books, not updates to existing books." +msgstr "" + +#: config.py:1005 +msgid "Allow %(ccset)s from %(pini)s to override" +msgstr "" + +#: config.py:1006 +msgid "The %(pini)s parameter %(ccset)s allows you to set custom columns to site specific values that aren't common to all sites.<br />%(ccset)s is ignored when this is off." +msgstr "" + +#: config.py:1011 +msgid "Special column:" +msgstr "" + +#: config.py:1016 +msgid "Update/Overwrite Error Column:" +msgstr "" + +#: config.py:1017 +msgid "" +"When an update or overwrite of an existing story fails, record the reason in this column.\n" +"(Text and Long Text columns only.)" +msgstr "" + +#: config.py:1043 +msgid "Author(s)" +msgstr "" + +#: config.py:1044 +msgid "Publisher" +msgstr "" + +#: config.py:1045 +msgid "Tags" +msgstr "" + +#: config.py:1046 +msgid "Languages" +msgstr "" + +#: config.py:1047 +msgid "Published Date" +msgstr "" + +#: config.py:1048 +msgid "Date" +msgstr "" + +#: config.py:1049 +msgid "Comments" +msgstr "" + +#: config.py:1051 +msgid "Ids(url id only)" +msgstr "" + +#: config.py:1056 +msgid "The standard calibre metadata columns are listed below. You may choose whether FFDL will fill each column automatically on updates or only for new books." +msgstr "" + +#: config.py:1070 +msgid "" +"Write to %s only for new\n" +"books, not updates to existing books." +msgstr "" + +#: dialogs.py:69 +msgid "Skip" +msgstr "" + +#: dialogs.py:70 +msgid "Add New Book" +msgstr "" + +#: dialogs.py:71 +msgid "Update EPUB if New Chapters" +msgstr "" + +#: dialogs.py:72 +msgid "Update EPUB Always" +msgstr "" + +#: dialogs.py:73 +msgid "Overwrite if Newer" +msgstr "" + +#: dialogs.py:74 +msgid "Overwrite Always" +msgstr "" + +#: dialogs.py:75 +msgid "Update Calibre Metadata Only" +msgstr "" + +#: dialogs.py:252 ffdl_plugin.py:89 +msgid "FanFictionDownLoader" +msgstr "" + +#: dialogs.py:269 dialogs.py:716 +msgid "Show Download Options" +msgstr "" + +#: dialogs.py:288 dialogs.py:733 +msgid "Output &Format:" +msgstr "" + +#: dialogs.py:296 dialogs.py:741 +msgid "Choose output format to create. May set default from plugin configuration." +msgstr "" + +#: dialogs.py:324 dialogs.py:758 +msgid "Update Calibre &Metadata?" +msgstr "" + +#: dialogs.py:325 dialogs.py:759 +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:331 dialogs.py:763 +msgid "Update EPUB Cover?" +msgstr "" + +#: dialogs.py:332 dialogs.py:764 +msgid "Update book cover image from site or defaults (if found) <i>inside</i> the EPUB when EPUB is updated." +msgstr "" + +#: dialogs.py:379 +msgid "Story URL(s) for anthology, one per line:" +msgstr "" + +#: dialogs.py:380 +msgid "" +"URLs for stories to include in the anthology, one per line.\n" +"Will take URLs from clipboard, but only valid URLs." +msgstr "" + +#: dialogs.py:381 +msgid "If Story Already Exists in Anthology?" +msgstr "" + +#: dialogs.py:382 +msgid "What to do if there's already an existing story with the same URL in the anthology." +msgstr "" + +#: dialogs.py:391 +msgid "Story URL(s), one per line:" +msgstr "" + +#: dialogs.py:392 +msgid "" +"URLs for stories, one per line.\n" +"Will take URLs from clipboard, but only valid URLs.\n" +"Add [1,5] after the URL to limit the download to chapters 1-5." +msgstr "" + +#: dialogs.py:393 +msgid "If Story Already Exists?" +msgstr "" + +#: dialogs.py:394 +msgid "What to do if there's already an existing story with the same URL or title and author." +msgstr "" + +#: dialogs.py:494 +msgid "For Individual Books" +msgstr "" + +#: dialogs.py:495 +msgid "Get URLs and go to dialog for individual story downloads." +msgstr "" + +#: dialogs.py:499 +msgid "For Anthology Epub" +msgstr "" + +#: dialogs.py:500 +msgid "" +"Get URLs and go to dialog for Anthology download.\n" +"Requires %s plugin." +msgstr "" + +#: dialogs.py:505 dialogs.py:559 dialogs.py:586 +msgid "Cancel" +msgstr "" + +#: dialogs.py:537 +msgid "Password" +msgstr "" + +#: dialogs.py:538 +msgid "Author requires a password for this story(%s)." +msgstr "" + +#: dialogs.py:543 +msgid "User/Password" +msgstr "" + +#: dialogs.py:544 +msgid "%s requires you to login to download this story." +msgstr "" + +#: dialogs.py:546 +msgid "User:" +msgstr "" + +#: dialogs.py:550 +msgid "Password:" +msgstr "" + +#: dialogs.py:581 +msgid "Fetching metadata for stories..." +msgstr "" + +#: dialogs.py:582 +msgid "Downloading metadata for stories" +msgstr "" + +#: dialogs.py:583 +msgid "Fetched metadata for" +msgstr "" + +#: dialogs.py:653 ffdl_plugin.py:325 +msgid "About FanFictionDownLoader" +msgstr "" + +#: dialogs.py:707 +msgid "Remove selected books from the list" +msgstr "" + +#: dialogs.py:746 +msgid "Update Mode:" +msgstr "" + +#: dialogs.py:749 +msgid "What sort of update to perform. May set default from plugin configuration." +msgstr "" + +#: dialogs.py:817 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Comment" +msgstr "" + +#: dialogs.py:885 +msgid "Are you sure you want to remove this book from the list?" +msgstr "" + +#: dialogs.py:887 +msgid "Are you sure you want to remove the selected %d books from the list?" +msgstr "" + +#: dialogs.py:913 +msgid "Note" +msgstr "" + +#: dialogs.py:955 +msgid "Select or Edit Reject Note." +msgstr "" + +#: dialogs.py:963 +msgid "Are you sure you want to remove this URL from the list?" +msgstr "" + +#: dialogs.py:965 +msgid "Are you sure you want to remove the %d selected URLs from the list?" +msgstr "" + +#: dialogs.py:983 +msgid "List of Books to Reject" +msgstr "" + +#: dialogs.py:996 +msgid "FFDL will remember these URLs and display the note and offer to reject them if you try to download them again later." +msgstr "" + +#: dialogs.py:1010 +msgid "Remove selected URL(s) from the list" +msgstr "" + +#: dialogs.py:1028 dialogs.py:1032 +msgid "This will be added to whatever note you've set for each URL above." +msgstr "" + +#: dialogs.py:1041 +msgid "Delete Books (including books without FanFiction URLs)?" +msgstr "" + +#: dialogs.py:1042 +msgid "Delete the selected books after adding them to the Rejected URLs list." +msgstr "" + +#: ffdl_plugin.py:90 +msgid "Download FanFiction stories from various web sites" +msgstr "" + +#: ffdl_plugin.py:120 +msgid "FanFictionDL" +msgstr "" + +#: ffdl_plugin.py:243 +msgid "&Add New from URL(s)" +msgstr "" + +#: ffdl_plugin.py:245 +msgid "Add New FanFiction Book(s) from URL(s)" +msgstr "" + +#: ffdl_plugin.py:248 +msgid "&Update Existing FanFiction Book(s)" +msgstr "" + +#: ffdl_plugin.py:254 +msgid "Get Story URLs to Download from Web Page" +msgstr "" + +#: ffdl_plugin.py:258 +msgid "&Make Anthology Epub Manually from URL(s)" +msgstr "" + +#: ffdl_plugin.py:260 +msgid "Make FanFiction Anthology Epub Manually from URL(s)" +msgstr "" + +#: ffdl_plugin.py:263 +msgid "&Update Anthology Epub" +msgstr "" + +#: ffdl_plugin.py:265 +msgid "Update FanFiction Anthology Epub" +msgstr "" + +#: ffdl_plugin.py:273 +msgid "Add to \"To Read\" and \"Send to Device\" Lists" +msgstr "" + +#: ffdl_plugin.py:275 +msgid "Remove from \"To Read\" and add to \"Send to Device\" Lists" +msgstr "" + +#: ffdl_plugin.py:277 ffdl_plugin.py:282 +msgid "Remove from \"To Read\" Lists" +msgstr "" + +#: ffdl_plugin.py:279 +msgid "Add Selected to \"Send to Device\" Lists" +msgstr "" + +#: ffdl_plugin.py:281 +msgid "Add to \"To Read\" Lists" +msgstr "" + +#: ffdl_plugin.py:297 +msgid "Get URLs from Selected Books" +msgstr "" + +#: ffdl_plugin.py:303 ffdl_plugin.py:396 +msgid "Get Story URLs from Web Page" +msgstr "" + +#: ffdl_plugin.py:308 +msgid "Reject Selected Books" +msgstr "" + +#: ffdl_plugin.py:316 +msgid "&Configure Plugin" +msgstr "" + +#: ffdl_plugin.py:319 +msgid "Configure FanFictionDownLoader" +msgstr "" + +#: ffdl_plugin.py:322 +msgid "About Plugin" +msgstr "" + +#: ffdl_plugin.py:379 +msgid "Cannot Update Reading Lists from Device View" +msgstr "" + +#: ffdl_plugin.py:383 +msgid "No Selected Books to Update Reading Lists" +msgstr "" + +#: ffdl_plugin.py:407 ffdl_plugin.py:459 +msgid "List of Story URLs" +msgstr "" + +#: ffdl_plugin.py:408 +msgid "No Valid Story URLs found on given page." +msgstr "" + +#: ffdl_plugin.py:423 +msgid "No Selected Books to Get URLs From" +msgstr "" + +#: ffdl_plugin.py:441 +msgid "Collecting URLs for stories..." +msgstr "" + +#: ffdl_plugin.py:442 +msgid "Get URLs for stories" +msgstr "" + +#: ffdl_plugin.py:443 ffdl_plugin.py:490 ffdl_plugin.py:677 +msgid "URL retrieved" +msgstr "" + +#: ffdl_plugin.py:463 +msgid "List of URLs" +msgstr "" + +#: ffdl_plugin.py:464 +msgid "No Story URLs found in selected books." +msgstr "" + +#: ffdl_plugin.py:480 +msgid "No Selected Books have URLs to Reject" +msgstr "" + +#: ffdl_plugin.py:488 +msgid "Collecting URLs for Reject List..." +msgstr "" + +#: ffdl_plugin.py:489 +msgid "Get URLs for Reject List" +msgstr "" + +#: ffdl_plugin.py:524 +msgid "Proceed to Remove?" +msgstr "" + +#: ffdl_plugin.py:524 +msgid "Rejecting FFDL URLs: None of the books selected have FanFiction URLs." +msgstr "" + +#: ffdl_plugin.py:546 +msgid "Cannot Make Anthologys without %s" +msgstr "" + +#: ffdl_plugin.py:550 ffdl_plugin.py:654 +msgid "Cannot Update Books from Device View" +msgstr "" + +#: ffdl_plugin.py:554 +msgid "Can only update 1 anthology at a time" +msgstr "" + +#: ffdl_plugin.py:563 +msgid "Can only Update Epub Anthologies" +msgstr "" + +#: ffdl_plugin.py:581 ffdl_plugin.py:582 +msgid "Cannot Update Anthology" +msgstr "" + +#: ffdl_plugin.py:582 +msgid "Book isn't an FFDL Anthology or contains book(s) without valid FFDL URLs." +msgstr "" + +#: ffdl_plugin.py:640 +msgid "There are %d stories in the current anthology that are <b>not</b> going to be kept if you go ahead." +msgstr "" + +#: ffdl_plugin.py:641 +msgid "Story URLs that will be removed:" +msgstr "" + +#: ffdl_plugin.py:643 +msgid "Update anyway?" +msgstr "" + +#: ffdl_plugin.py:644 +msgid "Stories Removed" +msgstr "" + +#: ffdl_plugin.py:661 +msgid "No Selected Books to Update" +msgstr "" + +#: ffdl_plugin.py:675 +msgid "Collecting stories for update..." +msgstr "" + +#: ffdl_plugin.py:676 +msgid "Get stories for updates" +msgstr "" + +#: ffdl_plugin.py:686 +msgid "Update Existing List" +msgstr "" + +#: ffdl_plugin.py:738 +msgid "Started fetching metadata for %s stories." +msgstr "" + +#: ffdl_plugin.py:744 +msgid "No valid story URLs entered." +msgstr "" + +#: ffdl_plugin.py:769 ffdl_plugin.py:775 +msgid "Reject URL?" +msgstr "" + +#: ffdl_plugin.py:776 ffdl_plugin.py:794 +msgid "<b>%s</b> is on your Reject URL list:" +msgstr "" + +#: ffdl_plugin.py:778 +msgid "Click '<b>Yes</b>' to Reject." +msgstr "" + +#: ffdl_plugin.py:779 ffdl_plugin.py:875 +msgid "Click '<b>No</b>' to download anyway." +msgstr "" + +#: ffdl_plugin.py:781 +msgid "Story on Reject URLs list (%s)." +msgstr "" + +#: ffdl_plugin.py:784 +msgid "Rejected" +msgstr "" + +#: ffdl_plugin.py:787 +msgid "Remove Reject URL?" +msgstr "" + +#: ffdl_plugin.py:793 +msgid "Remove URL from Reject List?" +msgstr "" + +#: ffdl_plugin.py:796 +msgid "Click '<b>Yes</b>' to remove it from the list," +msgstr "" + +#: ffdl_plugin.py:797 +msgid "Click '<b>No</b>' to leave it on the list." +msgstr "" + +#: ffdl_plugin.py:814 +msgid "Cannot update non-epub format." +msgstr "" + +#: ffdl_plugin.py:851 +msgid "Are You an Adult?" +msgstr "" + +#: ffdl_plugin.py:852 +msgid "%s requires that you be an adult. Please confirm you are an adult in your locale:" +msgstr "" + +#: ffdl_plugin.py:866 +msgid "Skip Story?" +msgstr "" + +#: ffdl_plugin.py:872 +msgid "Skip Anthology Story?" +msgstr "" + +#: ffdl_plugin.py:873 +msgid "\"<b>%s</b>\" is in series \"<b><a href=\"%s\">%s</a></b>\" that you have an anthology book for." +msgstr "" + +#: ffdl_plugin.py:874 +msgid "Click '<b>Yes</b>' to Skip." +msgstr "" + +#: ffdl_plugin.py:877 +msgid "Story in Series Anthology(%s)." +msgstr "" + +#: ffdl_plugin.py:882 +msgid "Skipped" +msgstr "" + +#: ffdl_plugin.py:910 +msgid "Add" +msgstr "" + +#: ffdl_plugin.py:923 +msgid "Meta" +msgstr "" + +#: ffdl_plugin.py:956 +msgid "Skipping duplicate story." +msgstr "" + +#: ffdl_plugin.py:959 +msgid "More than one identical book by Identifer URL or title/author(s)--can't tell which book to update/overwrite." +msgstr "" + +#: ffdl_plugin.py:970 +msgid "Update" +msgstr "" + +#: ffdl_plugin.py:978 ffdl_plugin.py:985 +msgid "Change Story URL?" +msgstr "" + +#: ffdl_plugin.py:986 +msgid "<b>%s</b> by <b>%s</b> is already in your library with a different source URL:" +msgstr "" + +#: ffdl_plugin.py:987 +msgid "In library: <a href=\"%(liburl)s\">%(liburl)s</a>" +msgstr "" + +#: ffdl_plugin.py:988 ffdl_plugin.py:1002 +msgid "New URL: <a href=\"%(newurl)s\">%(newurl)s</a>" +msgstr "" + +#: ffdl_plugin.py:989 +msgid "Click '<b>Yes</b>' to update/overwrite book with new URL." +msgstr "" + +#: ffdl_plugin.py:990 +msgid "Click '<b>No</b>' to skip updating/overwriting this book." +msgstr "" + +#: ffdl_plugin.py:992 ffdl_plugin.py:999 +msgid "Download as New Book?" +msgstr "" + +#: ffdl_plugin.py:1000 +msgid "<b>%s</b> by <b>%s</b> is already in your library with a different source URL." +msgstr "" + +#: ffdl_plugin.py:1001 +msgid "You chose not to update the existing book. Do you want to add a new book for this URL?" +msgstr "" + +#: ffdl_plugin.py:1003 +msgid "Click '<b>Yes</b>' to a new book with new URL." +msgstr "" + +#: ffdl_plugin.py:1004 +msgid "Click '<b>No</b>' to skip URL." +msgstr "" + +#: ffdl_plugin.py:1010 +msgid "Update declined by user due to differing story URL(%s)" +msgstr "" + +#: ffdl_plugin.py:1013 +msgid "Different URL" +msgstr "" + +#: ffdl_plugin.py:1018 +msgid "Metadata collected." +msgstr "" + +#: ffdl_plugin.py:1034 +msgid "Already contains %d chapters." +msgstr "" + +#: ffdl_plugin.py:1039 +msgid "Existing epub contains %d chapters, web site only has %d. Use Overwrite to force update." +msgstr "" + +#: ffdl_plugin.py:1041 +msgid "FFDL doesn't recognize chapters in existing epub, epub is probably from a different source. Use Overwrite to force update." +msgstr "" + +#: ffdl_plugin.py:1053 +msgid "Not Overwriting, web site is not newer." +msgstr "" + +#: ffdl_plugin.py:1122 +msgid "None of the <b>%d</b> URLs/stories given can be/need to be downloaded." +msgstr "" + +#: ffdl_plugin.py:1123 ffdl_plugin.py:1286 ffdl_plugin.py:1316 +msgid "See log for details." +msgstr "" + +#: ffdl_plugin.py:1124 +msgid "Proceed with updating your library(Error Column, if configured)?" +msgstr "" + +#: ffdl_plugin.py:1131 ffdl_plugin.py:1298 +msgid "Bad" +msgstr "" + +#: ffdl_plugin.py:1139 +msgid "FFDL download ended" +msgstr "" + +#: ffdl_plugin.py:1139 ffdl_plugin.py:1341 +msgid "FFDL log" +msgstr "" + +#: ffdl_plugin.py:1147 +msgid "Download FanFiction Book" +msgstr "" + +#: ffdl_plugin.py:1154 +msgid "Starting %d FanFictionDownLoads" +msgstr "" + +#: ffdl_plugin.py:1184 +msgid "Story Details:" +msgstr "" + +#: ffdl_plugin.py:1187 +msgid "Error Updating Metadata" +msgstr "" + +#: ffdl_plugin.py:1188 +msgid "An error has occurred while FFDL was updating calibre's metadata for <a href='%s'>%s</a>." +msgstr "" + +#: ffdl_plugin.py:1189 +msgid "The ebook has been updated, but the metadata has not." +msgstr "" + +#: ffdl_plugin.py:1241 +msgid "Finished Adding/Updating %d books." +msgstr "" + +#: ffdl_plugin.py:1249 +msgid "Starting auto conversion of %d books." +msgstr "" + +#: ffdl_plugin.py:1270 +msgid "No Good Stories for Anthology" +msgstr "" + +#: ffdl_plugin.py:1271 +msgid "No good stories/updates where downloaded, Anthology creation/update aborted." +msgstr "" + +#: ffdl_plugin.py:1276 ffdl_plugin.py:1315 +msgid "FFDL found <b>%s</b> good and <b>%s</b> bad updates." +msgstr "" + +#: ffdl_plugin.py:1283 +msgid "Are you sure you want to continue with creating/updating this Anthology?" +msgstr "" + +#: ffdl_plugin.py:1284 +msgid "Any updates that failed will <b>not</b> be included in the Anthology." +msgstr "" + +#: ffdl_plugin.py:1285 +msgid "However, if there's an older version, it will still be included." +msgstr "" + +#: ffdl_plugin.py:1288 +msgid "Proceed with updating this anthology and your library?" +msgstr "" + +#: ffdl_plugin.py:1296 +msgid "Good" +msgstr "" + +#: ffdl_plugin.py:1317 +msgid "Proceed with updating your library?" +msgstr "" + +#: ffdl_plugin.py:1341 +msgid "FFDL download complete" +msgstr "" + +#: ffdl_plugin.py:1354 +msgid "Merging %s books." +msgstr "" + +#: ffdl_plugin.py:1394 +msgid "FFDL Adding/Updating books." +msgstr "" + +#: ffdl_plugin.py:1401 +msgid "Updating calibre for FanFiction stories..." +msgstr "" + +#: ffdl_plugin.py:1402 +msgid "Update calibre for FanFiction stories" +msgstr "" + +#: ffdl_plugin.py:1411 +msgid "Adding/Updating %s BAD books." +msgstr "" + +#: ffdl_plugin.py:1420 +msgid "Updating calibre for BAD FanFiction stories..." +msgstr "" + +#: ffdl_plugin.py:1421 +msgid "Update calibre for BAD FanFiction stories" +msgstr "" + +#: ffdl_plugin.py:1447 +msgid "Adding format to book failed for some reason..." +msgstr "" + +#: ffdl_plugin.py:1450 +msgid "Error" +msgstr "" + +#: ffdl_plugin.py:1723 +msgid "You configured FanFictionDownLoader to automatically update Reading Lists, but you don't have the %s plugin installed anymore?" +msgstr "" + +#: ffdl_plugin.py:1735 +msgid "You configured FanFictionDownLoader to automatically update \"To Read\" Reading Lists, but you don't have any lists set?" +msgstr "" + +#: ffdl_plugin.py:1745 ffdl_plugin.py:1763 +msgid "You configured FanFictionDownLoader to automatically update Reading List '%s', but you don't have a list of that name?" +msgstr "" + +#: ffdl_plugin.py:1751 +msgid "You configured FanFictionDownLoader to automatically update \"Send to Device\" Reading Lists, but you don't have any lists set?" +msgstr "" + +#: ffdl_plugin.py:1871 +msgid "No story URL found." +msgstr "" + +#: ffdl_plugin.py:1874 +msgid "Not Found" +msgstr "" + +#: ffdl_plugin.py:1880 +msgid "URL is not a valid story URL." +msgstr "" + +#: ffdl_plugin.py:1883 +msgid "Bad URL" +msgstr "" + +#: ffdl_plugin.py:2018 +msgid "Anthology containing:" +msgstr "" + +#: ffdl_plugin.py:2019 +msgid "%s by %s" +msgstr "" + +#: ffdl_plugin.py:2038 +msgid " Anthology" +msgstr "" + +#: ffdl_plugin.py:2075 +msgid "(was set, removed for security)" +msgstr "" + diff --git a/calibre-plugin/translations/zz.po b/calibre-plugin/translations/zz.po new file mode 100644 index 00000000..0f1a9572 --- /dev/null +++ b/calibre-plugin/translations/zz.po @@ -0,0 +1,1794 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: FanFictionDownLoader 1.8\n" +"POT-Creation-Date: 2014-07-14 10:52+Central Daylight Time\n" +"PO-Revision-Date: 2014-08-01 12:43-0600\n" +"Last-Translator: Jim Miller <RetiefJimm@gmail.com>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 1.5.7\n" + +#: __init__.py:42 +msgid "UI plugin to download FanFiction stories from various sites." +msgstr "zzUI plugin to download FanFiction stories from various sites." + +#: __init__.py:109 +msgid "" +"Path to the calibre library. Default is to use the path stored in the " +"settings." +msgstr "" +"zzPath to the calibre library. Default is to use the path stored in the " +"settings." + +#: config.py:161 +msgid "FAQs" +msgstr "zzFAQs" + +#: config.py:161 +msgid "List of Supported Sites" +msgstr "zzList of Supported Sites" + +#: config.py:175 +msgid "Basic" +msgstr "zzBasic" + +#: config.py:196 +msgid "Standard Columns" +msgstr "zzStandard Columns" + +#: config.py:199 +msgid "Custom Columns" +msgstr "zzCustom Columns" + +#: config.py:202 +msgid "Other" +msgstr "zzOther" + +#: config.py:323 +msgid "" +"These settings control the basic features of the plugin--downloading " +"FanFiction." +msgstr "" +"zzThese settings control the basic features of the plugin--downloading " +"FanFiction." + +#: config.py:327 +msgid "Defaults Options on Download" +msgstr "zzDefaults Options on Download" + +#: config.py:331 +msgid "" +"On each download, FFDL offers an option to select the output format. <br /" +">This sets what that option will default to." +msgstr "" +"zzOn each download, FFDL offers an option to select the output format. <br /" +">This sets what that option will default to." + +#: config.py:333 +msgid "Default Output &Format:" +msgstr "zzDefault Output &Format:" + +#: config.py:348 +msgid "" +"On each download, FFDL offers an option of what happens if that story " +"already exists. <br />This sets what that option will default to." +msgstr "" +"zzOn each download, FFDL offers an option of what happens if that story " +"already exists. <br />This sets what that option will default to." + +#: config.py:350 +msgid "Default If Story Already Exists?" +msgstr "zzDefault If Story Already Exists?" + +#: config.py:364 +msgid "Default Update Calibre &Metadata?" +msgstr "zzDefault Update Calibre &Metadata?" + +#: config.py:365 +msgid "" +"On each download, FFDL offers an option to update Calibre's metadata (title, " +"author, URL, tags, custom columns, etc) from the web site. <br />This sets " +"whether that will default to on or off. <br />Columns set to 'New Only' in " +"the column tabs will only be set for new books." +msgstr "" +"zzOn each download, FFDL offers an option to update Calibre's metadata " +"(title, author, URL, tags, custom columns, etc) from the web site. <br /" +">This sets whether that will default to on or off. <br />Columns set to 'New " +"Only' in the column tabs will only be set for new books." + +#: config.py:369 +msgid "Default Update EPUB Cover when Updating EPUB?" +msgstr "zzDefault Update EPUB Cover when Updating EPUB?" + +#: config.py:370 +msgid "" +"On each download, FFDL offers an option to update the book cover image " +"<i>inside</i> the EPUB from the web site when the EPUB is updated.<br />This " +"sets whether that will default to on or off." +msgstr "" +"zzOn each download, FFDL offers an option to update the book cover image " +"<i>inside</i> the EPUB from the web site when the EPUB is updated.<br />This " +"sets whether that will default to on or off." + +#: config.py:374 +msgid "Smarten Punctuation (EPUB only)" +msgstr "zzSmarten Punctuation (EPUB only)" + +#: config.py:375 +msgid "" +"Run Smarten Punctuation from Calibre's Polish Book feature on each EPUB " +"download and update." +msgstr "" +"zzRun Smarten Punctuation from Calibre's Polish Book feature on each EPUB " +"download and update." + +#: config.py:380 +msgid "Updating Calibre Options" +msgstr "zzUpdating Calibre Options" + +#: config.py:384 +msgid "Delete other existing formats?" +msgstr "zzDelete other existing formats?" + +#: config.py:385 +msgid "" +"Check this to automatically delete all other ebook formats when updating an " +"existing book.\n" +"Handy if you have both a Nook(epub) and Kindle(mobi), for example." +msgstr "" +"zzCheck this to automatically delete all other ebook formats when updating " +"an existing book.\n" +"Handy if you have both a Nook(epub) and Kindle(mobi), for example." + +#: config.py:389 +msgid "Update Calibre Cover when Updating Metadata?" +msgstr "zzUpdate Calibre Cover when Updating Metadata?" + +#: config.py:390 +msgid "" +"Update calibre book cover image from EPUB when metadata is updated. (EPUB " +"only.)\n" +"Doesn't go looking for new images on 'Update Calibre Metadata Only'." +msgstr "" +"zzUpdate calibre book cover image from EPUB when metadata is updated. (EPUB " +"only.)\n" +"Doesn't go looking for new images on 'Update Calibre Metadata Only'." + +#: config.py:394 +msgid "Keep Existing Tags when Updating Metadata?" +msgstr "zzKeep Existing Tags when Updating Metadata?" + +#: config.py:395 +msgid "" +"Existing tags will be kept and any new tags added.\n" +"%(cmplt)s and %(inprog)s tags will be still be updated, if known.\n" +"%(lul)s tags will be updated if %(lus)s in %(is)s.\n" +"(If Tags is set to 'New Only' in the Standard Columns tab, this has no " +"effect.)" +msgstr "" +"zzExisting tags will be kept and any new tags added.\n" +"%(cmplt)s and %(inprog)s tags will be still be updated, if known.\n" +"%(lul)s tags will be updated if %(lus)s in %(is)s.\n" +"(If Tags is set to 'New Only' in the Standard Columns tab, this has no " +"effect.)" + +#: config.py:399 +msgid "Force Author into Author Sort?" +msgstr "zzForce Author into Author Sort?" + +#: config.py:400 +msgid "" +"If checked, the author(s) as given will be used for the Author Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'Bob " +"Smith' sort as 'Smith, Bob', etc." +msgstr "" +"zzIf checked, the author(s) as given will be used for the Author Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'Bob " +"Smith' sort as 'Smith, Bob', etc." + +#: config.py:404 +msgid "Force Title into Title Sort?" +msgstr "zzForce Title into Title Sort?" + +#: config.py:405 +msgid "" +"If checked, the title as given will be used for the Title Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'The " +"Title' sort as 'Title, The', etc." +msgstr "" +"zzIf checked, the title as given will be used for the Title Sort, too.\n" +"If not checked, calibre will apply it's built in algorithm which makes 'The " +"Title' sort as 'Title, The', etc." + +#: config.py:409 +msgid "Check for existing Series Anthology books?" +msgstr "zzCheck for existing Series Anthology books?" + +#: config.py:410 +msgid "" +"Check for existings Series Anthology books using each new story's series URL " +"before downloading.\n" +"Offer to skip downloading if a Series Anthology is found." +msgstr "" +"zzCheck for existings Series Anthology books using each new story's series " +"URL before downloading.\n" +"Offer to skip downloading if a Series Anthology is found." + +#: config.py:414 +msgid "Check for changed Story URL?" +msgstr "zzCheck for changed Story URL?" + +#: config.py:415 +msgid "" +"Warn you if an update will change the URL of an existing book.\n" +"fanfiction.net URLs will change from http to https silently." +msgstr "" +"zzWarn you if an update will change the URL of an existing book.\n" +"fanfiction.net URLs will change from http to https silently." + +#: config.py:419 +msgid "Search EPUB text for Story URL?" +msgstr "zzSearch EPUB text for Story URL?" + +#: config.py:420 +msgid "" +"Look for first valid story URL inside EPUB text if not found in metadata.\n" +"Somewhat risky, could find wrong URL depending on EPUB content.\n" +"Also finds and corrects bad ffnet URLs from ficsaver.com files." +msgstr "" +"zzLook for first valid story URL inside EPUB text if not found in metadata.\n" +"Somewhat risky, could find wrong URL depending on EPUB content.\n" +"Also finds and corrects bad ffnet URLs from ficsaver.com files." + +#: config.py:424 +msgid "Mark added/updated books when finished?" +msgstr "zzMark added/updated books when finished?" + +#: config.py:425 +msgid "" +"Mark added/updated books when finished. Use with option below.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "" +"zzMark added/updated books when finished. Use with option below.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." + +#: config.py:429 +msgid "Show Marked books when finished?" +msgstr "zzShow Marked books when finished?" + +#: config.py:430 +msgid "" +"Show Marked added/updated books only when finished.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." +msgstr "" +"zzShow Marked added/updated books only when finished.\n" +"You can also manually search for 'marked:ffdl_success'.\n" +"'marked:ffdl_failed' is also available, or search 'marked:ffdl' for both." + +#: config.py:434 +msgid "Automatically Convert new/update books?" +msgstr "zzAutomatically Convert new/update books?" + +#: config.py:435 +msgid "" +"Automatically call calibre's Convert for new/update books.\n" +"Converts to the current output format as chosen in calibre's\n" +"Preferences->Behavior settings." +msgstr "" +"zzAutomatically call calibre's Convert for new/update books.\n" +"Converts to the current output format as chosen in calibre's\n" +"Preferences->Behavior settings." + +#: config.py:439 +msgid "GUI Options" +msgstr "zzGUI Options" + +#: config.py:443 +msgid "Take URLs from Clipboard?" +msgstr "zzTake URLs from Clipboard?" + +#: config.py:444 +msgid "Prefill URLs from valid URLs in Clipboard when Adding New." +msgstr "zzPrefill URLs from valid URLs in Clipboard when Adding New." + +#: config.py:448 +msgid "Default to Update when books selected?" +msgstr "zzDefault to Update when books selected?" + +#: config.py:449 +msgid "" +"The top FanFictionDownLoader plugin button will start Update if\n" +"books are selected. If unchecked, it will always bring up 'Add New'." +msgstr "" +"zzThe top FanFictionDownLoader plugin button will start Update if\n" +"books are selected. If unchecked, it will always bring up 'Add New'." + +#: config.py:453 +msgid "Keep 'Add New from URL(s)' dialog on top?" +msgstr "zzKeep 'Add New from URL(s)' dialog on top?" + +#: config.py:454 +msgid "" +"Instructs the OS and Window Manager to keep the 'Add New from URL(s)'\n" +"dialog on top of all other windows. Useful for dragging URLs onto it." +msgstr "" +"zzInstructs the OS and Window Manager to keep the 'Add New from URL(s)'\n" +"dialog on top of all other windows. Useful for dragging URLs onto it." + +#: config.py:458 +msgid "Misc Options" +msgstr "zzMisc Options" + +#: config.py:463 +msgid "Include images in EPUBs?" +msgstr "zzInclude images in EPUBs?" + +#: config.py:464 +msgid "" +"Download and include images in EPUB stories. This is equivalent to adding:" +"%(imgset)s ...to the top of %(pini)s. Your settings in %(pini)s will " +"override this." +msgstr "" +"zzDownload and include images in EPUB stories. This is equivalent to adding:" +"%(imgset)s ...to the top of %(pini)s. Your settings in %(pini)s will " +"override this." + +#: config.py:468 +msgid "Inject calibre Series when none found?" +msgstr "zzInject calibre Series when none found?" + +#: config.py:469 +msgid "" +"If no series is found, inject the calibre series (if there is one) so it " +"appears on the FFDL title page(not cover)." +msgstr "" +"zzIf no series is found, inject the calibre series (if there is one) so it " +"appears on the FFDL title page(not cover)." + +#: config.py:473 +msgid "Reject List" +msgstr "zzReject List" + +#: config.py:477 +msgid "Edit Reject URL List" +msgstr "zzEdit Reject URL List" + +#: config.py:478 +msgid "Edit list of URLs FFDL will automatically Reject." +msgstr "zzEdit list of URLs FFDL will automatically Reject." + +#: config.py:482 config.py:556 +msgid "Add Reject URLs" +msgstr "zzAdd Reject URLs" + +#: config.py:483 +msgid "Add additional URLs to Reject as text." +msgstr "zzAdd additional URLs to Reject as text." + +#: config.py:487 +msgid "Edit Reject Reasons List" +msgstr "zzEdit Reject Reasons List" + +#: config.py:488 config.py:547 +msgid "Customize the Reasons presented when Rejecting URLs" +msgstr "zzCustomize the Reasons presented when Rejecting URLs" + +#: config.py:492 +msgid "Reject Without Confirmation?" +msgstr "zzReject Without Confirmation?" + +#: config.py:493 +msgid "Always reject URLs on the Reject List without stopping and asking." +msgstr "zzAlways reject URLs on the Reject List without stopping and asking." + +#: config.py:531 +msgid "Edit Reject URLs List" +msgstr "zzEdit Reject URLs List" + +#: config.py:545 +msgid "Reject Reasons" +msgstr "zzReject Reasons" + +#: config.py:546 +msgid "Customize Reject List Reasons" +msgstr "zzCustomize Reject List Reasons" + +#: config.py:554 +msgid "Reason why I rejected it" +msgstr "zzReason why I rejected it" + +#: config.py:554 +msgid "Title by Author" +msgstr "zzTitle by Author" + +#: config.py:557 +msgid "" +"Add Reject URLs. Use: <b>http://...,note</b> or <b>http://...,title by " +"author - note</b><br>Invalid story URLs will be ignored." +msgstr "" +"zzAdd Reject URLs. Use: <b>http://...,note</b> or <b>http://...,title by " +"author - note</b><br>Invalid story URLs will be ignored." + +#: config.py:558 +msgid "" +"One URL per line:\n" +"<b>http://...,note</b>\n" +"<b>http://...,title by author - note</b>" +msgstr "" +"zzOne URL per line:\n" +"<b>http://...,note</b>\n" +"<b>http://...,title by author - note</b>" + +#: config.py:560 dialogs.py:1031 +msgid "Add this reason to all URLs added:" +msgstr "zzAdd this reason to all URLs added:" + +#: config.py:575 +msgid "" +"These settings provide more detailed control over what metadata will be " +"displayed inside the ebook as well as let you set %(isa)s and %(u)s/%(p)s " +"for different sites." +msgstr "" +"zzThese settings provide more detailed control over what metadata will be " +"displayed inside the ebook as well as let you set %(isa)s and %(u)s/%(p)s " +"for different sites." + +#: config.py:593 +msgid "View Defaults" +msgstr "zzView Defaults" + +#: config.py:594 +msgid "" +"View all of the plugin's configurable settings\n" +"and their default settings." +msgstr "" +"zzView all of the plugin's configurable settings\n" +"and their default settings." + +#: config.py:612 +msgid "Plugin Defaults (%s) (Read-Only)" +msgstr "zzPlugin Defaults (%s) (Read-Only)" + +#: config.py:613 config.py:619 +msgid "" +"These are all of the plugin's configurable options\n" +"and their default settings." +msgstr "" +"zzThese are all of the plugin's configurable options\n" +"and their default settings." + +#: config.py:614 +msgid "Plugin Defaults" +msgstr "zzPlugin Defaults" + +#: config.py:630 dialogs.py:555 dialogs.py:658 +msgid "OK" +msgstr "zzOK" + +# %(rl)s = Reading List. Keep as is. +#: config.py:650 +msgid "" +"These settings provide integration with the %(rl)s Plugin. %(rl)s can " +"automatically send to devices and change custom columns. You have to create " +"and configure the lists in %(rl)s to be useful." +msgstr "" +"zzThese settings provide integration with the %(rl)s Plugin. %(rl)s can " +"automatically send to devices and change custom columns. You have to create " +"and configure the lists in %(rl)s to be useful." + +#: config.py:655 +msgid "Add new/updated stories to \"Send to Device\" Reading List(s)." +msgstr "zzAdd new/updated stories to \"Send to Device\" Reading List(s)." + +# %(rl)s = Reading List. Keep as is. +#: config.py:656 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin." +msgstr "" +"zzAutomatically add new/updated stories to these lists in the %(rl)s plugin." + +#: config.py:661 +msgid "\"Send to Device\" Reading Lists" +msgstr "zz\"Send to Device\" Reading Lists" + +#: config.py:662 config.py:665 config.py:678 config.py:681 +msgid "" +"When enabled, new/updated stories will be automatically added to these lists." +msgstr "" +"zzWhen enabled, new/updated stories will be automatically added to these " +"lists." + +#: config.py:671 +msgid "Add new/updated stories to \"To Read\" Reading List(s)." +msgstr "zzAdd new/updated stories to \"To Read\" Reading List(s)." + +# %(rl)s = Reading List. Keep as is. +#: config.py:672 +msgid "" +"Automatically add new/updated stories to these lists in the %(rl)s plugin.\n" +"Also offers menu option to remove stories from the \"To Read\" lists." +msgstr "" +"zzAutomatically add new/updated stories to these lists in the %(rl)s " +"plugin.\n" +"Also offers menu option to remove stories from the \"To Read\" lists." + +#: config.py:677 +msgid "\"To Read\" Reading Lists" +msgstr "zz\"To Read\" Reading Lists" + +#: config.py:687 +msgid "" +"Add stories back to \"Send to Device\" Reading List(s) when marked \"Read\"." +msgstr "" +"zzAdd stories back to \"Send to Device\" Reading List(s) when marked \"Read" +"\"." + +#: config.py:688 +msgid "" +"Menu option to remove from \"To Read\" lists will also add stories back to " +"\"Send to Device\" Reading List(s)" +msgstr "" +"zzMenu option to remove from \"To Read\" lists will also add stories back to " +"\"Send to Device\" Reading List(s)" + +# %(gc)s = Generate Cover. Keep as is. +#: config.py:710 +msgid "" +"The %(gc)s plugin can create cover images for books using various metadata " +"and configurations. If you have GC installed, FFDL can run GC on new " +"downloads and metadata updates. Pick a GC setting by site or Default." +msgstr "" +"zzThe %(gc)s plugin can create cover images for books using various metadata " +"and configurations. If you have GC installed, FFDL can run GC on new " +"downloads and metadata updates. Pick a GC setting by site or Default." + +#: config.py:728 config.py:732 config.py:745 +msgid "Default" +msgstr "zzDefault" + +# %(gc)s = Generate Cover. Keep as is. +#: config.py:733 +msgid "" +"On Metadata update, run %(gc)s with this setting, if not selected for " +"specific site." +msgstr "" +"zzOn Metadata update, run %(gc)s with this setting, if not selected for " +"specific site." + +# %(gc)s = Generate Cover. Keep as is. +#: config.py:736 +msgid "On Metadata update, run %(gc)s with this setting for %(site)s stories." +msgstr "" +"zzOn Metadata update, run %(gc)s with this setting for %(site)s stories." + +# %(gc)s = Generate Cover. Keep as is. +#: config.py:759 +msgid "Run %(gc)s Only on New Books" +msgstr "zzRun %(gc)s Only on New Books" + +#: config.py:760 +msgid "Default is to run GC any time the calibre metadata is updated." +msgstr "zzDefault is to run GC any time the calibre metadata is updated." + +#: config.py:764 +msgid "Allow %(gcset)s from %(pini)s to override" +msgstr "zzAllow %(gcset)s from %(pini)s to override" + +#: config.py:765 +msgid "" +"The %(pini)s parameter %(gcset)s allows you to choose a GC setting based on " +"metadata rather than site, but it's much more complex.<br \\>%(gcset)s is " +"ignored when this is off." +msgstr "" +"zzThe %(pini)s parameter %(gcset)s allows you to choose a GC setting based " +"on metadata rather than site, but it's much more complex.<br \\>%(gcset)s is " +"ignored when this is off." + +#: config.py:769 +msgid "Use calibre's Polish feature to inject/update the cover" +msgstr "zzUse calibre's Polish feature to inject/update the cover" + +#: config.py:770 +msgid "" +"Calibre's Polish feature will be used to inject or update the generated " +"cover into the ebook, EPUB only." +msgstr "" +"zzCalibre's Polish feature will be used to inject or update the generated " +"cover into the ebook, EPUB only." + +#: config.py:784 +msgid "" +"These settings provide integration with the %(cp)s Plugin. %(cp)s can " +"automatically update custom columns with page, word and reading level " +"statistics. You have to create and configure the columns in %(cp)s first." +msgstr "" +"zzThese settings provide integration with the %(cp)s Plugin. %(cp)s can " +"automatically update custom columns with page, word and reading level " +"statistics. You have to create and configure the columns in %(cp)s first." + +#: config.py:789 +msgid "" +"If any of the settings below are checked, when stories are added or updated, " +"the %(cp)s Plugin will be called to update the checked statistics." +msgstr "" +"zzIf any of the settings below are checked, when stories are added or " +"updated, the %(cp)s Plugin will be called to update the checked statistics." + +#: config.py:795 +msgid "Which column and algorithm to use are configured in %(cp)s." +msgstr "zzWhich column and algorithm to use are configured in %(cp)s." + +#: config.py:803 +msgid "" +"Will overwrite word count from FFDL metadata if set to update the same " +"custom column." +msgstr "" +"zzWill overwrite word count from FFDL metadata if set to update the same " +"custom column." + +#: config.py:834 +msgid "" +"These controls aren't plugin settings as such, but convenience buttons for " +"setting Keyboard shortcuts and getting all the FanFictionDownLoader " +"confirmation dialogs back again." +msgstr "" +"zzThese controls aren't plugin settings as such, but convenience buttons for " +"setting Keyboard shortcuts and getting all the FanFictionDownLoader " +"confirmation dialogs back again." + +#: config.py:839 +msgid "Keyboard shortcuts..." +msgstr "zzKeyboard shortcuts..." + +#: config.py:840 +msgid "Edit the keyboard shortcuts associated with this plugin" +msgstr "zzEdit the keyboard shortcuts associated with this plugin" + +#: config.py:844 +msgid "Reset disabled &confirmation dialogs" +msgstr "zzReset disabled &confirmation dialogs" + +#: config.py:845 +msgid "Reset all show me again dialogs for the FanFictionDownLoader plugin" +msgstr "zzReset all show me again dialogs for the FanFictionDownLoader plugin" + +#: config.py:849 +msgid "&View library preferences..." +msgstr "zz&View library preferences..." + +#: config.py:850 +msgid "View data stored in the library database for this plugin" +msgstr "zzView data stored in the library database for this plugin" + +#: config.py:861 +msgid "Done" +msgstr "zzDone" + +#: config.py:862 +msgid "Confirmation dialogs have all been reset" +msgstr "zzConfirmation dialogs have all been reset" + +#: config.py:910 +msgid "Category" +msgstr "zzCategory" + +#: config.py:911 +msgid "Genre" +msgstr "zzGenre" + +#: config.py:912 +msgid "Language" +msgstr "zzLanguage" + +#: config.py:913 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Status" +msgstr "zzStatus" + +#: config.py:914 +msgid "Status:%(cmplt)s" +msgstr "zzStatus:%(cmplt)s" + +#: config.py:915 +msgid "Status:%(inprog)s" +msgstr "zzStatus:%(inprog)s" + +#: config.py:916 config.py:1050 +msgid "Series" +msgstr "zzSeries" + +#: config.py:917 +msgid "Characters" +msgstr "zzCharacters" + +#: config.py:918 +msgid "Relationships" +msgstr "zzRelationships" + +#: config.py:919 +msgid "Published" +msgstr "zzPublished" + +#: config.py:920 ffdl_plugin.py:1403 ffdl_plugin.py:1422 +msgid "Updated" +msgstr "zzUpdated" + +#: config.py:921 +msgid "Created" +msgstr "zzCreated" + +#: config.py:922 +msgid "Rating" +msgstr "zzRating" + +#: config.py:923 +msgid "Warnings" +msgstr "zzWarnings" + +#: config.py:924 +msgid "Chapters" +msgstr "zzChapters" + +#: config.py:925 +msgid "Words" +msgstr "zzWords" + +#: config.py:926 +msgid "Site" +msgstr "zzSite" + +#: config.py:927 +msgid "Story ID" +msgstr "zzStory ID" + +#: config.py:928 +msgid "Author ID" +msgstr "zzAuthor ID" + +#: config.py:929 +msgid "Extra Tags" +msgstr "zzExtra Tags" + +#: config.py:930 config.py:1042 dialogs.py:817 dialogs.py:913 +#: ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Title" +msgstr "zzTitle" + +#: config.py:931 +msgid "Story URL" +msgstr "zzStory URL" + +#: config.py:932 +msgid "Description" +msgstr "zzDescription" + +#: config.py:933 dialogs.py:817 dialogs.py:913 ffdl_plugin.py:1126 +#: ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Author" +msgstr "zzAuthor" + +#: config.py:934 +msgid "Author URL" +msgstr "zzAuthor URL" + +#: config.py:935 +msgid "File Format" +msgstr "zzFile Format" + +#: config.py:936 +msgid "File Extension" +msgstr "zzFile Extension" + +#: config.py:937 +msgid "Site Abbrev" +msgstr "zzSite Abbrev" + +#: config.py:938 +msgid "FFDL Version" +msgstr "zzFFDL Version" + +#: config.py:953 +msgid "" +"If you have custom columns defined, they will be listed below. Choose a " +"metadata value type to fill your columns automatically." +msgstr "" +"zzIf you have custom columns defined, they will be listed below. Choose a " +"metadata value type to fill your columns automatically." + +#: config.py:978 +msgid "Update this %s column(%s) with..." +msgstr "zzUpdate this %s column(%s) with..." + +#: config.py:988 +msgid "Values that aren't valid for this enumeration column will be ignored." +msgstr "" +"zzValues that aren't valid for this enumeration column will be ignored." + +#: config.py:988 config.py:990 +msgid "Metadata values valid for this type of column." +msgstr "zzMetadata values valid for this type of column." + +#: config.py:993 config.py:1069 +msgid "New Only" +msgstr "zzNew Only" + +#: config.py:994 +msgid "" +"Write to %s(%s) only for new\n" +"books, not updates to existing books." +msgstr "" +"zzWrite to %s(%s) only for new\n" +"books, not updates to existing books." + +#: config.py:1005 +msgid "Allow %(ccset)s from %(pini)s to override" +msgstr "zzAllow %(ccset)s from %(pini)s to override" + +#: config.py:1006 +msgid "" +"The %(pini)s parameter %(ccset)s allows you to set custom columns to site " +"specific values that aren't common to all sites.<br />%(ccset)s is ignored " +"when this is off." +msgstr "" +"zzThe %(pini)s parameter %(ccset)s allows you to set custom columns to site " +"specific values that aren't common to all sites.<br />%(ccset)s is ignored " +"when this is off." + +#: config.py:1011 +msgid "Special column:" +msgstr "zzSpecial column:" + +#: config.py:1016 +msgid "Update/Overwrite Error Column:" +msgstr "zzUpdate/Overwrite Error Column:" + +#: config.py:1017 +msgid "" +"When an update or overwrite of an existing story fails, record the reason in " +"this column.\n" +"(Text and Long Text columns only.)" +msgstr "" +"zzWhen an update or overwrite of an existing story fails, record the reason " +"in this column.\n" +"(Text and Long Text columns only.)" + +#: config.py:1043 +msgid "Author(s)" +msgstr "zzAuthor(s)" + +#: config.py:1044 +msgid "Publisher" +msgstr "zzPublisher" + +#: config.py:1045 +msgid "Tags" +msgstr "zzTags" + +#: config.py:1046 +msgid "Languages" +msgstr "zzLanguages" + +#: config.py:1047 +msgid "Published Date" +msgstr "zzPublished Date" + +#: config.py:1048 +msgid "Date" +msgstr "zzDate" + +#: config.py:1049 +msgid "Comments" +msgstr "zzComments" + +#: config.py:1051 +msgid "Ids(url id only)" +msgstr "zzIds(url id only)" + +#: config.py:1056 +msgid "" +"The standard calibre metadata columns are listed below. You may choose " +"whether FFDL will fill each column automatically on updates or only for new " +"books." +msgstr "" +"zzThe standard calibre metadata columns are listed below. You may choose " +"whether FFDL will fill each column automatically on updates or only for new " +"books." + +#: config.py:1070 +msgid "" +"Write to %s only for new\n" +"books, not updates to existing books." +msgstr "" +"zzWrite to %s only for new\n" +"books, not updates to existing books." + +#: dialogs.py:69 +msgid "Skip" +msgstr "zzSkip" + +#: dialogs.py:70 +msgid "Add New Book" +msgstr "zzAdd New Book" + +#: dialogs.py:71 +msgid "Update EPUB if New Chapters" +msgstr "zzUpdate EPUB if New Chapters" + +#: dialogs.py:72 +msgid "Update EPUB Always" +msgstr "zzUpdate EPUB Always" + +#: dialogs.py:73 +msgid "Overwrite if Newer" +msgstr "zzOverwrite if Newer" + +#: dialogs.py:74 +msgid "Overwrite Always" +msgstr "zzOverwrite Always" + +#: dialogs.py:75 +msgid "Update Calibre Metadata Only" +msgstr "zzUpdate Calibre Metadata Only" + +#: dialogs.py:252 ffdl_plugin.py:89 +msgid "FanFictionDownLoader" +msgstr "zzFanFictionDownLoader" + +#: dialogs.py:269 dialogs.py:716 +msgid "Show Download Options" +msgstr "zzShow Download Options" + +#: dialogs.py:288 dialogs.py:733 +msgid "Output &Format:" +msgstr "zzOutput &Format:" + +#: dialogs.py:296 dialogs.py:741 +msgid "" +"Choose output format to create. May set default from plugin configuration." +msgstr "" +"zzChoose output format to create. May set default from plugin configuration." + +#: dialogs.py:324 dialogs.py:758 +msgid "Update Calibre &Metadata?" +msgstr "zzUpdate Calibre &Metadata?" + +#: dialogs.py:325 dialogs.py:759 +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 "" +"zzUpdate 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.)" + +#: dialogs.py:331 dialogs.py:763 +msgid "Update EPUB Cover?" +msgstr "zzUpdate EPUB Cover?" + +#: dialogs.py:332 dialogs.py:764 +msgid "" +"Update book cover image from site or defaults (if found) <i>inside</i> the " +"EPUB when EPUB is updated." +msgstr "" +"zzUpdate book cover image from site or defaults (if found) <i>inside</i> the " +"EPUB when EPUB is updated." + +#: dialogs.py:379 +msgid "Story URL(s) for anthology, one per line:" +msgstr "zzStory URL(s) for anthology, one per line:" + +#: dialogs.py:380 +msgid "" +"URLs for stories to include in the anthology, one per line.\n" +"Will take URLs from clipboard, but only valid URLs." +msgstr "" +"zzURLs for stories to include in the anthology, one per line.\n" +"Will take URLs from clipboard, but only valid URLs." + +#: dialogs.py:381 +msgid "If Story Already Exists in Anthology?" +msgstr "zzIf Story Already Exists in Anthology?" + +#: dialogs.py:382 +msgid "" +"What to do if there's already an existing story with the same URL in the " +"anthology." +msgstr "" +"zzWhat to do if there's already an existing story with the same URL in the " +"anthology." + +#: dialogs.py:391 +msgid "Story URL(s), one per line:" +msgstr "zzStory URL(s), one per line:" + +#: dialogs.py:392 +msgid "" +"URLs for stories, one per line.\n" +"Will take URLs from clipboard, but only valid URLs.\n" +"Add [1,5] after the URL to limit the download to chapters 1-5." +msgstr "" +"zzURLs for stories, one per line.\n" +"Will take URLs from clipboard, but only valid URLs.\n" +"Add [1,5] after the URL to limit the download to chapters 1-5." + +#: dialogs.py:393 +msgid "If Story Already Exists?" +msgstr "zzIf Story Already Exists?" + +#: dialogs.py:394 +msgid "" +"What to do if there's already an existing story with the same URL or title " +"and author." +msgstr "" +"zzWhat to do if there's already an existing story with the same URL or title " +"and author." + +#: dialogs.py:494 +msgid "For Individual Books" +msgstr "zzFor Individual Books" + +#: dialogs.py:495 +msgid "Get URLs and go to dialog for individual story downloads." +msgstr "zzGet URLs and go to dialog for individual story downloads." + +#: dialogs.py:499 +msgid "For Anthology Epub" +msgstr "zzFor Anthology Epub" + +#: dialogs.py:500 +msgid "" +"Get URLs and go to dialog for Anthology download.\n" +"Requires %s plugin." +msgstr "" +"zzGet URLs and go to dialog for Anthology download.\n" +"Requires %s plugin." + +#: dialogs.py:505 dialogs.py:559 dialogs.py:586 +msgid "Cancel" +msgstr "zzCancel" + +#: dialogs.py:537 +msgid "Password" +msgstr "zzPassword" + +#: dialogs.py:538 +msgid "Author requires a password for this story(%s)." +msgstr "zzAuthor requires a password for this story(%s)." + +#: dialogs.py:543 +msgid "User/Password" +msgstr "zzUser/Password" + +#: dialogs.py:544 +msgid "%s requires you to login to download this story." +msgstr "zz%s requires you to login to download this story." + +#: dialogs.py:546 +msgid "User:" +msgstr "zzUser:" + +#: dialogs.py:550 +msgid "Password:" +msgstr "zzPassword:" + +#: dialogs.py:581 +msgid "Fetching metadata for stories..." +msgstr "zzFetching metadata for stories..." + +#: dialogs.py:582 +msgid "Downloading metadata for stories" +msgstr "zzDownloading metadata for stories" + +#: dialogs.py:583 +msgid "Fetched metadata for" +msgstr "zzFetched metadata for" + +#: dialogs.py:653 ffdl_plugin.py:325 +msgid "About FanFictionDownLoader" +msgstr "zzAbout FanFictionDownLoader" + +#: dialogs.py:707 +msgid "Remove selected books from the list" +msgstr "zzRemove selected books from the list" + +#: dialogs.py:746 +msgid "Update Mode:" +msgstr "zzUpdate Mode:" + +#: dialogs.py:749 +msgid "" +"What sort of update to perform. May set default from plugin configuration." +msgstr "" +"zzWhat sort of update to perform. May set default from plugin configuration." + +#: dialogs.py:817 ffdl_plugin.py:1126 ffdl_plugin.py:1290 ffdl_plugin.py:1320 +msgid "Comment" +msgstr "zzComment" + +#: dialogs.py:885 +msgid "Are you sure you want to remove this book from the list?" +msgstr "zzAre you sure you want to remove this book from the list?" + +#: dialogs.py:887 +msgid "Are you sure you want to remove the selected %d books from the list?" +msgstr "zzAre you sure you want to remove the selected %d books from the list?" + +#: dialogs.py:913 +msgid "Note" +msgstr "zzNote" + +#: dialogs.py:955 +msgid "Select or Edit Reject Note." +msgstr "zzSelect or Edit Reject Note." + +#: dialogs.py:963 +msgid "Are you sure you want to remove this URL from the list?" +msgstr "zzAre you sure you want to remove this URL from the list?" + +#: dialogs.py:965 +msgid "Are you sure you want to remove the %d selected URLs from the list?" +msgstr "zzAre you sure you want to remove the %d selected URLs from the list?" + +#: dialogs.py:983 +msgid "List of Books to Reject" +msgstr "zzList of Books to Reject" + +#: dialogs.py:996 +msgid "" +"FFDL will remember these URLs and display the note and offer to reject them " +"if you try to download them again later." +msgstr "" +"zzFFDL will remember these URLs and display the note and offer to reject " +"them if you try to download them again later." + +#: dialogs.py:1010 +msgid "Remove selected URL(s) from the list" +msgstr "zzRemove selected URL(s) from the list" + +#: dialogs.py:1028 dialogs.py:1032 +msgid "This will be added to whatever note you've set for each URL above." +msgstr "zzThis will be added to whatever note you've set for each URL above." + +#: dialogs.py:1041 +msgid "Delete Books (including books without FanFiction URLs)?" +msgstr "zzDelete Books (including books without FanFiction URLs)?" + +#: dialogs.py:1042 +msgid "Delete the selected books after adding them to the Rejected URLs list." +msgstr "" +"zzDelete the selected books after adding them to the Rejected URLs list." + +#: ffdl_plugin.py:90 +msgid "Download FanFiction stories from various web sites" +msgstr "zzDownload FanFiction stories from various web sites" + +# This is what appears on the plugin button/menu when added to calibre's main toolbar or menu. +#: ffdl_plugin.py:120 +msgid "FanFictionDL" +msgstr "zzFanFictionDL" + +#: ffdl_plugin.py:243 +msgid "&Add New from URL(s)" +msgstr "zz&Add New from URL(s)" + +#: ffdl_plugin.py:245 +msgid "Add New FanFiction Book(s) from URL(s)" +msgstr "zzAdd New FanFiction Book(s) from URL(s)" + +#: ffdl_plugin.py:248 +msgid "&Update Existing FanFiction Book(s)" +msgstr "zz&Update Existing FanFiction Book(s)" + +#: ffdl_plugin.py:254 +msgid "Get Story URLs to Download from Web Page" +msgstr "zzGet Story URLs to Download from Web Page" + +#: ffdl_plugin.py:258 +msgid "&Make Anthology Epub Manually from URL(s)" +msgstr "zz&Make Anthology Epub Manually from URL(s)" + +#: ffdl_plugin.py:260 +msgid "Make FanFiction Anthology Epub Manually from URL(s)" +msgstr "zzMake FanFiction Anthology Epub Manually from URL(s)" + +#: ffdl_plugin.py:263 +msgid "&Update Anthology Epub" +msgstr "zz&Update Anthology Epub" + +#: ffdl_plugin.py:265 +msgid "Update FanFiction Anthology Epub" +msgstr "zzUpdate FanFiction Anthology Epub" + +#: ffdl_plugin.py:273 +msgid "Add to \"To Read\" and \"Send to Device\" Lists" +msgstr "zzAdd to \"To Read\" and \"Send to Device\" Lists" + +#: ffdl_plugin.py:275 +msgid "Remove from \"To Read\" and add to \"Send to Device\" Lists" +msgstr "zzRemove from \"To Read\" and add to \"Send to Device\" Lists" + +#: ffdl_plugin.py:277 ffdl_plugin.py:282 +msgid "Remove from \"To Read\" Lists" +msgstr "zzRemove from \"To Read\" Lists" + +#: ffdl_plugin.py:279 +msgid "Add Selected to \"Send to Device\" Lists" +msgstr "zzAdd Selected to \"Send to Device\" Lists" + +#: ffdl_plugin.py:281 +msgid "Add to \"To Read\" Lists" +msgstr "zzAdd to \"To Read\" Lists" + +#: ffdl_plugin.py:297 +msgid "Get URLs from Selected Books" +msgstr "zzGet URLs from Selected Books" + +#: ffdl_plugin.py:303 ffdl_plugin.py:396 +msgid "Get Story URLs from Web Page" +msgstr "zzGet Story URLs from Web Page" + +#: ffdl_plugin.py:308 +msgid "Reject Selected Books" +msgstr "zzReject Selected Books" + +#: ffdl_plugin.py:316 +msgid "&Configure Plugin" +msgstr "zz&Configure Plugin" + +#: ffdl_plugin.py:319 +msgid "Configure FanFictionDownLoader" +msgstr "zzConfigure FanFictionDownLoader" + +#: ffdl_plugin.py:322 +msgid "About Plugin" +msgstr "zzAbout Plugin" + +#: ffdl_plugin.py:379 +msgid "Cannot Update Reading Lists from Device View" +msgstr "zzCannot Update Reading Lists from Device View" + +#: ffdl_plugin.py:383 +msgid "No Selected Books to Update Reading Lists" +msgstr "zzNo Selected Books to Update Reading Lists" + +#: ffdl_plugin.py:407 ffdl_plugin.py:459 +msgid "List of Story URLs" +msgstr "zzList of Story URLs" + +#: ffdl_plugin.py:408 +msgid "No Valid Story URLs found on given page." +msgstr "zzNo Valid Story URLs found on given page." + +#: ffdl_plugin.py:423 +msgid "No Selected Books to Get URLs From" +msgstr "zzNo Selected Books to Get URLs From" + +#: ffdl_plugin.py:441 +msgid "Collecting URLs for stories..." +msgstr "zzCollecting URLs for stories..." + +#: ffdl_plugin.py:442 +msgid "Get URLs for stories" +msgstr "zzGet URLs for stories" + +#: ffdl_plugin.py:443 ffdl_plugin.py:490 ffdl_plugin.py:677 +msgid "URL retrieved" +msgstr "zzURL retrieved" + +#: ffdl_plugin.py:463 +msgid "List of URLs" +msgstr "zzList of URLs" + +#: ffdl_plugin.py:464 +msgid "No Story URLs found in selected books." +msgstr "zzNo Story URLs found in selected books." + +#: ffdl_plugin.py:480 +msgid "No Selected Books have URLs to Reject" +msgstr "zzNo Selected Books have URLs to Reject" + +#: ffdl_plugin.py:488 +msgid "Collecting URLs for Reject List..." +msgstr "zzCollecting URLs for Reject List..." + +#: ffdl_plugin.py:489 +msgid "Get URLs for Reject List" +msgstr "zzGet URLs for Reject List" + +#: ffdl_plugin.py:524 +msgid "Proceed to Remove?" +msgstr "zzProceed to Remove?" + +#: ffdl_plugin.py:524 +msgid "Rejecting FFDL URLs: None of the books selected have FanFiction URLs." +msgstr "" +"zzRejecting FFDL URLs: None of the books selected have FanFiction URLs." + +# %s = EpubMerge +#: ffdl_plugin.py:546 +msgid "Cannot Make Anthologys without %s" +msgstr "zzCannot Make Anthologys without %s" + +#: ffdl_plugin.py:550 ffdl_plugin.py:654 +msgid "Cannot Update Books from Device View" +msgstr "zzCannot Update Books from Device View" + +#: ffdl_plugin.py:554 +msgid "Can only update 1 anthology at a time" +msgstr "zzCan only update 1 anthology at a time" + +#: ffdl_plugin.py:563 +msgid "Can only Update Epub Anthologies" +msgstr "zzCan only Update Epub Anthologies" + +#: ffdl_plugin.py:581 ffdl_plugin.py:582 +msgid "Cannot Update Anthology" +msgstr "zzCannot Update Anthology" + +#: ffdl_plugin.py:582 +msgid "" +"Book isn't an FFDL Anthology or contains book(s) without valid FFDL URLs." +msgstr "" +"zzBook isn't an FFDL Anthology or contains book(s) without valid FFDL URLs." + +#: ffdl_plugin.py:640 +msgid "" +"There are %d stories in the current anthology that are <b>not</b> going to " +"be kept if you go ahead." +msgstr "" +"zzThere are %d stories in the current anthology that are <b>not</b> going to " +"be kept if you go ahead." + +#: ffdl_plugin.py:641 +msgid "Story URLs that will be removed:" +msgstr "zzStory URLs that will be removed:" + +#: ffdl_plugin.py:643 +msgid "Update anyway?" +msgstr "zzUpdate anyway?" + +#: ffdl_plugin.py:644 +msgid "Stories Removed" +msgstr "zzStories Removed" + +#: ffdl_plugin.py:661 +msgid "No Selected Books to Update" +msgstr "zzNo Selected Books to Update" + +#: ffdl_plugin.py:675 +msgid "Collecting stories for update..." +msgstr "zzCollecting stories for update..." + +#: ffdl_plugin.py:676 +msgid "Get stories for updates" +msgstr "zzGet stories for updates" + +#: ffdl_plugin.py:686 +msgid "Update Existing List" +msgstr "zzUpdate Existing List" + +#: ffdl_plugin.py:738 +msgid "Started fetching metadata for %s stories." +msgstr "zzStarted fetching metadata for %s stories." + +#: ffdl_plugin.py:744 +msgid "No valid story URLs entered." +msgstr "zzNo valid story URLs entered." + +#: ffdl_plugin.py:769 ffdl_plugin.py:775 +msgid "Reject URL?" +msgstr "zzReject URL?" + +#: ffdl_plugin.py:776 ffdl_plugin.py:794 +msgid "<b>%s</b> is on your Reject URL list:" +msgstr "zz<b>%s</b> is on your Reject URL list:" + +#: ffdl_plugin.py:778 +msgid "Click '<b>Yes</b>' to Reject." +msgstr "zzClick '<b>Yes</b>' to Reject." + +#: ffdl_plugin.py:779 ffdl_plugin.py:875 +msgid "Click '<b>No</b>' to download anyway." +msgstr "zzClick '<b>No</b>' to download anyway." + +#: ffdl_plugin.py:781 +msgid "Story on Reject URLs list (%s)." +msgstr "zzStory on Reject URLs list (%s)." + +#: ffdl_plugin.py:784 +msgid "Rejected" +msgstr "zzRejected" + +#: ffdl_plugin.py:787 +msgid "Remove Reject URL?" +msgstr "zzRemove Reject URL?" + +#: ffdl_plugin.py:793 +msgid "Remove URL from Reject List?" +msgstr "zzRemove URL from Reject List?" + +#: ffdl_plugin.py:796 +msgid "Click '<b>Yes</b>' to remove it from the list," +msgstr "zzClick '<b>Yes</b>' to remove it from the list," + +#: ffdl_plugin.py:797 +msgid "Click '<b>No</b>' to leave it on the list." +msgstr "zzClick '<b>No</b>' to leave it on the list." + +#: ffdl_plugin.py:814 +msgid "Cannot update non-epub format." +msgstr "zzCannot update non-epub format." + +#: ffdl_plugin.py:851 +msgid "Are You an Adult?" +msgstr "zzAre You an Adult?" + +#: ffdl_plugin.py:852 +msgid "" +"%s requires that you be an adult. Please confirm you are an adult in your " +"locale:" +msgstr "" +"zz%s requires that you be an adult. Please confirm you are an adult in your " +"locale:" + +#: ffdl_plugin.py:866 +msgid "Skip Story?" +msgstr "zzSkip Story?" + +#: ffdl_plugin.py:872 +msgid "Skip Anthology Story?" +msgstr "zzSkip Anthology Story?" + +#: ffdl_plugin.py:873 +msgid "" +"\"<b>%s</b>\" is in series \"<b><a href=\"%s\">%s</a></b>\" that you have an " +"anthology book for." +msgstr "" +"zz\"<b>%s</b>\" is in series \"<b><a href=\"%s\">%s</a></b>\" that you have " +"an anthology book for." + +#: ffdl_plugin.py:874 +msgid "Click '<b>Yes</b>' to Skip." +msgstr "zzClick '<b>Yes</b>' to Skip." + +#: ffdl_plugin.py:877 +msgid "Story in Series Anthology(%s)." +msgstr "zzStory in Series Anthology(%s)." + +#: ffdl_plugin.py:882 +msgid "Skipped" +msgstr "zzSkipped" + +#: ffdl_plugin.py:910 +msgid "Add" +msgstr "zzAdd" + +#: ffdl_plugin.py:923 +msgid "Meta" +msgstr "zzMeta" + +#: ffdl_plugin.py:956 +msgid "Skipping duplicate story." +msgstr "zzSkipping duplicate story." + +#: ffdl_plugin.py:959 +msgid "" +"More than one identical book by Identifer URL or title/author(s)--can't tell " +"which book to update/overwrite." +msgstr "" +"zzMore than one identical book by Identifer URL or title/author(s)--can't " +"tell which book to update/overwrite." + +#: ffdl_plugin.py:970 +msgid "Update" +msgstr "zzUpdate" + +#: ffdl_plugin.py:978 ffdl_plugin.py:985 +msgid "Change Story URL?" +msgstr "zzChange Story URL?" + +#: ffdl_plugin.py:986 +msgid "" +"<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL:" +msgstr "" +"zz<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL:" + +#: ffdl_plugin.py:987 +msgid "In library: <a href=\"%(liburl)s\">%(liburl)s</a>" +msgstr "zzIn library: <a href=\"%(liburl)s\">%(liburl)s</a>" + +#: ffdl_plugin.py:988 ffdl_plugin.py:1002 +msgid "New URL: <a href=\"%(newurl)s\">%(newurl)s</a>" +msgstr "zzNew URL: <a href=\"%(newurl)s\">%(newurl)s</a>" + +#: ffdl_plugin.py:989 +msgid "Click '<b>Yes</b>' to update/overwrite book with new URL." +msgstr "zzClick '<b>Yes</b>' to update/overwrite book with new URL." + +#: ffdl_plugin.py:990 +msgid "Click '<b>No</b>' to skip updating/overwriting this book." +msgstr "zzClick '<b>No</b>' to skip updating/overwriting this book." + +#: ffdl_plugin.py:992 ffdl_plugin.py:999 +msgid "Download as New Book?" +msgstr "zzDownload as New Book?" + +#: ffdl_plugin.py:1000 +msgid "" +"<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL." +msgstr "" +"zz<b>%s</b> by <b>%s</b> is already in your library with a different source " +"URL." + +#: ffdl_plugin.py:1001 +msgid "" +"You chose not to update the existing book. Do you want to add a new book " +"for this URL?" +msgstr "" +"zzYou chose not to update the existing book. Do you want to add a new book " +"for this URL?" + +#: ffdl_plugin.py:1003 +msgid "Click '<b>Yes</b>' to a new book with new URL." +msgstr "zzClick '<b>Yes</b>' to a new book with new URL." + +#: ffdl_plugin.py:1004 +msgid "Click '<b>No</b>' to skip URL." +msgstr "zzClick '<b>No</b>' to skip URL." + +#: ffdl_plugin.py:1010 +msgid "Update declined by user due to differing story URL(%s)" +msgstr "zzUpdate declined by user due to differing story URL(%s)" + +#: ffdl_plugin.py:1013 +msgid "Different URL" +msgstr "zzDifferent URL" + +#: ffdl_plugin.py:1018 +msgid "Metadata collected." +msgstr "zzMetadata collected." + +#: ffdl_plugin.py:1034 +msgid "Already contains %d chapters." +msgstr "zzAlready contains %d chapters." + +#: ffdl_plugin.py:1039 +msgid "" +"Existing epub contains %d chapters, web site only has %d. Use Overwrite to " +"force update." +msgstr "" +"zzExisting epub contains %d chapters, web site only has %d. Use Overwrite to " +"force update." + +#: ffdl_plugin.py:1041 +msgid "" +"FFDL doesn't recognize chapters in existing epub, epub is probably from a " +"different source. Use Overwrite to force update." +msgstr "" +"zzFFDL doesn't recognize chapters in existing epub, epub is probably from a " +"different source. Use Overwrite to force update." + +#: ffdl_plugin.py:1053 +msgid "Not Overwriting, web site is not newer." +msgstr "zzNot Overwriting, web site is not newer." + +#: ffdl_plugin.py:1122 +msgid "None of the <b>%d</b> URLs/stories given can be/need to be downloaded." +msgstr "" +"zzNone of the <b>%d</b> URLs/stories given can be/need to be downloaded." + +#: ffdl_plugin.py:1123 ffdl_plugin.py:1286 ffdl_plugin.py:1316 +msgid "See log for details." +msgstr "zzSee log for details." + +#: ffdl_plugin.py:1124 +msgid "Proceed with updating your library(Error Column, if configured)?" +msgstr "zzProceed with updating your library(Error Column, if configured)?" + +#: ffdl_plugin.py:1131 ffdl_plugin.py:1298 +msgid "Bad" +msgstr "zzBad" + +#: ffdl_plugin.py:1139 +msgid "FFDL download ended" +msgstr "zzFFDL download ended" + +#: ffdl_plugin.py:1139 ffdl_plugin.py:1341 +msgid "FFDL log" +msgstr "zzFFDL log" + +#: ffdl_plugin.py:1147 +msgid "Download FanFiction Book" +msgstr "zzDownload FanFiction Book" + +#: ffdl_plugin.py:1154 +msgid "Starting %d FanFictionDownLoads" +msgstr "zzStarting %d FanFictionDownLoads" + +#: ffdl_plugin.py:1184 +msgid "Story Details:" +msgstr "zzStory Details:" + +#: ffdl_plugin.py:1187 +msgid "Error Updating Metadata" +msgstr "zzError Updating Metadata" + +#: ffdl_plugin.py:1188 +msgid "" +"An error has occurred while FFDL was updating calibre's metadata for <a " +"href='%s'>%s</a>." +msgstr "" +"zzAn error has occurred while FFDL was updating calibre's metadata for <a " +"href='%s'>%s</a>." + +#: ffdl_plugin.py:1189 +msgid "The ebook has been updated, but the metadata has not." +msgstr "zzThe ebook has been updated, but the metadata has not." + +#: ffdl_plugin.py:1241 +msgid "Finished Adding/Updating %d books." +msgstr "zzFinished Adding/Updating %d books." + +#: ffdl_plugin.py:1249 +msgid "Starting auto conversion of %d books." +msgstr "zzStarting auto conversion of %d books." + +#: ffdl_plugin.py:1270 +msgid "No Good Stories for Anthology" +msgstr "zzNo Good Stories for Anthology" + +#: ffdl_plugin.py:1271 +msgid "" +"No good stories/updates where downloaded, Anthology creation/update aborted." +msgstr "" +"zzNo good stories/updates where downloaded, Anthology creation/update " +"aborted." + +#: ffdl_plugin.py:1276 ffdl_plugin.py:1315 +msgid "FFDL found <b>%s</b> good and <b>%s</b> bad updates." +msgstr "zzFFDL found <b>%s</b> good and <b>%s</b> bad updates." + +#: ffdl_plugin.py:1283 +msgid "" +"Are you sure you want to continue with creating/updating this Anthology?" +msgstr "" +"zzAre you sure you want to continue with creating/updating this Anthology?" + +#: ffdl_plugin.py:1284 +msgid "Any updates that failed will <b>not</b> be included in the Anthology." +msgstr "" +"zzAny updates that failed will <b>not</b> be included in the Anthology." + +#: ffdl_plugin.py:1285 +msgid "However, if there's an older version, it will still be included." +msgstr "zzHowever, if there's an older version, it will still be included." + +#: ffdl_plugin.py:1288 +msgid "Proceed with updating this anthology and your library?" +msgstr "zzProceed with updating this anthology and your library?" + +#: ffdl_plugin.py:1296 +msgid "Good" +msgstr "zzGood" + +#: ffdl_plugin.py:1317 +msgid "Proceed with updating your library?" +msgstr "zzProceed with updating your library?" + +#: ffdl_plugin.py:1341 +msgid "FFDL download complete" +msgstr "zzFFDL download complete" + +#: ffdl_plugin.py:1354 +msgid "Merging %s books." +msgstr "zzMerging %s books." + +#: ffdl_plugin.py:1394 +msgid "FFDL Adding/Updating books." +msgstr "zzFFDL Adding/Updating books." + +#: ffdl_plugin.py:1401 +msgid "Updating calibre for FanFiction stories..." +msgstr "zzUpdating calibre for FanFiction stories..." + +#: ffdl_plugin.py:1402 +msgid "Update calibre for FanFiction stories" +msgstr "zzUpdate calibre for FanFiction stories" + +#: ffdl_plugin.py:1411 +msgid "Adding/Updating %s BAD books." +msgstr "zzAdding/Updating %s BAD books." + +#: ffdl_plugin.py:1420 +msgid "Updating calibre for BAD FanFiction stories..." +msgstr "zzUpdating calibre for BAD FanFiction stories..." + +#: ffdl_plugin.py:1421 +msgid "Update calibre for BAD FanFiction stories" +msgstr "zzUpdate calibre for BAD FanFiction stories" + +#: ffdl_plugin.py:1447 +msgid "Adding format to book failed for some reason..." +msgstr "zzAdding format to book failed for some reason..." + +#: ffdl_plugin.py:1450 +msgid "Error" +msgstr "zzError" + +#: ffdl_plugin.py:1723 +msgid "" +"You configured FanFictionDownLoader to automatically update Reading Lists, " +"but you don't have the %s plugin installed anymore?" +msgstr "" +"zzYou configured FanFictionDownLoader to automatically update Reading Lists, " +"but you don't have the %s plugin installed anymore?" + +#: ffdl_plugin.py:1735 +msgid "" +"You configured FanFictionDownLoader to automatically update \"To Read\" " +"Reading Lists, but you don't have any lists set?" +msgstr "" +"zzYou configured FanFictionDownLoader to automatically update \"To Read\" " +"Reading Lists, but you don't have any lists set?" + +#: ffdl_plugin.py:1745 ffdl_plugin.py:1763 +msgid "" +"You configured FanFictionDownLoader to automatically update Reading List " +"'%s', but you don't have a list of that name?" +msgstr "" +"zzYou configured FanFictionDownLoader to automatically update Reading List " +"'%s', but you don't have a list of that name?" + +#: ffdl_plugin.py:1751 +msgid "" +"You configured FanFictionDownLoader to automatically update \"Send to Device" +"\" Reading Lists, but you don't have any lists set?" +msgstr "" +"zzYou configured FanFictionDownLoader to automatically update \"Send to " +"Device\" Reading Lists, but you don't have any lists set?" + +#: ffdl_plugin.py:1871 +msgid "No story URL found." +msgstr "zzNo story URL found." + +#: ffdl_plugin.py:1874 +msgid "Not Found" +msgstr "zzNot Found" + +#: ffdl_plugin.py:1880 +msgid "URL is not a valid story URL." +msgstr "zzURL is not a valid story URL." + +#: ffdl_plugin.py:1883 +msgid "Bad URL" +msgstr "zzBad URL" + +#: ffdl_plugin.py:2018 +msgid "Anthology containing:" +msgstr "zzAnthology containing:" + +# title by author +#: ffdl_plugin.py:2019 +msgid "%s by %s" +msgstr "zz%s by %s" + +#: ffdl_plugin.py:2038 +msgid " Anthology" +msgstr "zz Anthology" + +#: ffdl_plugin.py:2075 +msgid "(was set, removed for security)" +msgstr "zz(was set, removed for security)" diff --git a/cron.yaml b/cron.yaml new file mode 100644 index 00000000..e72999f4 --- /dev/null +++ b/cron.yaml @@ -0,0 +1,10 @@ +cron: +- description: cleanup job + url: /r3m0v3r + schedule: every 2 hours + +# There's a bug in the Python 2.7 runtime that prevents this from +# working properly. In theory, there should never be orphans anyway. +#- description: orphan cleanup job +# url: /r3m0v3rOrphans +# schedule: every 4 hours diff --git a/css/index.css b/css/index.css new file mode 100644 index 00000000..eae546b7 --- /dev/null +++ b/css/index.css @@ -0,0 +1,73 @@ +body +{ + font: 0.9em "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif; +} + +#main +{ + width: 60%; + margin-left: 20%; + background-color: #dae6ff; + padding: 2em; +} + +#greeting +{ +# margin-bottom: 1em; + border-color: #efefef; +} + + + +#logpassword:hover, #logpasswordtable:hover, #urlbox:hover, #typebox:hover, #helpbox:hover, #yourfile:hover +{ + border: thin solid #fffeff; +} + +h1 +{ + text-decoration: none; +} + +#logpasswordtable +{ + padding: 1em; +} + +#logpassword, #logpasswordtable { +// display: none; +} + +#urlbox, #typebox, #logpasswordtable, #logpassword, #helpbox, #yourfile +{ + margin: 1em; + padding: 1em; + border: thin dotted #fffeff; +} + +div.field +{ + margin-bottom: 0.5em; +} + +#submitbtn +{ + padding: 1em; +} + +#typelabel +{ +} + +#typeoptions +{ + margin-top: 0.5em; +} + +#error +{ + color: #f00; +} +.recent { + font-size: large; +} diff --git a/defaults.ini b/defaults.ini new file mode 100644 index 00000000..e001da5b --- /dev/null +++ b/defaults.ini @@ -0,0 +1,1878 @@ +# 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. +# + +[defaults] + +## [defaults] section applies to all formats and sites but may be +## overridden at several levels. Example: + +## [defaults] +## titlepage_entries: category,genre, status +## [www.whofic.com] +## # overrides defaults. +## titlepage_entries: category,genre, status,dateUpdated,rating +## [epub] +## # overrides defaults & site section +## titlepage_entries: category,genre, status,datePublished,dateUpdated,dateCreated +## [www.whofic.com:epub] +## # overrides defaults, site section & format section +## titlepage_entries: category,genre, status,datePublished +## [overrides] +## # overrides all other sections +## titlepage_entries: category + +## Some sites also require the user to confirm they are adult for +## adult content. Uncomment by removing '#' in front of is_adult. +#is_adult:true + +## All available titlepage_entries and the label used for them: +## <entryname>_label:<label> +## Labels may be customized. +title_label:Title +storyUrl_label:Story URL +description_label:Summary +author_label:Author +authorUrl_label:Author URL +## epub, txt, html +formatname_label:File Format +## .epub, .txt, .html +formatext_label:File Extension +## Category and Genre have overlap, depending on the site. +## Sometimes Harry Potter is a category and Fantasy a genre. (fanfiction.net) +## Sometimes Fantasy is category *and* a genre (fictionpress.com) +## Sometimes there are multiple categories and/or genres. +category_label:Category +genre_label:Genre +language_label:Language +characters_label:Characters +ships_label:Relationships +series_label:Series +seriesUrl_label:Series URL +## seriesHTML is series as a link to seriesUrl. +seriesHTML_label:Series +## Completed/In-Progress +status_label:Status +## Dates story first published, last updated, and downloaded(last with time). +datePublished_label:Published +dateUpdated_label:Updated +dateCreated_label:Packaged +## Rating depends on the site. Some use K,T,M,etc, and some PG,R,NC-17 +rating_label:Rating +## Also depends on the site. +warnings_label:Warnings +numChapters_label:Chapters +numWords_label:Words +## www.fanfiction.net, fictionalley.com, etc. +site_label:Publisher +## ffnet, fpcom, etc. +siteabbrev_label:Site Abbrev +## The site's unique story/author identifier. Usually a number. +storyId_label:Story ID +authorId_label:Author ID +## Primarily to put specific values in dc:subject tags for epub. Will +## show up in Calibre as tags. Also carried into mobi when converted. +extratags_label:Extra Tags +## The version of fanficdownloader +version_label:FFDL Version + +## Date formats used by FFDL. Published and Update don't have time. +## See http://docs.python.org/library/datetime.html#strftime-strptime-behavior +## Note that ini format requires % to be escaped as %%. +dateCreated_format:%%Y-%%m-%%d %%H:%%M:%%S +datePublished_format:%%Y-%%m-%%d +dateUpdated_format:%%Y-%%m-%%d + +## items to include in the title page +## Empty metadata entries will *not* appear, even if in the list. +## You can include extra text or HTML that will be included as-is in +## the title page. Eg: titlepage_entries: ...,<br />,summary,<br />,... +## All current formats already include title and author. +titlepage_entries: seriesHTML,category,genre,language,characters,ships,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,numWords,site,description + +## Try to collect series name and number of this story in series. +## Some sites (ab)use 'series' for reading lists and personal +## collections. This lets us turn it on and off by site without +## keeping a lengthy titlepage_entries per site and prevents it +## updating in the plugin. +collect_series: true + +## include title page as first page. +include_titlepage: true + +## include a TOC page before the story text +include_tocpage: true + +## website encoding(s) In theory, each website reports the character +## encoding they use for each page. In practice, some sites report it +## incorrectly. Each adapter has a default list, usually "utf8, +## Windows-1252" or "Windows-1252, utf8", but this will let you +## explicitly set the encoding and order if you need to. The special +## value 'auto' will call chardet and use the encoding it reports if +## it has +90% confidence. 'auto' is not reliable. +#website_encodings: auto, utf8, Windows-1252 + +## python string Template, string with ${title}, ${author} etc, same as titlepage_entries +## Can include directories. ${formatext} will be added if not in filename somewhere. +#output_filename: books/${title}-${siteabbrev}_${storyId}${formatext} +#output_filename: books/${formatname}/${siteabbrev}/${authorId}/${title}-${siteabbrev}_${storyId}${formatext} +output_filename: ${title}-${siteabbrev}_${storyId}${formatext} + +## Make directories as needed. +make_directories: true + +## Always overwrite output files. Otherwise, the downloader checks +## the timestamp on the existing file and only overwrites if the story +## has been updated more recently. Command line version only +#always_overwrite: true + +## put output (with output_filename) in a zip file zip_filename. +zip_output: false + +## Can include directories. .zip will be added if not in name somewhere +zip_filename: ${title}-${siteabbrev}_${storyId}${formatext}.zip + +## Normally, try to make the filenames 'safe' by removing invalid +## filename chars. Applies to default_cover_image, output_filename & +## zip_filename. +allow_unsafe_filename: false + +## The regex pattern of 'unsafe' filename chars for above. +#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" +include_subject_tags: extratags, genre, category, characters, ships, lastupdate, status + +## extra tags (comma separated) to include, primarily for epub. +extratags: FanFiction + +## extra categories, genres, characters, ships and warnings can be +## configured. Used primarily for sites that are dedicated to a genre +## or 'ship and so don't included it for every story. +#extracategories: +#extragenres: +#extracharacters: +#extraships: +#extrawarnings: + +## Add this to genre if there's more than one category. +#add_genre_when_multi_category: Crossover + +## default_value_(entry) can be used to set the value for a metadata +## entry when no value has been found on the site. For example, some +## sites doesn't have a status metadatum. If uncommented, this will +## use 'Unknown' for status when no status is found. +#default_value_status:Unknown +## Can also be used for other metadata values +#default_value_category:FanFiction + +## number of seconds to sleep between calls to the story site. May by +## useful if pulling large numbers of stories or if the site is slow. +#slow_down_sleep_time:0.5 + +## How long to wait for each HTTP connection to finish. Longer times +## are better for sites that are slow to respond. Shorter times +## prevent excessive wait when your network or the site is down. +connect_timeout:60.0 + +## For use only with stand-alone CLI version--run a command on the +## generated file after it's produced. All of the titlepage_entries +## values are available, plus output_filename. +#post_process_cmd: addbook -f "${output_filename}" -t "${title}" + +## Use regular expressions to find and replace (or remove) metadata. +## For example, you could change Sci-Fi=>SF, remove *-Centered tags, +## etc. See http://docs.python.org/library/re.html (look for re.sub) +## for regexp details. +## Make sure to keep at least one space at the start of each line and +## to escape % to %%, if used. +## Two, three or five part lines. Two part effect everything. +## Three part effect only those key(s) lists. +## *Five* part lines. Effect only when trailing conditional key=>regexp matches +## metakey[,metakey]=>pattern=>replacement[&&conditionalkey=>regexp] +## Note that if metakey == conditionalkey the conditional is ignored. +## You can use \s in the replacement to add explicit spaces. (The config parser +## tends to discard trailing spaces.) +## replace_metadata <entry>_LIST options: FFDL replace_metadata lines +## operate on individual list items for list entries. But if you +## want to do a replacement on the joined string for the whole list, +## you can by using <entry>_LIST. Example, if you added +## calibre_author: calibre_author_LIST=>^(.{,100}).*$=>\1 +#replace_metadata: +# genre,category=>Sci-Fi=>SF +# Puella Magi Madoka Magica.* => Madoka +# Comedy=>Humor +# Crossover: (.*)=>\1 +# title=>(.*)Great(.*)=>\1Moderate\2 +# .*-Centered=> +# characters=>Sam W\.=>Sam Witwicky&&category=>Transformers +# characters=>Sam W\.=>Sam Winchester&&category=>Supernatural + +## Include/Exclude metadata +## +## You can use the include/exclude metadata features to either limit +## the values of particular metadata lists to specific values or to +## exclude specific values. Further, you can conditionally apply each +## line depending on other metadata, use exact strings or regular +## expressions(regex) to match values, and negate matches. +## +## The settings are: +## include_metadata_pre +## exclude_metadata_pre +## include_metadata_post +## exclude_metadata_post +## +## The form of each line is: +## metakey[,metakey]==exactvalue +## metakey[,metakey]=~regex +## metakey[,metakey]==exactvalue&&conditionalkey==exactcondvalue +## metakey[,metakey]=~regex&&conditionalkey==exactcondvalue +## metakey[,metakey]==exactvalue&&conditionalkey=~condregex +## +## This is fairly complicated, so it's documented on its own wiki +## page: +## https://code.google.com/p/fanficdownloader/wiki/InExcludeMetadataFeature + +## Some readers don't show horizontal rule (<hr />) tags correctly. +## This replaces them all with a centered '* * *'. (Note centering +## doesn't work on some devices either.) +#replace_hr: false + +## Some sites/authors/stories use br tags instead of p tags for +## paragraphs. This feature uses some heuristics to find and replace +## br paragraphs with p tags while preserving scene breaks. +#replace_br_with_p: false + +## If set false, the summary will have all html stripped. +## Both this and include_images must be true to get images in the +## summary. +keep_summary_html:true + +## If set true, any style attributes on tags in the story HTML will be +## kept. Useful for keeping extra colors & formatting from original. +#keep_style_attr: false + +## Don't like the numbers at the start of chapter titles on some +## sites? You can use strip_chapter_numbers to strip them off. Just +## want to make them all look the same? Strip them off, then add them +## back on with add_chapter_numbers:true. Only want them added back +## on for Table of Contents(toc)? Use add_chapter_numbers:toconly. +## (toconly doesn't work on mobi output.) Don't like the way it +## strips numbers or adds them back? See chapter_title_strip_pattern +## and chapter_title_add_pattern. +strip_chapter_numbers:false + +## add_chapter_numbers can be true, false or toconly +## (Note number is not added when there's only one chapter.) +add_chapter_numbers:false + +## (Two versions of chapter_title_strip_pattern are shown below. You +## should only have one uncommented.) +## This version will remove the leading number from: +## "1." => "" +## "1. The Beginning" => "The Beginning" +## "1: Start" => "Start" +## "2, Chapter the second" => "Chapter the second" +## etc +chapter_title_strip_pattern:^[0-9]+[\.: -]+ + +## This version will strip all of the above *plus* remove 'Chapter 1': +## "Chapter 1" => "" +## "1. Chapter 1" => "" +## "1. Chapter 1, Bob's First Clue" => "Bob's First Clue" +## "Chapter 2 - Pirates Place" => "Pirates Place" +## etc +#chapter_title_strip_pattern:^([0-9]+[\.: -]+)?(Chapter *[0-9]+[\.:, -]*)? + +## Uses a python template substitution. The ${index} is the 'chapter' +## number and ${title} is the chapter title, after applying +## chapter_title_strip_pattern. Those are the only variables available. +## "The Beginning" => "1. The Beginning" +chapter_title_add_pattern:${index}. ${title} + +## Reorder ships so b/a and c/b/a become a/b and a/b/c. Only separates +## on '/', so use replace_metadata to change separator first if +## needed. Something like: ships=>[ ]*(/|&|&)[ ]*=>/ You can use +## ships_LIST to change the / back to something else if you want. +sort_ships:false + +## join_string_<entry> options -- FFDL list entries are comma +## separated by default. You can use this to change that. For example, +## if you want authors separated with ' & ' instead, use +## join_string_calibre_author:\s&\s. (\s == space) +#join_string_author:,\s + +## keep_in_order_<entry> options: FFDL sorts list entries by default +## (except for author/authorUrl/authorId). But if you want to use an +## extra entry derived from author, it ends up sorted. For example, if +## you added calibre_author: keep_in_order_calibre_author:true +#keep_in_order_author:true + +## User-agent +user_agent:FFDL/1.7 + +## Each output format has a section that overrides [defaults] +[html] + +## include images from img tags in the body and summary of +## stories. Images will be converted to jpg for size if possible. +## include_images is *only* available in epub and html output formats. +## include_images is *not* available in the web service in any format. +#include_images:false + +## Note that it's *highly* recommended to use zipfile output or story +## unique destination directories to avoid overwriting images. +#output_filename: books/${author}/${title}/${title}-${siteabbrev}_${authorId}_${storyId}${formatext} +#zip_output: false + +## This switch prevents FFDL from doing any processing on the images. +## Usually they would be converted to jpg, resized and optionally made +## grayscale. +no_image_processing: true + +## output background color--only used by html and epub (and ignored in +## epub by many readers). Included below in output_css--will be +## ignored if not in output_css. +background_color: ffffff + +## Allow customization of CSS. Make sure to keep at least one space +## at the start of each line and to escape % to %%. Also need +## background_color to be in the same section, if included in CSS. +output_css: + body { background-color: #%(background_color)s; } + .CI { + text-align:center; + margin-top:0px; + margin-bottom:0px; + padding:0px; + } + .center {text-align: center;} + .cover {text-align: center;} + .full {width: 100%%; } + .quarter {width: 25%%; } + .smcap {font-variant: small-caps;} + .u {text-decoration: underline;} + .bold {font-weight: bold;} + +[txt] +## Add URLs since there aren't links. +titlepage_entries: series,seriesUrl,category,genre,language,characters,ships,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,numWords,site,storyUrl, authorUrl, description + +## Width to word wrap text output. 0 indicates no wrapping. +wrap_width: 78 + +## use \r\n for line endings, the windows convention. text output only. +windows_eol: true + +[epub] + +## epub is already a zip file. +zip_output: false + +## epub carries the TOC in metadata. +## mobi generated from epub by calibre will have a TOC at the end. +include_tocpage: false + +## include a Update Log page before the story text. If 'true', the +## log will be updated each time the epub is and all the metadata +## fields that have changed since the last update (typically +## dateUpdated,numChapters,numWords at a minimum) will be shown. +## Great for tracking when chapters came out and when the description, +## etc changed. +include_logpage: false +## If set to 'smart', logpage will only be included if the story is +## status:In-Progress or already had a logpage. That way you don't +## end up with Completed stories that have just one logpage entry. +#include_logpage: smart + +## items to include in the log page Empty metadata entries, or those +## that haven't changed since the last update, will *not* appear, even +## if in the list. You can include extra text or HTML that will be +## included as-is in each log entry. Eg: logpage_entries: ...,<br />, +## summary,<br />,... +logpage_entries: dateCreated,datePublished,dateUpdated,numChapters,numWords,status,series,title,author,description,category,genre,rating,warnings + +## epub->mobi conversions typically don't like tables. +titlepage_use_table: false + +## When using tables, make these span both columns. +wide_titlepage_entries: description, storyUrl, authorUrl, seriesUrl + +## output background color--only used by html and epub (and ignored in +## epub by many readers). Included below in output_css--will be +## ignored if not in output_css. +background_color: ffffff + +## Allow customization of CSS. Make sure to keep at least one space +## at the start of each line and to escape % to %%. Also need +## background_color to be in the same section, if included in CSS. +## 'adobe-hyphenate: none;' prevents hyphenation on newer Nooks +## STR(wG) (1.2.1+ for sure) +output_css: + body { background-color: #%(background_color)s; + text-align: justify; + margin: 2%%; + adobe-hyphenate: none; } + pre { font-size: x-small; } + sml { font-size: small; } + h1 { text-align: center; } + h2 { text-align: center; } + h3 { text-align: center; } + h4 { text-align: center; } + h5 { text-align: center; } + h6 { text-align: center; } + .CI { + text-align:center; + margin-top:0px; + margin-bottom:0px; + padding:0px; + } + .center {text-align: center;} + .cover {text-align: center;} + .full {width: 100%%; } + .quarter {width: 25%%; } + .smcap {font-variant: small-caps;} + .u {text-decoration: underline;} + .bold {font-weight: bold;} + +## include images from img tags in the body and summary of +## stories. Images will be converted to jpg for size if possible. +## include_images is *only* available in epub and html output format. +## include_images is *not* available in the web service in any format. +#include_images:false + +## If set, the first image found will be made the cover image. If +## keep_summary_html is true, any images in summary will be before any +## in chapters. +#make_firstimage_cover: false + +## If set, the epub will never have a cover, even include_images is on +## and the site has specific cover images. +#never_make_cover: false + +## If set, and there isn't already a cover image from the adapter or +## from make_firstimage_cover, this image will be made the cover. +## It can be either a 'file:' or 'http:' url. +## Note that if you enable make_firstimage_cover in [epub], but want +## to use default_cover_image for a specific site, use the site:format +## section, for example: [ficwad.com:epub] +## default_cover_image is a python string Template string with +## ${title}, ${author} etc, same as titlepage_entries. Unless +## allow_unsafe_filename is true, invalid filename chars will be +## removed from metadata fields +#default_cover_image:file:///C:/Users/username/Desktop/nook/images/icon.png +#default_cover_image:file:///C:/Users/username/Desktop/nook/images/${title}/icon.png +#default_cover_image:http://www.somesite.com/someimage.gif + +## some sites include images that we don't ever want becoming the +## cover image. This lets you exclude them. +#cover_exclusion_regexp:/stories/999/images/.*?_trophy.png + +## Resize images down to width, height, preserving aspect ratio. +## Nook size, with margin. +image_max_size: 580, 725 + +## Change image to grayscale, if graphics library allows, to save +## space. Transparency removed as if remove_transparency: true +#grayscale_images: false + +## jpg or png +## -- jpg produces smaller images, and may be supported by more +## readers, but it's older and doesn't allow transparency. +## Transparency removed as if remove_transparency: true +## -- png is newer but does allow transparency, but only in CLI. +## It doesn't work in calibre PI due to limitations of the API. +convert_images_to: jpg + +## Remove transparency and fill with background_color if true. +remove_transparency: true + +## if the <img> tag doesn't have a div or a p around it, nook gets +## confused and displays it on every page after that under the text +## for the rest of the chapter. I doubt adding a div around the img +## will break any other readers, but in case it does, the fix can be +## turned off. This setting is not used if replace_br_with_p is +## true--replace_br_with_p also fixes the problem. +nook_img_fix:true + +[mobi] +## mobi TOC cannot be turned off right now. +#include_tocpage: true + +## Each site has a section that overrides [defaults]. +## test1.com specifically is not a real story site. Instead, +## it is a fake site for testing configuration and output. It uses +## URLs like: http://test1.com?sid=12345 +[test1.com] +extratags: FanFiction,Testing +# extracategories:Fafner +# extragenres:Romance,Fluff +# extracharacters:Reginald Smythe-Smythe,Mokona,Harry P. +# extraships:Smythe-Smythe/Mokona +# extrawarnings:Extreme Bogosity + +# extra_valid_entries:metaA,metaB,metaC,listX,listY,listZ,compositeJ,compositeK,compositeL + +# include_in_compositeJ:dateCreated +# include_in_compositeK:metaC,listX,compositeL,compositeJ,compositeK,listZ +# include_in_compositeL:ships,metaA,listZ,datePublished,dateUpdated, + +# extra_titlepage_entries: metaA,metaB,metaC,listX,listY,listZ,compositeJ,compositeK,compositeL +# extra_logpage_entries: metaA,metaB,metaC,listX,listY,listZ,compositeJ,compositeK,compositeL +# extra_subject_tags: metaA,metaB,metaC + +# replace_metadata: +# compositeL=>Val=>VALUE +# series,extratags=>Test=>Plan +# Puella Magi Madoka Magica.* => Madoka +# Comedy=>Humor +# Crossover: (.*)=>\1 +# (.*)Great(.*)=>\1Moderate\2 +# .*-Centered=> +# characters=>Harry P\.=>Harry Potter + + +## If necessary, you can define [<site>:<format>] sections to +## customize the formats differently for the same site. Overrides +## defaults, format and site. +[test1.com:txt] +extratags: FanFiction,Testing,Text + +[test1.com:html] +extratags: FanFiction,Testing,HTML + +[archive.skyehawke.com] + +[archiveofourown.org] +## 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 + +## archiveofourown.org stories allow chapters to be added out of +## order. So the newest chapter may not be the last one. FFDL update +## doesn't like that. If do_update_hook is uncommented and set true, +## the adapter will discard all existing chapters from the newest one +## on when updating to enforce accurate chapters. +#do_update_hook:false + +## AO3 adapter defines a few extra metadata entries. +## If there's ever more than 4 series, add series04,series04Url etc. +extra_valid_entries:fandoms,freeformtags,freefromtags,ao3categories,comments,kudos,hits,bookmarks,collections,series00,series01,series02,series03,series00Url,series01Url,series02Url,series03Url +fandoms_label:Fandoms +freeformtags_label:Freeform Tags +freefromtags_label:Freeform Tags +ao3categories_label:AO3 Categories +comments_label:Comments +kudos_label:Kudos +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,freeformtags,ao3categories,comments,kudos,hits,bookmarks,series00,series01,series02,series03,series00Url,series01Url,series02Url,series03Url + +## adds to include_subject_tags instead of replacing it. +#extra_subject_tags:fandoms,freeformtags,ao3categories + +## AO3 chapters can include several different types of notes. We've +## traditional included them all in the chapter text, but this allows +## you to customize which you include. Copy this parameter to your +## personal.ini and list the ones you don't want. +#exclude_notes:authorheadnotes,chaptersummary,chapterheadnotes,chapterfootnotes,authorfootnotes + +[ashwinder.sycophanthex.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extracharacters:Severus Snape,Hermione Granger +extraships:Severus Snape/Hermione Granger + +[asr3.slashzone.org] +## Site dedicated to these categories/characters/ships +extracategories:The Sentinel + +## 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 + +[bdsm-geschichten.net] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +[bloodshedverse.com] +## website encoding(s) In theory, each website reports the character +## encoding they use for each page. In practice, some sites report it +## incorrectly. Each adapter has a default list, usually "utf8, +## Windows-1252" or "Windows-1252, utf8", but this will let you +## explicitly set the encoding and order if you need to. The special +## value 'auto' will call chardet and use the encoding it reports if +## it has +90% confidence. 'auto' is not reliable. +website_encodings:Windows-1252,ISO-8859-1,auto + +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:warnings,reviews +reviews_label:Reviews + +## Site dedicated to these categories/characters/ships +extracharacters:Spike,Buffy +extracategories:Buffy the Vampire Slayer + +## Strips links found in the story text +## Specific to bloodshedverse.com +strip_text_links:true + +[bloodties-fans.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Some sites also require the user to confirm they are adult for +## adult content. In commandline version, this should go in your +## personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Blood Ties + +[buffynfaith.net] +## Site dedicated to these categories/characters/ships +extracategories:Buffy: The Vampire Slayer + +## 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 + +[castlefans.org] +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Castle + +[chaos.sycophanthex.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 + +## some sites include images that we don't ever want becoming the +## cover image. This lets you exclude them. +cover_exclusion_regexp:/images/.*?ribbon.gif + +[dark-solace.org] +## Site dedicated to these categories/characters/ships +## 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 + +extracategories:Buffy: The Vampire Slayer +extracharacters:Buffy, Spike +extraships:Spike/Buffy + +[dramione.org] +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extracharacters:Draco Malfoy,Hermione Granger +extraships:Draco Malfoy/Hermione Granger + +## some sites include images that we don't ever want becoming the +## cover image. This lets you exclude them. +cover_exclusion_regexp:/images/.*?ribbon.gif + +## Some adapters collect additional meta information beyond the +## standard ones. They need to be defined in extra_valid_entries to +## tell the rest of the FFDL system about them. They can be used in +## include_subject_tags, titlepage_entries, extra_titlepage_entries, +## logpage_entries, extra_logpage_entries, and include_in_* config +## items. You can also add additional entries here to build up +## composite metadata entries. dramione.org, for example, adds +## 'cliches' and then defines as the composite of hermiones,dracos in +## include_in_cliches. +extra_valid_entries:themes,hermiones,dracos,timeline,cliches,read,reviews +include_in_cliches:hermiones,dracos + +## For another example, you could, by uncommenting this line, include +## themes in with genre metadata. +#include_in_genre:genre, themes + +## You can give each new valid entry a specific label for use on +## titlepage and logpage. If not defined, it will simply be the +themes_label:Themes +hermiones_label:Hermiones +dracos_label:Dracos +timeline_label:Timeline +cliches_label:Character Cliches + +## extra_titlepage_entries (and extra_logpage_entries) *add* to +## titlepage_entries (and logpage_entries) so you can add site +## specific entries to titlepage/logpage without having to copy the +## entire titlepage_entries line. (But if you want them higher than +## the end, you will need to copy titlepage_entries.) +#extra_titlepage_entries: themes,timeline,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] +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +## some sites include images that we don't ever want becoming the +## cover image. This lets you exclude them. +cover_exclusion_regexp:/images/.*?ribbon.gif + +[fanfiction.csodaidok.hu] +## website encoding(s) In theory, each website reports the character +## encoding they use for each page. In practice, some sites report it +## incorrectly. Each adapter has a default list, usually "utf8, +## Windows-1252" or "Windows-1252, utf8", but this will let you +## explicitly set the encoding and order if you need to. The special +## value 'auto' will call chardet and use the encoding it reports if +## it has +90% confidence. 'auto' is not reliable. +website_encodings:ISO-8859-2,auto + +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:reviews,challenge +reviews_label:Reviews +challenge_label:Challenge + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[fanfic.hu] +## website encoding(s) In theory, each website reports the character +## encoding they use for each page. In practice, some sites report it +## incorrectly. Each adapter has a default list, usually "utf8, +## Windows-1252" or "Windows-1252, utf8", but this will let you +## explicitly set the encoding and order if you need to. The special +## value 'auto' will call chardet and use the encoding it reports if +## it has +90% confidence. 'auto' is not reliable. +website_encodings:ISO-8859-1,auto + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[fanfiction.mugglenet.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 + +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[fanfic.potterheadsanonymous.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 + +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[fanfiction.portkey.org] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extraships:Harry Potter/Hermione Granger + +[fanfiction.tenhawkpresents.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +[ficwad.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +[fictionmania.tv] +## website encoding(s) In theory, each website reports the character +## encoding they use for each page. In practice, some sites report it +## incorrectly. Each adapter has a default list, usually "utf8, +## Windows-1252" or "Windows-1252, utf8", but this will let you +## explicitly set the encoding and order if you need to. The special +## value 'auto' will call chardet and use the encoding it reports if +## it has +90% confidence. 'auto' is not reliable. +website_encodings:ISO-8859-1,auto + +## items to include in the log page Empty metadata entries, or those +## that haven't changed since the last update, will *not* appear, even +## if in the list. You can include extra text or HTML that will be +## included as-is in each log entry. Eg: logpage_entries: ...,<br />, +## summary,<br />,... +## Don't include numChapters since all stories are a single "chapter", there's +## no way to reliably find the next chapter +logpage_entries: dateCreated,datePublished,dateUpdated,numChapters,numWords,status,series,title,author,description,category,genre,rating,warnings + +## items to include in the title page +## Empty metadata entries will *not* appear, even if in the list. +## You can include extra text or HTML that will be included as-is in +## the title page. Eg: titlepage_entries: ...,<br />,summary,<br />,... +## All current formats already include title and author. +## Don't include numChapters since all stories are a single "chapter", there's +## no way to reliably find the next chapter +titlepage_entries: seriesHTML,category,genre,language,characters,ships,status,datePublished,dateUpdated,dateCreated,rating,warnings,numWords,site,description + +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:fileName,fileSize,oldName,newName,keyWords,mainCharactersAge,readings + +## Turns all space characters into " " HTML entities to forcefully preserve +## formatting with spaces. Enabling this will blow up the filesize quite a bit +## and is probably not a good idea, unless you absolutely need the story +## formatting. +## Specific to fictionmania.tv +non_breaking_spaces:false + +[fictionpad.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +extra_valid_entries:followers,comments,views,likes,dislikes +#extra_titlepage_entries:followers,comments,views,likes,dislikes + +followers_label:Followers +comments_label:Comments +views_label:Views +likes_label:Likes +dislikes_label:Dislikes + +[finestories.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +[grangerenchanted.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Some sites also require the user to confirm they are adult for +## adult content. In commandline version, this should go in your +## personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extracharacters:Hermione Granger + +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:read,reviews + +[hlfiction.net] +## Site dedicated to these categories/characters/ships +extracategories:Highlander + +[imagine.e-fic.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Some sites also require the user to confirm they are adult for +## adult content. In commandline version, this should go in your +## personal.ini, not defaults.ini. +#is_adult:true + +[indeath.net] +## Site dedicated to these categories/characters/ships +extracategories:In Death + +## some sites include images that we don't ever want becoming the +## cover image. This lets you exclude them. +cover_exclusion_regexp:/public/style_emoticons/.* + +[ksarchive.com] +## Site dedicated to these categories/characters/ships +extracategories:Star Trek +extracharacters:Kirk,Spock +extraships:Kirk/Spock + +[literotica.com] +extra_valid_entries:eroticatags +eroticatags_label:Erotica Tags +extra_titlepage_entries: eroticatags + +[lumos.sycophanthex.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 + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[merlinfic.dtwins.co.uk] +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Merlin + +[national-library.net] +## Site dedicated to these categories/characters/ships +extracategories:West Wing + +[ncisfic.com] +## Site dedicated to these categories/characters/ships +extracategories:NCIS + +[netraptor.org] + +[nfacommunity.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 + +## Site dedicated to these categories/characters/ships +extracategories:NCIS + +[nha.magical-worlds.us] +## Site dedicated to these categories/characters/ships +extracategories:Buffy: The Vampire Slayer +extracharacters:Willow + +[nocturnal-light.net] +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:readings,reviews +readings_label:Readings +reviews_label:Reviews + +## Site dedicated to these categories/characters/ships +extracharacters:Spike,Buffy +extracategories:Buffy the Vampire Slayer + +[occlumency.sycophanthex.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extracharacters:Severus Snape + +[onedirectionfanfiction.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Some sites also require the user to confirm they are adult for +## adult content. In commandline version, this should go in your +## personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:One Direction + +[pommedesang.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Some sites also require the user to confirm they are adult for +## adult content. In commandline version, this should go in your +## personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Anita Blake Vampire Hunter + +[ponyfictionarchive.net] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:My Little Pony: Friendship is Magic + +[pretendercentre.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 + +## Site dedicated to these categories/characters/ships +extracategories:The Pretender + +[samdean.archive.nu] +## Site dedicated to these categories/characters/ships +extracategories:Supernatural +extracharacters:Sam,Dean +extraships:Sam/Dean + +[scarhead.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 + +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[sg1-heliopolis.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 + +[spikeluver.com] +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:warnings,reviews +reviews_label:Reviews + +## Site dedicated to these categories/characters/ships +extracharacters:Spike,Buffy +extracategories:Buffy the Vampire Slayer + +[stargate-atlantis.org] +## Site dedicated to these categories/characters/ships +extracategories:Stargate: Atlantis + +[storiesonline.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 + +## Clear FanFiction from defaults, site is original fiction. +extratags: + +extra_valid_entries:size,universe,universeUrl,universeHTML,codes,notice +#extra_titlepage_entries:size,universeHTML,codes,notice + +size_label:Size +universe_label:Universe +universeUrl_label:Universe URL +universeHTML_label:Universe +codes_label:Codes +notice_label:Notice + +## Assume entryUrl, apply to "<a class='%slink' href='%s'>%s</a>" to +## make entryHTML. +make_linkhtml_entries:universe + +## storiesonline.net stories can be in a series or a universe, but not +## both. By default, universe will be populated in 'series' with +## index=0 +universe_as_series: true + +[svufiction.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Some sites also require the user to confirm they are adult for +## adult content. In commandline version, this should go in your +## personal.ini, not defaults.ini. +#is_adult:true + +[thehexfiles.net] +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extracharacters:Draco Malfoy,Harry Potter +extraships:Harry Potter/Draco Malfoy + +[thehookupzone.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 + +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Criminal Minds + +[themasque.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 + +## 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 + +[thequidditchpitch.org] +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[tokra.fandomnet.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 + +## Site dedicated to these categories/characters/ships +extracategories:Stargate: SG-1 + +[tolkienfanfiction.com] +## Site dedicated to these categories/characters/ships +extracategories:Lord of the Rings + +[trekiverse.org] +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Star Trek + +extra_valid_entries:awards +awards_label:Awards + +cover_exclusion_regexp:art/.*Awards.jpg + +[voracity2.e-fic.com] +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:reviews,readings +reviews_label:Reviews +readings_label:Readings + +[www.adastrafanfic.com] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Star Trek + +[www.dracoandginny.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Some sites also require the user to confirm they are adult for +## adult content. In commandline version, this should go in your +## personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extracharacters:Draco Malfoy,Ginny Weasley +extraships:Draco Malfoy/Ginny Weasley + +[www.thealphagate.com] +## Site dedicated to these categories/characters/ships +extracategories:Stargate: SG-1 + +[www.checkmated.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[www.destinysgateway.com] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +[www.dokuga.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Site dedicated to these categories/characters/ships +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 +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:notes,context,type +notes_label:Notes +context_label:Context +type_label:Type of Couple + +[www.fanfiction.net] +user_agent: +## fanfiction.net's 'cover' images are really just tiny thumbnails. +## Set this to true to never use them. +#never_make_cover: false + +## fanfiction.net shows the user's +cover_exclusion_regexp:/imageu/ + +## fanfiction.net is blocking people more aggressively. If you +## download fewer stories less often you can likely get by with +## reducing this sleep. +slow_down_sleep_time:4 + +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:reviews,favs,follows + +## ffnet uses 'Pairings', not 'Relationship', stating they don't have +## to be romantic pairings. +ships_label:Pairings + +## Date formats used by FFDL. Published and Update don't usually have +## time, but they do now on ffnet. +## See http://docs.python.org/library/datetime.html#strftime-strptime-behavior +## Note that ini format requires % to be escaped as %%. +#dateCreated_format:%%Y-%%m-%%d %%H:%%M:%%S +datePublished_format:%%Y-%%m-%%d %%H:%%M:%%S +dateUpdated_format:%%Y-%%m-%%d %%H:%%M:%%S + +## ffnet used to have a tendency to send out update notices in email +## before all their servers were showing the update on the first +## chapter. It generates another server request and doesn't seem to +## be needed lately, so now default it to off. +check_next_chapter:false + +[www.fanfiktion.de] +## 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.ficbook.net] + +[www.fictionalley.org] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +## fictionally.org storyIds are not unique. Combine with authorId. +output_filename: ${title}-${siteabbrev}_${authorId}_${storyId}${formatext} + +## fictionalley.org doesn't have a status metadatum. If uncommented, +## this will be used for status. +#default_value_status:Unknown + +[www.fictionpress.com] +user_agent: +## Clear FanFiction from defaults, fictionpress.com is original fiction. +extratags: + +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:reviews,favs,follows + +[www.fimfiction.net] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## fimfiction.net stories can be locked requiring individual +## passwords. If fail_on_password is set, the downloader will fail +## when a password is required rather than prompting every time. +#fail_on_password: false + +## fimfiction.net stories allow chapters to be added out of order. So +## the newest chapter may not be the last one. FFDL update doesn't +## like that. If do_update_hook is uncommented and set true, the +## adapter will discard all existing chapters from the newest one on +## when updating to enforce accurate chapters. +#do_update_hook:false + +## fimfiction.net is reported to misinterprete some BBCode with +## blockquotes incorrectly. This fixes those instances and defaults +## to on, but can be switched off if it is found to cause problems. +fix_fimf_blockquotes:true + +## Site dedicated to these categories/characters/ships +extracategories:My Little Pony: Friendship is Magic + +## Extra metadata that this adapter knows about. See [dramione.org] +## for examples of how to use them. +extra_valid_entries:likes,dislikes,views,total_views,short_description,groups,groupsUrl,groupsHTML,prequel,prequelUrl,prequelHTML,sequels,sequelsUrl,sequelsHTML +likes_label:Likes +dislikes_label:Dislikes +views_label:Highest Single Chapter Views +total_views_label:Total Views +short_description_label:Short Summary +groups_label:Groups +groupsUrl_label:Groups URLs +groupsHTML_label:Groups +prequel_label:Prequel +prequelUrl_label:Prequel URL +prequelHTML_label:Prequel +sequels_label:Sequels +sequelsUrl_label:Sequel URLs +sequelsHTML_label:Sequels + +keep_in_order_sequels:true +keep_in_order_sequelsUrl:true +keep_in_order_groups:true +keep_in_order_groupsUrl:true + +## Assume entryUrl, apply to "<a class='%slink' href='%s'>%s</a>" to +## make entryHTML. +make_linkhtml_entries:prequel,sequels,groups + +[www.harrypotterfanfiction.com] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[www.henneth-annun.net] +## Site dedicated to these categories/characters/ships +extracategories:The Hobbit + +[www.hpfandom.net] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[www.hpfanficarchive.com] +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[www.ik-eternal.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 + +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:InuYasha +extracharacters:InuYasha,Kagome +extraships:InuYasha/Kagome + +[www.jlaunlimited.com] +## Site dedicated to these categories/characters/ships +extracategories:JLA + +[www.libraryofmoria.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 + +## Site dedicated to these categories/characters/ships +extracategories:Lord of the Rings + +[www.mediaminer.org] + +[www.midnightwhispers.ca] +## 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 + +## Site dedicated to these categories/characters/ships +extracategories:Queer as Folk + +## some sites include images that we don't ever want becoming the +## cover image. This lets you exclude them. +cover_exclusion_regexp:/stories/999/images/.*?_trophy.png + +[www.ncisfiction.net] +## Site dedicated to these categories/characters/ships +extracategories:NCIS + +[www.nickandgreg.net] +## Site dedicated to these categories/characters/ships +extracategories:CSI +extraships:Nick Stokes/Greg Sanders + +[www.phoenixsong.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 + +## phoenixsong.net, oddly, can have high rated chapters (login +## required) in the middle of a lower rated story. Use this to force +## FFDL to always login to phoenixsong.net so those stories download +## correctly. If you have a login, this is recommended. +#force_login:true + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extraships:Harry Potter/Ginny Weasley + +[www.potionsandsnitches.net] +## 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 + +[www.psychfic.com] +## Site dedicated to these categories/characters/ships +extracategories:Psych + +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +[www.qaf-fic.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 + +## Site dedicated to these categories/characters/ships +extracategories:Queer as Folk + +[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 +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extragenres:Erotica + +[www.scarvesandcoffee.net] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Glee +extracharacters:Kurt Hummel,Blaine Anderson + +[www.simplyundeniable.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter + +[www.sinful-desire.org] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Supernatural + +[www.siye.co.uk] +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extracharacters:Harry Potter,Ginny Weasley +extraships:Harry Potter/Ginny Weasley + +[www.squidge.org/peja] +## www.squidge.org/peja calls it Fandom <shrug> +category_label:Fandom + +## Remove numWords -- www.squidge.org/peja word counts are inaccurate +titlepage_entries: seriesHTML,category,genre,language,characters,ships,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,site,description + +[www.squidge.org/peja:txt] +## Add URLs since there aren't links and remove numWords -- +## www.squidge.org/peja word counts are inaccurate +titlepage_entries: series,seriesUrl,category,genre,language,status,datePublished,dateUpdated,dateCreated,rating,warnings,numChapters,site,storyUrl, authorUrl, description + +[www.storiesofarda.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 + +## Site dedicated to these categories/characters/ships +extracategories:Lord of the Rings + +[www.thepetulantpoetess.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +[www.thewriterscoffeeshop.com] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Some sites also require the user to confirm they are adult for +## adult content. In commandline version, this should go in your +## personal.ini, not defaults.ini. +#is_adult:true + +## thewriterscoffeeshop.com (ab)uses series as personal reading lists. +collect_series: false + +[www.tthfanfic.org] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## tth is a little unusual--it doesn't require user/pass, but the site +## keeps track of which chapters you've read and won't send another +## update until it thinks you're up to date. This way, on download, +## it thinks you're up to date. +#username:YourName +#password:yourpassword + +[www.twilightarchives.com] +## Site dedicated to these categories/characters/ships +extracategories:Twilight + +[www.twilighted.net] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Site dedicated to these categories/characters/ships +extracategories:Twilight + +## twilighted.net (ab)uses series as personal reading lists. +collect_series: false + +[www.twiwrite.net] +## Some sites require login (or login for some rated stories) The +## program can prompt you, or you can save it in config. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#username:YourName +#password:yourpassword + +## Site dedicated to these categories/characters/ships +extracategories:Twilight + +## twiwrite.net (ab)uses series as personal reading lists. +collect_series: false + +[www.walkingtheplank.org] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. In commandline version, +## this should go in your personal.ini, not defaults.ini. +#is_adult:true + +## Site dedicated to these categories/characters/ships +extracategories:Harry Potter +extracharacters:Severus Snape,Harry Potter +extraships:Severus Snape/Harry Potter + +[www.whofic.com] + +[www.wizardtales.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 + +## 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 + +[www.wolverineandrogue.com] +## Site dedicated to these categories/characters/ships +extracategories:X-Men Movie +extracharacters:Wolverine,Rogue + +[www.wraithbait.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 + +## Site dedicated to these categories/characters/ships +extracategories:Stargate: Atlantis + +extra_valid_entries:reviews +reviews_label:Reviews + +[overrides] +## It may sometimes be useful to override all of the specific format, +## site and site:format sections in your private configuration. For +## example, this extratags param here would override all of the +## extratags params in all other sections. Only commandline options +## beat overrides. +#extratags:fanficdownloader + + +[teststory:defaults] +valid_entries:title,author_list,authorId_list,authorUrl_list,storyUrl, + datePublished,dateUpdated,numWords,status,language,series,seriesUrl, + rating,category_list,genre_list,warnings_list,characters_list,ships_list, + description,site,extratags + +# {{storyId}} is a special case--it's the only one that works. +title:Test Story Title {{storyId}} +author_list:Test Author aa +authorId_list:1 +authorUrl_list:http://test1.com?authid=1 +storyUrl:http://test1.com?sid={{storyId}} +datePublished:1975-03-15 +dateUpdated:1975-04-15 +numWords:123,456 +status:In-Progress +language:English + +chaptertitles:Prologue + +## Add additional sections with different numbers to get different +## parameters for different story urls. +## test1.com?sid=1000 +[teststory:1000] +# note the leading commas when doing add_to_ with valid_entries and *_list +add_to_valid_entries:,favs +title:Testing New Feature {{storyId}} +author_list:Bob Smith +authorId_list:45 +authorUrl_list:http://test1.com?authid=45 +datePublished:2013-03-15 +dateUpdated:2013-04-15 +numWords:1456 +favs:56 +series:The Great Test [4] +seriesUrl:http://test1.com?seriesid=1 +rating:Tweenie +category_list:Harry Potter,Furbie,Crossover,Puella Magi Madoka Magica/魔法少女まどか★マギカ,Magical Girl Lyrical Nanoha +genre_list:Fantasy,Comedy,Sci-Fi,Noir +warnings_list:Swearing,Violence +characters_list:Bob Smith,George Johnson,Fred Smythe + +chaptertitles:Prologue,Chapter 1\, Xenos on Cinnabar,Chapter 2\, Sinmay on Kintikin,3. Chapter 3 diff --git a/delete_fic.py b/delete_fic.py new file mode 100644 index 00000000..73722724 --- /dev/null +++ b/delete_fic.py @@ -0,0 +1,59 @@ +import os +import cgi +import sys +import logging +import traceback +import StringIO + +from google.appengine.api import users +from google.appengine.ext import webapp +from google.appengine.ext.webapp import util + +from fanficdownloader.downaloder import * +from fanficdownloader.ffnet import * +from fanficdownloader.output import * + +from google.appengine.ext import db + +from fanficdownloader.zipdir import * + +from ffstorage import * + +def create_mac(user, fic_id, fic_url): + return str(abs(hash(user)+hash(fic_id)))+str(abs(hash(fic_url))) + +def check_mac(user, fic_id, fic_url, mac): + return (create_mac(user, fic_id, fic_url) == mac) + +def create_mac_for_fic(user, fic_id): + key = db.Key(fic_id) + fanfic = db.get(key) + if fanfic.user != user: + return None + else: + return create_mac(user, key, fanfic.url) + +class DeleteFicHandler(webapp.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect('/login') + + fic_id = self.request.get('fic_id') + fic_mac = self.request.get('key_id') + + actual_mac = create_mac_for_fic(user, fic_id) + if actual_mac != fic_mac: + self.response.out.write("Ooops") + else: + key = db.Key(fic_id) + fanfic = db.get(key) + fanfic.delete() + self.redirect('/recent') + + + fics = db.GqlQuery("Select * From DownloadedFanfic WHERE user = :1", user) + template_values = dict(fics = fics, nickname = user.nickname()) + path = os.path.join(os.path.dirname(__file__), 'recent.html') + self.response.out.write(template.render(path, template_values)) + \ No newline at end of file diff --git a/downloader.py b/downloader.py new file mode 100644 index 00000000..290f2f93 --- /dev/null +++ b/downloader.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys, os +from os.path import normpath, expanduser, isfile, join +from StringIO import StringIO +from optparse import OptionParser +import getpass +import string +import ConfigParser +from subprocess import call +import pprint + +import logging +if sys.version_info >= (2, 7): + # suppresses default logger. Logging is setup in fanficdownload/__init__.py so it works in calibre, too. + rootlogger = logging.getLogger() + loghandler=logging.NullHandler() + loghandler.setFormatter(logging.Formatter("(=====)(levelname)s:%(message)s")) + rootlogger.addHandler(loghandler) + +try: + from calibre.constants import numeric_version as calibre_version + is_calibre = True +except: + is_calibre = False + +# using try/except directly was masking errors during development. +if is_calibre: + # running under calibre + from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters,writers,exceptions + from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.configurable import Configuration + from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.epubutils import get_dcsource_chaptercount, get_update_data + from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.geturls import get_urls_from_page +else: + from fanficdownloader import adapters,writers,exceptions + from fanficdownloader.configurable import Configuration + from fanficdownloader.epubutils import get_dcsource_chaptercount, get_update_data + from fanficdownloader.geturls import get_urls_from_page + + +if sys.version_info < (2, 5): + print "This program requires Python 2.5 or newer." + sys.exit(1) + +def writeStory(config,adapter,writeformat,metaonly=False,outstream=None): + writer = writers.getWriter(writeformat,config,adapter) + writer.writeStory(outstream=outstream,metaonly=metaonly) + output_filename=writer.getOutputFileName() + del writer + return output_filename + +def main(argv, + parser=None, + passed_defaultsini=None, + passed_personalini=None): + # read in args, anything starting with -- will be treated as --<varible>=<value> + if not parser: + parser = OptionParser("usage: %prog [options] storyurl") + parser.add_option("-f", "--format", dest="format", default="epub", + help="write story as FORMAT, epub(default), mobi, text or html", metavar="FORMAT") + + if passed_defaultsini: + config_help="read config from specified file(s) in addition to calibre plugin personal.ini, ~/.fanficdownloader/personal.ini, and ./personal.ini" + else: + config_help="read config from specified file(s) in addition to ~/.fanficdownloader/defaults.ini, ~/.fanficdownloader/personal.ini, ./defaults.ini, and ./personal.ini" + parser.add_option("-c", "--config", + action="append", dest="configfile", default=None, + help=config_help, metavar="CONFIG") + parser.add_option("-b", "--begin", dest="begin", default=None, + help="Begin with Chapter START", metavar="START") + parser.add_option("-e", "--end", dest="end", default=None, + help="End with Chapter END", metavar="END") + parser.add_option("-o", "--option", + action="append", dest="options", + help="set an option NAME=VALUE", metavar="NAME=VALUE") + parser.add_option("-m", "--meta-only", + action="store_true", dest="metaonly", + help="Retrieve metadata and stop. Or, if --update-epub, update metadata title page only.",) + parser.add_option("-u", "--update-epub", + action="store_true", dest="update", + help="Update an existing epub with new chapters, give epub filename instead of storyurl.",) + parser.add_option("--update-cover", + action="store_true", dest="updatecover", + help="Update cover in an existing epub, otherwise existing cover (if any) is used on update. Only valid with --update-epub.",) + parser.add_option("--force", + action="store_true", dest="force", + help="Force overwrite of an existing epub, download and overwrite all chapters.",) + parser.add_option("-l", "--list", + action="store_true", dest="list", + help="Get list of valid story URLs from page given.",) + parser.add_option("-n", "--normalize-list", + action="store_true", dest="normalize",default=False, + help="Get list of valid story URLs from page given, but normalized to standard forms.",) + parser.add_option("-s", "--sites-list", + action="store_true", dest="siteslist",default=False, + help="Get list of valid story URLs examples.",) + parser.add_option("-d", "--debug", + action="store_true", dest="debug", + help="Show debug output while downloading.",) + + (options, args) = parser.parse_args(argv) + + if not options.debug: + logger = logging.getLogger("fanficdownloader") + logger.setLevel(logging.INFO) + + if not options.siteslist and len(args) != 1: + parser.error("incorrect number of arguments") + + if options.siteslist: + for (site,examples) in adapters.getSiteExamples(): + print("\n====%s====\n\nExample URLs:"%site) + for u in examples: + print(" * %s"%u) + return + + if options.update and options.format != 'epub': + parser.error("-u/--update-epub only works with epub") + + ## Attempt to update an existing epub. + chaptercount = None + output_filename = None + if options.update: + try: + (url,chaptercount) = get_dcsource_chaptercount(args[0]) + if not url: + print "No story URL found in epub to update." + return + print "Updating %s, URL: %s" % (args[0],url) + output_filename = args[0] + except: + # if there's an error reading the update file, maybe it's a URL? + # we'll look for an existing outputfile down below. + url = args[0] + else: + url = args[0] + + try: + configuration = Configuration(adapters.getConfigSectionFor(url),options.format) + except exceptions.UnknownSite, e: + if options.list or options.normalize: + # list for page doesn't have to be a supported site. + configuration = Configuration("test1.com",options.format) + else: + raise e + + conflist = [] + homepath = join(expanduser("~"),".fanficdownloader") + + if passed_defaultsini: + configuration.readfp(passed_defaultsini) + + if isfile(join(homepath,"defaults.ini")): + conflist.append(join(homepath,"defaults.ini")) + if isfile("defaults.ini"): + conflist.append("defaults.ini") + + if passed_personalini: + configuration.readfp(passed_personalini) + + if isfile(join(homepath,"personal.ini")): + conflist.append(join(homepath,"personal.ini")) + if isfile("personal.ini"): + conflist.append("personal.ini") + + if options.configfile: + conflist.extend(options.configfile) + + logging.debug('reading %s config file(s), if present'%conflist) + configuration.read(conflist) + + try: + configuration.add_section("overrides") + except ConfigParser.DuplicateSectionError: + pass + + if options.force: + configuration.set("overrides","always_overwrite","true") + + if options.update and chaptercount: + configuration.set("overrides","output_filename",output_filename) + + if options.update and not options.updatecover: + configuration.set("overrides","never_make_cover","true") + + # images only for epub, even if the user mistakenly turned it + # on else where. + if options.format not in ("epub","html"): + configuration.set("overrides","include_images","false") + + if options.options: + for opt in options.options: + (var,val) = opt.split('=') + configuration.set("overrides",var,val) + + if options.list or options.normalize: + retlist = get_urls_from_page(args[0], configuration, normalize=options.normalize) + print "\n".join(retlist) + return + + try: + adapter = adapters.getAdapter(configuration,url) + adapter.setChaptersRange(options.begin,options.end) + + # check for updating from URL (vs from file) + if options.update and not chaptercount: + try: + writer = writers.getWriter("epub",configuration,adapter) + output_filename=writer.getOutputFileName() + (noturl,chaptercount) = get_dcsource_chaptercount(output_filename) + print "Updating %s, URL: %s" % (output_filename,url) + except: + options.update = False + pass + + ## Check for include_images without no_image_processing. In absence of PIL, give warning. + if adapter.getConfig('include_images') and not adapter.getConfig('no_image_processing'): + try: + from calibre.utils.magick import Image + logging.debug("Using calibre.utils.magick") + except: + try: + import Image + logging.debug("Using PIL") + except: + print "You have include_images enabled, but Python Image Library(PIL) isn't found.\nImages will be included full size in original format.\nContinue? (y/n)?" + if not sys.stdin.readline().strip().lower().startswith('y'): + return + + ## three tries, that's enough if both user/pass & is_adult needed, + ## or a couple tries of one or the other + for x in range(0,2): + try: + adapter.getStoryMetadataOnly() + except exceptions.FailedToLogin, f: + if f.passwdonly: + print "Story requires a password." + else: + print "Login Failed, Need Username/Password." + sys.stdout.write("Username: ") + adapter.username = sys.stdin.readline().strip() + adapter.password = getpass.getpass(prompt='Password: ') + #print("Login: `%s`, Password: `%s`" % (adapter.username, adapter.password)) + except exceptions.AdultCheckRequired: + print "Please confirm you are an adult in your locale: (y/n)?" + if sys.stdin.readline().strip().lower().startswith('y'): + adapter.is_adult=True + + if options.update and not options.force: + urlchaptercount = int(adapter.getStoryMetadataOnly().getMetadata('numChapters')) + + if chaptercount == urlchaptercount and not options.metaonly: + print "%s already contains %d chapters." % (output_filename,chaptercount) + elif chaptercount > urlchaptercount: + print "%s contains %d chapters, more than source: %d." % (output_filename,chaptercount,urlchaptercount) + elif chaptercount == 0: + print "%s doesn't contain any recognizable chapters, probably from a different source. Not updating." % (output_filename) + else: + # update now handled by pre-populating the old + # images and chapters in the adapter rather than + # merging epubs. + (url, + chaptercount, + adapter.oldchapters, + adapter.oldimgs, + adapter.oldcover, + adapter.calibrebookmark, + adapter.logfile) = get_update_data(output_filename) + + print "Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount) + + if not (options.update and chaptercount == urlchaptercount) \ + and adapter.getConfig("do_update_hook"): + chaptercount = adapter.hookForUpdates(chaptercount) + + writeStory(configuration,adapter,"epub") + + else: + # regular download + if options.metaonly: + pprint.pprint(adapter.getStoryMetadataOnly().getAllMetadata()) + + output_filename=writeStory(configuration,adapter,options.format,options.metaonly) + + if not options.metaonly and adapter.getConfig("post_process_cmd"): + metadata = adapter.story.metadata + metadata['output_filename']=output_filename + call(string.Template(adapter.getConfig("post_process_cmd")) + .substitute(metadata), shell=True) + + del adapter + + except exceptions.InvalidStoryURL, isu: + print isu + except exceptions.StoryDoesNotExist, dne: + print dne + except exceptions.UnknownSite, us: + print us + +if __name__ == "__main__": + #import time + #start = time.time() + main(sys.argv[1:]) + #print("Total time seconds:%f"%(time.time()-start)) diff --git a/editconfig.html b/editconfig.html new file mode 100644 index 00000000..7f4ed973 --- /dev/null +++ b/editconfig.html @@ -0,0 +1,89 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN"> +<html> + <head> + <link href="/css/index.css" rel="stylesheet" type="text/css"> + <title>FanFictionDownLoader - read fanfiction from twilighted.net, fanfiction.net, fictionpress.com, fictionalley.org, ficwad.com, potionsandsnitches.net, harrypotterfanfiction.com, mediaminer.org on Kindle, Nook, Sony Reader, iPad, iPhone, Android, Aldiko, Stanza + + + + + +
+

+ FanFictionDownLoader +

+ +
+ + +
+ +
+ +
+

Edit Config

+
+ Editing configuration for {{ nickname }}. +
+
+ +
+
+ +
+ +
+
+ +
+

Default System configuration

+
+{{ defaultsini }}
+
+
+ +
+ Powered by Google App Engine +

+ This is a web front-end to FanFictionDownLoader
+ Copyright © Fanficdownloader team +
+ +
+ + +
+
+ + diff --git a/epubmerge.py b/epubmerge.py new file mode 100644 index 00000000..f7e76b8c --- /dev/null +++ b/epubmerge.py @@ -0,0 +1,25 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# epubmerge.py 1.0 + +# Copyright 2011, Jim Miller + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +if __name__ == "__main__": + print(''' +The this utility has been split out into it's own project. +See: http://code.google.com/p/epubmerge/ +...for a CLI epubmerge.py program and calibre plugin. +''') diff --git a/example.ini b/example.ini new file mode 100644 index 00000000..13e4a854 --- /dev/null +++ b/example.ini @@ -0,0 +1,103 @@ +## This is an example of what your personal configuration might look +## like. Uncomment options by removing the '#' in front of them. + +[defaults] +## Some sites also require the user to confirm they are adult for +## adult content. Uncomment by removing '#' in front of is_adult. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#is_adult:true + +## Don't like the numbers at the start of chapter titles on some +## sites? You can use strip_chapter_numbers to strip them off. Just +## want to make them all look the same? Strip them off, then add them +## back on with add_chapter_numbers. Don't like the way it strips +## numbers or adds them back? See chapter_title_strip_pattern and +## chapter_title_add_pattern. +#strip_chapter_numbers:true +#add_chapter_numbers:true + +[epub] +## include images from img tags in the body and summary of stories. +## Images will be converted to jpg for size if possible. Images work +## in epub format only. To get mobi or other format with images, +## download as epub and use Calibre to convert. +#include_images:true + +## If not set, the summary will have all html stripped for safety. +## Both this and include_images must be true to get images in the +## summary. +#keep_summary_html:true + +## If set, the first image found will be made the cover image. If +## keep_summary_html is true, any images in summary will be before any +## in chapters. +#make_firstimage_cover:true + +## Resize images down to width, height, preserving aspect ratio. +## Nook size, with margin. +#image_max_size: 580, 725 + +## Change image to grayscale, if graphics library allows, to save +## space. +#grayscale_images: false + + +## Most common, I expect will be using this to save username/passwords +## for different sites. Here are a few examples. See defaults.ini +## for the full list. + +[www.twilighted.net] +#username:YourPenname +#password:YourPassword +## default is false +#collect_series: true + +[www.ficwad.com] +#username:YourUsername +#password:YourPassword + +[www.twiwrite.net] +#username:YourName +#password:yourpassword +## default is false +#collect_series: true + +[www.adastrafanfic.com] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. +#is_adult:true + +[www.thewriterscoffeeshop.com] +#username:YourName +#password:yourpassword +#is_adult:true +## default is false +#collect_series: true + +[www.fictionalley.org] +#is_adult:true + +[www.harrypotterfanfiction.com] +#is_adult:true + +[www.fimfiction.net] +#is_adult:true +#fail_on_password: false + +[www.tthfanfic.org] +#is_adult:true +## tth is a little unusual--it doesn't require user/pass, but the site +## keeps track of which chapters you've read and won't send another +## update until it thinks you're up to date. This way, on download, +## it thinks you're up to date. +#username:YourName +#password:yourpassword + + +## This section will override anything in the system defaults or other +## sections here. +[overrides] +## default varies by site. Set true here to force all sites to +## collect series. +#collect_series: true diff --git a/fanficdownloader.zip b/fanficdownloader.zip new file mode 100644 index 0000000000000000000000000000000000000000..76f1e191b2721a6861e4a4bc6a8d98b790ebd6f1 GIT binary patch literal 673167 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�qoqIvBP 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%0Ay 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

KnXpl$7EaW~^V`mIw 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

L&3zra6v6c zM3ILh#{Kr*MGBF>c=z+d3EFaDA2b@2+2E%^@?A=IcZ&VA;6q~9(#2()Y}Lwbl-{95 zqe_(;GY9jfs|lNu@7_mSYdoTqsO3=*70-i@iT?&pDL6|Qb#cB!4Ged4T(%*DK`d65 zOm`K<0`GBejHo{s_-1Dy(tS8epT7$5Tm^Y?ly<$YW?lQsALxNxjJ8ZiXFA|`AkSaepHm)G$KyX+~N^(#G$Iq8?dsuQL9cH1?GRyw!9jA3NN z94UcB4emqNIbyYwfnH;p4KD6V!2>oCiwUwPQGS%poehR0Zccm8*`lp`q_!MP#^-h6 zeQAwUfGVrb5HqJV6(c%_%uAjTvdSbG)Y`3!b{;R2T8CcbbsHWRwjKR;?4z0co}*Rm z7=>y%Bp$)I+f6A$2H0ngPXM}0ap~n#-(E$2_z?09@vL-(Xv<7mDhFtFPB{XYe_opB z7xZHhK~-wQqfI_d18rH4fc=6TW?&I_6fs>Hz0LSCFj{YXBwhN;0co4Dy?#mtx{_j1 zu?Tw>!k!VIN3$cFqj^9Yi8fBIic=GeKR^@o@pvXGC&x%U?6r;7!S4>kuzDP^s7=Is zNJHQt<uuznz&i_lVAS7cpF7$wdgIuNLWJ zw3e*KVz1)Ct7^q+UTwmssAN; zF}e;TsR%bpEW(poWj&k2eVzk#jo$~`EPSMeqza^KrzvskN#eFIF0$u%hJcZPAY0*} zY3$llTP&t&#Mk_iXz!w(G@JK5%NPokTL`;HIN9{s zCls!Rkn_MMk5Rgx7llfoW!OP%M8W+>%$JrRkA2CPg@IVt3cdGsh9m9a^n+golD`D5 z)&37@{}5eS1GVkKamBW6yW)ynv2EL`*tTukwr!(g+sVJ{dH>TmU*~*dykmD(bNAMo zbKduLHRC-GE`Bagd9OlU!(UenV~PspvqKC+iI%*U;MK)BrRWoSO4*%26qBL>4)(S? zGjbQUxu6jQw45|gHA$H?y>V_GbPuYa?9=rvSONTd?ZP$Z;Il+EZ|?z8nEa@Z)i$oV zeLwdWpV1_I8mQ1VzK3FQc5Rt~fJsPL%g^R1l$@ULy~U7z%>_3^Gg!Iq*uOe|-`M)~ z@G(t3()p=pB(>=WMs(@tYYZr!RiFAuJLzJ?@@@A1k<8Q6>#q3{{tNBw|G2cd4QMKq zdmi1lSe#q0KJNIPqq?@{Wt$`HeuMtMzmWD4QbfSufPfw-fPhr~S6@iBhK5$o*2Xr5 z?v8+bzMYxAjgy(N+y8e`zw6%-UKIT^6X+E|v0X>fGA+7+j{Q7pzTJm)m|PZ-8wX56 zFLE?)Ft}zKmd-Nb>zUWpL_7}la241EYBhc|jr-aCN@(4nRBa`+MRQ zkH^c&9y<4UL{X%v<5V`!_Ks-@PsXr*B11jtaU4KJ_#e&&D}-2)*udB%dlHKt}y&Qk{1fU0hsRJ8UE!raowa;@*UZP!;~bM0EvnfHids!h8$}85EiCP1nH4F z3nh>UfDq zUlAd*d}U)KMUl;Er2$er2Zn$pkKr~Tt>1C8$`FEKia|nr_jPeMbncpR@wvCr5^|hk zN`Z0lsOj?X_VIFYck%d>FROmw9^pdQ^mv4XJeMhx#0FXd*>@_nk&3vW9fnAQR@!kQ zo>~}l;0lp0Bk5mSzXzQ;K)4c-ISNf(*{?5jl++&EvU;kD?H|!b48h)po^8RN`V2vW zFT?MsAmjmJV|Xb`kNiQ((bmp6=~fAl)<4&F5XnuHK>nSL_*piohz>T%`zmsyFtWhh z_Me83yj+K6#T=R3-_&hCIbk~jBc(>C8mT_oY)Cok_hTAL^&nb|TV7D*?~$$uwFn_k z&%xb1-9e@m|7H<`al&1B0MZ5)0iTz&BvCMh*|27SkxDTxXd;3P*L|AhQ*aaau#Ds| zKe3rv0X8Fm=4De@;PLjl2olp%TN3P-3mx-*A}-C~IM}yLD3-3$?(_u*(_tuybmS=j zZUeg?@KnxVFOL}lHC3j2U^k1D4c15;zp1KfkA7ZJ;fFltBy1&X7zhQcPUT+>AGVvU zjb`5tfU!XdWcV_8KWvX+VFk?a1lAq$;mh z3nlzthOYdp;sx!^s})-21v;z7@Iiu>_7*ZuAzKv4B?6^zR!DXE zuFyJQAvxFF)2cvbG0z<+W|szQRk&)RQislH(a#W@5W}17Yz^_dOeJtK_YMFlB4<

d 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≈~+ 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�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

C0CvePpeb~XzdzhpeTif%gg39 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μ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 bUsdr}18x~ePvE&2_&0kwhBZ8g#1Q*Go!Ecr literal 0 HcmV?d00001 diff --git a/fanficdownloader/BeautifulSoup.py b/fanficdownloader/BeautifulSoup.py new file mode 100644 index 00000000..4b17b853 --- /dev/null +++ b/fanficdownloader/BeautifulSoup.py @@ -0,0 +1,2014 @@ +"""Beautiful Soup +Elixir and Tonic +"The Screen-Scraper's Friend" +http://www.crummy.com/software/BeautifulSoup/ + +Beautiful Soup parses a (possibly invalid) XML or HTML document into a +tree representation. It provides methods and Pythonic idioms that make +it easy to navigate, search, and modify the tree. + +A well-formed XML/HTML document yields a well-formed data +structure. An ill-formed XML/HTML document yields a correspondingly +ill-formed data structure. If your document is only locally +well-formed, you can use this library to find and process the +well-formed part of it. + +Beautiful Soup works with Python 2.2 and up. It has no external +dependencies, but you'll have more success at converting data to UTF-8 +if you also install these three packages: + +* chardet, for auto-detecting character encodings + http://chardet.feedparser.org/ +* cjkcodecs and iconv_codec, which add more encodings to the ones supported + by stock Python. + http://cjkpython.i18n.org/ + +Beautiful Soup defines classes for two main parsing strategies: + + * BeautifulStoneSoup, for parsing XML, SGML, or your domain-specific + language that kind of looks like XML. + + * BeautifulSoup, for parsing run-of-the-mill HTML code, be it valid + or invalid. This class has web browser-like heuristics for + obtaining a sensible parse tree in the face of common HTML errors. + +Beautiful Soup also defines a class (UnicodeDammit) for autodetecting +the encoding of an HTML or XML document, and converting it to +Unicode. Much of this code is taken from Mark Pilgrim's Universal Feed Parser. + +For more than you ever wanted to know about Beautiful Soup, see the +documentation: +http://www.crummy.com/software/BeautifulSoup/documentation.html + +Here, have some legalese: + +Copyright (c) 2004-2010, Leonard Richardson + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the the Beautiful Soup Consortium and All + Night Kosher Bakery nor the names of its contributors may be + used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE, DAMMIT. + +""" +from __future__ import generators + +__author__ = "Leonard Richardson (leonardr@segfault.org)" +__version__ = "3.2.0" +__copyright__ = "Copyright (c) 2004-2010 Leonard Richardson" +__license__ = "New-style BSD" + +from sgmllib import SGMLParser, SGMLParseError +import codecs +import markupbase +import types +import re +import sgmllib +try: + from htmlentitydefs import name2codepoint +except ImportError: + name2codepoint = {} +try: + set +except NameError: + from sets import Set as set + +#These hacks make Beautiful Soup able to parse XML with namespaces +sgmllib.tagfind = re.compile('[a-zA-Z][-_.:a-zA-Z0-9]*') +markupbase._declname_match = re.compile(r'[a-zA-Z][-_.:a-zA-Z0-9]*\s*').match + +DEFAULT_OUTPUT_ENCODING = "utf-8" + +def _match_css_class(str): + """Build a RE to match the given CSS class.""" + return re.compile(r"(^|.*\s)%s($|\s)" % str) + +# First, the classes that represent markup elements. + +class PageElement(object): + """Contains the navigational information for some part of the page + (either a tag or a piece of text)""" + + def setup(self, parent=None, previous=None): + """Sets up the initial relations between this element and + other elements.""" + self.parent = parent + self.previous = previous + self.next = None + self.previousSibling = None + self.nextSibling = None + if self.parent and self.parent.contents: + self.previousSibling = self.parent.contents[-1] + self.previousSibling.nextSibling = self + + def replaceWith(self, replaceWith): + oldParent = self.parent + myIndex = self.parent.index(self) + if hasattr(replaceWith, "parent")\ + and replaceWith.parent is self.parent: + # We're replacing this element with one of its siblings. + index = replaceWith.parent.index(replaceWith) + if index and index < myIndex: + # Furthermore, it comes before this element. That + # means that when we extract it, the index of this + # element will change. + myIndex = myIndex - 1 + self.extract() + oldParent.insert(myIndex, replaceWith) + + def replaceWithChildren(self): + myParent = self.parent + myIndex = self.parent.index(self) + self.extract() + reversedChildren = list(self.contents) + reversedChildren.reverse() + for child in reversedChildren: + myParent.insert(myIndex, child) + + def extract(self): + """Destructively rips this element out of the tree.""" + if self.parent: + try: + del self.parent.contents[self.parent.index(self)] + except ValueError: + pass + + #Find the two elements that would be next to each other if + #this element (and any children) hadn't been parsed. Connect + #the two. + lastChild = self._lastRecursiveChild() + nextElement = lastChild.next + + if self.previous: + self.previous.next = nextElement + if nextElement: + nextElement.previous = self.previous + self.previous = None + lastChild.next = None + + self.parent = None + if self.previousSibling: + self.previousSibling.nextSibling = self.nextSibling + if self.nextSibling: + self.nextSibling.previousSibling = self.previousSibling + self.previousSibling = self.nextSibling = None + return self + + def _lastRecursiveChild(self): + "Finds the last element beneath this object to be parsed." + lastChild = self + while hasattr(lastChild, 'contents') and lastChild.contents: + lastChild = lastChild.contents[-1] + return lastChild + + def insert(self, position, newChild): + if isinstance(newChild, basestring) \ + and not isinstance(newChild, NavigableString): + newChild = NavigableString(newChild) + + position = min(position, len(self.contents)) + if hasattr(newChild, 'parent') and newChild.parent is not None: + # We're 'inserting' an element that's already one + # of this object's children. + if newChild.parent is self: + index = self.index(newChild) + if index > position: + # Furthermore we're moving it further down the + # list of this object's children. That means that + # when we extract this element, our target index + # will jump down one. + position = position - 1 + newChild.extract() + + newChild.parent = self + previousChild = None + if position == 0: + newChild.previousSibling = None + newChild.previous = self + else: + previousChild = self.contents[position-1] + newChild.previousSibling = previousChild + newChild.previousSibling.nextSibling = newChild + newChild.previous = previousChild._lastRecursiveChild() + if newChild.previous: + newChild.previous.next = newChild + + newChildsLastElement = newChild._lastRecursiveChild() + + if position >= len(self.contents): + newChild.nextSibling = None + + parent = self + parentsNextSibling = None + while not parentsNextSibling: + parentsNextSibling = parent.nextSibling + parent = parent.parent + if not parent: # This is the last element in the document. + break + if parentsNextSibling: + newChildsLastElement.next = parentsNextSibling + else: + newChildsLastElement.next = None + else: + nextChild = self.contents[position] + newChild.nextSibling = nextChild + if newChild.nextSibling: + newChild.nextSibling.previousSibling = newChild + newChildsLastElement.next = nextChild + + if newChildsLastElement.next: + newChildsLastElement.next.previous = newChildsLastElement + self.contents.insert(position, newChild) + + def append(self, tag): + """Appends the given tag to the contents of this tag.""" + self.insert(len(self.contents), tag) + + def findNext(self, name=None, attrs={}, text=None, **kwargs): + """Returns the first item that matches the given criteria and + appears after this Tag in the document.""" + return self._findOne(self.findAllNext, name, attrs, text, **kwargs) + + def findAllNext(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns all items that match the given criteria and appear + after this Tag in the document.""" + return self._findAll(name, attrs, text, limit, self.nextGenerator, + **kwargs) + + def findNextSibling(self, name=None, attrs={}, text=None, **kwargs): + """Returns the closest sibling to this Tag that matches the + given criteria and appears after this Tag in the document.""" + return self._findOne(self.findNextSiblings, name, attrs, text, + **kwargs) + + def findNextSiblings(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns the siblings of this Tag that match the given + criteria and appear after this Tag in the document.""" + return self._findAll(name, attrs, text, limit, + self.nextSiblingGenerator, **kwargs) + fetchNextSiblings = findNextSiblings # Compatibility with pre-3.x + + def findPrevious(self, name=None, attrs={}, text=None, **kwargs): + """Returns the first item that matches the given criteria and + appears before this Tag in the document.""" + return self._findOne(self.findAllPrevious, name, attrs, text, **kwargs) + + def findAllPrevious(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns all items that match the given criteria and appear + before this Tag in the document.""" + return self._findAll(name, attrs, text, limit, self.previousGenerator, + **kwargs) + fetchPrevious = findAllPrevious # Compatibility with pre-3.x + + def findPreviousSibling(self, name=None, attrs={}, text=None, **kwargs): + """Returns the closest sibling to this Tag that matches the + given criteria and appears before this Tag in the document.""" + return self._findOne(self.findPreviousSiblings, name, attrs, text, + **kwargs) + + def findPreviousSiblings(self, name=None, attrs={}, text=None, + limit=None, **kwargs): + """Returns the siblings of this Tag that match the given + criteria and appear before this Tag in the document.""" + return self._findAll(name, attrs, text, limit, + self.previousSiblingGenerator, **kwargs) + fetchPreviousSiblings = findPreviousSiblings # Compatibility with pre-3.x + + def findParent(self, name=None, attrs={}, **kwargs): + """Returns the closest parent of this Tag that matches the given + criteria.""" + # NOTE: We can't use _findOne because findParents takes a different + # set of arguments. + r = None + l = self.findParents(name, attrs, 1) + if l: + r = l[0] + return r + + def findParents(self, name=None, attrs={}, limit=None, **kwargs): + """Returns the parents of this Tag that match the given + criteria.""" + + return self._findAll(name, attrs, None, limit, self.parentGenerator, + **kwargs) + fetchParents = findParents # Compatibility with pre-3.x + + #These methods do the real heavy lifting. + + def _findOne(self, method, name, attrs, text, **kwargs): + r = None + l = method(name, attrs, text, 1, **kwargs) + if l: + r = l[0] + return r + + def _findAll(self, name, attrs, text, limit, generator, **kwargs): + "Iterates over a generator looking for things that match." + + if isinstance(name, SoupStrainer): + strainer = name + # (Possibly) special case some findAll*(...) searches + elif text is None and not limit and not attrs and not kwargs: + # findAll*(True) + if name is True: + return [element for element in generator() + if isinstance(element, Tag)] + # findAll*('tag-name') + elif isinstance(name, basestring): + return [element for element in generator() + if isinstance(element, Tag) and + element.name == name] + else: + strainer = SoupStrainer(name, attrs, text, **kwargs) + # Build a SoupStrainer + else: + strainer = SoupStrainer(name, attrs, text, **kwargs) + results = ResultSet(strainer) + g = generator() + while True: + try: + i = g.next() + except StopIteration: + break + if i: + found = strainer.search(i) + if found: + results.append(found) + if limit and len(results) >= limit: + break + return results + + #These Generators can be used to navigate starting from both + #NavigableStrings and Tags. + def nextGenerator(self): + i = self + while i is not None: + i = i.next + yield i + + def nextSiblingGenerator(self): + i = self + while i is not None: + i = i.nextSibling + yield i + + def previousGenerator(self): + i = self + while i is not None: + i = i.previous + yield i + + def previousSiblingGenerator(self): + i = self + while i is not None: + i = i.previousSibling + yield i + + def parentGenerator(self): + i = self + while i is not None: + i = i.parent + yield i + + # Utility methods + def substituteEncoding(self, str, encoding=None): + encoding = encoding or "utf-8" + return str.replace("%SOUP-ENCODING%", encoding) + + def toEncoding(self, s, encoding=None): + """Encodes an object to a string in some encoding, or to Unicode. + .""" + if isinstance(s, unicode): + if encoding: + s = s.encode(encoding) + elif isinstance(s, str): + if encoding: + s = s.encode(encoding) + else: + s = unicode(s) + else: + if encoding: + s = self.toEncoding(str(s), encoding) + else: + s = unicode(s) + return s + +class NavigableString(unicode, PageElement): + + def __new__(cls, value): + """Create a new NavigableString. + + When unpickling a NavigableString, this method is called with + the string in DEFAULT_OUTPUT_ENCODING. That encoding needs to be + passed in to the superclass's __new__ or the superclass won't know + how to handle non-ASCII characters. + """ + if isinstance(value, unicode): + return unicode.__new__(cls, value) + return unicode.__new__(cls, value, DEFAULT_OUTPUT_ENCODING) + + def __getnewargs__(self): + return (NavigableString.__str__(self),) + + def __getattr__(self, attr): + """text.string gives you text. This is for backwards + compatibility for Navigable*String, but for CData* it lets you + get the string without the CData wrapper.""" + if attr == 'string': + return self + else: + raise AttributeError, "'%s' object has no attribute '%s'" % (self.__class__.__name__, attr) + + def __unicode__(self): + return str(self).decode(DEFAULT_OUTPUT_ENCODING) + + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + if encoding: + return self.encode(encoding) + else: + return self + +class CData(NavigableString): + + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + return "" % NavigableString.__str__(self, encoding) + +class ProcessingInstruction(NavigableString): + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + output = self + if "%SOUP-ENCODING%" in output: + output = self.substituteEncoding(output, encoding) + return "" % self.toEncoding(output, encoding) + +class Comment(NavigableString): + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + return "" % NavigableString.__str__(self, encoding) + +class Declaration(NavigableString): + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + return "" % NavigableString.__str__(self, encoding) + +class Tag(PageElement): + + """Represents a found HTML tag with its attributes and contents.""" + + def _invert(h): + "Cheap function to invert a hash." + i = {} + for k,v in h.items(): + i[v] = k + return i + + XML_ENTITIES_TO_SPECIAL_CHARS = { "apos" : "'", + "quot" : '"', + "amp" : "&", + "lt" : "<", + "gt" : ">" } + + XML_SPECIAL_CHARS_TO_ENTITIES = _invert(XML_ENTITIES_TO_SPECIAL_CHARS) + + def _convertEntities(self, match): + """Used in a call to re.sub to replace HTML, XML, and numeric + entities with the appropriate Unicode characters. If HTML + entities are being converted, any unrecognized entities are + escaped.""" + x = match.group(1) + if self.convertHTMLEntities and x in name2codepoint: + return unichr(name2codepoint[x]) + elif x in self.XML_ENTITIES_TO_SPECIAL_CHARS: + if self.convertXMLEntities: + return self.XML_ENTITIES_TO_SPECIAL_CHARS[x] + else: + return u'&%s;' % x + elif len(x) > 0 and x[0] == '#': + # Handle numeric entities + if len(x) > 1 and x[1] == 'x': + return unichr(int(x[2:], 16)) + else: + return unichr(int(x[1:])) + + elif self.escapeUnrecognizedEntities: + return u'&%s;' % x + else: + return u'&%s;' % x + + def __init__(self, parser, name, attrs=None, parent=None, + previous=None): + "Basic constructor." + + # We don't actually store the parser object: that lets extracted + # chunks be garbage-collected + self.parserClass = parser.__class__ + self.isSelfClosing = parser.isSelfClosingTag(name) + self.name = name + if attrs is None: + attrs = [] + elif isinstance(attrs, dict): + attrs = attrs.items() + self.attrs = attrs + self.contents = [] + self.setup(parent, previous) + self.hidden = False + self.containsSubstitutions = False + self.convertHTMLEntities = parser.convertHTMLEntities + self.convertXMLEntities = parser.convertXMLEntities + self.escapeUnrecognizedEntities = parser.escapeUnrecognizedEntities + + # Convert any HTML, XML, or numeric entities in the attribute values. + convert = lambda(k, val): (k, + re.sub("&(#\d+|#x[0-9a-fA-F]+|\w+);", + self._convertEntities, + val)) + self.attrs = map(convert, self.attrs) + + def getString(self): + if (len(self.contents) == 1 + and isinstance(self.contents[0], NavigableString)): + return self.contents[0] + + def setString(self, string): + """Replace the contents of the tag with a string""" + self.clear() + self.append(string) + + string = property(getString, setString) + + def getText(self, separator=u""): + if not len(self.contents): + return u"" + stopNode = self._lastRecursiveChild().next + strings = [] + current = self.contents[0] + while current is not stopNode: + if isinstance(current, NavigableString): + strings.append(current.strip()) + current = current.next + return separator.join(strings) + + text = property(getText) + + def get(self, key, default=None): + """Returns the value of the 'key' attribute for the tag, or + the value given for 'default' if it doesn't have that + attribute.""" + return self._getAttrMap().get(key, default) + + def clear(self): + """Extract all children.""" + for child in self.contents[:]: + child.extract() + + def index(self, element): + for i, child in enumerate(self.contents): + if child is element: + return i + raise ValueError("Tag.index: element not in tag") + + def has_key(self, key): + return self._getAttrMap().has_key(key) + + def __getitem__(self, key): + """tag[key] returns the value of the 'key' attribute for the tag, + and throws an exception if it's not there.""" + return self._getAttrMap()[key] + + def __iter__(self): + "Iterating over a tag iterates over its contents." + return iter(self.contents) + + def __len__(self): + "The length of a tag is the length of its list of contents." + return len(self.contents) + + def __contains__(self, x): + return x in self.contents + + def __nonzero__(self): + "A tag is non-None even if it has no contents." + return True + + def __setitem__(self, key, value): + """Setting tag[key] sets the value of the 'key' attribute for the + tag.""" + self._getAttrMap() + self.attrMap[key] = value + found = False + for i in range(0, len(self.attrs)): + if self.attrs[i][0] == key: + self.attrs[i] = (key, value) + found = True + if not found: + self.attrs.append((key, value)) + self._getAttrMap()[key] = value + + def __delitem__(self, key): + "Deleting tag[key] deletes all 'key' attributes for the tag." + for item in self.attrs: + if item[0] == key: + self.attrs.remove(item) + #We don't break because bad HTML can define the same + #attribute multiple times. + self._getAttrMap() + if self.attrMap.has_key(key): + del self.attrMap[key] + + def __call__(self, *args, **kwargs): + """Calling a tag like a function is the same as calling its + findAll() method. Eg. tag('a') returns a list of all the A tags + found within this tag.""" + return apply(self.findAll, args, kwargs) + + def __getattr__(self, tag): + #print "Getattr %s.%s" % (self.__class__, tag) + if len(tag) > 3 and tag.rfind('Tag') == len(tag)-3: + return self.find(tag[:-3]) + elif tag.find('__') != 0: + return self.find(tag) + raise AttributeError, "'%s' object has no attribute '%s'" % (self.__class__, tag) + + def __eq__(self, other): + """Returns true iff this tag has the same name, the same attributes, + and the same contents (recursively) as the given tag. + + NOTE: right now this will return false if two tags have the + same attributes in a different order. Should this be fixed?""" + if other is self: + return True + if not hasattr(other, 'name') or not hasattr(other, 'attrs') or not hasattr(other, 'contents') or self.name != other.name or self.attrs != other.attrs or len(self) != len(other): + return False + for i in range(0, len(self.contents)): + if self.contents[i] != other.contents[i]: + return False + return True + + def __ne__(self, other): + """Returns true iff this tag is not identical to the other tag, + as defined in __eq__.""" + return not self == other + + def __repr__(self, encoding=DEFAULT_OUTPUT_ENCODING): + """Renders this tag as a string.""" + return self.__str__(encoding) + + def __unicode__(self): + return self.__str__(None) + + BARE_AMPERSAND_OR_BRACKET = re.compile("([<>]|" + + "&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)" + + ")") + + def _sub_entity(self, x): + """Used with a regular expression to substitute the + appropriate XML entity for an XML special character.""" + return "&" + self.XML_SPECIAL_CHARS_TO_ENTITIES[x.group(0)[0]] + ";" + + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING, + prettyPrint=False, indentLevel=0): + """Returns a string or Unicode representation of this tag and + its contents. To get Unicode, pass None for encoding. + + NOTE: since Python's HTML parser consumes whitespace, this + method is not certain to reproduce the whitespace present in + the original string.""" + + encodedName = self.toEncoding(self.name, encoding) + + attrs = [] + if self.attrs: + for key, val in self.attrs: + fmt = '%s="%s"' + if isinstance(val, basestring): + if self.containsSubstitutions and '%SOUP-ENCODING%' in val: + val = self.substituteEncoding(val, encoding) + + # The attribute value either: + # + # * Contains no embedded double quotes or single quotes. + # No problem: we enclose it in double quotes. + # * Contains embedded single quotes. No problem: + # double quotes work here too. + # * Contains embedded double quotes. No problem: + # we enclose it in single quotes. + # * Embeds both single _and_ double quotes. This + # can't happen naturally, but it can happen if + # you modify an attribute value after parsing + # the document. Now we have a bit of a + # problem. We solve it by enclosing the + # attribute in single quotes, and escaping any + # embedded single quotes to XML entities. + if '"' in val: + fmt = "%s='%s'" + if "'" in val: + # TODO: replace with apos when + # appropriate. + val = val.replace("'", "&squot;") + + # Now we're okay w/r/t quotes. But the attribute + # value might also contain angle brackets, or + # ampersands that aren't part of entities. We need + # to escape those to XML entities too. + val = self.BARE_AMPERSAND_OR_BRACKET.sub(self._sub_entity, val) + + attrs.append(fmt % (self.toEncoding(key, encoding), + self.toEncoding(val, encoding))) + close = '' + closeTag = '' + if self.isSelfClosing: + close = ' /' + else: + closeTag = '' % encodedName + + indentTag, indentContents = 0, 0 + if prettyPrint: + indentTag = indentLevel + space = (' ' * (indentTag-1)) + indentContents = indentTag + 1 + contents = self.renderContents(encoding, prettyPrint, indentContents) + if self.hidden: + s = contents + else: + s = [] + attributeString = '' + if attrs: + attributeString = ' ' + ' '.join(attrs) + if prettyPrint: + s.append(space) + s.append('<%s%s%s>' % (encodedName, attributeString, close)) + if prettyPrint: + s.append("\n") + s.append(contents) + if prettyPrint and contents and contents[-1] != "\n": + s.append("\n") + if prettyPrint and closeTag: + s.append(space) + s.append(closeTag) + if prettyPrint and closeTag and self.nextSibling: + s.append("\n") + s = ''.join(s) + return s + + def decompose(self): + """Recursively destroys the contents of this tree.""" + self.extract() + if len(self.contents) == 0: + return + current = self.contents[0] + while current is not None: + next = current.next + if isinstance(current, Tag): + del current.contents[:] + current.parent = None + current.previous = None + current.previousSibling = None + current.next = None + current.nextSibling = None + current = next + + def prettify(self, encoding=DEFAULT_OUTPUT_ENCODING): + return self.__str__(encoding, True) + + def renderContents(self, encoding=DEFAULT_OUTPUT_ENCODING, + prettyPrint=False, indentLevel=0): + """Renders the contents of this tag as a string in the given + encoding. If encoding is None, returns a Unicode string..""" + s=[] + for c in self: + text = None + if isinstance(c, NavigableString): + text = c.__str__(encoding) + elif isinstance(c, Tag): + s.append(c.__str__(encoding, prettyPrint, indentLevel)) + if text and prettyPrint: + text = text.strip() + if text: + if prettyPrint: + s.append(" " * (indentLevel-1)) + s.append(text) + if prettyPrint: + s.append("\n") + return ''.join(s) + + #Soup methods + + def find(self, name=None, attrs={}, recursive=True, text=None, + **kwargs): + """Return only the first child of this Tag matching the given + criteria.""" + r = None + l = self.findAll(name, attrs, recursive, text, 1, **kwargs) + if l: + r = l[0] + return r + findChild = find + + def findAll(self, name=None, attrs={}, recursive=True, text=None, + limit=None, **kwargs): + """Extracts a list of Tag objects that match the given + criteria. You can specify the name of the Tag and any + attributes you want the Tag to have. + + The value of a key-value pair in the 'attrs' map can be a + string, a list of strings, a regular expression object, or a + callable that takes a string and returns whether or not the + string matches for some custom definition of 'matches'. The + same is true of the tag name.""" + generator = self.recursiveChildGenerator + if not recursive: + generator = self.childGenerator + return self._findAll(name, attrs, text, limit, generator, **kwargs) + findChildren = findAll + + # Pre-3.x compatibility methods + first = find + fetch = findAll + + def fetchText(self, text=None, recursive=True, limit=None): + return self.findAll(text=text, recursive=recursive, limit=limit) + + def firstText(self, text=None, recursive=True): + return self.find(text=text, recursive=recursive) + + #Private methods + + def _getAttrMap(self): + """Initializes a map representation of this tag's attributes, + if not already initialized.""" + if not getattr(self, 'attrMap'): + self.attrMap = {} + for (key, value) in self.attrs: + self.attrMap[key] = value + return self.attrMap + + #Generator methods + def childGenerator(self): + # Just use the iterator from the contents + return iter(self.contents) + + def recursiveChildGenerator(self): + if not len(self.contents): + raise StopIteration + stopNode = self._lastRecursiveChild().next + current = self.contents[0] + while current is not stopNode: + yield current + current = current.next + + +# Next, a couple classes to represent queries and their results. +class SoupStrainer: + """Encapsulates a number of ways of matching a markup element (tag or + text).""" + + def __init__(self, name=None, attrs={}, text=None, **kwargs): + self.name = name + if isinstance(attrs, basestring): + kwargs['class'] = _match_css_class(attrs) + attrs = None + if kwargs: + if attrs: + attrs = attrs.copy() + attrs.update(kwargs) + else: + attrs = kwargs + self.attrs = attrs + self.text = text + + def __str__(self): + if self.text: + return self.text + else: + return "%s|%s" % (self.name, self.attrs) + + def searchTag(self, markupName=None, markupAttrs={}): + found = None + markup = None + if isinstance(markupName, Tag): + markup = markupName + markupAttrs = markup + callFunctionWithTagData = callable(self.name) \ + and not isinstance(markupName, Tag) + + if (not self.name) \ + or callFunctionWithTagData \ + or (markup and self._matches(markup, self.name)) \ + or (not markup and self._matches(markupName, self.name)): + if callFunctionWithTagData: + match = self.name(markupName, markupAttrs) + else: + match = True + markupAttrMap = None + for attr, matchAgainst in self.attrs.items(): + if not markupAttrMap: + if hasattr(markupAttrs, 'get'): + markupAttrMap = markupAttrs + else: + markupAttrMap = {} + for k,v in markupAttrs: + markupAttrMap[k] = v + attrValue = markupAttrMap.get(attr) + if not self._matches(attrValue, matchAgainst): + match = False + break + if match: + if markup: + found = markup + else: + found = markupName + return found + + def search(self, markup): + #print 'looking for %s in %s' % (self, markup) + found = None + # If given a list of items, scan it for a text element that + # matches. + if hasattr(markup, "__iter__") \ + and not isinstance(markup, Tag): + for element in markup: + if isinstance(element, NavigableString) \ + and self.search(element): + found = element + break + # If it's a Tag, make sure its name or attributes match. + # Don't bother with Tags if we're searching for text. + elif isinstance(markup, Tag): + if not self.text: + found = self.searchTag(markup) + # If it's text, make sure the text matches. + elif isinstance(markup, NavigableString) or \ + isinstance(markup, basestring): + if self._matches(markup, self.text): + found = markup + else: + raise Exception, "I don't know how to match against a %s" \ + % markup.__class__ + return found + + def _matches(self, markup, matchAgainst): + #print "Matching %s against %s" % (markup, matchAgainst) + result = False + if matchAgainst is True: + result = markup is not None + elif callable(matchAgainst): + result = matchAgainst(markup) + else: + #Custom match methods take the tag as an argument, but all + #other ways of matching match the tag name as a string. + if isinstance(markup, Tag): + markup = markup.name + if markup and not isinstance(markup, basestring): + markup = unicode(markup) + #Now we know that chunk is either a string, or None. + if hasattr(matchAgainst, 'match'): + # It's a regexp object. + result = markup and matchAgainst.search(markup) + elif hasattr(matchAgainst, '__iter__'): # list-like + result = markup in matchAgainst + elif hasattr(matchAgainst, 'items'): + result = markup.has_key(matchAgainst) + elif matchAgainst and isinstance(markup, basestring): + if isinstance(markup, unicode): + matchAgainst = unicode(matchAgainst) + else: + matchAgainst = str(matchAgainst) + + if not result: + result = matchAgainst == markup + return result + +class ResultSet(list): + """A ResultSet is just a list that keeps track of the SoupStrainer + that created it.""" + def __init__(self, source): + list.__init__([]) + self.source = source + +# Now, some helper functions. + +def buildTagMap(default, *args): + """Turns a list of maps, lists, or scalars into a single map. + Used to build the SELF_CLOSING_TAGS, NESTABLE_TAGS, and + NESTING_RESET_TAGS maps out of lists and partial maps.""" + built = {} + for portion in args: + if hasattr(portion, 'items'): + #It's a map. Merge it. + for k,v in portion.items(): + built[k] = v + elif hasattr(portion, '__iter__'): # is a list + #It's a list. Map each item to the default. + for k in portion: + built[k] = default + else: + #It's a scalar. Map it to the default. + built[portion] = default + return built + +# Now, the parser classes. + +class BeautifulStoneSoup(Tag, SGMLParser): + + """This class contains the basic parser and search code. It defines + a parser that knows nothing about tag behavior except for the + following: + + You can't close a tag without closing all the tags it encloses. + That is, "" actually means + "". + + [Another possible explanation is "", but since + this class defines no SELF_CLOSING_TAGS, it will never use that + explanation.] + + This class is useful for parsing XML or made-up markup languages, + or when BeautifulSoup makes an assumption counter to what you were + expecting.""" + + SELF_CLOSING_TAGS = {} + NESTABLE_TAGS = {} + RESET_NESTING_TAGS = {} + QUOTE_TAGS = {} + PRESERVE_WHITESPACE_TAGS = [] + + MARKUP_MASSAGE = [(re.compile('(<[^<>]*)/>'), + lambda x: x.group(1) + ' />'), + (re.compile(']*)>'), + lambda x: '') + ] + + ROOT_TAG_NAME = u'[document]' + + HTML_ENTITIES = "html" + XML_ENTITIES = "xml" + XHTML_ENTITIES = "xhtml" + # TODO: This only exists for backwards-compatibility + ALL_ENTITIES = XHTML_ENTITIES + + # Used when determining whether a text node is all whitespace and + # can be replaced with a single space. A text node that contains + # fancy Unicode spaces (usually non-breaking) should be left + # alone. + STRIP_ASCII_SPACES = { 9: None, 10: None, 12: None, 13: None, 32: None, } + + def __init__(self, markup="", parseOnlyThese=None, fromEncoding=None, + markupMassage=True, smartQuotesTo=XML_ENTITIES, + convertEntities=None, selfClosingTags=None, isHTML=False): + """The Soup object is initialized as the 'root tag', and the + provided markup (which can be a string or a file-like object) + is fed into the underlying parser. + + sgmllib will process most bad HTML, and the BeautifulSoup + class has some tricks for dealing with some HTML that kills + sgmllib, but Beautiful Soup can nonetheless choke or lose data + if your data uses self-closing tags or declarations + incorrectly. + + By default, Beautiful Soup uses regexes to sanitize input, + avoiding the vast majority of these problems. If the problems + don't apply to you, pass in False for markupMassage, and + you'll get better performance. + + The default parser massage techniques fix the two most common + instances of invalid HTML that choke sgmllib: + +
(No space between name of closing tag and tag close) + (Extraneous whitespace in declaration) + + You can pass in a custom list of (RE object, replace method) + tuples to get Beautiful Soup to scrub your input the way you + want.""" + + self.parseOnlyThese = parseOnlyThese + self.fromEncoding = fromEncoding + self.smartQuotesTo = smartQuotesTo + self.convertEntities = convertEntities + # Set the rules for how we'll deal with the entities we + # encounter + if self.convertEntities: + # It doesn't make sense to convert encoded characters to + # entities even while you're converting entities to Unicode. + # Just convert it all to Unicode. + self.smartQuotesTo = None + if convertEntities == self.HTML_ENTITIES: + self.convertXMLEntities = False + self.convertHTMLEntities = True + self.escapeUnrecognizedEntities = True + elif convertEntities == self.XHTML_ENTITIES: + self.convertXMLEntities = True + self.convertHTMLEntities = True + self.escapeUnrecognizedEntities = False + elif convertEntities == self.XML_ENTITIES: + self.convertXMLEntities = True + self.convertHTMLEntities = False + self.escapeUnrecognizedEntities = False + else: + self.convertXMLEntities = False + self.convertHTMLEntities = False + self.escapeUnrecognizedEntities = False + + self.instanceSelfClosingTags = buildTagMap(None, selfClosingTags) + SGMLParser.__init__(self) + + if hasattr(markup, 'read'): # It's a file-type object. + markup = markup.read() + self.markup = markup + self.markupMassage = markupMassage + try: + self._feed(isHTML=isHTML) + except StopParsing: + pass + self.markup = None # The markup can now be GCed + + def convert_charref(self, name): + """This method fixes a bug in Python's SGMLParser.""" + try: + n = int(name) + except ValueError: + return + if not 0 <= n <= 127 : # ASCII ends at 127, not 255 + return + return self.convert_codepoint(n) + + def _feed(self, inDocumentEncoding=None, isHTML=False): + # Convert the document to Unicode. + markup = self.markup + if isinstance(markup, unicode): + if not hasattr(self, 'originalEncoding'): + self.originalEncoding = None + else: + dammit = UnicodeDammit\ + (markup, [self.fromEncoding, inDocumentEncoding], + smartQuotesTo=self.smartQuotesTo, isHTML=isHTML) + markup = dammit.unicode + self.originalEncoding = dammit.originalEncoding + self.declaredHTMLEncoding = dammit.declaredHTMLEncoding + if markup: + if self.markupMassage: + if not hasattr(self.markupMassage, "__iter__"): + self.markupMassage = self.MARKUP_MASSAGE + for fix, m in self.markupMassage: + markup = fix.sub(m, markup) + # TODO: We get rid of markupMassage so that the + # soup object can be deepcopied later on. Some + # Python installations can't copy regexes. If anyone + # was relying on the existence of markupMassage, this + # might cause problems. + del(self.markupMassage) + self.reset() + + SGMLParser.feed(self, markup) + # Close out any unfinished strings and close all the open tags. + self.endData() + while self.currentTag.name != self.ROOT_TAG_NAME: + self.popTag() + + def __getattr__(self, methodName): + """This method routes method call requests to either the SGMLParser + superclass or the Tag superclass, depending on the method name.""" + #print "__getattr__ called on %s.%s" % (self.__class__, methodName) + + if methodName.startswith('start_') or methodName.startswith('end_') \ + or methodName.startswith('do_'): + return SGMLParser.__getattr__(self, methodName) + elif not methodName.startswith('__'): + return Tag.__getattr__(self, methodName) + else: + raise AttributeError + + def isSelfClosingTag(self, name): + """Returns true iff the given string is the name of a + self-closing tag according to this parser.""" + return self.SELF_CLOSING_TAGS.has_key(name) \ + or self.instanceSelfClosingTags.has_key(name) + + def reset(self): + Tag.__init__(self, self, self.ROOT_TAG_NAME) + self.hidden = 1 + SGMLParser.reset(self) + self.currentData = [] + self.currentTag = None + self.tagStack = [] + self.quoteStack = [] + self.pushTag(self) + + def popTag(self): + tag = self.tagStack.pop() + + #print "Pop", tag.name + if self.tagStack: + self.currentTag = self.tagStack[-1] + return self.currentTag + + def pushTag(self, tag): + #print "Push", tag.name + if self.currentTag: + self.currentTag.contents.append(tag) + self.tagStack.append(tag) + self.currentTag = self.tagStack[-1] + + def endData(self, containerClass=NavigableString): + if self.currentData: + currentData = u''.join(self.currentData) + if (currentData.translate(self.STRIP_ASCII_SPACES) == '' and + not set([tag.name for tag in self.tagStack]).intersection( + self.PRESERVE_WHITESPACE_TAGS)): + if '\n' in currentData: + currentData = '\n' + else: + currentData = ' ' + self.currentData = [] + if self.parseOnlyThese and len(self.tagStack) <= 1 and \ + (not self.parseOnlyThese.text or \ + not self.parseOnlyThese.search(currentData)): + return + o = containerClass(currentData) + o.setup(self.currentTag, self.previous) + if self.previous: + self.previous.next = o + self.previous = o + self.currentTag.contents.append(o) + + + def _popToTag(self, name, inclusivePop=True): + """Pops the tag stack up to and including the most recent + instance of the given tag. If inclusivePop is false, pops the tag + stack up to but *not* including the most recent instqance of + the given tag.""" + #print "Popping to %s" % name + if name == self.ROOT_TAG_NAME: + return + + numPops = 0 + mostRecentTag = None + for i in range(len(self.tagStack)-1, 0, -1): + if name == self.tagStack[i].name: + numPops = len(self.tagStack)-i + break + if not inclusivePop: + numPops = numPops - 1 + + for i in range(0, numPops): + mostRecentTag = self.popTag() + return mostRecentTag + + def _smartPop(self, name): + + """We need to pop up to the previous tag of this type, unless + one of this tag's nesting reset triggers comes between this + tag and the previous tag of this type, OR unless this tag is a + generic nesting trigger and another generic nesting trigger + comes between this tag and the previous tag of this type. + + Examples: +

FooBar *

* should pop to 'p', not 'b'. +

FooBar *

* should pop to 'table', not 'p'. +

Foo

Bar *

* should pop to 'tr', not 'p'. + +

    • *
    • * should pop to 'ul', not the first 'li'. +
  • ** should pop to 'table', not the first 'tr' + tag should + implicitly close the previous tag within the same
    ** should pop to 'tr', not the first 'td' + """ + + nestingResetTriggers = self.NESTABLE_TAGS.get(name) + isNestable = nestingResetTriggers != None + isResetNesting = self.RESET_NESTING_TAGS.has_key(name) + popTo = None + inclusive = True + for i in range(len(self.tagStack)-1, 0, -1): + p = self.tagStack[i] + if (not p or p.name == name) and not isNestable: + #Non-nestable tags get popped to the top or to their + #last occurance. + popTo = name + break + if (nestingResetTriggers is not None + and p.name in nestingResetTriggers) \ + or (nestingResetTriggers is None and isResetNesting + and self.RESET_NESTING_TAGS.has_key(p.name)): + + #If we encounter one of the nesting reset triggers + #peculiar to this tag, or we encounter another tag + #that causes nesting to reset, pop up to but not + #including that tag. + popTo = p.name + inclusive = False + break + p = p.parent + if popTo: + self._popToTag(popTo, inclusive) + + def unknown_starttag(self, name, attrs, selfClosing=0): + #print "Start tag %s: %s" % (name, attrs) + if self.quoteStack: + #This is not a real tag. + #print "<%s> is not real!" % name + attrs = ''.join([' %s="%s"' % (x, y) for x, y in attrs]) + self.handle_data('<%s%s>' % (name, attrs)) + return + self.endData() + + if not self.isSelfClosingTag(name) and not selfClosing: + self._smartPop(name) + + if self.parseOnlyThese and len(self.tagStack) <= 1 \ + and (self.parseOnlyThese.text or not self.parseOnlyThese.searchTag(name, attrs)): + return + + tag = Tag(self, name, attrs, self.currentTag, self.previous) + if self.previous: + self.previous.next = tag + self.previous = tag + self.pushTag(tag) + if selfClosing or self.isSelfClosingTag(name): + self.popTag() + if name in self.QUOTE_TAGS: + #print "Beginning quote (%s)" % name + self.quoteStack.append(name) + self.literal = 1 + return tag + + def unknown_endtag(self, name): + #print "End tag %s" % name + if self.quoteStack and self.quoteStack[-1] != name: + #This is not a real end tag. + #print " is not real!" % name + self.handle_data('' % name) + return + self.endData() + self._popToTag(name) + if self.quoteStack and self.quoteStack[-1] == name: + self.quoteStack.pop() + self.literal = (len(self.quoteStack) > 0) + + def handle_data(self, data): + self.currentData.append(data) + + def _toStringSubclass(self, text, subclass): + """Adds a certain piece of text to the tree as a NavigableString + subclass.""" + self.endData() + self.handle_data(text) + self.endData(subclass) + + def handle_pi(self, text): + """Handle a processing instruction as a ProcessingInstruction + object, possibly one with a %SOUP-ENCODING% slot into which an + encoding will be plugged later.""" + if text[:3] == "xml": + text = u"xml version='1.0' encoding='%SOUP-ENCODING%'" + self._toStringSubclass(text, ProcessingInstruction) + + def handle_comment(self, text): + "Handle comments as Comment objects." + self._toStringSubclass(text, Comment) + + def handle_charref(self, ref): + "Handle character references as data." + if self.convertEntities: + data = unichr(int(ref)) + else: + data = '&#%s;' % ref + self.handle_data(data) + + def handle_entityref(self, ref): + """Handle entity references as data, possibly converting known + HTML and/or XML entity references to the corresponding Unicode + characters.""" + data = None + if self.convertHTMLEntities: + try: + data = unichr(name2codepoint[ref]) + except KeyError: + pass + + if not data and self.convertXMLEntities: + data = self.XML_ENTITIES_TO_SPECIAL_CHARS.get(ref) + + if not data and self.convertHTMLEntities and \ + not self.XML_ENTITIES_TO_SPECIAL_CHARS.get(ref): + # TODO: We've got a problem here. We're told this is + # an entity reference, but it's not an XML entity + # reference or an HTML entity reference. Nonetheless, + # the logical thing to do is to pass it through as an + # unrecognized entity reference. + # + # Except: when the input is "&carol;" this function + # will be called with input "carol". When the input is + # "AT&T", this function will be called with input + # "T". We have no way of knowing whether a semicolon + # was present originally, so we don't know whether + # this is an unknown entity or just a misplaced + # ampersand. + # + # The more common case is a misplaced ampersand, so I + # escape the ampersand and omit the trailing semicolon. + data = "&%s" % ref + if not data: + # This case is different from the one above, because we + # haven't already gone through a supposedly comprehensive + # mapping of entities to Unicode characters. We might not + # have gone through any mapping at all. So the chances are + # very high that this is a real entity, and not a + # misplaced ampersand. + data = "&%s;" % ref + self.handle_data(data) + + def handle_decl(self, data): + "Handle DOCTYPEs and the like as Declaration objects." + self._toStringSubclass(data, Declaration) + + def parse_declaration(self, i): + """Treat a bogus SGML declaration as raw data. Treat a CDATA + declaration as a CData object.""" + j = None + if self.rawdata[i:i+9] == '', i) + if k == -1: + k = len(self.rawdata) + data = self.rawdata[i+9:k] + j = k+3 + self._toStringSubclass(data, CData) + else: + try: + j = SGMLParser.parse_declaration(self, i) + except SGMLParseError: + toHandle = self.rawdata[i:] + self.handle_data(toHandle) + j = i + len(toHandle) + return j + +class BeautifulSoup(BeautifulStoneSoup): + + """This parser knows the following facts about HTML: + + * Some tags have no closing tag and should be interpreted as being + closed as soon as they are encountered. + + * The text inside some tags (ie. 'script') may contain tags which + are not really part of the document and which should be parsed + as text, not tags. If you want to parse the text as tags, you can + always fetch it and parse it explicitly. + + * Tag nesting rules: + + Most tags can't be nested at all. For instance, the occurance of + a

    tag should implicitly close the previous

    tag. + +

    Para1

    Para2 + should be transformed into: +

    Para1

    Para2 + + Some tags can be nested arbitrarily. For instance, the occurance + of a

    tag should _not_ implicitly close the previous +
    tag. + + Alice said:
    Bob said:
    Blah + should NOT be transformed into: + Alice said:
    Bob said:
    Blah + + Some tags can be nested, but the nesting is reset by the + interposition of other tags. For instance, a
    , + but not close a tag in another table. + +
    BlahBlah + should be transformed into: +
    BlahBlah + but, + Blah
    Blah + should NOT be transformed into + Blah
    Blah + + Differing assumptions about tag nesting rules are a major source + of problems with the BeautifulSoup class. If BeautifulSoup is not + treating as nestable a tag your page author treats as nestable, + try ICantBelieveItsBeautifulSoup, MinimalSoup, or + BeautifulStoneSoup before writing your own subclass.""" + + def __init__(self, *args, **kwargs): + if not kwargs.has_key('smartQuotesTo'): + kwargs['smartQuotesTo'] = self.HTML_ENTITIES + kwargs['isHTML'] = True + BeautifulStoneSoup.__init__(self, *args, **kwargs) + + SELF_CLOSING_TAGS = buildTagMap(None, + ('br' , 'hr', 'input', 'img', 'meta', + 'spacer', 'link', 'frame', 'base', 'col')) + + PRESERVE_WHITESPACE_TAGS = set(['pre', 'textarea']) + + QUOTE_TAGS = {'script' : None, 'textarea' : None} + + #According to the HTML standard, each of these inline tags can + #contain another tag of the same type. Furthermore, it's common + #to actually use these tags this way. + NESTABLE_INLINE_TAGS = ('span', 'font', 'q', 'object', 'bdo', 'sub', 'sup', + 'center') + + #According to the HTML standard, these block tags can contain + #another tag of the same type. Furthermore, it's common + #to actually use these tags this way. + NESTABLE_BLOCK_TAGS = ('blockquote', 'div', 'fieldset', 'ins', 'del') + + #Lists can contain other lists, but there are restrictions. + NESTABLE_LIST_TAGS = { 'ol' : [], + 'ul' : [], + 'li' : ['ul', 'ol'], + 'dl' : [], + 'dd' : ['dl'], + 'dt' : ['dl'] } + + #Tables can contain other tables, but there are restrictions. + NESTABLE_TABLE_TAGS = {'table' : [], + 'tr' : ['table', 'tbody', 'tfoot', 'thead'], + 'td' : ['tr'], + 'th' : ['tr'], + 'thead' : ['table'], + 'tbody' : ['table'], + 'tfoot' : ['table'], + } + + NON_NESTABLE_BLOCK_TAGS = ('address', 'form', 'p', 'pre') + + #If one of these tags is encountered, all tags up to the next tag of + #this type are popped. + RESET_NESTING_TAGS = buildTagMap(None, NESTABLE_BLOCK_TAGS, 'noscript', + NON_NESTABLE_BLOCK_TAGS, + NESTABLE_LIST_TAGS, + NESTABLE_TABLE_TAGS) + + NESTABLE_TAGS = buildTagMap([], NESTABLE_INLINE_TAGS, NESTABLE_BLOCK_TAGS, + NESTABLE_LIST_TAGS, NESTABLE_TABLE_TAGS) + + # Used to detect the charset in a META tag; see start_meta + CHARSET_RE = re.compile("((^|;)\s*charset=)([^;]*)", re.M) + + def start_meta(self, attrs): + """Beautiful Soup can detect a charset included in a META tag, + try to convert the document to that charset, and re-parse the + document from the beginning.""" + httpEquiv = None + contentType = None + contentTypeIndex = None + tagNeedsEncodingSubstitution = False + + for i in range(0, len(attrs)): + key, value = attrs[i] + key = key.lower() + if key == 'http-equiv': + httpEquiv = value + elif key == 'content': + contentType = value + contentTypeIndex = i + + if httpEquiv and contentType: # It's an interesting meta tag. + match = self.CHARSET_RE.search(contentType) + if match: + if (self.declaredHTMLEncoding is not None or + self.originalEncoding == self.fromEncoding): + # An HTML encoding was sniffed while converting + # the document to Unicode, or an HTML encoding was + # sniffed during a previous pass through the + # document, or an encoding was specified + # explicitly and it worked. Rewrite the meta tag. + def rewrite(match): + return match.group(1) + "%SOUP-ENCODING%" + newAttr = self.CHARSET_RE.sub(rewrite, contentType) + attrs[contentTypeIndex] = (attrs[contentTypeIndex][0], + newAttr) + tagNeedsEncodingSubstitution = True + else: + # This is our first pass through the document. + # Go through it again with the encoding information. + newCharset = match.group(3) + if newCharset and newCharset != self.originalEncoding: + self.declaredHTMLEncoding = newCharset + self._feed(self.declaredHTMLEncoding) + raise StopParsing + pass + tag = self.unknown_starttag("meta", attrs) + if tag and tagNeedsEncodingSubstitution: + tag.containsSubstitutions = True + +class StopParsing(Exception): + pass + +class ICantBelieveItsBeautifulSoup(BeautifulSoup): + + """The BeautifulSoup class is oriented towards skipping over + common HTML errors like unclosed tags. However, sometimes it makes + errors of its own. For instance, consider this fragment: + + FooBar + + This is perfectly valid (if bizarre) HTML. However, the + BeautifulSoup class will implicitly close the first b tag when it + encounters the second 'b'. It will think the author wrote + "FooBar", and didn't close the first 'b' tag, because + there's no real-world reason to bold something that's already + bold. When it encounters '' it will close two more 'b' + tags, for a grand total of three tags closed instead of two. This + can throw off the rest of your document structure. The same is + true of a number of other tags, listed below. + + It's much more common for someone to forget to close a 'b' tag + than to actually use nested 'b' tags, and the BeautifulSoup class + handles the common case. This class handles the not-co-common + case: where you can't believe someone wrote what they did, but + it's valid HTML and BeautifulSoup screwed up by assuming it + wouldn't be.""" + + I_CANT_BELIEVE_THEYRE_NESTABLE_INLINE_TAGS = \ + ('em', 'big', 'i', 'small', 'tt', 'abbr', 'acronym', 'strong', + 'cite', 'code', 'dfn', 'kbd', 'samp', 'strong', 'var', 'b', + 'big') + + I_CANT_BELIEVE_THEYRE_NESTABLE_BLOCK_TAGS = ('noscript',) + + NESTABLE_TAGS = buildTagMap([], BeautifulSoup.NESTABLE_TAGS, + I_CANT_BELIEVE_THEYRE_NESTABLE_BLOCK_TAGS, + I_CANT_BELIEVE_THEYRE_NESTABLE_INLINE_TAGS) + +class MinimalSoup(BeautifulSoup): + """The MinimalSoup class is for parsing HTML that contains + pathologically bad markup. It makes no assumptions about tag + nesting, but it does know which tags are self-closing, that + ")] + data = data[:data.rindex(";")] + data = data.replace('tables:','"tables":') + tables = json.loads(data)['tables'] + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(url) + else: + raise e + + # looks like only one author per story allowed. + author = tables['users'][0] + story = tables['stories'][0] + story_ver = tables['story_versions'][0] + print("story:%s"%story) + + self.story.setMetadata('authorId',author['id']) + self.story.setMetadata('author',author['display_name']) + self.story.setMetadata('authorUrl','https://'+self.host+'/author/'+author['display_name']+'/stories') + + self.story.setMetadata('title',story_ver['title']) + self.setDescription(url,story_ver['description']) + + if not ('assets/story_versions/covers' in story_ver['profile_image_url@2x']): + self.setCoverImage(url,story_ver['profile_image_url@2x']) + + self.story.setMetadata('datePublished',makeDate(story['published_at'], self.dateformat)) + self.story.setMetadata('dateUpdated',makeDate(story['published_at'], self.dateformat)) + + self.story.setMetadata('followers',story['followers_count']) + self.story.setMetadata('comments',story['comments_count']) + self.story.setMetadata('views',story['views_count']) + self.story.setMetadata('likes',int(story['likes'])) # no idea why they floated these. + if 'dislikes' in story: + self.story.setMetadata('dislikes',int(story['dislikes'])) + + if story_ver['is_complete']: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + self.story.setMetadata('rating', story_ver['maturity_level']) + self.story.setMetadata('numWords', unicode(story_ver['word_count'])) + + for i in tables['fandoms']: + self.story.addToList('category',i['name']) + + for i in tables['genres']: + self.story.addToList('genre',i['name']) + + for i in tables['characters']: + self.story.addToList('characters',i['name']) + + for c in tables['chapters']: + chtitle = "Chapter %d"%c['number'] + if c['title']: + chtitle += " - %s"%c['title'] + self.chapterUrls.append((chtitle,c['body_url'])) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + def getChapterText(self, url): + logger.debug('Getting chapter text from: %s' % url) + if not url: + data = u"This chapter has no text." + else: + data = self._fetchUrl(url) + soup = bs.BeautifulSoup(u"
    "+data+u"
    ") + return self.utf8FromSoup(url,soup) + +def getClass(): + return FictionPadSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_fictionpresscom.py b/fanficdownloader/adapters/adapter_fictionpresscom.py new file mode 100644 index 00000000..31bac840 --- /dev/null +++ b/fanficdownloader/adapters/adapter_fictionpresscom.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib2 +import time + +## They're from the same people and pretty much identical. +from adapter_fanfictionnet import FanFictionNetSiteAdapter + +class FictionPressComSiteAdapter(FanFictionNetSiteAdapter): + + def __init__(self, config, url): + FanFictionNetSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','fpcom') + + @staticmethod + def getSiteDomain(): + return 'www.fictionpress.com' + + @classmethod + def getAcceptDomains(cls): + return ['www.fictionpress.com','m.fictionpress.com'] + + @classmethod + def getSiteExampleURLs(self): + return "https://www.fictionpress.com/s/1234/1/ https://www.fictionpress.com/s/1234/12/ http://www.fictionpress.com/s/1234/1/Story_Title http://m.fictionpress.com/s/1234/1/" + + def getSiteURLPattern(self): + return r"https?://(www|m)?\.fictionpress\.com/s/\d+(/\d+)?(/|/[a-zA-Z0-9_-]+)?/?$" + +def getClass(): + return FictionPressComSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_ficwadcom.py b/fanficdownloader/adapters/adapter_ficwadcom.py new file mode 100644 index 00000000..e57a2db8 --- /dev/null +++ b/fanficdownloader/adapters/adapter_ficwadcom.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib2 +import time +import httplib, urllib + +from .. import BeautifulSoup as bs +from .. import exceptions as exceptions +from ..htmlcleanup import stripHTML + +from base_adapter import BaseSiteAdapter, makeDate + +class FicwadComSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','fw') + + # get storyId from url--url validation guarantees second part is storyId + self.story.setMetadata('storyId',self.parsedUrl.path.split('/',)[2]) + + self.username = "NoneGiven" + self.password = "" + + @staticmethod + def getSiteDomain(): + return 'ficwad.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://ficwad.com/story/1234" + + def getSiteURLPattern(self): + return re.escape(r"http://"+self.getSiteDomain())+"/story/\d+?$" + + def performLogin(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") + + loginUrl = 'http://' + self.getSiteDomain() + '/account/login' + logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl, + params['username'])) + d = self._postUrl(loginUrl,params) + + if "Login attempt failed..." in d: + logger.info("Failed to login to URL %s as %s" % (loginUrl, + params['username'])) + raise exceptions.FailedToLogin(url,params['username']) + return False + else: + return True + + def extractChapterUrlsAndMetadata(self): + + # fetch the chapter. From that we will get almost all the + # metadata and chapter list + + url = self.url + logger.debug("URL: "+url) + + # use BeautifulSoup HTML parser to make everything easier to find. + try: + data = self._fetchUrl(url) + # non-existent/removed story urls get thrown to the front page. + if "

    Welcome to FicWad

    " in data: + raise exceptions.StoryDoesNotExist(self.url) + soup = bs.BeautifulSoup(data) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + + h3 = soup.find('h3') + storya = h3.find('a',href=re.compile("^/story/\d+$")) + if storya : # if there's a story link in the h3 header, this is a chapter page. + # normalize story URL on chapter list. + self.story.setMetadata('storyId',storya['href'].split('/',)[2]) + url = "http://"+self.getSiteDomain()+storya['href'] + logger.debug("Normalizing to URL: "+url) + self._setURL(url) + try: + soup = bs.BeautifulSoup(self._fetchUrl(url)) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + + # if blocked, attempt login. + if soup.find("li",{"class":"blocked"}): + if self.performLogin(url): # performLogin raises + # FailedToLogin if it fails. + soup = bs.BeautifulSoup(self._fetchUrl(url)) + + # title - first h4 tag will be title. + titleh4 = soup.find('h4') + self.story.setMetadata('title', stripHTML(titleh4.a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"^/author/\d+")) + self.story.setMetadata('authorId',a['href'].split('/')[2]) + self.story.setMetadata('authorUrl','http://'+self.host+a['href']) + self.story.setMetadata('author',a.string) + + # description + storydiv = soup.find("div",{"id":"story"}) + self.setDescription(url,storydiv.find("blockquote",{'class':'summary'}).p) + #self.story.setMetadata('description', storydiv.find("blockquote",{'class':'summary'}).p.string) + + # most of the meta data is here: + metap = storydiv.find("p",{"class":"meta"}) + self.story.addToList('category',metap.find("a",href=re.compile(r"^/category/\d+")).string) + + # warnings + # [!!] [R] [V] [Y] + spanreq = metap.find("span",{"class":"req"}) + if spanreq: # can be no warnings. + for a in spanreq.findAll("a"): + self.story.addToList('warnings',a['title']) + + ## perhaps not the most efficient way to parse this, using + ## regexps for each rather than something more complex, but + ## IMO, it's more readable and amenable to change. + metastr = stripHTML(str(metap)).replace('\n',' ').replace('\t',' ') + #print "metap: (%s)"%metastr + + m = re.match(r".*?Rating: (.+?) -.*?",metastr) + if m: + self.story.setMetadata('rating', m.group(1)) + + m = re.match(r".*?Genres: (.+?) -.*?",metastr) + if m: + for g in m.group(1).split(','): + self.story.addToList('genre',g) + + m = re.match(r".*?Characters: (.*?) -.*?",metastr) + if m: + for g in m.group(1).split(','): + if g: + self.story.addToList('characters',g) + + m = re.match(r".*?Published: ([0-9/]+?) -.*?",metastr) + if m: + self.story.setMetadata('datePublished',makeDate(m.group(1), "%Y/%m/%d")) + + # Updated can have more than one space after it. + m = re.match(r".*?Updated: ([0-9/]+?) +-.*?",metastr) + if m: + self.story.setMetadata('dateUpdated',makeDate(m.group(1), "%Y/%m/%d")) + + m = re.match(r".*? - ([0-9/]+?) words.*?",metastr) + if m: + self.story.setMetadata('numWords',m.group(1)) + + if metastr.endswith("Complete"): + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + # get the chapter list first this time because that's how we + # detect the need to login. + storylistul = soup.find('ul',{'id':'storylist'}) + if not storylistul: + # no list found, so it's a one-chapter story. + self.chapterUrls.append((self.story.getMetadata('title'),url)) + else: + chapterlistlis = storylistul.findAll('li') + for chapterli in chapterlistlis: + if "blocked" in chapterli['class']: + # paranoia check. We should already be logged in by now. + raise exceptions.FailedToLogin(url,self.username) + else: + #print "chapterli.h4.a (%s)"%chapterli.h4.a + self.chapterUrls.append((chapterli.h4.a.string, + u'http://%s%s'%(self.getSiteDomain(), + chapterli.h4.a['href']))) + #print "self.chapterUrls:%s"%self.chapterUrls + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + return + + + 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. + + span = soup.find('div', {'id' : 'storytext'}) + + if None == span: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,span) + +def getClass(): + return FicwadComSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_fimfictionnet.py b/fanficdownloader/adapters/adapter_fimfictionnet.py new file mode 100644 index 00000000..cf8a4a99 --- /dev/null +++ b/fanficdownloader/adapters/adapter_fimfictionnet.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib2 +import cookielib as cl +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 FimFictionNetSiteAdapter + +class FimFictionNetSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','fimficnet') + self.story.setMetadata('storyId', self.parsedUrl.path.split('/',)[2]) + self._setURL("http://"+self.getSiteDomain()+"/story/"+self.story.getMetadata('storyId')+"/") + self.is_adult = False + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d %b %Y" + + @staticmethod + def getSiteDomain(): + return 'www.fimfiction.net' + + @classmethod + def getAcceptDomains(cls): + # mobile.fimifction.com isn't actually a valid domain, but we can still get the story id from URLs anyway + return ['www.fimfiction.net','mobile.fimfiction.net', 'www.fimfiction.com', 'mobile.fimfiction.com'] + + @classmethod + def getSiteExampleURLs(self): + return "http://www.fimfiction.net/story/1234/story-title-here http://www.fimfiction.net/story/1234/ http://www.fimfiction.com/story/1234/1/ http://mobile.fimfiction.net/story/1234/1/story-title-here/chapter-title-here" + + def getSiteURLPattern(self): + return r"https?://(www|mobile)\.fimfiction\.(net|com)/story/\d+/?.*" + + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + cookieproc = urllib2.HTTPCookieProcessor() + cookie = cl.Cookie(version=0, name='view_mature', value='true', + port=None, port_specified=False, + domain=self.getSiteDomain(), domain_specified=False, domain_initial_dot=False, + path='/story', path_specified=True, + secure=False, + expires=time.time()+10000, + discard=False, + comment=None, + comment_url=None, + rest={'HttpOnly': None}, + rfc2109=False) + cookieproc.cookiejar.set_cookie(cookie) + self.opener = urllib2.build_opener(cookieproc) + + try: + 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. And chapters, now, too. + data = self.do_fix_blockquotes(self._fetchUrl(self.url)) + soup = bs.BeautifulSoup(data) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + + if "Warning: mysql_fetch_array(): supplied argument is not a valid MySQL result resource" in data: + raise exceptions.StoryDoesNotExist(self.url) + + # Can cause problems if a missing story is referenced in a comment. + # Shouldn't be needed anyway. + # if "/images/missing_story.png" in data: + # raise exceptions.StoryDoesNotExist(self.url) + + if "This story has been marked as having adult content. Please click below to confirm you are of legal age to view adult material in your country." in data: + raise exceptions.AdultCheckRequired(self.url) + + if self.password: + params = {} + params['password'] = self.password + data = self._postUrl(self.url,params) + + if "Enter the password the author set for this story to view it." in data: + if self.getConfig('fail_on_password'): + raise exceptions.FailedToDownload("%s requires story password and fail_on_password is true."%self.url) + else: + raise exceptions.FailedToLogin(self.url,"Story requires individual password",passwdonly=True) + + if "Invalid story id" in apiData.values(): + raise exceptions.StoryDoesNotExist(self.url) + + storyMetadata = apiData["story"] + + ## Title + a = soup.find('a', href=re.compile(r'^/story/'+self.story.getMetadata('storyId'))) + self.story.setMetadata('title',stripHTML(a)) + + # 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"]] + + # ## 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) + # status to leave unchanged. + # Nov2012 - 'On Hiatus' is now passed, too. It's easy now for users to change/remove if they want + # with replace_metadata + 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) + + self.story.setMetadata("numWords", str(storyMetadata["words"])) + + # fimfic is the first site with an explicit cover image. + if "image" in storyMetadata.keys(): + if "full_image" in storyMetadata: + coverurl = storyMetadata["full_image"] + else: + coverurl = storyMetadata["image"] + if coverurl.startswith('//'): # fix for img urls missing 'http:' + coverurl = "http:"+coverurl + + self.setCoverImage(self.url,coverurl) + + # fimf has started including extra stuff inside the description div. + descdivstr = u"%s"%soup.find("div", {"class":"description"}) + hrstr=u"
    " + descdivstr = u'
    '+descdivstr[descdivstr.index(hrstr)+len(hrstr):] + self.setDescription(self.url,descdivstr) + + # Can't trust dates from API anymore I'm told. + # Dates are in Unix time + # Take the publish date from the first chapter posted + # rawDatePublished = storyMetadata["chapters"][0]["date_modified"] + # self.story.setMetadata("datePublished", datetime.fromtimestamp(rawDatePublished)) + # rawDateUpdated = storyMetadata["date_modified"] + # self.story.setMetadata("dateUpdated", datetime.fromtimestamp(rawDateUpdated)) + + oldestChapter = None + newestChapter = None + self.newestChapterNum = None # save for comparing during update. + # Scan all chapters to find the oldest and newest, on + # FiMFiction it's possible for authors to insert new chapters + # out-of-order or change the dates of earlier ones by editing + # them--That WILL break epub update. + for index, chapterDate in enumerate(soup.findAll('span', {'class':'date'})): + date=re.sub(r"(\d+)(st|nd|rd|th)",r"\1",chapterDate.contents[1].strip()) + chapterDate = makeDate(date,self.dateformat) + if oldestChapter == None or chapterDate < oldestChapter: + oldestChapter = chapterDate + if newestChapter == None or chapterDate > newestChapter: + newestChapter = chapterDate + self.newestChapterNum = index + + self.story.setMetadata("dateUpdated", newestChapter) + + pubdatetag = soup.find('span', {'class':'date_approved'}) + if pubdatetag is None: + self.story.setMetadata("datePublished", oldestChapter) + else: + pubdateraw = pubdatetag('span')[1].text + datestripped=re.sub(r"(\d+)(st|nd|rd|th)",r"\1",pubdateraw.strip()) + pubDate = makeDate(datestripped,self.dateformat) + self.story.setMetadata("datePublished", pubDate) + + chars = soup.find("div", {"class":"inner_data"}) + # 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 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() + if char == 'Oc': + char = "OC" + if char == 'Cmc': + char = "Cutie Mark Crusaders" + self.story.addToList("characters", char) + + # extra site specific metadata + extralist = ["likes","dislikes","views","total_views","short_description"] + for metakey in extralist: + if metakey in storyMetadata: + value = storyMetadata[metakey] + if not isinstance(value,basestring): + value = unicode(value) + self.story.setMetadata(metakey, value) + + ## Groups and sequels code from FaceDeer + allGroupLists = soup.findAll('ul', {'id':'story_group_list'}) + for groupList in allGroupLists: + for groupName in groupList.findAll('a', {'href':re.compile('^/group/')}): + self.story.addToList("groupsUrl", 'http://'+self.host+groupName["href"]) + self.story.addToList("groups",stripHTML(groupName).replace(',', ';')) + + sequelStoryHeader = soup.find('h1', {'class':'header-stories'}, text="Sequels") + if not sequelStoryHeader == None: + sequelContainer = sequelStoryHeader.parent.parent + for sequel in sequelContainer.findAll('a', {'class':'story_link'}): + self.story.addToList("sequelsUrl", 'http://'+self.host+sequel["href"]) + self.story.addToList("sequels", stripHTML(sequel).replace(',', ';')) + + #The link to the prequel is embedded in the description text, so erring + #on the side of caution and wrapping this whole thing in a try block. + #If anything goes wrong this probably wasn't a valid prequel link. + try: + description = soup.find('div', {'class':'description'}) + firstHR = description.find("hr") + nextSib = firstHR.nextSibling + if "This story is a sequel to" in nextSib.string: + link = nextSib.nextSibling + if link.name == "a": + self.story.setMetadata("prequelUrl", 'http://'+self.host+link["href"]) + self.story.setMetadata("prequel", stripHTML(link)) + except: + pass + + def hookForUpdates(self,chaptercount): + if self.oldchapters and len(self.oldchapters) > self.newestChapterNum: + print("Existing epub has %s chapters\nNewest chapter is %s. Discarding old chapters from there on."%(len(self.oldchapters), self.newestChapterNum+1)) + self.oldchapters = self.oldchapters[:self.newestChapterNum] + return len(self.oldchapters) + + def do_fix_blockquotes(self,data): + if self.getConfig('fix_fimf_blockquotes'): + #

    + #

    + # include > in re groups so there's always something in the group. + data = re.sub(r']*>\s*)]*>)',r'\s*)

    ',r'',data) + return data + + def getChapterText(self, url): + logger.debug('Getting chapter text from: %s' % url) + + data = self.do_fix_blockquotes(self._fetchUrl(url)) + soup = bs.BeautifulSoup(data,selfClosingTags=('br','hr')).find('div', {'class' : 'chapter_content'}) + if soup == None: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + return self.utf8FromSoup(url,soup) + diff --git a/fanficdownloader/adapters/adapter_finestoriescom.py b/fanficdownloader/adapters/adapter_finestoriescom.py new file mode 100644 index 00000000..29db7100 --- /dev/null +++ b/fanficdownloader/adapters/adapter_finestoriescom.py @@ -0,0 +1,288 @@ +# -*- 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 FineStoriesComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class FineStoriesComAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url + self.story.setMetadata('storyId',self.parsedUrl.path.split('/',)[2].split(':')[0]) + if 'storyInfo' in self.story.getMetadata('storyId'): + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/s/storyInfo.php?id='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','fnst') + + # 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 'finestories.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/s/1234 http://"+self.getSiteDomain()+"/s/1234:4010 http://"+self.getSiteDomain()+"/library/storyInfo.php?id=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain())+r"/(s|library)?/(storyInfo.php\?id=)?\d+(:\d+)?(;\d+)?$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Free Registration' in data \ + or "Invalid Password!" in data \ + or "Invalid User Name!" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['theusername'] = self.username + params['thepassword'] = self.password + else: + params['theusername'] = self.getConfig("username") + params['thepassword'] = self.getConfig("password") + params['rememberMe'] = '1' + params['page'] = 'http://'+self.getSiteDomain()+'/' + params['submit'] = 'Login' + + loginUrl = 'http://' + self.getSiteDomain() + '/login.php' + logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl, + params['theusername'])) + + d = self._fetchUrl(loginUrl, params) + + if "My Account" not in d : #Member Account + logger.info("Failed to login to URL %s as %s" % (loginUrl, + params['theusername'])) + raise exceptions.FailedToLogin(url,params['theusername']) + 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 "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 + a = soup.find('a', href=re.compile(r'/s/'+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"/a/\w+")) + self.story.setMetadata('authorId',a['href'].split('/')[2]) + self.story.setMetadata('authorUrl','http://'+self.host+a['href']) + self.story.setMetadata('author',a.text) + + # Find the chapters: + chapters = soup.findAll('a', href=re.compile(r'/s/'+self.story.getMetadata('storyId')+":\d+$")) + if len(chapters) != 0: + for chapter in chapters: + # just in case there's tags, like in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+chapter['href'])) + else: + self.chapterUrls.append((self.story.getMetadata('title'),'http://'+self.host+'/s/'+self.story.getMetadata('storyId'))) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + # surprisingly, the detailed page does not give enough details, so go to author's page + + skip=0 + i=0 + while i == 0: + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl')+"&skip="+str(skip))) + + a = asoup.findAll('td', {'class' : 'lc2'}) + for lc2 in a: + if lc2.find('a')['href'] == '/s/'+self.story.getMetadata('storyId'): + i=1 + break + if a[len(a)-1] == lc2: + skip=skip+10 + + for cat in lc2.findAll('div', {'class' : 'typediv'}): + self.story.addToList('category',cat.text) + + self.story.setMetadata('numWords', lc2.findNext('td', {'class' : 'num'}).text) + + lc4 = lc2.findNext('td', {'class' : 'lc4'}) + + + try: + a = lc4.find('a', href=re.compile(r"/library/show_series.php\?id=\d+")) + i = a.parent.text.split('(')[1].split(')')[0] + self.setSeries(a.text, i) + self.story.setMetadata('seriesUrl','http://'+self.host+a['href']) + except: + pass + try: + a = lc4.find('a', href=re.compile(r"/library/universe.php\?id=\d+")) + self.story.addToList("category",a.text) + except: + pass + + for a in lc4.findAll('span', {'class' : 'help'}): + a.extract() + + self.setDescription('http://'+self.host+'/s/'+self.story.getMetadata('storyId'),lc4.text.split('[More Info')[0]) + + for b in lc4.findAll('b'): + label = b.text + value = b.nextSibling + + if 'For Age' in label: + self.story.setMetadata('rating', value) + + if 'Tags' in label: + for genre in value.split(', '): + self.story.addToList('genre',genre) + + if 'Posted' in label: + self.story.setMetadata('datePublished', makeDate(stripHTML(value.split('/ (')[0]), self.dateformat)) + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value.split('/ (')[0]), self.dateformat)) + + if 'Concluded' in label: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value.split('/ (')[0]), self.dateformat)) + + if 'Updated' in label: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value.split('/ (')[0]), self.dateformat)) + + status = lc4.find('span', {'class' : 'ab'}) + if status != None: + self.story.setMetadata('status', 'In-Progress') + if "Last Activity" in status.text: + self.story.setMetadata('dateUpdated', makeDate(status.text.split('Activity: ')[1].split(')')[0], self.dateformat)) + else: + self.story.setMetadata('status', 'Completed') + + + # 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'}) + + # some big chapters are split over several pages + pager = div.find('span', {'class' : 'pager'}) + if pager != None: + urls=pager.findAll('a') + urls=urls[:len(urls)-1] + + + for ur in urls: + soup = bs.BeautifulSoup(self._fetchUrl("http://"+self.getSiteDomain()+ur['href']), + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + div1 = soup.find('div', {'id' : 'story'}) + + # appending next section + last=div.findAll('p') + next=div1.find('span', {'class' : 'conTag'}).nextSibling + + last[len(last)-1]=last[len(last)-1].append(next) + div.append(div1) + + # removing all the left-over stuff + for a in div.findAll('span'): + a.extract() + + for a in div.findAll('h1'): + a.extract() + for a in div.findAll('h2'): + a.extract() + for a in div.findAll('h3'): + a.extract() + for a in div.findAll('h4'): + a.extract() + for a in div.findAll('br'): + a.extract() + for a in div.findAll('div', {'class' : 'date'}): + a.extract() + + a = div.find('form') + if a != None: + b = a.nextSibling + while b != None: + a.extract() + a=b + b=b.nextSibling + + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_grangerenchantedcom.py b/fanficdownloader/adapters/adapter_grangerenchantedcom.py new file mode 100644 index 00000000..a39f738e --- /dev/null +++ b/fanficdownloader/adapters/adapter_grangerenchantedcom.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 GrangerEnchantedCom + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class GrangerEnchantedCom(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]) + + self.section=self.parsedUrl.path.split('/',)[1] + + # normalized story URL. + if "malfoymanor" in self.parsedUrl.netloc: + self._setURL('http://malfoymanor.' + self.getSiteDomain() + '/themanor/viewstory.php?sid='+self.story.getMetadata('storyId')) + self.story.addToList("category","The Manor") + else: + self._setURL('http://' + self.getSiteDomain() + '/enchant/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','gech') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d/%b/%Y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'grangerenchanted.com' + + @classmethod + def getAcceptDomains(cls): + return ['grangerenchanted.com','malfoymanor.grangerenchanted.com'] + + @classmethod + def getSiteExampleURLs(self): + return "http://grangerenchanted.com/enchant/viewstory.php?sid=1234 http://malfoymanor.grangerenchanted.com/themanor/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return r"http://(malfoymanor.)?grangerenchanted.com/(enchant|themanor)?/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' + + if "enchant" in self.section: + loginUrl = 'http://grangerenchanted.com/enchant/user.php?action=login' + else: + loginUrl = 'http://malfoymanor.grangerenchanted.com/themanor/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): + + 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=1" + else: + addurl="" + + # index=1 makes sure we see the story chapter index. Some + # sites skip that for one-chapter stories. + url = self.url+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+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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 in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+self.section+'/'+chapter['href']+addurl)) + + 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 "" + + # Rated: NC-17
    etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + if 'Word count' in label: + self.story.setMetadata('numWords', value) + + if 'Read' in label: + self.story.setMetadata('read', 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=4')) + 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: + 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+'/'+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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + except: + # I find it hard to care if the series parsing fails + pass + + try: + self.story.setMetadata('reviews', + stripHTML(soup.find('div',{'id':'sort'}). + findAll('a', href=re.compile(r'^reviews.php'))[1])) + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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' : 'story1'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_harrypotterfanfictioncom.py b/fanficdownloader/adapters/adapter_harrypotterfanfictioncom.py new file mode 100644 index 00000000..968a9dde --- /dev/null +++ b/fanficdownloader/adapters/adapter_harrypotterfanfictioncom.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib +import urllib2 + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class HarryPotterFanFictionComSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','hp') + 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.is_adult=False + + # get storyId from url--url validation guarantees query is only psid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/viewstory.php?psid='+self.story.getMetadata('storyId')) + + + @staticmethod + def getSiteDomain(): + return 'www.harrypotterfanfiction.com' + + @classmethod + def getAcceptDomains(cls): + return ['www.harrypotterfanfiction.com','harrypotterfanfiction.com'] + + @classmethod + def getSiteExampleURLs(self): + return "http://www.harrypotterfanfiction.com/viewstory.php?psid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+r"(www\.)?"+re.escape("harrypotterfanfiction.com/viewstory.php?psid=")+r"\d+$" + + 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 extractChapterUrlsAndMetadata(self): + + 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.") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + + ## Title + a = soup.find('a', href=re.compile(r'\?psid='+self.story.getMetadata('storyId'))) + self.story.setMetadata('title',stripHTML(a)) + ## javascript:if (confirm('Please note. This story may contain adult themes. By clicking here you are stating that you are over 17. Click cancel if you do not meet this requirement.')) location = '?psid=290995' + if "This story may contain adult themes." in a['href'] and not (self.is_adult or self.getConfig("is_adult")): + raise exceptions.AdultCheckRequired(self.url) + + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?showuid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + self.story.setMetadata('author',a.string) + + ## hpcom doesn't give us total words--but it does give + ## us words/chapter. I'd rather add than fetch and + ## parse another page. + words=0 + for tr in soup.find('table',{'class':'text'}).findAll('tr'): + tdstr = tr.findAll('td')[2].string + if tdstr and tdstr.isdigit(): + words+=int(tdstr) + self.story.setMetadata('numWords',str(words)) + + # Find the chapters: + tablelist = soup.find('table',{'class':'text'}) + for chapter in tablelist.findAll('a', href=re.compile(r'\?chapterid=\d+')): + #javascript:if (confirm('Please note. This story may contain adult themes. By clicking here you are stating that you are over 17. Click cancel if you do not meet this requirement.')) location = '?chapterid=433441&i=1' + # just in case there's tags, like in chapter titles. + chpt=re.sub(r'^.*?(\?chapterid=\d+).*?',r'\1',chapter['href']) + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/viewstory.php'+chpt)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + ## Finding the metadata is a bit of a pain. Desc is the only thing this color. + desctable= soup.find('table',{'bgcolor':'#f0e8e8'}) + self.setDescription(url,desctable) + #self.story.setMetadata('description',stripHTML(desctable)) + + ## Finding the metadata is a bit of a pain. Most of the meta + ## data is in a center.table without a bgcolor. + #for center in soup.findAll('center'): + table = soup.find('table',{'class':'storymaininfo'}) + if table: + metastr = stripHTML(str(table)).replace('\n',' ').replace('\t',' ') + # Rating: 12+ Story Reviews: 3 + # Chapters: 3 + # Characters: Andromeda, Ted, Bellatrix, R. Lestrange, Lucius, Narcissa, OC + # Genre(s): Fluff, Romance, Young Adult Era: OtherPairings: Other Pairing, Lucius/Narcissa + # Status: Completed + # First Published: 2010.09.02 + # Last Published Chapter: 2010.09.28 + # Last Updated: 2010.09.28 + # Favorite Story Of: 1 users + # Warnings: Scenes of a Mild Sexual Nature + + m = re.match(r".*?Status: Completed.*?",metastr) + if m: + self.story.setMetadata('status','Completed') + else: + self.story.setMetadata('status','In-Progress') + + m = re.match(r".*?Rating: (.+?) Story Reviews.*?",metastr) + if m: + self.story.setMetadata('rating', m.group(1)) + + m = re.match(r".*?Genre\(s\): (.+?) Era.*?",metastr) + if m: + for g in m.group(1).split(','): + self.story.addToList('genre',g) + + m = re.match(r".*?Characters: (.+?) Genre.*?",metastr) + if m: + for g in m.group(1).split(','): + self.story.addToList('characters',g) + + m = re.match(r".*?Warnings: (.+).*?",metastr) + if m: + for w in m.group(1).split(','): + if w != 'Now Warnings': + self.story.addToList('warnings',w) + + m = re.match(r".*?First Published: ([0-9\.]+).*?",metastr) + if m: + self.story.setMetadata('datePublished',makeDate(m.group(1), "%Y.%m.%d")) + + # Updated can have more than one space after it. + m = re.match(r".*?Last Updated: ([0-9\.]+).*?",metastr) + if m: + self.story.setMetadata('dateUpdated',makeDate(m.group(1), "%Y.%m.%d")) + + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + ## most adapters use BeautifulStoneSoup here, but non-Stone + ## allows nested div tags. + soup = bs.BeautifulSoup(self._fetchUrl(url), + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + div = soup.find('div', {'id' : 'fluidtext'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) + +def getClass(): + return HarryPotterFanFictionComSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_hennethannunnet.py b/fanficdownloader/adapters/adapter_hennethannunnet.py new file mode 100644 index 00000000..340e8c2a --- /dev/null +++ b/fanficdownloader/adapters/adapter_hennethannunnet.py @@ -0,0 +1,172 @@ +# -*- 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 HennethAnnunNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class HennethAnnunNetAdapter(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() + '/stories/chapter.cfm?stid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','htan') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.henneth-annun.net' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/stories/chapter.cfm?stid=1234" + + def getSiteURLPattern(self): + return "http://"+self.getSiteDomain()+"/stories/chapter(_view)?.cfm\?stid="+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 + 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 "We're sorry. This story is not available." in data: + raise exceptions.FailedToDownload(self.getSiteDomain() +" says: This story is not available.") + + # 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('h2', {'id':'page_heading'}) + self.story.setMetadata('title',stripHTML(a)) + + # Find the chapters: chapter_view.cfm?stid=6663&spordinal=1" + for chapter in soup.findAll('a', href=re.compile(r'chapter_view.cfm\?stid='+self.story.getMetadata('storyId')+"&spordinal=\d+$")): + # just in case there's tags, like in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/stories/'+chapter['href'])) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + self.story.setMetadata('numWords', soup.find('tr', {'class':'foot'}).findAll('td')[1].text) + + self.setDescription(url,soup.find('div', {'id':'summary'})) + + # Rated: NC-17
    etc + info = soup.find('div', {'id':'storyinformation'}) + labels=info.findAll('b') + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Completion' in label: + if 'Complete' in value.string: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + if 'Rating' in label: + self.story.setMetadata('rating', value.string) + + if 'Era:' in label: + self.story.addToList('category',value.string) + + if 'Genre' in label: + self.story.addToList('genre',value.string) + + labels=info.findAll('strong') + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Author' in label: + value=value.nextSibling + self.story.setMetadata('authorId',value['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+value['href']) + self.story.setMetadata('author',value.string) + + if 'Post' in label: + self.story.setMetadata('datePublished', makeDate(stripHTML(value), self.dateformat)) + + if 'Updated:' in label: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + for char in soup.findAll('a', href=re.compile(r"/resources/bios_view.cfm\?scid=\d+")): + self.story.addToList('characters',stripHTML(char)) + + # 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('div', {'class' : 'block chapter'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_hlfictionnet.py b/fanficdownloader/adapters/adapter_hlfictionnet.py new file mode 100644 index 00000000..5d15f7db --- /dev/null +++ b/fanficdownloader/adapters/adapter_hlfictionnet.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 HLFictionNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class HLFictionNetAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','hlf') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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 'hlfiction.net' + + @classmethod + 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 + 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.") + + # 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 + a = soup.find('div', {'id' : 'pagetitle'}) + + aut = a.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+'/'+aut['href']) + self.story.setMetadata('author',aut.string) + aut.extract() + + self.story.setMetadata('title',stripHTML(a)[:(len(a.string)-3)]) + + # Find the chapters: + chapters=soup.find('select') + if chapters != None: + for chapter in chapters.findAll('option'): + # just in case there's tags, like in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/viewstory.php?sid='+self.story.getMetadata('storyId')+'&chapter='+chapter['value'])) + else: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + for list in asoup.findAll('div', {'class' : re.compile('listbox\s+')}): + a = list.find('a') + if ('viewstory.php?sid='+self.story.getMetadata('storyId')) in a['href']: + break + + # 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 "" + + # Rated: NC-17
    etc + labels = list.findAll('span', {'class' : 'classification'}) + for labelspan in labels: + label = labelspan.string + value = labelspan.nextSibling + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'classification': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value[:len(value)-2]) + + if 'Word count' in label: + self.story.setMetadata('numWords', value) + + if 'Categories' in label: + cats = labelspan.parent.findAll('a',href=re.compile(r'categories.php\?catid=\d+')) + for cat in cats: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + for char in value.string.split(', '): + if not 'None' in char: + self.story.addToList('characters',char) + + if 'Genre' in label: + for genre in value.string.split(', '): + if not 'None' in genre: + self.story.addToList('genre',genre) + + if 'Warnings' in label: + for warning in value.string.split(', '): + if not 'None' in warning: + self.story.addToList('warnings',warning) + + 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 = list.find('a', href=re.compile(r"series.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # 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+')) + 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 ('viewstory.php?sid='+self.story.getMetadata('storyId')) in a['href']: + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_hpfandomnet.py b/fanficdownloader/adapters/adapter_hpfandomnet.py new file mode 100644 index 00000000..a2aabd85 --- /dev/null +++ b/fanficdownloader/adapters/adapter_hpfandomnet.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +# 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 HPFandomNetAdapterAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class HPFandomNetAdapterAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # normalized story URL. + # XXX Most sites don't have the /eff part. Replace all to remove it usually. + self._setURL('http://' + self.getSiteDomain() + '/eff/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','hpfdm') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%Y.%m.%d" # XXX + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'www.hpfandom.net' # XXX + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/eff/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/eff/viewstory.php?sid=")+r"\d+$" + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + 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 + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/eff/'+a['href']) + self.story.setMetadata('author',a.string) + + ## Going to get the rest from the author page. + authdata = self._fetchUrl(self.story.getMetadata('authorUrl')) + # fix a typo in the site HTML so I can find the Characters list. + authdata = authdata.replace('
    ", "
    ','') + + # hpfandom.net only seems to indicate adult-only by javascript on the story/chapter links. + if "javascript:if (confirm('Slash/het fiction which incorporates sexual situations to a somewhat graphic degree and some violence. ')) location = 'viewstory.php?sid=%s'"%self.story.getMetadata('storyId') in authdata \ + and not (self.is_adult or self.getConfig("is_adult")): + raise exceptions.AdultCheckRequired(self.url) + + authsoup = bs.BeautifulSoup(authdata) + + reviewsa = authsoup.find('a', href="reviews.php?sid="+self.story.getMetadata('storyId')+"&a=") + # + # + # + labels = metablock.findAll('td',{'width':'10%'}) + for td in labels: + label = td.string + value = td.nextSibling.string + #print("\nlabel:%s\nvalue:%s\n"%(label,value)) + + if 'Category' in label and value: + cats = td.parent.findAll('a',href=re.compile(r'categories.php')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label and value: # this site can have Character label with no + # values, apparently. Others as a precaution. + for char in value.split(','): + self.story.addToList('characters',char.strip()) + + if 'Genre' in label and value: + for genre in value.split(','): + self.story.addToList('genre',genre.strip()) + + if 'Warnings' in label and value: + for warning in value.split(','): + if warning.strip() != 'none': + self.story.addToList('warnings',warning.strip()) + + if 'Published' in label: + self.story.setMetadata('datePublished', makeDate(stripHTML(value), self.dateformat)) + + if 'Updated' in label: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._fetchUrl(url) + # There's no good wrapper around the chapter text. :-/ + # There are, however, tables with width=100% just above and below the real text. + data = re.sub(r'

    + metablock = reviewsa.findParent("table") + #print("metablock:%s"%metablock) + + ## Title + titlea = metablock.find('a', href=re.compile("viewstory.php")) + #print("titlea:%s"%titlea) + if titlea == None: + raise exceptions.FailedToDownload("Story URL (%s) not found on author's page, can't use chapter URLs"%url) + self.story.setMetadata('title',stripHTML(titlea)) + + # Find the chapters: !!! hpfandom.net differs from every other + # eFiction site--the sid on viewstory for chapters is + # *different* for each chapter + for chapter in soup.findAll('a', {'href':re.compile(r"viewstory.php\?sid=\d+&i=\d+")}): + m = re.match(r'.*?(viewstory.php\?sid=\d+&i=\d+).*?',chapter['href']) + # just in case there's tags, like in chapter titles. + #print("====chapter===%s"%m.group(1)) + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/eff/'+m.group(1))) + + if len(self.chapterUrls) == 0: + self.chapterUrls.append((stripHTML(self.story.getMetadata('title')),url)) + + 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 "" + + summary = metablock.find("td",{"class":"summary"}) + summary.name='span' + self.setDescription(url,summary) + + # words & completed in first row of metablock. + firstrow = stripHTML(metablock.find('tr')) + # A Mother's Love xx Going Grey 1 (G+) by Kiristeen | Reviews - 18 | Words: 27468 | Completed: Yes + m = re.match(r".*?\((?P[^)]+)\).*?Words: (?P\d+).*?Completed: (?PYes|No)",firstrow) + if m != None: + if m.group('rating') != None: + self.story.setMetadata('rating', m.group('rating')) + + if m.group('words') != None: + self.story.setMetadata('numWords', m.group('words')) + + if m.group('status') != None: + if 'Yes' in m.group('status'): + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + + #

    Chapters:4Published:2010.09.29
    Completed:YesUpdated:2010.10.03
    .*?
    ','
    ', + data,count=1,flags=re.DOTALL) + + data = re.sub(r'.*?
    ','
    ', + data,count=1,flags=re.DOTALL) + + soup = bs.BeautifulStoneSoup(data,selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + div = soup.find("div",{'name':'storybody'}) + #print("\n\ndiv:%s\n\n"%div) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_hpfanficarchivecom.py b/fanficdownloader/adapters/adapter_hpfanficarchivecom.py new file mode 100644 index 00000000..6f64371b --- /dev/null +++ b/fanficdownloader/adapters/adapter_hpfanficarchivecom.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 HPFanficArchiveComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class HPFanficArchiveComAdapter(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() + '/stories/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','hpffa') + + # 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 'www.hpfanficarchive.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/stories/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/stories/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 + 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.") + + # 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')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/stories/'+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 in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/stories/'+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 "" + + # Rated: NC-17
    etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + val = labelspan.nextSibling + value = unicode('') + while val and not defaultGetattr(val,'class') == 'label': + value += unicode(val) + val = val.nextSibling + label = labelspan.string + #print("label:%s\nvalue:%s"%(label,value)) + + if 'Summary' in label: + self.setDescription(url,value) + + if 'Rated' in label: + self.story.setMetadata('rating', stripHTML(value)) + + if 'Word count' in label: + self.story.setMetadata('numWords', stripHTML(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')) # XXX + for genre in genres: + self.story.addToList('genre',genre.string) + + if 'Pairing' in label: + ships = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=4')) + for ship in ships: + self.story.addToList('ships',ship.string) + + if 'Warnings' in label: + 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) + + if 'Completed' in label: + if 'Yes' in stripHTML(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: + 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+'/stories/'+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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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) diff --git a/fanficdownloader/adapters/adapter_iketernalnet.py b/fanficdownloader/adapters/adapter_iketernalnet.py new file mode 100644 index 00000000..a89ff8f4 --- /dev/null +++ b/fanficdownloader/adapters/adapter_iketernalnet.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 IkEternalNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class IkEternalNetAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','ike') + + # 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 'www.ik-eternal.net' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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): + + 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 = "&warning=1" + 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) + + # 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 + # &warning= -- actually, so do other sites. Must be an + # eFiction book. + + # viewstory.php?sid=1882&warning=4 + # viewstory.php?sid=1654&ageconsent=ok&warning=5 + #print data + #m = re.search(r"'viewstory.php\?sid=1882(&warning=4)'",data) + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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,selfClosingTags=('p')) #poor formatting of the paragraphs in the title page + # 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')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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 in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['href']+addurl)) + + 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 "" + + # Rated: NC-17
    etc + asoup = soup.find('div', {'class': 'listbox'}) + for a in asoup.findAll('p'): + a.name='br' + labels = asoup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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)) + + # 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_imagineeficcom.py b/fanficdownloader/adapters/adapter_imagineeficcom.py new file mode 100644 index 00000000..e080befb --- /dev/null +++ b/fanficdownloader/adapters/adapter_imagineeficcom.py @@ -0,0 +1,289 @@ +# -*- 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 ImagineEFicComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class ImagineEFicComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','ime') + + # 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 'imagine.e-fic.com' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" + 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+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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 in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['href']+addurl)) + + 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 "" + + + # Rated: NC-17
    etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # 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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_indeathnet.py b/fanficdownloader/adapters/adapter_indeathnet.py new file mode 100644 index 00000000..ecf79205 --- /dev/null +++ b/fanficdownloader/adapters/adapter_indeathnet.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 InDeathNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class InDeathNetAdapter(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')) + + # normalized story URL. + self._setURL('http://www.' + self.getSiteDomain() + '/blog/archive/'+self.story.getMetadata('storyId')+'-'+m.group('name')+'/') + else: + raise exceptions.InvalidStoryURL(url, + self.getSiteDomain(), + self.getSiteExampleURLs()) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','idn') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d %B %Y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'indeath.net' + + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/blog/archive/123-story-in-death/" + + def getSiteURLPattern(self): + # http://www.indeath.net/blog/archive/169-ransom-in-death/ + return re.escape("http://")+re.escape(self.getSiteDomain())+r"/blog/(archive/)?(?P\d+)\-(?P[a-z0-9\-]*)/?$" + + + def getDateFromComponents(self, postmonth, postday): + ym = re.search("Entries\ in\ (?PJanuary|February|March|April|May|June|July|August|September|October|November|December)\ (?P\d{4})",postmonth) + d = re.search("(?P\d{2})\ (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)",postday) + postdate = makeDate(d.group('day')+' '+ym.group('mon')+' '+ym.group('year'),self.dateformat) + return postdate + + def getAuthorData(self): + + mainUrl = self.url.replace("/archive","") + + try: + maindata = self._fetchUrl(mainUrl) + + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.meta) + else: + raise e + + # use BeautifulSoup HTML parser to make everything easier to find. + mainsoup = bs.BeautifulSoup(maindata) + + # find first entry + e = mainsoup.find('div',{'class':"entry"}) + + # get post author as author + d = e.find('div',{'class':"desc"}) + a = d.find('strong') + self.story.setMetadata('author',a.contents[0].string.strip()) + + # Don't seem to be able to get author pages anymore + self.story.setMetadata('authorUrl','http://www.indeath.net/') + self.story.setMetadata('authorId','0') + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + url = self.url + try: + data = self._fetchUrl(url) + + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.meta) + else: + raise e + + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + + # Now go hunting for all the meta data and the chapter list. + + ## Title + h = soup.find('a', id="blog_title") + t = h.find('span') + self.story.setMetadata('title',stripHTML(t.contents[0]).strip()) + + s = t.find('div') + if s != None: + self.setDescription(url,s) + + # Get Author from main blog page since it's not reliably on the archive page + self.getAuthorData() + + # Find the chapters: + chapters=soup.findAll('a', title="View entry", href=re.compile(r'http://www.indeath.net/blog/'+self.story.getMetadata('storyId')+"/entry\-(\d+)\-([^/]*)/$")) + + #reverse the list since newest at the top + chapters.reverse() + + # Get date published & updated from first & last entries + posttable=soup.find('div', id="main_column") + + postmonths=posttable.findAll('th', text=re.compile(r'Entries\ in\ ')) + postmonths.reverse() + + postdates=posttable.findAll('span', _class="desc", text=re.compile('\d{2}\ (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)')) + postdates.reverse() + + self.story.setMetadata('datePublished',self.getDateFromComponents(postmonths[0],postdates[0])) + self.story.setMetadata('dateUpdated',self.getDateFromComponents(postmonths[len(postmonths)-1],postdates[len(postdates)-1])) + + # Process List of Chapters + self.story.setMetadata('numChapters',len(chapters)) + logger.debug("numChapters: (%s)"%self.story.getMetadata('numChapters')) + for x in range(0,len(chapters)): + # just in case there's tags, like in chapter titles. + chapter=chapters[x] + if len(chapters)==1: + self.chapterUrls.append((self.story.getMetadata('title'),chapter['href'])) + else: + ct = stripHTML(chapter) + tnew = re.match("(?i)"+self.story.getMetadata('title')+r" - (?P.*)$",ct) + if tnew: + chaptertitle = tnew.group('newtitle') + else: + chaptertitle = ct + self.chapterUrls.append((chaptertitle,chapter['href'])) + + + + # grab the text for an individual chapter. + def getChapterText(self, url): + logger.debug('Getting chapter text from: %s' % url) + + #chapter=bs.BeautifulSoup('
    ') + data = self._fetchUrl(url) + soup = bs.BeautifulSoup(data,selfClosingTags=('br','hr','span','center')) + + chapter = soup.find("div", "entry_content") + + if None == chapter: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,chapter) + diff --git a/fanficdownloader/adapters/adapter_ksarchivecom.py b/fanficdownloader/adapters/adapter_ksarchivecom.py new file mode 100644 index 00000000..96fd2247 --- /dev/null +++ b/fanficdownloader/adapters/adapter_ksarchivecom.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +# Search for XXX comments--that's where things are most likely to need changing. + +# 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 KSArchiveComAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class KSArchiveComAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.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. + # XXX Most sites don't have the /fanfic part. Replace all to remove it usually. + 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','ksa') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%b/%d/%Y" # XXX + + @classmethod + def getAcceptDomains(cls): + return ['www.ksarchive.com','ksarchive.com'] + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'ksarchive.com' # XXX + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return "http://(www.)?"+re.escape(self.getSiteDomain()+"/viewstory.php?sid=")+r"\d+$" + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + # 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. + + # Furthermore, there's a couple sites now with more than + # one warning level for different ratings. And they're + # fussy about it. midnightwhispers has three: 10, 3 & 5. + # we'll try 5 first. + addurl = "&ageconsent=ok&warning=2" # XXX + 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 + + # 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 + # &warning= -- actually, so do other sites. Must be an + # eFiction book. + + # viewstory.php?sid=1882&warning=4 + # viewstory.php?sid=1654&ageconsent=ok&warning=5 + #print data + #m = re.search(r"'viewstory.php\?sid=1882(&warning=4)'",data) + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) # title's inside a tag. + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + self.story.setMetadata('author',stripHTML(a)) + + # 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 in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['href']+addurl)) + + 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 "" + + # Rated: NC-17
    etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = stripHTML(labelspan) + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + # poor HTML(unclosed

    for one) can cause run on + # over the next label. + if '' in svalue: + svalue = svalue[0:svalue.find('')] + break + else: + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [stripHTML(cat) for cat in cats] + for cat in catstext: + # ran across one story with an empty + # tag in the desc once. + if cat and cat.strip() in ('Poetry','Essays'): + self.story.addToList('category',stripHTML(cat)) + + if 'Characters' in label: + self.story.addToList('characters','Kirk') + self.story.addToList('characters','Spock') + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [stripHTML(char) for char in chars] + for char in charstext: + self.story.addToList('characters',stripHTML(char)) + + ## Not all sites use Genre, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')) # XXX + genrestext = [stripHTML(genre) for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',stripHTML(genre)) + + ## In addition to Genre (which is very site specific) KSA + ## has 'Story Type', which is much more what most sites + ## call genre. + if 'Story Type' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=5')) # XXX + genrestext = [stripHTML(genre) for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',stripHTML(genre)) + + ## Not all sites use Warnings, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Warnings' in label: + warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + warningstext = [stripHTML(warning) for warning in warnings] + self.warning = ', '.join(warningstext) + for warning in warningstext: + self.story.addToList('warnings',stripHTML(warning)) + + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = stripHTML(a) + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'viewstory.php\?sid=\d+')) + i=1 + for a in storyas: + # 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._fetchUrl(url) + soup = bs.BeautifulStoneSoup(data, + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + div = soup.find('div', {'id' : 'story'}) + + if None == div: + if "A fatal MySQL error was encountered" in data: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Database error on the site reported!" % url) + else: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_libraryofmoriacom.py b/fanficdownloader/adapters/adapter_libraryofmoriacom.py new file mode 100644 index 00000000..6f8ff7fe --- /dev/null +++ b/fanficdownloader/adapters/adapter_libraryofmoriacom.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 LibraryOfMoriaComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class LibraryOfMoriaComAdapter(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() + '/a/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','lom') + + # 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 'www.libraryofmoria.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/a/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/a/viewstory.php?sid=")+r"\d+$" + + + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + addurl = "&ageconsent=ok&warning=3" + else: + addurl="" + + # index=1 makes sure we see the story chapter index. Some + # sites skip that for one-chapter stories. + url = self.url+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 + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/a/'+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 in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/a/'+chapter['href']+addurl)) + + 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 "" + + # Rated: NC-17
    etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + if 'Type' 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 'Warning' in label: + warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=5')) + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/a/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'viewstory.php\?sid=\d+')) + i=1 + for a in storyas: + # 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_literotica.py b/fanficdownloader/adapters/adapter_literotica.py new file mode 100644 index 00000000..6fc38fbb --- /dev/null +++ b/fanficdownloader/adapters/adapter_literotica.py @@ -0,0 +1,258 @@ +# -*- 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 urlparse +import time + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class LiteroticaSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["utf8", + "Windows-1252"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + + self.story.setMetadata('siteabbrev','litero') + + # normalize to first chapter. Not sure if they ever have more than 2 digits. + storyId = self.parsedUrl.path.split('/',)[2] + # replace later chapters with first chapter but don't remove numbers + # from the URL that disambiguate stories with the same title. + storyId = re.sub("-ch-?\d\d", "", storyId) + self.story.setMetadata('storyId', storyId) + + ## accept m(mobile)url, but use www. + url = re.sub("^(www|german|spanish|french|dutch|italian|romanian|portuguese|other)\.i", + "\1", + url) + + ## strip ?page=... + url = re.sub("\?page=.*$", "", url) + + ## set url + self._setURL(url) + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = '%m/%d/%y' + + @staticmethod + def getSiteDomain(): + return 'literotica.com' + + @classmethod + def getAcceptDomains(cls): + return ['www.literotica.com', + 'www.i.literotica.com', + 'german.literotica.com', + 'german.i.literotica.com', + 'spanish.literotica.com', + 'spanish.i.literotica.com', + 'french.literotica.com', + 'french.i.literotica.com', + 'dutch.literotica.com', + 'dutch.i.literotica.com', + 'italian.literotica.com', + 'italian.i.literotica.com', + 'romanian.literotica.com', + 'romanian.i.literotica.com', + 'portuguese.literotica.com', + 'portuguese.i.literotica.com', + 'other.literotica.com', + 'other.i.literotica.com'] + + @classmethod + def getSiteExampleURLs(self): + return "http://www.literotica.com/s/story-title https://www.literotica.com/s/story-title http://portuguese.literotica.com/s/story-title http://german.literotica.com/s/story-title" + + def getSiteURLPattern(self): + return r"https?://(www|german|spanish|french|dutch|italian|romanian|portuguese|other)(\.i)?\.literotica\.com/s/([a-zA-Z0-9_-]+)" + + def extractChapterUrlsAndMetadata(self): + """ + NOTE: Some stories can have versions, + e.g. /my-story-ch-05-version-10 + NOTE: If two stories share the same title, a running index is added, + e.g.: /my-story-ch-02-1 + Strategy: + * Go to author's page, search for the current story link, + * If it's in a tr.root-story => One-part story + * , get metadata and be done + * If it's in a tr.sl => Chapter in series + * Search up from there until we find a tr.ser-ttl (this is the + story) + * Gather metadata + * Search down from there for all tr.sl until the next + tr.ser-ttl, foreach + * Chapter link is there + """ + + if not (self.is_adult or self.getConfig("is_adult")): + raise exceptions.AdultCheckRequired(self.url) + + logger.debug("Chapter/Story URL: <%s> " % self.url) + try: + data1 = self._fetchUrl(self.url) + soup1 = bs.BeautifulSoup(data1) + #strip comments from soup + [comment.extract() for comment in soup1.findAll(text=lambda text:isinstance(text, bs.Comment))] + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + + # author + a = soup1.find("span", "b-story-user-y") + self.story.setMetadata('authorId', urlparse.parse_qs(a.a['href'].split('?')[1])['uid'][0]) + authorurl = a.a['href'] + if authorurl.startswith('//'): + authorurl = self.parsedUrl.scheme+':'+authorurl + self.story.setMetadata('authorUrl', authorurl) + self.story.setMetadata('author', a.text) + + # get the author page + try: + dataAuth = self._fetchUrl(authorurl) + soupAuth = bs.BeautifulSoup(dataAuth) + #strip comments from soup + [comment.extract() for comment in soupAuth.findAll(text=lambda text:isinstance(text, bs.Comment))] + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(authorurl) + else: + raise e + + ## Find link to url in author's page + ## site has started using //domain.name/asdf urls remove https?: from front + storyLink = soupAuth.find('a', href=self.url[self.url.index(':')+1:]) + + if storyLink is not None: + urlTr = storyLink.parent.parent + if urlTr['class'] == "sl": + isSingleStory = False + else: + isSingleStory = True + else: + raise exceptions.FailedToDownload("Couldn't find story <%s> on author's page <%s>" % (url, authorurl)) + + if isSingleStory: + self.story.setMetadata('title', storyLink.text) + self.story.setMetadata('description', urlTr.findAll("td")[1].text) + self.story.addToList('eroticatags', urlTr.findAll("td")[2].text) + date = urlTr.findAll('td')[-1].text + self.story.setMetadata('datePublished', makeDate(date, self.dateformat)) + self.story.setMetadata('dateUpdated',makeDate(date, self.dateformat)) + self.chapterUrls = [(storyLink.text, self.url)] + else: + seriesTr = urlTr.previousSibling + while seriesTr['class'] != 'ser-ttl': + seriesTr = seriesTr.previousSibling + m = re.match("^(?P.*?):\s(?P<numChapters>\d+)\sPart\sSeries$", seriesTr.find("strong").text) + self.story.setMetadata('title', m.group('title')) + self.story.setMetadata('numChapters', int(m.group('numChapters'))) + + ## Walk the chapters + chapterTr = seriesTr.nextSibling + self.chapterUrls = [] + dates = [] + descriptions = [] + while chapterTr is not None and chapterTr['class'] == 'sl': + descriptions.append(chapterTr.findAll("td")[1].text) + chapterLink = chapterTr.find("td", "fc").find("a") + self.chapterUrls.append((chapterLink.text, "http:" + chapterLink["href"])) + self.story.addToList('eroticatags', chapterTr.findAll("td")[2].text) + dates.append(makeDate(chapterTr.findAll('td')[-1].text, self.dateformat)) + chapterTr = chapterTr.nextSibling + + ## Set description to joint chapter descriptions + self.story.setMetadata('description', " / ".join(descriptions)) + + ## Set the oldest date as publication date, the newest as update date + dates.sort() + self.story.setMetadata('datePublished', dates[0]) + self.story.setMetadata('dateUpdated', dates[-1]) + + # normalize on first chapter URL. + self._setURL(self.chapterUrls[0][1]) + + # set storyId to 'title-author' to avoid duplicates + # self.story.setMetadata('storyId', + # re.sub("[^a-z0-9]", "", self.story.getMetadata('title').lower()) + # + "-" + # + re.sub("[^a-z0-9]", "", self.story.getMetadata('author').lower())) + + return + + def getChapterText(self, url): + logger.debug('Getting chapter text from <%s>' % url) + # time.sleep(0.5) + data1 = self._fetchUrl(url) + soup1 = bs.BeautifulSoup(data1) + + #strip comments from soup + [comment.extract() for comment in soup1.findAll(text=lambda text:isinstance(text, bs.Comment))] + + # get story text + story1 = soup1.find('div', 'b-story-body-x').p + story1.name='div' + story1.append('<br />') + storytext = self.utf8FromSoup(url,story1) + + # find num pages + pgs = int(soup1.find("span", "b-pager-caption-t r-d45").string.split(' ')[0]) + logger.debug("pages: "+str(pgs)) + + # get all the pages + for i in xrange(2, pgs+1): + try: + logger.debug("fetching page "+str(i)) + time.sleep(0.5) + data2 = self._fetchUrl(url, {'page': i}) + soup2 = bs.BeautifulSoup(data2) + [comment.extract() for comment in soup2.findAll(text=lambda text:isinstance(text, bs.Comment))] + story2 = soup2.find('div', 'b-story-body-x').p + story2.name='div' + story2.append('<br />') + storytext += self.utf8FromSoup(url,story2) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(url) + else: + raise e + return storytext + + +def getClass(): + return LiteroticaSiteAdapter + + + diff --git a/fanficdownloader/adapters/adapter_lumossycophanthexcom.py b/fanficdownloader/adapters/adapter_lumossycophanthexcom.py new file mode 100644 index 00000000..93b8b761 --- /dev/null +++ b/fanficdownloader/adapters/adapter_lumossycophanthexcom.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 LumosSycophantHexComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class LumosSycophantHexComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','lsph') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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 'lumos.sycophanthex.com' + + @classmethod + 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): + + 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=19" + 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 + + # The actual text that is used to announce you need to be an + # adult varies from site to site. Again, print data before + # the title search to troubleshoot. + if "Age Consent Required" in data: + 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 + pt = soup.find('div', {'id' : 'pagetitle'}) + a = pt.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = pt.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) + + rating=pt.text.split('(')[1].split(')')[0] + self.story.setMetadata('rating', rating) + + # 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.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 + + labels = soup.findAll('span',{'class':'label'}) + + value = labels[0].previousSibling + svalue = "" + while value != None: + val = value + value = value.previousSibling + while not defaultGetattr(val,'class') == 'label': + svalue += str(val) + val = val.nextSibling + self.setDescription(url,svalue) + + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Word count' in label: + self.story.setMetadata('numWords', value.split(' -')[0]) + + 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 'Complete' 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.split(' -')[0]), 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'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_mediaminerorg.py b/fanficdownloader/adapters/adapter_mediaminerorg.py new file mode 100644 index 00000000..b9c46414 --- /dev/null +++ b/fanficdownloader/adapters/adapter_mediaminerorg.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib +import urllib2 + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class MediaMinerOrgSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','mm') + 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. + + # get storyId from url--url validation guarantees query correct + m = re.match(self.getSiteURLPattern(),url) + if m: + self.story.setMetadata('storyId',m.group('id')) + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/fanfic/view_st.php/'+self.story.getMetadata('storyId')) + else: + raise exceptions.InvalidStoryURL(url, + self.getSiteDomain(), + self.getSiteExampleURLs()) + + @staticmethod + def getSiteDomain(): + return 'www.mediaminer.org' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/fanfic/view_st.php/123456 http://"+self.getSiteDomain()+"/fanfic/view_ch.php/1234123/123444#fic_c" + + def getSiteURLPattern(self): + ## http://www.mediaminer.org/fanfic/view_st.php/76882 + ## http://www.mediaminer.org/fanfic/view_ch.php/167618/594087#fic_c + return re.escape("http://"+self.getSiteDomain())+\ + "/fanfic/view_(st|ch)\.php/"+r"(?P<id>\d+)(/\d+(#fic_c)?)?$" + + def extractChapterUrlsAndMetadata(self): + + 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 + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + + # [ A - All Readers ], strip '[' ']' + ## Above title because we remove the smtxt font to get title. + smtxt = soup.find("font",{"class":"smtxt"}) + if not smtxt: + raise exceptions.StoryDoesNotExist(self.url) + 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+")) + self.story.setMetadata('authorId',a['href'].split('/')[-1]) + self.story.setMetadata('authorUrl','http://'+self.host+a['href']) + self.story.setMetadata('author',a.string) + + ## Title - Good grief. Title varies by chaptered, 1chapter and 'type=one shot'--and even 'one-shot's can have titled chapter. + ## But, if colspan=2, there's no chapter title. + ## <td class="ffh">Atmosphere: Chapter 1</b> <font class="smtxt">[ P - Pre-Teen ]</font></td> + ## <td colspan=2 class="ffh">Hearts of Ice <font class="smtxt">[ P - Pre-Teen ]</font></td> + ## <td colspan=2 class="ffh">Suzaku no Princess <font class="smtxt">[ P - Pre-Teen ]</font></td> + ## <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_key('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) + ## The story title is difficult to reliably parse from the + ## story pages. Getting it from the author page is, but costs + ## another fetch. + # authsoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + # titlea = authsoup.find('a',{'href':'/fanfic/view_st.php/'+self.story.getMetadata('storyId')}) + # self.story.setMetadata('title',titlea.text) + + # 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) + self.chapterUrls.append((chapter,'http://'+self.host+'/fanfic/view_ch.php/'+self.story.getMetadata('storyId')+'/'+option['value'])) + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + # category + # <a href="/fanfic/src.php/a/567">Ranma 1/2</a> + for a in soup.findAll('a',href=re.compile(r"^/fanfic/src.php/a/")): + self.story.addToList('category',a.string) + + # genre + # <a href="/fanfic/src.php/a/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) + 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')) + + # Words: 123456 + m = re.match(r".*?\| Words: (\d+) \|",metastr) + if m: + self.story.setMetadata('numWords', m.group(1)) + + # Summary: .... + m = re.match(r".*?Summary: (.*)$",metastr) + if m: + self.setDescription(url, m.group(1)) + #self.story.setMetadata('description', m.group(1)) + + # completed + m = re.match(r".*?Status: Completed.*?",metastr) + if m: + self.story.setMetadata('status','Completed') + else: + self.story.setMetadata('status','In-Progress') + + return + + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data=self._fetchUrl(url) + soup = bs.BeautifulStoneSoup(data, + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + anchor = soup.find('a',{'name':'fic_c'}) + + if None == anchor: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + ## find divs with align=left, those are paragraphs in newer stories. + divlist = anchor.findAllNext('div',{'align':'left'}) + if divlist: + for div in divlist: + div.name='p' # convert to <p> mediaminer uses div with + # a margin for paragraphs. + anchor.append(div) # cheat! stuff all the content + # divs into anchor just as a + # holder. + del div['style'] + del div['align'] + anchor.name='div' + return self.utf8FromSoup(url,anchor) + + 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('<a name="fic_c">'):] +"</div>" + soup = bs.BeautifulStoneSoup(data, + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + for tag in soup.findAll('td',{'class':'ffh'}) + \ + soup.findAll('div',{'class':'acl'}) + \ + soup.findAll('div',{'class':'footer smtxt'}) + \ + soup.findAll('table',{'class':'tbbrdr'}): + tag.extract() # remove tag from soup. + + return self.utf8FromSoup(url,soup) + + +def getClass(): + return MediaMinerOrgSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_merlinficdtwinscouk.py b/fanficdownloader/adapters/adapter_merlinficdtwinscouk.py new file mode 100644 index 00000000..91b8d6e5 --- /dev/null +++ b/fanficdownloader/adapters/adapter_merlinficdtwinscouk.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 MerlinFicDtwinsCoUk + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class MerlinFicDtwinsCoUk(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','mrfd') + + # 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 'merlinfic.dtwins.co.uk' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" + 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+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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 'Pairing' in label: + ships = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')) + for ship in ships: + self.story.addToList('ships',ship.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=3')) + 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: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # 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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_midnightwhispersca.py b/fanficdownloader/adapters/adapter_midnightwhispersca.py new file mode 100644 index 00000000..d919772d --- /dev/null +++ b/fanficdownloader/adapters/adapter_midnightwhispersca.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +# Search for XXX comments--that's where things are most likely to need changing. + +# 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 MidnightwhispersCaAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class MidnightwhispersCaAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.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. + # XXX Most sites don't have the /fanfic part. Replace all to remove it usually. + 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','mw') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%B %d, %Y" # XXX + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'www.midnightwhispers.ca' # XXX + + @classmethod + 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): + + 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. + + # Furthermore, there's a couple sites now with more than + # one warning level for different ratings. And they're + # fussy about it. midnightwhispers has three: 10, 3 & 5. + # we'll try 5 first. + addurl = "&ageconsent=ok&warning=5" # XXX + 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 + + # 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. nfacommunity uses + # &warning= -- actually, so do other sites. Must be an + # eFiction book. + + # viewstory.php?sid=1882&warning=4 + # viewstory.php?sid=1654&ageconsent=ok&warning=5 + #print data + #m = re.search(r"'viewstory.php\?sid=1882(&warning=4)'",data) + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) # title's inside a <b> tag. + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + ## Not all sites use Genre, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',genre.string) + + ## Not all sites use Warnings, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Warnings' in label: + warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + warningstext = [warning.string for warning in warnings] + self.warning = ', '.join(warningstext) + for warning in warningstext: + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + # 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._fetchUrl(url) + soup = bs.BeautifulStoneSoup(data, + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + div = soup.find('div', {'id' : 'story'}) + + if None == div: + if "A fatal MySQL error was encountered" in data: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Database error on the site reported!" % url) + else: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_mugglenetcom.py b/fanficdownloader/adapters/adapter_mugglenetcom.py new file mode 100644 index 00000000..31599fbd --- /dev/null +++ b/fanficdownloader/adapters/adapter_mugglenetcom.py @@ -0,0 +1,335 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +# 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 MuggleNetComAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class MuggleNetComAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # 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','mgln') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%m/%d/%y" # XXX + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. + return 'fanfiction.mugglenet.com' + + @classmethod + def getAcceptDomains(cls): + return ['fanfiction.mugglenet.com','fanfic.mugglenet.com'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+r"fanfic(tion)?\.mugglenet\.com"+re.escape("/viewstory.php?sid=")+r"\d+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if "class='errortext'>Registered Users Only" in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login&sid='+self.story.getMetadata('storyId') + 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): + + 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. + # http://fanfiction.mugglenet.com/viewstory.php?sid=91079&ageconsent=ok&warning=3 + addurl = "&ageconsent=ok&warning=3" # XXX &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) + #print("\nurl:%s\ndata:\n%s\n"%(url,data)) + + # 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. nfacommunity uses + # &warning= -- actually, so do other sites. Must be an + # eFiction book. + + # viewstory.php?sid=1882&warning=4 + # viewstory.php?sid=1654&ageconsent=ok&warning=5 + #print data + #m = re.search(r"'viewstory.php\?sid=1882(&warning=4)'",data) + m = re.search(r"'viewstory.php\?sid=%s((?:&ageconsent=ok)?&warning=\d+)'"%self.story.getMetadata('storyId'),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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + + # Not good enough-- content can contain a ('), which ends the content prematurely. + # metadesc = soup.find('meta',{'name':'description'}) + # print("removeAllEntities(metadesc['content']):\n%s\n"%removeAllEntities(metadesc['content'])) + start='<span class="label">Summary: </span>' + end='<span class="label">Rated:</span>' + summarydata = data[data.index(start)+len(start):data.index(end)] + #print("summarydata:\n%s\n"%summarydata) + self.setDescription(url,bs.BeautifulSoup(summarydata)) + + # 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 = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + # not good enough--poorly formated summary html will break it. + # if 'Summary' in label: + # ## Everything until the next span class='label' + # svalue = "" + # while not defaultGetattr(value,'class') == 'label': + # svalue += str(value) + # value = value.nextSibling + # self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + ## Not all sites use Genre, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',genre.string) + + ## Not all sites use Warnings, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Warnings' in label: + warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + warningstext = [warning.string for warning in warnings] + self.warning = ', '.join(warningstext) + for warning in warningstext: + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'viewstory.php\?sid=\d+')) + i=1 + for a in storyas: + # 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_nationallibrarynet.py b/fanficdownloader/adapters/adapter_nationallibrarynet.py new file mode 100644 index 00000000..7e1947db --- /dev/null +++ b/fanficdownloader/adapters/adapter_nationallibrarynet.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 NationalLibraryNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class NationalLibraryNetAdapter(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 storyid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/viewstory.php?storyid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','ntlb') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%m-%d-%y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + return 'national-library.net' + + @classmethod + def getAcceptDomains(cls): + return ['www.national-library.net','national-library.net'] + + @classmethod + def getSiteExampleURLs(self): + # ONLY the stories archived on or after June 17, 2006 and that are hosted on the website: + return "http://"+self.getSiteDomain()+"/viewstory.php?storyid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+"(www\.)?"+re.escape(self.getSiteDomain()+"/viewstory.php?storyid=")+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 + 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.") + + # 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('h1') + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"authorresults.php\?author=\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 p in soup.findAll('p'): + chapters = p.findAll('a', href=re.compile(r'viewstory.php\?storyid='+self.story.getMetadata('storyId')+"&chapnum=\d+$")) + if len(chapters) > 0: + 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'])) + break + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + self.story.setMetadata('status', 'Completed') + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('b') + for x in range(2,len(labels)): + value = labels[x].nextSibling + label = labels[x].string + + if 'Summary' in label: + self.setDescription(url,value) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rating' in label: + self.story.setMetadata('rating', stripHTML(value.nextSibling)) + + if 'Word Count' in label: + self.story.setMetadata('numWords', value.string) + + if 'Category' in label: + for cat in value.string.split(', '): + self.story.addToList('category',cat) + if 'Crossover Shows' in label: + for cat in value.string.split(', '): + if "No Show" not in cat: + self.story.addToList('category',cat) + + if 'Character' in label: + for char in value.string.split(', '): + self.story.addToList('characters',char) + + if 'Pairing' in label: + for char in value.string.split(', '): + self.story.addToList('ships',char) + + if 'Warnings' in label: + for warning in value.string.split(', '): + self.story.addToList('warnings',warning) + + if 'Published' in label: + self.story.setMetadata('datePublished', makeDate(stripHTML(value), self.dateformat)) + + if 'Series' in label: + self.setSeries(stripHTML(value.nextSibling), value.nextSibling.nextSibling.string[2:]) + self.story.setMetadata('seriesUrl','http://'+self.host+'/'+value.nextSibling['href']) + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + story=asoup.find('a', href=re.compile(r'viewstory.php\?storyid='+self.story.getMetadata('storyId'))) + + a=story.findNext(text=re.compile('Genre')).parent.nextSibling.string.split(', ') + for genre in a: + self.story.setMetadata('genre', genre) + + a=story.findNext(text=re.compile('Archived')) + self.story.setMetadata('datePublished', makeDate(stripHTML(a.parent.nextSibling), self.dateformat)) + self.story.setMetadata('dateUpdated', makeDate(stripHTML(a.parent.nextSibling), self.dateformat)) + + # 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') + + # bit messy since higly inconsistent + for p in soup.findAll('p', {'align' : 'center'}): + p.extract() + p = soup.findAll('p') + for x in range(0,3): + p[x].extract() + if "Chapters: " in stripHTML(p[3]): + p[3].extract() + for x in range(len(p)-2,len(p)-1): + p[x].extract() + + for p in soup.findAll('h1'): + p.extract() + for p in soup.findAll('h3'): + p.extract() + for p in soup.findAll('a'): + p.extract() + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_ncisficcom.py b/fanficdownloader/adapters/adapter_ncisficcom.py new file mode 100644 index 00000000..4e6a4482 --- /dev/null +++ b/fanficdownloader/adapters/adapter_ncisficcom.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 NCISFicComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class NCISFicComAdapter(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 storyid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/viewstory.php?storyid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','ncisf') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%m-%d-%y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + return 'ncisfic.com' + + @classmethod + def getAcceptDomains(cls): + return ['www.ncisfic.com','ncisfic.com'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?storyid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+"(www\.)?"+re.escape(self.getSiteDomain()+"/viewstory.php?storyid=")+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 + 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.") + + # 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('h1') + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"authorresults.php\?author=\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 p in soup.findAll('p'): + chapters = p.findAll('a', href=re.compile(r'viewstory.php\?storyid='+self.story.getMetadata('storyId')+"&chapnum=\d+$")) + if len(chapters) > 0: + 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'])) + break + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + self.story.setMetadata('status', 'Completed') + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('b') + for x in range(2,len(labels)): + value = labels[x].nextSibling + label = labels[x].string + + if 'Summary' in label: + self.setDescription(url,value) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rating' in label: + self.story.setMetadata('rating', stripHTML(value.nextSibling)) + + if 'Word Count' in label: + self.story.setMetadata('numWords', value.string) + + if 'Category' in label: + for cat in value.string.split(', '): + self.story.addToList('category',cat) + if 'Crossover Shows' in label: + for cat in value.string.split(', '): + if "No Show" not in cat: + self.story.addToList('category',cat) + + if 'Character' in label: + for char in value.string.split(', '): + self.story.addToList('characters',char) + + if 'Pairing' in label: + for char in value.string.split(', '): + self.story.addToList('ships',char) + + if 'Warnings' in label: + for warning in value.string.split(', '): + self.story.addToList('warnings',warning) + + if 'Published' in label: + self.story.setMetadata('datePublished', makeDate(stripHTML(value), self.dateformat)) + + if 'Series' in label: + if "No Series" not in value.nextSibling.string: + self.setSeries(stripHTML(value.nextSibling), value.nextSibling.nextSibling.string[2:]) + self.story.setMetadata('seriesUrl','http://'+self.host+'/'+value.nextSibling['href']) + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + story=asoup.find('a', href=re.compile(r'viewstory.php\?storyid='+self.story.getMetadata('storyId'))) + + a=story.findNext('font') + if 'Complete' in a.string: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + a=story.findNext(text=re.compile('Genre')).parent.nextSibling.string.split(', ') + for genre in a: + self.story.setMetadata('genre', genre) + + a=story.findNext(text=re.compile('Archived')) + self.story.setMetadata('datePublished', makeDate(stripHTML(a.parent.nextSibling), self.dateformat)) + self.story.setMetadata('dateUpdated', makeDate(stripHTML(a.parent.nextSibling), self.dateformat)) + + # 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') + + # bit messy since higly inconsistent + for p in soup.findAll('p', {'align' : 'center'}): + p.extract() + p = soup.findAll('p') + for x in range(0,3): + p[x].extract() + if "Chapters: " in stripHTML(p[3]): + p[3].extract() + for x in range(len(p)-2,len(p)-1): + p[x].extract() + + for p in soup.findAll('h1'): + p.extract() + for p in soup.findAll('h3'): + p.extract() + for p in soup.findAll('a'): + p.extract() + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_ncisfictionnet.py b/fanficdownloader/adapters/adapter_ncisfictionnet.py new file mode 100644 index 00000000..8fea5a7c --- /dev/null +++ b/fanficdownloader/adapters/adapter_ncisfictionnet.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 NCISFictionNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class NCISFictionNetAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["iso-8859-1", + "Windows-1252"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # normalized story URL. + self._setURL("http://"+self.getSiteDomain()\ + +"/chapters.php?stid="+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','ncisfn') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d/%m/%Y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'www.ncisfiction.net' + + ## Changed from www.ncisfiction.com to www.ncisfiction.net Oct + ## 2012 due to the ncisfiction.com domain expiring. Still accept + ## .com domains for existing updates, etc. + + @classmethod + def getAcceptDomains(cls): + return ['www.ncisfiction.net','www.ncisfiction.com'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/story.php?stid=01234 http://"+self.getSiteDomain()+"/chapters.php?stid=1234" + + def getSiteURLPattern(self): + return r'http://www\.ncisfiction\.(net|com)/(chapters|story)?.php\?stid=\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 + 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.") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulStoneSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + ## Title and author + a = soup.find('div', {'class' : 'main_title'}) + + aut = a.find('a') + self.story.setMetadata('authorId',aut['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+aut['href']) + self.story.setMetadata('author',aut.string) + + aut.extract() + self.story.setMetadata('title',stripHTML(a)[:len(stripHTML(a))-2]) + + # Find the chapters: + i=0 + chapters=soup.findAll('table', {'class' : 'story_table'}) + for chapter in chapters: + ch=chapter.find('a') + # just in case there's tags, like <i> in chapter titles. + self.chapterUrls.append((stripHTML(ch),'http://'+self.host+'/'+ch['href'])) + if i == 0: + self.story.setMetadata('datePublished', makeDate(stripHTML(chapter.find('td')).split('Added: ')[1], self.dateformat)) + if i == len(chapters)-1: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(chapter.find('td')).split('Added: ')[1], self.dateformat)) + i=i+1 + + 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. + + info = soup.find('table', {'class' : 'story_info'}) + + # no convenient way to calculate word count as it is logged differently for stories with and without series + + labels = info.findAll('tr') + for tr in labels: + value = tr.find('td') + label = tr.find('th').string + + if 'Summary' in label: + self.setDescription(url,value) + + if 'Rating' in label: + self.story.setMetadata('rating', value.string) + + if 'Category' in label: + cats = value.findAll('a') + for cat in cats: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = value.findAll('a') + for char in chars: + self.story.addToList('characters',char.string) + + if 'Pairing' in label: + ships = value.findAll('a') + for ship in ships: + self.story.addToList('ships',ship.string) + + if 'Genre' in label: + genres = value.findAll('a') + for genre in genres: + self.story.addToList('genre',genre.string) + + if 'Warnings' in label: + warnings = value.findAll('a') + for warning in warnings: + self.story.addToList('warnings',warning.string) + + if 'Status' in label: + if 'not completed' in value.text: + self.story.setMetadata('status', 'In-Progress') + else: + self.story.setMetadata('status', 'Completed') + + try: + # Find Series name from series URL. + a = soup.find('div',{'class' : 'sub_header'}) + series_name = a.find('a').string + i = a.text.split('#')[1] + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl','http://'+self.host+'/'+a.find('a')['href']) + 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.BeautifulStoneSoup(self._fetchUrl(url), + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + div = soup.find('div', {'class' : 'story_text'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_netraptororg.py b/fanficdownloader/adapters/adapter_netraptororg.py new file mode 100644 index 00000000..524d8070 --- /dev/null +++ b/fanficdownloader/adapters/adapter_netraptororg.py @@ -0,0 +1,212 @@ +# -*- 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 NetRaptorOrgAdapter + +class NetRaptorOrgAdapter(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() + '/fanfiction/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','netrap') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d/%m/%y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'netraptor.org' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/fanfiction/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/fanfiction/viewstory.php?sid=")+r"\d+$" + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + 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.") + + # 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 + pagetitle = soup.find('div',{'id':'pagetitle'}) + a = pagetitle.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # 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+'/fanfiction/'+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+'/fanfiction/'+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 "" + + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')) + 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')) # XXX + 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: + 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+'/fanfiction/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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) diff --git a/fanficdownloader/adapters/adapter_nfacommunitycom.py b/fanficdownloader/adapters/adapter_nfacommunitycom.py new file mode 100644 index 00000000..98aa2155 --- /dev/null +++ b/fanficdownloader/adapters/adapter_nfacommunitycom.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +# Search for XXX comments--that's where things are most likely to need changing. + +# 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 NfaCommunityComAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class NfaCommunityComAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.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. + # XXX Most sites don't have the /fanfic part. Replace all to remove it usually. + 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','nfa') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%m/%d/%Y" # XXX + + @classmethod + def getAcceptDomains(cls): + return ['www.nfacommunity.com','nfacommunity.com'] + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'nfacommunity.com' # XXX + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return "http://(www.)?"+re.escape(self.getSiteDomain()+"/viewstory.php?sid=")+r"\d+$" + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + # 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. + + # Furthermore, there's a couple sites now with more than + # one warning level for different ratings. And they're + # fussy about it. nfacommunity has two: 4 & 5. + # we'll try 5 first. + addurl = "&ageconsent=ok&warning=5" # XXX + 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 + + # 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. nfacommunity uses + # &warning= -- actually, so do other sites. Must be an + # eFiction book. + + # viewstory.php?sid=1882&warning=4 + # viewstory.php?sid=1654&ageconsent=ok&warning=5 + #print data + #m = re.search(r"'viewstory.php\?sid=1882(&warning=4)'",data) + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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) + + # 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')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + ## Not all sites use Genre, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',genre.string) + + ## Not all sites use Warnings, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Warnings' in label: + warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + warningstext = [warning.string for warning in warnings] + self.warning = ', '.join(warningstext) + for warning in warningstext: + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'viewstory.php\?sid=\d+')) + i=1 + for a in storyas: + # 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_nhamagicalworldsus.py b/fanficdownloader/adapters/adapter_nhamagicalworldsus.py new file mode 100644 index 00000000..91fc965f --- /dev/null +++ b/fanficdownloader/adapters/adapter_nhamagicalworldsus.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 NHAMagicalWorldsUsAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class NHAMagicalWorldsUsAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','nha') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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 'nha.magical-worlds.us' + + @classmethod + 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): + 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 "Access denied. This story has not been validated by the adminstrators of this site." in data: + raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + self.story.setMetadata('author',a.string) + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + try: + # in case link points somewhere other than the first chapter + a = soup.findAll('option')[1]['value'] + self.story.setMetadata('storyId',a.split('=',)[1]) + url = 'http://'+self.host+'/'+a + soup = bs.BeautifulSoup(self._fetchUrl(url)) + except: + pass + + for info in asoup.findAll('table', {'width' : '100%', 'bordercolor' : re.compile(r'#')}): + a = info.find('a') + if 'viewstory.php?sid='+self.story.getMetadata('storyId') == a['href'] or \ + ('viewstory.php?sid='+self.story.getMetadata('storyId')+'&') in a['href']: + self.story.setMetadata('title',stripHTML(a)) + break + + + # Find the chapters: + chapters=soup.findAll('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+'&chapter=\d+$')) + if len(chapters) == 0: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + 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)) + + # 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): + try: + return d.name + except: + return "" + + cats = info.findAll('a',href=re.compile('categories.php')) + for cat in cats: + self.story.addToList('category',cat.string) + + a = info.find('a', href=re.compile(r'viewuser.php')) + val = a.nextSibling + svalue = "" + while not defaultGetattr(val) == 'br': + val = val.nextSibling + val = val.nextSibling + while not defaultGetattr(val) == 'br': + svalue += unicode(val) + val = val.nextSibling + self.setDescription(url,svalue) + + #does not provide convenient way to get word count + labels = info.findAll('i') + for labelspan in labels: + value = labelspan.nextSibling + label = stripHTML(labelspan) + + if 'Rating' in label: + self.story.setMetadata('rating', value.split(' -')[0]) + + if 'Genres' in label: + genres = value.string.split(', ') + for genre in genres: + if 'None' not in genre: + self.story.addToList('genre',genre.split(' -')[0]) + + if 'Characters' in label: + chars = value.string.split(', ') + for char in chars: + if 'None' not in char: + self.story.addToList('characters',char.split(' -')[0]) + + if 'Warnings' in label: + warnings = value.string.split(', ') + for warning in warnings: + if 'None' not in warning: + self.story.addToList('warnings',warning.split(' -')[0]) + + 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(value.split(' -')[0], self.dateformat)) + + if 'Updated' in label: + # there's a stray [ at the end. + #value = value[0:-1] + self.story.setMetadata('dateUpdated', makeDate(value.split(' -')[0], self.dateformat)) + + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._fetchUrl(url) + + soup = bs.BeautifulSoup(data, selfClosingTags=('br','hr','span','center')) # some chapters seem to be hanging up on those tags, so it is safer to close them + + story = soup.find('div', {"id" : "story"}) + + if None == story: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,story) diff --git a/fanficdownloader/adapters/adapter_nickandgregnet.py b/fanficdownloader/adapters/adapter_nickandgregnet.py new file mode 100644 index 00000000..37cac4df --- /dev/null +++ b/fanficdownloader/adapters/adapter_nickandgregnet.py @@ -0,0 +1,176 @@ +# -*- 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 NickAndGregNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class NickAndGregNetAdapter(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. + # XXX Most sites don't have the /fanfic part. Replace all to remove it usually. + self._setURL('http://' + self.getSiteDomain() + '/desert_archive/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','nag') + + # 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.nickandgreg.net' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/desert_archive/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/desert_archive/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+'&i=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.") + + # 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')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/desert_archive/'+a['href']) + self.story.setMetadata('author',a.string) + + # Find the chapters: + chapters = soup.find('select') + for chapter in chapters.findAll('option'): + if chapter.text != 'Story Index' and chapter.text != 'Chapters': + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/desert_archive/'+chapter['value'])) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + for div in asoup.findAll('td', {'class' : 'tblborder6'}): + a = div.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + if a != None: + break + + self.setDescription(url,div.find('br').nextSibling) + + a=div.text.split('Rating:') + if len(a) == 2: self.story.setMetadata('rating', a[1].split(' -')[0]) + + a=div.text.split('Characters:') + if len(a) == 2: + for char in a[1].split(' -')[0].split(', '): + self.story.addToList('characters',char) + + a=div.text.split('Genres:') + if len(a) == 2: + for genre in a[1].split(' -')[0].split(', '): + self.story.addToList('genre',genre) + + a=div.text.split('Warnings:') + if len(a) == 2: + for warn in a[1].split(' -')[0].split(', '): + if 'none' not in warn: + self.story.addToList('warnings',warn) + + a=div.text.split('Completed:') + if len(a) ==2: + if 'Yes' in a[1]: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + a=div.text.split('Published:') + if len(a) == 2: self.story.setMetadata('datePublished', makeDate(stripHTML(a[1].split(' -')[0]), self.dateformat)) + + a=div.text.split('Updated:') + if len(a) == 2: self.story.setMetadata('dateUpdated', makeDate(stripHTML(a[1].split(' -')[0]), self.dateformat)) + + + # 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. + + # wrap a div around it. + divsoup = bs.BeautifulStoneSoup('<div class="story"></div>', + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + div = divsoup.find('div') + div.append(soup.find('table', {'class' : 'tblborder6'})) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_nocturnallightnet.py b/fanficdownloader/adapters/adapter_nocturnallightnet.py new file mode 100644 index 00000000..214e38b8 --- /dev/null +++ b/fanficdownloader/adapters/adapter_nocturnallightnet.py @@ -0,0 +1,177 @@ +import re +import urllib2 +import urlparse + +from .. import BeautifulSoup + +from base_adapter import BaseSiteAdapter, makeDate +from .. import exceptions + + +def getClass(): + return NocturnalLightNetAdapter + + +# yields Tag _and_ NavigableString siblings from the given tag. The +# BeautifulSoup findNextSiblings() method for some reasons only returns either +# NavigableStrings _or_ Tag objects, not both. +def _yield_next_siblings(tag): + sibling = tag.nextSibling + while sibling: + yield sibling + sibling = sibling.nextSibling + + +class NocturnalLightNetAdapter(BaseSiteAdapter): + SITE_ABBREVIATION = 'nln' + SITE_DOMAIN = 'nocturnal-light.net' + BASE_URL = 'http://' + SITE_DOMAIN + '/fanfiction/' + STORY_URL_TEMPLATE = BASE_URL + 'story/%s' + AUTHORS_URL_TEMPLATE = BASE_URL + 'authors/%s' + + DATETIME_FORMAT = '%m-%d-%y' + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + url_tokens = self.parsedUrl.path.split('/') + story_id = url_tokens[url_tokens.index('story') + 1] + + self.story.setMetadata('storyId', story_id) + self._setURL(self.STORY_URL_TEMPLATE % story_id) + self.story.setMetadata('siteabbrev', self.SITE_ABBREVIATION) + + def _customized_fetch_url(self, url, exception=None, parameters=None): + if exception: + try: + data = self._fetchUrl(url, parameters) + except urllib2.HTTPError: + raise exception(self.url) + # Just let self._fetchUrl throw the exception, don't catch and + # customize it. + else: + data = self._fetchUrl(url, parameters) + + return BeautifulSoup.BeautifulSoup(data) + + @staticmethod + def getSiteDomain(): + return NocturnalLightNetAdapter.SITE_DOMAIN + + @classmethod + def getSiteExampleURLs(cls): + return cls.STORY_URL_TEMPLATE % 1234 + + def getSiteURLPattern(self): + return re.escape(self.STORY_URL_TEMPLATE[:-2]) + r'\d+.*$' + + def extractChapterUrlsAndMetadata(self): + soup = self._customized_fetch_url(self.url) + + # Since no 404 error code we have to raise the exception ourselves. + # A title that is just 'by' indicates that there is no author name + # and no story title available. + if soup.title.string.strip() == 'by': + raise exceptions.StoryDoesNotExist(self.url) + + # "storycontent" is found in a single-chapter story + author_anchor = soup.find('div', id=lambda id: id in ('main', 'storycontent')).h1.a + self.story.setMetadata('author', author_anchor.string) + + url_tokens = author_anchor['href'].split('/') + author_id = url_tokens[url_tokens.index('authors')+1] + self.story.setMetadata('authorId', author_id) + self.story.setMetadata('authorUrl', self.AUTHORS_URL_TEMPLATE % author_id) + + chapter_anchors = soup('a', href=lambda href: href and href.startswith('/fanfiction/story/')) + for chapter_anchor in chapter_anchors: + url = urlparse.urljoin(self.BASE_URL, chapter_anchor['href']) + self.chapterUrls.append((chapter_anchor.string, url)) + + author_url = urlparse.urljoin(self.BASE_URL, author_anchor['href']) + soup = self._customized_fetch_url(author_url) + story_id = self.story.getMetadata('storyId') + for listbox in soup('div', {'class': 'listbox'}): + url_tokens = listbox.a['href'].split('/') + # Found the div containing the story's metadata; break the loop and + # parse the element + if story_id == url_tokens[url_tokens.index('story')+1]: + break + else: + raise exceptions.FailedToDownload(self.url) + + title = listbox.a.string + self.story.setMetadata('title', title) + + # No chapter anchors found in the original story URL, so the story has + # only a single chapter. + if not chapter_anchors: + self.chapterUrls.append((title, self.url)) + + for b_tag in listbox('b'): + key = b_tag.string.strip(':') + try: + value = b_tag.nextSibling.string.replace('•', '').strip(': ') + # This can happen with some fancy markup in the summary. Just + # ignore this error and set value to None, the summary parsing + # takes care of this + except AttributeError: + value = None + + if key == 'Summary': + contents = [] + keep_summary_html = self.getConfig('keep_summary_html') + + for sibling in _yield_next_siblings(b_tag): + if isinstance(sibling, BeautifulSoup.Tag): + if sibling.name == 'b' and sibling.findPreviousSibling().name == 'br': + break + + if keep_summary_html: + contents.append(self.utf8FromSoup(author_url, sibling)) + else: + contents.append(''.join(sibling(text=True))) + else: + contents.append(sibling) + + # Pop last break line tag + contents.pop() + self.story.setMetadata('description', ''.join(contents)) + + elif key == 'Category': + for sibling in b_tag.findNextSiblings(['a', 'b']): + if sibling.name == 'b': + break + + self.story.addToList('category', sibling.string) + + elif key == 'Rating': + self.story.setMetadata('rating', value) + + elif key == 'Chapters': + self.story.setMetadata('numChapters', int(value)) + + # Also parse reviews number which lies right after the chapters + # section + reviews_anchor = b_tag.findNextSibling('a') + reviews = reviews_anchor.string.split(' ')[1].strip('()') + self.story.setMetadata('reviews', reviews) + + elif key == 'Completed': + self.story.setMetadata('status', 'Completed' if value == 'Yes' else 'In-Progress') + + elif key == 'Date Added': + self.story.setMetadata('datePublished', makeDate(value, self.DATETIME_FORMAT)) + + elif key == 'Last Updated': + self.story.setMetadata('dateUpdated', makeDate(value, self.DATETIME_FORMAT)) + + elif key == 'Read': + self.story.setMetadata('readings', value.split()[0]) + + if self.story.getMetadata('rating') == 'NC-17' and not (self.is_adult or self.getConfig('is_adult')): + raise exceptions.AdultCheckRequired(self.url) + + def getChapterText(self, url): + soup = self._customized_fetch_url(url) + return self.utf8FromSoup(url, soup.find('div', id='storytext')) diff --git a/fanficdownloader/adapters/adapter_occlumencysycophanthexcom.py b/fanficdownloader/adapters/adapter_occlumencysycophanthexcom.py new file mode 100644 index 00000000..475c87ca --- /dev/null +++ b/fanficdownloader/adapters/adapter_occlumencysycophanthexcom.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 OcclumencySycophantHexComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class OcclumencySycophantHexComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','osph') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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 'occlumency.sycophanthex.com' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'This story contains adult content and/or themes.' 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['rememberme'] = '1' + params['sid'] = '' + params['intent'] = '' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php' + logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl, + params['penname'])) + + d = self._fetchUrl(loginUrl, params) + + if "Logout" 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): + # 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 "Access denied. This story has not been validated by the adminstrators of this site." in data: + raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + self.story.setMetadata('author',a.string) + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + try: + # in case link points somewhere other than the first chapter + a = soup.findAll('option')[1]['value'] + self.story.setMetadata('storyId',a.split('=',)[1]) + url = 'http://'+self.host+'/'+a + soup = bs.BeautifulSoup(self._fetchUrl(url)) + except: + pass + + for info in asoup.findAll('table', {'class' : 'border'}): + a = info.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + if a != None: + self.story.setMetadata('title',stripHTML(a)) + break + + + # Find the chapters: + chapters=soup.findAll('a', href=re.compile(r'viewstory.php\?sid=\d+&i=1$')) + if len(chapters) == 0: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + 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)) + + # 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): + try: + return d.name + except: + return "" + + cats = info.findAll('a',href=re.compile('categories.php')) + for cat in cats: + self.story.addToList('category',cat.string) + + + a = info.find('a', href=re.compile(r'reviews.php\?sid='+self.story.getMetadata('storyId'))) + val = a.nextSibling + svalue = "" + while not defaultGetattr(val) == 'br': + val = val.nextSibling + val = val.nextSibling + while not defaultGetattr(val) == 'table': + svalue += str(val) + val = val.nextSibling + self.setDescription(url,svalue) + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = info.findAll('b') + for labelspan in labels: + value = labelspan.nextSibling + label = stripHTML(labelspan) + + if 'Rating' in label: + self.story.setMetadata('rating', value) + + if 'Word Count' in label: + self.story.setMetadata('numWords', value) + + if 'Genres' in label: + genres = value.string.split(', ') + for genre in genres: + if genre != 'none': + self.story.addToList('genre',genre) + + if 'Characters' in label: + chars = value.string.split(', ') + for char in chars: + if char != 'none': + self.story.addToList('characters',char) + + if 'Warnings' in label: + warnings = value.string.split(', ') + for warning in warnings: + if warning != ' none': + self.story.addToList('warnings',warning) + + 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)) + + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._fetchUrl(url) + data = data.replace('<div align="left"', '<div align="left">') + + soup = bs.BeautifulSoup(data, selfClosingTags=('br','hr')) + + story = soup.find('div', {"align" : "left"}) + + if None == story: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,story) diff --git a/fanficdownloader/adapters/adapter_onedirectionfanfictioncom.py b/fanficdownloader/adapters/adapter_onedirectionfanfictioncom.py new file mode 100644 index 00000000..42547ed4 --- /dev/null +++ b/fanficdownloader/adapters/adapter_onedirectionfanfictioncom.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 OneDirectionFanfictionComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class OneDirectionFanfictionComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','odf') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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 'onedirectionfanfiction.com' + + @classmethod + def getAcceptDomains(cls): + return ['www.onedirectionfanfiction.com','onedirectionfanfiction.com'] + + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+"(www\.)?"+re.escape(self.getSiteDomain()+"/viewstory.php?sid=")+r"\d+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" + 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) + + # The actual text that is used to announce you need to be an + # adult varies from site to site. Again, print data before + # the title search to troubleshoot. + if "Age Consent Required" in data: + 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) + + # 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')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while value and not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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 'Warnings' in label: + warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=6')) + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_phoenixsongnet.py b/fanficdownloader/adapters/adapter_phoenixsongnet.py new file mode 100644 index 00000000..f1da893a --- /dev/null +++ b/fanficdownloader/adapters/adapter_phoenixsongnet.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib2, urllib, cookielib + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +def getClass(): + return PhoenixSongNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class PhoenixSongNetAdapter(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.path.split('/',)[3]) + + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/fanfiction/story/' +self.story.getMetadata('storyId')+'/') + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','phs') + + # 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 'www.phoenixsong.net' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/fanfiction/story/1234/" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/fanfiction/story/")+r"\d+/?$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Please login to continue.' in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['txtusername'] = self.username + params['txtpassword'] = self.password + else: + params['txtusername'] = self.getConfig("username") + params['txtpassword'] = self.getConfig("password") + #params['remember'] = '1' + params['login'] = 'Login' + + loginUrl = 'http://' + self.getSiteDomain() + '/users/processlogin.php' + logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl, + params['txtusername'])) + d = self._fetchUrl(loginUrl, params) + + if 'Please login to continue.' in d : #Member Account + logger.info("Failed to login to URL %s as %s" % (loginUrl, + params['txtusername'])) + raise exceptions.FailedToLogin(url,params['txtusername']) + 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: + if self.getConfig('force_login'): + self.performLogin(url) + 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) + + # 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 + b = soup.find('div', {'id' : 'nav25'}) + a = b.find('a', href=re.compile(r'fanfiction/story/'+self.story.getMetadata('storyId')+"/$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. /fanfiction/stories.php?psid=125 + a = b.find('a', href=re.compile(r"/fanfiction/stories.php\?psid=\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: + chapters = soup.find('select') + if chapters == None: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + for b in soup.findAll('b'): + if b.text == "Updated": + date = b.nextSibling.string.split(': ')[1].split(',') + self.story.setMetadata('datePublished', makeDate(date[0]+date[1], self.dateformat)) + self.story.setMetadata('dateUpdated', makeDate(date[0]+date[1], self.dateformat)) + else: + i = 0 + chapters = chapters.findAll('option') + for chapter in chapters: + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+chapter['value'])) + if i == 0: + self.story.setMetadata('storyId',chapter['value'].split('/')[3]) + head = bs.BeautifulSoup(self._fetchUrl('http://'+self.host+chapter['value'])).findAll('b') + for b in head: + if b.text == "Updated": + date = b.nextSibling.string.split(': ')[1].split(',') + self.story.setMetadata('datePublished', makeDate(date[0]+date[1], self.dateformat)) + + if i == (len(chapters)-1): + head = bs.BeautifulSoup(self._fetchUrl('http://'+self.host+chapter['value'])).findAll('b') + for b in head: + if b.text == "Updated": + date = b.nextSibling.string.split(': ')[1].split(',') + self.story.setMetadata('dateUpdated', makeDate(date[0]+date[1], self.dateformat)) + i = i+1 + + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + info = asoup.find('a', href=re.compile(r'fanfiction/story/'+self.story.getMetadata('storyId')+"/$")) + while info != None: + info = info.findNext('div') + b = info.find('b') + val = b.nextSibling + + if 'Rating' in b.string: + self.story.setMetadata('rating', val.string.split(': ')[1]) + + if 'Words' in b.string: + self.story.setMetadata('numWords', val.string.split(': ')[1]) + + if 'Setting' in b.string: + self.story.addToList('category', val.string.split(': ')[1]) + + if 'Status' in b.string: + if 'Completed' in val: + val = 'Completed' + else: + val = 'In-Progress' + self.story.setMetadata('status', val) + + if 'Summary' in b.string: + b.extract() + info.find('br').extract() + self.setDescription(url,info) + break + + + # 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. + + chapter=bs.BeautifulSoup('<div class="story"></div>') + for p in soup.findAll('p'): + if "This is for problems with the formatting or the layout of the chapter." in stripHTML(p): + break + chapter.append(p) + + for a in chapter.findAll('div'): + a.extract() + for a in chapter.findAll('table'): + a.extract() + for a in chapter.findAll('script'): + a.extract() + for a in chapter.findAll('form'): + a.extract() + for a in chapter.findAll('textarea'): + a.extract() + + + if None == chapter: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,chapter) diff --git a/fanficdownloader/adapters/adapter_pommedesangcom.py b/fanficdownloader/adapters/adapter_pommedesangcom.py new file mode 100644 index 00000000..ffa7f8fa --- /dev/null +++ b/fanficdownloader/adapters/adapter_pommedesangcom.py @@ -0,0 +1,300 @@ +# -*- 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 PommeDeSangComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class PommeDeSangComAdapter(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]) + + + # 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() + '/'+self.section+'/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','pmds') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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 'pommedesang.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/efiction/viewstory.php?sid=1234 http://"+self.getSiteDomain()+"/sds/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + 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): + + 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+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile('viewstory.php\?sid=\d+')) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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+'/'+self.section+'/'+chapter['href']+addurl)) + + 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 "" + +# 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'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) # 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=2')) # XXX + 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: + 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+'/'+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('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 ('viewstory.php?sid='+self.story.getMetadata('storyId')) in a['href']: + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_ponyfictionarchivenet.py b/fanficdownloader/adapters/adapter_ponyfictionarchivenet.py new file mode 100644 index 00000000..b08b542f --- /dev/null +++ b/fanficdownloader/adapters/adapter_ponyfictionarchivenet.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 PonyFictionArchiveNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class PonyFictionArchiveNetAdapter(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. + if "explicit" in self.parsedUrl.netloc: + self._setURL('http://explicit.' + self.getSiteDomain() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + self.dateformat = "%d/%b/%y" + else: + self._setURL('http://' + self.getSiteDomain() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + self.dateformat = "%d %b %Y" + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','pffa') + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'ponyfictionarchive.net' + + @classmethod + def getAcceptDomains(cls): + return ['www.ponyfictionarchive.net','ponyfictionarchive.net','explicit.ponyfictionarchive.net'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234 http://explicit."+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+"(www\.|explicit\.)?"+re.escape(self.getSiteDomain()+"/viewstory.php?sid=")+r"\d+$" + + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + # 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 = "&warning=9" + 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 + + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + 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 "" + + genres = soup.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')) + for genre in genres: + self.story.addToList('genre',genre.string) + + warnings = soup.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=3')) + for warning in warnings: + self.story.addToList('warnings',warning.string) + + status = soup.find('a',href=re.compile(r'browse.php\?type=class&type_id=2')) + self.story.setMetadata('status',status.string) + + section = soup.findAll('span', {'class' : 'General'})[1] + + self.story.setMetadata('rating', section.previousSibling.previousSibling.string) + + value = section.nextSibling + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Word count' in label: + self.story.setMetadata('numWords', value) + + 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: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # 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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + soup = bs.BeautifulSoup(self._fetchUrl(url)) # 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) diff --git a/fanficdownloader/adapters/adapter_portkeyorg.py b/fanficdownloader/adapters/adapter_portkeyorg.py new file mode 100644 index 00000000..ba735214 --- /dev/null +++ b/fanficdownloader/adapters/adapter_portkeyorg.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib2 +import cookielib as cl + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +# Search for XXX comments--that's where things are most likely to need changing. + +# 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 PortkeyOrgAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class PortkeyOrgAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.path.split('/',)[2]) + + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/story/'+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','prtky') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d/%m/%y" # XXX + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'fanfiction.portkey.org' # XXX + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/story/1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/story/")+r"\d+(/\d+)?$" + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + url = self.url + logger.debug("URL: "+url) + + # portkey screws around with using a different URL to set the + # cookie and it's a pain. So... cheat! + if self.is_adult or self.getConfig("is_adult"): + cookieproc = urllib2.HTTPCookieProcessor() + cookie = cl.Cookie(version=0, name='verify17', value='1', + port=None, port_specified=False, + domain=self.getSiteDomain(), domain_specified=False, domain_initial_dot=False, + path='/', path_specified=True, + secure=False, + expires=time.time()+10000, + discard=False, + comment=None, + comment_url=None, + rest={'HttpOnly': None}, + rfc2109=False) + cookieproc.cookiejar.set_cookie(cookie) + self.opener = urllib2.build_opener(cookieproc) + + try: + data = self._fetchUrl(url) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + + if "You must be over 18 years of age to view it" in data: # XXX + raise exceptions.AdultCheckRequired(self.url) + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + #print data + + # Now go hunting for all the meta data and the chapter list. + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"/profile/\d+")) + #print("======a:%s"%a) + self.story.setMetadata('authorId',a['href'].split('/')[-1]) + self.story.setMetadata('authorUrl','http://'+self.host+a['href']) + self.story.setMetadata('author',a.string) + + ## Going to get the rest from the author page. + authsoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + ## Title + titlea = authsoup.find('a', href=re.compile(r'/story/'+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(titlea)) + metablock = titlea.parent + + # Find the chapters: + for chapter in soup.find('select',{'name':'select5'}).findAll('option', {'value':re.compile(r'/story/'+self.story.getMetadata('storyId')+"/\d+$")}): + # just in case there's tags, like <i> in chapter titles. + chtitle = stripHTML(chapter) + if not chtitle: + chtitle = "(Untitled Chapter)" + self.chapterUrls.append((chtitle,'http://'+self.host+chapter['value'])) + + if len(self.chapterUrls) == 0: + self.chapterUrls.append((stripHTML(self.story.getMetadata('title')),url)) + + 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="dark-small-bold">Contents:</SPAN> <SPAN class="small-grey">NC17 </SPAN> + # <SPAN class="dark-small-bold">Published: </SPAN><SPAN class="small-grey">12/11/07</SPAN> + # <SPAN class="dark-small-bold"><BR> + # Description:</SPAN> <SPAN class="small-black">A special book helps Harry tap into the power the Dark Lord knows not. Of course it’s a book on sex magic and rituals… but Harry’s not complaining. Spurned on by the ghost of a pervert founder, Harry leads his friends in the hunt for Voldemort’s Horcruxes. + # EROTIC COMEDY! Loads of crude humor and sexual situations! + # </SPAN> + labels = metablock.findAll('span',{'class':'dark-small-bold'}) + for labelspan in labels: + value = labelspan.findNext('span').string + label = stripHTML(labelspan) +# print("\nlabel:%s\nlabel:%s\nvalue:%s\n"%(labelspan,label,value)) + + if 'Description' in label: + self.setDescription(url,value) + + if 'Contents' in label: + self.story.setMetadata('rating', value) + + if 'Words' in label: + self.story.setMetadata('numWords', value) + + # if 'Categories' in label: + # cats = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=categories')) + # catstext = [cat.string for cat in cats] + # for cat in catstext: + # self.story.addToList('category',cat.string) + + # if 'Characters' in label: + # chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + # charstext = [char.string for char in chars] + # for char in charstext: + # self.story.addToList('characters',char.string) + + if 'Genre' in label: + # genre is typo'ed on the site--it falls between the + # dark-small-bold label and dark-small-bold content + # spans. + svalue = "" + value = labelspan.nextSibling + while not defaultGetattr(value,'class') == 'dark-small-bold': + svalue += str(value) + value = value.nextSibling + + for genre in svalue.split("/"): + genre = genre.strip() + if genre != 'None': + self.story.addToList('genre',genre) + + ## Not all sites use Warnings, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + # if 'Warnings' in label: + # warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + # warningstext = [warning.string for warning in warnings] + # self.warning = ', '.join(warningstext) + # for warning in warningstext: + # self.story.addToList('warnings',warning.string) + + if 'Status' in label: + if 'Completed' 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: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + # try: + # # Find Series name from series URL. + # a = metablock.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + # series_name = a.string + # series_url = 'http://'+self.host+'/'+a['href'] + + # # use BeautifulSoup HTML parser to make everything easier to find. + # seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + # i=1 + # for a in storyas: + # if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + # self.setSeries(series_name, i) + # break + # i+=1 + # except: + # # I find it hard to care if the series parsing fails + # pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + data = self._fetchUrl(url) + + data = data.replace("HTML>","div>") + + soup = bs.BeautifulSoup(data) + + #print("soup:%s"%soup) + tag = soup.find('td', {'class' : 'story'}) + if tag == None and "<center><b>Chapter does not exist!</b></center>" in data: + print("Chapter is missing at: %s"%url) + return self.utf8FromSoup(url,bs.BeautifulStoneSoup("<div><p><center><b>Chapter does not exist!</b></center></p><p>Chapter is missing at: <a href='%s'>%s</a></p></div>"%(url,url))) + tag.name='div' # force to be a div to avoid problems with nook. + + centers = tag.findAll('center') + # first two and last two center tags are some script, 'report + # story', 'report story' and an ad. + centers[0].extract() + centers[1].extract() + centers[-1].extract() + centers[-2].extract() + + if None == tag: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,tag) diff --git a/fanficdownloader/adapters/adapter_potionsandsnitchesnet.py b/fanficdownloader/adapters/adapter_potionsandsnitchesnet.py new file mode 100644 index 00000000..c1e2214c --- /dev/null +++ b/fanficdownloader/adapters/adapter_potionsandsnitchesnet.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib +import urllib2 + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class PotionsAndSnitchesNetSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','pns') + 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. + + # 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() + '/fanfiction/viewstory.php?sid='+self.story.getMetadata('storyId')) + + + @staticmethod + def getSiteDomain(): + return 'www.potionsandsnitches.net' + + @classmethod + def getAcceptDomains(cls): + return ['www.potionsandsnitches.net','potionsandsnitches.net'] + + @classmethod + def getSiteExampleURLs(self): + return "http://www.potionsandsnitches.net/fanfiction/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+r"(www\.)?"+re.escape("potionsandsnitches.net/fanfiction/viewstory.php?sid=")+r"\d+$" + + def extractChapterUrlsAndMetadata(self): + + 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.") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + + ## Title + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/fanfiction/'+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+'/fanfiction/'+chapter['href'])) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + ## <meta name='description' content='<p>Description</p> ...' > + ## Summary, strangely, is in the content attr of a <meta name='description'> tag + ## which is escaped HTML. Unfortunately, we can't use it because they don't + ## escape (') chars in the desc, breakin the tag. + #meta_desc = soup.find('meta',{'name':'description'}) + #metasoup = bs.BeautifulStoneSoup(meta_desc['content']) + #self.story.setMetadata('description',stripHTML(metasoup)) + + def defaultGetattr(d,k): + try: + return d[k] + except: + return "" + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next div class='listbox' + svalue = "" + while not defaultGetattr(value,'class') == 'listbox': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + if "Snape and Harry (required)" in char: + self.story.addToList('characters',"Snape") + self.story.addToList('characters',"Harry") + else: + self.story.addToList('characters',char.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class')) + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',genre.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: + # limit date values, there's some extra chars. + self.story.setMetadata('datePublished', makeDate(stripHTML(value[:12]), "%d %b %Y")) + + if 'Updated' in label: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value[:12]), "%d %b %Y")) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/fanfiction/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) + +def getClass(): + return PotionsAndSnitchesNetSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_potterficscom.py b/fanficdownloader/adapters/adapter_potterficscom.py new file mode 100644 index 00000000..84b9d229 --- /dev/null +++ b/fanficdownloader/adapters/adapter_potterficscom.py @@ -0,0 +1,243 @@ +# -*- 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')) + + # 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()) + + + # 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' + + @classmethod + def getSiteExampleURLs(self): + return "http://www.potterfics.com/historias/12345 http://www.potterfics.com/historias/12345/capitulo-1 " + + 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', stripHTML(mb.b)) + #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) diff --git a/fanficdownloader/adapters/adapter_potterheadsanonymouscom.py b/fanficdownloader/adapters/adapter_potterheadsanonymouscom.py new file mode 100644 index 00000000..7fc36d80 --- /dev/null +++ b/fanficdownloader/adapters/adapter_potterheadsanonymouscom.py @@ -0,0 +1,298 @@ +# -*- 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 PotterHeadsAnonymousComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class PotterHeadsAnonymousComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','pha') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d %b %Y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'fanfic.potterheadsanonymous.com' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" + 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) + + # Since the warning text can change by warning level, let's + # look for the warning pass url. ksarchive uses + # &warning= -- actually, so do other sites. Must be an + # eFiction book. + + # viewstory.php?sid=1882&warning=4 + # viewstory.php?sid=1654&ageconsent=ok&warning=5 + #print data + #m = re.search(r"'viewstory.php\?sid=1882(&warning=4)'",data) + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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. + + 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',stripHTML(a)) + + # 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']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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=3')) + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_pretendercentrecom.py b/fanficdownloader/adapters/adapter_pretendercentrecom.py new file mode 100644 index 00000000..4097eccc --- /dev/null +++ b/fanficdownloader/adapters/adapter_pretendercentrecom.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 PretenderCenterComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class PretenderCenterComAdapter(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() + '/missingpieces/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','ptdc') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d/%m/%y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'pretendercentre.com' + + @classmethod + def getAcceptDomains(cls): + return ['www.pretendercentre.com','pretendercentre.com'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/missingpieces/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+"(www\.)?"+re.escape(self.getSiteDomain()+"/missingpieces/viewstory.php?sid=")+r"\d+$" + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" + 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 + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/missingpieces/'+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+'/missingpieces/'+chapter['href']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) # 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=2')) # XXX + 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: + 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+'/missingpieces/'+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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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.findAll('div', {'id' : 'story'})[1] + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_psychficcom.py b/fanficdownloader/adapters/adapter_psychficcom.py new file mode 100644 index 00000000..bb69eea6 --- /dev/null +++ b/fanficdownloader/adapters/adapter_psychficcom.py @@ -0,0 +1,248 @@ +# -*- 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 PsychFicComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class PsychFicComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','psyf') + + # 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 'www.psychfic.com' + + @classmethod + 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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" + 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 + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.text + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # 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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_qafficcom.py b/fanficdownloader/adapters/adapter_qafficcom.py new file mode 100644 index 00000000..ce95780b --- /dev/null +++ b/fanficdownloader/adapters/adapter_qafficcom.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 QafFicComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class QafFicComAdapter(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() + '/atp/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','atp') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.qaf-fic.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/atp/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/atp/viewstory.php?sid=")+r"\d+$" + + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + # 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 = "&warning=NC-17" + else: + addurl="" + + # index=1 makes sure we see the story chapter index. Some + # sites skip that for one-chapter stories. + url = self.url+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 + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&warning=\s+)'",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 & error in url. + addurl = addurl.replace("&","&") + url = self.url+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 + a = soup.find('div', {'id' : 'pagetitle'}) + + aut = a.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+'/atp/'+aut['href']) + self.story.setMetadata('author',aut.string) + aut.extract() + + self.story.setMetadata('title',stripHTML(a)[:(len(a.string)-3)]) + + # Find the chapters: + chapters=soup.find('select') + if chapters != None: + for chapter in chapters.findAll('option'): + # just in case there's tags, like <i> in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/atp/viewstory.php?sid='+self.story.getMetadata('storyId')+'&chapter='+chapter['value'])) + else: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + for list in asoup.findAll('div', {'class' : re.compile('listbox\s+')}): + a = list.find('a') + if ('viewstory.php?sid='+self.story.getMetadata('storyId')) in a['href']: + break + + # 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 = list.findAll('span', {'class' : 'classification'}) + for labelspan in labels: + label = labelspan.string + value = labelspan.nextSibling + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'classification' and value != None: + if "Featured Stories" not in value: + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value[:len(value)-2]) + + if 'Word count' in label: + self.story.setMetadata('numWords', value) + + if 'Categories' in label: + cats = labelspan.parent.findAll('a',href=re.compile(r'categories.php\?catid=\d+')) + for cat in cats: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + for char in value.string.split(', '): + if not 'None' in char: + self.story.addToList('characters',char) + + if 'Genre' in label: + for genre in value.string.split(', '): + if not 'None' in genre: + self.story.addToList('genre',genre) + + if 'Warnings' in label: + for warning in value.string.split(', '): + if not 'None' in warning: + self.story.addToList('warnings',warning) + + 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.split(' ::')[0]), 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+'/atp/'+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) + self.story.setMetadata('seriesUrl',series_url) + 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) diff --git a/fanficdownloader/adapters/adapter_restrictedsectionorg.py b/fanficdownloader/adapters/adapter_restrictedsectionorg.py new file mode 100644 index 00000000..967dfd38 --- /dev/null +++ b/fanficdownloader/adapters/adapter_restrictedsectionorg.py @@ -0,0 +1,265 @@ +# -*- 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 + +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')) + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/' + m.group('filestory') + '.php?' + m.group('filestory') + '=' + self.story.getMetadata('storyId')) + 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' + + @classmethod + 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',stripHTML(a)) + + # title, remove byauthorname. + auth=stripHTML(a) + title=stripHTML(h2) + self.story.setMetadata('title',title[:title.index(" by "+auth)]) + + 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 + + excpt=None + for sleeptime in [0.5, 1.5, 4, 9]: + time.sleep(sleeptime) + try: + data = self._postUrl(url, params) + if data == "Unable to connect to the database": + raise exceptions.FailedToDownload("Site reported 'Unable to connect to the database'") + 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 + except exceptions.FailedToLogin, ftl: + # no need to retry these. + raise(ftl) + except Exception, e: + excpt=e + logger.warn("Caught an exception reading URL: %s Exception %s."%(unicode(url),unicode(e))) + + logger.error("Giving up on %s" %url) + logger.exception(excpt) + raise(excpt) + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._postUrlUP(url) + #print("data:%s"%data) + + # some stories have html that confuses the parser. For story + # text we don't care about anything before '<table id="page"' + # and seems to clear the issue. + data = data[data.index('<table id="page"'):] + + 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) diff --git a/fanficdownloader/adapters/adapter_samdeanarchivenu.py b/fanficdownloader/adapters/adapter_samdeanarchivenu.py new file mode 100644 index 00000000..3c2bf275 --- /dev/null +++ b/fanficdownloader/adapters/adapter_samdeanarchivenu.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 SamDeanArchiveNuAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class SamDeanArchiveNuAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','sda') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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 'samdean.archive.nu' + + @classmethod + def getAcceptDomains(cls): + return ['www.samdean.archive.nu','samdean.archive.nu'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+r"(www\.)?"+re.escape(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.") + + # 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 + a = soup.find('div', {'id' : 'pagetitle'}) + + aut = a.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+'/'+aut['href']) + self.story.setMetadata('author',aut.string) + aut.extract() + + self.story.setMetadata('title',stripHTML(a)[:(len(stripHTML(a))-3)]) + + # Find the chapters: + chapters=soup.find('select') + if chapters != None: + for chapter in chapters.findAll('option'): + # just in case there's tags, like <i> in chapter titles. http://samdean.archive.nu/viewstory.php?sid=4317&chapter=2 + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/viewstory.php?sid='+self.story.getMetadata('storyId')+'&chapter='+chapter['value'])) + else: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + for list in asoup.findAll('div', {'class' : re.compile('listbox\s+')}): + a = list.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + if a != None: + break + + # 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 = list.findAll('span', {'class' : 'classification'}) + for labelspan in labels: + label = labelspan.string + value = labelspan.nextSibling + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'classification': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value[:len(value)-2]) + + if 'Word count' in label: + self.story.setMetadata('numWords', value) + + if 'Categories' in label: + cats = labelspan.parent.findAll('a',href=re.compile(r'categories.php\?catid=\d+')) + for cat in cats: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + for char in value.string.split(', '): + if not 'None' in char: + self.story.addToList('characters',char) + + if 'Genre' in label: + for genre in value.string.split(', '): + if not 'None' in genre: + self.story.addToList('genre',genre) + + if 'Warnings' in label: + for warning in value.string.split(', '): + if not 'None' in warning: + self.story.addToList('warnings',warning) + + 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 = list.find('a', href=re.compile(r"series.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_scarheadnet.py b/fanficdownloader/adapters/adapter_scarheadnet.py new file mode 100644 index 00000000..593abc68 --- /dev/null +++ b/fanficdownloader/adapters/adapter_scarheadnet.py @@ -0,0 +1,299 @@ +# -*- 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 ScarHeadNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class ScarHeadNetAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','shn') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d/%m/%y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'scarhead.net' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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): + + 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) + + # Since the warning text can change by warning level, let's + # look for the warning pass url. ksarchive uses + # &warning= -- actually, so do other sites. Must be an + # eFiction book. + + # viewstory.php?sid=1882&warning=4 + # viewstory.php?sid=1654&ageconsent=ok&warning=5 + #print data + #m = re.search(r"'viewstory.php\?sid=1882(&warning=4)'",data) + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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. + + pagetitle = soup.find('tr',{'valign':'top'}) + + ## Title + a = pagetitle.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # 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']+addurl)) + + 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. + + cats = soup.findAll('a',href=re.compile(r'browse.php\?type=categories')) + for cat in cats: + if '/' == cat.string[0]: + self.story.addToList('ships','Harry Potter'+cat.string.split('(')[0]) + elif 'Harry' in cat.string: + self.story.addToList('ships',cat.string.split('(')[0]) + else: + self.story.addToList('category',cat.string) + if '(' in cat.string: + self.story.addToList('category',cat.string.split('(')[1].split(')')[0]) + + + + + chars = soup.findAll('a',href=re.compile(r'browse.php\?type=characters')) + for char in chars: + self.story.addToList('characters',char.string) + + genres = soup.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) + for genre in genres: + self.story.addToList('genre',genre.string) + + warnings = soup.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')) + for warning in warnings: + self.story.addToList('warnings',warning.string) + + textsoup = stripHTML(soup) + + a = textsoup.split('Published: ')[1].split(' ')[0] + self.story.setMetadata('datePublished', makeDate(stripHTML(a), self.dateformat)) + a = textsoup.split('Updated: ')[1].split(' ')[0] + self.story.setMetadata('dateUpdated', makeDate(stripHTML(a), self.dateformat)) + a = textsoup.split('Rating: ')[1].split(' ')[0] + self.story.setMetadata('rating', a) + a = textsoup.split('Length: ')[1].split('(')[1].split(' ')[0] + self.story.setMetadata('numWords', a) + a = textsoup.split('Completed: ')[1].split(' ')[0] + if 'Yes' in a: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + #a = textsoup.split('Summary: ')[1].split('Add Story to Favorites')[0] + #self.setDescription(url,a) + + + + a=soup.find(text=re.compile("Summary: ")) + i=0 + svalue = "" + while i == 0: + try: + b = str(a) + svalue += b.split('Summary: ')[1] + except: + svalue += str(a) + if a.nextSibling != None: + a = a.nextSibling + else: + a = a.parent.nextSibling + if 'Disclaimer: ' in stripHTML(a): + i=1 + self.setDescription(url,svalue) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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) diff --git a/fanficdownloader/adapters/adapter_scarvesandcoffeenet.py b/fanficdownloader/adapters/adapter_scarvesandcoffeenet.py new file mode 100644 index 00000000..3a46bb7d --- /dev/null +++ b/fanficdownloader/adapters/adapter_scarvesandcoffeenet.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 ScarvesAndCoffeeNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class ScarvesAndCoffeeNetAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','scacf') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.scarvesandcoffee.net' + + @classmethod + 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+$" + + ## 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 + + ## 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=20" + 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 + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('div',{"id":"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']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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('genre',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: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # 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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_sg1heliopoliscom.py b/fanficdownloader/adapters/adapter_sg1heliopoliscom.py new file mode 100644 index 00000000..aba87c0f --- /dev/null +++ b/fanficdownloader/adapters/adapter_sg1heliopoliscom.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 SG1HeliopolisComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class SG1HeliopolisComAdapter(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]) + + self.section=self.parsedUrl.path.split('/',)[1] + + # normalized story URL. + 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','sghp') + + # If all stories from the site fall into the same category, + # the site itself isn't likely to label them as such, so we + # do. Can't use extracategories, could be Atlantis or SG-1 + if 'atlantis' in self.section: + self.story.addToList("category","Stargate: Atlantis") + else: + self.story.addToList("category","Stargate: SG-1") + + # 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 'sg1-heliopolis.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/archive/viewstory.php?sid=1234 http://"+self.getSiteDomain()+"/adult/viewstory.php?sid=1234 http://"+self.getSiteDomain()+"/atlantis/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return r"http://sg1-heliopolis.com/(archive|adult|atlantis)?/viewstory.php\?sid=\d+$" + + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + # 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 = "&warning=4" + 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 + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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+'/'+self.section+'/'+chapter['href']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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: + 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+'/'+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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_simplyundeniablecom.py b/fanficdownloader/adapters/adapter_simplyundeniablecom.py new file mode 100644 index 00000000..7ad278f9 --- /dev/null +++ b/fanficdownloader/adapters/adapter_simplyundeniablecom.py @@ -0,0 +1,219 @@ +# -*- 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 SimplyUndeniableComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class SimplyUndeniableComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','sud') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.simplyundeniable.com' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Please log in now' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl, + params['penname'])) + + d = self._fetchUrl(loginUrl, params) + + if "My 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): + + # 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 "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.") + + if "Please log in now" in data: + raise exceptions.FailedToDownload(self.getSiteDomain() +" says: You need to have access to the restricted section.") + + # 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('h1') + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + self.story.setMetadata('author',a.string) + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + for info in asoup.findAll('table', {'cellpadding' : '5'}): + a = info.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + if a != None: + self.story.setMetadata('title',stripHTML(a)) + break + + # Find the chapters: + if "Disclaimer" in data: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + else: + for chapter in soup.findAll('a', href=re.compile(r'viewstory.php\?sid=\d+&i=1$')): + # 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. + + a = info.find('td', {'valign' : 'top'}).find('p') + self.setDescription(url,a) + + a = info.find('td', {'class' : 'greysm'}).findAll('b') + self.story.setMetadata('datePublished', makeDate(stripHTML(a[0].nextSibling), self.dateformat)) + self.story.setMetadata('dateUpdated', makeDate(stripHTML(a[1].nextSibling), self.dateformat)) + if 'Yes' in a[2].nextSibling: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + self.story.setMetadata('rating', a[3].nextSibling) + self.story.setMetadata('numWords', a[4].nextSibling) + + + warnings = info.find('td', {'width' : '45'}).nextSibling.nextSibling.text.split(', ') + for warning in warnings: + if 'none' not in warning: + self.story.addToList('warnings',warning) + chars = info.find('td', {'width' : '51'}).nextSibling.nextSibling.text.split(', ') + for char in chars: + if '&' in char: + self.story.addToList('ships',char) + else: + self.story.addToList('characters',char) + genres = info.find('td', {'width' : '36'}).nextSibling.nextSibling.text.split(', ') + for genre in genres: + self.story.addToList('genre',genre) + + cat = info.find('a', href=re.compile(r'categories.php')) + self.story.addToList('category',cat.string) + + # 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('span', {'class' : 'style'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + div.name='div' + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_sinfuldesireorg.py b/fanficdownloader/adapters/adapter_sinfuldesireorg.py new file mode 100644 index 00000000..c1e98206 --- /dev/null +++ b/fanficdownloader/adapters/adapter_sinfuldesireorg.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 SinfulDesireOrgAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class SinfulDesireOrgAdapter(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() + '/archive/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','snds') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.sinful-desire.org' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/archive/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/archive/viewstory.php?sid=")+r"\d+$" + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + # 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 = "&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 + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/archive/'+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+'/archive/'+chapter['href']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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) + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=3')) + 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: + 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+'/archive/'+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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_siyecouk.py b/fanficdownloader/adapters/adapter_siyecouk.py new file mode 100644 index 00000000..c59ad3d7 --- /dev/null +++ b/fanficdownloader/adapters/adapter_siyecouk.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +# This function is called by the downloader in all adapter_*.py files +# in this dir to register the adapter class. So it needs to be +# updated to reflect the class below it. That, plus getSiteDomain() +# take care of 'Registering'. +def getClass(): + return SiyeCoUkAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class SiyeCoUkAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8",]# 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + + # 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() + '/siye/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','siye') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%Y.%m.%d" # XXX + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'www.siye.co.uk' # XXX + + @classmethod + def getAcceptDomains(cls): + return ['www.siye.co.uk','siye.co.uk'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/siye/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+r"(www\.)?siye\.co\.uk/(siye/)?"+re.escape("viewstory.php?sid=")+r"\d+$" + + ## 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. + # Except it doesn't this time. :-/ + 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 + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/siye/'+a['href']) + self.story.setMetadata('author',a.string) + + # need(or easier) to pull other metadata from the author's list page. + authsoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + ## Title + titlea = authsoup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(titlea)) + + # Find the chapters (from soup, not authsoup): + for chapter in soup.findAll('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"&chapter=\d+$")): + # just in case there's tags, like <i> in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/siye/'+chapter['href'])) + + if self.chapterUrls: + self.story.setMetadata('numChapters',len(self.chapterUrls)) + else: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + self.story.setMetadata('numChapters',1) + + # The stuff we can get from the chapter list/one-shot page are + # in the first table with 95% width. + metatable = soup.find('table',{'width':'95%'}) + + # Categories + cat_as = metatable.findAll('a', href=re.compile(r'categories.php')) + for cat_a in cat_as: + self.story.addToList('category',stripHTML(cat_a)) + + moremetaparts = stripHTML(metatable).split('\n') + for part in moremetaparts: + part = part.strip() + if part.startswith("Characters:"): + part = part[part.find(':')+1:] + for item in part.split(','): + if item.strip() == "Harry/Ginny": + self.story.addToList('characters',"Harry") + self.story.addToList('characters',"Ginny") + elif item.strip() not in ("None","All"): + self.story.addToList('characters',item) + + if part.startswith("Genres:"): + part = part[part.find(':')+1:] + for item in part.split(','): + if item.strip() != "None": + self.story.addToList('genre',item) + + if part.startswith("Warnings:"): + part = part[part.find(':')+1:] + for item in part.split(','): + if item.strip() != "None": + self.story.addToList('warnings',item) + + if part.startswith("Rating:"): + part = part[part.find(':')+1:] + self.story.setMetadata('rating',part) + + if part.startswith("Summary:"): + part = part[part.find(':')+1:] + self.setDescription(url,part) + #self.story.setMetadata('description',part) + + # want to get the next tr of the table. + #print("%s"%titlea.parent.parent.findNextSibling('tr')) + + # eFiction sites don't help us out a lot with their meta data + # formating, so it's a little ugly. + + # SIYE formats stories in the author list differently when their part of a series. + # Look for non-series... + divdesc = titlea.parent.parent.find('div',{'class':'desc'}) + if not divdesc: + # ... now look for series. + divdesc = titlea.parent.parent.findNextSibling('tr').find('div',{'class':'desc'}) + + moremeta = stripHTML(divdesc) + #print("moremeta:%s"%moremeta) + for part in moremeta.replace(' - ','\n').split('\n'): + #print("part:%s"%part) + try: + (name,value) = part.split(': ') + except: + # not going to worry about fancier processing for the bits + # that don't match. + continue + name=name.strip() + value=value.strip() + if name == 'Published': + self.story.setMetadata('datePublished', makeDate(value, self.dateformat)) + if name == 'Updated': + self.story.setMetadata('dateUpdated', makeDate(value, self.dateformat)) + if name == 'Completed': + if value == 'Yes': + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + if name == 'Words': + self.story.setMetadata('numWords', value) + + try: + # Find Series name from series URL. + a = titlea.findPrevious('a', href=re.compile(r"series.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + # soup = bs.BeautifulSoup(self._fetchUrl(url)) + # BeautifulSoup objects to <p> inside <span>, which + # technically isn't allowed. + soup = bs.BeautifulStoneSoup(self._fetchUrl(url), + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + # not the most unique thing in the world, but it appears to be + # the best we can do here. + story = soup.find('span', {'style' : 'font-size: 100%;'}) + + if None == story: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + story.name='div' + + return self.utf8FromSoup(url,story) diff --git a/fanficdownloader/adapters/adapter_spikeluvercom.py b/fanficdownloader/adapters/adapter_spikeluvercom.py new file mode 100644 index 00000000..ae37c377 --- /dev/null +++ b/fanficdownloader/adapters/adapter_spikeluvercom.py @@ -0,0 +1,207 @@ +import re +import urllib2 +import urlparse + +from .. import BeautifulSoup +from ..htmlcleanup import stripHTML + +from base_adapter import BaseSiteAdapter, makeDate +from .. import exceptions + + +def getClass(): + return SpikeluverComAdapter + + +# yields Tag _and_ NavigableString siblings from the given tag. The +# BeautifulSoup findNextSiblings() method for some reasons only returns either +# NavigableStrings _or_ Tag objects, not both. +def _yield_next_siblings(tag): + sibling = tag.nextSibling + while sibling: + yield sibling + sibling = sibling.nextSibling + + +class SpikeluverComAdapter(BaseSiteAdapter): + SITE_ABBREVIATION = 'slc' + SITE_DOMAIN = 'spikeluver.com' + + BASE_URL = 'http://' + SITE_DOMAIN + '/SpuffyRealm/' + LOGIN_URL = BASE_URL + 'user.php?action=login' + VIEW_STORY_URL_TEMPLATE = BASE_URL + 'viewstory.php?sid=%d' + METADATA_URL_SUFFIX = '&index=1' + AGE_CONSENT_URL_SUFFIX = '&ageconsent=ok&warning=5' + + DATETIME_FORMAT = '%m/%d/%Y' + STORY_DOES_NOT_EXIST_ERROR_TEXT = 'That story does not exist on this archive. You may search for it or return to the home page.' + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + query_data = urlparse.parse_qs(self.parsedUrl.query) + story_id = query_data['sid'][0] + + self.story.setMetadata('storyId', story_id) + self._setURL(self.VIEW_STORY_URL_TEMPLATE % int(story_id)) + self.story.setMetadata('siteabbrev', self.SITE_ABBREVIATION) + + def _customized_fetch_url(self, url, exception=None, parameters=None): + if exception: + try: + data = self._fetchUrl(url, parameters) + except urllib2.HTTPError: + raise exception(self.url) + # Just let self._fetchUrl throw the exception, don't catch and + # customize it. + else: + data = self._fetchUrl(url, parameters) + + return BeautifulSoup.BeautifulSoup(data) + + @staticmethod + def getSiteDomain(): + return SpikeluverComAdapter.SITE_DOMAIN + + @classmethod + def getSiteExampleURLs(cls): + return cls.VIEW_STORY_URL_TEMPLATE % 1234 + + def getSiteURLPattern(self): + return re.escape(self.VIEW_STORY_URL_TEMPLATE[:-2]) + r'\d+$' + + def extractChapterUrlsAndMetadata(self): + soup = self._customized_fetch_url(self.url + self.METADATA_URL_SUFFIX) + + errortext_div = soup.find('div', {'class': 'errortext'}) + if errortext_div: + error_text = ''.join(errortext_div(text=True)).strip() + if error_text == self.STORY_DOES_NOT_EXIST_ERROR_TEXT: + raise exceptions.StoryDoesNotExist(self.url) + + # No additional login is required, just check for adult + pagetitle_div = soup.find('div', id='pagetitle') + if pagetitle_div.a['href'].startswith('javascript:'): + if not(self.is_adult or self.getConfig('is_adult')): + raise exceptions.AdultCheckRequired(self.url) + + url = ''.join([self.url, self.METADATA_URL_SUFFIX, self.AGE_CONSENT_URL_SUFFIX]) + soup = self._customized_fetch_url(url) + + pagetitle_div = soup.find('div', id='pagetitle') + self.story.setMetadata('title', stripHTML(pagetitle_div.a)) + + author_anchor = pagetitle_div.a.findNextSibling('a') + url = urlparse.urljoin(self.BASE_URL, author_anchor['href']) + components = urlparse.urlparse(url) + query_data = urlparse.parse_qs(components.query) + + self.story.setMetadata('author', stripHTML(author_anchor)) + self.story.setMetadata('authorId', query_data['uid']) + self.story.setMetadata('authorUrl', url) + + sort_div = soup.find('div', id='sort') + self.story.setMetadata('reviews', stripHTML(sort_div('a')[1])) + + listbox_tag = soup.find('div', {'class': 'listbox'}) + for span_tag in listbox_tag('span'): + key = span_tag.string.strip(' :') + try: + value = stripHTML(span_tag.nextSibling) + # This can happen with some fancy markup in the summary. Just + # ignore this error and set value to None, the summary parsing + # takes care of this + except AttributeError: + value = None + + if key == 'Summary': + contents = [] + keep_summary_html = self.getConfig('keep_summary_html') + + for sibling in _yield_next_siblings(span_tag): + if isinstance(sibling, BeautifulSoup.Tag): + # Encountered next label, break. Not as bad as other + # e-fiction sites, let's hope this is enough for proper + # parsing. + if sibling.name == 'span' and sibling.get('class', None) == 'label': + break + + if keep_summary_html: + contents.append(self.utf8FromSoup(self.url, sibling)) + else: + contents.append(''.join(sibling(text=True))) + else: + contents.append(sibling) + + # Remove the preceding break line tag and other crud + contents.pop() + contents.pop() + self.story.setMetadata('description', ''.join(contents)) + + elif key == 'Rated': + self.story.setMetadata('rating', value) + + elif key == 'Categories': + for sibling in span_tag.findNextSiblings(['a', 'br']): + if sibling.name == 'br': + break + + self.story.addToList('category', stripHTML(sibling)) + + # Seems to be always "None" for some reason + elif key == 'Characters': + for sibling in span_tag.findNextSiblings(['a', 'br']): + if sibling.name == 'br': + break + self.story.addToList('characters', stripHTML(sibling)) + + elif key == 'Genres': + for sibling in span_tag.findNextSiblings(['a', 'br']): + if sibling.name == 'br': + break + + self.story.addToList('genre', stripHTML(sibling)) + + elif key == 'Warnings': + for sibling in span_tag.findNextSiblings(['a', 'br']): + if sibling.name == 'br': + break + self.story.addToList('warnings', stripHTML(sibling)) + + # Challenges + + elif key == 'Series': + a = span_tag.findNextSibling('a') + if not a: + continue + self.story.setMetadata('series', stripHTML(a)) + self.story.setMetadata('seriesUrl', urlparse.urljoin(self.BASE_URL, a['href'])) + + elif key == 'Chapters': + self.story.setMetadata('numChapters', int(value)) + + elif key == 'Completed': + self.story.setMetadata('status', 'Completed' if value == 'Yes' else 'In-Progress') + + elif key == 'Word count': + self.story.setMetadata('numWords', value) + + elif key == 'Published': + self.story.setMetadata('datePublished', makeDate(value, self.DATETIME_FORMAT)) + + elif key == 'Updated': + self.story.setMetadata('dateUpdated', makeDate(value, self.DATETIME_FORMAT)) + + for p_tag in listbox_tag.findNextSiblings('p'): + chapter_anchor = p_tag.find('a', href=lambda href: href and href.startswith('viewstory.php?sid=')) + if not chapter_anchor: + continue + + title = stripHTML(chapter_anchor) + url = urlparse.urljoin(self.BASE_URL, chapter_anchor['href']) + self.chapterUrls.append((title, url)) + + def getChapterText(self, url): + url += self.AGE_CONSENT_URL_SUFFIX + soup = self._customized_fetch_url(url) + return self.utf8FromSoup(url, soup.find('div', id='story')) diff --git a/fanficdownloader/adapters/adapter_squidgeorgpeja.py b/fanficdownloader/adapters/adapter_squidgeorgpeja.py new file mode 100644 index 00000000..8ae9964b --- /dev/null +++ b/fanficdownloader/adapters/adapter_squidgeorgpeja.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 SquidgeOrgPejaAdapter + +## XXX IMPORTANT NOTE!! This adapter is for squidge.org/peja ONLY! +## There are lots of other sites and stuff under squidge.org that +## we're not supporting. If/When we ever want to support more +## sections of squidge.org, FFDL will need to be changed more +## fundamentally to find different adapters under the same domain. +## +## For now, I've only implemented the part for ini section names so +## if/when more adapters under squidge.org come along, existing ini +## files will still work correctly. + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class SquidgeOrgPejaAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["utf8", + "Windows-1252"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # normalized story URL. + self._setURL('https://' + self.getSiteDomain() + '/peja/cgi-bin/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','wwomb') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.squidge.org' + + @classmethod # must be @classmethod, don't remove it. + def getConfigSection(cls): + # The config section name. Only override if != site domain. + return cls.getSiteDomain()+'/peja' + + @classmethod + def getSiteExampleURLs(self): + return "https://"+self.getSiteDomain()+"/peja/cgi-bin/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return r"https?"+re.escape("://"+self.getSiteDomain()+"/")+r"~?"+re.escape("peja/cgi-bin/viewstory.php?sid=")+r"\d+$" + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + 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 + + data = self._fetchUrl(url) + + if "fatal MySQL error was encountered" in data: + raise exceptions.FailedToDownload("Site SQL Error--bad story") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + # Find authorid and URL from... author url. + author = soup.find('div', {'id':"pagetitle"}).find('a') + self.story.setMetadata('authorId',author['href'].split('=')[1]) + self.story.setMetadata('authorUrl','https://'+self.host+'/peja/cgi-bin/'+author['href']) + self.story.setMetadata('author',author.string) + + authorSoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + # eFiction sites don't help us out a lot with their meta data + # formating, so it's a little ugly. + + title = authorSoup.find('a',{'href':'viewstory.php?sid='+self.story.getMetadata('storyId')}) + self.story.setMetadata('title',stripHTML(title)) + titleblock=title.parent.parent + + chapterselect=soup.find('select',{'name':'chapter'}) + if chapterselect: + for ch in chapterselect.findAll('option'): + self.chapterUrls.append((stripHTML(ch),'https://'+self.host+'/peja/cgi-bin/viewstory.php?sid='+self.story.getMetadata('storyId')+'&chapter='+ch['value'])) + else: + self.chapterUrls.append((title,url)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + + # utility method + def defaultGetattr(d,k): + try: + return d[k] + except: + return "" + + # <span class="classification">Rated:</span> NC-17<br /> etc + labels = titleblock.findAll('span',{'class':'classification'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'classification': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + if value.endswith("["): + value = value[:-1] + self.story.setMetadata('rating', value) + + if 'Characters' in label: + for char in value.split(','): + self.story.addToList('characters',char.strip()) + + if 'Genre' in label: + for genre in value.split(','): + if genre.strip() != "None": + self.story.addToList('genre',genre.strip()) + + if 'Warnings' in label: + for warning in value.split(','): + if warning.strip() != 'None': + self.story.addToList('warnings',warning.strip()) + + if 'Word count' in label: + self.story.setMetadata('numWords', value) + + if 'Fandoms' in label: + cats = labelspan.parent.findAll('a',href=re.compile(r'categories.php')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.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. + # http://www.squidge.org/peja/cgi-bin/series.php?seriesid=254 + a = titleblock.find('a', href=re.compile(r"series.php\?seriesid=\d+")) + series_name = a.string + series_url = 'https://'+self.host+'/peja/cgi-bin/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + soup = bs.BeautifulStoneSoup(self._fetchUrl(url), + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + chaptext = soup.find('div',{'id':"story"}).find('span') + + if None == chaptext: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,chaptext) diff --git a/fanficdownloader/adapters/adapter_stargateatlantisorg.py b/fanficdownloader/adapters/adapter_stargateatlantisorg.py new file mode 100644 index 00000000..9fd3d0a2 --- /dev/null +++ b/fanficdownloader/adapters/adapter_stargateatlantisorg.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 StargateAtlantisOrgAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class StargateAtlantisOrgAdapter(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() + '/fanfics/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','stat') + + # 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 'stargate-atlantis.org' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/fanfics/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/fanfics/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.") + + # 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 + a = soup.find('div', {'id' : 'pagetitle'}) + + aut = a.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+'/fanfics/'+aut['href']) + self.story.setMetadata('author',aut.string) + aut.extract() + + self.story.setMetadata('title',stripHTML(a)[:(len(stripHTML(a))-3)]) + + # Find the chapters: + chapters=soup.findAll('div', {'class' : 'content'}) + if len(chapters) > 1: + for chapter in chapters: + # just in case there's tags, like <i> in chapter titles. + link = chapter.find('a') + self.chapterUrls.append((stripHTML(link),'http://'+self.host+'/fanfics/'+link['href'])) + else: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + for list in asoup.findAll('div', {'class' : re.compile('listbox\s+')}): + a = list.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + if a != None: + break + + # 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 = list.findAll('span', {'class' : 'classification'}) + for labelspan in labels: + label = labelspan.string + value = labelspan.nextSibling + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'tail' and value != None: + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value[:len(value)-2]) + + if 'Word count' in label: + self.story.setMetadata('numWords', value) + + if 'Categories' in label: + cats = labelspan.parent.findAll('a',href=re.compile(r'categories.php\?catid=\d+')) + for cat in cats: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + for char in value.string.split(', '): + if not 'None' in char: + self.story.addToList('characters',char) + + if 'Genre' in label: + for genre in value.string.split(', '): + if not 'None' in genre: + self.story.addToList('genre',genre) + + if 'Warnings' in label: + for warning in value.string.split(', '): + if not 'None' in warning: + self.story.addToList('warnings',warning) + + 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.split(' ::')[0]), 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 = list.find('a', href=re.compile(r"series.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/fanfics/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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) diff --git a/fanficdownloader/adapters/adapter_storiesofardacom.py b/fanficdownloader/adapters/adapter_storiesofardacom.py new file mode 100644 index 00000000..0dfc4a88 --- /dev/null +++ b/fanficdownloader/adapters/adapter_storiesofardacom.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 StoriesOfArdaComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class StoriesOfArdaComAdapter(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() + '/chapterlistview.asp?SID='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','soa') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.storiesofarda.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/chapterlistview.asp?SID=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/chapterlistview.asp?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 + 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 + + # 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 + a = soup.find('th', {'colspan' : '3'}) + + aut = a.find('a') + self.story.setMetadata('authorId',aut['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+aut['href']) + self.story.setMetadata('author',aut.string) + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + a.find('em').extract() + self.story.setMetadata('title',stripHTML(a)) + + # Find the chapters: chapterview.asp?sid=7000&cid=30919 + chapters=soup.findAll('a', href=re.compile(r'chapterview.asp\?sid='+self.story.getMetadata('storyId')+"&cid=\d+$")) + if len(chapters)==1: + self.chapterUrls.append((self.story.getMetadata('title'),'http://'+self.host+'/'+chapters[0]['href'])) + else: + for chapter in chapters: + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['href'])) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + summary = soup.find('td', {'colspan' : '3'}) + self.setDescription(url,summary) + + # no convenient way to get word count + + for td in asoup.findAll('td', {'colspan' : '3'}): + if td.find('a', href=re.compile('chapterlistview.asp\?SID='+self.story.getMetadata('storyId'))) != None: + break + td=td.nextSibling.nextSibling + self.story.setMetadata('dateUpdated', makeDate(stripHTML(td).split(': ')[1], self.dateformat)) + tr=td.parent.nextSibling.nextSibling.nextSibling.nextSibling + td=tr.findAll('td') + self.story.setMetadata('rating', td[0].string.split(': ')[1]) + self.story.setMetadata('status', td[2].string.split(': ')[1]) + self.story.setMetadata('datePublished', makeDate(stripHTML(td[4]).split(': ')[1], self.dateformat)) + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + if self.getConfig('is_adult'): + params = {'confirmAge':'1'} + data = self._postUrl(url,params) + else: + data = self._fetchUrl(url) + + data = data[data.index('<table width="90%" align="center">'):] + data.replace("<body","<notbody").replace("<BODY","<NOTBODY") + + soup = bs.BeautifulStoneSoup(data, + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + if "Please indicate that you are an adult by selecting the appropriate choice below" in data: + raise exceptions.FailedToDownload("Chapter requires you be an adult. Set is_adult in personal.ini (chapter url:%s)" % url) + + div = soup.find('table', {'width' : '90%'}).find('td') + div.name='div' + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_storiesonlinenet.py b/fanficdownloader/adapters/adapter_storiesonlinenet.py new file mode 100644 index 00000000..29022d7a --- /dev/null +++ b/fanficdownloader/adapters/adapter_storiesonlinenet.py @@ -0,0 +1,405 @@ +# -*- 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 StoriesOnlineNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class StoriesOnlineNetAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url + self.story.setMetadata('storyId',self.parsedUrl.path.split('/',)[2].split(':')[0]) + if 'storyInfo' in self.story.getMetadata('storyId'): + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + # normalized story URL. + self._setURL('http://' + self.getSiteDomain() + '/s/'+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','strol') + + # 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 'storiesonline.net' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/s/1234 http://"+self.getSiteDomain()+"/s/1234:4010" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain())+r"/s/\d+((:\d+)?(;\d+)?$|(:i)?$)?" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if self.needToLogin \ + or 'Free Registration' in data \ + or "Invalid Password!" in data \ + or "Invalid User Name!" in data \ + or "Log In" in data \ + or "Access to unlinked chapters requires" in data: + self.needToLogin = True + return self.needToLogin + + def performLogin(self, url): + params = {} + + if self.password: + params['theusername'] = self.username + params['thepassword'] = self.password + else: + params['theusername'] = self.getConfig("username") + params['thepassword'] = self.getConfig("password") + params['rememberMe'] = '1' + params['page'] = 'http://'+self.getSiteDomain()+'/' + params['submit'] = 'Login' + + loginUrl = 'http://' + self.getSiteDomain() + '/login.php' + logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl, + params['theusername'])) + + d = self._fetchUrl(loginUrl, params) + + if "My Account" not in d : #Member Account + logger.info("Failed to login to URL %s as %s" % (loginUrl, + params['theusername'])) + raise exceptions.FailedToLogin(url,params['theusername']) + 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) + + self.needToLogin = False + try: + data = self._fetchUrl(url+":i") + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + elif e.code == 401: + self.needToLogin = True + data = '' + else: + raise e + + if self.needToLoginCheck(data): + # need to log in for this one. + self.performLogin(url) + data = self._fetchUrl(url+":i") + + 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.") + elif "Error! The story you're trying to access is being filtered by your choice of contents filtering." in data: + raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Error! The story you're trying to access is being filtered by your choice of contents filtering.") + + # 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('h1') + self.story.setMetadata('title',stripHTML(a)) + + notice = soup.find('div', {'class' : 'notice'}) + if notice: + self.story.setMetadata('notice',unicode(notice)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"/a/\w+")) + self.story.setMetadata('authorId',a['href'].split('/')[2]) + self.story.setMetadata('authorUrl','http://'+self.host+a['href']) + self.story.setMetadata('author',stripHTML(a).replace("'s Page","")) + + # Find the chapters: + chapters = soup.findAll('a', href=re.compile(r'^/s/'+self.story.getMetadata('storyId')+":\d+$")) + if len(chapters) != 0: + 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'])) + else: + self.chapterUrls.append((self.story.getMetadata('title'),'http://'+self.host+'/s/'+self.story.getMetadata('storyId'))) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + # surprisingly, the detailed page does not give enough details, so go to author's page + skip=0 + i=0 + while i == 0: + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl')+"&skip="+str(skip))) + + a = asoup.findAll('td', {'class' : 'lc2'}) + for lc2 in a: + if lc2.find('a', href=re.compile(r'^/s/'+self.story.getMetadata('storyId'))): + i=1 + break + if a[len(a)-1] == lc2: + skip=skip+10 + + for cat in lc2.findAll('div', {'class' : 'typediv'}): + self.story.addToList('genre',cat.text) + + # in lieu of word count. + self.story.setMetadata('size', lc2.findNext('td', {'class' : 'num'}).text) + + lc4 = lc2.findNext('td', {'class' : 'lc4'}) + desc = lc4.contents[0] + + try: + a = lc4.find('a', href=re.compile(r"/library/show_series.php\?id=\d+")) + if a: + # if there's a number after the series name, series_contents is a two element list: + # [<a href="...">Title</a>, u' (2)'] + series_contents = a.parent.contents + i = 0 if len(series_contents) == 1 else series_contents[1].strip(' ()') + seriesUrl = 'http://'+self.host+a['href'] + self.story.setMetadata('seriesUrl',seriesUrl) + series_name = stripHTML(a) + logger.debug("Series name= %s" % series_name) + series_soup = bs.BeautifulSoup(self._fetchUrl(seriesUrl)) + if series_soup: + logger.debug("Retrieving Series - looking for name") + series_name = series_soup.find('span', {'id' : 'ptitle'}).text.partition(' — ')[0] + logger.debug("Series name: '{0}'".format(series_name)) + self.setSeries(series_name, i) + desc = lc4.contents[2] + # Check if series is in a universe + universes_soup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl') + "&type=uni")) +# logger.debug("Universe page=", universes_soup) + if universes_soup: + universes = universes_soup.findAll('div', {'class' : 'ser-box'}) + logger.debug("Number of Universes: %d" % len(universes)) + for universe in universes: + logger.debug("universe.find('a')={0}".format(universe.find('a'))) + # The universe id is in an "a" tag that has an id but nothing else. It is the first tag. + # The id is prefixed with the letter "u". + universe_id = universe.find('a')['id'][1:] + logger.debug("universe_id='%s'" % universe_id) + universe_name = universe.find('div', {'class' : 'ser-name'}).text.partition(' ')[2] + logger.debug("universe_name='%s'" % universe_name) + # If there is link to the story, we have the right universe + story_a = universe.find('a', {'href' : '/s/'+self.story.getMetadata('storyId')}) + if story_a: + logger.debug("Story is in a series that is in a universe! The universe is '%s'" % universe_name) + self.story.setMetadata("universe", universe_name) + self.story.setMetadata('universeUrl','http://'+self.host+ '/library/universe.php?id=' + universe_id) + break + except: + pass + try: + a = lc4.find('a', href=re.compile(r"/library/universe.php\?id=\d+")) + if a: + self.story.setMetadata("universe",stripHTML(a)) + desc = lc4.contents[2] + # Assumed only one universe, but it does have a URL--use universeHTML + universe_name = stripHTML(a) + universeUrl = 'http://'+self.host+a['href'] + logger.debug("Retrieving Universe - about to get page") + universe_soup = bs.BeautifulSoup(self._fetchUrl(universeUrl)) + logger.debug("Retrieving Universe - have page") + if universe_soup: + logger.debug("Retrieving Universe - looking for name") + universe_name = universe_soup.find('span', {'id' : 'ptitle'}).text.partition(' —')[0] + logger.debug("Universes name: '{0}'".format(universe_name)) + + self.story.setMetadata('universeUrl',universeUrl) + logger.debug("Setting universe name: '{0}'".format(universe_name)) + self.story.setMetadata('universe',universe_name) + if self.getConfig("universe_as_series"): + self.setSeries(universe_name, 0) + self.story.setMetadata('seriesUrl',universeUrl) + except: + pass + + self.setDescription('http://'+self.host+'/s/'+self.story.getMetadata('storyId'),desc) + + for b in lc4.findAll('b'): + #logger.debug('Getting metadata: "%s"' % b) + label = b.text + if label in ['Posted:', 'Concluded:', 'Updated:']: + value = b.findNext('noscript').text + #logger.debug('Have a date field label: "%s", value: "%s"' % (label, value)) + else: + value = b.nextSibling + #logger.debug('label: "%s", value: "%s"' % (label, value)) + + if 'Sex' in label: + self.story.setMetadata('rating', value) + + if 'Codes' in label: + for code in value.split(' '): + self.story.addToList('codes',code) + + if 'Posted' in label: + self.story.setMetadata('datePublished', makeDate(stripHTML(value), self.dateformat)) + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + if 'Concluded' in label: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + if 'Updated' in label: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) +# + status = lc4.find('span', {'class' : 'ab'}) + if status != None: + self.story.setMetadata('status', 'In-Progress') + if "Last Activity" in status.text: + # date is passed as a timestamp and converted in JS. + value = status.findNext('noscript').text + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + else: + self.story.setMetadata('status', 'Completed') + + + # 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'}) + + # some big chapters are split over several pages + pager = div.find('span', {'class' : 'pager'}) + if pager != None: + a = pager.previousSibling + while a != None: + logger.debug("before pager: {0}".format(a)) + b = a.previousSibling + a.extract() + a = b + + urls=pager.findAll('a') + urls=urls[:len(urls)-1] + pager.extract() + div.contents = div.contents[2:] +# logger.debug(div) + + for ur in urls: + soup = bs.BeautifulSoup(self._fetchUrl("http://"+self.getSiteDomain()+ur['href']), + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + div1 = soup.find('div', {'id' : 'story'}) + + # Find the "Continues" marker on the current page and remove everything after that. + continues = div.find('span', {'class' : 'conTag'}) + if continues != None: + while continues != None: +# logger.debug("removing end: {0}".format(continues)) + b = continues.nextSibling + continues.extract() + continues = b + + # Find the "Continued" marker and delete everything before that + continued = div1.find('span', {'class' : 'conTag'}) + if continued != None: + a = continued.previousSibling + while a != None: +# logger.debug("before conTag: {0}".format(a)) + b = a.previousSibling + a.extract() + a = b + # Remove the pager from the end if this is the last page + endPager = div1.find('span', {'class' : 'pager'}) + if endPager != None: + b = endPager.nextSibling + while endPager != None: + logger.debug("removing end: {0}".format(endPager)) + b = endPager.nextSibling + endPager.extract() + endPager = b + div1.contents = div1.contents[:len(div1) - 2] +# logger.debug("after removing pager: {0}".format(div1)) + for tag in div1.contents[2:]: + div.append(tag) + + # If it is a chapter, there are dates at the start for when it was posted or modified. These plus + # everything before them can be discarded. + postedDates = div.findAll('div', {'class' : 'date'}) + if postedDates: + a = postedDates[0].previousSibling + while a != None: +# logger.debug("before dates: {0}".format(a)) + b = a.previousSibling + a.extract() + a = b + for a in div.findAll('div', {'class' : 'date'}): + a.extract() + + # For single chapter stories, there is a copyright statement. Remove this and everything + # before it. + copy = div.find('h4', {'class': 'copy'}) + while copy != None: +# logger.debug("before copyright: {0}".format(copy)) + b = copy.previousSibling + copy.extract() + copy = b + + # For a story or the last chapter, remove voting form and the in library box + a = div.find('div', {'id' : 'vote-form'}) + if a != None: + a.extract() + a = div.find('div', {'id' : 'b-man-div'}) + if a != None: + a.extract() + + # Kill the "The End" header and everything after it. + a = div.find(['h2', 'h3'], {'class' : 'end'}) + logger.debug("Chapter end= '{0}'".format(a)) + while a != None: + b = a.nextSibling + a.extract() + a=b + + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_tenhawkpresentscom.py b/fanficdownloader/adapters/adapter_tenhawkpresentscom.py new file mode 100644 index 00000000..90c37146 --- /dev/null +++ b/fanficdownloader/adapters/adapter_tenhawkpresentscom.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib +import urllib2 + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class TenhawkPresentsComSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','thpc') + 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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + self.dateformat = "%b %d %Y" + + + @staticmethod + def getSiteDomain(): + return 'fanfiction.tenhawkpresents.com' + + @classmethod + 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+$" + + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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 + + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + addurl = "&ageconsent=ok&warning=3" + else: + addurl="" + + 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. + addurl = "&ageconsent=ok&warning=4" + url = self.url+'&index=1'+addurl + logger.debug("Changing URL: "+url) + self.performLogin(url) + data = self._fetchUrl(url) + + if "This story contains mature content which may include violence, sexual situations, and coarse language" in data: + 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) + + ## Title + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId'))) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + def defaultGetattr(d,k): + try: + return d[k] + except: + return "" + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class')) + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',genre.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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + + 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. + + span = soup.find('div', {'id' : 'story'}) + + if None == span: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,span) + +def getClass(): + return TenhawkPresentsComSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_test1.py b/fanficdownloader/adapters/adapter_test1.py new file mode 100644 index 00000000..95e6baa0 --- /dev/null +++ b/fanficdownloader/adapters/adapter_test1.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import datetime +import time +import logging +logger = logging.getLogger(__name__) + +from .. import BeautifulSoup as bs +from .. import exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class TestSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','tst1') + self.crazystring = u" crazy tests:[bare amp(&) quote(') amp(&) gt(>) lt(<) ATnT(AT&T) pound(£)]" + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + self.username='' + self.is_adult=False + + @staticmethod + def getSiteDomain(): + return 'test1.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"?sid=1234" + + def getSiteURLPattern(self): + return BaseSiteAdapter.getSiteURLPattern(self)+r'/?\?sid=\d+$' + + def extractChapterUrlsAndMetadata(self): + idstr = self.story.getMetadata('storyId') + idnum = int(idstr) + self.do_sleep() + + if idnum >= 1000: + logger.warn("storyId:%s - Custom INI data will be used."%idstr) + + sections = ['teststory:%s'%idstr,'teststory:defaults'] + #print("self.get_config_list(sections,'valid_entries'):%s"%self.get_config_list(sections,'valid_entries')) + for key in self.get_config_list(sections,'valid_entries'): + if key.endswith("_list"): + nkey = key[:-len("_list")] + #print("addList:%s"%(nkey)) + for val in self.get_config_list(sections,key): + #print("addList:%s->%s"%(nkey,val)) + self.story.addToList(nkey,val.decode('utf-8').replace('{{storyId}}',idstr)) + else: + # Special cases: + if key in ['datePublished','dateUpdated']: + self.story.setMetadata(key,makeDate(self.get_config(sections,key),"%Y-%m-%d")) + else: + self.story.setMetadata(key,self.get_config(sections,key).decode('utf-8').replace('{{storyId}}',idstr)) + #print("set:%s->%s"%(key,self.story.getMetadata(key))) + + self.chapterUrls = [] + for (j,chap) in enumerate(self.get_config_list(sections,'chaptertitles'),start=1): + self.chapterUrls.append( (chap,self.url+"&chapter=%d"%j) ) + # self.chapterUrls = [(u'Prologue '+self.crazystring,self.url+"&chapter=1"), + # ('Chapter 1, Xenos on Cinnabar',self.url+"&chapter=2"), + # ] + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + return + + 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) + + if idstr == '666': + raise exceptions.StoryDoesNotExist(self.url) + + if idstr.startswith('670'): + time.sleep(1.0) + + if idstr.startswith('671'): + time.sleep(1.0) + + if self.getConfig("username"): + self.username = self.getConfig("username") + + if idstr == '668' and self.username != "Me" : + raise exceptions.FailedToLogin(self.url,self.username) + + if idstr == '664': + self.story.setMetadata(u'title',"Test Story Title "+idstr+self.crazystring) + self.story.setMetadata('author','Test Author aa bare amp(&) quote(') amp(&)') + else: + self.story.setMetadata(u'title',"Test Story Title "+idstr) + self.story.setMetadata('author','Test Author aa') + self.story.setMetadata('storyUrl',self.url) + self.setDescription(self.url,u'Description '+self.crazystring+u''' Done +<p> +Some more longer description. "I suck at summaries!" "Better than it sounds!" "My first fic" +''') + self.story.setMetadata('datePublished',makeDate("1975-03-15","%Y-%m-%d")) + if idstr == '669': + self.story.setMetadata('dateUpdated',datetime.datetime.now()) + else: + self.story.setMetadata('dateUpdated',makeDate("1975-04-15","%Y-%m-%d")) + self.story.setMetadata('numWords','123456') + + if idnum % 2 == 1: + self.story.setMetadata('status','In-Progress') + else: + self.story.setMetadata('status','Completed') + + # greater than 10, no language or series. + if idnum < 10: + langs = { + 0:"English", + 1:"Russian", + 2:"French", + 3:"German", + } + self.story.setMetadata('language',langs[idnum%len(langs)]) + self.setSeries('The Great Test',idnum) + self.story.setMetadata('seriesUrl','http://test1.com?seriesid=1') + if idnum == 0: + self.setSeries("A Nook Hyphen Test "+self.story.getMetadata('dateCreated'),idnum) + self.story.setMetadata('seriesUrl','http://test1.com?seriesid=0') + + self.story.setMetadata('rating','Tweenie') + + if idstr == '673': + self.story.addToList('author','Author From List 1') + self.story.addToList('author','Author From List 2') + self.story.addToList('author','Author From List 3') + self.story.addToList('author','Author From List 4') + self.story.addToList('author','Author From List 5') + self.story.addToList('author','Author From List 6') + self.story.addToList('author','Author From List 7') + self.story.addToList('author','Author From List 8') + self.story.addToList('author','Author From List 9') + self.story.addToList('author','Author From List 0') + self.story.addToList('author','Author From List q') + self.story.addToList('author','Author From List w') + self.story.addToList('author','Author From List e') + self.story.addToList('author','Author From List r') + self.story.addToList('author','Author From List t') + self.story.addToList('author','Author From List y') + self.story.addToList('author','Author From List u') + self.story.addToList('author','Author From List i') + self.story.addToList('author','Author From List o') + + self.story.addToList('authorId','98765-1') + self.story.addToList('authorId','98765-2') + self.story.addToList('authorId','98765-3') + self.story.addToList('authorId','98765-4') + self.story.addToList('authorId','98765-5') + self.story.addToList('authorId','98765-6') + self.story.addToList('authorId','98765-7') + self.story.addToList('authorId','98765-8') + self.story.addToList('authorId','98765-9') + self.story.addToList('authorId','98765-0') + self.story.addToList('authorId','98765-q') + self.story.addToList('authorId','98765-w') + self.story.addToList('authorId','98765-e') + self.story.addToList('authorId','98765-r') + self.story.addToList('authorId','98765-t') + self.story.addToList('authorId','98765-y') + self.story.addToList('authorId','98765-u') + self.story.addToList('authorId','98765-i') + self.story.addToList('authorId','98765-o') + + self.story.addToList('authorUrl','http://author/url-1') + self.story.addToList('authorUrl','http://author/url-2') + self.story.addToList('authorUrl','http://author/url-3') + self.story.addToList('authorUrl','http://author/url-4') + self.story.addToList('authorUrl','http://author/url-5') + self.story.addToList('authorUrl','http://author/url-6') + self.story.addToList('authorUrl','http://author/url-7') + self.story.addToList('authorUrl','http://author/url-8') + self.story.addToList('authorUrl','http://author/url-9') + self.story.addToList('authorUrl','http://author/url-0') + self.story.addToList('authorUrl','http://author/url-q') + self.story.addToList('authorUrl','http://author/url-w') + self.story.addToList('authorUrl','http://author/url-e') + self.story.addToList('authorUrl','http://author/url-r') + self.story.addToList('authorUrl','http://author/url-t') + self.story.addToList('authorUrl','http://author/url-y') + self.story.addToList('authorUrl','http://author/url-u') + self.story.addToList('authorUrl','http://author/url-i') + self.story.addToList('authorUrl','http://author/url-o') + + self.story.addToList('category','Power Rangers') + self.story.addToList('category','SG-1') + self.story.addToList('genre','Porn') + self.story.addToList('genre','Drama') + else: + self.story.setMetadata('authorId','98765') + self.story.setMetadata('authorUrl','http://author/url') + + self.story.addToList('warnings','Swearing') + self.story.addToList('warnings','Violence') + + if idstr == '80': + self.story.addToList('category',u'Rizzoli & Isles') + self.story.addToList('characters','J. Rizzoli') + elif idstr == '81': + self.story.addToList('category',u'Pitch Perfect') + self.story.addToList('characters','Chloe B.') + elif idstr == '82': + self.story.addToList('characters','Henry (Once Upon a Time)') + self.story.addToList('category',u'Once Upon a Time (TV)') + elif idstr == '83': + self.story.addToList('category',u'Rizzoli & Isles') + self.story.addToList('characters','J. Rizzoli') + self.story.addToList('category',u'Pitch Perfect') + self.story.addToList('characters','Chloe B.') + self.story.addToList('ships','Chloe B. & J. Rizzoli') + elif idstr == '90': + self.story.setMetadata('characters','Henry (Once Upon a Time)') + self.story.setMetadata('category',u'Once Upon a Time (TV)') + else: + self.story.addToList('category','Harry Potter') + self.story.addToList('category','Furbie') + self.story.addToList('category','Crossover') + self.story.addToList('category',u'Puella Magi Madoka Magica/魔法少女まどか★マギカ') + self.story.addToList('category',u'Magical Girl Lyrical Nanoha') + self.story.addToList('category',u'Once Upon a Time (TV)') + self.story.addToList('characters','Bob Smith') + self.story.addToList('characters','George Johnson') + self.story.addToList('characters','Fred Smythe') + self.story.addToList('ships','Harry Potter/Ginny Weasley') + self.story.addToList('ships','Harry Potter/Ginny Weasley/Albus Dumbledore') + self.story.addToList('ships','Harry Potter & Hermione Granger') + + self.story.addToList('genre','Fantasy') + self.story.addToList('genre','Comedy') + self.story.addToList('genre','Sci-Fi') + self.story.addToList('genre','Noir') + + self.story.addToList('listX','xVal1') + self.story.addToList('listX','xVal2') + self.story.addToList('listX','xVal3') + self.story.addToList('listX','xVal4') + + self.story.addToList('listY','yVal1') + self.story.addToList('listY','yVal2') + self.story.addToList('listY','yVal3') + self.story.addToList('listY','yVal4') + + self.story.addToList('listZ','zVal1') + self.story.addToList('listZ','zVal2') + self.story.addToList('listZ','zVal3') + self.story.addToList('listZ','zVal4') + + self.story.setMetadata('metaA','98765') + self.story.setMetadata('metaB','01245') + self.story.setMetadata('metaC','The mighty metaC!') + + self.chapterUrls = [(u'Prologue '+self.crazystring,self.url+"&chapter=1"), + ('Chapter 1, Xenos on Cinnabar',self.url+"&chapter=2"), + ('Chapter 2, Sinmay on Kintikin',self.url+"&chapter=3"), + ('Chapter 3, Over Cinnabar',self.url+"&chapter=4"), + ('Chapter 4',self.url+"&chapter=5"), + ('Chapter 5',self.url+"&chapter=6"), + ('Chapter 6',self.url+"&chapter=7"), + ('Chapter 7',self.url+"&chapter=8"), + ('Chapter 8',self.url+"&chapter=9"), + #('Chapter 9',self.url+"&chapter=0"), + #('Chapter 0',self.url+"&chapter=a"), + #('Chapter a',self.url+"&chapter=b"), + #('Chapter b',self.url+"&chapter=c"), + #('Chapter c',self.url+"&chapter=d"), + #('Chapter d',self.url+"&chapter=e"), + #('Chapter e',self.url+"&chapter=f"), + #('Chapter f',self.url+"&chapter=g"), + #('Chapter g',self.url+"&chapter=h"), + #('Chapter h',self.url+"&chapter=i"), + #('Chapter i',self.url+"&chapter=j"), + #('Chapter j',self.url+"&chapter=k"), + #('Chapter k',self.url+"&chapter=l"), + #('Chapter l',self.url+"&chapter=m"), + #('Chapter m',self.url+"&chapter=n"), + #('Chapter n',self.url+"&chapter=o"), + ] + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + + def getChapterText(self, url): + logger.debug('Getting chapter text from: %s' % url) + self.do_sleep() + if self.story.getMetadata('storyId').startswith('670') or \ + self.story.getMetadata('storyId').startswith('672'): + time.sleep(1.0) + + if "chapter=1" in url : + text=u''' +<div> +<h3>Prologue</h3> +<p>This is a fake adapter for testing purposes. Different sid's will give different errors:</p> +<h4>Config(personal.ini)</h4> +<p>sid>=1000 will use custom test story data from your configuration(personal.ini)</p> +<p>Hard coded ids:</p> +<p>http://test1.com?sid=664 - Crazy string title</p> +<p>http://test1.com?sid=665 - raises AdultCheckRequired</p> +<p>http://test1.com?sid=666 - raises StoryDoesNotExist</p> +<p>http://test1.com?sid=667 - raises FailedToDownload on chapters 2+</p> +<p>http://test1.com?sid=668 - raises FailedToLogin unless username='Me'</p> +<p>http://test1.com?sid=669 - Succeeds with Updated Date=now</p> +<p>http://test1.com?sid=670 - Succeeds, but sleeps 2sec on each chapter</p> +<p>http://test1.com?sid=671 - Succeeds, but sleeps 2sec metadata only</p> +<p>http://test1.com?sid=672 - Succeeds, quick meta, sleeps 2sec chapters only</p> +<p>http://test1.com?sid=673 - Succeeds, multiple authors, extra categories, genres</p> +<p>http://test1.com?sid=0 - Succeeds, generates some text specifically for testing hyphenation problems with Nook STR/STRwG</p> +<p>Odd sid's will be In-Progress, evens complete. sid<10 will be assigned one of four languages and included in a series.</p> +</div> +''' + elif self.story.getMetadata('storyId') == '0': + text=u'''<div> +<h3>45. Pronglet Returns to Hogwarts: Chapter 7</h3> +<br /> + eyes… but I’m not convinced we should automatically<br /> +<br /><br /> +<b>Thanks to the latest to recommend me: Alastor</b><br /> +<br /><br /> + “Sure, invite her along. Does she have children?”<br /> +<br /> +</div> +''' + else: + if self.story.getMetadata('storyId') == '667': + raise exceptions.FailedToDownload("Error downloading Chapter: %s!" % url) + + text=u''' +<div> +<h3>Chapter title from site</h3> +<p>Timestamp:'''+datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")+'''</p> +<p>Lorem '''+self.crazystring+u''' <i>italics</i>, <b>bold</b>, <u>underline</u> consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> +br breaks<br><br> +Puella Magi Madoka Magica/魔法少女まどか★マギカ +<!-- a href="http://code.google.com/p/fanficdownloader/wiki/FanFictionDownLoaderPluginWithReadingList" title="Tilt-a-Whirl by Jim & Sarah, on Flickr"><img src="http://i.imgur.com/bo8eD.png"></a --><br/> +br breaks<br><br> +Don't—e;ver—d;o—that—a;gain, 法 é +<hr> +horizontal rules +<hr size=1 noshade> +<p>"Lorem ipsum dolor sit amet", consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore--et dolore magna aliqua. 'Ut enim ad minim veniam', quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> +<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> +</div> +''' + soup = bs.BeautifulStoneSoup(text,selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + return self.utf8FromSoup(url,soup) + +def getClass(): + return TestSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_thealphagatecom.py b/fanficdownloader/adapters/adapter_thealphagatecom.py new file mode 100644 index 00000000..ce5abec0 --- /dev/null +++ b/fanficdownloader/adapters/adapter_thealphagatecom.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 TheAlphaGateComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class TheAlphaGateComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','tag') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d %b %Y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'www.thealphagate.com' + + @classmethod + 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.") + + # 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')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) # XXX + 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: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_thehexfilesnet.py b/fanficdownloader/adapters/adapter_thehexfilesnet.py new file mode 100644 index 00000000..8490f462 --- /dev/null +++ b/fanficdownloader/adapters/adapter_thehexfilesnet.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 TheHexFilesNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class TheHexFilesNetAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','thf') + + # 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 'thehexfiles.net' + + @classmethod + def getAcceptDomains(cls): + return ['www.thehexfiles.net','thehexfiles.net'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+"(www\.)?"+re.escape(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 + 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 + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + self.story.setMetadata('author',stripHTML(a)) + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + + try: + # in case link points somewhere other than the first chapter + a = soup.findAll('option')[1]['value'] + self.story.setMetadata('storyId',a.split('=',)[1]) + url = 'http://'+self.host+'/'+a + soup = bs.BeautifulSoup(self._fetchUrl(url)) + except: + pass + + for info in asoup.findAll('table', {'cellspacing' : '4'}): + a = info.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + if a != None: + self.story.setMetadata('title',stripHTML(a)) + break + + + # Find the chapters: + chapters=soup.findAll('a', href=re.compile(r'viewstory.php\?sid=\d+&i=1$')) + if len(chapters) == 0: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + 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)) + + + cats = info.findAll('a',href=re.compile('categories.php')) + for cat in cats: + self.story.addToList('category',cat.string) + + words = info.find(text=re.compile('Words:')).split('|')[1].split(': ')[1] + self.story.setMetadata('numWords', words) + + comp = info.find('span', {'class' : 'completed'}).string.split(': ')[1] + if 'Yes' in comp: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + summary = info.find('td', {'class' : 'summary'}) + summary.name='div' # change td to div so it doesn't mess up the display when using table titlepage. + self.setDescription(url,summary) + + rating=stripHTML(info.find('td', {'align' : 'left'})).split('(')[1].split(')')[0] + self.story.setMetadata('rating', rating) + + labels = info.findAll('td', {'width' : '10%'}) + values = info.findAll('td', {'width' : '40%'}) + for i in range(0,len(labels)): + value = stripHTML(values[i]) + label = stripHTML(labels[i]) + + if 'Genres' in label: + genres = value.split(', ') + for genre in genres: + if genre != 'none': + self.story.addToList('genre',genre) + + + if 'Warnings' in label: + warnings = value.split(', ') + for warning in warnings: + if warning != 'none': + self.story.addToList('warnings',warning) + + 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)) + + + # 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','img')) # otherwise soup eats the br/hr tags. + + if None == soup: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + # Ugh. chapter html doesn't haven't anything useful around it to demarcate. + for a in soup.findAll('table'): + a.extract() + + for a in soup.findAll('head'): + a.extract() + + html = soup.find('html') + html.name='div' + + return self.utf8FromSoup(url,soup) diff --git a/fanficdownloader/adapters/adapter_thehookupzonenet.py b/fanficdownloader/adapters/adapter_thehookupzonenet.py new file mode 100644 index 00000000..22636b8c --- /dev/null +++ b/fanficdownloader/adapters/adapter_thehookupzonenet.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +# By virtue of being recent and requiring both is_adult and user/pass, +# adapter_fanficcastletvnet.py is the best choice for learning to +# write adapters--especially for sites that use the eFiction system. +# Most sites that have ".../viewstory.php?sid=123" in the story URL +# are eFiction. + +# For non-eFiction sites, it can be considerably more complex, but +# this is still a good starting point. + +# In general an 'adapter' needs to do these five things: + +# - 'Register' correctly with the downloader +# - Site Login (if needed) +# - 'Are you adult?' check (if needed--some do one, some the other, some both) +# - Grab the chapter list +# - Grab the story meta-data (some (non-eFiction) adapters have to get it from the author page) +# - Grab the chapter texts + +# Search for XXX comments--that's where things are most likely to need changing. + +# 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 TheHookupZoneNetAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class TheHookupZoneNetAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # normalized story URL. + # XXX Most sites don't have the /fanfic part. Replace all to remove it usually. + self._setURL('http://' + self.getSiteDomain() + '/CriminalMinds/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','thupz') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%b %d, %Y" # XXX + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'thehookupzone.net' # XXX + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/CriminalMinds/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/CriminalMinds/viewstory.php?sid=")+r"\d+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/CriminalMinds/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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" # XXX + else: + addurl="" + + # index=1 makes sure we see the story chapter index. Some + # sites skip that for one-chapter stories. + 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) + + # The actual text that is used to announce you need to be an + # adult varies from site to site. Again, print data before + # the title search to troubleshoot. + if "Age Consent Required" in data: # XXX + raise exceptions.AdultCheckRequired(self.url) + + if "Access denied. This story has not been validated by the adminstrators of this site." in data: + raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + ## Title + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/CriminalMinds/'+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+'/CriminalMinds/'+chapter['href']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + ## Not all sites use Genre, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',genre.string) + + ## Not all sites use Warnings, but there's no harm to + ## leaving it in. Check to make sure the type_id number + ## is correct, though--it's site specific. + if 'Warnings' in label: + warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')) # XXX + warningstext = [warning.string for warning in warnings] + self.warning = ', '.join(warningstext) + for warning in warningstext: + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/CriminalMinds/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_themaplebookshelf.py b/fanficdownloader/adapters/adapter_themaplebookshelf.py new file mode 100644 index 00000000..51ce4b47 --- /dev/null +++ b/fanficdownloader/adapters/adapter_themaplebookshelf.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- + +# Copyright 2014 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 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 + + +class TheMapleBookshelfComSiteAdapter(BaseSiteAdapter): + """ + Use Printable version which is easier to parse and has everything in one + page and cache between extractChapterUrlsAndMetadata and getChapterText + """ + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','maplebook') + self.decode = ["Windows-1252", "utf8"] + self.story.setMetadata('storyId', re.compile(self.getSiteURLPattern()).match(url).group('storyId')) + self.dateformat = "%b %d, %Y" + + @staticmethod + def getSiteDomain(): + return 'themaplebookshelf.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://www.themaplebookshelf.com/Literati/viewstory.php?sid=227 http://themaplebookshelf.com/Literati/viewstory.php?sid=227&chapter=2" + + def getSiteURLPattern(self): + return r"http://themaplebookshelf.com/Literati/viewstory.php\?sid=(?P<storyId>\d+)" + + def extractChapterUrlsAndMetadata(self): + logger.debug(self.url) + self._setURL(self.url + "&action=printable") + try: + html = self._fetchUrl(self.url) + soup = bs.BeautifulSoup(html) + # #strip comments from soup + # [comment.extract() for comment in soup1.findAll(text=lambda text:isinstance(text, bs.Comment))] + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + ## title + author + pagetitleDiv = soup.find("div", {"id": "pagetitle"}) + self.story.setMetadata('title', pagetitleDiv.find("a").text) + authorLink = pagetitleDiv.findAll("a")[1] + self.story.setMetadata('author', authorLink.text) + self.story.setMetadata('authorUrl', "http://" + self.getSiteDomain() + "/" + authorLink['href']) + self.story.setMetadata('authorId', re.search("\d+", authorLink['href']).group(0)) + + ## Description + description = "" + summaryEnd = soup.find("div", "content").find("span", "label").nextSibling + while summaryEnd is not None: + description += stripHTML(summaryEnd) + summaryEnd = summaryEnd.nextSibling + if type(summaryEnd) != bs.NavigableString and summaryEnd.name == 'br': + break + self.story.setMetadata('description', description) + + ## General Metadata + for kSpan in soup.findAll("span", "label"): + k = kSpan.text.strip().replace(':', '') + vSpan = kSpan.nextSibling + if k == 'Summary:' or not vSpan or not vSpan.string: + continue + v = vSpan.string.strip() + if v == 'None': + continue + logger.debug("%s '%s'" %(k, v)) + if k == 'Genre': + for genre in v.split(", "): + self.story.addToList('genre', genre) + elif k == 'Chapters': + self.story.setMetadata('numChapters', int(v)) + elif k == 'Word count': + self.story.setMetadata('numWords', v) + elif k == 'Published': + self.story.setMetadata('datePublished', makeDate(v, self.dateformat)) + elif k == 'Updated': + self.story.setMetadata('dateUpdated', makeDate(v, self.dateformat)) + # TODO: Series, Warnings + + ## Chapter URLs (fragment identifiers in the document, so we don' need to fetch so much) + for chapterNumB in soup.findAll("b", text=re.compile("^\d+\.$")): + self.chapterUrls.append(( + chapterNumB.parent.parent.find("a").text, + self.url + chapterNumB.parent.parent.find("a")["href"] + )) + + ## fix all local image 'src' to absolute + for img in soup.findAll("img", {"src": re.compile("^(?!http)")}): + img['src'] = re.sub("viewstory.php\?.*", "", self.url) + img['src'] + + self.html = soup + + def getChapterText(self, url): + logger.debug('Getting chapter text from <%s>' % url) + anchor = url.replace(self.url, "") + anchor = anchor.replace("#", "") + chapterDiv = self.html.find("a", {"name": anchor}).parent.findNext("div", "chapter") + return self.utf8FromSoup(self.url, chapterDiv) + +def getClass(): + return TheMapleBookshelfComSiteAdapter diff --git a/fanficdownloader/adapters/adapter_themasquenet.py b/fanficdownloader/adapters/adapter_themasquenet.py new file mode 100644 index 00000000..e2e80ffb --- /dev/null +++ b/fanficdownloader/adapters/adapter_themasquenet.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 TheMasqueNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class TheMasqueNetAdapter(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]) + + + if self.parsedUrl.path.split('/',)[1] == 'wiktt': + self.story.addToList("category","Harry Potter") + self.section='/wiktt/efiction/' + self.dateformat = "%m/%d/%Y" + else: + self.story.addToList("category","Originals") + self.section='/efiction/' + self.dateformat = "%b %d, %Y" + + + # normalized story URL. + 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','msq') + + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'themasque.net' + + @classmethod + def getSiteExampleURLs(self): + return "http://themasque.net/wiktt/efiction/viewstory.php?sid=1234 http://themasque.net/efiction/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain())+"(/wiktt)?/efiction"+re.escape("/viewstory.php?sid=")+r"\d+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + 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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" + else: + addurl="" + + # index=1 makes sure we see the story chapter index. Some + # sites skip that for one-chapter stories. + url = self.url+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+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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 + self.section + chapter['href']+addurl)) + + 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 "" + +# 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'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.text + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + # 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_thepetulantpoetesscom.py b/fanficdownloader/adapters/adapter_thepetulantpoetesscom.py new file mode 100644 index 00000000..e0a8c4fb --- /dev/null +++ b/fanficdownloader/adapters/adapter_thepetulantpoetesscom.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 ThePetulantPoetessComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class ThePetulantPoetessComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId') +'&i=1') + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','tpp') + + # 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.thepetulantpoetess.com' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'You must be a member to read this story.' in data \ + or "The Ministry of Magic does not have a record of that password. " in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl, + params['penname'])) + + d = self._fetchUrl(loginUrl, params) + + if "My Account Page" 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): + + # 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 "Access denied. This story has not been validated by the adminstrators of this site." in data: + raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + # print data + + # Now go hunting for all the meta data and the chapter list. + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + self.story.setMetadata('author',a.string) + + # Find the chapters: + chapters=soup.find('select', {'name' : 'sid'}) + if chapters == None: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + else: + for chapter in chapters.findAll('option', value=re.compile(r"viewstory.php\?sid=\d+&i=1")): + # just in case there's tags, like <i> in chapter titles. + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+'/'+chapter['value'])) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + # make sure that the story id is from the first chapter + self.story.setMetadata('storyId',self.chapterUrls[0][1].split('=')[1].split('&')[0]) + + #locate the story on author's page + index = 1 + found = 0 + while found == 0: + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl')+"&page="+str(index))) + + for info in asoup.findAll('td', {'class' : 'highlightcolor1'}): + a = info.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + if a != None: + self.story.setMetadata('title',stripHTML(a)) + found = 1 + break + index=index+1 + + # extract metadata + b=info.find('b') + b.find('a').extract() + self.story.setMetadata('rating', b.text.split('[')[1].split(']')[0]) + + info = info.findNext('td', {'colspan' : '2'}) + for label in info.findAll('b'): + value = label.nextSibling + + if "Category" in label.text: + for cat in info.findAll('a'): + self.story.addToList('category',cat.string) + + if "Characters" in label.text: + for char in value.split(', '): + self.story.addToList('characters',char) + + if "Genres" in label.text: + for genre in value.split(', '): + if "General" not in genre: + self.story.addToList('genre',genre) + + if "Warnings" in label.text: + for warning in value.split(', '): + if "none" not in warning.lower(): + self.story.addToList('warnings',warning) + + + info = info.findNext('td', {'class' : 'tblborder'}) + info.find('b').extract() + self.setDescription(url,info) + + info = info.findNext('td', {'class' : 'highlightcolor2'}) + for label in info.findAll('b'): + value = label.nextSibling + + if "Completed" in label.text: + if 'Yes' in value: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + if 'Published' in label.text: + self.story.setMetadata('datePublished', makeDate(stripHTML(value), self.dateformat)) + + if 'Updated' in label.text: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value), self.dateformat)) + + if 'Word Count' in label.text: + self.story.setMetadata('numWords', value) + + + # 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.findAll('table')[2].findAll('td')[1] + for a in div.findAll('div'): + a.extract() + div.name='div' + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_thequidditchpitchorg.py b/fanficdownloader/adapters/adapter_thequidditchpitchorg.py new file mode 100644 index 00000000..98bc028e --- /dev/null +++ b/fanficdownloader/adapters/adapter_thequidditchpitchorg.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +# 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 TheQuidditchPitchOrgAdapter # XXX + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class TheQuidditchPitchOrgAdapter(BaseSiteAdapter): # XXX + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["Windows-1252", + "utf8"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + + # normalized story URL. + # XXX Most sites don't have the part. Replace all to remove it usually. + 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','tqdpch') # XXX + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%m/%d/%Y" # XXX + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'thequidditchpitch.org' # XXX + + @classmethod + def getAcceptDomains(cls): + return ['www.thequidditchpitch.org','thequidditchpitch.org'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+"(www\.)?"+re.escape(self.getSiteDomain()+"/viewstory.php?sid=")+r"\d+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only - Not suitable for readers under the age of legal consent in their country.' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" # XXX + else: + addurl="" + + # index=1 makes sure we see the story chapter index. Some + # sites skip that for one-chapter stories. + 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) + + # The actual text that is used to announce you need to be an + # adult varies from site to site. Again, print data before + # the title search to troubleshoot. + if ("Not suitable for readers under the age of legal consent in their country." in data \ + or "Not suitable for readers under 16 yrs. \r\nStories may contain violence, slight nudity, and/or sexual situations." in data ) \ + and not (self.is_adult or self.getConfig("is_adult")): # XXX + raise exceptions.AdultCheckRequired(self.url) + + if "Access denied. This story has not been validated by the adminstrators of this site." in data: + raise exceptions.FailedToDownload(self.getSiteDomain() +" says: Access denied. This story has not been validated by the adminstrators of this site.") + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + #print data + + # Now go hunting for all the meta data and the chapter list. + + ## Title + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId'))) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+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']+addurl)) + + 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 + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')) # XXX + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + 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')) # XXX + warningstext = [warning.string for warning in warnings] + self.warning = ', '.join(warningstext) + for warning in warningstext: + 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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + + soup = bs.BeautifulStoneSoup(self._fetchUrl(url), + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + # span? Really? span? Yeah... I don't think so. + div = soup.find('span', {'style' : 'font-size: 100%;'}) + div.name='div' + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_thewriterscoffeeshopcom.py b/fanficdownloader/adapters/adapter_thewriterscoffeeshopcom.py new file mode 100644 index 00000000..543ac1ef --- /dev/null +++ b/fanficdownloader/adapters/adapter_thewriterscoffeeshopcom.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib +import urllib2 + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class TheWritersCoffeeShopComSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','twcs') + 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() + '/library/viewstory.php?sid='+self.story.getMetadata('storyId')) + self.dateformat = "%d %b %Y" + + + @staticmethod + def getSiteDomain(): + return 'www.thewriterscoffeeshop.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/library/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/library/viewstory.php?sid=")+r"\d+$" + + 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() + '/library/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 + + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + addurl = "&ageconsent=ok&warning=3" + else: + addurl="" + + 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) + + if "Age Consent Required" in data: + 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.") + + # problems with some stories, but only in calibre. I suspect + # issues with different SGML parsers in python. This is a + # nasty hack, but it works. + data = data[data.index("<body"):] + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + + ## Title + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/library/'+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+'/library/'+chapter['href']+addurl)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + def defaultGetattr(d,k): + try: + return d[k] + except: + return "" + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + # poor HTML(unclosed <p> for one) can cause run on + # over the next label. + if '<span class="label">' in svalue: + svalue = svalue[0:svalue.find('<span class="label">')] + break + else: + value = value.nextSibling + self.setDescription(url,svalue) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class')) + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',genre.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 = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/library/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._fetchUrl(url) + # problems with some stories, but only in calibre. I suspect + # issues with different SGML parsers in python. This is a + # nasty hack, but it works. + data = data[data.index("<body"):] + + chapter=bs.BeautifulSoup('<div class="story"></div>') + + soup = bs.BeautifulSoup(data) + + found=False + for div in soup.findAll('div'): + if div.has_key('class') and div['class'] == 'notes': + chapter.append(div) + if div.has_key('id') and div['id'] == 'story': + chapter.append(div) + found=True + + if not found: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,chapter) + +def getClass(): + return TheWritersCoffeeShopComSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_tokrafandomnetcom.py b/fanficdownloader/adapters/adapter_tokrafandomnetcom.py new file mode 100644 index 00000000..70a1d90d --- /dev/null +++ b/fanficdownloader/adapters/adapter_tokrafandomnetcom.py @@ -0,0 +1,237 @@ +# -*- 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 TokraFandomnetComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class TokraFandomnetComAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','tokra') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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. But it + # doesn't matter too much anymore. + return 'tokra.fandomnet.com' + + @classmethod + 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): + + 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=3" + 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 + + m = re.search(r"'viewstory.php\?sid=\d+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + self.story.setMetadata('author',a.string) + + # Rating + rate = stripHTML(soup.find('div',{'id':'pagetitle'})) + rate = rate[rate.rindex('[')+1:rate.rindex(']')] + self.story.setMetadata('rating', rate) + + # 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.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. + + metadiv = soup.find('div',{'class':'content'}) + smalldiv = metadiv.find('div',{'class':'small'}) + + # tokra categories -> genre + # categories will be filled from ini. + genres = smalldiv.parent.findAll('a',href=re.compile(r'browse.php\?type=categories')) + for genre in genres: + self.story.addToList('genre',genre.string) + + chars = smalldiv.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + for char in chars: + self.story.addToList('characters',char.string) + + metatext = stripHTML(smalldiv) + + if 'Completed: Yes' in metatext: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + wordstart=metatext.rindex('Word count:')+12 + words = metatext[wordstart:metatext.index(' ',wordstart)] + self.story.setMetadata('numWords', words) + + datesdiv = soup.find('div',{'class':'bottom'}) + dates = stripHTML(datesdiv).split() + # Published: 04/26/2011 Updated: 03/06/2013 + self.story.setMetadata('datePublished', makeDate(dates[1], self.dateformat)) + self.story.setMetadata('dateUpdated', makeDate(dates[3], self.dateformat)) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # 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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # remove 'small' leaving only summary. + smalldiv.extract() + self.setDescription(url,metadiv) + + # 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', {'class' : 'content'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + # remove some decorations while keeping notes. + remove = div.find('div', {'id' : 'pagetitle'}) + remove.extract() + + for remove in div.findAll('div', {'class' : 'right'}): + remove.extract() + + for remove in div.findAll('div', {'class' : 'left'}): + remove.extract() + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_tolkienfanfiction.py b/fanficdownloader/adapters/adapter_tolkienfanfiction.py new file mode 100644 index 00000000..81130e15 --- /dev/null +++ b/fanficdownloader/adapters/adapter_tolkienfanfiction.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- + +""" +FFDL Adapter for TolkienFanFiction.com. + +Chapter URL: http://www.tolkienfanfiction.com/Story_Read_Chapter.php?CHid=1234 + Metadata + Link to Story URL [Index] + chapterTitle + storyTitle +Story URL: http://www.tolkienfanfiction.com/Story_Read_Head.php?STid=1034 + Metadata + Links to Chapter URLs + storyTitle + chapterTitle[s] + author + authorId + authorUrl + numChapters + wordCount + description/summary + rating TODO + genre TODO + Characters + Ages (specific) TODO +Search: http://www.tolkienfanfiction.com/Story_Chapter_Search.php?text=From+Wilderness+to+Cities+White&field=1&type=3&search=Search + Strategy + Search by exact phrase for styo + Metadata + dateUpdated + Parameters + field (field to search) + 1: title + 2: description + 3: chapter text + type (any, all or exact phrase) + 1: any + 2: all + 3: exact phrase + +""" +# Copyright 2014 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 urllib +import urllib2 +import urlparse +import string + +from .. import BeautifulSoup as bs +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +def _is_chapter_url(url): + if "Story_Read_Chapter.php" in url: + return True + else: + return False + +def _latinize(text): + """ + See http://stackoverflow.com/a/19114706/201318 + """ + src = u"áâäÉéêëíóôöúû" + tgt = u"aaaEeeeiooouu" + src_ord = [ord(char) for char in src] + translate_table = dict(zip(src_ord, tgt)) + return text.translate(translate_table) + +def _fix_broken_markup(html): + """Replaces invalid comment tags""" + if html.startswith("<CENTER>"): + logger.error("TolkienFanFiction.com couldn't handle this request: '%s'" % html) + html = re.sub("<!-.+?->", "", html) + return html + + +class TolkienFanfictionAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["ISO-8859-1", + "Windows-1252"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + + self.story.setMetadata('siteabbrev','tolkien') + + # 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 + def getSiteDomain(): + return 'tolkienfanfiction.com' + + @classmethod + def getAcceptDomains(cls): + return ['www.tolkienfanfiction.com'] + + @classmethod + def getSiteExampleURLs(self): + return 'http://www.tolkienfanfiction.com/Story_Read_Head.php?STid=1034 http://www.tolkienfanfiction.com/Story_Read_Chapter.php?CHid=4945' + + def getSiteURLPattern(self): + return r"http://www.tolkienfanfiction.com/(Story_Read_Chapter.php\?CH|Story_Read_Head.php\?ST)id=([0-9]+)" + + def extractChapterUrlsAndMetadata(self): + + # if not (self.is_adult or self.getConfig("is_adult")): + # raise exceptions.AdultCheckRequired(self.url) + + if not _is_chapter_url(self.url): + self.indexUrl = self.url + else: + # Get the link to the index page + try: + chapterHtml = _fix_broken_markup(self._fetchUrl(self.url)) + chapterSoup = bs.BeautifulSoup(chapterHtml) + indexLink = chapterSoup.find("a", text="[Index]").parent + self.indexUrl = 'http://' + self.host + '/' + indexLink.get('href') + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + logger.debug("Determined index page: <%s>" % self.indexUrl) + + storyId = self.indexUrl[self.indexUrl.index('=')+1:] + logger.debug("Story ID: %s" % storyId) + self.story.setMetadata('storyId', storyId) + + try: + indexHtml = _fix_broken_markup(self._fetchUrl(self.indexUrl)) + soup = bs.BeautifulSoup(indexHtml) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + + # chapterUrls + for pfLink in soup.findAll("a", text='[PF] '): + chapterLink = pfLink.parent.findNext("a") + chapterTitle = chapterLink.string + if self.getConfig('strip_chapter_numeral'): + chapterTitle = re.sub("^\d+:", "", chapterTitle) + chapterUrl = 'http://' + self.host + '/' + chapterLink['href'] + self.chapterUrls.append((chapterTitle, chapterUrl)) + numChapters = len(self.chapterUrls) + self.story.setMetadata('numChapters', numChapters) + logger.debug('Number of Chapters: %s' % numChapters) + + # title + title = soup.find("table", "headertitle").find("tr").contents[1].string + logger.debug("Title: '%s'" % title) + self.story.setMetadata('title', title) + + # author + authorLink = soup.find("a", {"href":lambda x: x.startswith("Author_Profile.php")}) + authorName = authorLink.find("b").string + authorHref = authorLink['href'] + authorUrl = 'http:' + self.host + '/' + authorHref + authorId = authorHref[authorHref.index('=')+1:] + self.story.setMetadata('author', authorName) + self.story.setMetadata('authorId', authorId) + self.story.setMetadata('authorUrl', authorUrl) + logger.debug("Author: %s [%s] @ <%s>" % (authorId, authorName, authorUrl)) + + # numWords + numWordsMatch = re.search("Word Count: (\d+)<BR>", indexHtml) + if numWordsMatch: + numWords = numWordsMatch.group(1) + logger.debug('Number of words: %s' % numWords) + self.story.setMetadata('numWords', numWords) + + # description + description = soup.find("b", text="Description:").parent.nextSibling.nextSibling + self.story.setMetadata('description', description) + logger.debug("Summary: '%s'" % description) + + # characters + characters = soup.find("b", text="Characters").parent.nextSibling.nextSibling.nextSibling + for character in characters.split(", "): + self.story.addToList('characters', character) + logger.debug("Characters: %s" % self.story.getMetadata('characters')) + + logger.debug('Title as `str`: ' + str(title)) + # For publication date we need to search + try: + queryString = urllib.urlencode(( + ('type', 3), + ('field', 1), + # need translate here for the weird accented letters + ('text', _latinize(title)), + ('search', 'Search'), + )) + searchUrl = 'http://%s/Story_Chapter_Search.php?%s' % (self.host, queryString) + logger.debug("Search URL: <%s>" % searchUrl) + searchHtml = _fix_broken_markup(self._fetchUrl(searchUrl)) + searchSoup = bs.BeautifulSoup(searchHtml) + date = searchSoup.find(text="Updated:").nextSibling.string + logger.debug("Last Updated: '%s'" % date) + self.story.setMetadata('dateUpdated', makeDate(date, self.dateformat)) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + + # Set the URL to the Index URL + self._setURL(self.indexUrl) + + def getChapterText(self, url): + + logger.debug('Downloading chapter <%s>' % url) + + time.sleep(0.5) + htmldata = _fix_broken_markup(self._fetchUrl(url)) + soup = bs.BeautifulSoup(htmldata) + + #strip comments from soup + [comment.extract() for comment in soup.findAll(text=lambda text:isinstance(text, bs.Comment))] + + # Strip redundant headings + [font.parent.extract() for font in soup.findAll("font", {"size": "4"})] + + # get story text + textDiv = soup.find("div", "text") + storytext = self.utf8FromSoup(url, textDiv) + + return storytext + + +def getClass(): + return TolkienFanfictionAdapter diff --git a/fanficdownloader/adapters/adapter_trekiverseorg.py b/fanficdownloader/adapters/adapter_trekiverseorg.py new file mode 100644 index 00000000..aa53e2e6 --- /dev/null +++ b/fanficdownloader/adapters/adapter_trekiverseorg.py @@ -0,0 +1,325 @@ +# -*- 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 TrekiverseOrgAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class TrekiverseOrgAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + self.decode = ["iso-8859-1", + "Windows-1252"] # 1252 is a superset of iso-8859-1. + # Most sites that claim to be + # iso-8859-1 (and some that claim to be + # utf8) are really windows-1252. + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + # normalized story URL. + self._setURL("http://"+self.getSiteDomain()\ + +"/efiction/viewstory.php?sid="+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','trkvs') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d/%m/%Y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. + return 'trekiverse.org' + + @classmethod + def getAcceptDomains(cls): + return ['trekiverse.org','efiction.trekiverse.org'] + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/efiction/viewstory.php?sid=1234 http://efiction."+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return r'(http://trekiverse\.org/efiction/viewstory\.php\?sid=\d+|http://efiction\.trekiverse\.org/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() + '/efiction/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): + + 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 = "&index=1&ageconsent=ok&warning=5" + else: + addurl="&index=1" + + # index=1 makes sure we see the story chapter index. Some + # sites skip that for one-chapter stories. + url = self.url+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+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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) + + # Now go hunting for all the meta data and the chapter list. + + ## Title and author + a = soup.find('div', {'id' : 'pagetitle'}) + aut = a.find('a', href=re.compile(r"^viewuser\.php\?uid=")) + self.story.setMetadata('authorId',aut['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/efiction/'+aut['href']) + self.story.setMetadata('author',aut.string) + + ttl = a.find('a', href=re.compile(r'^viewstory.php\?sid=%s$'%self.story.getMetadata('storyId'))) + self.story.setMetadata('title',ttl.string) + + # Find the chapters: + outputdiv = soup.find('div', {'id':'output'}) + # (amp;)? because it should be &, but is escaped to & in URL. + # viewstory.php?sid=35&chapter=3 + chapters=outputdiv.findAll('a', href=re.compile(r'^viewstory.php\?sid=%s&(amp;)?chapter=\d+$'%self.story.getMetadata('storyId'))) + 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+'/efiction/'+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+'/efiction/'+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. + + def defaultGetattr(d,k): + try: + return d[k] + except: + return "" + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = '' + while value and 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. + svalue=svalue[:svalue.find('<span class="label">')] + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=9')) + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + self.story.addToList('genre',genre.string) + + if 'Awards' in label: + awards = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=12')) + awardstext = [award.string for award in awards] + self.award = ', '.join(awardstext) + for award in awardstext: + self.story.addToList('awards',award.string) + + if 'Pairing' in label: + ships = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=10')) + shipstext = [ship.string for ship in ships] + self.ship = ', '.join(shipstext) + for ship in shipstext: + self.story.addToList('ships',ship.string) + + if 'Warnings' in label: + warnings = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=11')) + warningstext = [warning.string for warning in warnings] + self.warning = ', '.join(warningstext) + for warning in warningstext: + 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(value.strip(), "%d %b %Y")) + + if 'Updated' in label: + self.story.setMetadata('dateUpdated', makeDate(value.strip(), "%d %b %Y")) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/efiction/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + soup = bs.BeautifulSoup(self._fetchUrl(url)) # 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) + + notesdiv = soup.find('div', {'class':'noteinfo'}) + if notesdiv != None: + div.insert(0,"<hr>") + div.insert(0,notesdiv) + div.insert(0,"<hr>") + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_tthfanficorg.py b/fanficdownloader/adapters/adapter_tthfanficorg.py new file mode 100644 index 00000000..b43af1fb --- /dev/null +++ b/fanficdownloader/adapters/adapter_tthfanficorg.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib2 +import time + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class TwistingTheHellmouthSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','tth') + self.dateformat = "%d %b %y" + self.is_adult=False + self.username = None + self.password = None + # get storyId from url--url validation guarantees query correct + m = re.match(self.getSiteURLPattern(),url) + if m: + self.story.setMetadata('storyId',m.group('id')) + + # normalized story URL. + self._setURL("http://"+self.getSiteDomain()\ + +"/Story-"+self.story.getMetadata('storyId')) + else: + raise exceptions.InvalidStoryURL(url, + self.getSiteDomain(), + self.getSiteExampleURLs()) + + @staticmethod + def getSiteDomain(): + return 'www.tthfanfic.org' + + @classmethod + def getSiteExampleURLs(self): + return "http://www.tthfanfic.org/Story-1234 http://www.tthfanfic.org/Story-1234/Author+Story+Title.htm http://www.tthfanfic.org/T-99999999/Story-1234-1/Author+Story+Title.htm http://www.tthfanfic.org/story.php?no=12345" + + # http://www.tthfanfic.org/T-999999999999/Story-12345-1/Author+Story+Title.htm + # http://www.tthfanfic.org/Story-12345 + # http://www.tthfanfic.org/Story-12345/Author+Story+Title.htm + # http://www.tthfanfic.org/story.php?no=12345 + def getSiteURLPattern(self): + return r"http://www.tthfanfic.org(/(T-\d+/)?Story-|/story.php\?no=)(?P<id>\d+)(-\d+)?(/.*)?$" + + # tth won't send you future updates if you aren't 'caught up' + # on the story. Login isn't required for F21, but logging in will + # mark stories you've downloaded as 'read' on tth. + def performLogin(self): + params = {} + + if self.password: + params['urealname'] = self.username + params['password'] = self.password + else: + params['urealname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['loginsubmit'] = 'Login' + + if not params['password']: + return + + loginUrl = 'http://' + self.getSiteDomain() + '/login.php' + logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl, + params['urealname'])) + + ## need to pull empty login page first to get ctkn and + ## password name, which are BUSs +# <form method='post' action='/login.php' accept-charset="utf-8"> +# <input type='hidden' name='ctkn' value='4bdf761f5bea06bf4477072afcbd0f8d721d1a4f989c09945a9e87afb7a66de1'/> +# <input type='text' id='urealname' name='urealname' value=''/> +# <input type='password' id='password' name='6bb3fcd148d148629223690bf19733b8'/> +# <input type='submit' value='Login' name='loginsubmit'/> + soup = bs.BeautifulSoup(self._fetchUrl(loginUrl)) + params['ctkn']=soup.find('input', {'name':'ctkn'})['value'] + params[soup.find('input', {'id':'password'})['name']] = params['password'] + + d = self._fetchUrl(loginUrl, params) + + if "Stories Published" not in d : #Member Account + logger.info("Failed to login to URL %s as %s" % (loginUrl, + params['urealname'])) + raise exceptions.FailedToLogin(self.url,params['urealname']) + return False + else: + return True + + def extractChapterUrlsAndMetadata(self): + # fetch the chapter. From that we will get almost all the + # metadata and chapter list + + url=self.url + logger.debug("URL: "+url) + + # tth won't send you future updates if you aren't 'caught up' + # on the story. Login isn't required for F21, but logging in will + # mark stories you've downloaded as 'read' on tth. + self.performLogin() + + # use BeautifulSoup HTML parser to make everything easier to find. + try: + data = self._fetchUrl(url) + soup = bs.BeautifulSoup(data) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(url) + else: + raise e + + descurl = url + + if "<h2>Story Not Found</h2>" in data: + raise exceptions.StoryDoesNotExist(url) + + if self.is_adult or self.getConfig("is_adult"): + form = soup.find('form', {'id':'sitemaxratingform'}) + params={'ctkn':form.find('input', {'name':'ctkn'})['value'], + 'sitemaxrating':'5'} + logger.info("Attempting to get rating cookie for %s" % url) + data = self._postUrl("http://"+self.getSiteDomain()+'/setmaxrating.php',params) + # refetch story page. + data = self._fetchUrl(url) + soup = bs.BeautifulSoup(data) + + if "NOTE: This story is rated FR21 which is above your chosen filter level." in data: + raise exceptions.AdultCheckRequired(self.url) + + # http://www.tthfanfic.org/AuthorStories-3449/Greywizard.htm + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"^/AuthorStories-\d+")) + self.story.setMetadata('authorId',a['href'].split('/')[1].split('-')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+a['href']) + self.story.setMetadata('author',stripHTML(a)) + authorurl = 'http://'+self.host+a['href'] + + try: + # going to pull part of the meta data from *primary* author list page. + logger.debug("**AUTHOR** URL: "+authorurl) + authordata = self._fetchUrl(authorurl) + descurl=authorurl + authorsoup = bs.BeautifulSoup(authordata) + # author can have several pages, scan until we find it. + while( not authorsoup.find('a', href=re.compile(r"^/Story-"+self.story.getMetadata('storyId'))) ): + nextarrow = authorsoup.find('a', {'class':'arrowf'}) + if not nextarrow: + ## if rating is set lower than story, it won't be + ## visible on author lists unless. The *story* is + ## visible via the url, just not the entry on + ## author list. + raise exceptions.AdultCheckRequired(self.url) + nextpage = 'http://'+self.host+nextarrow['href'] + logger.debug("**AUTHOR** nextpage URL: "+nextpage) + authordata = self._fetchUrl(nextpage) + descurl=nextpage + authorsoup = bs.BeautifulSoup(authordata) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(url) + else: + raise e + + storydiv = authorsoup.find('div', {'id':'st'+self.story.getMetadata('storyId'), 'class':re.compile(r"storylistitem")}) + self.setDescription(descurl,storydiv.find('div',{'class':'storydesc'})) + #self.story.setMetadata('description',stripHTML(storydiv.find('div',{'class':'storydesc'}))) + self.story.setMetadata('title',stripHTML(storydiv.find('a',{'class':'storylink'}))) + + ainfo = soup.find('a', href='/StoryInfo-%s-1'%self.story.getMetadata('storyId')) + if ainfo != None: # indicates multiple authors/contributors. + try: + # going to pull part of the meta data from author list page. + infourl = 'http://'+self.host+ainfo['href'] + logger.debug("**StoryInfo** URL: "+infourl) + infodata = self._fetchUrl(infourl) + infosoup = bs.BeautifulSoup(infodata) + + # for a in infosoup.findAll('a',href=re.compile(r"^/Author-\d+")): + # self.story.addToList('authorId',a['href'].split('/')[1].split('-')[1]) + # self.story.addToList('authorUrl','http://'+self.host+a['href'].replace("/Author-","/AuthorStories-")) + # self.story.addToList('author',stripHTML(a)) + + # second verticaltable is the chapter list. + table = infosoup.findAll('table',{'class':'verticaltable'})[1] + for a in table.findAll('a',href=re.compile(r"^/Story-"+self.story.getMetadata('storyId'))): + autha = a.findNext('a',href=re.compile(r"^/Author-\d+")) + self.story.addToList('authorId',autha['href'].split('/')[1].split('-')[1]) + self.story.addToList('authorUrl','http://'+self.host+autha['href'].replace("/Author-","/AuthorStories-")) + self.story.addToList('author',stripHTML(autha)) + # include leading number to match 1. ... 2. ... + self.chapterUrls.append(("%d. %s by %s"%(len(self.chapterUrls)+1, + stripHTML(a), + stripHTML(autha)),'http://'+self.host+a['href'])) + + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(url) + else: + raise e + else: # single author: + # Find the chapter selector + select = soup.find('select', { 'name' : 'chapnav' } ) + + if select is None: + # no selector found, so it's a one-chapter story. + self.chapterUrls.append((self.story.getMetadata('title'),url)) + else: + allOptions = select.findAll('option') + for o in allOptions: + url = "http://"+self.host+o['value'] + # just in case there's tags, like <i> in chapter titles. + self.chapterUrls.append((stripHTML(o),url)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + verticaltable = soup.find('table', {'class':'verticaltable'}) + + BtVS = True + BtVSNonX = False + for cat in verticaltable.findAll('a', href=re.compile(r"^/Category-")): + if cat.string not in ['General', 'Non-BtVS/AtS Stories', 'Non-BTVS/AtS Stories', 'BtVS/AtS Non-Crossover', 'Non-BtVS Crossovers']: + self.story.addToList('category',cat.string) + else: + if 'Non-BtVS' in cat.string or 'Non-BTVS' in cat.string: + BtVS = False + if 'BtVS/AtS Non-Crossover' == cat.string: + BtVSNonX = True + + verticaltabletds = verticaltable.findAll('td') + self.story.setMetadata('rating', verticaltabletds[2].string) + self.story.setMetadata('numWords', verticaltabletds[4].string) + + # Complete--if completed. + if 'Yes' in verticaltabletds[10].string: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + self.story.setMetadata('datePublished',makeDate(stripHTML(verticaltabletds[8].string), self.dateformat)) + self.story.setMetadata('dateUpdated',makeDate(stripHTML(verticaltabletds[9].string), self.dateformat)) + + for icon in storydiv.find('span',{'class':'storyicons'}).findAll('img'): + if( icon['title'] not in ['Non-Crossover'] ) : + self.story.addToList('genre',icon['title']) + else: + if not BtVSNonX: + BtVS = False # Don't add BtVS if Non-Crossover, unless it's a BtVS/AtS Non-Crossover + + #print("BtVS: %s BtVSNonX: %s"%(BtVS,BtVSNonX)) + if BtVS: + self.story.addToList('category','Buffy: The Vampire Slayer') + + pseries = soup.find('p', {'style':'margin-top:0px'}) + m = re.match('This story is No\. (?P<num>\d+) in the series "(?P<series>.+)"\.', + pseries.text) + if m: + self.setSeries(m.group('series'),m.group('num')) + self.story.setMetadata('seriesUrl',"http://"+self.host+pseries.find('a')['href']) + + def getChapterText(self, url): + logger.debug('Getting chapter text from: %s' % url) + soup = bs.BeautifulSoup(self._fetchUrl(url)) + + div = soup.find('div', {'id' : 'storyinnerbody'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + # strip out included chapter title, if present, to avoid doubling up. + try: + div.find('h3').extract() + except: + pass + return self.utf8FromSoup(url,div) + +def getClass(): + return TwistingTheHellmouthSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_twilightarchivescom.py b/fanficdownloader/adapters/adapter_twilightarchivescom.py new file mode 100644 index 00000000..7995456b --- /dev/null +++ b/fanficdownloader/adapters/adapter_twilightarchivescom.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 TwilightArchivesComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class TwilightArchivesComAdapter(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.path.split('/',)[2]) + + + # normalized story URL. http://www.twilightarchives.com/read/9353 + self._setURL('http://' + self.getSiteDomain() + '/read/'+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','twa') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d %b %y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'www.twilightarchives.com' + + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/read/1234" + + def getSiteURLPattern(self): + return re.escape("http://" + self.getSiteDomain()+"/read/")+r"\d+(/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 + 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.") + + # 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('h1') + self.story.setMetadata('title',stripHTML(a)) + + # Find the chapters: + chapters=soup.find('ol', {'class' : 'chapters'}) + if chapters != None: + for chapter in chapters.findAll('a', href=re.compile(r'/read/'+self.story.getMetadata('storyId')+"/\d+$")): + self.chapterUrls.append((stripHTML(chapter),'http://'+self.host+chapter['href'])) + else: + self.chapterUrls.append((self.story.getMetadata('title'),url)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + +# rated, genre, warnings, seires + + summary = soup.find('p', {'class' : 'images'}) + self.setDescription(url,summary) + + for c in soup.findAll('h2', {'class' : 'title'}): + div = c.nextSibling.nextSibling + + if 'Information' in c.text: + for dt in div.findAll('dt'): + dd=dt.nextSibling.nextSibling + + if 'Author' in dt.text: + a=dd.find('a') + self.story.setMetadata('authorId',a['href'].split('/')[2]) + self.story.setMetadata('authorUrl','http://'+self.host+a['href']) + self.story.setMetadata('author',a.text) + + if 'Words' in dt.text: + self.story.setMetadata('numWords', dd.text) + + if 'Published' in dt.text: + self.story.setMetadata('datePublished', makeDate(stripHTML(dd.text), self.dateformat)) + + if 'Updated' in dt.text: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(dd.text), self.dateformat)) + + if 'Status' in dt.text: + if 'Complete' in dd.text: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + if 'Categories' in c.text: + for a in div.findAll('a'): + self.story.addToList('category',a.text) + + if 'Characters' in c.text: + for a in div.findAll('a'): + self.story.addToList('category',a.text) + + if 'Series' in c.text: + a=div.find('a') + series_name = a.text + series_url = 'http://'+self.host+a['href'] + + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.find('tbody').findAll('a', href=re.compile(r'^/read/\d+$')) + i=1 + for a in storyas: + if a['href'] == ('/read/'+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + asoup = bs.BeautifulSoup(self._fetchUrl(self.story.getMetadata('authorUrl'))) + a=asoup.find('tbody').find('a', href=re.compile(r'^/read/'+self.story.getMetadata('storyId'))) + self.story.setMetadata('rating',a.parent.nextSibling.nextSibling.text) + + # 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('div', {'class' : 'size images medium'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_twilightednet.py b/fanficdownloader/adapters/adapter_twilightednet.py new file mode 100644 index 00000000..f67e985c --- /dev/null +++ b/fanficdownloader/adapters/adapter_twilightednet.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib +import urllib2 + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class TwilightedNetSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','tw') + 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 = "" + + # 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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + + @staticmethod + def getSiteDomain(): + return 'www.twilighted.net' + + @classmethod + def getAcceptDomains(cls): + return ['www.twilighted.net','twilighted.net'] + + @classmethod + def getSiteExampleURLs(self): + return "http://www.twilighted.net/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+r"(www\.)?"+re.escape("twilighted.net/viewstory.php?sid=")+r"\d+$" + + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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 + + def extractChapterUrlsAndMetadata(self): + + 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 self.needToLoginCheck(data): + # need to log in for this one. + self.performLogin(url) + data = self._fetchUrl(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.") + + # problems with some stories, but only in calibre. I suspect + # issues with different SGML parsers in python. This is a + # nasty hack, but it works. + # twilighted isn't writing <body> ??? wtf? + data = "<html><body>"+data[data.index("</head>"):] + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + + ## Title + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php")) + 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)) + + def defaultGetattr(d,k): + try: + return d[k] + except: + return "" + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + ## twilighted.net doesn't use genre. + # if 'Genre' in label: + # genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class')) + # genrestext = [genre.string for genre in genres] + # self.genre = ', '.join(genrestext) + # for genre in genrestext: + # self.story.addToList('genre',genre.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(value.strip(), "%B %d, %Y")) + + if 'Updated' in label: + # there's a stray [ at the end. + #value = value[0:-1] + self.story.setMetadata('dateUpdated', makeDate(value.strip(), "%B %d, %Y")) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._fetchUrl(url) + # problems with some stories, but only in calibre. I suspect + # issues with different SGML parsers in python. This is a + # nasty hack, but it works. + # twilighted isn't writing <body> ??? wtf? + data = "<html><body>"+data[data.index("</head>"):] + + soup = bs.BeautifulStoneSoup(data, + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + span = soup.find('div', {'id' : 'story'}) + + if None == span: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,span) + +def getClass(): + return TwilightedNetSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_twiwritenet.py b/fanficdownloader/adapters/adapter_twiwritenet.py new file mode 100644 index 00000000..d88d9320 --- /dev/null +++ b/fanficdownloader/adapters/adapter_twiwritenet.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +logger = logging.getLogger(__name__) +import re +import urllib +import urllib2 + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from .. import exceptions as exceptions + +from base_adapter import BaseSiteAdapter, makeDate + +class TwiwriteNetSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','twrt') + 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.is_adult = False + self.username = "NoneGiven" # if left empty, twiwrite.net doesn't return any message at all. + self.password = "" + + # 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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + + @staticmethod + def getSiteDomain(): + return 'www.twiwrite.net' + + @classmethod + def getAcceptDomains(cls): + return ['www.twiwrite.net','twiwrite.net'] + + @classmethod + def getSiteExampleURLs(self): + return "http://www.twiwrite.net/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://")+r"(www\.)?"+re.escape("twiwrite.net/viewstory.php?sid=")+r"\d+$" + + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + logger.info("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 + + 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=1" # XXX + else: + addurl="" + + 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) + + 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.") + + if "Contains Explicit Content for mature adults only! May contain graphic violence, mature sexual situations, and explicit language. Read with caution." in data: + raise exceptions.AdultCheckRequired(self.url) + + # problems with some stories, but only in calibre. I suspect + # issues with different SGML parsers in python. This is a + # nasty hack, but it works. + data = data[data.index("<body"):] + + # use BeautifulSoup HTML parser to make everything easier to find. + soup = bs.BeautifulSoup(data) + + pagetitlediv = soup.find('div',id='pagetitle') + + ## Title + a = pagetitlediv.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = pagetitlediv.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']+addurl)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + ## <meta name='description' content='<p>Description</p> ...' > + ## Summary, strangely, is in the content attr of a <meta name='description'> tag + ## which is escaped HTML. Unfortunately, we can't use it because they don't + ## escape (') chars in the desc, breakin the tag. + #meta_desc = soup.find('meta',{'name':'description'}) + #metasoup = bs.BeautifulStoneSoup(meta_desc['content']) + #self.story.setMetadata('description',stripHTML(metasoup)) + + def defaultGetattr(d,k): + try: + return d[k] + except: + return "" + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=3')) + genrestext = [genre.string for genre in genres] + self.genre = ', '.join(genrestext) + for genre in genrestext: + 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=8')) + warningstext = [warning.string for warning in warnings] + self.warning = ', '.join(warningstext) + for warning in warningstext: + self.story.addToList('warning',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(value.strip(), "%B %d, %Y")) + + if 'Updated' in label: + # there's a stray [ at the end. + value = value[0:-1] + self.story.setMetadata('dateUpdated', makeDate(value.strip(), "%B %d, %Y")) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + def getChapterText(self, url): + + logger.debug('Getting chapter text from: %s' % url) + + data = self._fetchUrl(url) + # problems with some stories, but only in calibre. I suspect + # issues with different SGML parsers in python. This is a + # nasty hack, but it works. + data = data[data.index("<body"):] + + soup = bs.BeautifulStoneSoup(data, + selfClosingTags=('br','hr')) # otherwise soup eats the br/hr tags. + + span = soup.find('div', {'id' : 'story'}) + + if None == span: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,span) + +def getClass(): + return TwiwriteNetSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_voracity2eficcom.py b/fanficdownloader/adapters/adapter_voracity2eficcom.py new file mode 100644 index 00000000..3b59935d --- /dev/null +++ b/fanficdownloader/adapters/adapter_voracity2eficcom.py @@ -0,0 +1,232 @@ +import re +import urllib2 +import urlparse + +from .. import BeautifulSoup + +from base_adapter import BaseSiteAdapter, makeDate +from .. import exceptions + + +def getClass(): + return Voracity2EficComAdapter + + +# yields Tag _and_ NavigableString siblings from the given tag. The +# BeautifulSoup findNextSiblings() method for some reasons only returns either +# NavigableStrings _or_ Tag objects, not both. +def _yield_next_siblings(tag): + sibling = tag.nextSibling + while sibling: + yield sibling + sibling = sibling.nextSibling + + +class Voracity2EficComAdapter(BaseSiteAdapter): + SITE_ABBREVIATION = 'voe' + SITE_DOMAIN = 'voracity2.e-fic.com' + + BASE_URL = 'http://' + SITE_DOMAIN + '/' + LOGIN_URL = BASE_URL + 'user.php?action=login' + VIEW_STORY_URL_TEMPLATE = BASE_URL + 'viewstory.php?sid=%d' + METADATA_URL_SUFFIX = '&index=1' + AGE_CONSENT_URL_SUFFIX = '&ageconsent=ok&warning=4' + + DATETIME_FORMAT = '%m/%d/%Y' + REQUIRED_SKIN = 'Simple Elegance' + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + + query_data = urlparse.parse_qs(self.parsedUrl.query) + story_id = query_data['sid'][0] + + self.story.setMetadata('storyId', story_id) + self._setURL(self.VIEW_STORY_URL_TEMPLATE % int(story_id)) + self.story.setMetadata('siteabbrev', self.SITE_ABBREVIATION) + + self.is_logged_in = False + + def _login(self): + # Apparently self.password is only set when login fails, i.e. + # the FailedToLogin exception is raised, so the adapter gets new + # login data and tries again + if self.password: + password = self.password + username = self.username + else: + username = self.getConfig('username') + password = self.getConfig('password') + + parameters = { + 'penname': username, + 'password': password, + 'submit': 'Submit'} + + class CustomizedFailedToLogin(exceptions.FailedToLogin): + def __init__(self, url, passwdonly=False): + # Use username variable from outer scope + exceptions.FailedToLogin.__init__(self, url, username, passwdonly) + + soup = self._customized_fetch_url(self.LOGIN_URL, CustomizedFailedToLogin, parameters) + div = soup.find('div', id='useropts') + if not div: + raise CustomizedFailedToLogin(self.LOGIN_URL) + + self.is_logged_in = True + + def _customized_fetch_url(self, url, exception=None, parameters=None): + if exception: + try: + data = self._fetchUrl(url, parameters) + except urllib2.HTTPError: + raise exception(self.url) + # Just let self._fetchUrl throw the exception, don't catch and + # customize it. + else: + data = self._fetchUrl(url, parameters) + + return BeautifulSoup.BeautifulSoup(data) + + @staticmethod + def getSiteDomain(): + return Voracity2EficComAdapter.SITE_DOMAIN + + @classmethod + def getSiteExampleURLs(cls): + return cls.VIEW_STORY_URL_TEMPLATE % 1234 + + def getSiteURLPattern(self): + return re.escape(self.VIEW_STORY_URL_TEMPLATE[:-2]) + r'\d+$' + + def extractChapterUrlsAndMetadata(self): + soup = self._customized_fetch_url(self.url + self.METADATA_URL_SUFFIX) + + # Check if the story is for "Registered Users Only", i.e. has adult + # content. Based on the "is_adult" attributes either login or raise an + # error. + errortext_div = soup.find('div', {'class': 'errortext'}) + if errortext_div: + error_text = ''.join(errortext_div(text=True)).strip() + if error_text == 'Registered Users Only': + if not (self.is_adult or self.getConfig('is_adult')): + raise exceptions.AdultCheckRequired(self.url) + self._login() + else: + # This case usually occurs when the story doesn't exist, but + # might potentially be something else, so just raise + # FailedToDownload exception with the found error text. + raise exceptions.FailedToDownload(error_text) + + url = ''.join([self.url, self.METADATA_URL_SUFFIX, self.AGE_CONSENT_URL_SUFFIX]) + soup = self._customized_fetch_url(url) + + # If logged in and the skin doesn't match the required skin throw an + # error + if self.is_logged_in: + skin = soup.find('select', {'name': 'skin'}).find('option', selected=True)['value'] + if skin != self.REQUIRED_SKIN: + raise exceptions.FailedToDownload('Required skin "%s" must be set in preferences' % self.REQUIRED_SKIN) + + pagetitle_div = soup.find('div', id='pagetitle') + self.story.setMetadata('title', pagetitle_div.a.string) + + author_anchor = pagetitle_div.a.findNextSibling('a') + url = urlparse.urljoin(self.BASE_URL, author_anchor['href']) + components = urlparse.urlparse(url) + query_data = urlparse.parse_qs(components.query) + + self.story.setMetadata('author', author_anchor.string) + self.story.setMetadata('authorId', query_data['uid']) + self.story.setMetadata('authorUrl', url) + + sort_div = soup.find('div', id='sort') + self.story.setMetadata('reviews', sort_div('a')[1].string) + + for b_tag in soup.find('div', {'class': 'listbox'})('b'): + key = b_tag.string.strip(' :') + try: + value = b_tag.nextSibling.string.strip() + # This can happen with some fancy markup in the summary. Just + # ignore this error and set value to None, the summary parsing + # takes care of this + except AttributeError: + value = None + + if key == 'Summary': + contents = [] + keep_summary_html = self.getConfig('keep_summary_html') + + for sibling in _yield_next_siblings(b_tag): + if isinstance(sibling, BeautifulSoup.Tag): + # Encountered next label, break. This method is the + # safest and most reliable I could think of. Blame + # e-fiction sites that allow their users to include + # arbitrary markup into their summaries and the + # horrible HTML markup. + if sibling.name == 'b' and sibling.findPreviousSibling().name == 'br': + break + + if keep_summary_html: + contents.append(self.utf8FromSoup(self.url, sibling)) + else: + contents.append(''.join(sibling(text=True))) + else: + contents.append(sibling) + + # Remove the preceding break line tag and other crud + contents.pop() + contents.pop() + self.story.setMetadata('description', ''.join(contents)) + + elif key == 'Rating': + self.story.setMetadata('rating', value) + + elif key == 'Category': + for sibling in b_tag.findNextSiblings(['a', 'br']): + if sibling.name == 'br': + break + self.story.addToList('category', sibling.string) + + # Seems to be always "None" for some reason + elif key == 'Characters': + for sibling in b_tag.findNextSiblings(['a', 'br']): + if sibling.name == 'br': + break + self.story.addToList('characters', sibling.string) + + elif key == 'Series': + a = b_tag.findNextSibling('a') + if not a: + continue + self.story.setMetadata('series', a.string) + self.story.setMetadata('seriesUrl', urlparse.urljoin(self.BASE_URL, a['href'])) + + elif key == 'Chapter': + self.story.setMetadata('numChapters', int(value)) + + elif key == 'Completed': + self.story.setMetadata('status', 'Completed' if value == 'Yes' else 'In-Progress') + + elif key == 'Words': + self.story.setMetadata('numWords', value) + + elif key == 'Read': + self.story.setMetadata('readings', value) + + elif key == 'Published': + self.story.setMetadata('datePublished', makeDate(value, self.DATETIME_FORMAT)) + + elif key == 'Updated': + self.story.setMetadata('dateUpdated', makeDate(value, self.DATETIME_FORMAT)) + + for b_tag in soup.find('div', id='output').findNextSiblings('b'): + chapter_anchor = b_tag.a + title = chapter_anchor.string + url = urlparse.urljoin(self.BASE_URL, chapter_anchor['href']) + self.chapterUrls.append((title, url)) + + def getChapterText(self, url): + url += self.AGE_CONSENT_URL_SUFFIX + soup = self._customized_fetch_url(url) + return self.utf8FromSoup(url, soup.find('div', id='story')) diff --git a/fanficdownloader/adapters/adapter_walkingtheplankorg.py b/fanficdownloader/adapters/adapter_walkingtheplankorg.py new file mode 100644 index 00000000..11f4fc42 --- /dev/null +++ b/fanficdownloader/adapters/adapter_walkingtheplankorg.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 WalkingThePlankOrgAdapter + +class WalkingThePlankOrgAdapter(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() + '/archive/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','wtp') + + # 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 'www.walkingtheplank.org' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/archive/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/archive/viewstory.php?sid=")+r"\d+$" + + + ## Getting the chapter list and the meta data, plus 'is adult' checking. + def extractChapterUrlsAndMetadata(self): + + if self.is_adult or self.getConfig("is_adult"): + # 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 = "&warning=4" + 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 + + + # The actual text that is used to announce you need to be an + # adult varies from site to site. Again, print data before + # the title search to troubleshoot. + + if "By clicking this link, you acknowledge" in data: + 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 + a = soup.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php\?uid=\d+")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/archive/'+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+'/archive/'+chapter['href']+addurl)) + + 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 "" + + labels = soup.findAll('span',{'class':'label'}) + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + #self.story.setMetadata('description',stripHTML(svalue)) + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + 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')) + catstext = [cat.string for cat in cats] + for cat in catstext: + self.story.addToList('category',cat.string) + + if 'Characters' in label: + chars = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=characters')) + charstext = [char.string for char in chars] + for char in charstext: + self.story.addToList('characters',char.string) + + if 'Genre' in label: + genres = labelspan.parent.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')) + 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')) # XXX + 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: + 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+'/archive/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_whoficcom.py b/fanficdownloader/adapters/adapter_whoficcom.py new file mode 100644 index 00000000..9ce5c8cb --- /dev/null +++ b/fanficdownloader/adapters/adapter_whoficcom.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 + +class WhoficComSiteAdapter(BaseSiteAdapter): + + def __init__(self, config, url): + BaseSiteAdapter.__init__(self, config, url) + self.story.setMetadata('siteabbrev','whof') + 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. + + @staticmethod + def getSiteDomain(): + return 'www.whofic.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/viewstory.php?sid=")+"\d+$" + + def extractChapterUrlsAndMetadata(self): + + # get storyId from url--url validation guarantees query is only sid=1234 + self.story.setMetadata('storyId',self.parsedUrl.query.split('=',)[1]) + + # fetch the first chapter. From that we will: + # - determine title, authorname, authorid + # - get chapter list, if not one-shot. + + url = self.url+'&chapter=1' + logger.debug("URL: "+url) + + # use BeautifulSoup HTML parser to make everything easier to find. + try: + soup = bs.BeautifulSoup(self._fetchUrl(url)) + except urllib2.HTTPError, e: + if e.code == 404: + raise exceptions.StoryDoesNotExist(self.url) + else: + raise e + + # pull title(title) and author from the HTML title. + title = stripHTML(soup.find('title')) + logger.debug('Title: %s' % title) + title = title.split('::')[1].strip() + self.story.setMetadata('title',title.split(' by ')[0].strip()) + self.story.setMetadata('author',title.split(' by ')[1].strip()) + + # Find authorid and URL from... author url. + a = soup.find('a', href=re.compile(r"viewuser.php")) + self.story.setMetadata('authorId',a['href'].split('=')[1]) + self.story.setMetadata('authorUrl','http://'+self.host+'/'+a['href']) + + # Find the chapter selector + select = soup.find('select', { 'name' : 'chapter' } ) + + if select is None: + # no selector found, so it's a one-chapter story. + self.chapterUrls.append((self.story.getMetadata('title'),url)) + else: + allOptions = select.findAll('option') + for o in allOptions: + url = self.url + "&chapter=%s" % o['value'] + # just in case there's tags, like <i> in chapter titles. + title = "%s" % o + title = re.sub(r'<[^>]+>','',title) + self.chapterUrls.append((title,url)) + + self.story.setMetadata('numChapters',len(self.chapterUrls)) + + ## Whofic.com puts none of the other meta data in the chapters + ## or even the story chapter index page. Need to scrape the + ## author page to find it. + + # <table width="100%" bordercolor="#333399" border="0" cellspacing="0" cellpadding="2"><tr><td> + # <b><a href="viewstory.php?sid=38220">Accompaniment 2</a></b> by <a href="viewuser.php?uid=12412">clandestinemiscreant</a> [<a href="reviews.php?sid=38220">Reviews</a> - <a href="reviews.php?sid=38220">0</a>] <br> + # This is a series of short stories written as an accompaniment to Season 2, Season 28 for us oldies, and each is unrelated except for that one factor. Each story is canon, in that it does not change established events at time of airing, based on things mentioned and/or implied and missing or deleted scenes that were not seen in the final aired episodes.<br> + # <font size="-1"><b><a href="categories.php?catid=15">Tenth Doctor</a></b> - All Ages - None - Humor, Hurt/Comfort, Romance<br> + # <i>Characters:</i> Rose Tyler<br> + # <i>Series:</i> None<br> + # <i>Published:</i> 2010.08.15 - <i>Updated:</i> 2010.08.16 - <i>Chapters:</i> 4 - <i>Completed:</i> Yes - <i>Word Count:</i> 4890 </font> + # </td></tr></table> + + logger.debug("Author URL: "+self.story.getMetadata('authorUrl')) + soup = bs.BeautifulStoneSoup(self._fetchUrl(self.story.getMetadata('authorUrl')), + selfClosingTags=('br')) # normalize <br> tags to <br /> + # find this story in the list, parse it's metadata based on + # lots of assumptions about the html, since there's little + # tagging. + # Found a story once that had the story URL in the desc for a + # series on the same author's page. Now using the reviews + # link instead to find the appropriate metadata. + a = soup.find('a', href=re.compile(r'reviews.php\?sid='+self.story.getMetadata('storyId'))) + metadata = a.findParent('td') + metadatachunks = self.utf8FromSoup(None,metadata).split('<br />') + # process metadata for this story. + self.setDescription(url,metadatachunks[1]) + #self.story.setMetadata('description', metadatachunks[1]) + + # First line of the stuff with ' - ' separators + moremeta = metadatachunks[2] + moremeta = re.sub(r'<[^>]+>','',moremeta) # strip tags. + + moremetaparts = moremeta.split(' - ') + + # first part is category--whofic.com has categories + # Doctor One-11, Torchwood, etc. We're going to + # prepend any with 'Doctor' or 'Era' (Multi-Era, Other + # Era) as 'Doctor Who'. + # + # Also push each in as 'extra tags'. + category = moremetaparts[0] + if 'Doctor' in category or 'Era' in category : + self.story.addToList('category','Doctor Who') + + for cat in category.split(', '): + self.story.addToList('category',cat) + + # next in that line is age rating. + self.story.setMetadata('rating',moremetaparts[1]) + + # after that is a possible list fo specific warnings, + # Explicit Violence, Swearing, etc + if "None" not in moremetaparts[2]: + for warn in moremetaparts[2].split(', '): + self.story.addToList('warnings',warn) + + # then genre. It's another comma list. All together + # in genre, plus each in extra tags. + genre=moremetaparts[3] + for g in genre.split(r', '): + self.story.addToList('genre',g) + + # line 3 is characters. + chars = metadatachunks[3] + charsearch="<i>Characters:</i>" + if charsearch in chars: + chars = chars[metadatachunks[3].index(charsearch)+len(charsearch):] + for c in chars.split(','): + if c.strip() != u'None': + self.story.addToList('characters',c) + + # the next line is stuff with ' - ' separators *and* names--with tags. + moremeta = metadatachunks[5] + moremeta = re.sub(r'<[^>]+>','',moremeta) # strip tags. + + moremetaparts = moremeta.split(' - ') + + for part in moremetaparts: + (name,value) = part.split(': ') + name=name.strip() + value=value.strip() + if name == 'Published': + self.story.setMetadata('datePublished', makeDate(value, '%Y.%m.%d')) + if name == 'Updated': + self.story.setMetadata('dateUpdated', makeDate(value, '%Y.%m.%d')) + if name == 'Completed': + if value == 'Yes': + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + if name == 'Word Count': + self.story.setMetadata('numWords', value) + + try: + # Find Series name from series URL. + a = metadata.find('a', href=re.compile(r"series.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + 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. + + + # hardly a great identifier, I know, but whofic really doesn't + # give us anything better to work with. + span = soup.find('span', {'style' : 'font-size: 100%;'}) + + if None == span: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + span.name='div' + return self.utf8FromSoup(url,span) + +def getClass(): + return WhoficComSiteAdapter + diff --git a/fanficdownloader/adapters/adapter_wizardtalesnet.py b/fanficdownloader/adapters/adapter_wizardtalesnet.py new file mode 100644 index 00000000..3f60560e --- /dev/null +++ b/fanficdownloader/adapters/adapter_wizardtalesnet.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 WizardTalesNetAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class WizardTalesNetAdapter(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() + '/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','wzt') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.wizardtales.net' + + @classmethod + 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+$" + + ## Login seems to be reasonably standard across eFiction sites. + def needToLoginCheck(self, data): + if 'Registered Users Only' in data \ + or 'There is no such account on our website' in data \ + or "That password doesn't match the one in our database" in data: + return True + else: + return False + + def performLogin(self, url): + params = {} + + if self.password: + params['penname'] = self.username + params['password'] = self.password + else: + params['penname'] = self.getConfig("username") + params['password'] = self.getConfig("password") + params['cookiecheck'] = '1' + params['submit'] = 'Submit' + + loginUrl = 'http://' + self.getSiteDomain() + '/user.php?action=login' + 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): + + if self.is_adult or self.getConfig("is_adult"): + # Weirdly, different sites use different warning numbers. + # If the title search below fails, there's a good chance + # you need a different number. print data at that point + # and see what the 'click here to continue' url says. + addurl = "&ageconsent=ok&warning=4" + else: + addurl="" + + # index=1 makes sure we see the story chapter index. Some + # sites skip that for one-chapter stories. + url = self.url+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+((?:&ageconsent=ok)?&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 & error in url. + addurl = addurl.replace("&","&") + 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 + pt = soup.find('div', {'id' : 'pagetitle'}) + a = pt.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = pt.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) + + rating=pt.text.split('[')[1].split(']')[0] + self.story.setMetadata('rating', rating) + + # 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.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. + + # <span class="label">Rated:</span> NC-17<br /> etc + content=soup.find('div',{'class' : 'content'}) + + for genre in content.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=1')): + self.story.addToList('genre',genre.string) + + for warning in content.findAll('a',href=re.compile(r'browse.php\?type=class&type_id=2')): + self.story.addToList('warnings',warning.string) + + labels = content.findAll('b') + + value = labels[0].previousSibling + svalue = "" + while value != None: + val = value + value = value.previousSibling + while "Categories" not in val: + svalue += str(val) + val = val.nextSibling + self.setDescription(url,svalue) + + + + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Summary' in label: + ## Everything until the next span class='label' + svalue = "" + while not defaultGetattr(value,'class') == 'label': + svalue += str(value) + value = value.nextSibling + self.setDescription(url,svalue) + + if 'Word count' in label: + self.story.setMetadata('numWords', stripHTML(value).split(': ')[1].split(';')[0]) + + 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).split(': ')[1].split(';')[0], self.dateformat)) + + if 'Updated' in label: + self.story.setMetadata('dateUpdated', makeDate(stripHTML(value).split(': ')[1], self.dateformat)) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + # 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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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.findAll('div', {'id' : 'story'})[1] + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_wolverineandroguecom.py b/fanficdownloader/adapters/adapter_wolverineandroguecom.py new file mode 100644 index 00000000..4bdfb913 --- /dev/null +++ b/fanficdownloader/adapters/adapter_wolverineandroguecom.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 WolverineAndRogueComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class WolverineAndRogueComAdapter(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() + '/wrfa/viewstory.php?sid='+self.story.getMetadata('storyId')) + + # Each adapter needs to have a unique site abbreviation. + self.story.setMetadata('siteabbrev','wrfa') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + 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.wolverineandrogue.com' + + @classmethod + def getSiteExampleURLs(self): + return "http://"+self.getSiteDomain()+"/wrfa/viewstory.php?sid=1234" + + def getSiteURLPattern(self): + return re.escape("http://"+self.getSiteDomain()+"/wrfa/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.") + + # 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 + pt = soup.find('div', {'id' : 'pagetitle'}) + a = pt.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + a = pt.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+'/wrfa/'+a['href']) + self.story.setMetadata('author',a.string) + + rating=pt.text.split('(')[1].split(')')[0] + self.story.setMetadata('rating', rating) + + # 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+'/wrfa/'+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. + + # <span class="label">Rated:</span> NC-17<br /> etc + content=soup.find('div',{'class' : 'content'}) + labels = soup.findAll('span',{'class':'label'}) + + value = labels[0].previousSibling + svalue = "" + while value != None: + val = value + value = value.previousSibling + while "Categories" not in str(val): + svalue += str(val) + val = val.nextSibling + self.setDescription(url,svalue) + + + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Rated' in label: + self.story.setMetadata('rating', value) + + if 'Word count' in label: + self.story.setMetadata('numWords', value.split(' -')[0]) + + 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 'Complete' 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(value.split(' -')[0], self.dateformat)) + + if 'Updated' in label: + 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+'/wrfa/'+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+')) + 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')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + # grab the text for an individual chapter. + def getChapterText(self, url): + + 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('div', {'id' : 'story'}) + + if None == div: + raise exceptions.FailedToDownload("Error downloading Chapter: %s! Missing required element!" % url) + + return self.utf8FromSoup(url,div) diff --git a/fanficdownloader/adapters/adapter_wraithbaitcom.py b/fanficdownloader/adapters/adapter_wraithbaitcom.py new file mode 100644 index 00000000..89d43160 --- /dev/null +++ b/fanficdownloader/adapters/adapter_wraithbaitcom.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time +import logging +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 WraithBaitComAdapter + +# Class name has to be unique. Our convention is camel case the +# sitename with Adapter at the end. www is skipped. +class WraithBaitComAdapter(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]) + + + + 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','wb') + + # The date format will vary from site to site. + # http://docs.python.org/library/datetime.html#strftime-strptime-behavior + self.dateformat = "%d %b %Y" + + @staticmethod # must be @staticmethod, don't remove it. + def getSiteDomain(): + # The site domain. Does have www here, if it uses it. + return 'www.wraithbait.com' + + @classmethod + 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): + + 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=12" + 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 "for adults only" in data: + 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 + pt = soup.find('div', {'id' : 'pagetitle'}) + a = pt.find('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"$")) + self.story.setMetadata('title',stripHTML(a)) + + # Find authorid and URL from... author url. + alist = pt.findAll('a', href=re.compile(r"viewuser.php\?uid=\d+")) + for a in alist: + self.story.addToList('authorId',a['href'].split('=')[1]) + self.story.addToList('authorUrl','http://'+self.host+'/'+a['href']) + self.story.addToList('author',a.string) + + rating=pt.text.split('[')[1].split(']')[0] + self.story.setMetadata('rating', rating) + + st = soup.find('div', {'class' : 'storytitle'}) + a = st.findAll('a', href=re.compile(r'reviews.php\?type=ST&item='+self.story.getMetadata('storyId')+"$"))[1] # second one. + self.story.setMetadata('reviews',stripHTML(a)) + + # Find the chapters: + for chapter in soup.findAll('a', href=re.compile(r'viewstory.php\?sid='+self.story.getMetadata('storyId')+"&chapter=\d+$")): + # include author on chapters if multiple authors. + if len(alist) > 1: + add = " by %s"%stripHTML(chapter.findNext('a', href=re.compile(r"viewuser.php\?uid=\d+"))) + else: + add = "" + # just in case there's tags, like <i> in chapter titles. + self.chapterUrls.append((stripHTML(chapter)+add,'http://'+self.host+'/'+chapter['href']+addurl)) + + 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 "" + + info = soup.find('div', {'class' : 'small'}) + + word=info.find(text=re.compile("Word count:")).split(':') + self.story.setMetadata('numWords', word[1]) + + cats = info.findAll('a',href=re.compile(r'browse.php\?type=categories&id=\d')) + for cat in cats: + if "General" != cat.string: + self.story.addToList('category',cat.string) + + chars = info.findAll('a',href=re.compile(r'browse.php\?type=characters&charid=\d')) + for char in chars: + self.story.addToList('characters',char.string) + + completed=info.find(text=re.compile("Completed: Yes")) + if completed != None: + self.story.setMetadata('status', 'Completed') + else: + self.story.setMetadata('status', 'In-Progress') + + date=soup.find('div',{'class' : 'bottom'}) + pd=date.find(text=re.compile("Published:")).string.split(': ') + self.story.setMetadata('datePublished', makeDate(stripHTML(pd[1].split(' U')[0]), self.dateformat)) + self.story.setMetadata('dateUpdated', makeDate(stripHTML(pd[2]), self.dateformat)) + + # <span class="label">Rated:</span> NC-17<br /> etc + labels = soup.findAll('span',{'class':'label'}) + pub=0 + for labelspan in labels: + value = labelspan.nextSibling + label = labelspan.string + + if 'Genres' 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) + + try: + # Find Series name from series URL. + a = soup.find('a', href=re.compile(r"viewseries.php\?seriesid=\d+")) + series_name = a.string + series_url = 'http://'+self.host+'/'+a['href'] + + # use BeautifulSoup HTML parser to make everything easier to find. + seriessoup = bs.BeautifulSoup(self._fetchUrl(series_url)) + storyas = seriessoup.findAll('a', href=re.compile(r'^viewstory.php\?sid=\d+$')) + i=1 + for a in storyas: + if a['href'] == ('viewstory.php?sid='+self.story.getMetadata('storyId')): + self.setSeries(series_name, i) + self.story.setMetadata('seriesUrl',series_url) + break + i+=1 + + except: + # I find it hard to care if the series parsing fails + pass + + info.extract() + summary = soup.find('div', {'class' : 'content'}) + self.setDescription(url,summary) + + # 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) + diff --git a/fanficdownloader/adapters/base_adapter.py b/fanficdownloader/adapters/base_adapter.py new file mode 100644 index 00000000..fd75e929 --- /dev/null +++ b/fanficdownloader/adapters/base_adapter.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import re +import datetime +import time +import logging +import urllib +import urllib2 as u2 +import urlparse as up +from functools import partial + +from .. import BeautifulSoup as bs +from ..htmlcleanup import stripHTML +from ..htmlheuristics import replace_br_with_p + +logger = logging.getLogger(__name__) + +try: + from google.appengine.api import apiproxy_stub_map + def urlfetch_timeout_hook(service, call, request, response): + if call != 'Fetch': + return + # Make the default deadline 10 seconds instead of 5. + if not request.has_deadline(): + request.set_deadline(10.0) + + apiproxy_stub_map.apiproxy.GetPreCallHooks().Append( + 'urlfetch_timeout_hook', urlfetch_timeout_hook, 'urlfetch') + logger.info("Hook to make default deadline 10.0 installed.") +except: + pass + #logger.info("Hook to make default deadline 10.0 NOT installed--not using appengine") + +from ..story import Story +from ..gziphttp import GZipProcessor +from ..configurable import Configurable +from ..htmlcleanup import removeEntities, removeAllEntities, stripHTML +from ..exceptions import InvalidStoryURL + +try: + from .. import chardet as chardet +except ImportError: + chardet = None + +class BaseSiteAdapter(Configurable): + + @classmethod + def matchesSite(cls,site): + return site in cls.getAcceptDomains() + + @classmethod + def getAcceptDomains(cls): + return [cls.getSiteDomain()] + + def validateURL(self): + return re.match(self.getSiteURLPattern(), self.url) + + def __init__(self, configuration, url): + Configurable.__init__(self, configuration) + + self.username = "NoneGiven" # if left empty, site doesn't return any message at all. + self.password = "" + self.is_adult=False + + self.override_sleep = None + + self.opener = u2.build_opener(u2.HTTPCookieProcessor(),GZipProcessor()) + ## Specific UA because too many sites are blocking the default python UA. + self.opener.addheaders = [('User-agent', self.getConfig('user_agent'))] + self.storyDone = False + self.metadataDone = False + self.story = Story(configuration) + self.story.setMetadata('site',self.getConfigSection()) + self.story.setMetadata('dateCreated',datetime.datetime.now()) + self.chapterUrls = [] # tuples of (chapter title,chapter url) + self.chapterFirst = None + self.chapterLast = None + self.oldchapters = None + self.oldimgs = None + self.oldcover = None # (data of existing cover html, data of existing cover image) + self.calibrebookmark = None + self.logfile = None + ## order of preference for decoding. + self.decode = ["utf8", + "Windows-1252"] # 1252 is a superset of + # iso-8859-1. Most sites that + # claim to be iso-8859-1 (and + # some that claim to be utf8) + # are really windows-1252. + self._setURL(url) + if not self.validateURL(): + raise InvalidStoryURL(url, + self.getSiteDomain(), + self.getSiteExampleURLs()) + + def _setURL(self,url): + self.url = url + self.parsedUrl = up.urlparse(url) + self.host = self.parsedUrl.netloc + self.path = self.parsedUrl.path + self.story.setMetadata('storyUrl',self.url) + +## website encoding(s)--in theory, each website reports the character +## encoding they use for each page. In practice, some sites report it +## incorrectly. Each adapter has a default list, usually "utf8, +## Windows-1252" or "Windows-1252, utf8". The special value 'auto' +## will call chardet and use the encoding it reports if it has +90% +## confidence. 'auto' is not reliable. + def _decode(self,data): + if self.getConfig('website_encodings'): + decode = self.getConfigList('website_encodings') + else: + decode = self.decode + + for code in decode: + try: + #print code + if code == "auto": + if not chardet: + logger.info("chardet not available, skipping 'auto' encoding") + continue + detected = chardet.detect(data) + #print detected + if detected['confidence'] > 0.9: + code=detected['encoding'] + else: + continue + return data.decode(code) + except: + logger.debug("code failed:"+code) + pass + logger.info("Could not decode story, tried:%s Stripping non-ASCII."%decode) + return "".join([x for x in data if ord(x) < 128]) + + # Assumes application/x-www-form-urlencoded. parameters, headers are dict()s + def _postUrl(self, url, parameters={}, headers={}): + self.do_sleep() + + ## u2.Request assumes POST when data!=None. Also assumes data + ## is application/x-www-form-urlencoded. + if 'Content-type' not in headers: + headers['Content-type']='application/x-www-form-urlencoded' + if 'Accept' not in headers: + headers['Accept']="text/html,*/*" + req = u2.Request(url, + data=urllib.urlencode(parameters), + headers=headers) + return self._decode(self.opener.open(req,None,float(self.getConfig('connect_timeout',30.0))).read()) + + def _fetchUrlRaw(self, url, parameters=None): + if parameters != None: + return self.opener.open(url.replace(' ','%20'),urllib.urlencode(parameters),float(self.getConfig('connect_timeout',30.0))).read() + else: + return self.opener.open(url.replace(' ','%20'),None,float(self.getConfig('connect_timeout',30.0))).read() + + def set_sleep(self,val): + print("\n===========\n set sleep time %s\n==========="%val) + self.override_sleep = val + + def do_sleep(self): + if self.override_sleep: + time.sleep(float(self.override_sleep)) + elif self.getConfig('slow_down_sleep_time'): + time.sleep(float(self.getConfig('slow_down_sleep_time'))) + + # parameters is a dict() + def _fetchUrl(self, url, parameters=None): + self.do_sleep() + + excpt=None + for sleeptime in [0, 0.5, 4, 9]: + time.sleep(sleeptime) + try: + return self._decode(self._fetchUrlRaw(url,parameters)) + except u2.HTTPError, he: + excpt=he + if he.code == 404: + logger.warn("Caught an exception reading URL: %s Exception %s."%(unicode(url),unicode(he))) + break # break out on 404 + except Exception, e: + excpt=e + logger.warn("Caught an exception reading URL: %s Exception %s."%(unicode(url),unicode(e))) + + logger.error("Giving up on %s" %url) + logger.exception(excpt) + raise(excpt) + + # Limit chapters to download. Input starts at 1, list starts at 0 + def setChaptersRange(self,first=None,last=None): + if first: + self.chapterFirst=int(first)-1 + if last: + self.chapterLast=int(last)-1 + + # Does the download the first time it's called. + def getStory(self): + if not self.storyDone: + self.getStoryMetadataOnly() + + for index, (title,url) in enumerate(self.chapterUrls): + if (self.chapterFirst!=None and index < self.chapterFirst) or \ + (self.chapterLast!=None and index > self.chapterLast): + self.story.addChapter(url, + removeEntities(title), + None) + else: + if self.oldchapters and index < len(self.oldchapters): + data = self.utf8FromSoup(None, + self.oldchapters[index], + partial(cachedfetch,self._fetchUrlRaw,self.oldimgs)) + else: + data = self.getChapterText(url) + self.story.addChapter(url, + removeEntities(title), + removeEntities(data)) + self.storyDone = True + + # include image, but no cover from story, add default_cover_image cover. + if self.getConfig('include_images') and \ + not self.story.cover and \ + self.getConfig('default_cover_image'): + self.story.addImgUrl(None, + #self.getConfig('default_cover_image'), + self.story.formatFileName(self.getConfig('default_cover_image'), + self.getConfig('allow_unsafe_filename')), + self._fetchUrlRaw, + cover=True) + + # no new cover, set old cover, if there is one. + if not self.story.cover and self.oldcover: + self.story.oldcover = self.oldcover + + # cheesy way to carry calibre bookmark file forward across update. + if self.calibrebookmark: + self.story.calibrebookmark = self.calibrebookmark + if self.logfile: + self.story.logfile = self.logfile + + return self.story + + def getStoryMetadataOnly(self): + if not self.metadataDone: + self.extractChapterUrlsAndMetadata() + + if not self.story.getMetadataRaw('dateUpdated'): + self.story.setMetadata('dateUpdated',self.story.getMetadataRaw('datePublished')) + + self.metadataDone = True + return self.story + + def hookForUpdates(self,chaptercount): + "Usually not needed." + return chaptercount + + ############################### + + @staticmethod + def getSiteDomain(): + "Needs to be overriden in each adapter class." + return 'no such domain' + + @classmethod + def getConfigSection(cls): + "Only needs to be overriden if != site domain." + return cls.getSiteDomain() + + @classmethod + def stripURLParameters(cls,url): + "Only needs to be overriden if URL contains more than one parameter" + ## remove any trailing '&' parameters--?sid=999 will be left. + ## that's all that any of the current adapters need or want. + return re.sub(r"&.*$","",url) + + ## URL pattern validation is done *after* picking an adaptor based + ## on domain instead of *as* the adaptor selector so we can offer + ## the user example(s) for that particular site. + ## Override validateURL(self) instead if you need more control. + def getSiteURLPattern(self): + "Used to validate URL. Should be override in each adapter class." + return '^http://'+re.escape(self.getSiteDomain()) + + @classmethod + def getSiteExampleURLs(self): + """ + Needs to be overriden in each adapter class. It's the adapter + writer's responsibility to make sure the example(s) pass the + URL validate. + """ + return 'no such example' + + def extractChapterUrlsAndMetadata(self): + "Needs to be overriden in each adapter class. Populates self.story metadata and self.chapterUrls" + pass + + def getChapterText(self, url): + "Needs to be overriden in each adapter class." + pass + + # Just for series, in case we choose to change how it's stored or represented later. + def setSeries(self,name,num): + if self.getConfig('collect_series'): + self.story.setMetadata('series','%s [%s]'%(name, int(num))) + + def setDescription(self,url,svalue): + #print("\n\nsvalue:\n%s\n"%svalue) + if self.getConfig('keep_summary_html'): + if isinstance(svalue,basestring): + svalue = bs.BeautifulSoup(svalue) + self.story.setMetadata('description',self.utf8FromSoup(url,svalue)) + else: + self.story.setMetadata('description',stripHTML(svalue)) + #print("\n\ndescription:\n"+self.story.getMetadata('description')+"\n\n") + + def setCoverImage(self,storyurl,imgurl): + if self.getConfig('include_images'): + self.story.addImgUrl(storyurl,imgurl,self._fetchUrlRaw,cover=True, + coverexclusion=self.getConfig('cover_exclusion_regexp')) + + # This gives us a unicode object, not just a string containing bytes. + # (I gave soup a unicode string, you'd think it could give it back...) + # Now also does a bunch of other common processing for us. + def utf8FromSoup(self,url,soup,fetch=None): + if not fetch: + fetch=self._fetchUrlRaw + + acceptable_attributes = ['href','name','class','id'] + if self.getConfig("keep_style_attr"): + acceptable_attributes.append('style') + #print("include_images:"+self.getConfig('include_images')) + if self.getConfig('include_images'): + acceptable_attributes.extend(('src','alt','longdesc')) + for img in soup.findAll('img'): + # some pre-existing epubs have img tags that had src stripped off. + if img.has_key('src'): + img['longdesc']=img['src'] + img['src']=self.story.addImgUrl(url,img['src'],fetch, + coverexclusion=self.getConfig('cover_exclusion_regexp')) + + for attr in soup._getAttrMap().keys(): + if attr not in acceptable_attributes: + del soup[attr] ## strip all tag attributes except href and name + + for t in soup.findAll(recursive=True): + for attr in t._getAttrMap().keys(): + if attr not in acceptable_attributes: + del t[attr] ## strip all tag attributes except href and name + + # these are not acceptable strict XHTML. But we do already have + # CSS classes of the same names defined + if t.name in ('u'): + t['class']=t.name + t.name='span' + if t.name in ('center'): + t['class']=t.name + t.name='div' + # removes paired, but empty non paragraph tags. + if t.name not in ('p') and t.string != None and len(t.string.strip()) == 0 : + t.extract() + + retval = soup.__str__('utf8').decode('utf-8') + + if self.getConfig('nook_img_fix') and not self.getConfig('replace_br_with_p'): + # if the <img> tag doesn't have a div or a p around it, + # nook gets confused and displays it on every page after + # that under the text for the rest of the chapter. + retval = re.sub(r"(?!<(div|p)>)\s*(?P<imgtag><img[^>]+>)\s*(?!</(div|p)>)", + "<div>\g<imgtag></div>",retval) + + # Don't want body tags in chapter html--writers add them. + # This is primarily for epub updates. + retval = re.sub(r"</?body[^>]*>\r?\n?","",retval) + + if self.getConfig("replace_br_with_p"): + # Apply heuristic processing to replace <br> paragraph + # breaks with <p> tags. + retval = replace_br_with_p(retval) + + if self.getConfig('replace_hr'): + # replacing a self-closing tag with a container tag in the + # soup is more difficult than it first appears. So cheat. + retval = retval.replace("<hr />","<div class='center'>* * *</div>") + + return retval + +def cachedfetch(realfetch,cache,url): + if url in cache: + return cache[url] + else: + return realfetch(url) + +fullmon = {"January":"01", "February":"02", "March":"03", "April":"04", "May":"05", + "June":"06","July":"07", "August":"08", "September":"09", "October":"10", + "November":"11", "December":"12" } + +def makeDate(string,dateform): + # Surprise! Abstracting this turned out to be more useful than + # just saving bytes. + + # fudge english month names for people who's locale is set to + # non-english. All our current sites date in english, even if + # there's non-english content. -- ficbook.net now makes that a + # lie. It has to do something even more complicated to get + # Russian month names correct everywhere. + do_abbrev = "%b" in dateform + + if "%B" in dateform or do_abbrev: + dateform = dateform.replace("%B","%m").replace("%b","%m") + for (name,num) in fullmon.items(): + if do_abbrev: + name = name[:3] # first three for abbrev + if name in string: + string = string.replace(name,num) + break + + return datetime.datetime.strptime(string,dateform) + diff --git a/fanficdownloader/chardet/__init__.py b/fanficdownloader/chardet/__init__.py new file mode 100644 index 00000000..953b3994 --- /dev/null +++ b/fanficdownloader/chardet/__init__.py @@ -0,0 +1,26 @@ +######################## BEGIN LICENSE BLOCK ######################## +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +__version__ = "2.0.1" + +def detect(aBuf): + import universaldetector + u = universaldetector.UniversalDetector() + u.reset() + u.feed(aBuf) + u.close() + return u.result diff --git a/fanficdownloader/chardet/big5freq.py b/fanficdownloader/chardet/big5freq.py new file mode 100644 index 00000000..c1b0f3ce --- /dev/null +++ b/fanficdownloader/chardet/big5freq.py @@ -0,0 +1,923 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +# Big5 frequency table +# by Taiwan's Mandarin Promotion Council +# <http://www.edu.tw:81/mandr/> +# +# 128 --> 0.42261 +# 256 --> 0.57851 +# 512 --> 0.74851 +# 1024 --> 0.89384 +# 2048 --> 0.97583 +# +# Ideal Distribution Ratio = 0.74851/(1-0.74851) =2.98 +# Random Distribution Ration = 512/(5401-512)=0.105 +# +# Typical Distribution Ratio about 25% of Ideal one, still much higher than RDR + +BIG5_TYPICAL_DISTRIBUTION_RATIO = 0.75 + +#Char to FreqOrder table +BIG5_TABLE_SIZE = 5376 + +Big5CharToFreqOrder = ( \ + 1,1801,1506, 255,1431, 198, 9, 82, 6,5008, 177, 202,3681,1256,2821, 110, # 16 +3814, 33,3274, 261, 76, 44,2114, 16,2946,2187,1176, 659,3971, 26,3451,2653, # 32 +1198,3972,3350,4202, 410,2215, 302, 590, 361,1964, 8, 204, 58,4510,5009,1932, # 48 + 63,5010,5011, 317,1614, 75, 222, 159,4203,2417,1480,5012,3555,3091, 224,2822, # 64 +3682, 3, 10,3973,1471, 29,2787,1135,2866,1940, 873, 130,3275,1123, 312,5013, # 80 +4511,2052, 507, 252, 682,5014, 142,1915, 124, 206,2947, 34,3556,3204, 64, 604, # 96 +5015,2501,1977,1978, 155,1991, 645, 641,1606,5016,3452, 337, 72, 406,5017, 80, # 112 + 630, 238,3205,1509, 263, 939,1092,2654, 756,1440,1094,3453, 449, 69,2987, 591, # 128 + 179,2096, 471, 115,2035,1844, 60, 50,2988, 134, 806,1869, 734,2036,3454, 180, # 144 + 995,1607, 156, 537,2907, 688,5018, 319,1305, 779,2145, 514,2379, 298,4512, 359, # 160 +2502, 90,2716,1338, 663, 11, 906,1099,2553, 20,2441, 182, 532,1716,5019, 732, # 176 +1376,4204,1311,1420,3206, 25,2317,1056, 113, 399, 382,1950, 242,3455,2474, 529, # 192 +3276, 475,1447,3683,5020, 117, 21, 656, 810,1297,2300,2334,3557,5021, 126,4205, # 208 + 706, 456, 150, 613,4513, 71,1118,2037,4206, 145,3092, 85, 835, 486,2115,1246, # 224 +1426, 428, 727,1285,1015, 800, 106, 623, 303,1281,5022,2128,2359, 347,3815, 221, # 240 +3558,3135,5023,1956,1153,4207, 83, 296,1199,3093, 192, 624, 93,5024, 822,1898, # 256 +2823,3136, 795,2065, 991,1554,1542,1592, 27, 43,2867, 859, 139,1456, 860,4514, # 272 + 437, 712,3974, 164,2397,3137, 695, 211,3037,2097, 195,3975,1608,3559,3560,3684, # 288 +3976, 234, 811,2989,2098,3977,2233,1441,3561,1615,2380, 668,2077,1638, 305, 228, # 304 +1664,4515, 467, 415,5025, 262,2099,1593, 239, 108, 300, 200,1033, 512,1247,2078, # 320 +5026,5027,2176,3207,3685,2682, 593, 845,1062,3277, 88,1723,2038,3978,1951, 212, # 336 + 266, 152, 149, 468,1899,4208,4516, 77, 187,5028,3038, 37, 5,2990,5029,3979, # 352 +5030,5031, 39,2524,4517,2908,3208,2079, 55, 148, 74,4518, 545, 483,1474,1029, # 368 +1665, 217,1870,1531,3138,1104,2655,4209, 24, 172,3562, 900,3980,3563,3564,4519, # 384 + 32,1408,2824,1312, 329, 487,2360,2251,2717, 784,2683, 4,3039,3351,1427,1789, # 400 + 188, 109, 499,5032,3686,1717,1790, 888,1217,3040,4520,5033,3565,5034,3352,1520, # 416 +3687,3981, 196,1034, 775,5035,5036, 929,1816, 249, 439, 38,5037,1063,5038, 794, # 432 +3982,1435,2301, 46, 178,3278,2066,5039,2381,5040, 214,1709,4521, 804, 35, 707, # 448 + 324,3688,1601,2554, 140, 459,4210,5041,5042,1365, 839, 272, 978,2262,2580,3456, # 464 +2129,1363,3689,1423, 697, 100,3094, 48, 70,1231, 495,3139,2196,5043,1294,5044, # 480 +2080, 462, 586,1042,3279, 853, 256, 988, 185,2382,3457,1698, 434,1084,5045,3458, # 496 + 314,2625,2788,4522,2335,2336, 569,2285, 637,1817,2525, 757,1162,1879,1616,3459, # 512 + 287,1577,2116, 768,4523,1671,2868,3566,2526,1321,3816, 909,2418,5046,4211, 933, # 528 +3817,4212,2053,2361,1222,4524, 765,2419,1322, 786,4525,5047,1920,1462,1677,2909, # 544 +1699,5048,4526,1424,2442,3140,3690,2600,3353,1775,1941,3460,3983,4213, 309,1369, # 560 +1130,2825, 364,2234,1653,1299,3984,3567,3985,3986,2656, 525,1085,3041, 902,2001, # 576 +1475, 964,4527, 421,1845,1415,1057,2286, 940,1364,3141, 376,4528,4529,1381, 7, # 592 +2527, 983,2383, 336,1710,2684,1846, 321,3461, 559,1131,3042,2752,1809,1132,1313, # 608 + 265,1481,1858,5049, 352,1203,2826,3280, 167,1089, 420,2827, 776, 792,1724,3568, # 624 +4214,2443,3281,5050,4215,5051, 446, 229, 333,2753, 901,3818,1200,1557,4530,2657, # 640 +1921, 395,2754,2685,3819,4216,1836, 125, 916,3209,2626,4531,5052,5053,3820,5054, # 656 +5055,5056,4532,3142,3691,1133,2555,1757,3462,1510,2318,1409,3569,5057,2146, 438, # 672 +2601,2910,2384,3354,1068, 958,3043, 461, 311,2869,2686,4217,1916,3210,4218,1979, # 688 + 383, 750,2755,2627,4219, 274, 539, 385,1278,1442,5058,1154,1965, 384, 561, 210, # 704 + 98,1295,2556,3570,5059,1711,2420,1482,3463,3987,2911,1257, 129,5060,3821, 642, # 720 + 523,2789,2790,2658,5061, 141,2235,1333, 68, 176, 441, 876, 907,4220, 603,2602, # 736 + 710, 171,3464, 404, 549, 18,3143,2398,1410,3692,1666,5062,3571,4533,2912,4534, # 752 +5063,2991, 368,5064, 146, 366, 99, 871,3693,1543, 748, 807,1586,1185, 22,2263, # 768 + 379,3822,3211,5065,3212, 505,1942,2628,1992,1382,2319,5066, 380,2362, 218, 702, # 784 +1818,1248,3465,3044,3572,3355,3282,5067,2992,3694, 930,3283,3823,5068, 59,5069, # 800 + 585, 601,4221, 497,3466,1112,1314,4535,1802,5070,1223,1472,2177,5071, 749,1837, # 816 + 690,1900,3824,1773,3988,1476, 429,1043,1791,2236,2117, 917,4222, 447,1086,1629, # 832 +5072, 556,5073,5074,2021,1654, 844,1090, 105, 550, 966,1758,2828,1008,1783, 686, # 848 +1095,5075,2287, 793,1602,5076,3573,2603,4536,4223,2948,2302,4537,3825, 980,2503, # 864 + 544, 353, 527,4538, 908,2687,2913,5077, 381,2629,1943,1348,5078,1341,1252, 560, # 880 +3095,5079,3467,2870,5080,2054, 973, 886,2081, 143,4539,5081,5082, 157,3989, 496, # 896 +4224, 57, 840, 540,2039,4540,4541,3468,2118,1445, 970,2264,1748,1966,2082,4225, # 912 +3144,1234,1776,3284,2829,3695, 773,1206,2130,1066,2040,1326,3990,1738,1725,4226, # 928 + 279,3145, 51,1544,2604, 423,1578,2131,2067, 173,4542,1880,5083,5084,1583, 264, # 944 + 610,3696,4543,2444, 280, 154,5085,5086,5087,1739, 338,1282,3096, 693,2871,1411, # 960 +1074,3826,2445,5088,4544,5089,5090,1240, 952,2399,5091,2914,1538,2688, 685,1483, # 976 +4227,2475,1436, 953,4228,2055,4545, 671,2400, 79,4229,2446,3285, 608, 567,2689, # 992 +3469,4230,4231,1691, 393,1261,1792,2401,5092,4546,5093,5094,5095,5096,1383,1672, # 1008 +3827,3213,1464, 522,1119, 661,1150, 216, 675,4547,3991,1432,3574, 609,4548,2690, # 1024 +2402,5097,5098,5099,4232,3045, 0,5100,2476, 315, 231,2447, 301,3356,4549,2385, # 1040 +5101, 233,4233,3697,1819,4550,4551,5102, 96,1777,1315,2083,5103, 257,5104,1810, # 1056 +3698,2718,1139,1820,4234,2022,1124,2164,2791,1778,2659,5105,3097, 363,1655,3214, # 1072 +5106,2993,5107,5108,5109,3992,1567,3993, 718, 103,3215, 849,1443, 341,3357,2949, # 1088 +1484,5110,1712, 127, 67, 339,4235,2403, 679,1412, 821,5111,5112, 834, 738, 351, # 1104 +2994,2147, 846, 235,1497,1881, 418,1993,3828,2719, 186,1100,2148,2756,3575,1545, # 1120 +1355,2950,2872,1377, 583,3994,4236,2581,2995,5113,1298,3699,1078,2557,3700,2363, # 1136 + 78,3829,3830, 267,1289,2100,2002,1594,4237, 348, 369,1274,2197,2178,1838,4552, # 1152 +1821,2830,3701,2757,2288,2003,4553,2951,2758, 144,3358, 882,4554,3995,2759,3470, # 1168 +4555,2915,5114,4238,1726, 320,5115,3996,3046, 788,2996,5116,2831,1774,1327,2873, # 1184 +3997,2832,5117,1306,4556,2004,1700,3831,3576,2364,2660, 787,2023, 506, 824,3702, # 1200 + 534, 323,4557,1044,3359,2024,1901, 946,3471,5118,1779,1500,1678,5119,1882,4558, # 1216 + 165, 243,4559,3703,2528, 123, 683,4239, 764,4560, 36,3998,1793, 589,2916, 816, # 1232 + 626,1667,3047,2237,1639,1555,1622,3832,3999,5120,4000,2874,1370,1228,1933, 891, # 1248 +2084,2917, 304,4240,5121, 292,2997,2720,3577, 691,2101,4241,1115,4561, 118, 662, # 1264 +5122, 611,1156, 854,2386,1316,2875, 2, 386, 515,2918,5123,5124,3286, 868,2238, # 1280 +1486, 855,2661, 785,2216,3048,5125,1040,3216,3578,5126,3146, 448,5127,1525,5128, # 1296 +2165,4562,5129,3833,5130,4242,2833,3579,3147, 503, 818,4001,3148,1568, 814, 676, # 1312 +1444, 306,1749,5131,3834,1416,1030, 197,1428, 805,2834,1501,4563,5132,5133,5134, # 1328 +1994,5135,4564,5136,5137,2198, 13,2792,3704,2998,3149,1229,1917,5138,3835,2132, # 1344 +5139,4243,4565,2404,3580,5140,2217,1511,1727,1120,5141,5142, 646,3836,2448, 307, # 1360 +5143,5144,1595,3217,5145,5146,5147,3705,1113,1356,4002,1465,2529,2530,5148, 519, # 1376 +5149, 128,2133, 92,2289,1980,5150,4003,1512, 342,3150,2199,5151,2793,2218,1981, # 1392 +3360,4244, 290,1656,1317, 789, 827,2365,5152,3837,4566, 562, 581,4004,5153, 401, # 1408 +4567,2252, 94,4568,5154,1399,2794,5155,1463,2025,4569,3218,1944,5156, 828,1105, # 1424 +4245,1262,1394,5157,4246, 605,4570,5158,1784,2876,5159,2835, 819,2102, 578,2200, # 1440 +2952,5160,1502, 436,3287,4247,3288,2836,4005,2919,3472,3473,5161,2721,2320,5162, # 1456 +5163,2337,2068, 23,4571, 193, 826,3838,2103, 699,1630,4248,3098, 390,1794,1064, # 1472 +3581,5164,1579,3099,3100,1400,5165,4249,1839,1640,2877,5166,4572,4573, 137,4250, # 1488 + 598,3101,1967, 780, 104, 974,2953,5167, 278, 899, 253, 402, 572, 504, 493,1339, # 1504 +5168,4006,1275,4574,2582,2558,5169,3706,3049,3102,2253, 565,1334,2722, 863, 41, # 1520 +5170,5171,4575,5172,1657,2338, 19, 463,2760,4251, 606,5173,2999,3289,1087,2085, # 1536 +1323,2662,3000,5174,1631,1623,1750,4252,2691,5175,2878, 791,2723,2663,2339, 232, # 1552 +2421,5176,3001,1498,5177,2664,2630, 755,1366,3707,3290,3151,2026,1609, 119,1918, # 1568 +3474, 862,1026,4253,5178,4007,3839,4576,4008,4577,2265,1952,2477,5179,1125, 817, # 1584 +4254,4255,4009,1513,1766,2041,1487,4256,3050,3291,2837,3840,3152,5180,5181,1507, # 1600 +5182,2692, 733, 40,1632,1106,2879, 345,4257, 841,2531, 230,4578,3002,1847,3292, # 1616 +3475,5183,1263, 986,3476,5184, 735, 879, 254,1137, 857, 622,1300,1180,1388,1562, # 1632 +4010,4011,2954, 967,2761,2665,1349, 592,2134,1692,3361,3003,1995,4258,1679,4012, # 1648 +1902,2188,5185, 739,3708,2724,1296,1290,5186,4259,2201,2202,1922,1563,2605,2559, # 1664 +1871,2762,3004,5187, 435,5188, 343,1108, 596, 17,1751,4579,2239,3477,3709,5189, # 1680 +4580, 294,3582,2955,1693, 477, 979, 281,2042,3583, 643,2043,3710,2631,2795,2266, # 1696 +1031,2340,2135,2303,3584,4581, 367,1249,2560,5190,3585,5191,4582,1283,3362,2005, # 1712 + 240,1762,3363,4583,4584, 836,1069,3153, 474,5192,2149,2532, 268,3586,5193,3219, # 1728 +1521,1284,5194,1658,1546,4260,5195,3587,3588,5196,4261,3364,2693,1685,4262, 961, # 1744 +1673,2632, 190,2006,2203,3841,4585,4586,5197, 570,2504,3711,1490,5198,4587,2633, # 1760 +3293,1957,4588, 584,1514, 396,1045,1945,5199,4589,1968,2449,5200,5201,4590,4013, # 1776 + 619,5202,3154,3294, 215,2007,2796,2561,3220,4591,3221,4592, 763,4263,3842,4593, # 1792 +5203,5204,1958,1767,2956,3365,3712,1174, 452,1477,4594,3366,3155,5205,2838,1253, # 1808 +2387,2189,1091,2290,4264, 492,5206, 638,1169,1825,2136,1752,4014, 648, 926,1021, # 1824 +1324,4595, 520,4596, 997, 847,1007, 892,4597,3843,2267,1872,3713,2405,1785,4598, # 1840 +1953,2957,3103,3222,1728,4265,2044,3714,4599,2008,1701,3156,1551, 30,2268,4266, # 1856 +5207,2027,4600,3589,5208, 501,5209,4267, 594,3478,2166,1822,3590,3479,3591,3223, # 1872 + 829,2839,4268,5210,1680,3157,1225,4269,5211,3295,4601,4270,3158,2341,5212,4602, # 1888 +4271,5213,4015,4016,5214,1848,2388,2606,3367,5215,4603, 374,4017, 652,4272,4273, # 1904 + 375,1140, 798,5216,5217,5218,2366,4604,2269, 546,1659, 138,3051,2450,4605,5219, # 1920 +2254, 612,1849, 910, 796,3844,1740,1371, 825,3845,3846,5220,2920,2562,5221, 692, # 1936 + 444,3052,2634, 801,4606,4274,5222,1491, 244,1053,3053,4275,4276, 340,5223,4018, # 1952 +1041,3005, 293,1168, 87,1357,5224,1539, 959,5225,2240, 721, 694,4277,3847, 219, # 1968 +1478, 644,1417,3368,2666,1413,1401,1335,1389,4019,5226,5227,3006,2367,3159,1826, # 1984 + 730,1515, 184,2840, 66,4607,5228,1660,2958, 246,3369, 378,1457, 226,3480, 975, # 2000 +4020,2959,1264,3592, 674, 696,5229, 163,5230,1141,2422,2167, 713,3593,3370,4608, # 2016 +4021,5231,5232,1186, 15,5233,1079,1070,5234,1522,3224,3594, 276,1050,2725, 758, # 2032 +1126, 653,2960,3296,5235,2342, 889,3595,4022,3104,3007, 903,1250,4609,4023,3481, # 2048 +3596,1342,1681,1718, 766,3297, 286, 89,2961,3715,5236,1713,5237,2607,3371,3008, # 2064 +5238,2962,2219,3225,2880,5239,4610,2505,2533, 181, 387,1075,4024, 731,2190,3372, # 2080 +5240,3298, 310, 313,3482,2304, 770,4278, 54,3054, 189,4611,3105,3848,4025,5241, # 2096 +1230,1617,1850, 355,3597,4279,4612,3373, 111,4280,3716,1350,3160,3483,3055,4281, # 2112 +2150,3299,3598,5242,2797,4026,4027,3009, 722,2009,5243,1071, 247,1207,2343,2478, # 2128 +1378,4613,2010, 864,1437,1214,4614, 373,3849,1142,2220, 667,4615, 442,2763,2563, # 2144 +3850,4028,1969,4282,3300,1840, 837, 170,1107, 934,1336,1883,5244,5245,2119,4283, # 2160 +2841, 743,1569,5246,4616,4284, 582,2389,1418,3484,5247,1803,5248, 357,1395,1729, # 2176 +3717,3301,2423,1564,2241,5249,3106,3851,1633,4617,1114,2086,4285,1532,5250, 482, # 2192 +2451,4618,5251,5252,1492, 833,1466,5253,2726,3599,1641,2842,5254,1526,1272,3718, # 2208 +4286,1686,1795, 416,2564,1903,1954,1804,5255,3852,2798,3853,1159,2321,5256,2881, # 2224 +4619,1610,1584,3056,2424,2764, 443,3302,1163,3161,5257,5258,4029,5259,4287,2506, # 2240 +3057,4620,4030,3162,2104,1647,3600,2011,1873,4288,5260,4289, 431,3485,5261, 250, # 2256 + 97, 81,4290,5262,1648,1851,1558, 160, 848,5263, 866, 740,1694,5264,2204,2843, # 2272 +3226,4291,4621,3719,1687, 950,2479, 426, 469,3227,3720,3721,4031,5265,5266,1188, # 2288 + 424,1996, 861,3601,4292,3854,2205,2694, 168,1235,3602,4293,5267,2087,1674,4622, # 2304 +3374,3303, 220,2565,1009,5268,3855, 670,3010, 332,1208, 717,5269,5270,3603,2452, # 2320 +4032,3375,5271, 513,5272,1209,2882,3376,3163,4623,1080,5273,5274,5275,5276,2534, # 2336 +3722,3604, 815,1587,4033,4034,5277,3605,3486,3856,1254,4624,1328,3058,1390,4035, # 2352 +1741,4036,3857,4037,5278, 236,3858,2453,3304,5279,5280,3723,3859,1273,3860,4625, # 2368 +5281, 308,5282,4626, 245,4627,1852,2480,1307,2583, 430, 715,2137,2454,5283, 270, # 2384 + 199,2883,4038,5284,3606,2727,1753, 761,1754, 725,1661,1841,4628,3487,3724,5285, # 2400 +5286, 587, 14,3305, 227,2608, 326, 480,2270, 943,2765,3607, 291, 650,1884,5287, # 2416 +1702,1226, 102,1547, 62,3488, 904,4629,3489,1164,4294,5288,5289,1224,1548,2766, # 2432 + 391, 498,1493,5290,1386,1419,5291,2056,1177,4630, 813, 880,1081,2368, 566,1145, # 2448 +4631,2291,1001,1035,2566,2609,2242, 394,1286,5292,5293,2069,5294, 86,1494,1730, # 2464 +4039, 491,1588, 745, 897,2963, 843,3377,4040,2767,2884,3306,1768, 998,2221,2070, # 2480 + 397,1827,1195,1970,3725,3011,3378, 284,5295,3861,2507,2138,2120,1904,5296,4041, # 2496 +2151,4042,4295,1036,3490,1905, 114,2567,4296, 209,1527,5297,5298,2964,2844,2635, # 2512 +2390,2728,3164, 812,2568,5299,3307,5300,1559, 737,1885,3726,1210, 885, 28,2695, # 2528 +3608,3862,5301,4297,1004,1780,4632,5302, 346,1982,2222,2696,4633,3863,1742, 797, # 2544 +1642,4043,1934,1072,1384,2152, 896,4044,3308,3727,3228,2885,3609,5303,2569,1959, # 2560 +4634,2455,1786,5304,5305,5306,4045,4298,1005,1308,3728,4299,2729,4635,4636,1528, # 2576 +2610, 161,1178,4300,1983, 987,4637,1101,4301, 631,4046,1157,3229,2425,1343,1241, # 2592 +1016,2243,2570, 372, 877,2344,2508,1160, 555,1935, 911,4047,5307, 466,1170, 169, # 2608 +1051,2921,2697,3729,2481,3012,1182,2012,2571,1251,2636,5308, 992,2345,3491,1540, # 2624 +2730,1201,2071,2406,1997,2482,5309,4638, 528,1923,2191,1503,1874,1570,2369,3379, # 2640 +3309,5310, 557,1073,5311,1828,3492,2088,2271,3165,3059,3107, 767,3108,2799,4639, # 2656 +1006,4302,4640,2346,1267,2179,3730,3230, 778,4048,3231,2731,1597,2667,5312,4641, # 2672 +5313,3493,5314,5315,5316,3310,2698,1433,3311, 131, 95,1504,4049, 723,4303,3166, # 2688 +1842,3610,2768,2192,4050,2028,2105,3731,5317,3013,4051,1218,5318,3380,3232,4052, # 2704 +4304,2584, 248,1634,3864, 912,5319,2845,3732,3060,3865, 654, 53,5320,3014,5321, # 2720 +1688,4642, 777,3494,1032,4053,1425,5322, 191, 820,2121,2846, 971,4643, 931,3233, # 2736 + 135, 664, 783,3866,1998, 772,2922,1936,4054,3867,4644,2923,3234, 282,2732, 640, # 2752 +1372,3495,1127, 922, 325,3381,5323,5324, 711,2045,5325,5326,4055,2223,2800,1937, # 2768 +4056,3382,2224,2255,3868,2305,5327,4645,3869,1258,3312,4057,3235,2139,2965,4058, # 2784 +4059,5328,2225, 258,3236,4646, 101,1227,5329,3313,1755,5330,1391,3314,5331,2924, # 2800 +2057, 893,5332,5333,5334,1402,4305,2347,5335,5336,3237,3611,5337,5338, 878,1325, # 2816 +1781,2801,4647, 259,1385,2585, 744,1183,2272,4648,5339,4060,2509,5340, 684,1024, # 2832 +4306,5341, 472,3612,3496,1165,3315,4061,4062, 322,2153, 881, 455,1695,1152,1340, # 2848 + 660, 554,2154,4649,1058,4650,4307, 830,1065,3383,4063,4651,1924,5342,1703,1919, # 2864 +5343, 932,2273, 122,5344,4652, 947, 677,5345,3870,2637, 297,1906,1925,2274,4653, # 2880 +2322,3316,5346,5347,4308,5348,4309, 84,4310, 112, 989,5349, 547,1059,4064, 701, # 2896 +3613,1019,5350,4311,5351,3497, 942, 639, 457,2306,2456, 993,2966, 407, 851, 494, # 2912 +4654,3384, 927,5352,1237,5353,2426,3385, 573,4312, 680, 921,2925,1279,1875, 285, # 2928 + 790,1448,1984, 719,2168,5354,5355,4655,4065,4066,1649,5356,1541, 563,5357,1077, # 2944 +5358,3386,3061,3498, 511,3015,4067,4068,3733,4069,1268,2572,3387,3238,4656,4657, # 2960 +5359, 535,1048,1276,1189,2926,2029,3167,1438,1373,2847,2967,1134,2013,5360,4313, # 2976 +1238,2586,3109,1259,5361, 700,5362,2968,3168,3734,4314,5363,4315,1146,1876,1907, # 2992 +4658,2611,4070, 781,2427, 132,1589, 203, 147, 273,2802,2407, 898,1787,2155,4071, # 3008 +4072,5364,3871,2803,5365,5366,4659,4660,5367,3239,5368,1635,3872, 965,5369,1805, # 3024 +2699,1516,3614,1121,1082,1329,3317,4073,1449,3873, 65,1128,2848,2927,2769,1590, # 3040 +3874,5370,5371, 12,2668, 45, 976,2587,3169,4661, 517,2535,1013,1037,3240,5372, # 3056 +3875,2849,5373,3876,5374,3499,5375,2612, 614,1999,2323,3877,3110,2733,2638,5376, # 3072 +2588,4316, 599,1269,5377,1811,3735,5378,2700,3111, 759,1060, 489,1806,3388,3318, # 3088 +1358,5379,5380,2391,1387,1215,2639,2256, 490,5381,5382,4317,1759,2392,2348,5383, # 3104 +4662,3878,1908,4074,2640,1807,3241,4663,3500,3319,2770,2349, 874,5384,5385,3501, # 3120 +3736,1859, 91,2928,3737,3062,3879,4664,5386,3170,4075,2669,5387,3502,1202,1403, # 3136 +3880,2969,2536,1517,2510,4665,3503,2511,5388,4666,5389,2701,1886,1495,1731,4076, # 3152 +2370,4667,5390,2030,5391,5392,4077,2702,1216, 237,2589,4318,2324,4078,3881,4668, # 3168 +4669,2703,3615,3504, 445,4670,5393,5394,5395,5396,2771, 61,4079,3738,1823,4080, # 3184 +5397, 687,2046, 935, 925, 405,2670, 703,1096,1860,2734,4671,4081,1877,1367,2704, # 3200 +3389, 918,2106,1782,2483, 334,3320,1611,1093,4672, 564,3171,3505,3739,3390, 945, # 3216 +2641,2058,4673,5398,1926, 872,4319,5399,3506,2705,3112, 349,4320,3740,4082,4674, # 3232 +3882,4321,3741,2156,4083,4675,4676,4322,4677,2408,2047, 782,4084, 400, 251,4323, # 3248 +1624,5400,5401, 277,3742, 299,1265, 476,1191,3883,2122,4324,4325,1109, 205,5402, # 3264 +2590,1000,2157,3616,1861,5403,5404,5405,4678,5406,4679,2573, 107,2484,2158,4085, # 3280 +3507,3172,5407,1533, 541,1301, 158, 753,4326,2886,3617,5408,1696, 370,1088,4327, # 3296 +4680,3618, 579, 327, 440, 162,2244, 269,1938,1374,3508, 968,3063, 56,1396,3113, # 3312 +2107,3321,3391,5409,1927,2159,4681,3016,5410,3619,5411,5412,3743,4682,2485,5413, # 3328 +2804,5414,1650,4683,5415,2613,5416,5417,4086,2671,3392,1149,3393,4087,3884,4088, # 3344 +5418,1076, 49,5419, 951,3242,3322,3323, 450,2850, 920,5420,1812,2805,2371,4328, # 3360 +1909,1138,2372,3885,3509,5421,3243,4684,1910,1147,1518,2428,4685,3886,5422,4686, # 3376 +2393,2614, 260,1796,3244,5423,5424,3887,3324, 708,5425,3620,1704,5426,3621,1351, # 3392 +1618,3394,3017,1887, 944,4329,3395,4330,3064,3396,4331,5427,3744, 422, 413,1714, # 3408 +3325, 500,2059,2350,4332,2486,5428,1344,1911, 954,5429,1668,5430,5431,4089,2409, # 3424 +4333,3622,3888,4334,5432,2307,1318,2512,3114, 133,3115,2887,4687, 629, 31,2851, # 3440 +2706,3889,4688, 850, 949,4689,4090,2970,1732,2089,4335,1496,1853,5433,4091, 620, # 3456 +3245, 981,1242,3745,3397,1619,3746,1643,3326,2140,2457,1971,1719,3510,2169,5434, # 3472 +3246,5435,5436,3398,1829,5437,1277,4690,1565,2048,5438,1636,3623,3116,5439, 869, # 3488 +2852, 655,3890,3891,3117,4092,3018,3892,1310,3624,4691,5440,5441,5442,1733, 558, # 3504 +4692,3747, 335,1549,3065,1756,4336,3748,1946,3511,1830,1291,1192, 470,2735,2108, # 3520 +2806, 913,1054,4093,5443,1027,5444,3066,4094,4693, 982,2672,3399,3173,3512,3247, # 3536 +3248,1947,2807,5445, 571,4694,5446,1831,5447,3625,2591,1523,2429,5448,2090, 984, # 3552 +4695,3749,1960,5449,3750, 852, 923,2808,3513,3751, 969,1519, 999,2049,2325,1705, # 3568 +5450,3118, 615,1662, 151, 597,4095,2410,2326,1049, 275,4696,3752,4337, 568,3753, # 3584 +3626,2487,4338,3754,5451,2430,2275, 409,3249,5452,1566,2888,3514,1002, 769,2853, # 3600 + 194,2091,3174,3755,2226,3327,4339, 628,1505,5453,5454,1763,2180,3019,4096, 521, # 3616 +1161,2592,1788,2206,2411,4697,4097,1625,4340,4341, 412, 42,3119, 464,5455,2642, # 3632 +4698,3400,1760,1571,2889,3515,2537,1219,2207,3893,2643,2141,2373,4699,4700,3328, # 3648 +1651,3401,3627,5456,5457,3628,2488,3516,5458,3756,5459,5460,2276,2092, 460,5461, # 3664 +4701,5462,3020, 962, 588,3629, 289,3250,2644,1116, 52,5463,3067,1797,5464,5465, # 3680 +5466,1467,5467,1598,1143,3757,4342,1985,1734,1067,4702,1280,3402, 465,4703,1572, # 3696 + 510,5468,1928,2245,1813,1644,3630,5469,4704,3758,5470,5471,2673,1573,1534,5472, # 3712 +5473, 536,1808,1761,3517,3894,3175,2645,5474,5475,5476,4705,3518,2929,1912,2809, # 3728 +5477,3329,1122, 377,3251,5478, 360,5479,5480,4343,1529, 551,5481,2060,3759,1769, # 3744 +2431,5482,2930,4344,3330,3120,2327,2109,2031,4706,1404, 136,1468,1479, 672,1171, # 3760 +3252,2308, 271,3176,5483,2772,5484,2050, 678,2736, 865,1948,4707,5485,2014,4098, # 3776 +2971,5486,2737,2227,1397,3068,3760,4708,4709,1735,2931,3403,3631,5487,3895, 509, # 3792 +2854,2458,2890,3896,5488,5489,3177,3178,4710,4345,2538,4711,2309,1166,1010, 552, # 3808 + 681,1888,5490,5491,2972,2973,4099,1287,1596,1862,3179, 358, 453, 736, 175, 478, # 3824 +1117, 905,1167,1097,5492,1854,1530,5493,1706,5494,2181,3519,2292,3761,3520,3632, # 3840 +4346,2093,4347,5495,3404,1193,2489,4348,1458,2193,2208,1863,1889,1421,3331,2932, # 3856 +3069,2182,3521, 595,2123,5496,4100,5497,5498,4349,1707,2646, 223,3762,1359, 751, # 3872 +3121, 183,3522,5499,2810,3021, 419,2374, 633, 704,3897,2394, 241,5500,5501,5502, # 3888 + 838,3022,3763,2277,2773,2459,3898,1939,2051,4101,1309,3122,2246,1181,5503,1136, # 3904 +2209,3899,2375,1446,4350,2310,4712,5504,5505,4351,1055,2615, 484,3764,5506,4102, # 3920 + 625,4352,2278,3405,1499,4353,4103,5507,4104,4354,3253,2279,2280,3523,5508,5509, # 3936 +2774, 808,2616,3765,3406,4105,4355,3123,2539, 526,3407,3900,4356, 955,5510,1620, # 3952 +4357,2647,2432,5511,1429,3766,1669,1832, 994, 928,5512,3633,1260,5513,5514,5515, # 3968 +1949,2293, 741,2933,1626,4358,2738,2460, 867,1184, 362,3408,1392,5516,5517,4106, # 3984 +4359,1770,1736,3254,2934,4713,4714,1929,2707,1459,1158,5518,3070,3409,2891,1292, # 4000 +1930,2513,2855,3767,1986,1187,2072,2015,2617,4360,5519,2574,2514,2170,3768,2490, # 4016 +3332,5520,3769,4715,5521,5522, 666,1003,3023,1022,3634,4361,5523,4716,1814,2257, # 4032 + 574,3901,1603, 295,1535, 705,3902,4362, 283, 858, 417,5524,5525,3255,4717,4718, # 4048 +3071,1220,1890,1046,2281,2461,4107,1393,1599, 689,2575, 388,4363,5526,2491, 802, # 4064 +5527,2811,3903,2061,1405,2258,5528,4719,3904,2110,1052,1345,3256,1585,5529, 809, # 4080 +5530,5531,5532, 575,2739,3524, 956,1552,1469,1144,2328,5533,2329,1560,2462,3635, # 4096 +3257,4108, 616,2210,4364,3180,2183,2294,5534,1833,5535,3525,4720,5536,1319,3770, # 4112 +3771,1211,3636,1023,3258,1293,2812,5537,5538,5539,3905, 607,2311,3906, 762,2892, # 4128 +1439,4365,1360,4721,1485,3072,5540,4722,1038,4366,1450,2062,2648,4367,1379,4723, # 4144 +2593,5541,5542,4368,1352,1414,2330,2935,1172,5543,5544,3907,3908,4724,1798,1451, # 4160 +5545,5546,5547,5548,2936,4109,4110,2492,2351, 411,4111,4112,3637,3333,3124,4725, # 4176 +1561,2674,1452,4113,1375,5549,5550, 47,2974, 316,5551,1406,1591,2937,3181,5552, # 4192 +1025,2142,3125,3182, 354,2740, 884,2228,4369,2412, 508,3772, 726,3638, 996,2433, # 4208 +3639, 729,5553, 392,2194,1453,4114,4726,3773,5554,5555,2463,3640,2618,1675,2813, # 4224 + 919,2352,2975,2353,1270,4727,4115, 73,5556,5557, 647,5558,3259,2856,2259,1550, # 4240 +1346,3024,5559,1332, 883,3526,5560,5561,5562,5563,3334,2775,5564,1212, 831,1347, # 4256 +4370,4728,2331,3909,1864,3073, 720,3910,4729,4730,3911,5565,4371,5566,5567,4731, # 4272 +5568,5569,1799,4732,3774,2619,4733,3641,1645,2376,4734,5570,2938, 669,2211,2675, # 4288 +2434,5571,2893,5572,5573,1028,3260,5574,4372,2413,5575,2260,1353,5576,5577,4735, # 4304 +3183, 518,5578,4116,5579,4373,1961,5580,2143,4374,5581,5582,3025,2354,2355,3912, # 4320 + 516,1834,1454,4117,2708,4375,4736,2229,2620,1972,1129,3642,5583,2776,5584,2976, # 4336 +1422, 577,1470,3026,1524,3410,5585,5586, 432,4376,3074,3527,5587,2594,1455,2515, # 4352 +2230,1973,1175,5588,1020,2741,4118,3528,4737,5589,2742,5590,1743,1361,3075,3529, # 4368 +2649,4119,4377,4738,2295, 895, 924,4378,2171, 331,2247,3076, 166,1627,3077,1098, # 4384 +5591,1232,2894,2231,3411,4739, 657, 403,1196,2377, 542,3775,3412,1600,4379,3530, # 4400 +5592,4740,2777,3261, 576, 530,1362,4741,4742,2540,2676,3776,4120,5593, 842,3913, # 4416 +5594,2814,2032,1014,4121, 213,2709,3413, 665, 621,4380,5595,3777,2939,2435,5596, # 4432 +2436,3335,3643,3414,4743,4381,2541,4382,4744,3644,1682,4383,3531,1380,5597, 724, # 4448 +2282, 600,1670,5598,1337,1233,4745,3126,2248,5599,1621,4746,5600, 651,4384,5601, # 4464 +1612,4385,2621,5602,2857,5603,2743,2312,3078,5604, 716,2464,3079, 174,1255,2710, # 4480 +4122,3645, 548,1320,1398, 728,4123,1574,5605,1891,1197,3080,4124,5606,3081,3082, # 4496 +3778,3646,3779, 747,5607, 635,4386,4747,5608,5609,5610,4387,5611,5612,4748,5613, # 4512 +3415,4749,2437, 451,5614,3780,2542,2073,4388,2744,4389,4125,5615,1764,4750,5616, # 4528 +4390, 350,4751,2283,2395,2493,5617,4391,4126,2249,1434,4127, 488,4752, 458,4392, # 4544 +4128,3781, 771,1330,2396,3914,2576,3184,2160,2414,1553,2677,3185,4393,5618,2494, # 4560 +2895,2622,1720,2711,4394,3416,4753,5619,2543,4395,5620,3262,4396,2778,5621,2016, # 4576 +2745,5622,1155,1017,3782,3915,5623,3336,2313, 201,1865,4397,1430,5624,4129,5625, # 4592 +5626,5627,5628,5629,4398,1604,5630, 414,1866, 371,2595,4754,4755,3532,2017,3127, # 4608 +4756,1708, 960,4399, 887, 389,2172,1536,1663,1721,5631,2232,4130,2356,2940,1580, # 4624 +5632,5633,1744,4757,2544,4758,4759,5634,4760,5635,2074,5636,4761,3647,3417,2896, # 4640 +4400,5637,4401,2650,3418,2815, 673,2712,2465, 709,3533,4131,3648,4402,5638,1148, # 4656 + 502, 634,5639,5640,1204,4762,3649,1575,4763,2623,3783,5641,3784,3128, 948,3263, # 4672 + 121,1745,3916,1110,5642,4403,3083,2516,3027,4132,3785,1151,1771,3917,1488,4133, # 4688 +1987,5643,2438,3534,5644,5645,2094,5646,4404,3918,1213,1407,2816, 531,2746,2545, # 4704 +3264,1011,1537,4764,2779,4405,3129,1061,5647,3786,3787,1867,2897,5648,2018, 120, # 4720 +4406,4407,2063,3650,3265,2314,3919,2678,3419,1955,4765,4134,5649,3535,1047,2713, # 4736 +1266,5650,1368,4766,2858, 649,3420,3920,2546,2747,1102,2859,2679,5651,5652,2000, # 4752 +5653,1111,3651,2977,5654,2495,3921,3652,2817,1855,3421,3788,5655,5656,3422,2415, # 4768 +2898,3337,3266,3653,5657,2577,5658,3654,2818,4135,1460, 856,5659,3655,5660,2899, # 4784 +2978,5661,2900,3922,5662,4408, 632,2517, 875,3923,1697,3924,2296,5663,5664,4767, # 4800 +3028,1239, 580,4768,4409,5665, 914, 936,2075,1190,4136,1039,2124,5666,5667,5668, # 4816 +5669,3423,1473,5670,1354,4410,3925,4769,2173,3084,4137, 915,3338,4411,4412,3339, # 4832 +1605,1835,5671,2748, 398,3656,4413,3926,4138, 328,1913,2860,4139,3927,1331,4414, # 4848 +3029, 937,4415,5672,3657,4140,4141,3424,2161,4770,3425, 524, 742, 538,3085,1012, # 4864 +5673,5674,3928,2466,5675, 658,1103, 225,3929,5676,5677,4771,5678,4772,5679,3267, # 4880 +1243,5680,4142, 963,2250,4773,5681,2714,3658,3186,5682,5683,2596,2332,5684,4774, # 4896 +5685,5686,5687,3536, 957,3426,2547,2033,1931,2941,2467, 870,2019,3659,1746,2780, # 4912 +2781,2439,2468,5688,3930,5689,3789,3130,3790,3537,3427,3791,5690,1179,3086,5691, # 4928 +3187,2378,4416,3792,2548,3188,3131,2749,4143,5692,3428,1556,2549,2297, 977,2901, # 4944 +2034,4144,1205,3429,5693,1765,3430,3189,2125,1271, 714,1689,4775,3538,5694,2333, # 4960 +3931, 533,4417,3660,2184, 617,5695,2469,3340,3539,2315,5696,5697,3190,5698,5699, # 4976 +3932,1988, 618, 427,2651,3540,3431,5700,5701,1244,1690,5702,2819,4418,4776,5703, # 4992 +3541,4777,5704,2284,1576, 473,3661,4419,3432, 972,5705,3662,5706,3087,5707,5708, # 5008 +4778,4779,5709,3793,4145,4146,5710, 153,4780, 356,5711,1892,2902,4420,2144, 408, # 5024 + 803,2357,5712,3933,5713,4421,1646,2578,2518,4781,4782,3934,5714,3935,4422,5715, # 5040 +2416,3433, 752,5716,5717,1962,3341,2979,5718, 746,3030,2470,4783,4423,3794, 698, # 5056 +4784,1893,4424,3663,2550,4785,3664,3936,5719,3191,3434,5720,1824,1302,4147,2715, # 5072 +3937,1974,4425,5721,4426,3192, 823,1303,1288,1236,2861,3542,4148,3435, 774,3938, # 5088 +5722,1581,4786,1304,2862,3939,4787,5723,2440,2162,1083,3268,4427,4149,4428, 344, # 5104 +1173, 288,2316, 454,1683,5724,5725,1461,4788,4150,2597,5726,5727,4789, 985, 894, # 5120 +5728,3436,3193,5729,1914,2942,3795,1989,5730,2111,1975,5731,4151,5732,2579,1194, # 5136 + 425,5733,4790,3194,1245,3796,4429,5734,5735,2863,5736, 636,4791,1856,3940, 760, # 5152 +1800,5737,4430,2212,1508,4792,4152,1894,1684,2298,5738,5739,4793,4431,4432,2213, # 5168 + 479,5740,5741, 832,5742,4153,2496,5743,2980,2497,3797, 990,3132, 627,1815,2652, # 5184 +4433,1582,4434,2126,2112,3543,4794,5744, 799,4435,3195,5745,4795,2113,1737,3031, # 5200 +1018, 543, 754,4436,3342,1676,4796,4797,4154,4798,1489,5746,3544,5747,2624,2903, # 5216 +4155,5748,5749,2981,5750,5751,5752,5753,3196,4799,4800,2185,1722,5754,3269,3270, # 5232 +1843,3665,1715, 481, 365,1976,1857,5755,5756,1963,2498,4801,5757,2127,3666,3271, # 5248 + 433,1895,2064,2076,5758, 602,2750,5759,5760,5761,5762,5763,3032,1628,3437,5764, # 5264 +3197,4802,4156,2904,4803,2519,5765,2551,2782,5766,5767,5768,3343,4804,2905,5769, # 5280 +4805,5770,2864,4806,4807,1221,2982,4157,2520,5771,5772,5773,1868,1990,5774,5775, # 5296 +5776,1896,5777,5778,4808,1897,4158, 318,5779,2095,4159,4437,5780,5781, 485,5782, # 5312 + 938,3941, 553,2680, 116,5783,3942,3667,5784,3545,2681,2783,3438,3344,2820,5785, # 5328 +3668,2943,4160,1747,2944,2983,5786,5787, 207,5788,4809,5789,4810,2521,5790,3033, # 5344 + 890,3669,3943,5791,1878,3798,3439,5792,2186,2358,3440,1652,5793,5794,5795, 941, # 5360 +2299, 208,3546,4161,2020, 330,4438,3944,2906,2499,3799,4439,4811,5796,5797,5798, # 5376 #last 512 +#Everything below is of no interest for detection purpose +2522,1613,4812,5799,3345,3945,2523,5800,4162,5801,1637,4163,2471,4813,3946,5802, # 5392 +2500,3034,3800,5803,5804,2195,4814,5805,2163,5806,5807,5808,5809,5810,5811,5812, # 5408 +5813,5814,5815,5816,5817,5818,5819,5820,5821,5822,5823,5824,5825,5826,5827,5828, # 5424 +5829,5830,5831,5832,5833,5834,5835,5836,5837,5838,5839,5840,5841,5842,5843,5844, # 5440 +5845,5846,5847,5848,5849,5850,5851,5852,5853,5854,5855,5856,5857,5858,5859,5860, # 5456 +5861,5862,5863,5864,5865,5866,5867,5868,5869,5870,5871,5872,5873,5874,5875,5876, # 5472 +5877,5878,5879,5880,5881,5882,5883,5884,5885,5886,5887,5888,5889,5890,5891,5892, # 5488 +5893,5894,5895,5896,5897,5898,5899,5900,5901,5902,5903,5904,5905,5906,5907,5908, # 5504 +5909,5910,5911,5912,5913,5914,5915,5916,5917,5918,5919,5920,5921,5922,5923,5924, # 5520 +5925,5926,5927,5928,5929,5930,5931,5932,5933,5934,5935,5936,5937,5938,5939,5940, # 5536 +5941,5942,5943,5944,5945,5946,5947,5948,5949,5950,5951,5952,5953,5954,5955,5956, # 5552 +5957,5958,5959,5960,5961,5962,5963,5964,5965,5966,5967,5968,5969,5970,5971,5972, # 5568 +5973,5974,5975,5976,5977,5978,5979,5980,5981,5982,5983,5984,5985,5986,5987,5988, # 5584 +5989,5990,5991,5992,5993,5994,5995,5996,5997,5998,5999,6000,6001,6002,6003,6004, # 5600 +6005,6006,6007,6008,6009,6010,6011,6012,6013,6014,6015,6016,6017,6018,6019,6020, # 5616 +6021,6022,6023,6024,6025,6026,6027,6028,6029,6030,6031,6032,6033,6034,6035,6036, # 5632 +6037,6038,6039,6040,6041,6042,6043,6044,6045,6046,6047,6048,6049,6050,6051,6052, # 5648 +6053,6054,6055,6056,6057,6058,6059,6060,6061,6062,6063,6064,6065,6066,6067,6068, # 5664 +6069,6070,6071,6072,6073,6074,6075,6076,6077,6078,6079,6080,6081,6082,6083,6084, # 5680 +6085,6086,6087,6088,6089,6090,6091,6092,6093,6094,6095,6096,6097,6098,6099,6100, # 5696 +6101,6102,6103,6104,6105,6106,6107,6108,6109,6110,6111,6112,6113,6114,6115,6116, # 5712 +6117,6118,6119,6120,6121,6122,6123,6124,6125,6126,6127,6128,6129,6130,6131,6132, # 5728 +6133,6134,6135,6136,6137,6138,6139,6140,6141,6142,6143,6144,6145,6146,6147,6148, # 5744 +6149,6150,6151,6152,6153,6154,6155,6156,6157,6158,6159,6160,6161,6162,6163,6164, # 5760 +6165,6166,6167,6168,6169,6170,6171,6172,6173,6174,6175,6176,6177,6178,6179,6180, # 5776 +6181,6182,6183,6184,6185,6186,6187,6188,6189,6190,6191,6192,6193,6194,6195,6196, # 5792 +6197,6198,6199,6200,6201,6202,6203,6204,6205,6206,6207,6208,6209,6210,6211,6212, # 5808 +6213,6214,6215,6216,6217,6218,6219,6220,6221,6222,6223,3670,6224,6225,6226,6227, # 5824 +6228,6229,6230,6231,6232,6233,6234,6235,6236,6237,6238,6239,6240,6241,6242,6243, # 5840 +6244,6245,6246,6247,6248,6249,6250,6251,6252,6253,6254,6255,6256,6257,6258,6259, # 5856 +6260,6261,6262,6263,6264,6265,6266,6267,6268,6269,6270,6271,6272,6273,6274,6275, # 5872 +6276,6277,6278,6279,6280,6281,6282,6283,6284,6285,4815,6286,6287,6288,6289,6290, # 5888 +6291,6292,4816,6293,6294,6295,6296,6297,6298,6299,6300,6301,6302,6303,6304,6305, # 5904 +6306,6307,6308,6309,6310,6311,4817,4818,6312,6313,6314,6315,6316,6317,6318,4819, # 5920 +6319,6320,6321,6322,6323,6324,6325,6326,6327,6328,6329,6330,6331,6332,6333,6334, # 5936 +6335,6336,6337,4820,6338,6339,6340,6341,6342,6343,6344,6345,6346,6347,6348,6349, # 5952 +6350,6351,6352,6353,6354,6355,6356,6357,6358,6359,6360,6361,6362,6363,6364,6365, # 5968 +6366,6367,6368,6369,6370,6371,6372,6373,6374,6375,6376,6377,6378,6379,6380,6381, # 5984 +6382,6383,6384,6385,6386,6387,6388,6389,6390,6391,6392,6393,6394,6395,6396,6397, # 6000 +6398,6399,6400,6401,6402,6403,6404,6405,6406,6407,6408,6409,6410,3441,6411,6412, # 6016 +6413,6414,6415,6416,6417,6418,6419,6420,6421,6422,6423,6424,6425,4440,6426,6427, # 6032 +6428,6429,6430,6431,6432,6433,6434,6435,6436,6437,6438,6439,6440,6441,6442,6443, # 6048 +6444,6445,6446,6447,6448,6449,6450,6451,6452,6453,6454,4821,6455,6456,6457,6458, # 6064 +6459,6460,6461,6462,6463,6464,6465,6466,6467,6468,6469,6470,6471,6472,6473,6474, # 6080 +6475,6476,6477,3947,3948,6478,6479,6480,6481,3272,4441,6482,6483,6484,6485,4442, # 6096 +6486,6487,6488,6489,6490,6491,6492,6493,6494,6495,6496,4822,6497,6498,6499,6500, # 6112 +6501,6502,6503,6504,6505,6506,6507,6508,6509,6510,6511,6512,6513,6514,6515,6516, # 6128 +6517,6518,6519,6520,6521,6522,6523,6524,6525,6526,6527,6528,6529,6530,6531,6532, # 6144 +6533,6534,6535,6536,6537,6538,6539,6540,6541,6542,6543,6544,6545,6546,6547,6548, # 6160 +6549,6550,6551,6552,6553,6554,6555,6556,2784,6557,4823,6558,6559,6560,6561,6562, # 6176 +6563,6564,6565,6566,6567,6568,6569,3949,6570,6571,6572,4824,6573,6574,6575,6576, # 6192 +6577,6578,6579,6580,6581,6582,6583,4825,6584,6585,6586,3950,2785,6587,6588,6589, # 6208 +6590,6591,6592,6593,6594,6595,6596,6597,6598,6599,6600,6601,6602,6603,6604,6605, # 6224 +6606,6607,6608,6609,6610,6611,6612,4826,6613,6614,6615,4827,6616,6617,6618,6619, # 6240 +6620,6621,6622,6623,6624,6625,4164,6626,6627,6628,6629,6630,6631,6632,6633,6634, # 6256 +3547,6635,4828,6636,6637,6638,6639,6640,6641,6642,3951,2984,6643,6644,6645,6646, # 6272 +6647,6648,6649,4165,6650,4829,6651,6652,4830,6653,6654,6655,6656,6657,6658,6659, # 6288 +6660,6661,6662,4831,6663,6664,6665,6666,6667,6668,6669,6670,6671,4166,6672,4832, # 6304 +3952,6673,6674,6675,6676,4833,6677,6678,6679,4167,6680,6681,6682,3198,6683,6684, # 6320 +6685,6686,6687,6688,6689,6690,6691,6692,6693,6694,6695,6696,6697,4834,6698,6699, # 6336 +6700,6701,6702,6703,6704,6705,6706,6707,6708,6709,6710,6711,6712,6713,6714,6715, # 6352 +6716,6717,6718,6719,6720,6721,6722,6723,6724,6725,6726,6727,6728,6729,6730,6731, # 6368 +6732,6733,6734,4443,6735,6736,6737,6738,6739,6740,6741,6742,6743,6744,6745,4444, # 6384 +6746,6747,6748,6749,6750,6751,6752,6753,6754,6755,6756,6757,6758,6759,6760,6761, # 6400 +6762,6763,6764,6765,6766,6767,6768,6769,6770,6771,6772,6773,6774,6775,6776,6777, # 6416 +6778,6779,6780,6781,4168,6782,6783,3442,6784,6785,6786,6787,6788,6789,6790,6791, # 6432 +4169,6792,6793,6794,6795,6796,6797,6798,6799,6800,6801,6802,6803,6804,6805,6806, # 6448 +6807,6808,6809,6810,6811,4835,6812,6813,6814,4445,6815,6816,4446,6817,6818,6819, # 6464 +6820,6821,6822,6823,6824,6825,6826,6827,6828,6829,6830,6831,6832,6833,6834,6835, # 6480 +3548,6836,6837,6838,6839,6840,6841,6842,6843,6844,6845,6846,4836,6847,6848,6849, # 6496 +6850,6851,6852,6853,6854,3953,6855,6856,6857,6858,6859,6860,6861,6862,6863,6864, # 6512 +6865,6866,6867,6868,6869,6870,6871,6872,6873,6874,6875,6876,6877,3199,6878,6879, # 6528 +6880,6881,6882,4447,6883,6884,6885,6886,6887,6888,6889,6890,6891,6892,6893,6894, # 6544 +6895,6896,6897,6898,6899,6900,6901,6902,6903,6904,4170,6905,6906,6907,6908,6909, # 6560 +6910,6911,6912,6913,6914,6915,6916,6917,6918,6919,6920,6921,6922,6923,6924,6925, # 6576 +6926,6927,4837,6928,6929,6930,6931,6932,6933,6934,6935,6936,3346,6937,6938,4838, # 6592 +6939,6940,6941,4448,6942,6943,6944,6945,6946,4449,6947,6948,6949,6950,6951,6952, # 6608 +6953,6954,6955,6956,6957,6958,6959,6960,6961,6962,6963,6964,6965,6966,6967,6968, # 6624 +6969,6970,6971,6972,6973,6974,6975,6976,6977,6978,6979,6980,6981,6982,6983,6984, # 6640 +6985,6986,6987,6988,6989,6990,6991,6992,6993,6994,3671,6995,6996,6997,6998,4839, # 6656 +6999,7000,7001,7002,3549,7003,7004,7005,7006,7007,7008,7009,7010,7011,7012,7013, # 6672 +7014,7015,7016,7017,7018,7019,7020,7021,7022,7023,7024,7025,7026,7027,7028,7029, # 6688 +7030,4840,7031,7032,7033,7034,7035,7036,7037,7038,4841,7039,7040,7041,7042,7043, # 6704 +7044,7045,7046,7047,7048,7049,7050,7051,7052,7053,7054,7055,7056,7057,7058,7059, # 6720 +7060,7061,7062,7063,7064,7065,7066,7067,7068,7069,7070,2985,7071,7072,7073,7074, # 6736 +7075,7076,7077,7078,7079,7080,4842,7081,7082,7083,7084,7085,7086,7087,7088,7089, # 6752 +7090,7091,7092,7093,7094,7095,7096,7097,7098,7099,7100,7101,7102,7103,7104,7105, # 6768 +7106,7107,7108,7109,7110,7111,7112,7113,7114,7115,7116,7117,7118,4450,7119,7120, # 6784 +7121,7122,7123,7124,7125,7126,7127,7128,7129,7130,7131,7132,7133,7134,7135,7136, # 6800 +7137,7138,7139,7140,7141,7142,7143,4843,7144,7145,7146,7147,7148,7149,7150,7151, # 6816 +7152,7153,7154,7155,7156,7157,7158,7159,7160,7161,7162,7163,7164,7165,7166,7167, # 6832 +7168,7169,7170,7171,7172,7173,7174,7175,7176,7177,7178,7179,7180,7181,7182,7183, # 6848 +7184,7185,7186,7187,7188,4171,4172,7189,7190,7191,7192,7193,7194,7195,7196,7197, # 6864 +7198,7199,7200,7201,7202,7203,7204,7205,7206,7207,7208,7209,7210,7211,7212,7213, # 6880 +7214,7215,7216,7217,7218,7219,7220,7221,7222,7223,7224,7225,7226,7227,7228,7229, # 6896 +7230,7231,7232,7233,7234,7235,7236,7237,7238,7239,7240,7241,7242,7243,7244,7245, # 6912 +7246,7247,7248,7249,7250,7251,7252,7253,7254,7255,7256,7257,7258,7259,7260,7261, # 6928 +7262,7263,7264,7265,7266,7267,7268,7269,7270,7271,7272,7273,7274,7275,7276,7277, # 6944 +7278,7279,7280,7281,7282,7283,7284,7285,7286,7287,7288,7289,7290,7291,7292,7293, # 6960 +7294,7295,7296,4844,7297,7298,7299,7300,7301,7302,7303,7304,7305,7306,7307,7308, # 6976 +7309,7310,7311,7312,7313,7314,7315,7316,4451,7317,7318,7319,7320,7321,7322,7323, # 6992 +7324,7325,7326,7327,7328,7329,7330,7331,7332,7333,7334,7335,7336,7337,7338,7339, # 7008 +7340,7341,7342,7343,7344,7345,7346,7347,7348,7349,7350,7351,7352,7353,4173,7354, # 7024 +7355,4845,7356,7357,7358,7359,7360,7361,7362,7363,7364,7365,7366,7367,7368,7369, # 7040 +7370,7371,7372,7373,7374,7375,7376,7377,7378,7379,7380,7381,7382,7383,7384,7385, # 7056 +7386,7387,7388,4846,7389,7390,7391,7392,7393,7394,7395,7396,7397,7398,7399,7400, # 7072 +7401,7402,7403,7404,7405,3672,7406,7407,7408,7409,7410,7411,7412,7413,7414,7415, # 7088 +7416,7417,7418,7419,7420,7421,7422,7423,7424,7425,7426,7427,7428,7429,7430,7431, # 7104 +7432,7433,7434,7435,7436,7437,7438,7439,7440,7441,7442,7443,7444,7445,7446,7447, # 7120 +7448,7449,7450,7451,7452,7453,4452,7454,3200,7455,7456,7457,7458,7459,7460,7461, # 7136 +7462,7463,7464,7465,7466,7467,7468,7469,7470,7471,7472,7473,7474,4847,7475,7476, # 7152 +7477,3133,7478,7479,7480,7481,7482,7483,7484,7485,7486,7487,7488,7489,7490,7491, # 7168 +7492,7493,7494,7495,7496,7497,7498,7499,7500,7501,7502,3347,7503,7504,7505,7506, # 7184 +7507,7508,7509,7510,7511,7512,7513,7514,7515,7516,7517,7518,7519,7520,7521,4848, # 7200 +7522,7523,7524,7525,7526,7527,7528,7529,7530,7531,7532,7533,7534,7535,7536,7537, # 7216 +7538,7539,7540,7541,7542,7543,7544,7545,7546,7547,7548,7549,3801,4849,7550,7551, # 7232 +7552,7553,7554,7555,7556,7557,7558,7559,7560,7561,7562,7563,7564,7565,7566,7567, # 7248 +7568,7569,3035,7570,7571,7572,7573,7574,7575,7576,7577,7578,7579,7580,7581,7582, # 7264 +7583,7584,7585,7586,7587,7588,7589,7590,7591,7592,7593,7594,7595,7596,7597,7598, # 7280 +7599,7600,7601,7602,7603,7604,7605,7606,7607,7608,7609,7610,7611,7612,7613,7614, # 7296 +7615,7616,4850,7617,7618,3802,7619,7620,7621,7622,7623,7624,7625,7626,7627,7628, # 7312 +7629,7630,7631,7632,4851,7633,7634,7635,7636,7637,7638,7639,7640,7641,7642,7643, # 7328 +7644,7645,7646,7647,7648,7649,7650,7651,7652,7653,7654,7655,7656,7657,7658,7659, # 7344 +7660,7661,7662,7663,7664,7665,7666,7667,7668,7669,7670,4453,7671,7672,7673,7674, # 7360 +7675,7676,7677,7678,7679,7680,7681,7682,7683,7684,7685,7686,7687,7688,7689,7690, # 7376 +7691,7692,7693,7694,7695,7696,7697,3443,7698,7699,7700,7701,7702,4454,7703,7704, # 7392 +7705,7706,7707,7708,7709,7710,7711,7712,7713,2472,7714,7715,7716,7717,7718,7719, # 7408 +7720,7721,7722,7723,7724,7725,7726,7727,7728,7729,7730,7731,3954,7732,7733,7734, # 7424 +7735,7736,7737,7738,7739,7740,7741,7742,7743,7744,7745,7746,7747,7748,7749,7750, # 7440 +3134,7751,7752,4852,7753,7754,7755,4853,7756,7757,7758,7759,7760,4174,7761,7762, # 7456 +7763,7764,7765,7766,7767,7768,7769,7770,7771,7772,7773,7774,7775,7776,7777,7778, # 7472 +7779,7780,7781,7782,7783,7784,7785,7786,7787,7788,7789,7790,7791,7792,7793,7794, # 7488 +7795,7796,7797,7798,7799,7800,7801,7802,7803,7804,7805,4854,7806,7807,7808,7809, # 7504 +7810,7811,7812,7813,7814,7815,7816,7817,7818,7819,7820,7821,7822,7823,7824,7825, # 7520 +4855,7826,7827,7828,7829,7830,7831,7832,7833,7834,7835,7836,7837,7838,7839,7840, # 7536 +7841,7842,7843,7844,7845,7846,7847,3955,7848,7849,7850,7851,7852,7853,7854,7855, # 7552 +7856,7857,7858,7859,7860,3444,7861,7862,7863,7864,7865,7866,7867,7868,7869,7870, # 7568 +7871,7872,7873,7874,7875,7876,7877,7878,7879,7880,7881,7882,7883,7884,7885,7886, # 7584 +7887,7888,7889,7890,7891,4175,7892,7893,7894,7895,7896,4856,4857,7897,7898,7899, # 7600 +7900,2598,7901,7902,7903,7904,7905,7906,7907,7908,4455,7909,7910,7911,7912,7913, # 7616 +7914,3201,7915,7916,7917,7918,7919,7920,7921,4858,7922,7923,7924,7925,7926,7927, # 7632 +7928,7929,7930,7931,7932,7933,7934,7935,7936,7937,7938,7939,7940,7941,7942,7943, # 7648 +7944,7945,7946,7947,7948,7949,7950,7951,7952,7953,7954,7955,7956,7957,7958,7959, # 7664 +7960,7961,7962,7963,7964,7965,7966,7967,7968,7969,7970,7971,7972,7973,7974,7975, # 7680 +7976,7977,7978,7979,7980,7981,4859,7982,7983,7984,7985,7986,7987,7988,7989,7990, # 7696 +7991,7992,7993,7994,7995,7996,4860,7997,7998,7999,8000,8001,8002,8003,8004,8005, # 7712 +8006,8007,8008,8009,8010,8011,8012,8013,8014,8015,8016,4176,8017,8018,8019,8020, # 7728 +8021,8022,8023,4861,8024,8025,8026,8027,8028,8029,8030,8031,8032,8033,8034,8035, # 7744 +8036,4862,4456,8037,8038,8039,8040,4863,8041,8042,8043,8044,8045,8046,8047,8048, # 7760 +8049,8050,8051,8052,8053,8054,8055,8056,8057,8058,8059,8060,8061,8062,8063,8064, # 7776 +8065,8066,8067,8068,8069,8070,8071,8072,8073,8074,8075,8076,8077,8078,8079,8080, # 7792 +8081,8082,8083,8084,8085,8086,8087,8088,8089,8090,8091,8092,8093,8094,8095,8096, # 7808 +8097,8098,8099,4864,4177,8100,8101,8102,8103,8104,8105,8106,8107,8108,8109,8110, # 7824 +8111,8112,8113,8114,8115,8116,8117,8118,8119,8120,4178,8121,8122,8123,8124,8125, # 7840 +8126,8127,8128,8129,8130,8131,8132,8133,8134,8135,8136,8137,8138,8139,8140,8141, # 7856 +8142,8143,8144,8145,4865,4866,8146,8147,8148,8149,8150,8151,8152,8153,8154,8155, # 7872 +8156,8157,8158,8159,8160,8161,8162,8163,8164,8165,4179,8166,8167,8168,8169,8170, # 7888 +8171,8172,8173,8174,8175,8176,8177,8178,8179,8180,8181,4457,8182,8183,8184,8185, # 7904 +8186,8187,8188,8189,8190,8191,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201, # 7920 +8202,8203,8204,8205,8206,8207,8208,8209,8210,8211,8212,8213,8214,8215,8216,8217, # 7936 +8218,8219,8220,8221,8222,8223,8224,8225,8226,8227,8228,8229,8230,8231,8232,8233, # 7952 +8234,8235,8236,8237,8238,8239,8240,8241,8242,8243,8244,8245,8246,8247,8248,8249, # 7968 +8250,8251,8252,8253,8254,8255,8256,3445,8257,8258,8259,8260,8261,8262,4458,8263, # 7984 +8264,8265,8266,8267,8268,8269,8270,8271,8272,4459,8273,8274,8275,8276,3550,8277, # 8000 +8278,8279,8280,8281,8282,8283,8284,8285,8286,8287,8288,8289,4460,8290,8291,8292, # 8016 +8293,8294,8295,8296,8297,8298,8299,8300,8301,8302,8303,8304,8305,8306,8307,4867, # 8032 +8308,8309,8310,8311,8312,3551,8313,8314,8315,8316,8317,8318,8319,8320,8321,8322, # 8048 +8323,8324,8325,8326,4868,8327,8328,8329,8330,8331,8332,8333,8334,8335,8336,8337, # 8064 +8338,8339,8340,8341,8342,8343,8344,8345,8346,8347,8348,8349,8350,8351,8352,8353, # 8080 +8354,8355,8356,8357,8358,8359,8360,8361,8362,8363,4869,4461,8364,8365,8366,8367, # 8096 +8368,8369,8370,4870,8371,8372,8373,8374,8375,8376,8377,8378,8379,8380,8381,8382, # 8112 +8383,8384,8385,8386,8387,8388,8389,8390,8391,8392,8393,8394,8395,8396,8397,8398, # 8128 +8399,8400,8401,8402,8403,8404,8405,8406,8407,8408,8409,8410,4871,8411,8412,8413, # 8144 +8414,8415,8416,8417,8418,8419,8420,8421,8422,4462,8423,8424,8425,8426,8427,8428, # 8160 +8429,8430,8431,8432,8433,2986,8434,8435,8436,8437,8438,8439,8440,8441,8442,8443, # 8176 +8444,8445,8446,8447,8448,8449,8450,8451,8452,8453,8454,8455,8456,8457,8458,8459, # 8192 +8460,8461,8462,8463,8464,8465,8466,8467,8468,8469,8470,8471,8472,8473,8474,8475, # 8208 +8476,8477,8478,4180,8479,8480,8481,8482,8483,8484,8485,8486,8487,8488,8489,8490, # 8224 +8491,8492,8493,8494,8495,8496,8497,8498,8499,8500,8501,8502,8503,8504,8505,8506, # 8240 +8507,8508,8509,8510,8511,8512,8513,8514,8515,8516,8517,8518,8519,8520,8521,8522, # 8256 +8523,8524,8525,8526,8527,8528,8529,8530,8531,8532,8533,8534,8535,8536,8537,8538, # 8272 +8539,8540,8541,8542,8543,8544,8545,8546,8547,8548,8549,8550,8551,8552,8553,8554, # 8288 +8555,8556,8557,8558,8559,8560,8561,8562,8563,8564,4872,8565,8566,8567,8568,8569, # 8304 +8570,8571,8572,8573,4873,8574,8575,8576,8577,8578,8579,8580,8581,8582,8583,8584, # 8320 +8585,8586,8587,8588,8589,8590,8591,8592,8593,8594,8595,8596,8597,8598,8599,8600, # 8336 +8601,8602,8603,8604,8605,3803,8606,8607,8608,8609,8610,8611,8612,8613,4874,3804, # 8352 +8614,8615,8616,8617,8618,8619,8620,8621,3956,8622,8623,8624,8625,8626,8627,8628, # 8368 +8629,8630,8631,8632,8633,8634,8635,8636,8637,8638,2865,8639,8640,8641,8642,8643, # 8384 +8644,8645,8646,8647,8648,8649,8650,8651,8652,8653,8654,8655,8656,4463,8657,8658, # 8400 +8659,4875,4876,8660,8661,8662,8663,8664,8665,8666,8667,8668,8669,8670,8671,8672, # 8416 +8673,8674,8675,8676,8677,8678,8679,8680,8681,4464,8682,8683,8684,8685,8686,8687, # 8432 +8688,8689,8690,8691,8692,8693,8694,8695,8696,8697,8698,8699,8700,8701,8702,8703, # 8448 +8704,8705,8706,8707,8708,8709,2261,8710,8711,8712,8713,8714,8715,8716,8717,8718, # 8464 +8719,8720,8721,8722,8723,8724,8725,8726,8727,8728,8729,8730,8731,8732,8733,4181, # 8480 +8734,8735,8736,8737,8738,8739,8740,8741,8742,8743,8744,8745,8746,8747,8748,8749, # 8496 +8750,8751,8752,8753,8754,8755,8756,8757,8758,8759,8760,8761,8762,8763,4877,8764, # 8512 +8765,8766,8767,8768,8769,8770,8771,8772,8773,8774,8775,8776,8777,8778,8779,8780, # 8528 +8781,8782,8783,8784,8785,8786,8787,8788,4878,8789,4879,8790,8791,8792,4880,8793, # 8544 +8794,8795,8796,8797,8798,8799,8800,8801,4881,8802,8803,8804,8805,8806,8807,8808, # 8560 +8809,8810,8811,8812,8813,8814,8815,3957,8816,8817,8818,8819,8820,8821,8822,8823, # 8576 +8824,8825,8826,8827,8828,8829,8830,8831,8832,8833,8834,8835,8836,8837,8838,8839, # 8592 +8840,8841,8842,8843,8844,8845,8846,8847,4882,8848,8849,8850,8851,8852,8853,8854, # 8608 +8855,8856,8857,8858,8859,8860,8861,8862,8863,8864,8865,8866,8867,8868,8869,8870, # 8624 +8871,8872,8873,8874,8875,8876,8877,8878,8879,8880,8881,8882,8883,8884,3202,8885, # 8640 +8886,8887,8888,8889,8890,8891,8892,8893,8894,8895,8896,8897,8898,8899,8900,8901, # 8656 +8902,8903,8904,8905,8906,8907,8908,8909,8910,8911,8912,8913,8914,8915,8916,8917, # 8672 +8918,8919,8920,8921,8922,8923,8924,4465,8925,8926,8927,8928,8929,8930,8931,8932, # 8688 +4883,8933,8934,8935,8936,8937,8938,8939,8940,8941,8942,8943,2214,8944,8945,8946, # 8704 +8947,8948,8949,8950,8951,8952,8953,8954,8955,8956,8957,8958,8959,8960,8961,8962, # 8720 +8963,8964,8965,4884,8966,8967,8968,8969,8970,8971,8972,8973,8974,8975,8976,8977, # 8736 +8978,8979,8980,8981,8982,8983,8984,8985,8986,8987,8988,8989,8990,8991,8992,4885, # 8752 +8993,8994,8995,8996,8997,8998,8999,9000,9001,9002,9003,9004,9005,9006,9007,9008, # 8768 +9009,9010,9011,9012,9013,9014,9015,9016,9017,9018,9019,9020,9021,4182,9022,9023, # 8784 +9024,9025,9026,9027,9028,9029,9030,9031,9032,9033,9034,9035,9036,9037,9038,9039, # 8800 +9040,9041,9042,9043,9044,9045,9046,9047,9048,9049,9050,9051,9052,9053,9054,9055, # 8816 +9056,9057,9058,9059,9060,9061,9062,9063,4886,9064,9065,9066,9067,9068,9069,4887, # 8832 +9070,9071,9072,9073,9074,9075,9076,9077,9078,9079,9080,9081,9082,9083,9084,9085, # 8848 +9086,9087,9088,9089,9090,9091,9092,9093,9094,9095,9096,9097,9098,9099,9100,9101, # 8864 +9102,9103,9104,9105,9106,9107,9108,9109,9110,9111,9112,9113,9114,9115,9116,9117, # 8880 +9118,9119,9120,9121,9122,9123,9124,9125,9126,9127,9128,9129,9130,9131,9132,9133, # 8896 +9134,9135,9136,9137,9138,9139,9140,9141,3958,9142,9143,9144,9145,9146,9147,9148, # 8912 +9149,9150,9151,4888,9152,9153,9154,9155,9156,9157,9158,9159,9160,9161,9162,9163, # 8928 +9164,9165,9166,9167,9168,9169,9170,9171,9172,9173,9174,9175,4889,9176,9177,9178, # 8944 +9179,9180,9181,9182,9183,9184,9185,9186,9187,9188,9189,9190,9191,9192,9193,9194, # 8960 +9195,9196,9197,9198,9199,9200,9201,9202,9203,4890,9204,9205,9206,9207,9208,9209, # 8976 +9210,9211,9212,9213,9214,9215,9216,9217,9218,9219,9220,9221,9222,4466,9223,9224, # 8992 +9225,9226,9227,9228,9229,9230,9231,9232,9233,9234,9235,9236,9237,9238,9239,9240, # 9008 +9241,9242,9243,9244,9245,4891,9246,9247,9248,9249,9250,9251,9252,9253,9254,9255, # 9024 +9256,9257,4892,9258,9259,9260,9261,4893,4894,9262,9263,9264,9265,9266,9267,9268, # 9040 +9269,9270,9271,9272,9273,4467,9274,9275,9276,9277,9278,9279,9280,9281,9282,9283, # 9056 +9284,9285,3673,9286,9287,9288,9289,9290,9291,9292,9293,9294,9295,9296,9297,9298, # 9072 +9299,9300,9301,9302,9303,9304,9305,9306,9307,9308,9309,9310,9311,9312,9313,9314, # 9088 +9315,9316,9317,9318,9319,9320,9321,9322,4895,9323,9324,9325,9326,9327,9328,9329, # 9104 +9330,9331,9332,9333,9334,9335,9336,9337,9338,9339,9340,9341,9342,9343,9344,9345, # 9120 +9346,9347,4468,9348,9349,9350,9351,9352,9353,9354,9355,9356,9357,9358,9359,9360, # 9136 +9361,9362,9363,9364,9365,9366,9367,9368,9369,9370,9371,9372,9373,4896,9374,4469, # 9152 +9375,9376,9377,9378,9379,4897,9380,9381,9382,9383,9384,9385,9386,9387,9388,9389, # 9168 +9390,9391,9392,9393,9394,9395,9396,9397,9398,9399,9400,9401,9402,9403,9404,9405, # 9184 +9406,4470,9407,2751,9408,9409,3674,3552,9410,9411,9412,9413,9414,9415,9416,9417, # 9200 +9418,9419,9420,9421,4898,9422,9423,9424,9425,9426,9427,9428,9429,3959,9430,9431, # 9216 +9432,9433,9434,9435,9436,4471,9437,9438,9439,9440,9441,9442,9443,9444,9445,9446, # 9232 +9447,9448,9449,9450,3348,9451,9452,9453,9454,9455,9456,9457,9458,9459,9460,9461, # 9248 +9462,9463,9464,9465,9466,9467,9468,9469,9470,9471,9472,4899,9473,9474,9475,9476, # 9264 +9477,4900,9478,9479,9480,9481,9482,9483,9484,9485,9486,9487,9488,3349,9489,9490, # 9280 +9491,9492,9493,9494,9495,9496,9497,9498,9499,9500,9501,9502,9503,9504,9505,9506, # 9296 +9507,9508,9509,9510,9511,9512,9513,9514,9515,9516,9517,9518,9519,9520,4901,9521, # 9312 +9522,9523,9524,9525,9526,4902,9527,9528,9529,9530,9531,9532,9533,9534,9535,9536, # 9328 +9537,9538,9539,9540,9541,9542,9543,9544,9545,9546,9547,9548,9549,9550,9551,9552, # 9344 +9553,9554,9555,9556,9557,9558,9559,9560,9561,9562,9563,9564,9565,9566,9567,9568, # 9360 +9569,9570,9571,9572,9573,9574,9575,9576,9577,9578,9579,9580,9581,9582,9583,9584, # 9376 +3805,9585,9586,9587,9588,9589,9590,9591,9592,9593,9594,9595,9596,9597,9598,9599, # 9392 +9600,9601,9602,4903,9603,9604,9605,9606,9607,4904,9608,9609,9610,9611,9612,9613, # 9408 +9614,4905,9615,9616,9617,9618,9619,9620,9621,9622,9623,9624,9625,9626,9627,9628, # 9424 +9629,9630,9631,9632,4906,9633,9634,9635,9636,9637,9638,9639,9640,9641,9642,9643, # 9440 +4907,9644,9645,9646,9647,9648,9649,9650,9651,9652,9653,9654,9655,9656,9657,9658, # 9456 +9659,9660,9661,9662,9663,9664,9665,9666,9667,9668,9669,9670,9671,9672,4183,9673, # 9472 +9674,9675,9676,9677,4908,9678,9679,9680,9681,4909,9682,9683,9684,9685,9686,9687, # 9488 +9688,9689,9690,4910,9691,9692,9693,3675,9694,9695,9696,2945,9697,9698,9699,9700, # 9504 +9701,9702,9703,9704,9705,4911,9706,9707,9708,9709,9710,9711,9712,9713,9714,9715, # 9520 +9716,9717,9718,9719,9720,9721,9722,9723,9724,9725,9726,9727,9728,9729,9730,9731, # 9536 +9732,9733,9734,9735,4912,9736,9737,9738,9739,9740,4913,9741,9742,9743,9744,9745, # 9552 +9746,9747,9748,9749,9750,9751,9752,9753,9754,9755,9756,9757,9758,4914,9759,9760, # 9568 +9761,9762,9763,9764,9765,9766,9767,9768,9769,9770,9771,9772,9773,9774,9775,9776, # 9584 +9777,9778,9779,9780,9781,9782,4915,9783,9784,9785,9786,9787,9788,9789,9790,9791, # 9600 +9792,9793,4916,9794,9795,9796,9797,9798,9799,9800,9801,9802,9803,9804,9805,9806, # 9616 +9807,9808,9809,9810,9811,9812,9813,9814,9815,9816,9817,9818,9819,9820,9821,9822, # 9632 +9823,9824,9825,9826,9827,9828,9829,9830,9831,9832,9833,9834,9835,9836,9837,9838, # 9648 +9839,9840,9841,9842,9843,9844,9845,9846,9847,9848,9849,9850,9851,9852,9853,9854, # 9664 +9855,9856,9857,9858,9859,9860,9861,9862,9863,9864,9865,9866,9867,9868,4917,9869, # 9680 +9870,9871,9872,9873,9874,9875,9876,9877,9878,9879,9880,9881,9882,9883,9884,9885, # 9696 +9886,9887,9888,9889,9890,9891,9892,4472,9893,9894,9895,9896,9897,3806,9898,9899, # 9712 +9900,9901,9902,9903,9904,9905,9906,9907,9908,9909,9910,9911,9912,9913,9914,4918, # 9728 +9915,9916,9917,4919,9918,9919,9920,9921,4184,9922,9923,9924,9925,9926,9927,9928, # 9744 +9929,9930,9931,9932,9933,9934,9935,9936,9937,9938,9939,9940,9941,9942,9943,9944, # 9760 +9945,9946,4920,9947,9948,9949,9950,9951,9952,9953,9954,9955,4185,9956,9957,9958, # 9776 +9959,9960,9961,9962,9963,9964,9965,4921,9966,9967,9968,4473,9969,9970,9971,9972, # 9792 +9973,9974,9975,9976,9977,4474,9978,9979,9980,9981,9982,9983,9984,9985,9986,9987, # 9808 +9988,9989,9990,9991,9992,9993,9994,9995,9996,9997,9998,9999,10000,10001,10002,10003, # 9824 +10004,10005,10006,10007,10008,10009,10010,10011,10012,10013,10014,10015,10016,10017,10018,10019, # 9840 +10020,10021,4922,10022,4923,10023,10024,10025,10026,10027,10028,10029,10030,10031,10032,10033, # 9856 +10034,10035,10036,10037,10038,10039,10040,10041,10042,10043,10044,10045,10046,10047,10048,4924, # 9872 +10049,10050,10051,10052,10053,10054,10055,10056,10057,10058,10059,10060,10061,10062,10063,10064, # 9888 +10065,10066,10067,10068,10069,10070,10071,10072,10073,10074,10075,10076,10077,10078,10079,10080, # 9904 +10081,10082,10083,10084,10085,10086,10087,4475,10088,10089,10090,10091,10092,10093,10094,10095, # 9920 +10096,10097,4476,10098,10099,10100,10101,10102,10103,10104,10105,10106,10107,10108,10109,10110, # 9936 +10111,2174,10112,10113,10114,10115,10116,10117,10118,10119,10120,10121,10122,10123,10124,10125, # 9952 +10126,10127,10128,10129,10130,10131,10132,10133,10134,10135,10136,10137,10138,10139,10140,3807, # 9968 +4186,4925,10141,10142,10143,10144,10145,10146,10147,4477,4187,10148,10149,10150,10151,10152, # 9984 +10153,4188,10154,10155,10156,10157,10158,10159,10160,10161,4926,10162,10163,10164,10165,10166, #10000 +10167,10168,10169,10170,10171,10172,10173,10174,10175,10176,10177,10178,10179,10180,10181,10182, #10016 +10183,10184,10185,10186,10187,10188,10189,10190,10191,10192,3203,10193,10194,10195,10196,10197, #10032 +10198,10199,10200,4478,10201,10202,10203,10204,4479,10205,10206,10207,10208,10209,10210,10211, #10048 +10212,10213,10214,10215,10216,10217,10218,10219,10220,10221,10222,10223,10224,10225,10226,10227, #10064 +10228,10229,10230,10231,10232,10233,10234,4927,10235,10236,10237,10238,10239,10240,10241,10242, #10080 +10243,10244,10245,10246,10247,10248,10249,10250,10251,10252,10253,10254,10255,10256,10257,10258, #10096 +10259,10260,10261,10262,10263,10264,10265,10266,10267,10268,10269,10270,10271,10272,10273,4480, #10112 +4928,4929,10274,10275,10276,10277,10278,10279,10280,10281,10282,10283,10284,10285,10286,10287, #10128 +10288,10289,10290,10291,10292,10293,10294,10295,10296,10297,10298,10299,10300,10301,10302,10303, #10144 +10304,10305,10306,10307,10308,10309,10310,10311,10312,10313,10314,10315,10316,10317,10318,10319, #10160 +10320,10321,10322,10323,10324,10325,10326,10327,10328,10329,10330,10331,10332,10333,10334,4930, #10176 +10335,10336,10337,10338,10339,10340,10341,10342,4931,10343,10344,10345,10346,10347,10348,10349, #10192 +10350,10351,10352,10353,10354,10355,3088,10356,2786,10357,10358,10359,10360,4189,10361,10362, #10208 +10363,10364,10365,10366,10367,10368,10369,10370,10371,10372,10373,10374,10375,4932,10376,10377, #10224 +10378,10379,10380,10381,10382,10383,10384,10385,10386,10387,10388,10389,10390,10391,10392,4933, #10240 +10393,10394,10395,4934,10396,10397,10398,10399,10400,10401,10402,10403,10404,10405,10406,10407, #10256 +10408,10409,10410,10411,10412,3446,10413,10414,10415,10416,10417,10418,10419,10420,10421,10422, #10272 +10423,4935,10424,10425,10426,10427,10428,10429,10430,4936,10431,10432,10433,10434,10435,10436, #10288 +10437,10438,10439,10440,10441,10442,10443,4937,10444,10445,10446,10447,4481,10448,10449,10450, #10304 +10451,10452,10453,10454,10455,10456,10457,10458,10459,10460,10461,10462,10463,10464,10465,10466, #10320 +10467,10468,10469,10470,10471,10472,10473,10474,10475,10476,10477,10478,10479,10480,10481,10482, #10336 +10483,10484,10485,10486,10487,10488,10489,10490,10491,10492,10493,10494,10495,10496,10497,10498, #10352 +10499,10500,10501,10502,10503,10504,10505,4938,10506,10507,10508,10509,10510,2552,10511,10512, #10368 +10513,10514,10515,10516,3447,10517,10518,10519,10520,10521,10522,10523,10524,10525,10526,10527, #10384 +10528,10529,10530,10531,10532,10533,10534,10535,10536,10537,10538,10539,10540,10541,10542,10543, #10400 +4482,10544,4939,10545,10546,10547,10548,10549,10550,10551,10552,10553,10554,10555,10556,10557, #10416 +10558,10559,10560,10561,10562,10563,10564,10565,10566,10567,3676,4483,10568,10569,10570,10571, #10432 +10572,3448,10573,10574,10575,10576,10577,10578,10579,10580,10581,10582,10583,10584,10585,10586, #10448 +10587,10588,10589,10590,10591,10592,10593,10594,10595,10596,10597,10598,10599,10600,10601,10602, #10464 +10603,10604,10605,10606,10607,10608,10609,10610,10611,10612,10613,10614,10615,10616,10617,10618, #10480 +10619,10620,10621,10622,10623,10624,10625,10626,10627,4484,10628,10629,10630,10631,10632,4940, #10496 +10633,10634,10635,10636,10637,10638,10639,10640,10641,10642,10643,10644,10645,10646,10647,10648, #10512 +10649,10650,10651,10652,10653,10654,10655,10656,4941,10657,10658,10659,2599,10660,10661,10662, #10528 +10663,10664,10665,10666,3089,10667,10668,10669,10670,10671,10672,10673,10674,10675,10676,10677, #10544 +10678,10679,10680,4942,10681,10682,10683,10684,10685,10686,10687,10688,10689,10690,10691,10692, #10560 +10693,10694,10695,10696,10697,4485,10698,10699,10700,10701,10702,10703,10704,4943,10705,3677, #10576 +10706,10707,10708,10709,10710,10711,10712,4944,10713,10714,10715,10716,10717,10718,10719,10720, #10592 +10721,10722,10723,10724,10725,10726,10727,10728,4945,10729,10730,10731,10732,10733,10734,10735, #10608 +10736,10737,10738,10739,10740,10741,10742,10743,10744,10745,10746,10747,10748,10749,10750,10751, #10624 +10752,10753,10754,10755,10756,10757,10758,10759,10760,10761,4946,10762,10763,10764,10765,10766, #10640 +10767,4947,4948,10768,10769,10770,10771,10772,10773,10774,10775,10776,10777,10778,10779,10780, #10656 +10781,10782,10783,10784,10785,10786,10787,10788,10789,10790,10791,10792,10793,10794,10795,10796, #10672 +10797,10798,10799,10800,10801,10802,10803,10804,10805,10806,10807,10808,10809,10810,10811,10812, #10688 +10813,10814,10815,10816,10817,10818,10819,10820,10821,10822,10823,10824,10825,10826,10827,10828, #10704 +10829,10830,10831,10832,10833,10834,10835,10836,10837,10838,10839,10840,10841,10842,10843,10844, #10720 +10845,10846,10847,10848,10849,10850,10851,10852,10853,10854,10855,10856,10857,10858,10859,10860, #10736 +10861,10862,10863,10864,10865,10866,10867,10868,10869,10870,10871,10872,10873,10874,10875,10876, #10752 +10877,10878,4486,10879,10880,10881,10882,10883,10884,10885,4949,10886,10887,10888,10889,10890, #10768 +10891,10892,10893,10894,10895,10896,10897,10898,10899,10900,10901,10902,10903,10904,10905,10906, #10784 +10907,10908,10909,10910,10911,10912,10913,10914,10915,10916,10917,10918,10919,4487,10920,10921, #10800 +10922,10923,10924,10925,10926,10927,10928,10929,10930,10931,10932,4950,10933,10934,10935,10936, #10816 +10937,10938,10939,10940,10941,10942,10943,10944,10945,10946,10947,10948,10949,4488,10950,10951, #10832 +10952,10953,10954,10955,10956,10957,10958,10959,4190,10960,10961,10962,10963,10964,10965,10966, #10848 +10967,10968,10969,10970,10971,10972,10973,10974,10975,10976,10977,10978,10979,10980,10981,10982, #10864 +10983,10984,10985,10986,10987,10988,10989,10990,10991,10992,10993,10994,10995,10996,10997,10998, #10880 +10999,11000,11001,11002,11003,11004,11005,11006,3960,11007,11008,11009,11010,11011,11012,11013, #10896 +11014,11015,11016,11017,11018,11019,11020,11021,11022,11023,11024,11025,11026,11027,11028,11029, #10912 +11030,11031,11032,4951,11033,11034,11035,11036,11037,11038,11039,11040,11041,11042,11043,11044, #10928 +11045,11046,11047,4489,11048,11049,11050,11051,4952,11052,11053,11054,11055,11056,11057,11058, #10944 +4953,11059,11060,11061,11062,11063,11064,11065,11066,11067,11068,11069,11070,11071,4954,11072, #10960 +11073,11074,11075,11076,11077,11078,11079,11080,11081,11082,11083,11084,11085,11086,11087,11088, #10976 +11089,11090,11091,11092,11093,11094,11095,11096,11097,11098,11099,11100,11101,11102,11103,11104, #10992 +11105,11106,11107,11108,11109,11110,11111,11112,11113,11114,11115,3808,11116,11117,11118,11119, #11008 +11120,11121,11122,11123,11124,11125,11126,11127,11128,11129,11130,11131,11132,11133,11134,4955, #11024 +11135,11136,11137,11138,11139,11140,11141,11142,11143,11144,11145,11146,11147,11148,11149,11150, #11040 +11151,11152,11153,11154,11155,11156,11157,11158,11159,11160,11161,4956,11162,11163,11164,11165, #11056 +11166,11167,11168,11169,11170,11171,11172,11173,11174,11175,11176,11177,11178,11179,11180,4957, #11072 +11181,11182,11183,11184,11185,11186,4958,11187,11188,11189,11190,11191,11192,11193,11194,11195, #11088 +11196,11197,11198,11199,11200,3678,11201,11202,11203,11204,11205,11206,4191,11207,11208,11209, #11104 +11210,11211,11212,11213,11214,11215,11216,11217,11218,11219,11220,11221,11222,11223,11224,11225, #11120 +11226,11227,11228,11229,11230,11231,11232,11233,11234,11235,11236,11237,11238,11239,11240,11241, #11136 +11242,11243,11244,11245,11246,11247,11248,11249,11250,11251,4959,11252,11253,11254,11255,11256, #11152 +11257,11258,11259,11260,11261,11262,11263,11264,11265,11266,11267,11268,11269,11270,11271,11272, #11168 +11273,11274,11275,11276,11277,11278,11279,11280,11281,11282,11283,11284,11285,11286,11287,11288, #11184 +11289,11290,11291,11292,11293,11294,11295,11296,11297,11298,11299,11300,11301,11302,11303,11304, #11200 +11305,11306,11307,11308,11309,11310,11311,11312,11313,11314,3679,11315,11316,11317,11318,4490, #11216 +11319,11320,11321,11322,11323,11324,11325,11326,11327,11328,11329,11330,11331,11332,11333,11334, #11232 +11335,11336,11337,11338,11339,11340,11341,11342,11343,11344,11345,11346,11347,4960,11348,11349, #11248 +11350,11351,11352,11353,11354,11355,11356,11357,11358,11359,11360,11361,11362,11363,11364,11365, #11264 +11366,11367,11368,11369,11370,11371,11372,11373,11374,11375,11376,11377,3961,4961,11378,11379, #11280 +11380,11381,11382,11383,11384,11385,11386,11387,11388,11389,11390,11391,11392,11393,11394,11395, #11296 +11396,11397,4192,11398,11399,11400,11401,11402,11403,11404,11405,11406,11407,11408,11409,11410, #11312 +11411,4962,11412,11413,11414,11415,11416,11417,11418,11419,11420,11421,11422,11423,11424,11425, #11328 +11426,11427,11428,11429,11430,11431,11432,11433,11434,11435,11436,11437,11438,11439,11440,11441, #11344 +11442,11443,11444,11445,11446,11447,11448,11449,11450,11451,11452,11453,11454,11455,11456,11457, #11360 +11458,11459,11460,11461,11462,11463,11464,11465,11466,11467,11468,11469,4963,11470,11471,4491, #11376 +11472,11473,11474,11475,4964,11476,11477,11478,11479,11480,11481,11482,11483,11484,11485,11486, #11392 +11487,11488,11489,11490,11491,11492,4965,11493,11494,11495,11496,11497,11498,11499,11500,11501, #11408 +11502,11503,11504,11505,11506,11507,11508,11509,11510,11511,11512,11513,11514,11515,11516,11517, #11424 +11518,11519,11520,11521,11522,11523,11524,11525,11526,11527,11528,11529,3962,11530,11531,11532, #11440 +11533,11534,11535,11536,11537,11538,11539,11540,11541,11542,11543,11544,11545,11546,11547,11548, #11456 +11549,11550,11551,11552,11553,11554,11555,11556,11557,11558,11559,11560,11561,11562,11563,11564, #11472 +4193,4194,11565,11566,11567,11568,11569,11570,11571,11572,11573,11574,11575,11576,11577,11578, #11488 +11579,11580,11581,11582,11583,11584,11585,11586,11587,11588,11589,11590,11591,4966,4195,11592, #11504 +11593,11594,11595,11596,11597,11598,11599,11600,11601,11602,11603,11604,3090,11605,11606,11607, #11520 +11608,11609,11610,4967,11611,11612,11613,11614,11615,11616,11617,11618,11619,11620,11621,11622, #11536 +11623,11624,11625,11626,11627,11628,11629,11630,11631,11632,11633,11634,11635,11636,11637,11638, #11552 +11639,11640,11641,11642,11643,11644,11645,11646,11647,11648,11649,11650,11651,11652,11653,11654, #11568 +11655,11656,11657,11658,11659,11660,11661,11662,11663,11664,11665,11666,11667,11668,11669,11670, #11584 +11671,11672,11673,11674,4968,11675,11676,11677,11678,11679,11680,11681,11682,11683,11684,11685, #11600 +11686,11687,11688,11689,11690,11691,11692,11693,3809,11694,11695,11696,11697,11698,11699,11700, #11616 +11701,11702,11703,11704,11705,11706,11707,11708,11709,11710,11711,11712,11713,11714,11715,11716, #11632 +11717,11718,3553,11719,11720,11721,11722,11723,11724,11725,11726,11727,11728,11729,11730,4969, #11648 +11731,11732,11733,11734,11735,11736,11737,11738,11739,11740,4492,11741,11742,11743,11744,11745, #11664 +11746,11747,11748,11749,11750,11751,11752,4970,11753,11754,11755,11756,11757,11758,11759,11760, #11680 +11761,11762,11763,11764,11765,11766,11767,11768,11769,11770,11771,11772,11773,11774,11775,11776, #11696 +11777,11778,11779,11780,11781,11782,11783,11784,11785,11786,11787,11788,11789,11790,4971,11791, #11712 +11792,11793,11794,11795,11796,11797,4972,11798,11799,11800,11801,11802,11803,11804,11805,11806, #11728 +11807,11808,11809,11810,4973,11811,11812,11813,11814,11815,11816,11817,11818,11819,11820,11821, #11744 +11822,11823,11824,11825,11826,11827,11828,11829,11830,11831,11832,11833,11834,3680,3810,11835, #11760 +11836,4974,11837,11838,11839,11840,11841,11842,11843,11844,11845,11846,11847,11848,11849,11850, #11776 +11851,11852,11853,11854,11855,11856,11857,11858,11859,11860,11861,11862,11863,11864,11865,11866, #11792 +11867,11868,11869,11870,11871,11872,11873,11874,11875,11876,11877,11878,11879,11880,11881,11882, #11808 +11883,11884,4493,11885,11886,11887,11888,11889,11890,11891,11892,11893,11894,11895,11896,11897, #11824 +11898,11899,11900,11901,11902,11903,11904,11905,11906,11907,11908,11909,11910,11911,11912,11913, #11840 +11914,11915,4975,11916,11917,11918,11919,11920,11921,11922,11923,11924,11925,11926,11927,11928, #11856 +11929,11930,11931,11932,11933,11934,11935,11936,11937,11938,11939,11940,11941,11942,11943,11944, #11872 +11945,11946,11947,11948,11949,4976,11950,11951,11952,11953,11954,11955,11956,11957,11958,11959, #11888 +11960,11961,11962,11963,11964,11965,11966,11967,11968,11969,11970,11971,11972,11973,11974,11975, #11904 +11976,11977,11978,11979,11980,11981,11982,11983,11984,11985,11986,11987,4196,11988,11989,11990, #11920 +11991,11992,4977,11993,11994,11995,11996,11997,11998,11999,12000,12001,12002,12003,12004,12005, #11936 +12006,12007,12008,12009,12010,12011,12012,12013,12014,12015,12016,12017,12018,12019,12020,12021, #11952 +12022,12023,12024,12025,12026,12027,12028,12029,12030,12031,12032,12033,12034,12035,12036,12037, #11968 +12038,12039,12040,12041,12042,12043,12044,12045,12046,12047,12048,12049,12050,12051,12052,12053, #11984 +12054,12055,12056,12057,12058,12059,12060,12061,4978,12062,12063,12064,12065,12066,12067,12068, #12000 +12069,12070,12071,12072,12073,12074,12075,12076,12077,12078,12079,12080,12081,12082,12083,12084, #12016 +12085,12086,12087,12088,12089,12090,12091,12092,12093,12094,12095,12096,12097,12098,12099,12100, #12032 +12101,12102,12103,12104,12105,12106,12107,12108,12109,12110,12111,12112,12113,12114,12115,12116, #12048 +12117,12118,12119,12120,12121,12122,12123,4979,12124,12125,12126,12127,12128,4197,12129,12130, #12064 +12131,12132,12133,12134,12135,12136,12137,12138,12139,12140,12141,12142,12143,12144,12145,12146, #12080 +12147,12148,12149,12150,12151,12152,12153,12154,4980,12155,12156,12157,12158,12159,12160,4494, #12096 +12161,12162,12163,12164,3811,12165,12166,12167,12168,12169,4495,12170,12171,4496,12172,12173, #12112 +12174,12175,12176,3812,12177,12178,12179,12180,12181,12182,12183,12184,12185,12186,12187,12188, #12128 +12189,12190,12191,12192,12193,12194,12195,12196,12197,12198,12199,12200,12201,12202,12203,12204, #12144 +12205,12206,12207,12208,12209,12210,12211,12212,12213,12214,12215,12216,12217,12218,12219,12220, #12160 +12221,4981,12222,12223,12224,12225,12226,12227,12228,12229,12230,12231,12232,12233,12234,12235, #12176 +4982,12236,12237,12238,12239,12240,12241,12242,12243,12244,12245,4983,12246,12247,12248,12249, #12192 +4984,12250,12251,12252,12253,12254,12255,12256,12257,12258,12259,12260,12261,12262,12263,12264, #12208 +4985,12265,4497,12266,12267,12268,12269,12270,12271,12272,12273,12274,12275,12276,12277,12278, #12224 +12279,12280,12281,12282,12283,12284,12285,12286,12287,4986,12288,12289,12290,12291,12292,12293, #12240 +12294,12295,12296,2473,12297,12298,12299,12300,12301,12302,12303,12304,12305,12306,12307,12308, #12256 +12309,12310,12311,12312,12313,12314,12315,12316,12317,12318,12319,3963,12320,12321,12322,12323, #12272 +12324,12325,12326,12327,12328,12329,12330,12331,12332,4987,12333,12334,12335,12336,12337,12338, #12288 +12339,12340,12341,12342,12343,12344,12345,12346,12347,12348,12349,12350,12351,12352,12353,12354, #12304 +12355,12356,12357,12358,12359,3964,12360,12361,12362,12363,12364,12365,12366,12367,12368,12369, #12320 +12370,3965,12371,12372,12373,12374,12375,12376,12377,12378,12379,12380,12381,12382,12383,12384, #12336 +12385,12386,12387,12388,12389,12390,12391,12392,12393,12394,12395,12396,12397,12398,12399,12400, #12352 +12401,12402,12403,12404,12405,12406,12407,12408,4988,12409,12410,12411,12412,12413,12414,12415, #12368 +12416,12417,12418,12419,12420,12421,12422,12423,12424,12425,12426,12427,12428,12429,12430,12431, #12384 +12432,12433,12434,12435,12436,12437,12438,3554,12439,12440,12441,12442,12443,12444,12445,12446, #12400 +12447,12448,12449,12450,12451,12452,12453,12454,12455,12456,12457,12458,12459,12460,12461,12462, #12416 +12463,12464,4989,12465,12466,12467,12468,12469,12470,12471,12472,12473,12474,12475,12476,12477, #12432 +12478,12479,12480,4990,12481,12482,12483,12484,12485,12486,12487,12488,12489,4498,12490,12491, #12448 +12492,12493,12494,12495,12496,12497,12498,12499,12500,12501,12502,12503,12504,12505,12506,12507, #12464 +12508,12509,12510,12511,12512,12513,12514,12515,12516,12517,12518,12519,12520,12521,12522,12523, #12480 +12524,12525,12526,12527,12528,12529,12530,12531,12532,12533,12534,12535,12536,12537,12538,12539, #12496 +12540,12541,12542,12543,12544,12545,12546,12547,12548,12549,12550,12551,4991,12552,12553,12554, #12512 +12555,12556,12557,12558,12559,12560,12561,12562,12563,12564,12565,12566,12567,12568,12569,12570, #12528 +12571,12572,12573,12574,12575,12576,12577,12578,3036,12579,12580,12581,12582,12583,3966,12584, #12544 +12585,12586,12587,12588,12589,12590,12591,12592,12593,12594,12595,12596,12597,12598,12599,12600, #12560 +12601,12602,12603,12604,12605,12606,12607,12608,12609,12610,12611,12612,12613,12614,12615,12616, #12576 +12617,12618,12619,12620,12621,12622,12623,12624,12625,12626,12627,12628,12629,12630,12631,12632, #12592 +12633,12634,12635,12636,12637,12638,12639,12640,12641,12642,12643,12644,12645,12646,4499,12647, #12608 +12648,12649,12650,12651,12652,12653,12654,12655,12656,12657,12658,12659,12660,12661,12662,12663, #12624 +12664,12665,12666,12667,12668,12669,12670,12671,12672,12673,12674,12675,12676,12677,12678,12679, #12640 +12680,12681,12682,12683,12684,12685,12686,12687,12688,12689,12690,12691,12692,12693,12694,12695, #12656 +12696,12697,12698,4992,12699,12700,12701,12702,12703,12704,12705,12706,12707,12708,12709,12710, #12672 +12711,12712,12713,12714,12715,12716,12717,12718,12719,12720,12721,12722,12723,12724,12725,12726, #12688 +12727,12728,12729,12730,12731,12732,12733,12734,12735,12736,12737,12738,12739,12740,12741,12742, #12704 +12743,12744,12745,12746,12747,12748,12749,12750,12751,12752,12753,12754,12755,12756,12757,12758, #12720 +12759,12760,12761,12762,12763,12764,12765,12766,12767,12768,12769,12770,12771,12772,12773,12774, #12736 +12775,12776,12777,12778,4993,2175,12779,12780,12781,12782,12783,12784,12785,12786,4500,12787, #12752 +12788,12789,12790,12791,12792,12793,12794,12795,12796,12797,12798,12799,12800,12801,12802,12803, #12768 +12804,12805,12806,12807,12808,12809,12810,12811,12812,12813,12814,12815,12816,12817,12818,12819, #12784 +12820,12821,12822,12823,12824,12825,12826,4198,3967,12827,12828,12829,12830,12831,12832,12833, #12800 +12834,12835,12836,12837,12838,12839,12840,12841,12842,12843,12844,12845,12846,12847,12848,12849, #12816 +12850,12851,12852,12853,12854,12855,12856,12857,12858,12859,12860,12861,4199,12862,12863,12864, #12832 +12865,12866,12867,12868,12869,12870,12871,12872,12873,12874,12875,12876,12877,12878,12879,12880, #12848 +12881,12882,12883,12884,12885,12886,12887,4501,12888,12889,12890,12891,12892,12893,12894,12895, #12864 +12896,12897,12898,12899,12900,12901,12902,12903,12904,12905,12906,12907,12908,12909,12910,12911, #12880 +12912,4994,12913,12914,12915,12916,12917,12918,12919,12920,12921,12922,12923,12924,12925,12926, #12896 +12927,12928,12929,12930,12931,12932,12933,12934,12935,12936,12937,12938,12939,12940,12941,12942, #12912 +12943,12944,12945,12946,12947,12948,12949,12950,12951,12952,12953,12954,12955,12956,1772,12957, #12928 +12958,12959,12960,12961,12962,12963,12964,12965,12966,12967,12968,12969,12970,12971,12972,12973, #12944 +12974,12975,12976,12977,12978,12979,12980,12981,12982,12983,12984,12985,12986,12987,12988,12989, #12960 +12990,12991,12992,12993,12994,12995,12996,12997,4502,12998,4503,12999,13000,13001,13002,13003, #12976 +4504,13004,13005,13006,13007,13008,13009,13010,13011,13012,13013,13014,13015,13016,13017,13018, #12992 +13019,13020,13021,13022,13023,13024,13025,13026,13027,13028,13029,3449,13030,13031,13032,13033, #13008 +13034,13035,13036,13037,13038,13039,13040,13041,13042,13043,13044,13045,13046,13047,13048,13049, #13024 +13050,13051,13052,13053,13054,13055,13056,13057,13058,13059,13060,13061,13062,13063,13064,13065, #13040 +13066,13067,13068,13069,13070,13071,13072,13073,13074,13075,13076,13077,13078,13079,13080,13081, #13056 +13082,13083,13084,13085,13086,13087,13088,13089,13090,13091,13092,13093,13094,13095,13096,13097, #13072 +13098,13099,13100,13101,13102,13103,13104,13105,13106,13107,13108,13109,13110,13111,13112,13113, #13088 +13114,13115,13116,13117,13118,3968,13119,4995,13120,13121,13122,13123,13124,13125,13126,13127, #13104 +4505,13128,13129,13130,13131,13132,13133,13134,4996,4506,13135,13136,13137,13138,13139,4997, #13120 +13140,13141,13142,13143,13144,13145,13146,13147,13148,13149,13150,13151,13152,13153,13154,13155, #13136 +13156,13157,13158,13159,4998,13160,13161,13162,13163,13164,13165,13166,13167,13168,13169,13170, #13152 +13171,13172,13173,13174,13175,13176,4999,13177,13178,13179,13180,13181,13182,13183,13184,13185, #13168 +13186,13187,13188,13189,13190,13191,13192,13193,13194,13195,13196,13197,13198,13199,13200,13201, #13184 +13202,13203,13204,13205,13206,5000,13207,13208,13209,13210,13211,13212,13213,13214,13215,13216, #13200 +13217,13218,13219,13220,13221,13222,13223,13224,13225,13226,13227,4200,5001,13228,13229,13230, #13216 +13231,13232,13233,13234,13235,13236,13237,13238,13239,13240,3969,13241,13242,13243,13244,3970, #13232 +13245,13246,13247,13248,13249,13250,13251,13252,13253,13254,13255,13256,13257,13258,13259,13260, #13248 +13261,13262,13263,13264,13265,13266,13267,13268,3450,13269,13270,13271,13272,13273,13274,13275, #13264 +13276,5002,13277,13278,13279,13280,13281,13282,13283,13284,13285,13286,13287,13288,13289,13290, #13280 +13291,13292,13293,13294,13295,13296,13297,13298,13299,13300,13301,13302,3813,13303,13304,13305, #13296 +13306,13307,13308,13309,13310,13311,13312,13313,13314,13315,13316,13317,13318,13319,13320,13321, #13312 +13322,13323,13324,13325,13326,13327,13328,4507,13329,13330,13331,13332,13333,13334,13335,13336, #13328 +13337,13338,13339,13340,13341,5003,13342,13343,13344,13345,13346,13347,13348,13349,13350,13351, #13344 +13352,13353,13354,13355,13356,13357,13358,13359,13360,13361,13362,13363,13364,13365,13366,13367, #13360 +5004,13368,13369,13370,13371,13372,13373,13374,13375,13376,13377,13378,13379,13380,13381,13382, #13376 +13383,13384,13385,13386,13387,13388,13389,13390,13391,13392,13393,13394,13395,13396,13397,13398, #13392 +13399,13400,13401,13402,13403,13404,13405,13406,13407,13408,13409,13410,13411,13412,13413,13414, #13408 +13415,13416,13417,13418,13419,13420,13421,13422,13423,13424,13425,13426,13427,13428,13429,13430, #13424 +13431,13432,4508,13433,13434,13435,4201,13436,13437,13438,13439,13440,13441,13442,13443,13444, #13440 +13445,13446,13447,13448,13449,13450,13451,13452,13453,13454,13455,13456,13457,5005,13458,13459, #13456 +13460,13461,13462,13463,13464,13465,13466,13467,13468,13469,13470,4509,13471,13472,13473,13474, #13472 +13475,13476,13477,13478,13479,13480,13481,13482,13483,13484,13485,13486,13487,13488,13489,13490, #13488 +13491,13492,13493,13494,13495,13496,13497,13498,13499,13500,13501,13502,13503,13504,13505,13506, #13504 +13507,13508,13509,13510,13511,13512,13513,13514,13515,13516,13517,13518,13519,13520,13521,13522, #13520 +13523,13524,13525,13526,13527,13528,13529,13530,13531,13532,13533,13534,13535,13536,13537,13538, #13536 +13539,13540,13541,13542,13543,13544,13545,13546,13547,13548,13549,13550,13551,13552,13553,13554, #13552 +13555,13556,13557,13558,13559,13560,13561,13562,13563,13564,13565,13566,13567,13568,13569,13570, #13568 +13571,13572,13573,13574,13575,13576,13577,13578,13579,13580,13581,13582,13583,13584,13585,13586, #13584 +13587,13588,13589,13590,13591,13592,13593,13594,13595,13596,13597,13598,13599,13600,13601,13602, #13600 +13603,13604,13605,13606,13607,13608,13609,13610,13611,13612,13613,13614,13615,13616,13617,13618, #13616 +13619,13620,13621,13622,13623,13624,13625,13626,13627,13628,13629,13630,13631,13632,13633,13634, #13632 +13635,13636,13637,13638,13639,13640,13641,13642,5006,13643,13644,13645,13646,13647,13648,13649, #13648 +13650,13651,5007,13652,13653,13654,13655,13656,13657,13658,13659,13660,13661,13662,13663,13664, #13664 +13665,13666,13667,13668,13669,13670,13671,13672,13673,13674,13675,13676,13677,13678,13679,13680, #13680 +13681,13682,13683,13684,13685,13686,13687,13688,13689,13690,13691,13692,13693,13694,13695,13696, #13696 +13697,13698,13699,13700,13701,13702,13703,13704,13705,13706,13707,13708,13709,13710,13711,13712, #13712 +13713,13714,13715,13716,13717,13718,13719,13720,13721,13722,13723,13724,13725,13726,13727,13728, #13728 +13729,13730,13731,13732,13733,13734,13735,13736,13737,13738,13739,13740,13741,13742,13743,13744, #13744 +13745,13746,13747,13748,13749,13750,13751,13752,13753,13754,13755,13756,13757,13758,13759,13760, #13760 +13761,13762,13763,13764,13765,13766,13767,13768,13769,13770,13771,13772,13773,13774,3273,13775, #13776 +13776,13777,13778,13779,13780,13781,13782,13783,13784,13785,13786,13787,13788,13789,13790,13791, #13792 +13792,13793,13794,13795,13796,13797,13798,13799,13800,13801,13802,13803,13804,13805,13806,13807, #13808 +13808,13809,13810,13811,13812,13813,13814,13815,13816,13817,13818,13819,13820,13821,13822,13823, #13824 +13824,13825,13826,13827,13828,13829,13830,13831,13832,13833,13834,13835,13836,13837,13838,13839, #13840 +13840,13841,13842,13843,13844,13845,13846,13847,13848,13849,13850,13851,13852,13853,13854,13855, #13856 +13856,13857,13858,13859,13860,13861,13862,13863,13864,13865,13866,13867,13868,13869,13870,13871, #13872 +13872,13873,13874,13875,13876,13877,13878,13879,13880,13881,13882,13883,13884,13885,13886,13887, #13888 +13888,13889,13890,13891,13892,13893,13894,13895,13896,13897,13898,13899,13900,13901,13902,13903, #13904 +13904,13905,13906,13907,13908,13909,13910,13911,13912,13913,13914,13915,13916,13917,13918,13919, #13920 +13920,13921,13922,13923,13924,13925,13926,13927,13928,13929,13930,13931,13932,13933,13934,13935, #13936 +13936,13937,13938,13939,13940,13941,13942,13943,13944,13945,13946,13947,13948,13949,13950,13951, #13952 +13952,13953,13954,13955,13956,13957,13958,13959,13960,13961,13962,13963,13964,13965,13966,13967, #13968 +13968,13969,13970,13971,13972) #13973 diff --git a/fanficdownloader/chardet/big5prober.py b/fanficdownloader/chardet/big5prober.py new file mode 100644 index 00000000..e6b52aad --- /dev/null +++ b/fanficdownloader/chardet/big5prober.py @@ -0,0 +1,41 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from mbcharsetprober import MultiByteCharSetProber +from codingstatemachine import CodingStateMachine +from chardistribution import Big5DistributionAnalysis +from mbcssm import Big5SMModel + +class Big5Prober(MultiByteCharSetProber): + def __init__(self): + MultiByteCharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(Big5SMModel) + self._mDistributionAnalyzer = Big5DistributionAnalysis() + self.reset() + + def get_charset_name(self): + return "Big5" diff --git a/fanficdownloader/chardet/chardistribution.py b/fanficdownloader/chardet/chardistribution.py new file mode 100644 index 00000000..b8933418 --- /dev/null +++ b/fanficdownloader/chardet/chardistribution.py @@ -0,0 +1,200 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants +from euctwfreq import EUCTWCharToFreqOrder, EUCTW_TABLE_SIZE, EUCTW_TYPICAL_DISTRIBUTION_RATIO +from euckrfreq import EUCKRCharToFreqOrder, EUCKR_TABLE_SIZE, EUCKR_TYPICAL_DISTRIBUTION_RATIO +from gb2312freq import GB2312CharToFreqOrder, GB2312_TABLE_SIZE, GB2312_TYPICAL_DISTRIBUTION_RATIO +from big5freq import Big5CharToFreqOrder, BIG5_TABLE_SIZE, BIG5_TYPICAL_DISTRIBUTION_RATIO +from jisfreq import JISCharToFreqOrder, JIS_TABLE_SIZE, JIS_TYPICAL_DISTRIBUTION_RATIO + +ENOUGH_DATA_THRESHOLD = 1024 +SURE_YES = 0.99 +SURE_NO = 0.01 + +class CharDistributionAnalysis: + def __init__(self): + self._mCharToFreqOrder = None # Mapping table to get frequency order from char order (get from GetOrder()) + self._mTableSize = None # Size of above table + self._mTypicalDistributionRatio = None # This is a constant value which varies from language to language, used in calculating confidence. See http://www.mozilla.org/projects/intl/UniversalCharsetDetection.html for further detail. + self.reset() + + def reset(self): + """reset analyser, clear any state""" + self._mDone = constants.False # If this flag is set to constants.True, detection is done and conclusion has been made + self._mTotalChars = 0 # Total characters encountered + self._mFreqChars = 0 # The number of characters whose frequency order is less than 512 + + def feed(self, aStr, aCharLen): + """feed a character with known length""" + if aCharLen == 2: + # we only care about 2-bytes character in our distribution analysis + order = self.get_order(aStr) + else: + order = -1 + if order >= 0: + self._mTotalChars += 1 + # order is valid + if order < self._mTableSize: + if 512 > self._mCharToFreqOrder[order]: + self._mFreqChars += 1 + + def get_confidence(self): + """return confidence based on existing data""" + # if we didn't receive any character in our consideration range, return negative answer + if self._mTotalChars <= 0: + return SURE_NO + + if self._mTotalChars != self._mFreqChars: + r = self._mFreqChars / ((self._mTotalChars - self._mFreqChars) * self._mTypicalDistributionRatio) + if r < SURE_YES: + return r + + # normalize confidence (we don't want to be 100% sure) + return SURE_YES + + def got_enough_data(self): + # It is not necessary to receive all data to draw conclusion. For charset detection, + # certain amount of data is enough + return self._mTotalChars > ENOUGH_DATA_THRESHOLD + + def get_order(self, aStr): + # We do not handle characters based on the original encoding string, but + # convert this encoding string to a number, here called order. + # This allows multiple encodings of a language to share one frequency table. + return -1 + +class EUCTWDistributionAnalysis(CharDistributionAnalysis): + def __init__(self): + CharDistributionAnalysis.__init__(self) + self._mCharToFreqOrder = EUCTWCharToFreqOrder + self._mTableSize = EUCTW_TABLE_SIZE + self._mTypicalDistributionRatio = EUCTW_TYPICAL_DISTRIBUTION_RATIO + + def get_order(self, aStr): + # for euc-TW encoding, we are interested + # first byte range: 0xc4 -- 0xfe + # second byte range: 0xa1 -- 0xfe + # no validation needed here. State machine has done that + if aStr[0] >= '\xC4': + return 94 * (ord(aStr[0]) - 0xC4) + ord(aStr[1]) - 0xA1 + else: + return -1 + +class EUCKRDistributionAnalysis(CharDistributionAnalysis): + def __init__(self): + CharDistributionAnalysis.__init__(self) + self._mCharToFreqOrder = EUCKRCharToFreqOrder + self._mTableSize = EUCKR_TABLE_SIZE + self._mTypicalDistributionRatio = EUCKR_TYPICAL_DISTRIBUTION_RATIO + + def get_order(self, aStr): + # for euc-KR encoding, we are interested + # first byte range: 0xb0 -- 0xfe + # second byte range: 0xa1 -- 0xfe + # no validation needed here. State machine has done that + if aStr[0] >= '\xB0': + return 94 * (ord(aStr[0]) - 0xB0) + ord(aStr[1]) - 0xA1 + else: + return -1; + +class GB2312DistributionAnalysis(CharDistributionAnalysis): + def __init__(self): + CharDistributionAnalysis.__init__(self) + self._mCharToFreqOrder = GB2312CharToFreqOrder + self._mTableSize = GB2312_TABLE_SIZE + self._mTypicalDistributionRatio = GB2312_TYPICAL_DISTRIBUTION_RATIO + + def get_order(self, aStr): + # for GB2312 encoding, we are interested + # first byte range: 0xb0 -- 0xfe + # second byte range: 0xa1 -- 0xfe + # no validation needed here. State machine has done that + if (aStr[0] >= '\xB0') and (aStr[1] >= '\xA1'): + return 94 * (ord(aStr[0]) - 0xB0) + ord(aStr[1]) - 0xA1 + else: + return -1; + +class Big5DistributionAnalysis(CharDistributionAnalysis): + def __init__(self): + CharDistributionAnalysis.__init__(self) + self._mCharToFreqOrder = Big5CharToFreqOrder + self._mTableSize = BIG5_TABLE_SIZE + self._mTypicalDistributionRatio = BIG5_TYPICAL_DISTRIBUTION_RATIO + + def get_order(self, aStr): + # for big5 encoding, we are interested + # first byte range: 0xa4 -- 0xfe + # second byte range: 0x40 -- 0x7e , 0xa1 -- 0xfe + # no validation needed here. State machine has done that + if aStr[0] >= '\xA4': + if aStr[1] >= '\xA1': + return 157 * (ord(aStr[0]) - 0xA4) + ord(aStr[1]) - 0xA1 + 63 + else: + return 157 * (ord(aStr[0]) - 0xA4) + ord(aStr[1]) - 0x40 + else: + return -1 + +class SJISDistributionAnalysis(CharDistributionAnalysis): + def __init__(self): + CharDistributionAnalysis.__init__(self) + self._mCharToFreqOrder = JISCharToFreqOrder + self._mTableSize = JIS_TABLE_SIZE + self._mTypicalDistributionRatio = JIS_TYPICAL_DISTRIBUTION_RATIO + + def get_order(self, aStr): + # for sjis encoding, we are interested + # first byte range: 0x81 -- 0x9f , 0xe0 -- 0xfe + # second byte range: 0x40 -- 0x7e, 0x81 -- oxfe + # no validation needed here. State machine has done that + if (aStr[0] >= '\x81') and (aStr[0] <= '\x9F'): + order = 188 * (ord(aStr[0]) - 0x81) + elif (aStr[0] >= '\xE0') and (aStr[0] <= '\xEF'): + order = 188 * (ord(aStr[0]) - 0xE0 + 31) + else: + return -1; + order = order + ord(aStr[1]) - 0x40 + if aStr[1] > '\x7F': + order =- 1 + return order + +class EUCJPDistributionAnalysis(CharDistributionAnalysis): + def __init__(self): + CharDistributionAnalysis.__init__(self) + self._mCharToFreqOrder = JISCharToFreqOrder + self._mTableSize = JIS_TABLE_SIZE + self._mTypicalDistributionRatio = JIS_TYPICAL_DISTRIBUTION_RATIO + + def get_order(self, aStr): + # for euc-JP encoding, we are interested + # first byte range: 0xa0 -- 0xfe + # second byte range: 0xa1 -- 0xfe + # no validation needed here. State machine has done that + if aStr[0] >= '\xA0': + return 94 * (ord(aStr[0]) - 0xA1) + ord(aStr[1]) - 0xa1 + else: + return -1 diff --git a/fanficdownloader/chardet/charsetgroupprober.py b/fanficdownloader/chardet/charsetgroupprober.py new file mode 100644 index 00000000..51880694 --- /dev/null +++ b/fanficdownloader/chardet/charsetgroupprober.py @@ -0,0 +1,96 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, sys +from charsetprober import CharSetProber + +class CharSetGroupProber(CharSetProber): + def __init__(self): + CharSetProber.__init__(self) + self._mActiveNum = 0 + self._mProbers = [] + self._mBestGuessProber = None + + def reset(self): + CharSetProber.reset(self) + self._mActiveNum = 0 + for prober in self._mProbers: + if prober: + prober.reset() + prober.active = constants.True + self._mActiveNum += 1 + self._mBestGuessProber = None + + def get_charset_name(self): + if not self._mBestGuessProber: + self.get_confidence() + if not self._mBestGuessProber: return None +# self._mBestGuessProber = self._mProbers[0] + return self._mBestGuessProber.get_charset_name() + + def feed(self, aBuf): + for prober in self._mProbers: + if not prober: continue + if not prober.active: continue + st = prober.feed(aBuf) + if not st: continue + if st == constants.eFoundIt: + self._mBestGuessProber = prober + return self.get_state() + elif st == constants.eNotMe: + prober.active = constants.False + self._mActiveNum -= 1 + if self._mActiveNum <= 0: + self._mState = constants.eNotMe + return self.get_state() + return self.get_state() + + def get_confidence(self): + st = self.get_state() + if st == constants.eFoundIt: + return 0.99 + elif st == constants.eNotMe: + return 0.01 + bestConf = 0.0 + self._mBestGuessProber = None + for prober in self._mProbers: + if not prober: continue + if not prober.active: + if constants._debug: + sys.stderr.write(prober.get_charset_name() + ' not active\n') + continue + cf = prober.get_confidence() + if constants._debug: + sys.stderr.write('%s confidence = %s\n' % (prober.get_charset_name(), cf)) + if bestConf < cf: + bestConf = cf + self._mBestGuessProber = prober + if not self._mBestGuessProber: return 0.0 + return bestConf +# else: +# self._mBestGuessProber = self._mProbers[0] +# return self._mBestGuessProber.get_confidence() diff --git a/fanficdownloader/chardet/charsetprober.py b/fanficdownloader/chardet/charsetprober.py new file mode 100644 index 00000000..3ac1683c --- /dev/null +++ b/fanficdownloader/chardet/charsetprober.py @@ -0,0 +1,60 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, re + +class CharSetProber: + def __init__(self): + pass + + def reset(self): + self._mState = constants.eDetecting + + def get_charset_name(self): + return None + + def feed(self, aBuf): + pass + + def get_state(self): + return self._mState + + def get_confidence(self): + return 0.0 + + def filter_high_bit_only(self, aBuf): + aBuf = re.sub(r'([\x00-\x7F])+', ' ', aBuf) + return aBuf + + def filter_without_english_letters(self, aBuf): + aBuf = re.sub(r'([A-Za-z])+', ' ', aBuf) + return aBuf + + def filter_with_english_letters(self, aBuf): + # TODO + return aBuf diff --git a/fanficdownloader/chardet/codingstatemachine.py b/fanficdownloader/chardet/codingstatemachine.py new file mode 100644 index 00000000..452d3b0a --- /dev/null +++ b/fanficdownloader/chardet/codingstatemachine.py @@ -0,0 +1,56 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from constants import eStart, eError, eItsMe + +class CodingStateMachine: + def __init__(self, sm): + self._mModel = sm + self._mCurrentBytePos = 0 + self._mCurrentCharLen = 0 + self.reset() + + def reset(self): + self._mCurrentState = eStart + + def next_state(self, c): + # for each byte we get its class + # if it is first byte, we also get byte length + byteCls = self._mModel['classTable'][ord(c)] + if self._mCurrentState == eStart: + self._mCurrentBytePos = 0 + self._mCurrentCharLen = self._mModel['charLenTable'][byteCls] + # from byte's class and stateTable, we get its next state + self._mCurrentState = self._mModel['stateTable'][self._mCurrentState * self._mModel['classFactor'] + byteCls] + self._mCurrentBytePos += 1 + return self._mCurrentState + + def get_current_charlen(self): + return self._mCurrentCharLen + + def get_coding_state_machine(self): + return self._mModel['name'] diff --git a/fanficdownloader/chardet/constants.py b/fanficdownloader/chardet/constants.py new file mode 100644 index 00000000..e94e226b --- /dev/null +++ b/fanficdownloader/chardet/constants.py @@ -0,0 +1,47 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +_debug = 0 + +eDetecting = 0 +eFoundIt = 1 +eNotMe = 2 + +eStart = 0 +eError = 1 +eItsMe = 2 + +SHORTCUT_THRESHOLD = 0.95 + +import __builtin__ +if not hasattr(__builtin__, 'False'): + False = 0 + True = 1 +else: + False = __builtin__.False + True = __builtin__.True diff --git a/fanficdownloader/chardet/escprober.py b/fanficdownloader/chardet/escprober.py new file mode 100644 index 00000000..572ed7be --- /dev/null +++ b/fanficdownloader/chardet/escprober.py @@ -0,0 +1,79 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, sys +from escsm import HZSMModel, ISO2022CNSMModel, ISO2022JPSMModel, ISO2022KRSMModel +from charsetprober import CharSetProber +from codingstatemachine import CodingStateMachine + +class EscCharSetProber(CharSetProber): + def __init__(self): + CharSetProber.__init__(self) + self._mCodingSM = [ \ + CodingStateMachine(HZSMModel), + CodingStateMachine(ISO2022CNSMModel), + CodingStateMachine(ISO2022JPSMModel), + CodingStateMachine(ISO2022KRSMModel) + ] + self.reset() + + def reset(self): + CharSetProber.reset(self) + for codingSM in self._mCodingSM: + if not codingSM: continue + codingSM.active = constants.True + codingSM.reset() + self._mActiveSM = len(self._mCodingSM) + self._mDetectedCharset = None + + def get_charset_name(self): + return self._mDetectedCharset + + def get_confidence(self): + if self._mDetectedCharset: + return 0.99 + else: + return 0.00 + + def feed(self, aBuf): + for c in aBuf: + for codingSM in self._mCodingSM: + if not codingSM: continue + if not codingSM.active: continue + codingState = codingSM.next_state(c) + if codingState == constants.eError: + codingSM.active = constants.False + self._mActiveSM -= 1 + if self._mActiveSM <= 0: + self._mState = constants.eNotMe + return self.get_state() + elif codingState == constants.eItsMe: + self._mState = constants.eFoundIt + self._mDetectedCharset = codingSM.get_coding_state_machine() + return self.get_state() + + return self.get_state() diff --git a/fanficdownloader/chardet/escsm.py b/fanficdownloader/chardet/escsm.py new file mode 100644 index 00000000..9fa22952 --- /dev/null +++ b/fanficdownloader/chardet/escsm.py @@ -0,0 +1,240 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from constants import eStart, eError, eItsMe + +HZ_cls = ( \ +1,0,0,0,0,0,0,0, # 00 - 07 +0,0,0,0,0,0,0,0, # 08 - 0f +0,0,0,0,0,0,0,0, # 10 - 17 +0,0,0,1,0,0,0,0, # 18 - 1f +0,0,0,0,0,0,0,0, # 20 - 27 +0,0,0,0,0,0,0,0, # 28 - 2f +0,0,0,0,0,0,0,0, # 30 - 37 +0,0,0,0,0,0,0,0, # 38 - 3f +0,0,0,0,0,0,0,0, # 40 - 47 +0,0,0,0,0,0,0,0, # 48 - 4f +0,0,0,0,0,0,0,0, # 50 - 57 +0,0,0,0,0,0,0,0, # 58 - 5f +0,0,0,0,0,0,0,0, # 60 - 67 +0,0,0,0,0,0,0,0, # 68 - 6f +0,0,0,0,0,0,0,0, # 70 - 77 +0,0,0,4,0,5,2,0, # 78 - 7f +1,1,1,1,1,1,1,1, # 80 - 87 +1,1,1,1,1,1,1,1, # 88 - 8f +1,1,1,1,1,1,1,1, # 90 - 97 +1,1,1,1,1,1,1,1, # 98 - 9f +1,1,1,1,1,1,1,1, # a0 - a7 +1,1,1,1,1,1,1,1, # a8 - af +1,1,1,1,1,1,1,1, # b0 - b7 +1,1,1,1,1,1,1,1, # b8 - bf +1,1,1,1,1,1,1,1, # c0 - c7 +1,1,1,1,1,1,1,1, # c8 - cf +1,1,1,1,1,1,1,1, # d0 - d7 +1,1,1,1,1,1,1,1, # d8 - df +1,1,1,1,1,1,1,1, # e0 - e7 +1,1,1,1,1,1,1,1, # e8 - ef +1,1,1,1,1,1,1,1, # f0 - f7 +1,1,1,1,1,1,1,1, # f8 - ff +) + +HZ_st = ( \ +eStart,eError, 3,eStart,eStart,eStart,eError,eError,# 00-07 +eError,eError,eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,# 08-0f +eItsMe,eItsMe,eError,eError,eStart,eStart, 4,eError,# 10-17 + 5,eError, 6,eError, 5, 5, 4,eError,# 18-1f + 4,eError, 4, 4, 4,eError, 4,eError,# 20-27 + 4,eItsMe,eStart,eStart,eStart,eStart,eStart,eStart,# 28-2f +) + +HZCharLenTable = (0, 0, 0, 0, 0, 0) + +HZSMModel = {'classTable': HZ_cls, + 'classFactor': 6, + 'stateTable': HZ_st, + 'charLenTable': HZCharLenTable, + 'name': "HZ-GB-2312"} + +ISO2022CN_cls = ( \ +2,0,0,0,0,0,0,0, # 00 - 07 +0,0,0,0,0,0,0,0, # 08 - 0f +0,0,0,0,0,0,0,0, # 10 - 17 +0,0,0,1,0,0,0,0, # 18 - 1f +0,0,0,0,0,0,0,0, # 20 - 27 +0,3,0,0,0,0,0,0, # 28 - 2f +0,0,0,0,0,0,0,0, # 30 - 37 +0,0,0,0,0,0,0,0, # 38 - 3f +0,0,0,4,0,0,0,0, # 40 - 47 +0,0,0,0,0,0,0,0, # 48 - 4f +0,0,0,0,0,0,0,0, # 50 - 57 +0,0,0,0,0,0,0,0, # 58 - 5f +0,0,0,0,0,0,0,0, # 60 - 67 +0,0,0,0,0,0,0,0, # 68 - 6f +0,0,0,0,0,0,0,0, # 70 - 77 +0,0,0,0,0,0,0,0, # 78 - 7f +2,2,2,2,2,2,2,2, # 80 - 87 +2,2,2,2,2,2,2,2, # 88 - 8f +2,2,2,2,2,2,2,2, # 90 - 97 +2,2,2,2,2,2,2,2, # 98 - 9f +2,2,2,2,2,2,2,2, # a0 - a7 +2,2,2,2,2,2,2,2, # a8 - af +2,2,2,2,2,2,2,2, # b0 - b7 +2,2,2,2,2,2,2,2, # b8 - bf +2,2,2,2,2,2,2,2, # c0 - c7 +2,2,2,2,2,2,2,2, # c8 - cf +2,2,2,2,2,2,2,2, # d0 - d7 +2,2,2,2,2,2,2,2, # d8 - df +2,2,2,2,2,2,2,2, # e0 - e7 +2,2,2,2,2,2,2,2, # e8 - ef +2,2,2,2,2,2,2,2, # f0 - f7 +2,2,2,2,2,2,2,2, # f8 - ff +) + +ISO2022CN_st = ( \ +eStart, 3,eError,eStart,eStart,eStart,eStart,eStart,# 00-07 +eStart,eError,eError,eError,eError,eError,eError,eError,# 08-0f +eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,# 10-17 +eItsMe,eItsMe,eItsMe,eError,eError,eError, 4,eError,# 18-1f +eError,eError,eError,eItsMe,eError,eError,eError,eError,# 20-27 + 5, 6,eError,eError,eError,eError,eError,eError,# 28-2f +eError,eError,eError,eItsMe,eError,eError,eError,eError,# 30-37 +eError,eError,eError,eError,eError,eItsMe,eError,eStart,# 38-3f +) + +ISO2022CNCharLenTable = (0, 0, 0, 0, 0, 0, 0, 0, 0) + +ISO2022CNSMModel = {'classTable': ISO2022CN_cls, + 'classFactor': 9, + 'stateTable': ISO2022CN_st, + 'charLenTable': ISO2022CNCharLenTable, + 'name': "ISO-2022-CN"} + +ISO2022JP_cls = ( \ +2,0,0,0,0,0,0,0, # 00 - 07 +0,0,0,0,0,0,2,2, # 08 - 0f +0,0,0,0,0,0,0,0, # 10 - 17 +0,0,0,1,0,0,0,0, # 18 - 1f +0,0,0,0,7,0,0,0, # 20 - 27 +3,0,0,0,0,0,0,0, # 28 - 2f +0,0,0,0,0,0,0,0, # 30 - 37 +0,0,0,0,0,0,0,0, # 38 - 3f +6,0,4,0,8,0,0,0, # 40 - 47 +0,9,5,0,0,0,0,0, # 48 - 4f +0,0,0,0,0,0,0,0, # 50 - 57 +0,0,0,0,0,0,0,0, # 58 - 5f +0,0,0,0,0,0,0,0, # 60 - 67 +0,0,0,0,0,0,0,0, # 68 - 6f +0,0,0,0,0,0,0,0, # 70 - 77 +0,0,0,0,0,0,0,0, # 78 - 7f +2,2,2,2,2,2,2,2, # 80 - 87 +2,2,2,2,2,2,2,2, # 88 - 8f +2,2,2,2,2,2,2,2, # 90 - 97 +2,2,2,2,2,2,2,2, # 98 - 9f +2,2,2,2,2,2,2,2, # a0 - a7 +2,2,2,2,2,2,2,2, # a8 - af +2,2,2,2,2,2,2,2, # b0 - b7 +2,2,2,2,2,2,2,2, # b8 - bf +2,2,2,2,2,2,2,2, # c0 - c7 +2,2,2,2,2,2,2,2, # c8 - cf +2,2,2,2,2,2,2,2, # d0 - d7 +2,2,2,2,2,2,2,2, # d8 - df +2,2,2,2,2,2,2,2, # e0 - e7 +2,2,2,2,2,2,2,2, # e8 - ef +2,2,2,2,2,2,2,2, # f0 - f7 +2,2,2,2,2,2,2,2, # f8 - ff +) + +ISO2022JP_st = ( \ +eStart, 3,eError,eStart,eStart,eStart,eStart,eStart,# 00-07 +eStart,eStart,eError,eError,eError,eError,eError,eError,# 08-0f +eError,eError,eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,# 10-17 +eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eError,eError,# 18-1f +eError, 5,eError,eError,eError, 4,eError,eError,# 20-27 +eError,eError,eError, 6,eItsMe,eError,eItsMe,eError,# 28-2f +eError,eError,eError,eError,eError,eError,eItsMe,eItsMe,# 30-37 +eError,eError,eError,eItsMe,eError,eError,eError,eError,# 38-3f +eError,eError,eError,eError,eItsMe,eError,eStart,eStart,# 40-47 +) + +ISO2022JPCharLenTable = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + +ISO2022JPSMModel = {'classTable': ISO2022JP_cls, + 'classFactor': 10, + 'stateTable': ISO2022JP_st, + 'charLenTable': ISO2022JPCharLenTable, + 'name': "ISO-2022-JP"} + +ISO2022KR_cls = ( \ +2,0,0,0,0,0,0,0, # 00 - 07 +0,0,0,0,0,0,0,0, # 08 - 0f +0,0,0,0,0,0,0,0, # 10 - 17 +0,0,0,1,0,0,0,0, # 18 - 1f +0,0,0,0,3,0,0,0, # 20 - 27 +0,4,0,0,0,0,0,0, # 28 - 2f +0,0,0,0,0,0,0,0, # 30 - 37 +0,0,0,0,0,0,0,0, # 38 - 3f +0,0,0,5,0,0,0,0, # 40 - 47 +0,0,0,0,0,0,0,0, # 48 - 4f +0,0,0,0,0,0,0,0, # 50 - 57 +0,0,0,0,0,0,0,0, # 58 - 5f +0,0,0,0,0,0,0,0, # 60 - 67 +0,0,0,0,0,0,0,0, # 68 - 6f +0,0,0,0,0,0,0,0, # 70 - 77 +0,0,0,0,0,0,0,0, # 78 - 7f +2,2,2,2,2,2,2,2, # 80 - 87 +2,2,2,2,2,2,2,2, # 88 - 8f +2,2,2,2,2,2,2,2, # 90 - 97 +2,2,2,2,2,2,2,2, # 98 - 9f +2,2,2,2,2,2,2,2, # a0 - a7 +2,2,2,2,2,2,2,2, # a8 - af +2,2,2,2,2,2,2,2, # b0 - b7 +2,2,2,2,2,2,2,2, # b8 - bf +2,2,2,2,2,2,2,2, # c0 - c7 +2,2,2,2,2,2,2,2, # c8 - cf +2,2,2,2,2,2,2,2, # d0 - d7 +2,2,2,2,2,2,2,2, # d8 - df +2,2,2,2,2,2,2,2, # e0 - e7 +2,2,2,2,2,2,2,2, # e8 - ef +2,2,2,2,2,2,2,2, # f0 - f7 +2,2,2,2,2,2,2,2, # f8 - ff +) + +ISO2022KR_st = ( \ +eStart, 3,eError,eStart,eStart,eStart,eError,eError,# 00-07 +eError,eError,eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,# 08-0f +eItsMe,eItsMe,eError,eError,eError, 4,eError,eError,# 10-17 +eError,eError,eError,eError, 5,eError,eError,eError,# 18-1f +eError,eError,eError,eItsMe,eStart,eStart,eStart,eStart,# 20-27 +) + +ISO2022KRCharLenTable = (0, 0, 0, 0, 0, 0) + +ISO2022KRSMModel = {'classTable': ISO2022KR_cls, + 'classFactor': 6, + 'stateTable': ISO2022KR_st, + 'charLenTable': ISO2022KRCharLenTable, + 'name': "ISO-2022-KR"} diff --git a/fanficdownloader/chardet/eucjpprober.py b/fanficdownloader/chardet/eucjpprober.py new file mode 100644 index 00000000..46a8b38b --- /dev/null +++ b/fanficdownloader/chardet/eucjpprober.py @@ -0,0 +1,85 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, sys +from constants import eStart, eError, eItsMe +from mbcharsetprober import MultiByteCharSetProber +from codingstatemachine import CodingStateMachine +from chardistribution import EUCJPDistributionAnalysis +from jpcntx import EUCJPContextAnalysis +from mbcssm import EUCJPSMModel + +class EUCJPProber(MultiByteCharSetProber): + def __init__(self): + MultiByteCharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(EUCJPSMModel) + self._mDistributionAnalyzer = EUCJPDistributionAnalysis() + self._mContextAnalyzer = EUCJPContextAnalysis() + self.reset() + + def reset(self): + MultiByteCharSetProber.reset(self) + self._mContextAnalyzer.reset() + + def get_charset_name(self): + return "EUC-JP" + + def feed(self, aBuf): + aLen = len(aBuf) + for i in range(0, aLen): + codingState = self._mCodingSM.next_state(aBuf[i]) + if codingState == eError: + if constants._debug: + sys.stderr.write(self.get_charset_name() + ' prober hit error at byte ' + str(i) + '\n') + self._mState = constants.eNotMe + break + elif codingState == eItsMe: + self._mState = constants.eFoundIt + break + elif codingState == eStart: + charLen = self._mCodingSM.get_current_charlen() + if i == 0: + self._mLastChar[1] = aBuf[0] + self._mContextAnalyzer.feed(self._mLastChar, charLen) + self._mDistributionAnalyzer.feed(self._mLastChar, charLen) + else: + self._mContextAnalyzer.feed(aBuf[i-1:i+1], charLen) + self._mDistributionAnalyzer.feed(aBuf[i-1:i+1], charLen) + + self._mLastChar[0] = aBuf[aLen - 1] + + if self.get_state() == constants.eDetecting: + if self._mContextAnalyzer.got_enough_data() and \ + (self.get_confidence() > constants.SHORTCUT_THRESHOLD): + self._mState = constants.eFoundIt + + return self.get_state() + + def get_confidence(self): + contxtCf = self._mContextAnalyzer.get_confidence() + distribCf = self._mDistributionAnalyzer.get_confidence() + return max(contxtCf, distribCf) diff --git a/fanficdownloader/chardet/euckrfreq.py b/fanficdownloader/chardet/euckrfreq.py new file mode 100644 index 00000000..1463fa1d --- /dev/null +++ b/fanficdownloader/chardet/euckrfreq.py @@ -0,0 +1,594 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +# Sampling from about 20M text materials include literature and computer technology + +# 128 --> 0.79 +# 256 --> 0.92 +# 512 --> 0.986 +# 1024 --> 0.99944 +# 2048 --> 0.99999 +# +# Idea Distribution Ratio = 0.98653 / (1-0.98653) = 73.24 +# Random Distribution Ration = 512 / (2350-512) = 0.279. +# +# Typical Distribution Ratio + +EUCKR_TYPICAL_DISTRIBUTION_RATIO = 6.0 + +EUCKR_TABLE_SIZE = 2352 + +# Char to FreqOrder table , +EUCKRCharToFreqOrder = ( \ + 13, 130, 120,1396, 481,1719,1720, 328, 609, 212,1721, 707, 400, 299,1722, 87, +1397,1723, 104, 536,1117,1203,1724,1267, 685,1268, 508,1725,1726,1727,1728,1398, +1399,1729,1730,1731, 141, 621, 326,1057, 368,1732, 267, 488, 20,1733,1269,1734, + 945,1400,1735, 47, 904,1270,1736,1737, 773, 248,1738, 409, 313, 786, 429,1739, + 116, 987, 813,1401, 683, 75,1204, 145,1740,1741,1742,1743, 16, 847, 667, 622, + 708,1744,1745,1746, 966, 787, 304, 129,1747, 60, 820, 123, 676,1748,1749,1750, +1751, 617,1752, 626,1753,1754,1755,1756, 653,1757,1758,1759,1760,1761,1762, 856, + 344,1763,1764,1765,1766, 89, 401, 418, 806, 905, 848,1767,1768,1769, 946,1205, + 709,1770,1118,1771, 241,1772,1773,1774,1271,1775, 569,1776, 999,1777,1778,1779, +1780, 337, 751,1058, 28, 628, 254,1781, 177, 906, 270, 349, 891,1079,1782, 19, +1783, 379,1784, 315,1785, 629, 754,1402, 559,1786, 636, 203,1206,1787, 710, 567, +1788, 935, 814,1789,1790,1207, 766, 528,1791,1792,1208,1793,1794,1795,1796,1797, +1403,1798,1799, 533,1059,1404,1405,1156,1406, 936, 884,1080,1800, 351,1801,1802, +1803,1804,1805, 801,1806,1807,1808,1119,1809,1157, 714, 474,1407,1810, 298, 899, + 885,1811,1120, 802,1158,1812, 892,1813,1814,1408, 659,1815,1816,1121,1817,1818, +1819,1820,1821,1822, 319,1823, 594, 545,1824, 815, 937,1209,1825,1826, 573,1409, +1022,1827,1210,1828,1829,1830,1831,1832,1833, 556, 722, 807,1122,1060,1834, 697, +1835, 900, 557, 715,1836,1410, 540,1411, 752,1159, 294, 597,1211, 976, 803, 770, +1412,1837,1838, 39, 794,1413, 358,1839, 371, 925,1840, 453, 661, 788, 531, 723, + 544,1023,1081, 869, 91,1841, 392, 430, 790, 602,1414, 677,1082, 457,1415,1416, +1842,1843, 475, 327,1024,1417, 795, 121,1844, 733, 403,1418,1845,1846,1847, 300, + 119, 711,1212, 627,1848,1272, 207,1849,1850, 796,1213, 382,1851, 519,1852,1083, + 893,1853,1854,1855, 367, 809, 487, 671,1856, 663,1857,1858, 956, 471, 306, 857, +1859,1860,1160,1084,1861,1862,1863,1864,1865,1061,1866,1867,1868,1869,1870,1871, + 282, 96, 574,1872, 502,1085,1873,1214,1874, 907,1875,1876, 827, 977,1419,1420, +1421, 268,1877,1422,1878,1879,1880, 308,1881, 2, 537,1882,1883,1215,1884,1885, + 127, 791,1886,1273,1423,1887, 34, 336, 404, 643,1888, 571, 654, 894, 840,1889, + 0, 886,1274, 122, 575, 260, 908, 938,1890,1275, 410, 316,1891,1892, 100,1893, +1894,1123, 48,1161,1124,1025,1895, 633, 901,1276,1896,1897, 115, 816,1898, 317, +1899, 694,1900, 909, 734,1424, 572, 866,1425, 691, 85, 524,1010, 543, 394, 841, +1901,1902,1903,1026,1904,1905,1906,1907,1908,1909, 30, 451, 651, 988, 310,1910, +1911,1426, 810,1216, 93,1912,1913,1277,1217,1914, 858, 759, 45, 58, 181, 610, + 269,1915,1916, 131,1062, 551, 443,1000, 821,1427, 957, 895,1086,1917,1918, 375, +1919, 359,1920, 687,1921, 822,1922, 293,1923,1924, 40, 662, 118, 692, 29, 939, + 887, 640, 482, 174,1925, 69,1162, 728,1428, 910,1926,1278,1218,1279, 386, 870, + 217, 854,1163, 823,1927,1928,1929,1930, 834,1931, 78,1932, 859,1933,1063,1934, +1935,1936,1937, 438,1164, 208, 595,1938,1939,1940,1941,1219,1125,1942, 280, 888, +1429,1430,1220,1431,1943,1944,1945,1946,1947,1280, 150, 510,1432,1948,1949,1950, +1951,1952,1953,1954,1011,1087,1955,1433,1043,1956, 881,1957, 614, 958,1064,1065, +1221,1958, 638,1001, 860, 967, 896,1434, 989, 492, 553,1281,1165,1959,1282,1002, +1283,1222,1960,1961,1962,1963, 36, 383, 228, 753, 247, 454,1964, 876, 678,1965, +1966,1284, 126, 464, 490, 835, 136, 672, 529, 940,1088,1435, 473,1967,1968, 467, + 50, 390, 227, 587, 279, 378, 598, 792, 968, 240, 151, 160, 849, 882,1126,1285, + 639,1044, 133, 140, 288, 360, 811, 563,1027, 561, 142, 523,1969,1970,1971, 7, + 103, 296, 439, 407, 506, 634, 990,1972,1973,1974,1975, 645,1976,1977,1978,1979, +1980,1981, 236,1982,1436,1983,1984,1089, 192, 828, 618, 518,1166, 333,1127,1985, + 818,1223,1986,1987,1988,1989,1990,1991,1992,1993, 342,1128,1286, 746, 842,1994, +1995, 560, 223,1287, 98, 8, 189, 650, 978,1288,1996,1437,1997, 17, 345, 250, + 423, 277, 234, 512, 226, 97, 289, 42, 167,1998, 201,1999,2000, 843, 836, 824, + 532, 338, 783,1090, 182, 576, 436,1438,1439, 527, 500,2001, 947, 889,2002,2003, +2004,2005, 262, 600, 314, 447,2006, 547,2007, 693, 738,1129,2008, 71,1440, 745, + 619, 688,2009, 829,2010,2011, 147,2012, 33, 948,2013,2014, 74, 224,2015, 61, + 191, 918, 399, 637,2016,1028,1130, 257, 902,2017,2018,2019,2020,2021,2022,2023, +2024,2025,2026, 837,2027,2028,2029,2030, 179, 874, 591, 52, 724, 246,2031,2032, +2033,2034,1167, 969,2035,1289, 630, 605, 911,1091,1168,2036,2037,2038,1441, 912, +2039, 623,2040,2041, 253,1169,1290,2042,1442, 146, 620, 611, 577, 433,2043,1224, + 719,1170, 959, 440, 437, 534, 84, 388, 480,1131, 159, 220, 198, 679,2044,1012, + 819,1066,1443, 113,1225, 194, 318,1003,1029,2045,2046,2047,2048,1067,2049,2050, +2051,2052,2053, 59, 913, 112,2054, 632,2055, 455, 144, 739,1291,2056, 273, 681, + 499,2057, 448,2058,2059, 760,2060,2061, 970, 384, 169, 245,1132,2062,2063, 414, +1444,2064,2065, 41, 235,2066, 157, 252, 877, 568, 919, 789, 580,2067, 725,2068, +2069,1292,2070,2071,1445,2072,1446,2073,2074, 55, 588, 66,1447, 271,1092,2075, +1226,2076, 960,1013, 372,2077,2078,2079,2080,2081,1293,2082,2083,2084,2085, 850, +2086,2087,2088,2089,2090, 186,2091,1068, 180,2092,2093,2094, 109,1227, 522, 606, +2095, 867,1448,1093, 991,1171, 926, 353,1133,2096, 581,2097,2098,2099,1294,1449, +1450,2100, 596,1172,1014,1228,2101,1451,1295,1173,1229,2102,2103,1296,1134,1452, + 949,1135,2104,2105,1094,1453,1454,1455,2106,1095,2107,2108,2109,2110,2111,2112, +2113,2114,2115,2116,2117, 804,2118,2119,1230,1231, 805,1456, 405,1136,2120,2121, +2122,2123,2124, 720, 701,1297, 992,1457, 927,1004,2125,2126,2127,2128,2129,2130, + 22, 417,2131, 303,2132, 385,2133, 971, 520, 513,2134,1174, 73,1096, 231, 274, + 962,1458, 673,2135,1459,2136, 152,1137,2137,2138,2139,2140,1005,1138,1460,1139, +2141,2142,2143,2144, 11, 374, 844,2145, 154,1232, 46,1461,2146, 838, 830, 721, +1233, 106,2147, 90, 428, 462, 578, 566,1175, 352,2148,2149, 538,1234, 124,1298, +2150,1462, 761, 565,2151, 686,2152, 649,2153, 72, 173,2154, 460, 415,2155,1463, +2156,1235, 305,2157,2158,2159,2160,2161,2162, 579,2163,2164,2165,2166,2167, 747, +2168,2169,2170,2171,1464, 669,2172,2173,2174,2175,2176,1465,2177, 23, 530, 285, +2178, 335, 729,2179, 397,2180,2181,2182,1030,2183,2184, 698,2185,2186, 325,2187, +2188, 369,2189, 799,1097,1015, 348,2190,1069, 680,2191, 851,1466,2192,2193, 10, +2194, 613, 424,2195, 979, 108, 449, 589, 27, 172, 81,1031, 80, 774, 281, 350, +1032, 525, 301, 582,1176,2196, 674,1045,2197,2198,1467, 730, 762,2199,2200,2201, +2202,1468,2203, 993,2204,2205, 266,1070, 963,1140,2206,2207,2208, 664,1098, 972, +2209,2210,2211,1177,1469,1470, 871,2212,2213,2214,2215,2216,1471,2217,2218,2219, +2220,2221,2222,2223,2224,2225,2226,2227,1472,1236,2228,2229,2230,2231,2232,2233, +2234,2235,1299,2236,2237, 200,2238, 477, 373,2239,2240, 731, 825, 777,2241,2242, +2243, 521, 486, 548,2244,2245,2246,1473,1300, 53, 549, 137, 875, 76, 158,2247, +1301,1474, 469, 396,1016, 278, 712,2248, 321, 442, 503, 767, 744, 941,1237,1178, +1475,2249, 82, 178,1141,1179, 973,2250,1302,2251, 297,2252,2253, 570,2254,2255, +2256, 18, 450, 206,2257, 290, 292,1142,2258, 511, 162, 99, 346, 164, 735,2259, +1476,1477, 4, 554, 343, 798,1099,2260,1100,2261, 43, 171,1303, 139, 215,2262, +2263, 717, 775,2264,1033, 322, 216,2265, 831,2266, 149,2267,1304,2268,2269, 702, +1238, 135, 845, 347, 309,2270, 484,2271, 878, 655, 238,1006,1478,2272, 67,2273, + 295,2274,2275, 461,2276, 478, 942, 412,2277,1034,2278,2279,2280, 265,2281, 541, +2282,2283,2284,2285,2286, 70, 852,1071,2287,2288,2289,2290, 21, 56, 509, 117, + 432,2291,2292, 331, 980, 552,1101, 148, 284, 105, 393,1180,1239, 755,2293, 187, +2294,1046,1479,2295, 340,2296, 63,1047, 230,2297,2298,1305, 763,1306, 101, 800, + 808, 494,2299,2300,2301, 903,2302, 37,1072, 14, 5,2303, 79, 675,2304, 312, +2305,2306,2307,2308,2309,1480, 6,1307,2310,2311,2312, 1, 470, 35, 24, 229, +2313, 695, 210, 86, 778, 15, 784, 592, 779, 32, 77, 855, 964,2314, 259,2315, + 501, 380,2316,2317, 83, 981, 153, 689,1308,1481,1482,1483,2318,2319, 716,1484, +2320,2321,2322,2323,2324,2325,1485,2326,2327, 128, 57, 68, 261,1048, 211, 170, +1240, 31,2328, 51, 435, 742,2329,2330,2331, 635,2332, 264, 456,2333,2334,2335, + 425,2336,1486, 143, 507, 263, 943,2337, 363, 920,1487, 256,1488,1102, 243, 601, +1489,2338,2339,2340,2341,2342,2343,2344, 861,2345,2346,2347,2348,2349,2350, 395, +2351,1490,1491, 62, 535, 166, 225,2352,2353, 668, 419,1241, 138, 604, 928,2354, +1181,2355,1492,1493,2356,2357,2358,1143,2359, 696,2360, 387, 307,1309, 682, 476, +2361,2362, 332, 12, 222, 156,2363, 232,2364, 641, 276, 656, 517,1494,1495,1035, + 416, 736,1496,2365,1017, 586,2366,2367,2368,1497,2369, 242,2370,2371,2372,1498, +2373, 965, 713,2374,2375,2376,2377, 740, 982,1499, 944,1500,1007,2378,2379,1310, +1501,2380,2381,2382, 785, 329,2383,2384,1502,2385,2386,2387, 932,2388,1503,2389, +2390,2391,2392,1242,2393,2394,2395,2396,2397, 994, 950,2398,2399,2400,2401,1504, +1311,2402,2403,2404,2405,1049, 749,2406,2407, 853, 718,1144,1312,2408,1182,1505, +2409,2410, 255, 516, 479, 564, 550, 214,1506,1507,1313, 413, 239, 444, 339,1145, +1036,1508,1509,1314,1037,1510,1315,2411,1511,2412,2413,2414, 176, 703, 497, 624, + 593, 921, 302,2415, 341, 165,1103,1512,2416,1513,2417,2418,2419, 376,2420, 700, +2421,2422,2423, 258, 768,1316,2424,1183,2425, 995, 608,2426,2427,2428,2429, 221, +2430,2431,2432,2433,2434,2435,2436,2437, 195, 323, 726, 188, 897, 983,1317, 377, + 644,1050, 879,2438, 452,2439,2440,2441,2442,2443,2444, 914,2445,2446,2447,2448, + 915, 489,2449,1514,1184,2450,2451, 515, 64, 427, 495,2452, 583,2453, 483, 485, +1038, 562, 213,1515, 748, 666,2454,2455,2456,2457, 334,2458, 780, 996,1008, 705, +1243,2459,2460,2461,2462,2463, 114,2464, 493,1146, 366, 163,1516, 961,1104,2465, + 291,2466,1318,1105,2467,1517, 365,2468, 355, 951,1244,2469,1319,2470, 631,2471, +2472, 218,1320, 364, 320, 756,1518,1519,1321,1520,1322,2473,2474,2475,2476, 997, +2477,2478,2479,2480, 665,1185,2481, 916,1521,2482,2483,2484, 584, 684,2485,2486, + 797,2487,1051,1186,2488,2489,2490,1522,2491,2492, 370,2493,1039,1187, 65,2494, + 434, 205, 463,1188,2495, 125, 812, 391, 402, 826, 699, 286, 398, 155, 781, 771, + 585,2496, 590, 505,1073,2497, 599, 244, 219, 917,1018, 952, 646,1523,2498,1323, +2499,2500, 49, 984, 354, 741,2501, 625,2502,1324,2503,1019, 190, 357, 757, 491, + 95, 782, 868,2504,2505,2506,2507,2508,2509, 134,1524,1074, 422,1525, 898,2510, + 161,2511,2512,2513,2514, 769,2515,1526,2516,2517, 411,1325,2518, 472,1527,2519, +2520,2521,2522,2523,2524, 985,2525,2526,2527,2528,2529,2530, 764,2531,1245,2532, +2533, 25, 204, 311,2534, 496,2535,1052,2536,2537,2538,2539,2540,2541,2542, 199, + 704, 504, 468, 758, 657,1528, 196, 44, 839,1246, 272, 750,2543, 765, 862,2544, +2545,1326,2546, 132, 615, 933,2547, 732,2548,2549,2550,1189,1529,2551, 283,1247, +1053, 607, 929,2552,2553,2554, 930, 183, 872, 616,1040,1147,2555,1148,1020, 441, + 249,1075,2556,2557,2558, 466, 743,2559,2560,2561, 92, 514, 426, 420, 526,2562, +2563,2564,2565,2566,2567,2568, 185,2569,2570,2571,2572, 776,1530, 658,2573, 362, +2574, 361, 922,1076, 793,2575,2576,2577,2578,2579,2580,1531, 251,2581,2582,2583, +2584,1532, 54, 612, 237,1327,2585,2586, 275, 408, 647, 111,2587,1533,1106, 465, + 3, 458, 9, 38,2588, 107, 110, 890, 209, 26, 737, 498,2589,1534,2590, 431, + 202, 88,1535, 356, 287,1107, 660,1149,2591, 381,1536, 986,1150, 445,1248,1151, + 974,2592,2593, 846,2594, 446, 953, 184,1249,1250, 727,2595, 923, 193, 883,2596, +2597,2598, 102, 324, 539, 817,2599, 421,1041,2600, 832,2601, 94, 175, 197, 406, +2602, 459,2603,2604,2605,2606,2607, 330, 555,2608,2609,2610, 706,1108, 389,2611, +2612,2613,2614, 233,2615, 833, 558, 931, 954,1251,2616,2617,1537, 546,2618,2619, +1009,2620,2621,2622,1538, 690,1328,2623, 955,2624,1539,2625,2626, 772,2627,2628, +2629,2630,2631, 924, 648, 863, 603,2632,2633, 934,1540, 864, 865,2634, 642,1042, + 670,1190,2635,2636,2637,2638, 168,2639, 652, 873, 542,1054,1541,2640,2641,2642, # 512, 256 +#Everything below is of no interest for detection purpose +2643,2644,2645,2646,2647,2648,2649,2650,2651,2652,2653,2654,2655,2656,2657,2658, +2659,2660,2661,2662,2663,2664,2665,2666,2667,2668,2669,2670,2671,2672,2673,2674, +2675,2676,2677,2678,2679,2680,2681,2682,2683,2684,2685,2686,2687,2688,2689,2690, +2691,2692,2693,2694,2695,2696,2697,2698,2699,1542, 880,2700,2701,2702,2703,2704, +2705,2706,2707,2708,2709,2710,2711,2712,2713,2714,2715,2716,2717,2718,2719,2720, +2721,2722,2723,2724,2725,1543,2726,2727,2728,2729,2730,2731,2732,1544,2733,2734, +2735,2736,2737,2738,2739,2740,2741,2742,2743,2744,2745,2746,2747,2748,2749,2750, +2751,2752,2753,2754,1545,2755,2756,2757,2758,2759,2760,2761,2762,2763,2764,2765, +2766,1546,2767,1547,2768,2769,2770,2771,2772,2773,2774,2775,2776,2777,2778,2779, +2780,2781,2782,2783,2784,2785,2786,1548,2787,2788,2789,1109,2790,2791,2792,2793, +2794,2795,2796,2797,2798,2799,2800,2801,2802,2803,2804,2805,2806,2807,2808,2809, +2810,2811,2812,1329,2813,2814,2815,2816,2817,2818,2819,2820,2821,2822,2823,2824, +2825,2826,2827,2828,2829,2830,2831,2832,2833,2834,2835,2836,2837,2838,2839,2840, +2841,2842,2843,2844,2845,2846,2847,2848,2849,2850,2851,2852,2853,2854,2855,2856, +1549,2857,2858,2859,2860,1550,2861,2862,1551,2863,2864,2865,2866,2867,2868,2869, +2870,2871,2872,2873,2874,1110,1330,2875,2876,2877,2878,2879,2880,2881,2882,2883, +2884,2885,2886,2887,2888,2889,2890,2891,2892,2893,2894,2895,2896,2897,2898,2899, +2900,2901,2902,2903,2904,2905,2906,2907,2908,2909,2910,2911,2912,2913,2914,2915, +2916,2917,2918,2919,2920,2921,2922,2923,2924,2925,2926,2927,2928,2929,2930,1331, +2931,2932,2933,2934,2935,2936,2937,2938,2939,2940,2941,2942,2943,1552,2944,2945, +2946,2947,2948,2949,2950,2951,2952,2953,2954,2955,2956,2957,2958,2959,2960,2961, +2962,2963,2964,1252,2965,2966,2967,2968,2969,2970,2971,2972,2973,2974,2975,2976, +2977,2978,2979,2980,2981,2982,2983,2984,2985,2986,2987,2988,2989,2990,2991,2992, +2993,2994,2995,2996,2997,2998,2999,3000,3001,3002,3003,3004,3005,3006,3007,3008, +3009,3010,3011,3012,1553,3013,3014,3015,3016,3017,1554,3018,1332,3019,3020,3021, +3022,3023,3024,3025,3026,3027,3028,3029,3030,3031,3032,3033,3034,3035,3036,3037, +3038,3039,3040,3041,3042,3043,3044,3045,3046,3047,3048,3049,3050,1555,3051,3052, +3053,1556,1557,3054,3055,3056,3057,3058,3059,3060,3061,3062,3063,3064,3065,3066, +3067,1558,3068,3069,3070,3071,3072,3073,3074,3075,3076,1559,3077,3078,3079,3080, +3081,3082,3083,1253,3084,3085,3086,3087,3088,3089,3090,3091,3092,3093,3094,3095, +3096,3097,3098,3099,3100,3101,3102,3103,3104,3105,3106,3107,3108,1152,3109,3110, +3111,3112,3113,1560,3114,3115,3116,3117,1111,3118,3119,3120,3121,3122,3123,3124, +3125,3126,3127,3128,3129,3130,3131,3132,3133,3134,3135,3136,3137,3138,3139,3140, +3141,3142,3143,3144,3145,3146,3147,3148,3149,3150,3151,3152,3153,3154,3155,3156, +3157,3158,3159,3160,3161,3162,3163,3164,3165,3166,3167,3168,3169,3170,3171,3172, +3173,3174,3175,3176,1333,3177,3178,3179,3180,3181,3182,3183,3184,3185,3186,3187, +3188,3189,1561,3190,3191,1334,3192,3193,3194,3195,3196,3197,3198,3199,3200,3201, +3202,3203,3204,3205,3206,3207,3208,3209,3210,3211,3212,3213,3214,3215,3216,3217, +3218,3219,3220,3221,3222,3223,3224,3225,3226,3227,3228,3229,3230,3231,3232,3233, +3234,1562,3235,3236,3237,3238,3239,3240,3241,3242,3243,3244,3245,3246,3247,3248, +3249,3250,3251,3252,3253,3254,3255,3256,3257,3258,3259,3260,3261,3262,3263,3264, +3265,3266,3267,3268,3269,3270,3271,3272,3273,3274,3275,3276,3277,1563,3278,3279, +3280,3281,3282,3283,3284,3285,3286,3287,3288,3289,3290,3291,3292,3293,3294,3295, +3296,3297,3298,3299,3300,3301,3302,3303,3304,3305,3306,3307,3308,3309,3310,3311, +3312,3313,3314,3315,3316,3317,3318,3319,3320,3321,3322,3323,3324,3325,3326,3327, +3328,3329,3330,3331,3332,3333,3334,3335,3336,3337,3338,3339,3340,3341,3342,3343, +3344,3345,3346,3347,3348,3349,3350,3351,3352,3353,3354,3355,3356,3357,3358,3359, +3360,3361,3362,3363,3364,1335,3365,3366,3367,3368,3369,3370,3371,3372,3373,3374, +3375,3376,3377,3378,3379,3380,3381,3382,3383,3384,3385,3386,3387,1336,3388,3389, +3390,3391,3392,3393,3394,3395,3396,3397,3398,3399,3400,3401,3402,3403,3404,3405, +3406,3407,3408,3409,3410,3411,3412,3413,3414,1337,3415,3416,3417,3418,3419,1338, +3420,3421,3422,1564,1565,3423,3424,3425,3426,3427,3428,3429,3430,3431,1254,3432, +3433,3434,1339,3435,3436,3437,3438,3439,1566,3440,3441,3442,3443,3444,3445,3446, +3447,3448,3449,3450,3451,3452,3453,3454,1255,3455,3456,3457,3458,3459,1567,1191, +3460,1568,1569,3461,3462,3463,1570,3464,3465,3466,3467,3468,1571,3469,3470,3471, +3472,3473,1572,3474,3475,3476,3477,3478,3479,3480,3481,3482,3483,3484,3485,3486, +1340,3487,3488,3489,3490,3491,3492,1021,3493,3494,3495,3496,3497,3498,1573,3499, +1341,3500,3501,3502,3503,3504,3505,3506,3507,3508,3509,3510,3511,1342,3512,3513, +3514,3515,3516,1574,1343,3517,3518,3519,1575,3520,1576,3521,3522,3523,3524,3525, +3526,3527,3528,3529,3530,3531,3532,3533,3534,3535,3536,3537,3538,3539,3540,3541, +3542,3543,3544,3545,3546,3547,3548,3549,3550,3551,3552,3553,3554,3555,3556,3557, +3558,3559,3560,3561,3562,3563,3564,3565,3566,3567,3568,3569,3570,3571,3572,3573, +3574,3575,3576,3577,3578,3579,3580,1577,3581,3582,1578,3583,3584,3585,3586,3587, +3588,3589,3590,3591,3592,3593,3594,3595,3596,3597,3598,3599,3600,3601,3602,3603, +3604,1579,3605,3606,3607,3608,3609,3610,3611,3612,3613,3614,3615,3616,3617,3618, +3619,3620,3621,3622,3623,3624,3625,3626,3627,3628,3629,1580,3630,3631,1581,3632, +3633,3634,3635,3636,3637,3638,3639,3640,3641,3642,3643,3644,3645,3646,3647,3648, +3649,3650,3651,3652,3653,3654,3655,3656,1582,3657,3658,3659,3660,3661,3662,3663, +3664,3665,3666,3667,3668,3669,3670,3671,3672,3673,3674,3675,3676,3677,3678,3679, +3680,3681,3682,3683,3684,3685,3686,3687,3688,3689,3690,3691,3692,3693,3694,3695, +3696,3697,3698,3699,3700,1192,3701,3702,3703,3704,1256,3705,3706,3707,3708,1583, +1257,3709,3710,3711,3712,3713,3714,3715,3716,1584,3717,3718,3719,3720,3721,3722, +3723,3724,3725,3726,3727,3728,3729,3730,3731,3732,3733,3734,3735,3736,3737,3738, +3739,3740,3741,3742,3743,3744,3745,1344,3746,3747,3748,3749,3750,3751,3752,3753, +3754,3755,3756,1585,3757,3758,3759,3760,3761,3762,3763,3764,3765,3766,1586,3767, +3768,3769,3770,3771,3772,3773,3774,3775,3776,3777,3778,1345,3779,3780,3781,3782, +3783,3784,3785,3786,3787,3788,3789,3790,3791,3792,3793,3794,3795,1346,1587,3796, +3797,1588,3798,3799,3800,3801,3802,3803,3804,3805,3806,1347,3807,3808,3809,3810, +3811,1589,3812,3813,3814,3815,3816,3817,3818,3819,3820,3821,1590,3822,3823,1591, +1348,3824,3825,3826,3827,3828,3829,3830,1592,3831,3832,1593,3833,3834,3835,3836, +3837,3838,3839,3840,3841,3842,3843,3844,1349,3845,3846,3847,3848,3849,3850,3851, +3852,3853,3854,3855,3856,3857,3858,1594,3859,3860,3861,3862,3863,3864,3865,3866, +3867,3868,3869,1595,3870,3871,3872,3873,1596,3874,3875,3876,3877,3878,3879,3880, +3881,3882,3883,3884,3885,3886,1597,3887,3888,3889,3890,3891,3892,3893,3894,3895, +1598,3896,3897,3898,1599,1600,3899,1350,3900,1351,3901,3902,1352,3903,3904,3905, +3906,3907,3908,3909,3910,3911,3912,3913,3914,3915,3916,3917,3918,3919,3920,3921, +3922,3923,3924,1258,3925,3926,3927,3928,3929,3930,3931,1193,3932,1601,3933,3934, +3935,3936,3937,3938,3939,3940,3941,3942,3943,1602,3944,3945,3946,3947,3948,1603, +3949,3950,3951,3952,3953,3954,3955,3956,3957,3958,3959,3960,3961,3962,3963,3964, +3965,1604,3966,3967,3968,3969,3970,3971,3972,3973,3974,3975,3976,3977,1353,3978, +3979,3980,3981,3982,3983,3984,3985,3986,3987,3988,3989,3990,3991,1354,3992,3993, +3994,3995,3996,3997,3998,3999,4000,4001,4002,4003,4004,4005,4006,4007,4008,4009, +4010,4011,4012,4013,4014,4015,4016,4017,4018,4019,4020,4021,4022,4023,1355,4024, +4025,4026,4027,4028,4029,4030,4031,4032,4033,4034,4035,4036,4037,4038,4039,4040, +1605,4041,4042,4043,4044,4045,4046,4047,4048,4049,4050,4051,4052,4053,4054,4055, +4056,4057,4058,4059,4060,1606,4061,4062,4063,4064,1607,4065,4066,4067,4068,4069, +4070,4071,4072,4073,4074,4075,4076,1194,4077,4078,1608,4079,4080,4081,4082,4083, +4084,4085,4086,4087,1609,4088,4089,4090,4091,4092,4093,4094,4095,4096,4097,4098, +4099,4100,4101,4102,4103,4104,4105,4106,4107,4108,1259,4109,4110,4111,4112,4113, +4114,4115,4116,4117,4118,4119,4120,4121,4122,4123,4124,1195,4125,4126,4127,1610, +4128,4129,4130,4131,4132,4133,4134,4135,4136,4137,1356,4138,4139,4140,4141,4142, +4143,4144,1611,4145,4146,4147,4148,4149,4150,4151,4152,4153,4154,4155,4156,4157, +4158,4159,4160,4161,4162,4163,4164,4165,4166,4167,4168,4169,4170,4171,4172,4173, +4174,4175,4176,4177,4178,4179,4180,4181,4182,4183,4184,4185,4186,4187,4188,4189, +4190,4191,4192,4193,4194,4195,4196,4197,4198,4199,4200,4201,4202,4203,4204,4205, +4206,4207,4208,4209,4210,4211,4212,4213,4214,4215,4216,4217,4218,4219,1612,4220, +4221,4222,4223,4224,4225,4226,4227,1357,4228,1613,4229,4230,4231,4232,4233,4234, +4235,4236,4237,4238,4239,4240,4241,4242,4243,1614,4244,4245,4246,4247,4248,4249, +4250,4251,4252,4253,4254,4255,4256,4257,4258,4259,4260,4261,4262,4263,4264,4265, +4266,4267,4268,4269,4270,1196,1358,4271,4272,4273,4274,4275,4276,4277,4278,4279, +4280,4281,4282,4283,4284,4285,4286,4287,1615,4288,4289,4290,4291,4292,4293,4294, +4295,4296,4297,4298,4299,4300,4301,4302,4303,4304,4305,4306,4307,4308,4309,4310, +4311,4312,4313,4314,4315,4316,4317,4318,4319,4320,4321,4322,4323,4324,4325,4326, +4327,4328,4329,4330,4331,4332,4333,4334,1616,4335,4336,4337,4338,4339,4340,4341, +4342,4343,4344,4345,4346,4347,4348,4349,4350,4351,4352,4353,4354,4355,4356,4357, +4358,4359,4360,1617,4361,4362,4363,4364,4365,1618,4366,4367,4368,4369,4370,4371, +4372,4373,4374,4375,4376,4377,4378,4379,4380,4381,4382,4383,4384,4385,4386,4387, +4388,4389,4390,4391,4392,4393,4394,4395,4396,4397,4398,4399,4400,4401,4402,4403, +4404,4405,4406,4407,4408,4409,4410,4411,4412,4413,4414,4415,4416,1619,4417,4418, +4419,4420,4421,4422,4423,4424,4425,1112,4426,4427,4428,4429,4430,1620,4431,4432, +4433,4434,4435,4436,4437,4438,4439,4440,4441,4442,1260,1261,4443,4444,4445,4446, +4447,4448,4449,4450,4451,4452,4453,4454,4455,1359,4456,4457,4458,4459,4460,4461, +4462,4463,4464,4465,1621,4466,4467,4468,4469,4470,4471,4472,4473,4474,4475,4476, +4477,4478,4479,4480,4481,4482,4483,4484,4485,4486,4487,4488,4489,1055,4490,4491, +4492,4493,4494,4495,4496,4497,4498,4499,4500,4501,4502,4503,4504,4505,4506,4507, +4508,4509,4510,4511,4512,4513,4514,4515,4516,4517,4518,1622,4519,4520,4521,1623, +4522,4523,4524,4525,4526,4527,4528,4529,4530,4531,4532,4533,4534,4535,1360,4536, +4537,4538,4539,4540,4541,4542,4543, 975,4544,4545,4546,4547,4548,4549,4550,4551, +4552,4553,4554,4555,4556,4557,4558,4559,4560,4561,4562,4563,4564,4565,4566,4567, +4568,4569,4570,4571,1624,4572,4573,4574,4575,4576,1625,4577,4578,4579,4580,4581, +4582,4583,4584,1626,4585,4586,4587,4588,4589,4590,4591,4592,4593,4594,4595,1627, +4596,4597,4598,4599,4600,4601,4602,4603,4604,4605,4606,4607,4608,4609,4610,4611, +4612,4613,4614,4615,1628,4616,4617,4618,4619,4620,4621,4622,4623,4624,4625,4626, +4627,4628,4629,4630,4631,4632,4633,4634,4635,4636,4637,4638,4639,4640,4641,4642, +4643,4644,4645,4646,4647,4648,4649,1361,4650,4651,4652,4653,4654,4655,4656,4657, +4658,4659,4660,4661,1362,4662,4663,4664,4665,4666,4667,4668,4669,4670,4671,4672, +4673,4674,4675,4676,4677,4678,4679,4680,4681,4682,1629,4683,4684,4685,4686,4687, +1630,4688,4689,4690,4691,1153,4692,4693,4694,1113,4695,4696,4697,4698,4699,4700, +4701,4702,4703,4704,4705,4706,4707,4708,4709,4710,4711,1197,4712,4713,4714,4715, +4716,4717,4718,4719,4720,4721,4722,4723,4724,4725,4726,4727,4728,4729,4730,4731, +4732,4733,4734,4735,1631,4736,1632,4737,4738,4739,4740,4741,4742,4743,4744,1633, +4745,4746,4747,4748,4749,1262,4750,4751,4752,4753,4754,1363,4755,4756,4757,4758, +4759,4760,4761,4762,4763,4764,4765,4766,4767,4768,1634,4769,4770,4771,4772,4773, +4774,4775,4776,4777,4778,1635,4779,4780,4781,4782,4783,4784,4785,4786,4787,4788, +4789,1636,4790,4791,4792,4793,4794,4795,4796,4797,4798,4799,4800,4801,4802,4803, +4804,4805,4806,1637,4807,4808,4809,1638,4810,4811,4812,4813,4814,4815,4816,4817, +4818,1639,4819,4820,4821,4822,4823,4824,4825,4826,4827,4828,4829,4830,4831,4832, +4833,1077,4834,4835,4836,4837,4838,4839,4840,4841,4842,4843,4844,4845,4846,4847, +4848,4849,4850,4851,4852,4853,4854,4855,4856,4857,4858,4859,4860,4861,4862,4863, +4864,4865,4866,4867,4868,4869,4870,4871,4872,4873,4874,4875,4876,4877,4878,4879, +4880,4881,4882,4883,1640,4884,4885,1641,4886,4887,4888,4889,4890,4891,4892,4893, +4894,4895,4896,4897,4898,4899,4900,4901,4902,4903,4904,4905,4906,4907,4908,4909, +4910,4911,1642,4912,4913,4914,1364,4915,4916,4917,4918,4919,4920,4921,4922,4923, +4924,4925,4926,4927,4928,4929,4930,4931,1643,4932,4933,4934,4935,4936,4937,4938, +4939,4940,4941,4942,4943,4944,4945,4946,4947,4948,4949,4950,4951,4952,4953,4954, +4955,4956,4957,4958,4959,4960,4961,4962,4963,4964,4965,4966,4967,4968,4969,4970, +4971,4972,4973,4974,4975,4976,4977,4978,4979,4980,1644,4981,4982,4983,4984,1645, +4985,4986,1646,4987,4988,4989,4990,4991,4992,4993,4994,4995,4996,4997,4998,4999, +5000,5001,5002,5003,5004,5005,1647,5006,1648,5007,5008,5009,5010,5011,5012,1078, +5013,5014,5015,5016,5017,5018,5019,5020,5021,5022,5023,5024,5025,5026,5027,5028, +1365,5029,5030,5031,5032,5033,5034,5035,5036,5037,5038,5039,1649,5040,5041,5042, +5043,5044,5045,1366,5046,5047,5048,5049,5050,5051,5052,5053,5054,5055,1650,5056, +5057,5058,5059,5060,5061,5062,5063,5064,5065,5066,5067,5068,5069,5070,5071,5072, +5073,5074,5075,5076,5077,1651,5078,5079,5080,5081,5082,5083,5084,5085,5086,5087, +5088,5089,5090,5091,5092,5093,5094,5095,5096,5097,5098,5099,5100,5101,5102,5103, +5104,5105,5106,5107,5108,5109,5110,1652,5111,5112,5113,5114,5115,5116,5117,5118, +1367,5119,5120,5121,5122,5123,5124,5125,5126,5127,5128,5129,1653,5130,5131,5132, +5133,5134,5135,5136,5137,5138,5139,5140,5141,5142,5143,5144,5145,5146,5147,5148, +5149,1368,5150,1654,5151,1369,5152,5153,5154,5155,5156,5157,5158,5159,5160,5161, +5162,5163,5164,5165,5166,5167,5168,5169,5170,5171,5172,5173,5174,5175,5176,5177, +5178,1370,5179,5180,5181,5182,5183,5184,5185,5186,5187,5188,5189,5190,5191,5192, +5193,5194,5195,5196,5197,5198,1655,5199,5200,5201,5202,1656,5203,5204,5205,5206, +1371,5207,1372,5208,5209,5210,5211,1373,5212,5213,1374,5214,5215,5216,5217,5218, +5219,5220,5221,5222,5223,5224,5225,5226,5227,5228,5229,5230,5231,5232,5233,5234, +5235,5236,5237,5238,5239,5240,5241,5242,5243,5244,5245,5246,5247,1657,5248,5249, +5250,5251,1658,1263,5252,5253,5254,5255,5256,1375,5257,5258,5259,5260,5261,5262, +5263,5264,5265,5266,5267,5268,5269,5270,5271,5272,5273,5274,5275,5276,5277,5278, +5279,5280,5281,5282,5283,1659,5284,5285,5286,5287,5288,5289,5290,5291,5292,5293, +5294,5295,5296,5297,5298,5299,5300,1660,5301,5302,5303,5304,5305,5306,5307,5308, +5309,5310,5311,5312,5313,5314,5315,5316,5317,5318,5319,5320,5321,1376,5322,5323, +5324,5325,5326,5327,5328,5329,5330,5331,5332,5333,1198,5334,5335,5336,5337,5338, +5339,5340,5341,5342,5343,1661,5344,5345,5346,5347,5348,5349,5350,5351,5352,5353, +5354,5355,5356,5357,5358,5359,5360,5361,5362,5363,5364,5365,5366,5367,5368,5369, +5370,5371,5372,5373,5374,5375,5376,5377,5378,5379,5380,5381,5382,5383,5384,5385, +5386,5387,5388,5389,5390,5391,5392,5393,5394,5395,5396,5397,5398,1264,5399,5400, +5401,5402,5403,5404,5405,5406,5407,5408,5409,5410,5411,5412,1662,5413,5414,5415, +5416,1663,5417,5418,5419,5420,5421,5422,5423,5424,5425,5426,5427,5428,5429,5430, +5431,5432,5433,5434,5435,5436,5437,5438,1664,5439,5440,5441,5442,5443,5444,5445, +5446,5447,5448,5449,5450,5451,5452,5453,5454,5455,5456,5457,5458,5459,5460,5461, +5462,5463,5464,5465,5466,5467,5468,5469,5470,5471,5472,5473,5474,5475,5476,5477, +5478,1154,5479,5480,5481,5482,5483,5484,5485,1665,5486,5487,5488,5489,5490,5491, +5492,5493,5494,5495,5496,5497,5498,5499,5500,5501,5502,5503,5504,5505,5506,5507, +5508,5509,5510,5511,5512,5513,5514,5515,5516,5517,5518,5519,5520,5521,5522,5523, +5524,5525,5526,5527,5528,5529,5530,5531,5532,5533,5534,5535,5536,5537,5538,5539, +5540,5541,5542,5543,5544,5545,5546,5547,5548,1377,5549,5550,5551,5552,5553,5554, +5555,5556,5557,5558,5559,5560,5561,5562,5563,5564,5565,5566,5567,5568,5569,5570, +1114,5571,5572,5573,5574,5575,5576,5577,5578,5579,5580,5581,5582,5583,5584,5585, +5586,5587,5588,5589,5590,5591,5592,1378,5593,5594,5595,5596,5597,5598,5599,5600, +5601,5602,5603,5604,5605,5606,5607,5608,5609,5610,5611,5612,5613,5614,1379,5615, +5616,5617,5618,5619,5620,5621,5622,5623,5624,5625,5626,5627,5628,5629,5630,5631, +5632,5633,5634,1380,5635,5636,5637,5638,5639,5640,5641,5642,5643,5644,5645,5646, +5647,5648,5649,1381,1056,5650,5651,5652,5653,5654,5655,5656,5657,5658,5659,5660, +1666,5661,5662,5663,5664,5665,5666,5667,5668,1667,5669,1668,5670,5671,5672,5673, +5674,5675,5676,5677,5678,1155,5679,5680,5681,5682,5683,5684,5685,5686,5687,5688, +5689,5690,5691,5692,5693,5694,5695,5696,5697,5698,1669,5699,5700,5701,5702,5703, +5704,5705,1670,5706,5707,5708,5709,5710,1671,5711,5712,5713,5714,1382,5715,5716, +5717,5718,5719,5720,5721,5722,5723,5724,5725,1672,5726,5727,1673,1674,5728,5729, +5730,5731,5732,5733,5734,5735,5736,1675,5737,5738,5739,5740,5741,5742,5743,5744, +1676,5745,5746,5747,5748,5749,5750,5751,1383,5752,5753,5754,5755,5756,5757,5758, +5759,5760,5761,5762,5763,5764,5765,5766,5767,5768,1677,5769,5770,5771,5772,5773, +1678,5774,5775,5776, 998,5777,5778,5779,5780,5781,5782,5783,5784,5785,1384,5786, +5787,5788,5789,5790,5791,5792,5793,5794,5795,5796,5797,5798,5799,5800,1679,5801, +5802,5803,1115,1116,5804,5805,5806,5807,5808,5809,5810,5811,5812,5813,5814,5815, +5816,5817,5818,5819,5820,5821,5822,5823,5824,5825,5826,5827,5828,5829,5830,5831, +5832,5833,5834,5835,5836,5837,5838,5839,5840,5841,5842,5843,5844,5845,5846,5847, +5848,5849,5850,5851,5852,5853,5854,5855,1680,5856,5857,5858,5859,5860,5861,5862, +5863,5864,1681,5865,5866,5867,1682,5868,5869,5870,5871,5872,5873,5874,5875,5876, +5877,5878,5879,1683,5880,1684,5881,5882,5883,5884,1685,5885,5886,5887,5888,5889, +5890,5891,5892,5893,5894,5895,5896,5897,5898,5899,5900,5901,5902,5903,5904,5905, +5906,5907,1686,5908,5909,5910,5911,5912,5913,5914,5915,5916,5917,5918,5919,5920, +5921,5922,5923,5924,5925,5926,5927,5928,5929,5930,5931,5932,5933,5934,5935,1687, +5936,5937,5938,5939,5940,5941,5942,5943,5944,5945,5946,5947,5948,5949,5950,5951, +5952,1688,1689,5953,1199,5954,5955,5956,5957,5958,5959,5960,5961,1690,5962,5963, +5964,5965,5966,5967,5968,5969,5970,5971,5972,5973,5974,5975,5976,5977,5978,5979, +5980,5981,1385,5982,1386,5983,5984,5985,5986,5987,5988,5989,5990,5991,5992,5993, +5994,5995,5996,5997,5998,5999,6000,6001,6002,6003,6004,6005,6006,6007,6008,6009, +6010,6011,6012,6013,6014,6015,6016,6017,6018,6019,6020,6021,6022,6023,6024,6025, +6026,6027,1265,6028,6029,1691,6030,6031,6032,6033,6034,6035,6036,6037,6038,6039, +6040,6041,6042,6043,6044,6045,6046,6047,6048,6049,6050,6051,6052,6053,6054,6055, +6056,6057,6058,6059,6060,6061,6062,6063,6064,6065,6066,6067,6068,6069,6070,6071, +6072,6073,6074,6075,6076,6077,6078,6079,6080,6081,6082,6083,6084,1692,6085,6086, +6087,6088,6089,6090,6091,6092,6093,6094,6095,6096,6097,6098,6099,6100,6101,6102, +6103,6104,6105,6106,6107,6108,6109,6110,6111,6112,6113,6114,6115,6116,6117,6118, +6119,6120,6121,6122,6123,6124,6125,6126,6127,6128,6129,6130,6131,1693,6132,6133, +6134,6135,6136,1694,6137,6138,6139,6140,6141,1695,6142,6143,6144,6145,6146,6147, +6148,6149,6150,6151,6152,6153,6154,6155,6156,6157,6158,6159,6160,6161,6162,6163, +6164,6165,6166,6167,6168,6169,6170,6171,6172,6173,6174,6175,6176,6177,6178,6179, +6180,6181,6182,6183,6184,6185,1696,6186,6187,6188,6189,6190,6191,6192,6193,6194, +6195,6196,6197,6198,6199,6200,6201,6202,6203,6204,6205,6206,6207,6208,6209,6210, +6211,6212,6213,6214,6215,6216,6217,6218,6219,1697,6220,6221,6222,6223,6224,6225, +6226,6227,6228,6229,6230,6231,6232,6233,6234,6235,6236,6237,6238,6239,6240,6241, +6242,6243,6244,6245,6246,6247,6248,6249,6250,6251,6252,6253,1698,6254,6255,6256, +6257,6258,6259,6260,6261,6262,6263,1200,6264,6265,6266,6267,6268,6269,6270,6271, #1024 +6272,6273,6274,6275,6276,6277,6278,6279,6280,6281,6282,6283,6284,6285,6286,6287, +6288,6289,6290,6291,6292,6293,6294,6295,6296,6297,6298,6299,6300,6301,6302,1699, +6303,6304,1700,6305,6306,6307,6308,6309,6310,6311,6312,6313,6314,6315,6316,6317, +6318,6319,6320,6321,6322,6323,6324,6325,6326,6327,6328,6329,6330,6331,6332,6333, +6334,6335,6336,6337,6338,6339,1701,6340,6341,6342,6343,6344,1387,6345,6346,6347, +6348,6349,6350,6351,6352,6353,6354,6355,6356,6357,6358,6359,6360,6361,6362,6363, +6364,6365,6366,6367,6368,6369,6370,6371,6372,6373,6374,6375,6376,6377,6378,6379, +6380,6381,6382,6383,6384,6385,6386,6387,6388,6389,6390,6391,6392,6393,6394,6395, +6396,6397,6398,6399,6400,6401,6402,6403,6404,6405,6406,6407,6408,6409,6410,6411, +6412,6413,1702,6414,6415,6416,6417,6418,6419,6420,6421,6422,1703,6423,6424,6425, +6426,6427,6428,6429,6430,6431,6432,6433,6434,6435,6436,6437,6438,1704,6439,6440, +6441,6442,6443,6444,6445,6446,6447,6448,6449,6450,6451,6452,6453,6454,6455,6456, +6457,6458,6459,6460,6461,6462,6463,6464,6465,6466,6467,6468,6469,6470,6471,6472, +6473,6474,6475,6476,6477,6478,6479,6480,6481,6482,6483,6484,6485,6486,6487,6488, +6489,6490,6491,6492,6493,6494,6495,6496,6497,6498,6499,6500,6501,6502,6503,1266, +6504,6505,6506,6507,6508,6509,6510,6511,6512,6513,6514,6515,6516,6517,6518,6519, +6520,6521,6522,6523,6524,6525,6526,6527,6528,6529,6530,6531,6532,6533,6534,6535, +6536,6537,6538,6539,6540,6541,6542,6543,6544,6545,6546,6547,6548,6549,6550,6551, +1705,1706,6552,6553,6554,6555,6556,6557,6558,6559,6560,6561,6562,6563,6564,6565, +6566,6567,6568,6569,6570,6571,6572,6573,6574,6575,6576,6577,6578,6579,6580,6581, +6582,6583,6584,6585,6586,6587,6588,6589,6590,6591,6592,6593,6594,6595,6596,6597, +6598,6599,6600,6601,6602,6603,6604,6605,6606,6607,6608,6609,6610,6611,6612,6613, +6614,6615,6616,6617,6618,6619,6620,6621,6622,6623,6624,6625,6626,6627,6628,6629, +6630,6631,6632,6633,6634,6635,6636,6637,1388,6638,6639,6640,6641,6642,6643,6644, +1707,6645,6646,6647,6648,6649,6650,6651,6652,6653,6654,6655,6656,6657,6658,6659, +6660,6661,6662,6663,1708,6664,6665,6666,6667,6668,6669,6670,6671,6672,6673,6674, +1201,6675,6676,6677,6678,6679,6680,6681,6682,6683,6684,6685,6686,6687,6688,6689, +6690,6691,6692,6693,6694,6695,6696,6697,6698,6699,6700,6701,6702,6703,6704,6705, +6706,6707,6708,6709,6710,6711,6712,6713,6714,6715,6716,6717,6718,6719,6720,6721, +6722,6723,6724,6725,1389,6726,6727,6728,6729,6730,6731,6732,6733,6734,6735,6736, +1390,1709,6737,6738,6739,6740,6741,6742,1710,6743,6744,6745,6746,1391,6747,6748, +6749,6750,6751,6752,6753,6754,6755,6756,6757,1392,6758,6759,6760,6761,6762,6763, +6764,6765,6766,6767,6768,6769,6770,6771,6772,6773,6774,6775,6776,6777,6778,6779, +6780,1202,6781,6782,6783,6784,6785,6786,6787,6788,6789,6790,6791,6792,6793,6794, +6795,6796,6797,6798,6799,6800,6801,6802,6803,6804,6805,6806,6807,6808,6809,1711, +6810,6811,6812,6813,6814,6815,6816,6817,6818,6819,6820,6821,6822,6823,6824,6825, +6826,6827,6828,6829,6830,6831,6832,6833,6834,6835,6836,1393,6837,6838,6839,6840, +6841,6842,6843,6844,6845,6846,6847,6848,6849,6850,6851,6852,6853,6854,6855,6856, +6857,6858,6859,6860,6861,6862,6863,6864,6865,6866,6867,6868,6869,6870,6871,6872, +6873,6874,6875,6876,6877,6878,6879,6880,6881,6882,6883,6884,6885,6886,6887,6888, +6889,6890,6891,6892,6893,6894,6895,6896,6897,6898,6899,6900,6901,6902,1712,6903, +6904,6905,6906,6907,6908,6909,6910,1713,6911,6912,6913,6914,6915,6916,6917,6918, +6919,6920,6921,6922,6923,6924,6925,6926,6927,6928,6929,6930,6931,6932,6933,6934, +6935,6936,6937,6938,6939,6940,6941,6942,6943,6944,6945,6946,6947,6948,6949,6950, +6951,6952,6953,6954,6955,6956,6957,6958,6959,6960,6961,6962,6963,6964,6965,6966, +6967,6968,6969,6970,6971,6972,6973,6974,1714,6975,6976,6977,6978,6979,6980,6981, +6982,6983,6984,6985,6986,6987,6988,1394,6989,6990,6991,6992,6993,6994,6995,6996, +6997,6998,6999,7000,1715,7001,7002,7003,7004,7005,7006,7007,7008,7009,7010,7011, +7012,7013,7014,7015,7016,7017,7018,7019,7020,7021,7022,7023,7024,7025,7026,7027, +7028,1716,7029,7030,7031,7032,7033,7034,7035,7036,7037,7038,7039,7040,7041,7042, +7043,7044,7045,7046,7047,7048,7049,7050,7051,7052,7053,7054,7055,7056,7057,7058, +7059,7060,7061,7062,7063,7064,7065,7066,7067,7068,7069,7070,7071,7072,7073,7074, +7075,7076,7077,7078,7079,7080,7081,7082,7083,7084,7085,7086,7087,7088,7089,7090, +7091,7092,7093,7094,7095,7096,7097,7098,7099,7100,7101,7102,7103,7104,7105,7106, +7107,7108,7109,7110,7111,7112,7113,7114,7115,7116,7117,7118,7119,7120,7121,7122, +7123,7124,7125,7126,7127,7128,7129,7130,7131,7132,7133,7134,7135,7136,7137,7138, +7139,7140,7141,7142,7143,7144,7145,7146,7147,7148,7149,7150,7151,7152,7153,7154, +7155,7156,7157,7158,7159,7160,7161,7162,7163,7164,7165,7166,7167,7168,7169,7170, +7171,7172,7173,7174,7175,7176,7177,7178,7179,7180,7181,7182,7183,7184,7185,7186, +7187,7188,7189,7190,7191,7192,7193,7194,7195,7196,7197,7198,7199,7200,7201,7202, +7203,7204,7205,7206,7207,1395,7208,7209,7210,7211,7212,7213,1717,7214,7215,7216, +7217,7218,7219,7220,7221,7222,7223,7224,7225,7226,7227,7228,7229,7230,7231,7232, +7233,7234,7235,7236,7237,7238,7239,7240,7241,7242,7243,7244,7245,7246,7247,7248, +7249,7250,7251,7252,7253,7254,7255,7256,7257,7258,7259,7260,7261,7262,7263,7264, +7265,7266,7267,7268,7269,7270,7271,7272,7273,7274,7275,7276,7277,7278,7279,7280, +7281,7282,7283,7284,7285,7286,7287,7288,7289,7290,7291,7292,7293,7294,7295,7296, +7297,7298,7299,7300,7301,7302,7303,7304,7305,7306,7307,7308,7309,7310,7311,7312, +7313,1718,7314,7315,7316,7317,7318,7319,7320,7321,7322,7323,7324,7325,7326,7327, +7328,7329,7330,7331,7332,7333,7334,7335,7336,7337,7338,7339,7340,7341,7342,7343, +7344,7345,7346,7347,7348,7349,7350,7351,7352,7353,7354,7355,7356,7357,7358,7359, +7360,7361,7362,7363,7364,7365,7366,7367,7368,7369,7370,7371,7372,7373,7374,7375, +7376,7377,7378,7379,7380,7381,7382,7383,7384,7385,7386,7387,7388,7389,7390,7391, +7392,7393,7394,7395,7396,7397,7398,7399,7400,7401,7402,7403,7404,7405,7406,7407, +7408,7409,7410,7411,7412,7413,7414,7415,7416,7417,7418,7419,7420,7421,7422,7423, +7424,7425,7426,7427,7428,7429,7430,7431,7432,7433,7434,7435,7436,7437,7438,7439, +7440,7441,7442,7443,7444,7445,7446,7447,7448,7449,7450,7451,7452,7453,7454,7455, +7456,7457,7458,7459,7460,7461,7462,7463,7464,7465,7466,7467,7468,7469,7470,7471, +7472,7473,7474,7475,7476,7477,7478,7479,7480,7481,7482,7483,7484,7485,7486,7487, +7488,7489,7490,7491,7492,7493,7494,7495,7496,7497,7498,7499,7500,7501,7502,7503, +7504,7505,7506,7507,7508,7509,7510,7511,7512,7513,7514,7515,7516,7517,7518,7519, +7520,7521,7522,7523,7524,7525,7526,7527,7528,7529,7530,7531,7532,7533,7534,7535, +7536,7537,7538,7539,7540,7541,7542,7543,7544,7545,7546,7547,7548,7549,7550,7551, +7552,7553,7554,7555,7556,7557,7558,7559,7560,7561,7562,7563,7564,7565,7566,7567, +7568,7569,7570,7571,7572,7573,7574,7575,7576,7577,7578,7579,7580,7581,7582,7583, +7584,7585,7586,7587,7588,7589,7590,7591,7592,7593,7594,7595,7596,7597,7598,7599, +7600,7601,7602,7603,7604,7605,7606,7607,7608,7609,7610,7611,7612,7613,7614,7615, +7616,7617,7618,7619,7620,7621,7622,7623,7624,7625,7626,7627,7628,7629,7630,7631, +7632,7633,7634,7635,7636,7637,7638,7639,7640,7641,7642,7643,7644,7645,7646,7647, +7648,7649,7650,7651,7652,7653,7654,7655,7656,7657,7658,7659,7660,7661,7662,7663, +7664,7665,7666,7667,7668,7669,7670,7671,7672,7673,7674,7675,7676,7677,7678,7679, +7680,7681,7682,7683,7684,7685,7686,7687,7688,7689,7690,7691,7692,7693,7694,7695, +7696,7697,7698,7699,7700,7701,7702,7703,7704,7705,7706,7707,7708,7709,7710,7711, +7712,7713,7714,7715,7716,7717,7718,7719,7720,7721,7722,7723,7724,7725,7726,7727, +7728,7729,7730,7731,7732,7733,7734,7735,7736,7737,7738,7739,7740,7741,7742,7743, +7744,7745,7746,7747,7748,7749,7750,7751,7752,7753,7754,7755,7756,7757,7758,7759, +7760,7761,7762,7763,7764,7765,7766,7767,7768,7769,7770,7771,7772,7773,7774,7775, +7776,7777,7778,7779,7780,7781,7782,7783,7784,7785,7786,7787,7788,7789,7790,7791, +7792,7793,7794,7795,7796,7797,7798,7799,7800,7801,7802,7803,7804,7805,7806,7807, +7808,7809,7810,7811,7812,7813,7814,7815,7816,7817,7818,7819,7820,7821,7822,7823, +7824,7825,7826,7827,7828,7829,7830,7831,7832,7833,7834,7835,7836,7837,7838,7839, +7840,7841,7842,7843,7844,7845,7846,7847,7848,7849,7850,7851,7852,7853,7854,7855, +7856,7857,7858,7859,7860,7861,7862,7863,7864,7865,7866,7867,7868,7869,7870,7871, +7872,7873,7874,7875,7876,7877,7878,7879,7880,7881,7882,7883,7884,7885,7886,7887, +7888,7889,7890,7891,7892,7893,7894,7895,7896,7897,7898,7899,7900,7901,7902,7903, +7904,7905,7906,7907,7908,7909,7910,7911,7912,7913,7914,7915,7916,7917,7918,7919, +7920,7921,7922,7923,7924,7925,7926,7927,7928,7929,7930,7931,7932,7933,7934,7935, +7936,7937,7938,7939,7940,7941,7942,7943,7944,7945,7946,7947,7948,7949,7950,7951, +7952,7953,7954,7955,7956,7957,7958,7959,7960,7961,7962,7963,7964,7965,7966,7967, +7968,7969,7970,7971,7972,7973,7974,7975,7976,7977,7978,7979,7980,7981,7982,7983, +7984,7985,7986,7987,7988,7989,7990,7991,7992,7993,7994,7995,7996,7997,7998,7999, +8000,8001,8002,8003,8004,8005,8006,8007,8008,8009,8010,8011,8012,8013,8014,8015, +8016,8017,8018,8019,8020,8021,8022,8023,8024,8025,8026,8027,8028,8029,8030,8031, +8032,8033,8034,8035,8036,8037,8038,8039,8040,8041,8042,8043,8044,8045,8046,8047, +8048,8049,8050,8051,8052,8053,8054,8055,8056,8057,8058,8059,8060,8061,8062,8063, +8064,8065,8066,8067,8068,8069,8070,8071,8072,8073,8074,8075,8076,8077,8078,8079, +8080,8081,8082,8083,8084,8085,8086,8087,8088,8089,8090,8091,8092,8093,8094,8095, +8096,8097,8098,8099,8100,8101,8102,8103,8104,8105,8106,8107,8108,8109,8110,8111, +8112,8113,8114,8115,8116,8117,8118,8119,8120,8121,8122,8123,8124,8125,8126,8127, +8128,8129,8130,8131,8132,8133,8134,8135,8136,8137,8138,8139,8140,8141,8142,8143, +8144,8145,8146,8147,8148,8149,8150,8151,8152,8153,8154,8155,8156,8157,8158,8159, +8160,8161,8162,8163,8164,8165,8166,8167,8168,8169,8170,8171,8172,8173,8174,8175, +8176,8177,8178,8179,8180,8181,8182,8183,8184,8185,8186,8187,8188,8189,8190,8191, +8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8203,8204,8205,8206,8207, +8208,8209,8210,8211,8212,8213,8214,8215,8216,8217,8218,8219,8220,8221,8222,8223, +8224,8225,8226,8227,8228,8229,8230,8231,8232,8233,8234,8235,8236,8237,8238,8239, +8240,8241,8242,8243,8244,8245,8246,8247,8248,8249,8250,8251,8252,8253,8254,8255, +8256,8257,8258,8259,8260,8261,8262,8263,8264,8265,8266,8267,8268,8269,8270,8271, +8272,8273,8274,8275,8276,8277,8278,8279,8280,8281,8282,8283,8284,8285,8286,8287, +8288,8289,8290,8291,8292,8293,8294,8295,8296,8297,8298,8299,8300,8301,8302,8303, +8304,8305,8306,8307,8308,8309,8310,8311,8312,8313,8314,8315,8316,8317,8318,8319, +8320,8321,8322,8323,8324,8325,8326,8327,8328,8329,8330,8331,8332,8333,8334,8335, +8336,8337,8338,8339,8340,8341,8342,8343,8344,8345,8346,8347,8348,8349,8350,8351, +8352,8353,8354,8355,8356,8357,8358,8359,8360,8361,8362,8363,8364,8365,8366,8367, +8368,8369,8370,8371,8372,8373,8374,8375,8376,8377,8378,8379,8380,8381,8382,8383, +8384,8385,8386,8387,8388,8389,8390,8391,8392,8393,8394,8395,8396,8397,8398,8399, +8400,8401,8402,8403,8404,8405,8406,8407,8408,8409,8410,8411,8412,8413,8414,8415, +8416,8417,8418,8419,8420,8421,8422,8423,8424,8425,8426,8427,8428,8429,8430,8431, +8432,8433,8434,8435,8436,8437,8438,8439,8440,8441,8442,8443,8444,8445,8446,8447, +8448,8449,8450,8451,8452,8453,8454,8455,8456,8457,8458,8459,8460,8461,8462,8463, +8464,8465,8466,8467,8468,8469,8470,8471,8472,8473,8474,8475,8476,8477,8478,8479, +8480,8481,8482,8483,8484,8485,8486,8487,8488,8489,8490,8491,8492,8493,8494,8495, +8496,8497,8498,8499,8500,8501,8502,8503,8504,8505,8506,8507,8508,8509,8510,8511, +8512,8513,8514,8515,8516,8517,8518,8519,8520,8521,8522,8523,8524,8525,8526,8527, +8528,8529,8530,8531,8532,8533,8534,8535,8536,8537,8538,8539,8540,8541,8542,8543, +8544,8545,8546,8547,8548,8549,8550,8551,8552,8553,8554,8555,8556,8557,8558,8559, +8560,8561,8562,8563,8564,8565,8566,8567,8568,8569,8570,8571,8572,8573,8574,8575, +8576,8577,8578,8579,8580,8581,8582,8583,8584,8585,8586,8587,8588,8589,8590,8591, +8592,8593,8594,8595,8596,8597,8598,8599,8600,8601,8602,8603,8604,8605,8606,8607, +8608,8609,8610,8611,8612,8613,8614,8615,8616,8617,8618,8619,8620,8621,8622,8623, +8624,8625,8626,8627,8628,8629,8630,8631,8632,8633,8634,8635,8636,8637,8638,8639, +8640,8641,8642,8643,8644,8645,8646,8647,8648,8649,8650,8651,8652,8653,8654,8655, +8656,8657,8658,8659,8660,8661,8662,8663,8664,8665,8666,8667,8668,8669,8670,8671, +8672,8673,8674,8675,8676,8677,8678,8679,8680,8681,8682,8683,8684,8685,8686,8687, +8688,8689,8690,8691,8692,8693,8694,8695,8696,8697,8698,8699,8700,8701,8702,8703, +8704,8705,8706,8707,8708,8709,8710,8711,8712,8713,8714,8715,8716,8717,8718,8719, +8720,8721,8722,8723,8724,8725,8726,8727,8728,8729,8730,8731,8732,8733,8734,8735, +8736,8737,8738,8739,8740,8741) diff --git a/fanficdownloader/chardet/euckrprober.py b/fanficdownloader/chardet/euckrprober.py new file mode 100644 index 00000000..bd697ebf --- /dev/null +++ b/fanficdownloader/chardet/euckrprober.py @@ -0,0 +1,41 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from mbcharsetprober import MultiByteCharSetProber +from codingstatemachine import CodingStateMachine +from chardistribution import EUCKRDistributionAnalysis +from mbcssm import EUCKRSMModel + +class EUCKRProber(MultiByteCharSetProber): + def __init__(self): + MultiByteCharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(EUCKRSMModel) + self._mDistributionAnalyzer = EUCKRDistributionAnalysis() + self.reset() + + def get_charset_name(self): + return "EUC-KR" diff --git a/fanficdownloader/chardet/euctwfreq.py b/fanficdownloader/chardet/euctwfreq.py new file mode 100644 index 00000000..c0572095 --- /dev/null +++ b/fanficdownloader/chardet/euctwfreq.py @@ -0,0 +1,426 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +# EUCTW frequency table +# Converted from big5 work +# by Taiwan's Mandarin Promotion Council +# <http:#www.edu.tw:81/mandr/> + +# 128 --> 0.42261 +# 256 --> 0.57851 +# 512 --> 0.74851 +# 1024 --> 0.89384 +# 2048 --> 0.97583 +# +# Idea Distribution Ratio = 0.74851/(1-0.74851) =2.98 +# Random Distribution Ration = 512/(5401-512)=0.105 +# +# Typical Distribution Ratio about 25% of Ideal one, still much higher than RDR + +EUCTW_TYPICAL_DISTRIBUTION_RATIO = 0.75 + +# Char to FreqOrder table , +EUCTW_TABLE_SIZE = 8102 + +EUCTWCharToFreqOrder = ( \ + 1,1800,1506, 255,1431, 198, 9, 82, 6,7310, 177, 202,3615,1256,2808, 110, # 2742 +3735, 33,3241, 261, 76, 44,2113, 16,2931,2184,1176, 659,3868, 26,3404,2643, # 2758 +1198,3869,3313,4060, 410,2211, 302, 590, 361,1963, 8, 204, 58,4296,7311,1931, # 2774 + 63,7312,7313, 317,1614, 75, 222, 159,4061,2412,1480,7314,3500,3068, 224,2809, # 2790 +3616, 3, 10,3870,1471, 29,2774,1135,2852,1939, 873, 130,3242,1123, 312,7315, # 2806 +4297,2051, 507, 252, 682,7316, 142,1914, 124, 206,2932, 34,3501,3173, 64, 604, # 2822 +7317,2494,1976,1977, 155,1990, 645, 641,1606,7318,3405, 337, 72, 406,7319, 80, # 2838 + 630, 238,3174,1509, 263, 939,1092,2644, 756,1440,1094,3406, 449, 69,2969, 591, # 2854 + 179,2095, 471, 115,2034,1843, 60, 50,2970, 134, 806,1868, 734,2035,3407, 180, # 2870 + 995,1607, 156, 537,2893, 688,7320, 319,1305, 779,2144, 514,2374, 298,4298, 359, # 2886 +2495, 90,2707,1338, 663, 11, 906,1099,2545, 20,2436, 182, 532,1716,7321, 732, # 2902 +1376,4062,1311,1420,3175, 25,2312,1056, 113, 399, 382,1949, 242,3408,2467, 529, # 2918 +3243, 475,1447,3617,7322, 117, 21, 656, 810,1297,2295,2329,3502,7323, 126,4063, # 2934 + 706, 456, 150, 613,4299, 71,1118,2036,4064, 145,3069, 85, 835, 486,2114,1246, # 2950 +1426, 428, 727,1285,1015, 800, 106, 623, 303,1281,7324,2127,2354, 347,3736, 221, # 2966 +3503,3110,7325,1955,1153,4065, 83, 296,1199,3070, 192, 624, 93,7326, 822,1897, # 2982 +2810,3111, 795,2064, 991,1554,1542,1592, 27, 43,2853, 859, 139,1456, 860,4300, # 2998 + 437, 712,3871, 164,2392,3112, 695, 211,3017,2096, 195,3872,1608,3504,3505,3618, # 3014 +3873, 234, 811,2971,2097,3874,2229,1441,3506,1615,2375, 668,2076,1638, 305, 228, # 3030 +1664,4301, 467, 415,7327, 262,2098,1593, 239, 108, 300, 200,1033, 512,1247,2077, # 3046 +7328,7329,2173,3176,3619,2673, 593, 845,1062,3244, 88,1723,2037,3875,1950, 212, # 3062 + 266, 152, 149, 468,1898,4066,4302, 77, 187,7330,3018, 37, 5,2972,7331,3876, # 3078 +7332,7333, 39,2517,4303,2894,3177,2078, 55, 148, 74,4304, 545, 483,1474,1029, # 3094 +1665, 217,1869,1531,3113,1104,2645,4067, 24, 172,3507, 900,3877,3508,3509,4305, # 3110 + 32,1408,2811,1312, 329, 487,2355,2247,2708, 784,2674, 4,3019,3314,1427,1788, # 3126 + 188, 109, 499,7334,3620,1717,1789, 888,1217,3020,4306,7335,3510,7336,3315,1520, # 3142 +3621,3878, 196,1034, 775,7337,7338, 929,1815, 249, 439, 38,7339,1063,7340, 794, # 3158 +3879,1435,2296, 46, 178,3245,2065,7341,2376,7342, 214,1709,4307, 804, 35, 707, # 3174 + 324,3622,1601,2546, 140, 459,4068,7343,7344,1365, 839, 272, 978,2257,2572,3409, # 3190 +2128,1363,3623,1423, 697, 100,3071, 48, 70,1231, 495,3114,2193,7345,1294,7346, # 3206 +2079, 462, 586,1042,3246, 853, 256, 988, 185,2377,3410,1698, 434,1084,7347,3411, # 3222 + 314,2615,2775,4308,2330,2331, 569,2280, 637,1816,2518, 757,1162,1878,1616,3412, # 3238 + 287,1577,2115, 768,4309,1671,2854,3511,2519,1321,3737, 909,2413,7348,4069, 933, # 3254 +3738,7349,2052,2356,1222,4310, 765,2414,1322, 786,4311,7350,1919,1462,1677,2895, # 3270 +1699,7351,4312,1424,2437,3115,3624,2590,3316,1774,1940,3413,3880,4070, 309,1369, # 3286 +1130,2812, 364,2230,1653,1299,3881,3512,3882,3883,2646, 525,1085,3021, 902,2000, # 3302 +1475, 964,4313, 421,1844,1415,1057,2281, 940,1364,3116, 376,4314,4315,1381, 7, # 3318 +2520, 983,2378, 336,1710,2675,1845, 321,3414, 559,1131,3022,2742,1808,1132,1313, # 3334 + 265,1481,1857,7352, 352,1203,2813,3247, 167,1089, 420,2814, 776, 792,1724,3513, # 3350 +4071,2438,3248,7353,4072,7354, 446, 229, 333,2743, 901,3739,1200,1557,4316,2647, # 3366 +1920, 395,2744,2676,3740,4073,1835, 125, 916,3178,2616,4317,7355,7356,3741,7357, # 3382 +7358,7359,4318,3117,3625,1133,2547,1757,3415,1510,2313,1409,3514,7360,2145, 438, # 3398 +2591,2896,2379,3317,1068, 958,3023, 461, 311,2855,2677,4074,1915,3179,4075,1978, # 3414 + 383, 750,2745,2617,4076, 274, 539, 385,1278,1442,7361,1154,1964, 384, 561, 210, # 3430 + 98,1295,2548,3515,7362,1711,2415,1482,3416,3884,2897,1257, 129,7363,3742, 642, # 3446 + 523,2776,2777,2648,7364, 141,2231,1333, 68, 176, 441, 876, 907,4077, 603,2592, # 3462 + 710, 171,3417, 404, 549, 18,3118,2393,1410,3626,1666,7365,3516,4319,2898,4320, # 3478 +7366,2973, 368,7367, 146, 366, 99, 871,3627,1543, 748, 807,1586,1185, 22,2258, # 3494 + 379,3743,3180,7368,3181, 505,1941,2618,1991,1382,2314,7369, 380,2357, 218, 702, # 3510 +1817,1248,3418,3024,3517,3318,3249,7370,2974,3628, 930,3250,3744,7371, 59,7372, # 3526 + 585, 601,4078, 497,3419,1112,1314,4321,1801,7373,1223,1472,2174,7374, 749,1836, # 3542 + 690,1899,3745,1772,3885,1476, 429,1043,1790,2232,2116, 917,4079, 447,1086,1629, # 3558 +7375, 556,7376,7377,2020,1654, 844,1090, 105, 550, 966,1758,2815,1008,1782, 686, # 3574 +1095,7378,2282, 793,1602,7379,3518,2593,4322,4080,2933,2297,4323,3746, 980,2496, # 3590 + 544, 353, 527,4324, 908,2678,2899,7380, 381,2619,1942,1348,7381,1341,1252, 560, # 3606 +3072,7382,3420,2856,7383,2053, 973, 886,2080, 143,4325,7384,7385, 157,3886, 496, # 3622 +4081, 57, 840, 540,2038,4326,4327,3421,2117,1445, 970,2259,1748,1965,2081,4082, # 3638 +3119,1234,1775,3251,2816,3629, 773,1206,2129,1066,2039,1326,3887,1738,1725,4083, # 3654 + 279,3120, 51,1544,2594, 423,1578,2130,2066, 173,4328,1879,7386,7387,1583, 264, # 3670 + 610,3630,4329,2439, 280, 154,7388,7389,7390,1739, 338,1282,3073, 693,2857,1411, # 3686 +1074,3747,2440,7391,4330,7392,7393,1240, 952,2394,7394,2900,1538,2679, 685,1483, # 3702 +4084,2468,1436, 953,4085,2054,4331, 671,2395, 79,4086,2441,3252, 608, 567,2680, # 3718 +3422,4087,4088,1691, 393,1261,1791,2396,7395,4332,7396,7397,7398,7399,1383,1672, # 3734 +3748,3182,1464, 522,1119, 661,1150, 216, 675,4333,3888,1432,3519, 609,4334,2681, # 3750 +2397,7400,7401,7402,4089,3025, 0,7403,2469, 315, 231,2442, 301,3319,4335,2380, # 3766 +7404, 233,4090,3631,1818,4336,4337,7405, 96,1776,1315,2082,7406, 257,7407,1809, # 3782 +3632,2709,1139,1819,4091,2021,1124,2163,2778,1777,2649,7408,3074, 363,1655,3183, # 3798 +7409,2975,7410,7411,7412,3889,1567,3890, 718, 103,3184, 849,1443, 341,3320,2934, # 3814 +1484,7413,1712, 127, 67, 339,4092,2398, 679,1412, 821,7414,7415, 834, 738, 351, # 3830 +2976,2146, 846, 235,1497,1880, 418,1992,3749,2710, 186,1100,2147,2746,3520,1545, # 3846 +1355,2935,2858,1377, 583,3891,4093,2573,2977,7416,1298,3633,1078,2549,3634,2358, # 3862 + 78,3750,3751, 267,1289,2099,2001,1594,4094, 348, 369,1274,2194,2175,1837,4338, # 3878 +1820,2817,3635,2747,2283,2002,4339,2936,2748, 144,3321, 882,4340,3892,2749,3423, # 3894 +4341,2901,7417,4095,1726, 320,7418,3893,3026, 788,2978,7419,2818,1773,1327,2859, # 3910 +3894,2819,7420,1306,4342,2003,1700,3752,3521,2359,2650, 787,2022, 506, 824,3636, # 3926 + 534, 323,4343,1044,3322,2023,1900, 946,3424,7421,1778,1500,1678,7422,1881,4344, # 3942 + 165, 243,4345,3637,2521, 123, 683,4096, 764,4346, 36,3895,1792, 589,2902, 816, # 3958 + 626,1667,3027,2233,1639,1555,1622,3753,3896,7423,3897,2860,1370,1228,1932, 891, # 3974 +2083,2903, 304,4097,7424, 292,2979,2711,3522, 691,2100,4098,1115,4347, 118, 662, # 3990 +7425, 611,1156, 854,2381,1316,2861, 2, 386, 515,2904,7426,7427,3253, 868,2234, # 4006 +1486, 855,2651, 785,2212,3028,7428,1040,3185,3523,7429,3121, 448,7430,1525,7431, # 4022 +2164,4348,7432,3754,7433,4099,2820,3524,3122, 503, 818,3898,3123,1568, 814, 676, # 4038 +1444, 306,1749,7434,3755,1416,1030, 197,1428, 805,2821,1501,4349,7435,7436,7437, # 4054 +1993,7438,4350,7439,7440,2195, 13,2779,3638,2980,3124,1229,1916,7441,3756,2131, # 4070 +7442,4100,4351,2399,3525,7443,2213,1511,1727,1120,7444,7445, 646,3757,2443, 307, # 4086 +7446,7447,1595,3186,7448,7449,7450,3639,1113,1356,3899,1465,2522,2523,7451, 519, # 4102 +7452, 128,2132, 92,2284,1979,7453,3900,1512, 342,3125,2196,7454,2780,2214,1980, # 4118 +3323,7455, 290,1656,1317, 789, 827,2360,7456,3758,4352, 562, 581,3901,7457, 401, # 4134 +4353,2248, 94,4354,1399,2781,7458,1463,2024,4355,3187,1943,7459, 828,1105,4101, # 4150 +1262,1394,7460,4102, 605,4356,7461,1783,2862,7462,2822, 819,2101, 578,2197,2937, # 4166 +7463,1502, 436,3254,4103,3255,2823,3902,2905,3425,3426,7464,2712,2315,7465,7466, # 4182 +2332,2067, 23,4357, 193, 826,3759,2102, 699,1630,4104,3075, 390,1793,1064,3526, # 4198 +7467,1579,3076,3077,1400,7468,4105,1838,1640,2863,7469,4358,4359, 137,4106, 598, # 4214 +3078,1966, 780, 104, 974,2938,7470, 278, 899, 253, 402, 572, 504, 493,1339,7471, # 4230 +3903,1275,4360,2574,2550,7472,3640,3029,3079,2249, 565,1334,2713, 863, 41,7473, # 4246 +7474,4361,7475,1657,2333, 19, 463,2750,4107, 606,7476,2981,3256,1087,2084,1323, # 4262 +2652,2982,7477,1631,1623,1750,4108,2682,7478,2864, 791,2714,2653,2334, 232,2416, # 4278 +7479,2983,1498,7480,2654,2620, 755,1366,3641,3257,3126,2025,1609, 119,1917,3427, # 4294 + 862,1026,4109,7481,3904,3760,4362,3905,4363,2260,1951,2470,7482,1125, 817,4110, # 4310 +4111,3906,1513,1766,2040,1487,4112,3030,3258,2824,3761,3127,7483,7484,1507,7485, # 4326 +2683, 733, 40,1632,1106,2865, 345,4113, 841,2524, 230,4364,2984,1846,3259,3428, # 4342 +7486,1263, 986,3429,7487, 735, 879, 254,1137, 857, 622,1300,1180,1388,1562,3907, # 4358 +3908,2939, 967,2751,2655,1349, 592,2133,1692,3324,2985,1994,4114,1679,3909,1901, # 4374 +2185,7488, 739,3642,2715,1296,1290,7489,4115,2198,2199,1921,1563,2595,2551,1870, # 4390 +2752,2986,7490, 435,7491, 343,1108, 596, 17,1751,4365,2235,3430,3643,7492,4366, # 4406 + 294,3527,2940,1693, 477, 979, 281,2041,3528, 643,2042,3644,2621,2782,2261,1031, # 4422 +2335,2134,2298,3529,4367, 367,1249,2552,7493,3530,7494,4368,1283,3325,2004, 240, # 4438 +1762,3326,4369,4370, 836,1069,3128, 474,7495,2148,2525, 268,3531,7496,3188,1521, # 4454 +1284,7497,1658,1546,4116,7498,3532,3533,7499,4117,3327,2684,1685,4118, 961,1673, # 4470 +2622, 190,2005,2200,3762,4371,4372,7500, 570,2497,3645,1490,7501,4373,2623,3260, # 4486 +1956,4374, 584,1514, 396,1045,1944,7502,4375,1967,2444,7503,7504,4376,3910, 619, # 4502 +7505,3129,3261, 215,2006,2783,2553,3189,4377,3190,4378, 763,4119,3763,4379,7506, # 4518 +7507,1957,1767,2941,3328,3646,1174, 452,1477,4380,3329,3130,7508,2825,1253,2382, # 4534 +2186,1091,2285,4120, 492,7509, 638,1169,1824,2135,1752,3911, 648, 926,1021,1324, # 4550 +4381, 520,4382, 997, 847,1007, 892,4383,3764,2262,1871,3647,7510,2400,1784,4384, # 4566 +1952,2942,3080,3191,1728,4121,2043,3648,4385,2007,1701,3131,1551, 30,2263,4122, # 4582 +7511,2026,4386,3534,7512, 501,7513,4123, 594,3431,2165,1821,3535,3432,3536,3192, # 4598 + 829,2826,4124,7514,1680,3132,1225,4125,7515,3262,4387,4126,3133,2336,7516,4388, # 4614 +4127,7517,3912,3913,7518,1847,2383,2596,3330,7519,4389, 374,3914, 652,4128,4129, # 4630 + 375,1140, 798,7520,7521,7522,2361,4390,2264, 546,1659, 138,3031,2445,4391,7523, # 4646 +2250, 612,1848, 910, 796,3765,1740,1371, 825,3766,3767,7524,2906,2554,7525, 692, # 4662 + 444,3032,2624, 801,4392,4130,7526,1491, 244,1053,3033,4131,4132, 340,7527,3915, # 4678 +1041,2987, 293,1168, 87,1357,7528,1539, 959,7529,2236, 721, 694,4133,3768, 219, # 4694 +1478, 644,1417,3331,2656,1413,1401,1335,1389,3916,7530,7531,2988,2362,3134,1825, # 4710 + 730,1515, 184,2827, 66,4393,7532,1660,2943, 246,3332, 378,1457, 226,3433, 975, # 4726 +3917,2944,1264,3537, 674, 696,7533, 163,7534,1141,2417,2166, 713,3538,3333,4394, # 4742 +3918,7535,7536,1186, 15,7537,1079,1070,7538,1522,3193,3539, 276,1050,2716, 758, # 4758 +1126, 653,2945,3263,7539,2337, 889,3540,3919,3081,2989, 903,1250,4395,3920,3434, # 4774 +3541,1342,1681,1718, 766,3264, 286, 89,2946,3649,7540,1713,7541,2597,3334,2990, # 4790 +7542,2947,2215,3194,2866,7543,4396,2498,2526, 181, 387,1075,3921, 731,2187,3335, # 4806 +7544,3265, 310, 313,3435,2299, 770,4134, 54,3034, 189,4397,3082,3769,3922,7545, # 4822 +1230,1617,1849, 355,3542,4135,4398,3336, 111,4136,3650,1350,3135,3436,3035,4137, # 4838 +2149,3266,3543,7546,2784,3923,3924,2991, 722,2008,7547,1071, 247,1207,2338,2471, # 4854 +1378,4399,2009, 864,1437,1214,4400, 373,3770,1142,2216, 667,4401, 442,2753,2555, # 4870 +3771,3925,1968,4138,3267,1839, 837, 170,1107, 934,1336,1882,7548,7549,2118,4139, # 4886 +2828, 743,1569,7550,4402,4140, 582,2384,1418,3437,7551,1802,7552, 357,1395,1729, # 4902 +3651,3268,2418,1564,2237,7553,3083,3772,1633,4403,1114,2085,4141,1532,7554, 482, # 4918 +2446,4404,7555,7556,1492, 833,1466,7557,2717,3544,1641,2829,7558,1526,1272,3652, # 4934 +4142,1686,1794, 416,2556,1902,1953,1803,7559,3773,2785,3774,1159,2316,7560,2867, # 4950 +4405,1610,1584,3036,2419,2754, 443,3269,1163,3136,7561,7562,3926,7563,4143,2499, # 4966 +3037,4406,3927,3137,2103,1647,3545,2010,1872,4144,7564,4145, 431,3438,7565, 250, # 4982 + 97, 81,4146,7566,1648,1850,1558, 160, 848,7567, 866, 740,1694,7568,2201,2830, # 4998 +3195,4147,4407,3653,1687, 950,2472, 426, 469,3196,3654,3655,3928,7569,7570,1188, # 5014 + 424,1995, 861,3546,4148,3775,2202,2685, 168,1235,3547,4149,7571,2086,1674,4408, # 5030 +3337,3270, 220,2557,1009,7572,3776, 670,2992, 332,1208, 717,7573,7574,3548,2447, # 5046 +3929,3338,7575, 513,7576,1209,2868,3339,3138,4409,1080,7577,7578,7579,7580,2527, # 5062 +3656,3549, 815,1587,3930,3931,7581,3550,3439,3777,1254,4410,1328,3038,1390,3932, # 5078 +1741,3933,3778,3934,7582, 236,3779,2448,3271,7583,7584,3657,3780,1273,3781,4411, # 5094 +7585, 308,7586,4412, 245,4413,1851,2473,1307,2575, 430, 715,2136,2449,7587, 270, # 5110 + 199,2869,3935,7588,3551,2718,1753, 761,1754, 725,1661,1840,4414,3440,3658,7589, # 5126 +7590, 587, 14,3272, 227,2598, 326, 480,2265, 943,2755,3552, 291, 650,1883,7591, # 5142 +1702,1226, 102,1547, 62,3441, 904,4415,3442,1164,4150,7592,7593,1224,1548,2756, # 5158 + 391, 498,1493,7594,1386,1419,7595,2055,1177,4416, 813, 880,1081,2363, 566,1145, # 5174 +4417,2286,1001,1035,2558,2599,2238, 394,1286,7596,7597,2068,7598, 86,1494,1730, # 5190 +3936, 491,1588, 745, 897,2948, 843,3340,3937,2757,2870,3273,1768, 998,2217,2069, # 5206 + 397,1826,1195,1969,3659,2993,3341, 284,7599,3782,2500,2137,2119,1903,7600,3938, # 5222 +2150,3939,4151,1036,3443,1904, 114,2559,4152, 209,1527,7601,7602,2949,2831,2625, # 5238 +2385,2719,3139, 812,2560,7603,3274,7604,1559, 737,1884,3660,1210, 885, 28,2686, # 5254 +3553,3783,7605,4153,1004,1779,4418,7606, 346,1981,2218,2687,4419,3784,1742, 797, # 5270 +1642,3940,1933,1072,1384,2151, 896,3941,3275,3661,3197,2871,3554,7607,2561,1958, # 5286 +4420,2450,1785,7608,7609,7610,3942,4154,1005,1308,3662,4155,2720,4421,4422,1528, # 5302 +2600, 161,1178,4156,1982, 987,4423,1101,4157, 631,3943,1157,3198,2420,1343,1241, # 5318 +1016,2239,2562, 372, 877,2339,2501,1160, 555,1934, 911,3944,7611, 466,1170, 169, # 5334 +1051,2907,2688,3663,2474,2994,1182,2011,2563,1251,2626,7612, 992,2340,3444,1540, # 5350 +2721,1201,2070,2401,1996,2475,7613,4424, 528,1922,2188,1503,1873,1570,2364,3342, # 5366 +3276,7614, 557,1073,7615,1827,3445,2087,2266,3140,3039,3084, 767,3085,2786,4425, # 5382 +1006,4158,4426,2341,1267,2176,3664,3199, 778,3945,3200,2722,1597,2657,7616,4427, # 5398 +7617,3446,7618,7619,7620,3277,2689,1433,3278, 131, 95,1504,3946, 723,4159,3141, # 5414 +1841,3555,2758,2189,3947,2027,2104,3665,7621,2995,3948,1218,7622,3343,3201,3949, # 5430 +4160,2576, 248,1634,3785, 912,7623,2832,3666,3040,3786, 654, 53,7624,2996,7625, # 5446 +1688,4428, 777,3447,1032,3950,1425,7626, 191, 820,2120,2833, 971,4429, 931,3202, # 5462 + 135, 664, 783,3787,1997, 772,2908,1935,3951,3788,4430,2909,3203, 282,2723, 640, # 5478 +1372,3448,1127, 922, 325,3344,7627,7628, 711,2044,7629,7630,3952,2219,2787,1936, # 5494 +3953,3345,2220,2251,3789,2300,7631,4431,3790,1258,3279,3954,3204,2138,2950,3955, # 5510 +3956,7632,2221, 258,3205,4432, 101,1227,7633,3280,1755,7634,1391,3281,7635,2910, # 5526 +2056, 893,7636,7637,7638,1402,4161,2342,7639,7640,3206,3556,7641,7642, 878,1325, # 5542 +1780,2788,4433, 259,1385,2577, 744,1183,2267,4434,7643,3957,2502,7644, 684,1024, # 5558 +4162,7645, 472,3557,3449,1165,3282,3958,3959, 322,2152, 881, 455,1695,1152,1340, # 5574 + 660, 554,2153,4435,1058,4436,4163, 830,1065,3346,3960,4437,1923,7646,1703,1918, # 5590 +7647, 932,2268, 122,7648,4438, 947, 677,7649,3791,2627, 297,1905,1924,2269,4439, # 5606 +2317,3283,7650,7651,4164,7652,4165, 84,4166, 112, 989,7653, 547,1059,3961, 701, # 5622 +3558,1019,7654,4167,7655,3450, 942, 639, 457,2301,2451, 993,2951, 407, 851, 494, # 5638 +4440,3347, 927,7656,1237,7657,2421,3348, 573,4168, 680, 921,2911,1279,1874, 285, # 5654 + 790,1448,1983, 719,2167,7658,7659,4441,3962,3963,1649,7660,1541, 563,7661,1077, # 5670 +7662,3349,3041,3451, 511,2997,3964,3965,3667,3966,1268,2564,3350,3207,4442,4443, # 5686 +7663, 535,1048,1276,1189,2912,2028,3142,1438,1373,2834,2952,1134,2012,7664,4169, # 5702 +1238,2578,3086,1259,7665, 700,7666,2953,3143,3668,4170,7667,4171,1146,1875,1906, # 5718 +4444,2601,3967, 781,2422, 132,1589, 203, 147, 273,2789,2402, 898,1786,2154,3968, # 5734 +3969,7668,3792,2790,7669,7670,4445,4446,7671,3208,7672,1635,3793, 965,7673,1804, # 5750 +2690,1516,3559,1121,1082,1329,3284,3970,1449,3794, 65,1128,2835,2913,2759,1590, # 5766 +3795,7674,7675, 12,2658, 45, 976,2579,3144,4447, 517,2528,1013,1037,3209,7676, # 5782 +3796,2836,7677,3797,7678,3452,7679,2602, 614,1998,2318,3798,3087,2724,2628,7680, # 5798 +2580,4172, 599,1269,7681,1810,3669,7682,2691,3088, 759,1060, 489,1805,3351,3285, # 5814 +1358,7683,7684,2386,1387,1215,2629,2252, 490,7685,7686,4173,1759,2387,2343,7687, # 5830 +4448,3799,1907,3971,2630,1806,3210,4449,3453,3286,2760,2344, 874,7688,7689,3454, # 5846 +3670,1858, 91,2914,3671,3042,3800,4450,7690,3145,3972,2659,7691,3455,1202,1403, # 5862 +3801,2954,2529,1517,2503,4451,3456,2504,7692,4452,7693,2692,1885,1495,1731,3973, # 5878 +2365,4453,7694,2029,7695,7696,3974,2693,1216, 237,2581,4174,2319,3975,3802,4454, # 5894 +4455,2694,3560,3457, 445,4456,7697,7698,7699,7700,2761, 61,3976,3672,1822,3977, # 5910 +7701, 687,2045, 935, 925, 405,2660, 703,1096,1859,2725,4457,3978,1876,1367,2695, # 5926 +3352, 918,2105,1781,2476, 334,3287,1611,1093,4458, 564,3146,3458,3673,3353, 945, # 5942 +2631,2057,4459,7702,1925, 872,4175,7703,3459,2696,3089, 349,4176,3674,3979,4460, # 5958 +3803,4177,3675,2155,3980,4461,4462,4178,4463,2403,2046, 782,3981, 400, 251,4179, # 5974 +1624,7704,7705, 277,3676, 299,1265, 476,1191,3804,2121,4180,4181,1109, 205,7706, # 5990 +2582,1000,2156,3561,1860,7707,7708,7709,4464,7710,4465,2565, 107,2477,2157,3982, # 6006 +3460,3147,7711,1533, 541,1301, 158, 753,4182,2872,3562,7712,1696, 370,1088,4183, # 6022 +4466,3563, 579, 327, 440, 162,2240, 269,1937,1374,3461, 968,3043, 56,1396,3090, # 6038 +2106,3288,3354,7713,1926,2158,4467,2998,7714,3564,7715,7716,3677,4468,2478,7717, # 6054 +2791,7718,1650,4469,7719,2603,7720,7721,3983,2661,3355,1149,3356,3984,3805,3985, # 6070 +7722,1076, 49,7723, 951,3211,3289,3290, 450,2837, 920,7724,1811,2792,2366,4184, # 6086 +1908,1138,2367,3806,3462,7725,3212,4470,1909,1147,1518,2423,4471,3807,7726,4472, # 6102 +2388,2604, 260,1795,3213,7727,7728,3808,3291, 708,7729,3565,1704,7730,3566,1351, # 6118 +1618,3357,2999,1886, 944,4185,3358,4186,3044,3359,4187,7731,3678, 422, 413,1714, # 6134 +3292, 500,2058,2345,4188,2479,7732,1344,1910, 954,7733,1668,7734,7735,3986,2404, # 6150 +4189,3567,3809,4190,7736,2302,1318,2505,3091, 133,3092,2873,4473, 629, 31,2838, # 6166 +2697,3810,4474, 850, 949,4475,3987,2955,1732,2088,4191,1496,1852,7737,3988, 620, # 6182 +3214, 981,1242,3679,3360,1619,3680,1643,3293,2139,2452,1970,1719,3463,2168,7738, # 6198 +3215,7739,7740,3361,1828,7741,1277,4476,1565,2047,7742,1636,3568,3093,7743, 869, # 6214 +2839, 655,3811,3812,3094,3989,3000,3813,1310,3569,4477,7744,7745,7746,1733, 558, # 6230 +4478,3681, 335,1549,3045,1756,4192,3682,1945,3464,1829,1291,1192, 470,2726,2107, # 6246 +2793, 913,1054,3990,7747,1027,7748,3046,3991,4479, 982,2662,3362,3148,3465,3216, # 6262 +3217,1946,2794,7749, 571,4480,7750,1830,7751,3570,2583,1523,2424,7752,2089, 984, # 6278 +4481,3683,1959,7753,3684, 852, 923,2795,3466,3685, 969,1519, 999,2048,2320,1705, # 6294 +7754,3095, 615,1662, 151, 597,3992,2405,2321,1049, 275,4482,3686,4193, 568,3687, # 6310 +3571,2480,4194,3688,7755,2425,2270, 409,3218,7756,1566,2874,3467,1002, 769,2840, # 6326 + 194,2090,3149,3689,2222,3294,4195, 628,1505,7757,7758,1763,2177,3001,3993, 521, # 6342 +1161,2584,1787,2203,2406,4483,3994,1625,4196,4197, 412, 42,3096, 464,7759,2632, # 6358 +4484,3363,1760,1571,2875,3468,2530,1219,2204,3814,2633,2140,2368,4485,4486,3295, # 6374 +1651,3364,3572,7760,7761,3573,2481,3469,7762,3690,7763,7764,2271,2091, 460,7765, # 6390 +4487,7766,3002, 962, 588,3574, 289,3219,2634,1116, 52,7767,3047,1796,7768,7769, # 6406 +7770,1467,7771,1598,1143,3691,4198,1984,1734,1067,4488,1280,3365, 465,4489,1572, # 6422 + 510,7772,1927,2241,1812,1644,3575,7773,4490,3692,7774,7775,2663,1573,1534,7776, # 6438 +7777,4199, 536,1807,1761,3470,3815,3150,2635,7778,7779,7780,4491,3471,2915,1911, # 6454 +2796,7781,3296,1122, 377,3220,7782, 360,7783,7784,4200,1529, 551,7785,2059,3693, # 6470 +1769,2426,7786,2916,4201,3297,3097,2322,2108,2030,4492,1404, 136,1468,1479, 672, # 6486 +1171,3221,2303, 271,3151,7787,2762,7788,2049, 678,2727, 865,1947,4493,7789,2013, # 6502 +3995,2956,7790,2728,2223,1397,3048,3694,4494,4495,1735,2917,3366,3576,7791,3816, # 6518 + 509,2841,2453,2876,3817,7792,7793,3152,3153,4496,4202,2531,4497,2304,1166,1010, # 6534 + 552, 681,1887,7794,7795,2957,2958,3996,1287,1596,1861,3154, 358, 453, 736, 175, # 6550 + 478,1117, 905,1167,1097,7796,1853,1530,7797,1706,7798,2178,3472,2287,3695,3473, # 6566 +3577,4203,2092,4204,7799,3367,1193,2482,4205,1458,2190,2205,1862,1888,1421,3298, # 6582 +2918,3049,2179,3474, 595,2122,7800,3997,7801,7802,4206,1707,2636, 223,3696,1359, # 6598 + 751,3098, 183,3475,7803,2797,3003, 419,2369, 633, 704,3818,2389, 241,7804,7805, # 6614 +7806, 838,3004,3697,2272,2763,2454,3819,1938,2050,3998,1309,3099,2242,1181,7807, # 6630 +1136,2206,3820,2370,1446,4207,2305,4498,7808,7809,4208,1055,2605, 484,3698,7810, # 6646 +3999, 625,4209,2273,3368,1499,4210,4000,7811,4001,4211,3222,2274,2275,3476,7812, # 6662 +7813,2764, 808,2606,3699,3369,4002,4212,3100,2532, 526,3370,3821,4213, 955,7814, # 6678 +1620,4214,2637,2427,7815,1429,3700,1669,1831, 994, 928,7816,3578,1260,7817,7818, # 6694 +7819,1948,2288, 741,2919,1626,4215,2729,2455, 867,1184, 362,3371,1392,7820,7821, # 6710 +4003,4216,1770,1736,3223,2920,4499,4500,1928,2698,1459,1158,7822,3050,3372,2877, # 6726 +1292,1929,2506,2842,3701,1985,1187,2071,2014,2607,4217,7823,2566,2507,2169,3702, # 6742 +2483,3299,7824,3703,4501,7825,7826, 666,1003,3005,1022,3579,4218,7827,4502,1813, # 6758 +2253, 574,3822,1603, 295,1535, 705,3823,4219, 283, 858, 417,7828,7829,3224,4503, # 6774 +4504,3051,1220,1889,1046,2276,2456,4004,1393,1599, 689,2567, 388,4220,7830,2484, # 6790 + 802,7831,2798,3824,2060,1405,2254,7832,4505,3825,2109,1052,1345,3225,1585,7833, # 6806 + 809,7834,7835,7836, 575,2730,3477, 956,1552,1469,1144,2323,7837,2324,1560,2457, # 6822 +3580,3226,4005, 616,2207,3155,2180,2289,7838,1832,7839,3478,4506,7840,1319,3704, # 6838 +3705,1211,3581,1023,3227,1293,2799,7841,7842,7843,3826, 607,2306,3827, 762,2878, # 6854 +1439,4221,1360,7844,1485,3052,7845,4507,1038,4222,1450,2061,2638,4223,1379,4508, # 6870 +2585,7846,7847,4224,1352,1414,2325,2921,1172,7848,7849,3828,3829,7850,1797,1451, # 6886 +7851,7852,7853,7854,2922,4006,4007,2485,2346, 411,4008,4009,3582,3300,3101,4509, # 6902 +1561,2664,1452,4010,1375,7855,7856, 47,2959, 316,7857,1406,1591,2923,3156,7858, # 6918 +1025,2141,3102,3157, 354,2731, 884,2224,4225,2407, 508,3706, 726,3583, 996,2428, # 6934 +3584, 729,7859, 392,2191,1453,4011,4510,3707,7860,7861,2458,3585,2608,1675,2800, # 6950 + 919,2347,2960,2348,1270,4511,4012, 73,7862,7863, 647,7864,3228,2843,2255,1550, # 6966 +1346,3006,7865,1332, 883,3479,7866,7867,7868,7869,3301,2765,7870,1212, 831,1347, # 6982 +4226,4512,2326,3830,1863,3053, 720,3831,4513,4514,3832,7871,4227,7872,7873,4515, # 6998 +7874,7875,1798,4516,3708,2609,4517,3586,1645,2371,7876,7877,2924, 669,2208,2665, # 7014 +2429,7878,2879,7879,7880,1028,3229,7881,4228,2408,7882,2256,1353,7883,7884,4518, # 7030 +3158, 518,7885,4013,7886,4229,1960,7887,2142,4230,7888,7889,3007,2349,2350,3833, # 7046 + 516,1833,1454,4014,2699,4231,4519,2225,2610,1971,1129,3587,7890,2766,7891,2961, # 7062 +1422, 577,1470,3008,1524,3373,7892,7893, 432,4232,3054,3480,7894,2586,1455,2508, # 7078 +2226,1972,1175,7895,1020,2732,4015,3481,4520,7896,2733,7897,1743,1361,3055,3482, # 7094 +2639,4016,4233,4521,2290, 895, 924,4234,2170, 331,2243,3056, 166,1627,3057,1098, # 7110 +7898,1232,2880,2227,3374,4522, 657, 403,1196,2372, 542,3709,3375,1600,4235,3483, # 7126 +7899,4523,2767,3230, 576, 530,1362,7900,4524,2533,2666,3710,4017,7901, 842,3834, # 7142 +7902,2801,2031,1014,4018, 213,2700,3376, 665, 621,4236,7903,3711,2925,2430,7904, # 7158 +2431,3302,3588,3377,7905,4237,2534,4238,4525,3589,1682,4239,3484,1380,7906, 724, # 7174 +2277, 600,1670,7907,1337,1233,4526,3103,2244,7908,1621,4527,7909, 651,4240,7910, # 7190 +1612,4241,2611,7911,2844,7912,2734,2307,3058,7913, 716,2459,3059, 174,1255,2701, # 7206 +4019,3590, 548,1320,1398, 728,4020,1574,7914,1890,1197,3060,4021,7915,3061,3062, # 7222 +3712,3591,3713, 747,7916, 635,4242,4528,7917,7918,7919,4243,7920,7921,4529,7922, # 7238 +3378,4530,2432, 451,7923,3714,2535,2072,4244,2735,4245,4022,7924,1764,4531,7925, # 7254 +4246, 350,7926,2278,2390,2486,7927,4247,4023,2245,1434,4024, 488,4532, 458,4248, # 7270 +4025,3715, 771,1330,2391,3835,2568,3159,2159,2409,1553,2667,3160,4249,7928,2487, # 7286 +2881,2612,1720,2702,4250,3379,4533,7929,2536,4251,7930,3231,4252,2768,7931,2015, # 7302 +2736,7932,1155,1017,3716,3836,7933,3303,2308, 201,1864,4253,1430,7934,4026,7935, # 7318 +7936,7937,7938,7939,4254,1604,7940, 414,1865, 371,2587,4534,4535,3485,2016,3104, # 7334 +4536,1708, 960,4255, 887, 389,2171,1536,1663,1721,7941,2228,4027,2351,2926,1580, # 7350 +7942,7943,7944,1744,7945,2537,4537,4538,7946,4539,7947,2073,7948,7949,3592,3380, # 7366 +2882,4256,7950,4257,2640,3381,2802, 673,2703,2460, 709,3486,4028,3593,4258,7951, # 7382 +1148, 502, 634,7952,7953,1204,4540,3594,1575,4541,2613,3717,7954,3718,3105, 948, # 7398 +3232, 121,1745,3837,1110,7955,4259,3063,2509,3009,4029,3719,1151,1771,3838,1488, # 7414 +4030,1986,7956,2433,3487,7957,7958,2093,7959,4260,3839,1213,1407,2803, 531,2737, # 7430 +2538,3233,1011,1537,7960,2769,4261,3106,1061,7961,3720,3721,1866,2883,7962,2017, # 7446 + 120,4262,4263,2062,3595,3234,2309,3840,2668,3382,1954,4542,7963,7964,3488,1047, # 7462 +2704,1266,7965,1368,4543,2845, 649,3383,3841,2539,2738,1102,2846,2669,7966,7967, # 7478 +1999,7968,1111,3596,2962,7969,2488,3842,3597,2804,1854,3384,3722,7970,7971,3385, # 7494 +2410,2884,3304,3235,3598,7972,2569,7973,3599,2805,4031,1460, 856,7974,3600,7975, # 7510 +2885,2963,7976,2886,3843,7977,4264, 632,2510, 875,3844,1697,3845,2291,7978,7979, # 7526 +4544,3010,1239, 580,4545,4265,7980, 914, 936,2074,1190,4032,1039,2123,7981,7982, # 7542 +7983,3386,1473,7984,1354,4266,3846,7985,2172,3064,4033, 915,3305,4267,4268,3306, # 7558 +1605,1834,7986,2739, 398,3601,4269,3847,4034, 328,1912,2847,4035,3848,1331,4270, # 7574 +3011, 937,4271,7987,3602,4036,4037,3387,2160,4546,3388, 524, 742, 538,3065,1012, # 7590 +7988,7989,3849,2461,7990, 658,1103, 225,3850,7991,7992,4547,7993,4548,7994,3236, # 7606 +1243,7995,4038, 963,2246,4549,7996,2705,3603,3161,7997,7998,2588,2327,7999,4550, # 7622 +8000,8001,8002,3489,3307, 957,3389,2540,2032,1930,2927,2462, 870,2018,3604,1746, # 7638 +2770,2771,2434,2463,8003,3851,8004,3723,3107,3724,3490,3390,3725,8005,1179,3066, # 7654 +8006,3162,2373,4272,3726,2541,3163,3108,2740,4039,8007,3391,1556,2542,2292, 977, # 7670 +2887,2033,4040,1205,3392,8008,1765,3393,3164,2124,1271,1689, 714,4551,3491,8009, # 7686 +2328,3852, 533,4273,3605,2181, 617,8010,2464,3308,3492,2310,8011,8012,3165,8013, # 7702 +8014,3853,1987, 618, 427,2641,3493,3394,8015,8016,1244,1690,8017,2806,4274,4552, # 7718 +8018,3494,8019,8020,2279,1576, 473,3606,4275,3395, 972,8021,3607,8022,3067,8023, # 7734 +8024,4553,4554,8025,3727,4041,4042,8026, 153,4555, 356,8027,1891,2888,4276,2143, # 7750 + 408, 803,2352,8028,3854,8029,4277,1646,2570,2511,4556,4557,3855,8030,3856,4278, # 7766 +8031,2411,3396, 752,8032,8033,1961,2964,8034, 746,3012,2465,8035,4279,3728, 698, # 7782 +4558,1892,4280,3608,2543,4559,3609,3857,8036,3166,3397,8037,1823,1302,4043,2706, # 7798 +3858,1973,4281,8038,4282,3167, 823,1303,1288,1236,2848,3495,4044,3398, 774,3859, # 7814 +8039,1581,4560,1304,2849,3860,4561,8040,2435,2161,1083,3237,4283,4045,4284, 344, # 7830 +1173, 288,2311, 454,1683,8041,8042,1461,4562,4046,2589,8043,8044,4563, 985, 894, # 7846 +8045,3399,3168,8046,1913,2928,3729,1988,8047,2110,1974,8048,4047,8049,2571,1194, # 7862 + 425,8050,4564,3169,1245,3730,4285,8051,8052,2850,8053, 636,4565,1855,3861, 760, # 7878 +1799,8054,4286,2209,1508,4566,4048,1893,1684,2293,8055,8056,8057,4287,4288,2210, # 7894 + 479,8058,8059, 832,8060,4049,2489,8061,2965,2490,3731, 990,3109, 627,1814,2642, # 7910 +4289,1582,4290,2125,2111,3496,4567,8062, 799,4291,3170,8063,4568,2112,1737,3013, # 7926 +1018, 543, 754,4292,3309,1676,4569,4570,4050,8064,1489,8065,3497,8066,2614,2889, # 7942 +4051,8067,8068,2966,8069,8070,8071,8072,3171,4571,4572,2182,1722,8073,3238,3239, # 7958 +1842,3610,1715, 481, 365,1975,1856,8074,8075,1962,2491,4573,8076,2126,3611,3240, # 7974 + 433,1894,2063,2075,8077, 602,2741,8078,8079,8080,8081,8082,3014,1628,3400,8083, # 7990 +3172,4574,4052,2890,4575,2512,8084,2544,2772,8085,8086,8087,3310,4576,2891,8088, # 8006 +4577,8089,2851,4578,4579,1221,2967,4053,2513,8090,8091,8092,1867,1989,8093,8094, # 8022 +8095,1895,8096,8097,4580,1896,4054, 318,8098,2094,4055,4293,8099,8100, 485,8101, # 8038 + 938,3862, 553,2670, 116,8102,3863,3612,8103,3498,2671,2773,3401,3311,2807,8104, # 8054 +3613,2929,4056,1747,2930,2968,8105,8106, 207,8107,8108,2672,4581,2514,8109,3015, # 8070 + 890,3614,3864,8110,1877,3732,3402,8111,2183,2353,3403,1652,8112,8113,8114, 941, # 8086 +2294, 208,3499,4057,2019, 330,4294,3865,2892,2492,3733,4295,8115,8116,8117,8118, # 8102 +#Everything below is of no interest for detection purpose +2515,1613,4582,8119,3312,3866,2516,8120,4058,8121,1637,4059,2466,4583,3867,8122, # 8118 +2493,3016,3734,8123,8124,2192,8125,8126,2162,8127,8128,8129,8130,8131,8132,8133, # 8134 +8134,8135,8136,8137,8138,8139,8140,8141,8142,8143,8144,8145,8146,8147,8148,8149, # 8150 +8150,8151,8152,8153,8154,8155,8156,8157,8158,8159,8160,8161,8162,8163,8164,8165, # 8166 +8166,8167,8168,8169,8170,8171,8172,8173,8174,8175,8176,8177,8178,8179,8180,8181, # 8182 +8182,8183,8184,8185,8186,8187,8188,8189,8190,8191,8192,8193,8194,8195,8196,8197, # 8198 +8198,8199,8200,8201,8202,8203,8204,8205,8206,8207,8208,8209,8210,8211,8212,8213, # 8214 +8214,8215,8216,8217,8218,8219,8220,8221,8222,8223,8224,8225,8226,8227,8228,8229, # 8230 +8230,8231,8232,8233,8234,8235,8236,8237,8238,8239,8240,8241,8242,8243,8244,8245, # 8246 +8246,8247,8248,8249,8250,8251,8252,8253,8254,8255,8256,8257,8258,8259,8260,8261, # 8262 +8262,8263,8264,8265,8266,8267,8268,8269,8270,8271,8272,8273,8274,8275,8276,8277, # 8278 +8278,8279,8280,8281,8282,8283,8284,8285,8286,8287,8288,8289,8290,8291,8292,8293, # 8294 +8294,8295,8296,8297,8298,8299,8300,8301,8302,8303,8304,8305,8306,8307,8308,8309, # 8310 +8310,8311,8312,8313,8314,8315,8316,8317,8318,8319,8320,8321,8322,8323,8324,8325, # 8326 +8326,8327,8328,8329,8330,8331,8332,8333,8334,8335,8336,8337,8338,8339,8340,8341, # 8342 +8342,8343,8344,8345,8346,8347,8348,8349,8350,8351,8352,8353,8354,8355,8356,8357, # 8358 +8358,8359,8360,8361,8362,8363,8364,8365,8366,8367,8368,8369,8370,8371,8372,8373, # 8374 +8374,8375,8376,8377,8378,8379,8380,8381,8382,8383,8384,8385,8386,8387,8388,8389, # 8390 +8390,8391,8392,8393,8394,8395,8396,8397,8398,8399,8400,8401,8402,8403,8404,8405, # 8406 +8406,8407,8408,8409,8410,8411,8412,8413,8414,8415,8416,8417,8418,8419,8420,8421, # 8422 +8422,8423,8424,8425,8426,8427,8428,8429,8430,8431,8432,8433,8434,8435,8436,8437, # 8438 +8438,8439,8440,8441,8442,8443,8444,8445,8446,8447,8448,8449,8450,8451,8452,8453, # 8454 +8454,8455,8456,8457,8458,8459,8460,8461,8462,8463,8464,8465,8466,8467,8468,8469, # 8470 +8470,8471,8472,8473,8474,8475,8476,8477,8478,8479,8480,8481,8482,8483,8484,8485, # 8486 +8486,8487,8488,8489,8490,8491,8492,8493,8494,8495,8496,8497,8498,8499,8500,8501, # 8502 +8502,8503,8504,8505,8506,8507,8508,8509,8510,8511,8512,8513,8514,8515,8516,8517, # 8518 +8518,8519,8520,8521,8522,8523,8524,8525,8526,8527,8528,8529,8530,8531,8532,8533, # 8534 +8534,8535,8536,8537,8538,8539,8540,8541,8542,8543,8544,8545,8546,8547,8548,8549, # 8550 +8550,8551,8552,8553,8554,8555,8556,8557,8558,8559,8560,8561,8562,8563,8564,8565, # 8566 +8566,8567,8568,8569,8570,8571,8572,8573,8574,8575,8576,8577,8578,8579,8580,8581, # 8582 +8582,8583,8584,8585,8586,8587,8588,8589,8590,8591,8592,8593,8594,8595,8596,8597, # 8598 +8598,8599,8600,8601,8602,8603,8604,8605,8606,8607,8608,8609,8610,8611,8612,8613, # 8614 +8614,8615,8616,8617,8618,8619,8620,8621,8622,8623,8624,8625,8626,8627,8628,8629, # 8630 +8630,8631,8632,8633,8634,8635,8636,8637,8638,8639,8640,8641,8642,8643,8644,8645, # 8646 +8646,8647,8648,8649,8650,8651,8652,8653,8654,8655,8656,8657,8658,8659,8660,8661, # 8662 +8662,8663,8664,8665,8666,8667,8668,8669,8670,8671,8672,8673,8674,8675,8676,8677, # 8678 +8678,8679,8680,8681,8682,8683,8684,8685,8686,8687,8688,8689,8690,8691,8692,8693, # 8694 +8694,8695,8696,8697,8698,8699,8700,8701,8702,8703,8704,8705,8706,8707,8708,8709, # 8710 +8710,8711,8712,8713,8714,8715,8716,8717,8718,8719,8720,8721,8722,8723,8724,8725, # 8726 +8726,8727,8728,8729,8730,8731,8732,8733,8734,8735,8736,8737,8738,8739,8740,8741) # 8742 diff --git a/fanficdownloader/chardet/euctwprober.py b/fanficdownloader/chardet/euctwprober.py new file mode 100644 index 00000000..b073f134 --- /dev/null +++ b/fanficdownloader/chardet/euctwprober.py @@ -0,0 +1,41 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from mbcharsetprober import MultiByteCharSetProber +from codingstatemachine import CodingStateMachine +from chardistribution import EUCTWDistributionAnalysis +from mbcssm import EUCTWSMModel + +class EUCTWProber(MultiByteCharSetProber): + def __init__(self): + MultiByteCharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(EUCTWSMModel) + self._mDistributionAnalyzer = EUCTWDistributionAnalysis() + self.reset() + + def get_charset_name(self): + return "EUC-TW" diff --git a/fanficdownloader/chardet/gb2312freq.py b/fanficdownloader/chardet/gb2312freq.py new file mode 100644 index 00000000..7a4d5a1b --- /dev/null +++ b/fanficdownloader/chardet/gb2312freq.py @@ -0,0 +1,471 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +# GB2312 most frequently used character table +# +# Char to FreqOrder table , from hz6763 + +# 512 --> 0.79 -- 0.79 +# 1024 --> 0.92 -- 0.13 +# 2048 --> 0.98 -- 0.06 +# 6768 --> 1.00 -- 0.02 +# +# Ideal Distribution Ratio = 0.79135/(1-0.79135) = 3.79 +# Random Distribution Ration = 512 / (3755 - 512) = 0.157 +# +# Typical Distribution Ratio about 25% of Ideal one, still much higher that RDR + +GB2312_TYPICAL_DISTRIBUTION_RATIO = 0.9 + +GB2312_TABLE_SIZE = 3760 + +GB2312CharToFreqOrder = ( \ +1671, 749,1443,2364,3924,3807,2330,3921,1704,3463,2691,1511,1515, 572,3191,2205, +2361, 224,2558, 479,1711, 963,3162, 440,4060,1905,2966,2947,3580,2647,3961,3842, +2204, 869,4207, 970,2678,5626,2944,2956,1479,4048, 514,3595, 588,1346,2820,3409, + 249,4088,1746,1873,2047,1774, 581,1813, 358,1174,3590,1014,1561,4844,2245, 670, +1636,3112, 889,1286, 953, 556,2327,3060,1290,3141, 613, 185,3477,1367, 850,3820, +1715,2428,2642,2303,2732,3041,2562,2648,3566,3946,1349, 388,3098,2091,1360,3585, + 152,1687,1539, 738,1559, 59,1232,2925,2267,1388,1249,1741,1679,2960, 151,1566, +1125,1352,4271, 924,4296, 385,3166,4459, 310,1245,2850, 70,3285,2729,3534,3575, +2398,3298,3466,1960,2265, 217,3647, 864,1909,2084,4401,2773,1010,3269,5152, 853, +3051,3121,1244,4251,1895, 364,1499,1540,2313,1180,3655,2268, 562, 715,2417,3061, + 544, 336,3768,2380,1752,4075, 950, 280,2425,4382, 183,2759,3272, 333,4297,2155, +1688,2356,1444,1039,4540, 736,1177,3349,2443,2368,2144,2225, 565, 196,1482,3406, + 927,1335,4147, 692, 878,1311,1653,3911,3622,1378,4200,1840,2969,3149,2126,1816, +2534,1546,2393,2760, 737,2494, 13, 447, 245,2747, 38,2765,2129,2589,1079, 606, + 360, 471,3755,2890, 404, 848, 699,1785,1236, 370,2221,1023,3746,2074,2026,2023, +2388,1581,2119, 812,1141,3091,2536,1519, 804,2053, 406,1596,1090, 784, 548,4414, +1806,2264,2936,1100, 343,4114,5096, 622,3358, 743,3668,1510,1626,5020,3567,2513, +3195,4115,5627,2489,2991, 24,2065,2697,1087,2719, 48,1634, 315, 68, 985,2052, + 198,2239,1347,1107,1439, 597,2366,2172, 871,3307, 919,2487,2790,1867, 236,2570, +1413,3794, 906,3365,3381,1701,1982,1818,1524,2924,1205, 616,2586,2072,2004, 575, + 253,3099, 32,1365,1182, 197,1714,2454,1201, 554,3388,3224,2748, 756,2587, 250, +2567,1507,1517,3529,1922,2761,2337,3416,1961,1677,2452,2238,3153, 615, 911,1506, +1474,2495,1265,1906,2749,3756,3280,2161, 898,2714,1759,3450,2243,2444, 563, 26, +3286,2266,3769,3344,2707,3677, 611,1402, 531,1028,2871,4548,1375, 261,2948, 835, +1190,4134, 353, 840,2684,1900,3082,1435,2109,1207,1674, 329,1872,2781,4055,2686, +2104, 608,3318,2423,2957,2768,1108,3739,3512,3271,3985,2203,1771,3520,1418,2054, +1681,1153, 225,1627,2929, 162,2050,2511,3687,1954, 124,1859,2431,1684,3032,2894, + 585,4805,3969,2869,2704,2088,2032,2095,3656,2635,4362,2209, 256, 518,2042,2105, +3777,3657, 643,2298,1148,1779, 190, 989,3544, 414, 11,2135,2063,2979,1471, 403, +3678, 126, 770,1563, 671,2499,3216,2877, 600,1179, 307,2805,4937,1268,1297,2694, + 252,4032,1448,1494,1331,1394, 127,2256, 222,1647,1035,1481,3056,1915,1048, 873, +3651, 210, 33,1608,2516, 200,1520, 415, 102, 0,3389,1287, 817, 91,3299,2940, + 836,1814, 549,2197,1396,1669,2987,3582,2297,2848,4528,1070, 687, 20,1819, 121, +1552,1364,1461,1968,2617,3540,2824,2083, 177, 948,4938,2291, 110,4549,2066, 648, +3359,1755,2110,2114,4642,4845,1693,3937,3308,1257,1869,2123, 208,1804,3159,2992, +2531,2549,3361,2418,1350,2347,2800,2568,1291,2036,2680, 72, 842,1990, 212,1233, +1154,1586, 75,2027,3410,4900,1823,1337,2710,2676, 728,2810,1522,3026,4995, 157, + 755,1050,4022, 710, 785,1936,2194,2085,1406,2777,2400, 150,1250,4049,1206, 807, +1910, 534, 529,3309,1721,1660, 274, 39,2827, 661,2670,1578, 925,3248,3815,1094, +4278,4901,4252, 41,1150,3747,2572,2227,4501,3658,4902,3813,3357,3617,2884,2258, + 887, 538,4187,3199,1294,2439,3042,2329,2343,2497,1255, 107, 543,1527, 521,3478, +3568, 194,5062, 15, 961,3870,1241,1192,2664, 66,5215,3260,2111,1295,1127,2152, +3805,4135, 901,1164,1976, 398,1278, 530,1460, 748, 904,1054,1966,1426, 53,2909, + 509, 523,2279,1534, 536,1019, 239,1685, 460,2353, 673,1065,2401,3600,4298,2272, +1272,2363, 284,1753,3679,4064,1695, 81, 815,2677,2757,2731,1386, 859, 500,4221, +2190,2566, 757,1006,2519,2068,1166,1455, 337,2654,3203,1863,1682,1914,3025,1252, +1409,1366, 847, 714,2834,2038,3209, 964,2970,1901, 885,2553,1078,1756,3049, 301, +1572,3326, 688,2130,1996,2429,1805,1648,2930,3421,2750,3652,3088, 262,1158,1254, + 389,1641,1812, 526,1719, 923,2073,1073,1902, 468, 489,4625,1140, 857,2375,3070, +3319,2863, 380, 116,1328,2693,1161,2244, 273,1212,1884,2769,3011,1775,1142, 461, +3066,1200,2147,2212, 790, 702,2695,4222,1601,1058, 434,2338,5153,3640, 67,2360, +4099,2502, 618,3472,1329, 416,1132, 830,2782,1807,2653,3211,3510,1662, 192,2124, + 296,3979,1739,1611,3684, 23, 118, 324, 446,1239,1225, 293,2520,3814,3795,2535, +3116, 17,1074, 467,2692,2201, 387,2922, 45,1326,3055,1645,3659,2817, 958, 243, +1903,2320,1339,2825,1784,3289, 356, 576, 865,2315,2381,3377,3916,1088,3122,1713, +1655, 935, 628,4689,1034,1327, 441, 800, 720, 894,1979,2183,1528,5289,2702,1071, +4046,3572,2399,1571,3281, 79, 761,1103, 327, 134, 758,1899,1371,1615, 879, 442, + 215,2605,2579, 173,2048,2485,1057,2975,3317,1097,2253,3801,4263,1403,1650,2946, + 814,4968,3487,1548,2644,1567,1285, 2, 295,2636, 97, 946,3576, 832, 141,4257, +3273, 760,3821,3521,3156,2607, 949,1024,1733,1516,1803,1920,2125,2283,2665,3180, +1501,2064,3560,2171,1592, 803,3518,1416, 732,3897,4258,1363,1362,2458, 119,1427, + 602,1525,2608,1605,1639,3175, 694,3064, 10, 465, 76,2000,4846,4208, 444,3781, +1619,3353,2206,1273,3796, 740,2483, 320,1723,2377,3660,2619,1359,1137,1762,1724, +2345,2842,1850,1862, 912, 821,1866, 612,2625,1735,2573,3369,1093, 844, 89, 937, + 930,1424,3564,2413,2972,1004,3046,3019,2011, 711,3171,1452,4178, 428, 801,1943, + 432, 445,2811, 206,4136,1472, 730, 349, 73, 397,2802,2547, 998,1637,1167, 789, + 396,3217, 154,1218, 716,1120,1780,2819,4826,1931,3334,3762,2139,1215,2627, 552, +3664,3628,3232,1405,2383,3111,1356,2652,3577,3320,3101,1703, 640,1045,1370,1246, +4996, 371,1575,2436,1621,2210, 984,4033,1734,2638, 16,4529, 663,2755,3255,1451, +3917,2257,1253,1955,2234,1263,2951, 214,1229, 617, 485, 359,1831,1969, 473,2310, + 750,2058, 165, 80,2864,2419, 361,4344,2416,2479,1134, 796,3726,1266,2943, 860, +2715, 938, 390,2734,1313,1384, 248, 202, 877,1064,2854, 522,3907, 279,1602, 297, +2357, 395,3740, 137,2075, 944,4089,2584,1267,3802, 62,1533,2285, 178, 176, 780, +2440, 201,3707, 590, 478,1560,4354,2117,1075, 30, 74,4643,4004,1635,1441,2745, + 776,2596, 238,1077,1692,1912,2844, 605, 499,1742,3947, 241,3053, 980,1749, 936, +2640,4511,2582, 515,1543,2162,5322,2892,2993, 890,2148,1924, 665,1827,3581,1032, + 968,3163, 339,1044,1896, 270, 583,1791,1720,4367,1194,3488,3669, 43,2523,1657, + 163,2167, 290,1209,1622,3378, 550, 634,2508,2510, 695,2634,2384,2512,1476,1414, + 220,1469,2341,2138,2852,3183,2900,4939,2865,3502,1211,3680, 854,3227,1299,2976, +3172, 186,2998,1459, 443,1067,3251,1495, 321,1932,3054, 909, 753,1410,1828, 436, +2441,1119,1587,3164,2186,1258, 227, 231,1425,1890,3200,3942, 247, 959, 725,5254, +2741, 577,2158,2079, 929, 120, 174, 838,2813, 591,1115, 417,2024, 40,3240,1536, +1037, 291,4151,2354, 632,1298,2406,2500,3535,1825,1846,3451, 205,1171, 345,4238, + 18,1163, 811, 685,2208,1217, 425,1312,1508,1175,4308,2552,1033, 587,1381,3059, +2984,3482, 340,1316,4023,3972, 792,3176, 519, 777,4690, 918, 933,4130,2981,3741, + 90,3360,2911,2200,5184,4550, 609,3079,2030, 272,3379,2736, 363,3881,1130,1447, + 286, 779, 357,1169,3350,3137,1630,1220,2687,2391, 747,1277,3688,2618,2682,2601, +1156,3196,5290,4034,3102,1689,3596,3128, 874, 219,2783, 798, 508,1843,2461, 269, +1658,1776,1392,1913,2983,3287,2866,2159,2372, 829,4076, 46,4253,2873,1889,1894, + 915,1834,1631,2181,2318, 298, 664,2818,3555,2735, 954,3228,3117, 527,3511,2173, + 681,2712,3033,2247,2346,3467,1652, 155,2164,3382, 113,1994, 450, 899, 494, 994, +1237,2958,1875,2336,1926,3727, 545,1577,1550, 633,3473, 204,1305,3072,2410,1956, +2471, 707,2134, 841,2195,2196,2663,3843,1026,4940, 990,3252,4997, 368,1092, 437, +3212,3258,1933,1829, 675,2977,2893, 412, 943,3723,4644,3294,3283,2230,2373,5154, +2389,2241,2661,2323,1404,2524, 593, 787, 677,3008,1275,2059, 438,2709,2609,2240, +2269,2246,1446, 36,1568,1373,3892,1574,2301,1456,3962, 693,2276,5216,2035,1143, +2720,1919,1797,1811,2763,4137,2597,1830,1699,1488,1198,2090, 424,1694, 312,3634, +3390,4179,3335,2252,1214, 561,1059,3243,2295,2561, 975,5155,2321,2751,3772, 472, +1537,3282,3398,1047,2077,2348,2878,1323,3340,3076, 690,2906, 51, 369, 170,3541, +1060,2187,2688,3670,2541,1083,1683, 928,3918, 459, 109,4427, 599,3744,4286, 143, +2101,2730,2490, 82,1588,3036,2121, 281,1860, 477,4035,1238,2812,3020,2716,3312, +1530,2188,2055,1317, 843, 636,1808,1173,3495, 649, 181,1002, 147,3641,1159,2414, +3750,2289,2795, 813,3123,2610,1136,4368, 5,3391,4541,2174, 420, 429,1728, 754, +1228,2115,2219, 347,2223,2733, 735,1518,3003,2355,3134,1764,3948,3329,1888,2424, +1001,1234,1972,3321,3363,1672,1021,1450,1584, 226, 765, 655,2526,3404,3244,2302, +3665, 731, 594,2184, 319,1576, 621, 658,2656,4299,2099,3864,1279,2071,2598,2739, + 795,3086,3699,3908,1707,2352,2402,1382,3136,2475,1465,4847,3496,3865,1085,3004, +2591,1084, 213,2287,1963,3565,2250, 822, 793,4574,3187,1772,1789,3050, 595,1484, +1959,2770,1080,2650, 456, 422,2996, 940,3322,4328,4345,3092,2742, 965,2784, 739, +4124, 952,1358,2498,2949,2565, 332,2698,2378, 660,2260,2473,4194,3856,2919, 535, +1260,2651,1208,1428,1300,1949,1303,2942, 433,2455,2450,1251,1946, 614,1269, 641, +1306,1810,2737,3078,2912, 564,2365,1419,1415,1497,4460,2367,2185,1379,3005,1307, +3218,2175,1897,3063, 682,1157,4040,4005,1712,1160,1941,1399, 394, 402,2952,1573, +1151,2986,2404, 862, 299,2033,1489,3006, 346, 171,2886,3401,1726,2932, 168,2533, + 47,2507,1030,3735,1145,3370,1395,1318,1579,3609,4560,2857,4116,1457,2529,1965, + 504,1036,2690,2988,2405, 745,5871, 849,2397,2056,3081, 863,2359,3857,2096, 99, +1397,1769,2300,4428,1643,3455,1978,1757,3718,1440, 35,4879,3742,1296,4228,2280, + 160,5063,1599,2013, 166, 520,3479,1646,3345,3012, 490,1937,1545,1264,2182,2505, +1096,1188,1369,1436,2421,1667,2792,2460,1270,2122, 727,3167,2143, 806,1706,1012, +1800,3037, 960,2218,1882, 805, 139,2456,1139,1521, 851,1052,3093,3089, 342,2039, + 744,5097,1468,1502,1585,2087, 223, 939, 326,2140,2577, 892,2481,1623,4077, 982, +3708, 135,2131, 87,2503,3114,2326,1106, 876,1616, 547,2997,2831,2093,3441,4530, +4314, 9,3256,4229,4148, 659,1462,1986,1710,2046,2913,2231,4090,4880,5255,3392, +3274,1368,3689,4645,1477, 705,3384,3635,1068,1529,2941,1458,3782,1509, 100,1656, +2548, 718,2339, 408,1590,2780,3548,1838,4117,3719,1345,3530, 717,3442,2778,3220, +2898,1892,4590,3614,3371,2043,1998,1224,3483, 891, 635, 584,2559,3355, 733,1766, +1729,1172,3789,1891,2307, 781,2982,2271,1957,1580,5773,2633,2005,4195,3097,1535, +3213,1189,1934,5693,3262, 586,3118,1324,1598, 517,1564,2217,1868,1893,4445,3728, +2703,3139,1526,1787,1992,3882,2875,1549,1199,1056,2224,1904,2711,5098,4287, 338, +1993,3129,3489,2689,1809,2815,1997, 957,1855,3898,2550,3275,3057,1105,1319, 627, +1505,1911,1883,3526, 698,3629,3456,1833,1431, 746, 77,1261,2017,2296,1977,1885, + 125,1334,1600, 525,1798,1109,2222,1470,1945, 559,2236,1186,3443,2476,1929,1411, +2411,3135,1777,3372,2621,1841,1613,3229, 668,1430,1839,2643,2916, 195,1989,2671, +2358,1387, 629,3205,2293,5256,4439, 123,1310, 888,1879,4300,3021,3605,1003,1162, +3192,2910,2010, 140,2395,2859, 55,1082,2012,2901, 662, 419,2081,1438, 680,2774, +4654,3912,1620,1731,1625,5035,4065,2328, 512,1344, 802,5443,2163,2311,2537, 524, +3399, 98,1155,2103,1918,2606,3925,2816,1393,2465,1504,3773,2177,3963,1478,4346, + 180,1113,4655,3461,2028,1698, 833,2696,1235,1322,1594,4408,3623,3013,3225,2040, +3022, 541,2881, 607,3632,2029,1665,1219, 639,1385,1686,1099,2803,3231,1938,3188, +2858, 427, 676,2772,1168,2025, 454,3253,2486,3556, 230,1950, 580, 791,1991,1280, +1086,1974,2034, 630, 257,3338,2788,4903,1017, 86,4790, 966,2789,1995,1696,1131, + 259,3095,4188,1308, 179,1463,5257, 289,4107,1248, 42,3413,1725,2288, 896,1947, + 774,4474,4254, 604,3430,4264, 392,2514,2588, 452, 237,1408,3018, 988,4531,1970, +3034,3310, 540,2370,1562,1288,2990, 502,4765,1147, 4,1853,2708, 207, 294,2814, +4078,2902,2509, 684, 34,3105,3532,2551, 644, 709,2801,2344, 573,1727,3573,3557, +2021,1081,3100,4315,2100,3681, 199,2263,1837,2385, 146,3484,1195,2776,3949, 997, +1939,3973,1008,1091,1202,1962,1847,1149,4209,5444,1076, 493, 117,5400,2521, 972, +1490,2934,1796,4542,2374,1512,2933,2657, 413,2888,1135,2762,2314,2156,1355,2369, + 766,2007,2527,2170,3124,2491,2593,2632,4757,2437, 234,3125,3591,1898,1750,1376, +1942,3468,3138, 570,2127,2145,3276,4131, 962, 132,1445,4196, 19, 941,3624,3480, +3366,1973,1374,4461,3431,2629, 283,2415,2275, 808,2887,3620,2112,2563,1353,3610, + 955,1089,3103,1053, 96, 88,4097, 823,3808,1583, 399, 292,4091,3313, 421,1128, + 642,4006, 903,2539,1877,2082, 596, 29,4066,1790, 722,2157, 130, 995,1569, 769, +1485, 464, 513,2213, 288,1923,1101,2453,4316, 133, 486,2445, 50, 625, 487,2207, + 57, 423, 481,2962, 159,3729,1558, 491, 303, 482, 501, 240,2837, 112,3648,2392, +1783, 362, 8,3433,3422, 610,2793,3277,1390,1284,1654, 21,3823, 734, 367, 623, + 193, 287, 374,1009,1483, 816, 476, 313,2255,2340,1262,2150,2899,1146,2581, 782, +2116,1659,2018,1880, 255,3586,3314,1110,2867,2137,2564, 986,2767,5185,2006, 650, + 158, 926, 762, 881,3157,2717,2362,3587, 306,3690,3245,1542,3077,2427,1691,2478, +2118,2985,3490,2438, 539,2305, 983, 129,1754, 355,4201,2386, 827,2923, 104,1773, +2838,2771, 411,2905,3919, 376, 767, 122,1114, 828,2422,1817,3506, 266,3460,1007, +1609,4998, 945,2612,4429,2274, 726,1247,1964,2914,2199,2070,4002,4108, 657,3323, +1422, 579, 455,2764,4737,1222,2895,1670, 824,1223,1487,2525, 558, 861,3080, 598, +2659,2515,1967, 752,2583,2376,2214,4180, 977, 704,2464,4999,2622,4109,1210,2961, + 819,1541, 142,2284, 44, 418, 457,1126,3730,4347,4626,1644,1876,3671,1864, 302, +1063,5694, 624, 723,1984,3745,1314,1676,2488,1610,1449,3558,3569,2166,2098, 409, +1011,2325,3704,2306, 818,1732,1383,1824,1844,3757, 999,2705,3497,1216,1423,2683, +2426,2954,2501,2726,2229,1475,2554,5064,1971,1794,1666,2014,1343, 783, 724, 191, +2434,1354,2220,5065,1763,2752,2472,4152, 131, 175,2885,3434, 92,1466,4920,2616, +3871,3872,3866, 128,1551,1632, 669,1854,3682,4691,4125,1230, 188,2973,3290,1302, +1213, 560,3266, 917, 763,3909,3249,1760, 868,1958, 764,1782,2097, 145,2277,3774, +4462, 64,1491,3062, 971,2132,3606,2442, 221,1226,1617, 218, 323,1185,3207,3147, + 571, 619,1473,1005,1744,2281, 449,1887,2396,3685, 275, 375,3816,1743,3844,3731, + 845,1983,2350,4210,1377, 773, 967,3499,3052,3743,2725,4007,1697,1022,3943,1464, +3264,2855,2722,1952,1029,2839,2467, 84,4383,2215, 820,1391,2015,2448,3672, 377, +1948,2168, 797,2545,3536,2578,2645, 94,2874,1678, 405,1259,3071, 771, 546,1315, + 470,1243,3083, 895,2468, 981, 969,2037, 846,4181, 653,1276,2928, 14,2594, 557, +3007,2474, 156, 902,1338,1740,2574, 537,2518, 973,2282,2216,2433,1928, 138,2903, +1293,2631,1612, 646,3457, 839,2935, 111, 496,2191,2847, 589,3186, 149,3994,2060, +4031,2641,4067,3145,1870, 37,3597,2136,1025,2051,3009,3383,3549,1121,1016,3261, +1301, 251,2446,2599,2153, 872,3246, 637, 334,3705, 831, 884, 921,3065,3140,4092, +2198,1944, 246,2964, 108,2045,1152,1921,2308,1031, 203,3173,4170,1907,3890, 810, +1401,2003,1690, 506, 647,1242,2828,1761,1649,3208,2249,1589,3709,2931,5156,1708, + 498, 666,2613, 834,3817,1231, 184,2851,1124, 883,3197,2261,3710,1765,1553,2658, +1178,2639,2351, 93,1193, 942,2538,2141,4402, 235,1821, 870,1591,2192,1709,1871, +3341,1618,4126,2595,2334, 603, 651, 69, 701, 268,2662,3411,2555,1380,1606, 503, + 448, 254,2371,2646, 574,1187,2309,1770, 322,2235,1292,1801, 305, 566,1133, 229, +2067,2057, 706, 167, 483,2002,2672,3295,1820,3561,3067, 316, 378,2746,3452,1112, + 136,1981, 507,1651,2917,1117, 285,4591, 182,2580,3522,1304, 335,3303,1835,2504, +1795,1792,2248, 674,1018,2106,2449,1857,2292,2845, 976,3047,1781,2600,2727,1389, +1281, 52,3152, 153, 265,3950, 672,3485,3951,4463, 430,1183, 365, 278,2169, 27, +1407,1336,2304, 209,1340,1730,2202,1852,2403,2883, 979,1737,1062, 631,2829,2542, +3876,2592, 825,2086,2226,3048,3625, 352,1417,3724, 542, 991, 431,1351,3938,1861, +2294, 826,1361,2927,3142,3503,1738, 463,2462,2723, 582,1916,1595,2808, 400,3845, +3891,2868,3621,2254, 58,2492,1123, 910,2160,2614,1372,1603,1196,1072,3385,1700, +3267,1980, 696, 480,2430, 920, 799,1570,2920,1951,2041,4047,2540,1321,4223,2469, +3562,2228,1271,2602, 401,2833,3351,2575,5157, 907,2312,1256, 410, 263,3507,1582, + 996, 678,1849,2316,1480, 908,3545,2237, 703,2322, 667,1826,2849,1531,2604,2999, +2407,3146,2151,2630,1786,3711, 469,3542, 497,3899,2409, 858, 837,4446,3393,1274, + 786, 620,1845,2001,3311, 484, 308,3367,1204,1815,3691,2332,1532,2557,1842,2020, +2724,1927,2333,4440, 567, 22,1673,2728,4475,1987,1858,1144,1597, 101,1832,3601, + 12, 974,3783,4391, 951,1412, 1,3720, 453,4608,4041, 528,1041,1027,3230,2628, +1129, 875,1051,3291,1203,2262,1069,2860,2799,2149,2615,3278, 144,1758,3040, 31, + 475,1680, 366,2685,3184, 311,1642,4008,2466,5036,1593,1493,2809, 216,1420,1668, + 233, 304,2128,3284, 232,1429,1768,1040,2008,3407,2740,2967,2543, 242,2133, 778, +1565,2022,2620, 505,2189,2756,1098,2273, 372,1614, 708, 553,2846,2094,2278, 169, +3626,2835,4161, 228,2674,3165, 809,1454,1309, 466,1705,1095, 900,3423, 880,2667, +3751,5258,2317,3109,2571,4317,2766,1503,1342, 866,4447,1118, 63,2076, 314,1881, +1348,1061, 172, 978,3515,1747, 532, 511,3970, 6, 601, 905,2699,3300,1751, 276, +1467,3725,2668, 65,4239,2544,2779,2556,1604, 578,2451,1802, 992,2331,2624,1320, +3446, 713,1513,1013, 103,2786,2447,1661, 886,1702, 916, 654,3574,2031,1556, 751, +2178,2821,2179,1498,1538,2176, 271, 914,2251,2080,1325, 638,1953,2937,3877,2432, +2754, 95,3265,1716, 260,1227,4083, 775, 106,1357,3254, 426,1607, 555,2480, 772, +1985, 244,2546, 474, 495,1046,2611,1851,2061, 71,2089,1675,2590, 742,3758,2843, +3222,1433, 267,2180,2576,2826,2233,2092,3913,2435, 956,1745,3075, 856,2113,1116, + 451, 3,1988,2896,1398, 993,2463,1878,2049,1341,2718,2721,2870,2108, 712,2904, +4363,2753,2324, 277,2872,2349,2649, 384, 987, 435, 691,3000, 922, 164,3939, 652, +1500,1184,4153,2482,3373,2165,4848,2335,3775,3508,3154,2806,2830,1554,2102,1664, +2530,1434,2408, 893,1547,2623,3447,2832,2242,2532,3169,2856,3223,2078, 49,3770, +3469, 462, 318, 656,2259,3250,3069, 679,1629,2758, 344,1138,1104,3120,1836,1283, +3115,2154,1437,4448, 934, 759,1999, 794,2862,1038, 533,2560,1722,2342, 855,2626, +1197,1663,4476,3127, 85,4240,2528, 25,1111,1181,3673, 407,3470,4561,2679,2713, + 768,1925,2841,3986,1544,1165, 932, 373,1240,2146,1930,2673, 721,4766, 354,4333, + 391,2963, 187, 61,3364,1442,1102, 330,1940,1767, 341,3809,4118, 393,2496,2062, +2211, 105, 331, 300, 439, 913,1332, 626, 379,3304,1557, 328, 689,3952, 309,1555, + 931, 317,2517,3027, 325, 569, 686,2107,3084, 60,1042,1333,2794, 264,3177,4014, +1628, 258,3712, 7,4464,1176,1043,1778, 683, 114,1975, 78,1492, 383,1886, 510, + 386, 645,5291,2891,2069,3305,4138,3867,2939,2603,2493,1935,1066,1848,3588,1015, +1282,1289,4609, 697,1453,3044,2666,3611,1856,2412, 54, 719,1330, 568,3778,2459, +1748, 788, 492, 551,1191,1000, 488,3394,3763, 282,1799, 348,2016,1523,3155,2390, +1049, 382,2019,1788,1170, 729,2968,3523, 897,3926,2785,2938,3292, 350,2319,3238, +1718,1717,2655,3453,3143,4465, 161,2889,2980,2009,1421, 56,1908,1640,2387,2232, +1917,1874,2477,4921, 148, 83,3438, 592,4245,2882,1822,1055, 741, 115,1496,1624, + 381,1638,4592,1020, 516,3214, 458, 947,4575,1432, 211,1514,2926,1865,2142, 189, + 852,1221,1400,1486, 882,2299,4036, 351, 28,1122, 700,6479,6480,6481,6482,6483, # last 512 +#Everything below is of no interest for detection purpose +5508,6484,3900,3414,3974,4441,4024,3537,4037,5628,5099,3633,6485,3148,6486,3636, +5509,3257,5510,5973,5445,5872,4941,4403,3174,4627,5873,6276,2286,4230,5446,5874, +5122,6102,6103,4162,5447,5123,5323,4849,6277,3980,3851,5066,4246,5774,5067,6278, +3001,2807,5695,3346,5775,5974,5158,5448,6487,5975,5976,5776,3598,6279,5696,4806, +4211,4154,6280,6488,6489,6490,6281,4212,5037,3374,4171,6491,4562,4807,4722,4827, +5977,6104,4532,4079,5159,5324,5160,4404,3858,5359,5875,3975,4288,4610,3486,4512, +5325,3893,5360,6282,6283,5560,2522,4231,5978,5186,5449,2569,3878,6284,5401,3578, +4415,6285,4656,5124,5979,2506,4247,4449,3219,3417,4334,4969,4329,6492,4576,4828, +4172,4416,4829,5402,6286,3927,3852,5361,4369,4830,4477,4867,5876,4173,6493,6105, +4657,6287,6106,5877,5450,6494,4155,4868,5451,3700,5629,4384,6288,6289,5878,3189, +4881,6107,6290,6495,4513,6496,4692,4515,4723,5100,3356,6497,6291,3810,4080,5561, +3570,4430,5980,6498,4355,5697,6499,4724,6108,6109,3764,4050,5038,5879,4093,3226, +6292,5068,5217,4693,3342,5630,3504,4831,4377,4466,4309,5698,4431,5777,6293,5778, +4272,3706,6110,5326,3752,4676,5327,4273,5403,4767,5631,6500,5699,5880,3475,5039, +6294,5562,5125,4348,4301,4482,4068,5126,4593,5700,3380,3462,5981,5563,3824,5404, +4970,5511,3825,4738,6295,6501,5452,4516,6111,5881,5564,6502,6296,5982,6503,4213, +4163,3454,6504,6112,4009,4450,6113,4658,6297,6114,3035,6505,6115,3995,4904,4739, +4563,4942,4110,5040,3661,3928,5362,3674,6506,5292,3612,4791,5565,4149,5983,5328, +5259,5021,4725,4577,4564,4517,4364,6298,5405,4578,5260,4594,4156,4157,5453,3592, +3491,6507,5127,5512,4709,4922,5984,5701,4726,4289,6508,4015,6116,5128,4628,3424, +4241,5779,6299,4905,6509,6510,5454,5702,5780,6300,4365,4923,3971,6511,5161,3270, +3158,5985,4100, 867,5129,5703,6117,5363,3695,3301,5513,4467,6118,6512,5455,4232, +4242,4629,6513,3959,4478,6514,5514,5329,5986,4850,5162,5566,3846,4694,6119,5456, +4869,5781,3779,6301,5704,5987,5515,4710,6302,5882,6120,4392,5364,5705,6515,6121, +6516,6517,3736,5988,5457,5989,4695,2457,5883,4551,5782,6303,6304,6305,5130,4971, +6122,5163,6123,4870,3263,5365,3150,4871,6518,6306,5783,5069,5706,3513,3498,4409, +5330,5632,5366,5458,5459,3991,5990,4502,3324,5991,5784,3696,4518,5633,4119,6519, +4630,5634,4417,5707,4832,5992,3418,6124,5993,5567,4768,5218,6520,4595,3458,5367, +6125,5635,6126,4202,6521,4740,4924,6307,3981,4069,4385,6308,3883,2675,4051,3834, +4302,4483,5568,5994,4972,4101,5368,6309,5164,5884,3922,6127,6522,6523,5261,5460, +5187,4164,5219,3538,5516,4111,3524,5995,6310,6311,5369,3181,3386,2484,5188,3464, +5569,3627,5708,6524,5406,5165,4677,4492,6312,4872,4851,5885,4468,5996,6313,5709, +5710,6128,2470,5886,6314,5293,4882,5785,3325,5461,5101,6129,5711,5786,6525,4906, +6526,6527,4418,5887,5712,4808,2907,3701,5713,5888,6528,3765,5636,5331,6529,6530, +3593,5889,3637,4943,3692,5714,5787,4925,6315,6130,5462,4405,6131,6132,6316,5262, +6531,6532,5715,3859,5716,5070,4696,5102,3929,5788,3987,4792,5997,6533,6534,3920, +4809,5000,5998,6535,2974,5370,6317,5189,5263,5717,3826,6536,3953,5001,4883,3190, +5463,5890,4973,5999,4741,6133,6134,3607,5570,6000,4711,3362,3630,4552,5041,6318, +6001,2950,2953,5637,4646,5371,4944,6002,2044,4120,3429,6319,6537,5103,4833,6538, +6539,4884,4647,3884,6003,6004,4758,3835,5220,5789,4565,5407,6540,6135,5294,4697, +4852,6320,6321,3206,4907,6541,6322,4945,6542,6136,6543,6323,6005,4631,3519,6544, +5891,6545,5464,3784,5221,6546,5571,4659,6547,6324,6137,5190,6548,3853,6549,4016, +4834,3954,6138,5332,3827,4017,3210,3546,4469,5408,5718,3505,4648,5790,5131,5638, +5791,5465,4727,4318,6325,6326,5792,4553,4010,4698,3439,4974,3638,4335,3085,6006, +5104,5042,5166,5892,5572,6327,4356,4519,5222,5573,5333,5793,5043,6550,5639,5071, +4503,6328,6139,6551,6140,3914,3901,5372,6007,5640,4728,4793,3976,3836,4885,6552, +4127,6553,4451,4102,5002,6554,3686,5105,6555,5191,5072,5295,4611,5794,5296,6556, +5893,5264,5894,4975,5466,5265,4699,4976,4370,4056,3492,5044,4886,6557,5795,4432, +4769,4357,5467,3940,4660,4290,6141,4484,4770,4661,3992,6329,4025,4662,5022,4632, +4835,4070,5297,4663,4596,5574,5132,5409,5895,6142,4504,5192,4664,5796,5896,3885, +5575,5797,5023,4810,5798,3732,5223,4712,5298,4084,5334,5468,6143,4052,4053,4336, +4977,4794,6558,5335,4908,5576,5224,4233,5024,4128,5469,5225,4873,6008,5045,4729, +4742,4633,3675,4597,6559,5897,5133,5577,5003,5641,5719,6330,6560,3017,2382,3854, +4406,4811,6331,4393,3964,4946,6561,2420,3722,6562,4926,4378,3247,1736,4442,6332, +5134,6333,5226,3996,2918,5470,4319,4003,4598,4743,4744,4485,3785,3902,5167,5004, +5373,4394,5898,6144,4874,1793,3997,6334,4085,4214,5106,5642,4909,5799,6009,4419, +4189,3330,5899,4165,4420,5299,5720,5227,3347,6145,4081,6335,2876,3930,6146,3293, +3786,3910,3998,5900,5300,5578,2840,6563,5901,5579,6147,3531,5374,6564,6565,5580, +4759,5375,6566,6148,3559,5643,6336,6010,5517,6337,6338,5721,5902,3873,6011,6339, +6567,5518,3868,3649,5722,6568,4771,4947,6569,6149,4812,6570,2853,5471,6340,6341, +5644,4795,6342,6012,5723,6343,5724,6013,4349,6344,3160,6150,5193,4599,4514,4493, +5168,4320,6345,4927,3666,4745,5169,5903,5005,4928,6346,5725,6014,4730,4203,5046, +4948,3395,5170,6015,4150,6016,5726,5519,6347,5047,3550,6151,6348,4197,4310,5904, +6571,5581,2965,6152,4978,3960,4291,5135,6572,5301,5727,4129,4026,5905,4853,5728, +5472,6153,6349,4533,2700,4505,5336,4678,3583,5073,2994,4486,3043,4554,5520,6350, +6017,5800,4487,6351,3931,4103,5376,6352,4011,4321,4311,4190,5136,6018,3988,3233, +4350,5906,5645,4198,6573,5107,3432,4191,3435,5582,6574,4139,5410,6353,5411,3944, +5583,5074,3198,6575,6354,4358,6576,5302,4600,5584,5194,5412,6577,6578,5585,5413, +5303,4248,5414,3879,4433,6579,4479,5025,4854,5415,6355,4760,4772,3683,2978,4700, +3797,4452,3965,3932,3721,4910,5801,6580,5195,3551,5907,3221,3471,3029,6019,3999, +5908,5909,5266,5267,3444,3023,3828,3170,4796,5646,4979,4259,6356,5647,5337,3694, +6357,5648,5338,4520,4322,5802,3031,3759,4071,6020,5586,4836,4386,5048,6581,3571, +4679,4174,4949,6154,4813,3787,3402,3822,3958,3215,3552,5268,4387,3933,4950,4359, +6021,5910,5075,3579,6358,4234,4566,5521,6359,3613,5049,6022,5911,3375,3702,3178, +4911,5339,4521,6582,6583,4395,3087,3811,5377,6023,6360,6155,4027,5171,5649,4421, +4249,2804,6584,2270,6585,4000,4235,3045,6156,5137,5729,4140,4312,3886,6361,4330, +6157,4215,6158,3500,3676,4929,4331,3713,4930,5912,4265,3776,3368,5587,4470,4855, +3038,4980,3631,6159,6160,4132,4680,6161,6362,3923,4379,5588,4255,6586,4121,6587, +6363,4649,6364,3288,4773,4774,6162,6024,6365,3543,6588,4274,3107,3737,5050,5803, +4797,4522,5589,5051,5730,3714,4887,5378,4001,4523,6163,5026,5522,4701,4175,2791, +3760,6589,5473,4224,4133,3847,4814,4815,4775,3259,5416,6590,2738,6164,6025,5304, +3733,5076,5650,4816,5590,6591,6165,6592,3934,5269,6593,3396,5340,6594,5804,3445, +3602,4042,4488,5731,5732,3525,5591,4601,5196,6166,6026,5172,3642,4612,3202,4506, +4798,6366,3818,5108,4303,5138,5139,4776,3332,4304,2915,3415,4434,5077,5109,4856, +2879,5305,4817,6595,5913,3104,3144,3903,4634,5341,3133,5110,5651,5805,6167,4057, +5592,2945,4371,5593,6596,3474,4182,6367,6597,6168,4507,4279,6598,2822,6599,4777, +4713,5594,3829,6169,3887,5417,6170,3653,5474,6368,4216,2971,5228,3790,4579,6369, +5733,6600,6601,4951,4746,4555,6602,5418,5475,6027,3400,4665,5806,6171,4799,6028, +5052,6172,3343,4800,4747,5006,6370,4556,4217,5476,4396,5229,5379,5477,3839,5914, +5652,5807,4714,3068,4635,5808,6173,5342,4192,5078,5419,5523,5734,6174,4557,6175, +4602,6371,6176,6603,5809,6372,5735,4260,3869,5111,5230,6029,5112,6177,3126,4681, +5524,5915,2706,3563,4748,3130,6178,4018,5525,6604,6605,5478,4012,4837,6606,4534, +4193,5810,4857,3615,5479,6030,4082,3697,3539,4086,5270,3662,4508,4931,5916,4912, +5811,5027,3888,6607,4397,3527,3302,3798,2775,2921,2637,3966,4122,4388,4028,4054, +1633,4858,5079,3024,5007,3982,3412,5736,6608,3426,3236,5595,3030,6179,3427,3336, +3279,3110,6373,3874,3039,5080,5917,5140,4489,3119,6374,5812,3405,4494,6031,4666, +4141,6180,4166,6032,5813,4981,6609,5081,4422,4982,4112,3915,5653,3296,3983,6375, +4266,4410,5654,6610,6181,3436,5082,6611,5380,6033,3819,5596,4535,5231,5306,5113, +6612,4952,5918,4275,3113,6613,6376,6182,6183,5814,3073,4731,4838,5008,3831,6614, +4888,3090,3848,4280,5526,5232,3014,5655,5009,5737,5420,5527,6615,5815,5343,5173, +5381,4818,6616,3151,4953,6617,5738,2796,3204,4360,2989,4281,5739,5174,5421,5197, +3132,5141,3849,5142,5528,5083,3799,3904,4839,5480,2880,4495,3448,6377,6184,5271, +5919,3771,3193,6034,6035,5920,5010,6036,5597,6037,6378,6038,3106,5422,6618,5423, +5424,4142,6619,4889,5084,4890,4313,5740,6620,3437,5175,5307,5816,4199,5198,5529, +5817,5199,5656,4913,5028,5344,3850,6185,2955,5272,5011,5818,4567,4580,5029,5921, +3616,5233,6621,6622,6186,4176,6039,6379,6380,3352,5200,5273,2908,5598,5234,3837, +5308,6623,6624,5819,4496,4323,5309,5201,6625,6626,4983,3194,3838,4167,5530,5922, +5274,6381,6382,3860,3861,5599,3333,4292,4509,6383,3553,5481,5820,5531,4778,6187, +3955,3956,4324,4389,4218,3945,4325,3397,2681,5923,4779,5085,4019,5482,4891,5382, +5383,6040,4682,3425,5275,4094,6627,5310,3015,5483,5657,4398,5924,3168,4819,6628, +5925,6629,5532,4932,4613,6041,6630,4636,6384,4780,4204,5658,4423,5821,3989,4683, +5822,6385,4954,6631,5345,6188,5425,5012,5384,3894,6386,4490,4104,6632,5741,5053, +6633,5823,5926,5659,5660,5927,6634,5235,5742,5824,4840,4933,4820,6387,4859,5928, +4955,6388,4143,3584,5825,5346,5013,6635,5661,6389,5014,5484,5743,4337,5176,5662, +6390,2836,6391,3268,6392,6636,6042,5236,6637,4158,6638,5744,5663,4471,5347,3663, +4123,5143,4293,3895,6639,6640,5311,5929,5826,3800,6189,6393,6190,5664,5348,3554, +3594,4749,4603,6641,5385,4801,6043,5827,4183,6642,5312,5426,4761,6394,5665,6191, +4715,2669,6643,6644,5533,3185,5427,5086,5930,5931,5386,6192,6044,6645,4781,4013, +5745,4282,4435,5534,4390,4267,6045,5746,4984,6046,2743,6193,3501,4087,5485,5932, +5428,4184,4095,5747,4061,5054,3058,3862,5933,5600,6646,5144,3618,6395,3131,5055, +5313,6396,4650,4956,3855,6194,3896,5202,4985,4029,4225,6195,6647,5828,5486,5829, +3589,3002,6648,6397,4782,5276,6649,6196,6650,4105,3803,4043,5237,5830,6398,4096, +3643,6399,3528,6651,4453,3315,4637,6652,3984,6197,5535,3182,3339,6653,3096,2660, +6400,6654,3449,5934,4250,4236,6047,6401,5831,6655,5487,3753,4062,5832,6198,6199, +6656,3766,6657,3403,4667,6048,6658,4338,2897,5833,3880,2797,3780,4326,6659,5748, +5015,6660,5387,4351,5601,4411,6661,3654,4424,5935,4339,4072,5277,4568,5536,6402, +6662,5238,6663,5349,5203,6200,5204,6201,5145,4536,5016,5056,4762,5834,4399,4957, +6202,6403,5666,5749,6664,4340,6665,5936,5177,5667,6666,6667,3459,4668,6404,6668, +6669,4543,6203,6670,4276,6405,4480,5537,6671,4614,5205,5668,6672,3348,2193,4763, +6406,6204,5937,5602,4177,5669,3419,6673,4020,6205,4443,4569,5388,3715,3639,6407, +6049,4058,6206,6674,5938,4544,6050,4185,4294,4841,4651,4615,5488,6207,6408,6051, +5178,3241,3509,5835,6208,4958,5836,4341,5489,5278,6209,2823,5538,5350,5206,5429, +6675,4638,4875,4073,3516,4684,4914,4860,5939,5603,5389,6052,5057,3237,5490,3791, +6676,6409,6677,4821,4915,4106,5351,5058,4243,5539,4244,5604,4842,4916,5239,3028, +3716,5837,5114,5605,5390,5940,5430,6210,4332,6678,5540,4732,3667,3840,6053,4305, +3408,5670,5541,6410,2744,5240,5750,6679,3234,5606,6680,5607,5671,3608,4283,4159, +4400,5352,4783,6681,6411,6682,4491,4802,6211,6412,5941,6413,6414,5542,5751,6683, +4669,3734,5942,6684,6415,5943,5059,3328,4670,4144,4268,6685,6686,6687,6688,4372, +3603,6689,5944,5491,4373,3440,6416,5543,4784,4822,5608,3792,4616,5838,5672,3514, +5391,6417,4892,6690,4639,6691,6054,5673,5839,6055,6692,6056,5392,6212,4038,5544, +5674,4497,6057,6693,5840,4284,5675,4021,4545,5609,6418,4454,6419,6213,4113,4472, +5314,3738,5087,5279,4074,5610,4959,4063,3179,4750,6058,6420,6214,3476,4498,4716, +5431,4960,4685,6215,5241,6694,6421,6216,6695,5841,5945,6422,3748,5946,5179,3905, +5752,5545,5947,4374,6217,4455,6423,4412,6218,4803,5353,6696,3832,5280,6219,4327, +4702,6220,6221,6059,4652,5432,6424,3749,4751,6425,5753,4986,5393,4917,5948,5030, +5754,4861,4733,6426,4703,6697,6222,4671,5949,4546,4961,5180,6223,5031,3316,5281, +6698,4862,4295,4934,5207,3644,6427,5842,5950,6428,6429,4570,5843,5282,6430,6224, +5088,3239,6060,6699,5844,5755,6061,6431,2701,5546,6432,5115,5676,4039,3993,3327, +4752,4425,5315,6433,3941,6434,5677,4617,4604,3074,4581,6225,5433,6435,6226,6062, +4823,5756,5116,6227,3717,5678,4717,5845,6436,5679,5846,6063,5847,6064,3977,3354, +6437,3863,5117,6228,5547,5394,4499,4524,6229,4605,6230,4306,4500,6700,5951,6065, +3693,5952,5089,4366,4918,6701,6231,5548,6232,6702,6438,4704,5434,6703,6704,5953, +4168,6705,5680,3420,6706,5242,4407,6066,3812,5757,5090,5954,4672,4525,3481,5681, +4618,5395,5354,5316,5955,6439,4962,6707,4526,6440,3465,4673,6067,6441,5682,6708, +5435,5492,5758,5683,4619,4571,4674,4804,4893,4686,5493,4753,6233,6068,4269,6442, +6234,5032,4705,5146,5243,5208,5848,6235,6443,4963,5033,4640,4226,6236,5849,3387, +6444,6445,4436,4437,5850,4843,5494,4785,4894,6709,4361,6710,5091,5956,3331,6237, +4987,5549,6069,6711,4342,3517,4473,5317,6070,6712,6071,4706,6446,5017,5355,6713, +6714,4988,5436,6447,4734,5759,6715,4735,4547,4456,4754,6448,5851,6449,6450,3547, +5852,5318,6451,6452,5092,4205,6716,6238,4620,4219,5611,6239,6072,4481,5760,5957, +5958,4059,6240,6453,4227,4537,6241,5761,4030,4186,5244,5209,3761,4457,4876,3337, +5495,5181,6242,5959,5319,5612,5684,5853,3493,5854,6073,4169,5613,5147,4895,6074, +5210,6717,5182,6718,3830,6243,2798,3841,6075,6244,5855,5614,3604,4606,5496,5685, +5118,5356,6719,6454,5960,5357,5961,6720,4145,3935,4621,5119,5962,4261,6721,6455, +4786,5963,4375,4582,6245,6246,6247,6076,5437,4877,5856,3376,4380,6248,4160,6722, +5148,6456,5211,6457,6723,4718,6458,6724,6249,5358,4044,3297,6459,6250,5857,5615, +5497,5245,6460,5498,6725,6251,6252,5550,3793,5499,2959,5396,6461,6462,4572,5093, +5500,5964,3806,4146,6463,4426,5762,5858,6077,6253,4755,3967,4220,5965,6254,4989, +5501,6464,4352,6726,6078,4764,2290,5246,3906,5438,5283,3767,4964,2861,5763,5094, +6255,6256,4622,5616,5859,5860,4707,6727,4285,4708,4824,5617,6257,5551,4787,5212, +4965,4935,4687,6465,6728,6466,5686,6079,3494,4413,2995,5247,5966,5618,6729,5967, +5764,5765,5687,5502,6730,6731,6080,5397,6467,4990,6258,6732,4538,5060,5619,6733, +4719,5688,5439,5018,5149,5284,5503,6734,6081,4607,6259,5120,3645,5861,4583,6260, +4584,4675,5620,4098,5440,6261,4863,2379,3306,4585,5552,5689,4586,5285,6735,4864, +6736,5286,6082,6737,4623,3010,4788,4381,4558,5621,4587,4896,3698,3161,5248,4353, +4045,6262,3754,5183,4588,6738,6263,6739,6740,5622,3936,6741,6468,6742,6264,5095, +6469,4991,5968,6743,4992,6744,6083,4897,6745,4256,5766,4307,3108,3968,4444,5287, +3889,4343,6084,4510,6085,4559,6086,4898,5969,6746,5623,5061,4919,5249,5250,5504, +5441,6265,5320,4878,3242,5862,5251,3428,6087,6747,4237,5624,5442,6266,5553,4539, +6748,2585,3533,5398,4262,6088,5150,4736,4438,6089,6267,5505,4966,6749,6268,6750, +6269,5288,5554,3650,6090,6091,4624,6092,5690,6751,5863,4270,5691,4277,5555,5864, +6752,5692,4720,4865,6470,5151,4688,4825,6753,3094,6754,6471,3235,4653,6755,5213, +5399,6756,3201,4589,5865,4967,6472,5866,6473,5019,3016,6757,5321,4756,3957,4573, +6093,4993,5767,4721,6474,6758,5625,6759,4458,6475,6270,6760,5556,4994,5214,5252, +6271,3875,5768,6094,5034,5506,4376,5769,6761,2120,6476,5253,5770,6762,5771,5970, +3990,5971,5557,5558,5772,6477,6095,2787,4641,5972,5121,6096,6097,6272,6763,3703, +5867,5507,6273,4206,6274,4789,6098,6764,3619,3646,3833,3804,2394,3788,4936,3978, +4866,4899,6099,6100,5559,6478,6765,3599,5868,6101,5869,5870,6275,6766,4527,6767) + diff --git a/fanficdownloader/chardet/gb2312prober.py b/fanficdownloader/chardet/gb2312prober.py new file mode 100644 index 00000000..91eb3925 --- /dev/null +++ b/fanficdownloader/chardet/gb2312prober.py @@ -0,0 +1,41 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from mbcharsetprober import MultiByteCharSetProber +from codingstatemachine import CodingStateMachine +from chardistribution import GB2312DistributionAnalysis +from mbcssm import GB2312SMModel + +class GB2312Prober(MultiByteCharSetProber): + def __init__(self): + MultiByteCharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(GB2312SMModel) + self._mDistributionAnalyzer = GB2312DistributionAnalysis() + self.reset() + + def get_charset_name(self): + return "GB2312" diff --git a/fanficdownloader/chardet/hebrewprober.py b/fanficdownloader/chardet/hebrewprober.py new file mode 100644 index 00000000..a2b1eaa9 --- /dev/null +++ b/fanficdownloader/chardet/hebrewprober.py @@ -0,0 +1,269 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Shy Shalom +# Portions created by the Initial Developer are Copyright (C) 2005 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from charsetprober import CharSetProber +import constants + +# This prober doesn't actually recognize a language or a charset. +# It is a helper prober for the use of the Hebrew model probers + +### General ideas of the Hebrew charset recognition ### +# +# Four main charsets exist in Hebrew: +# "ISO-8859-8" - Visual Hebrew +# "windows-1255" - Logical Hebrew +# "ISO-8859-8-I" - Logical Hebrew +# "x-mac-hebrew" - ?? Logical Hebrew ?? +# +# Both "ISO" charsets use a completely identical set of code points, whereas +# "windows-1255" and "x-mac-hebrew" are two different proper supersets of +# these code points. windows-1255 defines additional characters in the range +# 0x80-0x9F as some misc punctuation marks as well as some Hebrew-specific +# diacritics and additional 'Yiddish' ligature letters in the range 0xc0-0xd6. +# x-mac-hebrew defines similar additional code points but with a different +# mapping. +# +# As far as an average Hebrew text with no diacritics is concerned, all four +# charsets are identical with respect to code points. Meaning that for the +# main Hebrew alphabet, all four map the same values to all 27 Hebrew letters +# (including final letters). +# +# The dominant difference between these charsets is their directionality. +# "Visual" directionality means that the text is ordered as if the renderer is +# not aware of a BIDI rendering algorithm. The renderer sees the text and +# draws it from left to right. The text itself when ordered naturally is read +# backwards. A buffer of Visual Hebrew generally looks like so: +# "[last word of first line spelled backwards] [whole line ordered backwards +# and spelled backwards] [first word of first line spelled backwards] +# [end of line] [last word of second line] ... etc' " +# adding punctuation marks, numbers and English text to visual text is +# naturally also "visual" and from left to right. +# +# "Logical" directionality means the text is ordered "naturally" according to +# the order it is read. It is the responsibility of the renderer to display +# the text from right to left. A BIDI algorithm is used to place general +# punctuation marks, numbers and English text in the text. +# +# Texts in x-mac-hebrew are almost impossible to find on the Internet. From +# what little evidence I could find, it seems that its general directionality +# is Logical. +# +# To sum up all of the above, the Hebrew probing mechanism knows about two +# charsets: +# Visual Hebrew - "ISO-8859-8" - backwards text - Words and sentences are +# backwards while line order is natural. For charset recognition purposes +# the line order is unimportant (In fact, for this implementation, even +# word order is unimportant). +# Logical Hebrew - "windows-1255" - normal, naturally ordered text. +# +# "ISO-8859-8-I" is a subset of windows-1255 and doesn't need to be +# specifically identified. +# "x-mac-hebrew" is also identified as windows-1255. A text in x-mac-hebrew +# that contain special punctuation marks or diacritics is displayed with +# some unconverted characters showing as question marks. This problem might +# be corrected using another model prober for x-mac-hebrew. Due to the fact +# that x-mac-hebrew texts are so rare, writing another model prober isn't +# worth the effort and performance hit. +# +#### The Prober #### +# +# The prober is divided between two SBCharSetProbers and a HebrewProber, +# all of which are managed, created, fed data, inquired and deleted by the +# SBCSGroupProber. The two SBCharSetProbers identify that the text is in +# fact some kind of Hebrew, Logical or Visual. The final decision about which +# one is it is made by the HebrewProber by combining final-letter scores +# with the scores of the two SBCharSetProbers to produce a final answer. +# +# The SBCSGroupProber is responsible for stripping the original text of HTML +# tags, English characters, numbers, low-ASCII punctuation characters, spaces +# and new lines. It reduces any sequence of such characters to a single space. +# The buffer fed to each prober in the SBCS group prober is pure text in +# high-ASCII. +# The two SBCharSetProbers (model probers) share the same language model: +# Win1255Model. +# The first SBCharSetProber uses the model normally as any other +# SBCharSetProber does, to recognize windows-1255, upon which this model was +# built. The second SBCharSetProber is told to make the pair-of-letter +# lookup in the language model backwards. This in practice exactly simulates +# a visual Hebrew model using the windows-1255 logical Hebrew model. +# +# The HebrewProber is not using any language model. All it does is look for +# final-letter evidence suggesting the text is either logical Hebrew or visual +# Hebrew. Disjointed from the model probers, the results of the HebrewProber +# alone are meaningless. HebrewProber always returns 0.00 as confidence +# since it never identifies a charset by itself. Instead, the pointer to the +# HebrewProber is passed to the model probers as a helper "Name Prober". +# When the Group prober receives a positive identification from any prober, +# it asks for the name of the charset identified. If the prober queried is a +# Hebrew model prober, the model prober forwards the call to the +# HebrewProber to make the final decision. In the HebrewProber, the +# decision is made according to the final-letters scores maintained and Both +# model probers scores. The answer is returned in the form of the name of the +# charset identified, either "windows-1255" or "ISO-8859-8". + +# windows-1255 / ISO-8859-8 code points of interest +FINAL_KAF = '\xea' +NORMAL_KAF = '\xeb' +FINAL_MEM = '\xed' +NORMAL_MEM = '\xee' +FINAL_NUN = '\xef' +NORMAL_NUN = '\xf0' +FINAL_PE = '\xf3' +NORMAL_PE = '\xf4' +FINAL_TSADI = '\xf5' +NORMAL_TSADI = '\xf6' + +# Minimum Visual vs Logical final letter score difference. +# If the difference is below this, don't rely solely on the final letter score distance. +MIN_FINAL_CHAR_DISTANCE = 5 + +# Minimum Visual vs Logical model score difference. +# If the difference is below this, don't rely at all on the model score distance. +MIN_MODEL_DISTANCE = 0.01 + +VISUAL_HEBREW_NAME = "ISO-8859-8" +LOGICAL_HEBREW_NAME = "windows-1255" + +class HebrewProber(CharSetProber): + def __init__(self): + CharSetProber.__init__(self) + self._mLogicalProber = None + self._mVisualProber = None + self.reset() + + def reset(self): + self._mFinalCharLogicalScore = 0 + self._mFinalCharVisualScore = 0 + # The two last characters seen in the previous buffer, + # mPrev and mBeforePrev are initialized to space in order to simulate a word + # delimiter at the beginning of the data + self._mPrev = ' ' + self._mBeforePrev = ' ' + # These probers are owned by the group prober. + + def set_model_probers(self, logicalProber, visualProber): + self._mLogicalProber = logicalProber + self._mVisualProber = visualProber + + def is_final(self, c): + return c in [FINAL_KAF, FINAL_MEM, FINAL_NUN, FINAL_PE, FINAL_TSADI] + + def is_non_final(self, c): + # The normal Tsadi is not a good Non-Final letter due to words like + # 'lechotet' (to chat) containing an apostrophe after the tsadi. This + # apostrophe is converted to a space in FilterWithoutEnglishLetters causing + # the Non-Final tsadi to appear at an end of a word even though this is not + # the case in the original text. + # The letters Pe and Kaf rarely display a related behavior of not being a + # good Non-Final letter. Words like 'Pop', 'Winamp' and 'Mubarak' for + # example legally end with a Non-Final Pe or Kaf. However, the benefit of + # these letters as Non-Final letters outweighs the damage since these words + # are quite rare. + return c in [NORMAL_KAF, NORMAL_MEM, NORMAL_NUN, NORMAL_PE] + + def feed(self, aBuf): + # Final letter analysis for logical-visual decision. + # Look for evidence that the received buffer is either logical Hebrew or + # visual Hebrew. + # The following cases are checked: + # 1) A word longer than 1 letter, ending with a final letter. This is an + # indication that the text is laid out "naturally" since the final letter + # really appears at the end. +1 for logical score. + # 2) A word longer than 1 letter, ending with a Non-Final letter. In normal + # Hebrew, words ending with Kaf, Mem, Nun, Pe or Tsadi, should not end with + # the Non-Final form of that letter. Exceptions to this rule are mentioned + # above in isNonFinal(). This is an indication that the text is laid out + # backwards. +1 for visual score + # 3) A word longer than 1 letter, starting with a final letter. Final letters + # should not appear at the beginning of a word. This is an indication that + # the text is laid out backwards. +1 for visual score. + # + # The visual score and logical score are accumulated throughout the text and + # are finally checked against each other in GetCharSetName(). + # No checking for final letters in the middle of words is done since that case + # is not an indication for either Logical or Visual text. + # + # We automatically filter out all 7-bit characters (replace them with spaces) + # so the word boundary detection works properly. [MAP] + + if self.get_state() == constants.eNotMe: + # Both model probers say it's not them. No reason to continue. + return constants.eNotMe + + aBuf = self.filter_high_bit_only(aBuf) + + for cur in aBuf: + if cur == ' ': + # We stand on a space - a word just ended + if self._mBeforePrev != ' ': + # next-to-last char was not a space so self._mPrev is not a 1 letter word + if self.is_final(self._mPrev): + # case (1) [-2:not space][-1:final letter][cur:space] + self._mFinalCharLogicalScore += 1 + elif self.is_non_final(self._mPrev): + # case (2) [-2:not space][-1:Non-Final letter][cur:space] + self._mFinalCharVisualScore += 1 + else: + # Not standing on a space + if (self._mBeforePrev == ' ') and (self.is_final(self._mPrev)) and (cur != ' '): + # case (3) [-2:space][-1:final letter][cur:not space] + self._mFinalCharVisualScore += 1 + self._mBeforePrev = self._mPrev + self._mPrev = cur + + # Forever detecting, till the end or until both model probers return eNotMe (handled above) + return constants.eDetecting + + def get_charset_name(self): + # Make the decision: is it Logical or Visual? + # If the final letter score distance is dominant enough, rely on it. + finalsub = self._mFinalCharLogicalScore - self._mFinalCharVisualScore + if finalsub >= MIN_FINAL_CHAR_DISTANCE: + return LOGICAL_HEBREW_NAME + if finalsub <= -MIN_FINAL_CHAR_DISTANCE: + return VISUAL_HEBREW_NAME + + # It's not dominant enough, try to rely on the model scores instead. + modelsub = self._mLogicalProber.get_confidence() - self._mVisualProber.get_confidence() + if modelsub > MIN_MODEL_DISTANCE: + return LOGICAL_HEBREW_NAME + if modelsub < -MIN_MODEL_DISTANCE: + return VISUAL_HEBREW_NAME + + # Still no good, back to final letter distance, maybe it'll save the day. + if finalsub < 0.0: + return VISUAL_HEBREW_NAME + + # (finalsub > 0 - Logical) or (don't know what to do) default to Logical. + return LOGICAL_HEBREW_NAME + + def get_state(self): + # Remain active as long as any of the model probers are active. + if (self._mLogicalProber.get_state() == constants.eNotMe) and \ + (self._mVisualProber.get_state() == constants.eNotMe): + return constants.eNotMe + return constants.eDetecting diff --git a/fanficdownloader/chardet/jisfreq.py b/fanficdownloader/chardet/jisfreq.py new file mode 100644 index 00000000..5fe4a5c3 --- /dev/null +++ b/fanficdownloader/chardet/jisfreq.py @@ -0,0 +1,567 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +# Sampling from about 20M text materials include literature and computer technology +# +# Japanese frequency table, applied to both S-JIS and EUC-JP +# They are sorted in order. + +# 128 --> 0.77094 +# 256 --> 0.85710 +# 512 --> 0.92635 +# 1024 --> 0.97130 +# 2048 --> 0.99431 +# +# Ideal Distribution Ratio = 0.92635 / (1-0.92635) = 12.58 +# Random Distribution Ration = 512 / (2965+62+83+86-512) = 0.191 +# +# Typical Distribution Ratio, 25% of IDR + +JIS_TYPICAL_DISTRIBUTION_RATIO = 3.0 + +# Char to FreqOrder table , +JIS_TABLE_SIZE = 4368 + +JISCharToFreqOrder = ( \ + 40, 1, 6, 182, 152, 180, 295,2127, 285, 381,3295,4304,3068,4606,3165,3510, # 16 +3511,1822,2785,4607,1193,2226,5070,4608, 171,2996,1247, 18, 179,5071, 856,1661, # 32 +1262,5072, 619, 127,3431,3512,3230,1899,1700, 232, 228,1294,1298, 284, 283,2041, # 48 +2042,1061,1062, 48, 49, 44, 45, 433, 434,1040,1041, 996, 787,2997,1255,4305, # 64 +2108,4609,1684,1648,5073,5074,5075,5076,5077,5078,3687,5079,4610,5080,3927,3928, # 80 +5081,3296,3432, 290,2285,1471,2187,5082,2580,2825,1303,2140,1739,1445,2691,3375, # 96 +1691,3297,4306,4307,4611, 452,3376,1182,2713,3688,3069,4308,5083,5084,5085,5086, # 112 +5087,5088,5089,5090,5091,5092,5093,5094,5095,5096,5097,5098,5099,5100,5101,5102, # 128 +5103,5104,5105,5106,5107,5108,5109,5110,5111,5112,4097,5113,5114,5115,5116,5117, # 144 +5118,5119,5120,5121,5122,5123,5124,5125,5126,5127,5128,5129,5130,5131,5132,5133, # 160 +5134,5135,5136,5137,5138,5139,5140,5141,5142,5143,5144,5145,5146,5147,5148,5149, # 176 +5150,5151,5152,4612,5153,5154,5155,5156,5157,5158,5159,5160,5161,5162,5163,5164, # 192 +5165,5166,5167,5168,5169,5170,5171,5172,5173,5174,5175,1472, 598, 618, 820,1205, # 208 +1309,1412,1858,1307,1692,5176,5177,5178,5179,5180,5181,5182,1142,1452,1234,1172, # 224 +1875,2043,2149,1793,1382,2973, 925,2404,1067,1241, 960,1377,2935,1491, 919,1217, # 240 +1865,2030,1406,1499,2749,4098,5183,5184,5185,5186,5187,5188,2561,4099,3117,1804, # 256 +2049,3689,4309,3513,1663,5189,3166,3118,3298,1587,1561,3433,5190,3119,1625,2998, # 272 +3299,4613,1766,3690,2786,4614,5191,5192,5193,5194,2161, 26,3377, 2,3929, 20, # 288 +3691, 47,4100, 50, 17, 16, 35, 268, 27, 243, 42, 155, 24, 154, 29, 184, # 304 + 4, 91, 14, 92, 53, 396, 33, 289, 9, 37, 64, 620, 21, 39, 321, 5, # 320 + 12, 11, 52, 13, 3, 208, 138, 0, 7, 60, 526, 141, 151,1069, 181, 275, # 336 +1591, 83, 132,1475, 126, 331, 829, 15, 69, 160, 59, 22, 157, 55,1079, 312, # 352 + 109, 38, 23, 25, 10, 19, 79,5195, 61, 382,1124, 8, 30,5196,5197,5198, # 368 +5199,5200,5201,5202,5203,5204,5205,5206, 89, 62, 74, 34,2416, 112, 139, 196, # 384 + 271, 149, 84, 607, 131, 765, 46, 88, 153, 683, 76, 874, 101, 258, 57, 80, # 400 + 32, 364, 121,1508, 169,1547, 68, 235, 145,2999, 41, 360,3027, 70, 63, 31, # 416 + 43, 259, 262,1383, 99, 533, 194, 66, 93, 846, 217, 192, 56, 106, 58, 565, # 432 + 280, 272, 311, 256, 146, 82, 308, 71, 100, 128, 214, 655, 110, 261, 104,1140, # 448 + 54, 51, 36, 87, 67,3070, 185,2618,2936,2020, 28,1066,2390,2059,5207,5208, # 464 +5209,5210,5211,5212,5213,5214,5215,5216,4615,5217,5218,5219,5220,5221,5222,5223, # 480 +5224,5225,5226,5227,5228,5229,5230,5231,5232,5233,5234,5235,5236,3514,5237,5238, # 496 +5239,5240,5241,5242,5243,5244,2297,2031,4616,4310,3692,5245,3071,5246,3598,5247, # 512 +4617,3231,3515,5248,4101,4311,4618,3808,4312,4102,5249,4103,4104,3599,5250,5251, # 528 +5252,5253,5254,5255,5256,5257,5258,5259,5260,5261,5262,5263,5264,5265,5266,5267, # 544 +5268,5269,5270,5271,5272,5273,5274,5275,5276,5277,5278,5279,5280,5281,5282,5283, # 560 +5284,5285,5286,5287,5288,5289,5290,5291,5292,5293,5294,5295,5296,5297,5298,5299, # 576 +5300,5301,5302,5303,5304,5305,5306,5307,5308,5309,5310,5311,5312,5313,5314,5315, # 592 +5316,5317,5318,5319,5320,5321,5322,5323,5324,5325,5326,5327,5328,5329,5330,5331, # 608 +5332,5333,5334,5335,5336,5337,5338,5339,5340,5341,5342,5343,5344,5345,5346,5347, # 624 +5348,5349,5350,5351,5352,5353,5354,5355,5356,5357,5358,5359,5360,5361,5362,5363, # 640 +5364,5365,5366,5367,5368,5369,5370,5371,5372,5373,5374,5375,5376,5377,5378,5379, # 656 +5380,5381, 363, 642,2787,2878,2788,2789,2316,3232,2317,3434,2011, 165,1942,3930, # 672 +3931,3932,3933,5382,4619,5383,4620,5384,5385,5386,5387,5388,5389,5390,5391,5392, # 688 +5393,5394,5395,5396,5397,5398,5399,5400,5401,5402,5403,5404,5405,5406,5407,5408, # 704 +5409,5410,5411,5412,5413,5414,5415,5416,5417,5418,5419,5420,5421,5422,5423,5424, # 720 +5425,5426,5427,5428,5429,5430,5431,5432,5433,5434,5435,5436,5437,5438,5439,5440, # 736 +5441,5442,5443,5444,5445,5446,5447,5448,5449,5450,5451,5452,5453,5454,5455,5456, # 752 +5457,5458,5459,5460,5461,5462,5463,5464,5465,5466,5467,5468,5469,5470,5471,5472, # 768 +5473,5474,5475,5476,5477,5478,5479,5480,5481,5482,5483,5484,5485,5486,5487,5488, # 784 +5489,5490,5491,5492,5493,5494,5495,5496,5497,5498,5499,5500,5501,5502,5503,5504, # 800 +5505,5506,5507,5508,5509,5510,5511,5512,5513,5514,5515,5516,5517,5518,5519,5520, # 816 +5521,5522,5523,5524,5525,5526,5527,5528,5529,5530,5531,5532,5533,5534,5535,5536, # 832 +5537,5538,5539,5540,5541,5542,5543,5544,5545,5546,5547,5548,5549,5550,5551,5552, # 848 +5553,5554,5555,5556,5557,5558,5559,5560,5561,5562,5563,5564,5565,5566,5567,5568, # 864 +5569,5570,5571,5572,5573,5574,5575,5576,5577,5578,5579,5580,5581,5582,5583,5584, # 880 +5585,5586,5587,5588,5589,5590,5591,5592,5593,5594,5595,5596,5597,5598,5599,5600, # 896 +5601,5602,5603,5604,5605,5606,5607,5608,5609,5610,5611,5612,5613,5614,5615,5616, # 912 +5617,5618,5619,5620,5621,5622,5623,5624,5625,5626,5627,5628,5629,5630,5631,5632, # 928 +5633,5634,5635,5636,5637,5638,5639,5640,5641,5642,5643,5644,5645,5646,5647,5648, # 944 +5649,5650,5651,5652,5653,5654,5655,5656,5657,5658,5659,5660,5661,5662,5663,5664, # 960 +5665,5666,5667,5668,5669,5670,5671,5672,5673,5674,5675,5676,5677,5678,5679,5680, # 976 +5681,5682,5683,5684,5685,5686,5687,5688,5689,5690,5691,5692,5693,5694,5695,5696, # 992 +5697,5698,5699,5700,5701,5702,5703,5704,5705,5706,5707,5708,5709,5710,5711,5712, # 1008 +5713,5714,5715,5716,5717,5718,5719,5720,5721,5722,5723,5724,5725,5726,5727,5728, # 1024 +5729,5730,5731,5732,5733,5734,5735,5736,5737,5738,5739,5740,5741,5742,5743,5744, # 1040 +5745,5746,5747,5748,5749,5750,5751,5752,5753,5754,5755,5756,5757,5758,5759,5760, # 1056 +5761,5762,5763,5764,5765,5766,5767,5768,5769,5770,5771,5772,5773,5774,5775,5776, # 1072 +5777,5778,5779,5780,5781,5782,5783,5784,5785,5786,5787,5788,5789,5790,5791,5792, # 1088 +5793,5794,5795,5796,5797,5798,5799,5800,5801,5802,5803,5804,5805,5806,5807,5808, # 1104 +5809,5810,5811,5812,5813,5814,5815,5816,5817,5818,5819,5820,5821,5822,5823,5824, # 1120 +5825,5826,5827,5828,5829,5830,5831,5832,5833,5834,5835,5836,5837,5838,5839,5840, # 1136 +5841,5842,5843,5844,5845,5846,5847,5848,5849,5850,5851,5852,5853,5854,5855,5856, # 1152 +5857,5858,5859,5860,5861,5862,5863,5864,5865,5866,5867,5868,5869,5870,5871,5872, # 1168 +5873,5874,5875,5876,5877,5878,5879,5880,5881,5882,5883,5884,5885,5886,5887,5888, # 1184 +5889,5890,5891,5892,5893,5894,5895,5896,5897,5898,5899,5900,5901,5902,5903,5904, # 1200 +5905,5906,5907,5908,5909,5910,5911,5912,5913,5914,5915,5916,5917,5918,5919,5920, # 1216 +5921,5922,5923,5924,5925,5926,5927,5928,5929,5930,5931,5932,5933,5934,5935,5936, # 1232 +5937,5938,5939,5940,5941,5942,5943,5944,5945,5946,5947,5948,5949,5950,5951,5952, # 1248 +5953,5954,5955,5956,5957,5958,5959,5960,5961,5962,5963,5964,5965,5966,5967,5968, # 1264 +5969,5970,5971,5972,5973,5974,5975,5976,5977,5978,5979,5980,5981,5982,5983,5984, # 1280 +5985,5986,5987,5988,5989,5990,5991,5992,5993,5994,5995,5996,5997,5998,5999,6000, # 1296 +6001,6002,6003,6004,6005,6006,6007,6008,6009,6010,6011,6012,6013,6014,6015,6016, # 1312 +6017,6018,6019,6020,6021,6022,6023,6024,6025,6026,6027,6028,6029,6030,6031,6032, # 1328 +6033,6034,6035,6036,6037,6038,6039,6040,6041,6042,6043,6044,6045,6046,6047,6048, # 1344 +6049,6050,6051,6052,6053,6054,6055,6056,6057,6058,6059,6060,6061,6062,6063,6064, # 1360 +6065,6066,6067,6068,6069,6070,6071,6072,6073,6074,6075,6076,6077,6078,6079,6080, # 1376 +6081,6082,6083,6084,6085,6086,6087,6088,6089,6090,6091,6092,6093,6094,6095,6096, # 1392 +6097,6098,6099,6100,6101,6102,6103,6104,6105,6106,6107,6108,6109,6110,6111,6112, # 1408 +6113,6114,2044,2060,4621, 997,1235, 473,1186,4622, 920,3378,6115,6116, 379,1108, # 1424 +4313,2657,2735,3934,6117,3809, 636,3233, 573,1026,3693,3435,2974,3300,2298,4105, # 1440 + 854,2937,2463, 393,2581,2417, 539, 752,1280,2750,2480, 140,1161, 440, 708,1569, # 1456 + 665,2497,1746,1291,1523,3000, 164,1603, 847,1331, 537,1997, 486, 508,1693,2418, # 1472 +1970,2227, 878,1220, 299,1030, 969, 652,2751, 624,1137,3301,2619, 65,3302,2045, # 1488 +1761,1859,3120,1930,3694,3516, 663,1767, 852, 835,3695, 269, 767,2826,2339,1305, # 1504 + 896,1150, 770,1616,6118, 506,1502,2075,1012,2519, 775,2520,2975,2340,2938,4314, # 1520 +3028,2086,1224,1943,2286,6119,3072,4315,2240,1273,1987,3935,1557, 175, 597, 985, # 1536 +3517,2419,2521,1416,3029, 585, 938,1931,1007,1052,1932,1685,6120,3379,4316,4623, # 1552 + 804, 599,3121,1333,2128,2539,1159,1554,2032,3810, 687,2033,2904, 952, 675,1467, # 1568 +3436,6121,2241,1096,1786,2440,1543,1924, 980,1813,2228, 781,2692,1879, 728,1918, # 1584 +3696,4624, 548,1950,4625,1809,1088,1356,3303,2522,1944, 502, 972, 373, 513,2827, # 1600 + 586,2377,2391,1003,1976,1631,6122,2464,1084, 648,1776,4626,2141, 324, 962,2012, # 1616 +2177,2076,1384, 742,2178,1448,1173,1810, 222, 102, 301, 445, 125,2420, 662,2498, # 1632 + 277, 200,1476,1165,1068, 224,2562,1378,1446, 450,1880, 659, 791, 582,4627,2939, # 1648 +3936,1516,1274, 555,2099,3697,1020,1389,1526,3380,1762,1723,1787,2229, 412,2114, # 1664 +1900,2392,3518, 512,2597, 427,1925,2341,3122,1653,1686,2465,2499, 697, 330, 273, # 1680 + 380,2162, 951, 832, 780, 991,1301,3073, 965,2270,3519, 668,2523,2636,1286, 535, # 1696 +1407, 518, 671, 957,2658,2378, 267, 611,2197,3030,6123, 248,2299, 967,1799,2356, # 1712 + 850,1418,3437,1876,1256,1480,2828,1718,6124,6125,1755,1664,2405,6126,4628,2879, # 1728 +2829, 499,2179, 676,4629, 557,2329,2214,2090, 325,3234, 464, 811,3001, 992,2342, # 1744 +2481,1232,1469, 303,2242, 466,1070,2163, 603,1777,2091,4630,2752,4631,2714, 322, # 1760 +2659,1964,1768, 481,2188,1463,2330,2857,3600,2092,3031,2421,4632,2318,2070,1849, # 1776 +2598,4633,1302,2254,1668,1701,2422,3811,2905,3032,3123,2046,4106,1763,1694,4634, # 1792 +1604, 943,1724,1454, 917, 868,2215,1169,2940, 552,1145,1800,1228,1823,1955, 316, # 1808 +1080,2510, 361,1807,2830,4107,2660,3381,1346,1423,1134,4108,6127, 541,1263,1229, # 1824 +1148,2540, 545, 465,1833,2880,3438,1901,3074,2482, 816,3937, 713,1788,2500, 122, # 1840 +1575, 195,1451,2501,1111,6128, 859, 374,1225,2243,2483,4317, 390,1033,3439,3075, # 1856 +2524,1687, 266, 793,1440,2599, 946, 779, 802, 507, 897,1081, 528,2189,1292, 711, # 1872 +1866,1725,1167,1640, 753, 398,2661,1053, 246, 348,4318, 137,1024,3440,1600,2077, # 1888 +2129, 825,4319, 698, 238, 521, 187,2300,1157,2423,1641,1605,1464,1610,1097,2541, # 1904 +1260,1436, 759,2255,1814,2150, 705,3235, 409,2563,3304, 561,3033,2005,2564, 726, # 1920 +1956,2343,3698,4109, 949,3812,3813,3520,1669, 653,1379,2525, 881,2198, 632,2256, # 1936 +1027, 778,1074, 733,1957, 514,1481,2466, 554,2180, 702,3938,1606,1017,1398,6129, # 1952 +1380,3521, 921, 993,1313, 594, 449,1489,1617,1166, 768,1426,1360, 495,1794,3601, # 1968 +1177,3602,1170,4320,2344, 476, 425,3167,4635,3168,1424, 401,2662,1171,3382,1998, # 1984 +1089,4110, 477,3169, 474,6130,1909, 596,2831,1842, 494, 693,1051,1028,1207,3076, # 2000 + 606,2115, 727,2790,1473,1115, 743,3522, 630, 805,1532,4321,2021, 366,1057, 838, # 2016 + 684,1114,2142,4322,2050,1492,1892,1808,2271,3814,2424,1971,1447,1373,3305,1090, # 2032 +1536,3939,3523,3306,1455,2199, 336, 369,2331,1035, 584,2393, 902, 718,2600,6131, # 2048 +2753, 463,2151,1149,1611,2467, 715,1308,3124,1268, 343,1413,3236,1517,1347,2663, # 2064 +2093,3940,2022,1131,1553,2100,2941,1427,3441,2942,1323,2484,6132,1980, 872,2368, # 2080 +2441,2943, 320,2369,2116,1082, 679,1933,3941,2791,3815, 625,1143,2023, 422,2200, # 2096 +3816,6133, 730,1695, 356,2257,1626,2301,2858,2637,1627,1778, 937, 883,2906,2693, # 2112 +3002,1769,1086, 400,1063,1325,3307,2792,4111,3077, 456,2345,1046, 747,6134,1524, # 2128 + 884,1094,3383,1474,2164,1059, 974,1688,2181,2258,1047, 345,1665,1187, 358, 875, # 2144 +3170, 305, 660,3524,2190,1334,1135,3171,1540,1649,2542,1527, 927, 968,2793, 885, # 2160 +1972,1850, 482, 500,2638,1218,1109,1085,2543,1654,2034, 876, 78,2287,1482,1277, # 2176 + 861,1675,1083,1779, 724,2754, 454, 397,1132,1612,2332, 893, 672,1237, 257,2259, # 2192 +2370, 135,3384, 337,2244, 547, 352, 340, 709,2485,1400, 788,1138,2511, 540, 772, # 2208 +1682,2260,2272,2544,2013,1843,1902,4636,1999,1562,2288,4637,2201,1403,1533, 407, # 2224 + 576,3308,1254,2071, 978,3385, 170, 136,1201,3125,2664,3172,2394, 213, 912, 873, # 2240 +3603,1713,2202, 699,3604,3699, 813,3442, 493, 531,1054, 468,2907,1483, 304, 281, # 2256 +4112,1726,1252,2094, 339,2319,2130,2639, 756,1563,2944, 748, 571,2976,1588,2425, # 2272 +2715,1851,1460,2426,1528,1392,1973,3237, 288,3309, 685,3386, 296, 892,2716,2216, # 2288 +1570,2245, 722,1747,2217, 905,3238,1103,6135,1893,1441,1965, 251,1805,2371,3700, # 2304 +2601,1919,1078, 75,2182,1509,1592,1270,2640,4638,2152,6136,3310,3817, 524, 706, # 2320 +1075, 292,3818,1756,2602, 317, 98,3173,3605,3525,1844,2218,3819,2502, 814, 567, # 2336 + 385,2908,1534,6137, 534,1642,3239, 797,6138,1670,1529, 953,4323, 188,1071, 538, # 2352 + 178, 729,3240,2109,1226,1374,2000,2357,2977, 731,2468,1116,2014,2051,6139,1261, # 2368 +1593, 803,2859,2736,3443, 556, 682, 823,1541,6140,1369,2289,1706,2794, 845, 462, # 2384 +2603,2665,1361, 387, 162,2358,1740, 739,1770,1720,1304,1401,3241,1049, 627,1571, # 2400 +2427,3526,1877,3942,1852,1500, 431,1910,1503, 677, 297,2795, 286,1433,1038,1198, # 2416 +2290,1133,1596,4113,4639,2469,1510,1484,3943,6141,2442, 108, 712,4640,2372, 866, # 2432 +3701,2755,3242,1348, 834,1945,1408,3527,2395,3243,1811, 824, 994,1179,2110,1548, # 2448 +1453, 790,3003, 690,4324,4325,2832,2909,3820,1860,3821, 225,1748, 310, 346,1780, # 2464 +2470, 821,1993,2717,2796, 828, 877,3528,2860,2471,1702,2165,2910,2486,1789, 453, # 2480 + 359,2291,1676, 73,1164,1461,1127,3311, 421, 604, 314,1037, 589, 116,2487, 737, # 2496 + 837,1180, 111, 244, 735,6142,2261,1861,1362, 986, 523, 418, 581,2666,3822, 103, # 2512 + 855, 503,1414,1867,2488,1091, 657,1597, 979, 605,1316,4641,1021,2443,2078,2001, # 2528 +1209, 96, 587,2166,1032, 260,1072,2153, 173, 94, 226,3244, 819,2006,4642,4114, # 2544 +2203, 231,1744, 782, 97,2667, 786,3387, 887, 391, 442,2219,4326,1425,6143,2694, # 2560 + 633,1544,1202, 483,2015, 592,2052,1958,2472,1655, 419, 129,4327,3444,3312,1714, # 2576 +1257,3078,4328,1518,1098, 865,1310,1019,1885,1512,1734, 469,2444, 148, 773, 436, # 2592 +1815,1868,1128,1055,4329,1245,2756,3445,2154,1934,1039,4643, 579,1238, 932,2320, # 2608 + 353, 205, 801, 115,2428, 944,2321,1881, 399,2565,1211, 678, 766,3944, 335,2101, # 2624 +1459,1781,1402,3945,2737,2131,1010, 844, 981,1326,1013, 550,1816,1545,2620,1335, # 2640 +1008, 371,2881, 936,1419,1613,3529,1456,1395,2273,1834,2604,1317,2738,2503, 416, # 2656 +1643,4330, 806,1126, 229, 591,3946,1314,1981,1576,1837,1666, 347,1790, 977,3313, # 2672 + 764,2861,1853, 688,2429,1920,1462, 77, 595, 415,2002,3034, 798,1192,4115,6144, # 2688 +2978,4331,3035,2695,2582,2072,2566, 430,2430,1727, 842,1396,3947,3702, 613, 377, # 2704 + 278, 236,1417,3388,3314,3174, 757,1869, 107,3530,6145,1194, 623,2262, 207,1253, # 2720 +2167,3446,3948, 492,1117,1935, 536,1838,2757,1246,4332, 696,2095,2406,1393,1572, # 2736 +3175,1782, 583, 190, 253,1390,2230, 830,3126,3389, 934,3245,1703,1749,2979,1870, # 2752 +2545,1656,2204, 869,2346,4116,3176,1817, 496,1764,4644, 942,1504, 404,1903,1122, # 2768 +1580,3606,2945,1022, 515, 372,1735, 955,2431,3036,6146,2797,1110,2302,2798, 617, # 2784 +6147, 441, 762,1771,3447,3607,3608,1904, 840,3037, 86, 939,1385, 572,1370,2445, # 2800 +1336, 114,3703, 898, 294, 203,3315, 703,1583,2274, 429, 961,4333,1854,1951,3390, # 2816 +2373,3704,4334,1318,1381, 966,1911,2322,1006,1155, 309, 989, 458,2718,1795,1372, # 2832 +1203, 252,1689,1363,3177, 517,1936, 168,1490, 562, 193,3823,1042,4117,1835, 551, # 2848 + 470,4645, 395, 489,3448,1871,1465,2583,2641, 417,1493, 279,1295, 511,1236,1119, # 2864 + 72,1231,1982,1812,3004, 871,1564, 984,3449,1667,2696,2096,4646,2347,2833,1673, # 2880 +3609, 695,3246,2668, 807,1183,4647, 890, 388,2333,1801,1457,2911,1765,1477,1031, # 2896 +3316,3317,1278,3391,2799,2292,2526, 163,3450,4335,2669,1404,1802,6148,2323,2407, # 2912 +1584,1728,1494,1824,1269, 298, 909,3318,1034,1632, 375, 776,1683,2061, 291, 210, # 2928 +1123, 809,1249,1002,2642,3038, 206,1011,2132, 144, 975, 882,1565, 342, 667, 754, # 2944 +1442,2143,1299,2303,2062, 447, 626,2205,1221,2739,2912,1144,1214,2206,2584, 760, # 2960 +1715, 614, 950,1281,2670,2621, 810, 577,1287,2546,4648, 242,2168, 250,2643, 691, # 2976 + 123,2644, 647, 313,1029, 689,1357,2946,1650, 216, 771,1339,1306, 808,2063, 549, # 2992 + 913,1371,2913,2914,6149,1466,1092,1174,1196,1311,2605,2396,1783,1796,3079, 406, # 3008 +2671,2117,3949,4649, 487,1825,2220,6150,2915, 448,2348,1073,6151,2397,1707, 130, # 3024 + 900,1598, 329, 176,1959,2527,1620,6152,2275,4336,3319,1983,2191,3705,3610,2155, # 3040 +3706,1912,1513,1614,6153,1988, 646, 392,2304,1589,3320,3039,1826,1239,1352,1340, # 3056 +2916, 505,2567,1709,1437,2408,2547, 906,6154,2672, 384,1458,1594,1100,1329, 710, # 3072 + 423,3531,2064,2231,2622,1989,2673,1087,1882, 333, 841,3005,1296,2882,2379, 580, # 3088 +1937,1827,1293,2585, 601, 574, 249,1772,4118,2079,1120, 645, 901,1176,1690, 795, # 3104 +2207, 478,1434, 516,1190,1530, 761,2080, 930,1264, 355, 435,1552, 644,1791, 987, # 3120 + 220,1364,1163,1121,1538, 306,2169,1327,1222, 546,2645, 218, 241, 610,1704,3321, # 3136 +1984,1839,1966,2528, 451,6155,2586,3707,2568, 907,3178, 254,2947, 186,1845,4650, # 3152 + 745, 432,1757, 428,1633, 888,2246,2221,2489,3611,2118,1258,1265, 956,3127,1784, # 3168 +4337,2490, 319, 510, 119, 457,3612, 274,2035,2007,4651,1409,3128, 970,2758, 590, # 3184 +2800, 661,2247,4652,2008,3950,1420,1549,3080,3322,3951,1651,1375,2111, 485,2491, # 3200 +1429,1156,6156,2548,2183,1495, 831,1840,2529,2446, 501,1657, 307,1894,3247,1341, # 3216 + 666, 899,2156,1539,2549,1559, 886, 349,2208,3081,2305,1736,3824,2170,2759,1014, # 3232 +1913,1386, 542,1397,2948, 490, 368, 716, 362, 159, 282,2569,1129,1658,1288,1750, # 3248 +2674, 276, 649,2016, 751,1496, 658,1818,1284,1862,2209,2087,2512,3451, 622,2834, # 3264 + 376, 117,1060,2053,1208,1721,1101,1443, 247,1250,3179,1792,3952,2760,2398,3953, # 3280 +6157,2144,3708, 446,2432,1151,2570,3452,2447,2761,2835,1210,2448,3082, 424,2222, # 3296 +1251,2449,2119,2836, 504,1581,4338, 602, 817, 857,3825,2349,2306, 357,3826,1470, # 3312 +1883,2883, 255, 958, 929,2917,3248, 302,4653,1050,1271,1751,2307,1952,1430,2697, # 3328 +2719,2359, 354,3180, 777, 158,2036,4339,1659,4340,4654,2308,2949,2248,1146,2232, # 3344 +3532,2720,1696,2623,3827,6158,3129,1550,2698,1485,1297,1428, 637, 931,2721,2145, # 3360 + 914,2550,2587, 81,2450, 612, 827,2646,1242,4655,1118,2884, 472,1855,3181,3533, # 3376 +3534, 569,1353,2699,1244,1758,2588,4119,2009,2762,2171,3709,1312,1531,6159,1152, # 3392 +1938, 134,1830, 471,3710,2276,1112,1535,3323,3453,3535, 982,1337,2950, 488, 826, # 3408 + 674,1058,1628,4120,2017, 522,2399, 211, 568,1367,3454, 350, 293,1872,1139,3249, # 3424 +1399,1946,3006,1300,2360,3324, 588, 736,6160,2606, 744, 669,3536,3828,6161,1358, # 3440 + 199, 723, 848, 933, 851,1939,1505,1514,1338,1618,1831,4656,1634,3613, 443,2740, # 3456 +3829, 717,1947, 491,1914,6162,2551,1542,4121,1025,6163,1099,1223, 198,3040,2722, # 3472 + 370, 410,1905,2589, 998,1248,3182,2380, 519,1449,4122,1710, 947, 928,1153,4341, # 3488 +2277, 344,2624,1511, 615, 105, 161,1212,1076,1960,3130,2054,1926,1175,1906,2473, # 3504 + 414,1873,2801,6164,2309, 315,1319,3325, 318,2018,2146,2157, 963, 631, 223,4342, # 3520 +4343,2675, 479,3711,1197,2625,3712,2676,2361,6165,4344,4123,6166,2451,3183,1886, # 3536 +2184,1674,1330,1711,1635,1506, 799, 219,3250,3083,3954,1677,3713,3326,2081,3614, # 3552 +1652,2073,4657,1147,3041,1752, 643,1961, 147,1974,3955,6167,1716,2037, 918,3007, # 3568 +1994, 120,1537, 118, 609,3184,4345, 740,3455,1219, 332,1615,3830,6168,1621,2980, # 3584 +1582, 783, 212, 553,2350,3714,1349,2433,2082,4124, 889,6169,2310,1275,1410, 973, # 3600 + 166,1320,3456,1797,1215,3185,2885,1846,2590,2763,4658, 629, 822,3008, 763, 940, # 3616 +1990,2862, 439,2409,1566,1240,1622, 926,1282,1907,2764, 654,2210,1607, 327,1130, # 3632 +3956,1678,1623,6170,2434,2192, 686, 608,3831,3715, 903,3957,3042,6171,2741,1522, # 3648 +1915,1105,1555,2552,1359, 323,3251,4346,3457, 738,1354,2553,2311,2334,1828,2003, # 3664 +3832,1753,2351,1227,6172,1887,4125,1478,6173,2410,1874,1712,1847, 520,1204,2607, # 3680 + 264,4659, 836,2677,2102, 600,4660,3833,2278,3084,6174,4347,3615,1342, 640, 532, # 3696 + 543,2608,1888,2400,2591,1009,4348,1497, 341,1737,3616,2723,1394, 529,3252,1321, # 3712 + 983,4661,1515,2120, 971,2592, 924, 287,1662,3186,4349,2700,4350,1519, 908,1948, # 3728 +2452, 156, 796,1629,1486,2223,2055, 694,4126,1259,1036,3392,1213,2249,2742,1889, # 3744 +1230,3958,1015, 910, 408, 559,3617,4662, 746, 725, 935,4663,3959,3009,1289, 563, # 3760 + 867,4664,3960,1567,2981,2038,2626, 988,2263,2381,4351, 143,2374, 704,1895,6175, # 3776 +1188,3716,2088, 673,3085,2362,4352, 484,1608,1921,2765,2918, 215, 904,3618,3537, # 3792 + 894, 509, 976,3043,2701,3961,4353,2837,2982, 498,6176,6177,1102,3538,1332,3393, # 3808 +1487,1636,1637, 233, 245,3962, 383, 650, 995,3044, 460,1520,1206,2352, 749,3327, # 3824 + 530, 700, 389,1438,1560,1773,3963,2264, 719,2951,2724,3834, 870,1832,1644,1000, # 3840 + 839,2474,3717, 197,1630,3394, 365,2886,3964,1285,2133, 734, 922, 818,1106, 732, # 3856 + 480,2083,1774,3458, 923,2279,1350, 221,3086, 85,2233,2234,3835,1585,3010,2147, # 3872 +1387,1705,2382,1619,2475, 133, 239,2802,1991,1016,2084,2383, 411,2838,1113, 651, # 3888 +1985,1160,3328, 990,1863,3087,1048,1276,2647, 265,2627,1599,3253,2056, 150, 638, # 3904 +2019, 656, 853, 326,1479, 680,1439,4354,1001,1759, 413,3459,3395,2492,1431, 459, # 3920 +4355,1125,3329,2265,1953,1450,2065,2863, 849, 351,2678,3131,3254,3255,1104,1577, # 3936 + 227,1351,1645,2453,2193,1421,2887, 812,2121, 634, 95,2435, 201,2312,4665,1646, # 3952 +1671,2743,1601,2554,2702,2648,2280,1315,1366,2089,3132,1573,3718,3965,1729,1189, # 3968 + 328,2679,1077,1940,1136, 558,1283, 964,1195, 621,2074,1199,1743,3460,3619,1896, # 3984 +1916,1890,3836,2952,1154,2112,1064, 862, 378,3011,2066,2113,2803,1568,2839,6178, # 4000 +3088,2919,1941,1660,2004,1992,2194, 142, 707,1590,1708,1624,1922,1023,1836,1233, # 4016 +1004,2313, 789, 741,3620,6179,1609,2411,1200,4127,3719,3720,4666,2057,3721, 593, # 4032 +2840, 367,2920,1878,6180,3461,1521, 628,1168, 692,2211,2649, 300, 720,2067,2571, # 4048 +2953,3396, 959,2504,3966,3539,3462,1977, 701,6181, 954,1043, 800, 681, 183,3722, # 4064 +1803,1730,3540,4128,2103, 815,2314, 174, 467, 230,2454,1093,2134, 755,3541,3397, # 4080 +1141,1162,6182,1738,2039, 270,3256,2513,1005,1647,2185,3837, 858,1679,1897,1719, # 4096 +2954,2324,1806, 402, 670, 167,4129,1498,2158,2104, 750,6183, 915, 189,1680,1551, # 4112 + 455,4356,1501,2455, 405,1095,2955, 338,1586,1266,1819, 570, 641,1324, 237,1556, # 4128 +2650,1388,3723,6184,1368,2384,1343,1978,3089,2436, 879,3724, 792,1191, 758,3012, # 4144 +1411,2135,1322,4357, 240,4667,1848,3725,1574,6185, 420,3045,1546,1391, 714,4358, # 4160 +1967, 941,1864, 863, 664, 426, 560,1731,2680,1785,2864,1949,2363, 403,3330,1415, # 4176 +1279,2136,1697,2335, 204, 721,2097,3838, 90,6186,2085,2505, 191,3967, 124,2148, # 4192 +1376,1798,1178,1107,1898,1405, 860,4359,1243,1272,2375,2983,1558,2456,1638, 113, # 4208 +3621, 578,1923,2609, 880, 386,4130, 784,2186,2266,1422,2956,2172,1722, 497, 263, # 4224 +2514,1267,2412,2610, 177,2703,3542, 774,1927,1344, 616,1432,1595,1018, 172,4360, # 4240 +2325, 911,4361, 438,1468,3622, 794,3968,2024,2173,1681,1829,2957, 945, 895,3090, # 4256 + 575,2212,2476, 475,2401,2681, 785,2744,1745,2293,2555,1975,3133,2865, 394,4668, # 4272 +3839, 635,4131, 639, 202,1507,2195,2766,1345,1435,2572,3726,1908,1184,1181,2457, # 4288 +3727,3134,4362, 843,2611, 437, 916,4669, 234, 769,1884,3046,3047,3623, 833,6187, # 4304 +1639,2250,2402,1355,1185,2010,2047, 999, 525,1732,1290,1488,2612, 948,1578,3728, # 4320 +2413,2477,1216,2725,2159, 334,3840,1328,3624,2921,1525,4132, 564,1056, 891,4363, # 4336 +1444,1698,2385,2251,3729,1365,2281,2235,1717,6188, 864,3841,2515, 444, 527,2767, # 4352 +2922,3625, 544, 461,6189, 566, 209,2437,3398,2098,1065,2068,3331,3626,3257,2137, # 4368 #last 512 +#Everything below is of no interest for detection purpose +2138,2122,3730,2888,1995,1820,1044,6190,6191,6192,6193,6194,6195,6196,6197,6198, # 4384 +6199,6200,6201,6202,6203,6204,6205,4670,6206,6207,6208,6209,6210,6211,6212,6213, # 4400 +6214,6215,6216,6217,6218,6219,6220,6221,6222,6223,6224,6225,6226,6227,6228,6229, # 4416 +6230,6231,6232,6233,6234,6235,6236,6237,3187,6238,6239,3969,6240,6241,6242,6243, # 4432 +6244,4671,6245,6246,4672,6247,6248,4133,6249,6250,4364,6251,2923,2556,2613,4673, # 4448 +4365,3970,6252,6253,6254,6255,4674,6256,6257,6258,2768,2353,4366,4675,4676,3188, # 4464 +4367,3463,6259,4134,4677,4678,6260,2267,6261,3842,3332,4368,3543,6262,6263,6264, # 4480 +3013,1954,1928,4135,4679,6265,6266,2478,3091,6267,4680,4369,6268,6269,1699,6270, # 4496 +3544,4136,4681,6271,4137,6272,4370,2804,6273,6274,2593,3971,3972,4682,6275,2236, # 4512 +4683,6276,6277,4684,6278,6279,4138,3973,4685,6280,6281,3258,6282,6283,6284,6285, # 4528 +3974,4686,2841,3975,6286,6287,3545,6288,6289,4139,4687,4140,6290,4141,6291,4142, # 4544 +6292,6293,3333,6294,6295,6296,4371,6297,3399,6298,6299,4372,3976,6300,6301,6302, # 4560 +4373,6303,6304,3843,3731,6305,4688,4374,6306,6307,3259,2294,6308,3732,2530,4143, # 4576 +6309,4689,6310,6311,6312,3048,6313,6314,4690,3733,2237,6315,6316,2282,3334,6317, # 4592 +6318,3844,6319,6320,4691,6321,3400,4692,6322,4693,6323,3049,6324,4375,6325,3977, # 4608 +6326,6327,6328,3546,6329,4694,3335,6330,4695,4696,6331,6332,6333,6334,4376,3978, # 4624 +6335,4697,3979,4144,6336,3980,4698,6337,6338,6339,6340,6341,4699,4700,4701,6342, # 4640 +6343,4702,6344,6345,4703,6346,6347,4704,6348,4705,4706,3135,6349,4707,6350,4708, # 4656 +6351,4377,6352,4709,3734,4145,6353,2506,4710,3189,6354,3050,4711,3981,6355,3547, # 4672 +3014,4146,4378,3735,2651,3845,3260,3136,2224,1986,6356,3401,6357,4712,2594,3627, # 4688 +3137,2573,3736,3982,4713,3628,4714,4715,2682,3629,4716,6358,3630,4379,3631,6359, # 4704 +6360,6361,3983,6362,6363,6364,6365,4147,3846,4717,6366,6367,3737,2842,6368,4718, # 4720 +2628,6369,3261,6370,2386,6371,6372,3738,3984,4719,3464,4720,3402,6373,2924,3336, # 4736 +4148,2866,6374,2805,3262,4380,2704,2069,2531,3138,2806,2984,6375,2769,6376,4721, # 4752 +4722,3403,6377,6378,3548,6379,6380,2705,3092,1979,4149,2629,3337,2889,6381,3338, # 4768 +4150,2557,3339,4381,6382,3190,3263,3739,6383,4151,4723,4152,2558,2574,3404,3191, # 4784 +6384,6385,4153,6386,4724,4382,6387,6388,4383,6389,6390,4154,6391,4725,3985,6392, # 4800 +3847,4155,6393,6394,6395,6396,6397,3465,6398,4384,6399,6400,6401,6402,6403,6404, # 4816 +4156,6405,6406,6407,6408,2123,6409,6410,2326,3192,4726,6411,6412,6413,6414,4385, # 4832 +4157,6415,6416,4158,6417,3093,3848,6418,3986,6419,6420,3849,6421,6422,6423,4159, # 4848 +6424,6425,4160,6426,3740,6427,6428,6429,6430,3987,6431,4727,6432,2238,6433,6434, # 4864 +4386,3988,6435,6436,3632,6437,6438,2843,6439,6440,6441,6442,3633,6443,2958,6444, # 4880 +6445,3466,6446,2364,4387,3850,6447,4388,2959,3340,6448,3851,6449,4728,6450,6451, # 4896 +3264,4729,6452,3193,6453,4389,4390,2706,3341,4730,6454,3139,6455,3194,6456,3051, # 4912 +2124,3852,1602,4391,4161,3853,1158,3854,4162,3989,4392,3990,4731,4732,4393,2040, # 4928 +4163,4394,3265,6457,2807,3467,3855,6458,6459,6460,3991,3468,4733,4734,6461,3140, # 4944 +2960,6462,4735,6463,6464,6465,6466,4736,4737,4738,4739,6467,6468,4164,2403,3856, # 4960 +6469,6470,2770,2844,6471,4740,6472,6473,6474,6475,6476,6477,6478,3195,6479,4741, # 4976 +4395,6480,2867,6481,4742,2808,6482,2493,4165,6483,6484,6485,6486,2295,4743,6487, # 4992 +6488,6489,3634,6490,6491,6492,6493,6494,6495,6496,2985,4744,6497,6498,4745,6499, # 5008 +6500,2925,3141,4166,6501,6502,4746,6503,6504,4747,6505,6506,6507,2890,6508,6509, # 5024 +6510,6511,6512,6513,6514,6515,6516,6517,6518,6519,3469,4167,6520,6521,6522,4748, # 5040 +4396,3741,4397,4749,4398,3342,2125,4750,6523,4751,4752,4753,3052,6524,2961,4168, # 5056 +6525,4754,6526,4755,4399,2926,4169,6527,3857,6528,4400,4170,6529,4171,6530,6531, # 5072 +2595,6532,6533,6534,6535,3635,6536,6537,6538,6539,6540,6541,6542,4756,6543,6544, # 5088 +6545,6546,6547,6548,4401,6549,6550,6551,6552,4402,3405,4757,4403,6553,6554,6555, # 5104 +4172,3742,6556,6557,6558,3992,3636,6559,6560,3053,2726,6561,3549,4173,3054,4404, # 5120 +6562,6563,3993,4405,3266,3550,2809,4406,6564,6565,6566,4758,4759,6567,3743,6568, # 5136 +4760,3744,4761,3470,6569,6570,6571,4407,6572,3745,4174,6573,4175,2810,4176,3196, # 5152 +4762,6574,4177,6575,6576,2494,2891,3551,6577,6578,3471,6579,4408,6580,3015,3197, # 5168 +6581,3343,2532,3994,3858,6582,3094,3406,4409,6583,2892,4178,4763,4410,3016,4411, # 5184 +6584,3995,3142,3017,2683,6585,4179,6586,6587,4764,4412,6588,6589,4413,6590,2986, # 5200 +6591,2962,3552,6592,2963,3472,6593,6594,4180,4765,6595,6596,2225,3267,4414,6597, # 5216 +3407,3637,4766,6598,6599,3198,6600,4415,6601,3859,3199,6602,3473,4767,2811,4416, # 5232 +1856,3268,3200,2575,3996,3997,3201,4417,6603,3095,2927,6604,3143,6605,2268,6606, # 5248 +3998,3860,3096,2771,6607,6608,3638,2495,4768,6609,3861,6610,3269,2745,4769,4181, # 5264 +3553,6611,2845,3270,6612,6613,6614,3862,6615,6616,4770,4771,6617,3474,3999,4418, # 5280 +4419,6618,3639,3344,6619,4772,4182,6620,2126,6621,6622,6623,4420,4773,6624,3018, # 5296 +6625,4774,3554,6626,4183,2025,3746,6627,4184,2707,6628,4421,4422,3097,1775,4185, # 5312 +3555,6629,6630,2868,6631,6632,4423,6633,6634,4424,2414,2533,2928,6635,4186,2387, # 5328 +6636,4775,6637,4187,6638,1891,4425,3202,3203,6639,6640,4776,6641,3345,6642,6643, # 5344 +3640,6644,3475,3346,3641,4000,6645,3144,6646,3098,2812,4188,3642,3204,6647,3863, # 5360 +3476,6648,3864,6649,4426,4001,6650,6651,6652,2576,6653,4189,4777,6654,6655,6656, # 5376 +2846,6657,3477,3205,4002,6658,4003,6659,3347,2252,6660,6661,6662,4778,6663,6664, # 5392 +6665,6666,6667,6668,6669,4779,4780,2048,6670,3478,3099,6671,3556,3747,4004,6672, # 5408 +6673,6674,3145,4005,3748,6675,6676,6677,6678,6679,3408,6680,6681,6682,6683,3206, # 5424 +3207,6684,6685,4781,4427,6686,4782,4783,4784,6687,6688,6689,4190,6690,6691,3479, # 5440 +6692,2746,6693,4428,6694,6695,6696,6697,6698,6699,4785,6700,6701,3208,2727,6702, # 5456 +3146,6703,6704,3409,2196,6705,4429,6706,6707,6708,2534,1996,6709,6710,6711,2747, # 5472 +6712,6713,6714,4786,3643,6715,4430,4431,6716,3557,6717,4432,4433,6718,6719,6720, # 5488 +6721,3749,6722,4006,4787,6723,6724,3644,4788,4434,6725,6726,4789,2772,6727,6728, # 5504 +6729,6730,6731,2708,3865,2813,4435,6732,6733,4790,4791,3480,6734,6735,6736,6737, # 5520 +4436,3348,6738,3410,4007,6739,6740,4008,6741,6742,4792,3411,4191,6743,6744,6745, # 5536 +6746,6747,3866,6748,3750,6749,6750,6751,6752,6753,6754,6755,3867,6756,4009,6757, # 5552 +4793,4794,6758,2814,2987,6759,6760,6761,4437,6762,6763,6764,6765,3645,6766,6767, # 5568 +3481,4192,6768,3751,6769,6770,2174,6771,3868,3752,6772,6773,6774,4193,4795,4438, # 5584 +3558,4796,4439,6775,4797,6776,6777,4798,6778,4799,3559,4800,6779,6780,6781,3482, # 5600 +6782,2893,6783,6784,4194,4801,4010,6785,6786,4440,6787,4011,6788,6789,6790,6791, # 5616 +6792,6793,4802,6794,6795,6796,4012,6797,6798,6799,6800,3349,4803,3483,6801,4804, # 5632 +4195,6802,4013,6803,6804,4196,6805,4014,4015,6806,2847,3271,2848,6807,3484,6808, # 5648 +6809,6810,4441,6811,4442,4197,4443,3272,4805,6812,3412,4016,1579,6813,6814,4017, # 5664 +6815,3869,6816,2964,6817,4806,6818,6819,4018,3646,6820,6821,4807,4019,4020,6822, # 5680 +6823,3560,6824,6825,4021,4444,6826,4198,6827,6828,4445,6829,6830,4199,4808,6831, # 5696 +6832,6833,3870,3019,2458,6834,3753,3413,3350,6835,4809,3871,4810,3561,4446,6836, # 5712 +6837,4447,4811,4812,6838,2459,4448,6839,4449,6840,6841,4022,3872,6842,4813,4814, # 5728 +6843,6844,4815,4200,4201,4202,6845,4023,6846,6847,4450,3562,3873,6848,6849,4816, # 5744 +4817,6850,4451,4818,2139,6851,3563,6852,6853,3351,6854,6855,3352,4024,2709,3414, # 5760 +4203,4452,6856,4204,6857,6858,3874,3875,6859,6860,4819,6861,6862,6863,6864,4453, # 5776 +3647,6865,6866,4820,6867,6868,6869,6870,4454,6871,2869,6872,6873,4821,6874,3754, # 5792 +6875,4822,4205,6876,6877,6878,3648,4206,4455,6879,4823,6880,4824,3876,6881,3055, # 5808 +4207,6882,3415,6883,6884,6885,4208,4209,6886,4210,3353,6887,3354,3564,3209,3485, # 5824 +2652,6888,2728,6889,3210,3755,6890,4025,4456,6891,4825,6892,6893,6894,6895,4211, # 5840 +6896,6897,6898,4826,6899,6900,4212,6901,4827,6902,2773,3565,6903,4828,6904,6905, # 5856 +6906,6907,3649,3650,6908,2849,3566,6909,3567,3100,6910,6911,6912,6913,6914,6915, # 5872 +4026,6916,3355,4829,3056,4457,3756,6917,3651,6918,4213,3652,2870,6919,4458,6920, # 5888 +2438,6921,6922,3757,2774,4830,6923,3356,4831,4832,6924,4833,4459,3653,2507,6925, # 5904 +4834,2535,6926,6927,3273,4027,3147,6928,3568,6929,6930,6931,4460,6932,3877,4461, # 5920 +2729,3654,6933,6934,6935,6936,2175,4835,2630,4214,4028,4462,4836,4215,6937,3148, # 5936 +4216,4463,4837,4838,4217,6938,6939,2850,4839,6940,4464,6941,6942,6943,4840,6944, # 5952 +4218,3274,4465,6945,6946,2710,6947,4841,4466,6948,6949,2894,6950,6951,4842,6952, # 5968 +4219,3057,2871,6953,6954,6955,6956,4467,6957,2711,6958,6959,6960,3275,3101,4843, # 5984 +6961,3357,3569,6962,4844,6963,6964,4468,4845,3570,6965,3102,4846,3758,6966,4847, # 6000 +3878,4848,4849,4029,6967,2929,3879,4850,4851,6968,6969,1733,6970,4220,6971,6972, # 6016 +6973,6974,6975,6976,4852,6977,6978,6979,6980,6981,6982,3759,6983,6984,6985,3486, # 6032 +3487,6986,3488,3416,6987,6988,6989,6990,6991,6992,6993,6994,6995,6996,6997,4853, # 6048 +6998,6999,4030,7000,7001,3211,7002,7003,4221,7004,7005,3571,4031,7006,3572,7007, # 6064 +2614,4854,2577,7008,7009,2965,3655,3656,4855,2775,3489,3880,4222,4856,3881,4032, # 6080 +3882,3657,2730,3490,4857,7010,3149,7011,4469,4858,2496,3491,4859,2283,7012,7013, # 6096 +7014,2365,4860,4470,7015,7016,3760,7017,7018,4223,1917,7019,7020,7021,4471,7022, # 6112 +2776,4472,7023,7024,7025,7026,4033,7027,3573,4224,4861,4034,4862,7028,7029,1929, # 6128 +3883,4035,7030,4473,3058,7031,2536,3761,3884,7032,4036,7033,2966,2895,1968,4474, # 6144 +3276,4225,3417,3492,4226,2105,7034,7035,1754,2596,3762,4227,4863,4475,3763,4864, # 6160 +3764,2615,2777,3103,3765,3658,3418,4865,2296,3766,2815,7036,7037,7038,3574,2872, # 6176 +3277,4476,7039,4037,4477,7040,7041,4038,7042,7043,7044,7045,7046,7047,2537,7048, # 6192 +7049,7050,7051,7052,7053,7054,4478,7055,7056,3767,3659,4228,3575,7057,7058,4229, # 6208 +7059,7060,7061,3660,7062,3212,7063,3885,4039,2460,7064,7065,7066,7067,7068,7069, # 6224 +7070,7071,7072,7073,7074,4866,3768,4867,7075,7076,7077,7078,4868,3358,3278,2653, # 6240 +7079,7080,4479,3886,7081,7082,4869,7083,7084,7085,7086,7087,7088,2538,7089,7090, # 6256 +7091,4040,3150,3769,4870,4041,2896,3359,4230,2930,7092,3279,7093,2967,4480,3213, # 6272 +4481,3661,7094,7095,7096,7097,7098,7099,7100,7101,7102,2461,3770,7103,7104,4231, # 6288 +3151,7105,7106,7107,4042,3662,7108,7109,4871,3663,4872,4043,3059,7110,7111,7112, # 6304 +3493,2988,7113,4873,7114,7115,7116,3771,4874,7117,7118,4232,4875,7119,3576,2336, # 6320 +4876,7120,4233,3419,4044,4877,4878,4482,4483,4879,4484,4234,7121,3772,4880,1045, # 6336 +3280,3664,4881,4882,7122,7123,7124,7125,4883,7126,2778,7127,4485,4486,7128,4884, # 6352 +3214,3887,7129,7130,3215,7131,4885,4045,7132,7133,4046,7134,7135,7136,7137,7138, # 6368 +7139,7140,7141,7142,7143,4235,7144,4886,7145,7146,7147,4887,7148,7149,7150,4487, # 6384 +4047,4488,7151,7152,4888,4048,2989,3888,7153,3665,7154,4049,7155,7156,7157,7158, # 6400 +7159,7160,2931,4889,4890,4489,7161,2631,3889,4236,2779,7162,7163,4891,7164,3060, # 6416 +7165,1672,4892,7166,4893,4237,3281,4894,7167,7168,3666,7169,3494,7170,7171,4050, # 6432 +7172,7173,3104,3360,3420,4490,4051,2684,4052,7174,4053,7175,7176,7177,2253,4054, # 6448 +7178,7179,4895,7180,3152,3890,3153,4491,3216,7181,7182,7183,2968,4238,4492,4055, # 6464 +7184,2990,7185,2479,7186,7187,4493,7188,7189,7190,7191,7192,4896,7193,4897,2969, # 6480 +4494,4898,7194,3495,7195,7196,4899,4495,7197,3105,2731,7198,4900,7199,7200,7201, # 6496 +4056,7202,3361,7203,7204,4496,4901,4902,7205,4497,7206,7207,2315,4903,7208,4904, # 6512 +7209,4905,2851,7210,7211,3577,7212,3578,4906,7213,4057,3667,4907,7214,4058,2354, # 6528 +3891,2376,3217,3773,7215,7216,7217,7218,7219,4498,7220,4908,3282,2685,7221,3496, # 6544 +4909,2632,3154,4910,7222,2337,7223,4911,7224,7225,7226,4912,4913,3283,4239,4499, # 6560 +7227,2816,7228,7229,7230,7231,7232,7233,7234,4914,4500,4501,7235,7236,7237,2686, # 6576 +7238,4915,7239,2897,4502,7240,4503,7241,2516,7242,4504,3362,3218,7243,7244,7245, # 6592 +4916,7246,7247,4505,3363,7248,7249,7250,7251,3774,4506,7252,7253,4917,7254,7255, # 6608 +3284,2991,4918,4919,3219,3892,4920,3106,3497,4921,7256,7257,7258,4922,7259,4923, # 6624 +3364,4507,4508,4059,7260,4240,3498,7261,7262,4924,7263,2992,3893,4060,3220,7264, # 6640 +7265,7266,7267,7268,7269,4509,3775,7270,2817,7271,4061,4925,4510,3776,7272,4241, # 6656 +4511,3285,7273,7274,3499,7275,7276,7277,4062,4512,4926,7278,3107,3894,7279,7280, # 6672 +4927,7281,4513,7282,7283,3668,7284,7285,4242,4514,4243,7286,2058,4515,4928,4929, # 6688 +4516,7287,3286,4244,7288,4517,7289,7290,7291,3669,7292,7293,4930,4931,4932,2355, # 6704 +4933,7294,2633,4518,7295,4245,7296,7297,4519,7298,7299,4520,4521,4934,7300,4246, # 6720 +4522,7301,7302,7303,3579,7304,4247,4935,7305,4936,7306,7307,7308,7309,3777,7310, # 6736 +4523,7311,7312,7313,4248,3580,7314,4524,3778,4249,7315,3581,7316,3287,7317,3221, # 6752 +7318,4937,7319,7320,7321,7322,7323,7324,4938,4939,7325,4525,7326,7327,7328,4063, # 6768 +7329,7330,4940,7331,7332,4941,7333,4526,7334,3500,2780,1741,4942,2026,1742,7335, # 6784 +7336,3582,4527,2388,7337,7338,7339,4528,7340,4250,4943,7341,7342,7343,4944,7344, # 6800 +7345,7346,3020,7347,4945,7348,7349,7350,7351,3895,7352,3896,4064,3897,7353,7354, # 6816 +7355,4251,7356,7357,3898,7358,3779,7359,3780,3288,7360,7361,4529,7362,4946,4530, # 6832 +2027,7363,3899,4531,4947,3222,3583,7364,4948,7365,7366,7367,7368,4949,3501,4950, # 6848 +3781,4951,4532,7369,2517,4952,4252,4953,3155,7370,4954,4955,4253,2518,4533,7371, # 6864 +7372,2712,4254,7373,7374,7375,3670,4956,3671,7376,2389,3502,4065,7377,2338,7378, # 6880 +7379,7380,7381,3061,7382,4957,7383,7384,7385,7386,4958,4534,7387,7388,2993,7389, # 6896 +3062,7390,4959,7391,7392,7393,4960,3108,4961,7394,4535,7395,4962,3421,4536,7396, # 6912 +4963,7397,4964,1857,7398,4965,7399,7400,2176,3584,4966,7401,7402,3422,4537,3900, # 6928 +3585,7403,3782,7404,2852,7405,7406,7407,4538,3783,2654,3423,4967,4539,7408,3784, # 6944 +3586,2853,4540,4541,7409,3901,7410,3902,7411,7412,3785,3109,2327,3903,7413,7414, # 6960 +2970,4066,2932,7415,7416,7417,3904,3672,3424,7418,4542,4543,4544,7419,4968,7420, # 6976 +7421,4255,7422,7423,7424,7425,7426,4067,7427,3673,3365,4545,7428,3110,2559,3674, # 6992 +7429,7430,3156,7431,7432,3503,7433,3425,4546,7434,3063,2873,7435,3223,4969,4547, # 7008 +4548,2898,4256,4068,7436,4069,3587,3786,2933,3787,4257,4970,4971,3788,7437,4972, # 7024 +3064,7438,4549,7439,7440,7441,7442,7443,4973,3905,7444,2874,7445,7446,7447,7448, # 7040 +3021,7449,4550,3906,3588,4974,7450,7451,3789,3675,7452,2578,7453,4070,7454,7455, # 7056 +7456,4258,3676,7457,4975,7458,4976,4259,3790,3504,2634,4977,3677,4551,4260,7459, # 7072 +7460,7461,7462,3907,4261,4978,7463,7464,7465,7466,4979,4980,7467,7468,2213,4262, # 7088 +7469,7470,7471,3678,4981,7472,2439,7473,4263,3224,3289,7474,3908,2415,4982,7475, # 7104 +4264,7476,4983,2655,7477,7478,2732,4552,2854,2875,7479,7480,4265,7481,4553,4984, # 7120 +7482,7483,4266,7484,3679,3366,3680,2818,2781,2782,3367,3589,4554,3065,7485,4071, # 7136 +2899,7486,7487,3157,2462,4072,4555,4073,4985,4986,3111,4267,2687,3368,4556,4074, # 7152 +3791,4268,7488,3909,2783,7489,2656,1962,3158,4557,4987,1963,3159,3160,7490,3112, # 7168 +4988,4989,3022,4990,4991,3792,2855,7491,7492,2971,4558,7493,7494,4992,7495,7496, # 7184 +7497,7498,4993,7499,3426,4559,4994,7500,3681,4560,4269,4270,3910,7501,4075,4995, # 7200 +4271,7502,7503,4076,7504,4996,7505,3225,4997,4272,4077,2819,3023,7506,7507,2733, # 7216 +4561,7508,4562,7509,3369,3793,7510,3590,2508,7511,7512,4273,3113,2994,2616,7513, # 7232 +7514,7515,7516,7517,7518,2820,3911,4078,2748,7519,7520,4563,4998,7521,7522,7523, # 7248 +7524,4999,4274,7525,4564,3682,2239,4079,4565,7526,7527,7528,7529,5000,7530,7531, # 7264 +5001,4275,3794,7532,7533,7534,3066,5002,4566,3161,7535,7536,4080,7537,3162,7538, # 7280 +7539,4567,7540,7541,7542,7543,7544,7545,5003,7546,4568,7547,7548,7549,7550,7551, # 7296 +7552,7553,7554,7555,7556,5004,7557,7558,7559,5005,7560,3795,7561,4569,7562,7563, # 7312 +7564,2821,3796,4276,4277,4081,7565,2876,7566,5006,7567,7568,2900,7569,3797,3912, # 7328 +7570,7571,7572,4278,7573,7574,7575,5007,7576,7577,5008,7578,7579,4279,2934,7580, # 7344 +7581,5009,7582,4570,7583,4280,7584,7585,7586,4571,4572,3913,7587,4573,3505,7588, # 7360 +5010,7589,7590,7591,7592,3798,4574,7593,7594,5011,7595,4281,7596,7597,7598,4282, # 7376 +5012,7599,7600,5013,3163,7601,5014,7602,3914,7603,7604,2734,4575,4576,4577,7605, # 7392 +7606,7607,7608,7609,3506,5015,4578,7610,4082,7611,2822,2901,2579,3683,3024,4579, # 7408 +3507,7612,4580,7613,3226,3799,5016,7614,7615,7616,7617,7618,7619,7620,2995,3290, # 7424 +7621,4083,7622,5017,7623,7624,7625,7626,7627,4581,3915,7628,3291,7629,5018,7630, # 7440 +7631,7632,7633,4084,7634,7635,3427,3800,7636,7637,4582,7638,5019,4583,5020,7639, # 7456 +3916,7640,3801,5021,4584,4283,7641,7642,3428,3591,2269,7643,2617,7644,4585,3592, # 7472 +7645,4586,2902,7646,7647,3227,5022,7648,4587,7649,4284,7650,7651,7652,4588,2284, # 7488 +7653,5023,7654,7655,7656,4589,5024,3802,7657,7658,5025,3508,4590,7659,7660,7661, # 7504 +1969,5026,7662,7663,3684,1821,2688,7664,2028,2509,4285,7665,2823,1841,7666,2689, # 7520 +3114,7667,3917,4085,2160,5027,5028,2972,7668,5029,7669,7670,7671,3593,4086,7672, # 7536 +4591,4087,5030,3803,7673,7674,7675,7676,7677,7678,7679,4286,2366,4592,4593,3067, # 7552 +2328,7680,7681,4594,3594,3918,2029,4287,7682,5031,3919,3370,4288,4595,2856,7683, # 7568 +3509,7684,7685,5032,5033,7686,7687,3804,2784,7688,7689,7690,7691,3371,7692,7693, # 7584 +2877,5034,7694,7695,3920,4289,4088,7696,7697,7698,5035,7699,5036,4290,5037,5038, # 7600 +5039,7700,7701,7702,5040,5041,3228,7703,1760,7704,5042,3229,4596,2106,4089,7705, # 7616 +4597,2824,5043,2107,3372,7706,4291,4090,5044,7707,4091,7708,5045,3025,3805,4598, # 7632 +4292,4293,4294,3373,7709,4599,7710,5046,7711,7712,5047,5048,3806,7713,7714,7715, # 7648 +5049,7716,7717,7718,7719,4600,5050,7720,7721,7722,5051,7723,4295,3429,7724,7725, # 7664 +7726,7727,3921,7728,3292,5052,4092,7729,7730,7731,7732,7733,7734,7735,5053,5054, # 7680 +7736,7737,7738,7739,3922,3685,7740,7741,7742,7743,2635,5055,7744,5056,4601,7745, # 7696 +7746,2560,7747,7748,7749,7750,3923,7751,7752,7753,7754,7755,4296,2903,7756,7757, # 7712 +7758,7759,7760,3924,7761,5057,4297,7762,7763,5058,4298,7764,4093,7765,7766,5059, # 7728 +3925,7767,7768,7769,7770,7771,7772,7773,7774,7775,7776,3595,7777,4299,5060,4094, # 7744 +7778,3293,5061,7779,7780,4300,7781,7782,4602,7783,3596,7784,7785,3430,2367,7786, # 7760 +3164,5062,5063,4301,7787,7788,4095,5064,5065,7789,3374,3115,7790,7791,7792,7793, # 7776 +7794,7795,7796,3597,4603,7797,7798,3686,3116,3807,5066,7799,7800,5067,7801,7802, # 7792 +4604,4302,5068,4303,4096,7803,7804,3294,7805,7806,5069,4605,2690,7807,3026,7808, # 7808 +7809,7810,7811,7812,7813,7814,7815,7816,7817,7818,7819,7820,7821,7822,7823,7824, # 7824 +7825,7826,7827,7828,7829,7830,7831,7832,7833,7834,7835,7836,7837,7838,7839,7840, # 7840 +7841,7842,7843,7844,7845,7846,7847,7848,7849,7850,7851,7852,7853,7854,7855,7856, # 7856 +7857,7858,7859,7860,7861,7862,7863,7864,7865,7866,7867,7868,7869,7870,7871,7872, # 7872 +7873,7874,7875,7876,7877,7878,7879,7880,7881,7882,7883,7884,7885,7886,7887,7888, # 7888 +7889,7890,7891,7892,7893,7894,7895,7896,7897,7898,7899,7900,7901,7902,7903,7904, # 7904 +7905,7906,7907,7908,7909,7910,7911,7912,7913,7914,7915,7916,7917,7918,7919,7920, # 7920 +7921,7922,7923,7924,3926,7925,7926,7927,7928,7929,7930,7931,7932,7933,7934,7935, # 7936 +7936,7937,7938,7939,7940,7941,7942,7943,7944,7945,7946,7947,7948,7949,7950,7951, # 7952 +7952,7953,7954,7955,7956,7957,7958,7959,7960,7961,7962,7963,7964,7965,7966,7967, # 7968 +7968,7969,7970,7971,7972,7973,7974,7975,7976,7977,7978,7979,7980,7981,7982,7983, # 7984 +7984,7985,7986,7987,7988,7989,7990,7991,7992,7993,7994,7995,7996,7997,7998,7999, # 8000 +8000,8001,8002,8003,8004,8005,8006,8007,8008,8009,8010,8011,8012,8013,8014,8015, # 8016 +8016,8017,8018,8019,8020,8021,8022,8023,8024,8025,8026,8027,8028,8029,8030,8031, # 8032 +8032,8033,8034,8035,8036,8037,8038,8039,8040,8041,8042,8043,8044,8045,8046,8047, # 8048 +8048,8049,8050,8051,8052,8053,8054,8055,8056,8057,8058,8059,8060,8061,8062,8063, # 8064 +8064,8065,8066,8067,8068,8069,8070,8071,8072,8073,8074,8075,8076,8077,8078,8079, # 8080 +8080,8081,8082,8083,8084,8085,8086,8087,8088,8089,8090,8091,8092,8093,8094,8095, # 8096 +8096,8097,8098,8099,8100,8101,8102,8103,8104,8105,8106,8107,8108,8109,8110,8111, # 8112 +8112,8113,8114,8115,8116,8117,8118,8119,8120,8121,8122,8123,8124,8125,8126,8127, # 8128 +8128,8129,8130,8131,8132,8133,8134,8135,8136,8137,8138,8139,8140,8141,8142,8143, # 8144 +8144,8145,8146,8147,8148,8149,8150,8151,8152,8153,8154,8155,8156,8157,8158,8159, # 8160 +8160,8161,8162,8163,8164,8165,8166,8167,8168,8169,8170,8171,8172,8173,8174,8175, # 8176 +8176,8177,8178,8179,8180,8181,8182,8183,8184,8185,8186,8187,8188,8189,8190,8191, # 8192 +8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8203,8204,8205,8206,8207, # 8208 +8208,8209,8210,8211,8212,8213,8214,8215,8216,8217,8218,8219,8220,8221,8222,8223, # 8224 +8224,8225,8226,8227,8228,8229,8230,8231,8232,8233,8234,8235,8236,8237,8238,8239, # 8240 +8240,8241,8242,8243,8244,8245,8246,8247,8248,8249,8250,8251,8252,8253,8254,8255, # 8256 +8256,8257,8258,8259,8260,8261,8262,8263,8264,8265,8266,8267,8268,8269,8270,8271) # 8272 diff --git a/fanficdownloader/chardet/jpcntx.py b/fanficdownloader/chardet/jpcntx.py new file mode 100644 index 00000000..93db4a9c --- /dev/null +++ b/fanficdownloader/chardet/jpcntx.py @@ -0,0 +1,210 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants + +NUM_OF_CATEGORY = 6 +DONT_KNOW = -1 +ENOUGH_REL_THRESHOLD = 100 +MAX_REL_THRESHOLD = 1000 +MINIMUM_DATA_THRESHOLD = 4 + +# This is hiragana 2-char sequence table, the number in each cell represents its frequency category +jp2CharContext = ( \ +(0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1), +(2,4,0,4,0,3,0,4,0,3,4,4,4,2,4,3,3,4,3,2,3,3,4,2,3,3,3,2,4,1,4,3,3,1,5,4,3,4,3,4,3,5,3,0,3,5,4,2,0,3,1,0,3,3,0,3,3,0,1,1,0,4,3,0,3,3,0,4,0,2,0,3,5,5,5,5,4,0,4,1,0,3,4), +(0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2), +(0,4,0,5,0,5,0,4,0,4,5,4,4,3,5,3,5,1,5,3,4,3,4,4,3,4,3,3,4,3,5,4,4,3,5,5,3,5,5,5,3,5,5,3,4,5,5,3,1,3,2,0,3,4,0,4,2,0,4,2,1,5,3,2,3,5,0,4,0,2,0,5,4,4,5,4,5,0,4,0,0,4,4), +(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0), +(0,3,0,4,0,3,0,3,0,4,5,4,3,3,3,3,4,3,5,4,4,3,5,4,4,3,4,3,4,4,4,4,5,3,4,4,3,4,5,5,4,5,5,1,4,5,4,3,0,3,3,1,3,3,0,4,4,0,3,3,1,5,3,3,3,5,0,4,0,3,0,4,4,3,4,3,3,0,4,1,1,3,4), +(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0), +(0,4,0,3,0,3,0,4,0,3,4,4,3,2,2,1,2,1,3,1,3,3,3,3,3,4,3,1,3,3,5,3,3,0,4,3,0,5,4,3,3,5,4,4,3,4,4,5,0,1,2,0,1,2,0,2,2,0,1,0,0,5,2,2,1,4,0,3,0,1,0,4,4,3,5,4,3,0,2,1,0,4,3), +(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0), +(0,3,0,5,0,4,0,2,1,4,4,2,4,1,4,2,4,2,4,3,3,3,4,3,3,3,3,1,4,2,3,3,3,1,4,4,1,1,1,4,3,3,2,0,2,4,3,2,0,3,3,0,3,1,1,0,0,0,3,3,0,4,2,2,3,4,0,4,0,3,0,4,4,5,3,4,4,0,3,0,0,1,4), +(1,4,0,4,0,4,0,4,0,3,5,4,4,3,4,3,5,4,3,3,4,3,5,4,4,4,4,3,4,2,4,3,3,1,5,4,3,2,4,5,4,5,5,4,4,5,4,4,0,3,2,2,3,3,0,4,3,1,3,2,1,4,3,3,4,5,0,3,0,2,0,4,5,5,4,5,4,0,4,0,0,5,4), +(0,5,0,5,0,4,0,3,0,4,4,3,4,3,3,3,4,0,4,4,4,3,4,3,4,3,3,1,4,2,4,3,4,0,5,4,1,4,5,4,4,5,3,2,4,3,4,3,2,4,1,3,3,3,2,3,2,0,4,3,3,4,3,3,3,4,0,4,0,3,0,4,5,4,4,4,3,0,4,1,0,1,3), +(0,3,1,4,0,3,0,2,0,3,4,4,3,1,4,2,3,3,4,3,4,3,4,3,4,4,3,2,3,1,5,4,4,1,4,4,3,5,4,4,3,5,5,4,3,4,4,3,1,2,3,1,2,2,0,3,2,0,3,1,0,5,3,3,3,4,3,3,3,3,4,4,4,4,5,4,2,0,3,3,2,4,3), +(0,2,0,3,0,1,0,1,0,0,3,2,0,0,2,0,1,0,2,1,3,3,3,1,2,3,1,0,1,0,4,2,1,1,3,3,0,4,3,3,1,4,3,3,0,3,3,2,0,0,0,0,1,0,0,2,0,0,0,0,0,4,1,0,2,3,2,2,2,1,3,3,3,4,4,3,2,0,3,1,0,3,3), +(0,4,0,4,0,3,0,3,0,4,4,4,3,3,3,3,3,3,4,3,4,2,4,3,4,3,3,2,4,3,4,5,4,1,4,5,3,5,4,5,3,5,4,0,3,5,5,3,1,3,3,2,2,3,0,3,4,1,3,3,2,4,3,3,3,4,0,4,0,3,0,4,5,4,4,5,3,0,4,1,0,3,4), +(0,2,0,3,0,3,0,0,0,2,2,2,1,0,1,0,0,0,3,0,3,0,3,0,1,3,1,0,3,1,3,3,3,1,3,3,3,0,1,3,1,3,4,0,0,3,1,1,0,3,2,0,0,0,0,1,3,0,1,0,0,3,3,2,0,3,0,0,0,0,0,3,4,3,4,3,3,0,3,0,0,2,3), +(2,3,0,3,0,2,0,1,0,3,3,4,3,1,3,1,1,1,3,1,4,3,4,3,3,3,0,0,3,1,5,4,3,1,4,3,2,5,5,4,4,4,4,3,3,4,4,4,0,2,1,1,3,2,0,1,2,0,0,1,0,4,1,3,3,3,0,3,0,1,0,4,4,4,5,5,3,0,2,0,0,4,4), +(0,2,0,1,0,3,1,3,0,2,3,3,3,0,3,1,0,0,3,0,3,2,3,1,3,2,1,1,0,0,4,2,1,0,2,3,1,4,3,2,0,4,4,3,1,3,1,3,0,1,0,0,1,0,0,0,1,0,0,0,0,4,1,1,1,2,0,3,0,0,0,3,4,2,4,3,2,0,1,0,0,3,3), +(0,1,0,4,0,5,0,4,0,2,4,4,2,3,3,2,3,3,5,3,3,3,4,3,4,2,3,0,4,3,3,3,4,1,4,3,2,1,5,5,3,4,5,1,3,5,4,2,0,3,3,0,1,3,0,4,2,0,1,3,1,4,3,3,3,3,0,3,0,1,0,3,4,4,4,5,5,0,3,0,1,4,5), +(0,2,0,3,0,3,0,0,0,2,3,1,3,0,4,0,1,1,3,0,3,4,3,2,3,1,0,3,3,2,3,1,3,0,2,3,0,2,1,4,1,2,2,0,0,3,3,0,0,2,0,0,0,1,0,0,0,0,2,2,0,3,2,1,3,3,0,2,0,2,0,0,3,3,1,2,4,0,3,0,2,2,3), +(2,4,0,5,0,4,0,4,0,2,4,4,4,3,4,3,3,3,1,2,4,3,4,3,4,4,5,0,3,3,3,3,2,0,4,3,1,4,3,4,1,4,4,3,3,4,4,3,1,2,3,0,4,2,0,4,1,0,3,3,0,4,3,3,3,4,0,4,0,2,0,3,5,3,4,5,2,0,3,0,0,4,5), +(0,3,0,4,0,1,0,1,0,1,3,2,2,1,3,0,3,0,2,0,2,0,3,0,2,0,0,0,1,0,1,1,0,0,3,1,0,0,0,4,0,3,1,0,2,1,3,0,0,0,0,0,0,3,0,0,0,0,0,0,0,4,2,2,3,1,0,3,0,0,0,1,4,4,4,3,0,0,4,0,0,1,4), +(1,4,1,5,0,3,0,3,0,4,5,4,4,3,5,3,3,4,4,3,4,1,3,3,3,3,2,1,4,1,5,4,3,1,4,4,3,5,4,4,3,5,4,3,3,4,4,4,0,3,3,1,2,3,0,3,1,0,3,3,0,5,4,4,4,4,4,4,3,3,5,4,4,3,3,5,4,0,3,2,0,4,4), +(0,2,0,3,0,1,0,0,0,1,3,3,3,2,4,1,3,0,3,1,3,0,2,2,1,1,0,0,2,0,4,3,1,0,4,3,0,4,4,4,1,4,3,1,1,3,3,1,0,2,0,0,1,3,0,0,0,0,2,0,0,4,3,2,4,3,5,4,3,3,3,4,3,3,4,3,3,0,2,1,0,3,3), +(0,2,0,4,0,3,0,2,0,2,5,5,3,4,4,4,4,1,4,3,3,0,4,3,4,3,1,3,3,2,4,3,0,3,4,3,0,3,4,4,2,4,4,0,4,5,3,3,2,2,1,1,1,2,0,1,5,0,3,3,2,4,3,3,3,4,0,3,0,2,0,4,4,3,5,5,0,0,3,0,2,3,3), +(0,3,0,4,0,3,0,1,0,3,4,3,3,1,3,3,3,0,3,1,3,0,4,3,3,1,1,0,3,0,3,3,0,0,4,4,0,1,5,4,3,3,5,0,3,3,4,3,0,2,0,1,1,1,0,1,3,0,1,2,1,3,3,2,3,3,0,3,0,1,0,1,3,3,4,4,1,0,1,2,2,1,3), +(0,1,0,4,0,4,0,3,0,1,3,3,3,2,3,1,1,0,3,0,3,3,4,3,2,4,2,0,1,0,4,3,2,0,4,3,0,5,3,3,2,4,4,4,3,3,3,4,0,1,3,0,0,1,0,0,1,0,0,0,0,4,2,3,3,3,0,3,0,0,0,4,4,4,5,3,2,0,3,3,0,3,5), +(0,2,0,3,0,0,0,3,0,1,3,0,2,0,0,0,1,0,3,1,1,3,3,0,0,3,0,0,3,0,2,3,1,0,3,1,0,3,3,2,0,4,2,2,0,2,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,2,1,2,0,1,0,1,0,0,0,1,3,1,2,0,0,0,1,0,0,1,4), +(0,3,0,3,0,5,0,1,0,2,4,3,1,3,3,2,1,1,5,2,1,0,5,1,2,0,0,0,3,3,2,2,3,2,4,3,0,0,3,3,1,3,3,0,2,5,3,4,0,3,3,0,1,2,0,2,2,0,3,2,0,2,2,3,3,3,0,2,0,1,0,3,4,4,2,5,4,0,3,0,0,3,5), +(0,3,0,3,0,3,0,1,0,3,3,3,3,0,3,0,2,0,2,1,1,0,2,0,1,0,0,0,2,1,0,0,1,0,3,2,0,0,3,3,1,2,3,1,0,3,3,0,0,1,0,0,0,0,0,2,0,0,0,0,0,2,3,1,2,3,0,3,0,1,0,3,2,1,0,4,3,0,1,1,0,3,3), +(0,4,0,5,0,3,0,3,0,4,5,5,4,3,5,3,4,3,5,3,3,2,5,3,4,4,4,3,4,3,4,5,5,3,4,4,3,4,4,5,4,4,4,3,4,5,5,4,2,3,4,2,3,4,0,3,3,1,4,3,2,4,3,3,5,5,0,3,0,3,0,5,5,5,5,4,4,0,4,0,1,4,4), +(0,4,0,4,0,3,0,3,0,3,5,4,4,2,3,2,5,1,3,2,5,1,4,2,3,2,3,3,4,3,3,3,3,2,5,4,1,3,3,5,3,4,4,0,4,4,3,1,1,3,1,0,2,3,0,2,3,0,3,0,0,4,3,1,3,4,0,3,0,2,0,4,4,4,3,4,5,0,4,0,0,3,4), +(0,3,0,3,0,3,1,2,0,3,4,4,3,3,3,0,2,2,4,3,3,1,3,3,3,1,1,0,3,1,4,3,2,3,4,4,2,4,4,4,3,4,4,3,2,4,4,3,1,3,3,1,3,3,0,4,1,0,2,2,1,4,3,2,3,3,5,4,3,3,5,4,4,3,3,0,4,0,3,2,2,4,4), +(0,2,0,1,0,0,0,0,0,1,2,1,3,0,0,0,0,0,2,0,1,2,1,0,0,1,0,0,0,0,3,0,0,1,0,1,1,3,1,0,0,0,1,1,0,1,1,0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,1,2,2,0,3,4,0,0,0,1,1,0,0,1,0,0,0,0,0,1,1), +(0,1,0,0,0,1,0,0,0,0,4,0,4,1,4,0,3,0,4,0,3,0,4,0,3,0,3,0,4,1,5,1,4,0,0,3,0,5,0,5,2,0,1,0,0,0,2,1,4,0,1,3,0,0,3,0,0,3,1,1,4,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0), +(1,4,0,5,0,3,0,2,0,3,5,4,4,3,4,3,5,3,4,3,3,0,4,3,3,3,3,3,3,2,4,4,3,1,3,4,4,5,4,4,3,4,4,1,3,5,4,3,3,3,1,2,2,3,3,1,3,1,3,3,3,5,3,3,4,5,0,3,0,3,0,3,4,3,4,4,3,0,3,0,2,4,3), +(0,1,0,4,0,0,0,0,0,1,4,0,4,1,4,2,4,0,3,0,1,0,1,0,0,0,0,0,2,0,3,1,1,1,0,3,0,0,0,1,2,1,0,0,1,1,1,1,0,1,0,0,0,1,0,0,3,0,0,0,0,3,2,0,2,2,0,1,0,0,0,2,3,2,3,3,0,0,0,0,2,1,0), +(0,5,1,5,0,3,0,3,0,5,4,4,5,1,5,3,3,0,4,3,4,3,5,3,4,3,3,2,4,3,4,3,3,0,3,3,1,4,4,3,4,4,4,3,4,5,5,3,2,3,1,1,3,3,1,3,1,1,3,3,2,4,5,3,3,5,0,4,0,3,0,4,4,3,5,3,3,0,3,4,0,4,3), +(0,5,0,5,0,3,0,2,0,4,4,3,5,2,4,3,3,3,4,4,4,3,5,3,5,3,3,1,4,0,4,3,3,0,3,3,0,4,4,4,4,5,4,3,3,5,5,3,2,3,1,2,3,2,0,1,0,0,3,2,2,4,4,3,1,5,0,4,0,3,0,4,3,1,3,2,1,0,3,3,0,3,3), +(0,4,0,5,0,5,0,4,0,4,5,5,5,3,4,3,3,2,5,4,4,3,5,3,5,3,4,0,4,3,4,4,3,2,4,4,3,4,5,4,4,5,5,0,3,5,5,4,1,3,3,2,3,3,1,3,1,0,4,3,1,4,4,3,4,5,0,4,0,2,0,4,3,4,4,3,3,0,4,0,0,5,5), +(0,4,0,4,0,5,0,1,1,3,3,4,4,3,4,1,3,0,5,1,3,0,3,1,3,1,1,0,3,0,3,3,4,0,4,3,0,4,4,4,3,4,4,0,3,5,4,1,0,3,0,0,2,3,0,3,1,0,3,1,0,3,2,1,3,5,0,3,0,1,0,3,2,3,3,4,4,0,2,2,0,4,4), +(2,4,0,5,0,4,0,3,0,4,5,5,4,3,5,3,5,3,5,3,5,2,5,3,4,3,3,4,3,4,5,3,2,1,5,4,3,2,3,4,5,3,4,1,2,5,4,3,0,3,3,0,3,2,0,2,3,0,4,1,0,3,4,3,3,5,0,3,0,1,0,4,5,5,5,4,3,0,4,2,0,3,5), +(0,5,0,4,0,4,0,2,0,5,4,3,4,3,4,3,3,3,4,3,4,2,5,3,5,3,4,1,4,3,4,4,4,0,3,5,0,4,4,4,4,5,3,1,3,4,5,3,3,3,3,3,3,3,0,2,2,0,3,3,2,4,3,3,3,5,3,4,1,3,3,5,3,2,0,0,0,0,4,3,1,3,3), +(0,1,0,3,0,3,0,1,0,1,3,3,3,2,3,3,3,0,3,0,0,0,3,1,3,0,0,0,2,2,2,3,0,0,3,2,0,1,2,4,1,3,3,0,0,3,3,3,0,1,0,0,2,1,0,0,3,0,3,1,0,3,0,0,1,3,0,2,0,1,0,3,3,1,3,3,0,0,1,1,0,3,3), +(0,2,0,3,0,2,1,4,0,2,2,3,1,1,3,1,1,0,2,0,3,1,2,3,1,3,0,0,1,0,4,3,2,3,3,3,1,4,2,3,3,3,3,1,0,3,1,4,0,1,1,0,1,2,0,1,1,0,1,1,0,3,1,3,2,2,0,1,0,0,0,2,3,3,3,1,0,0,0,0,0,2,3), +(0,5,0,4,0,5,0,2,0,4,5,5,3,3,4,3,3,1,5,4,4,2,4,4,4,3,4,2,4,3,5,5,4,3,3,4,3,3,5,5,4,5,5,1,3,4,5,3,1,4,3,1,3,3,0,3,3,1,4,3,1,4,5,3,3,5,0,4,0,3,0,5,3,3,1,4,3,0,4,0,1,5,3), +(0,5,0,5,0,4,0,2,0,4,4,3,4,3,3,3,3,3,5,4,4,4,4,4,4,5,3,3,5,2,4,4,4,3,4,4,3,3,4,4,5,5,3,3,4,3,4,3,3,4,3,3,3,3,1,2,2,1,4,3,3,5,4,4,3,4,0,4,0,3,0,4,4,4,4,4,1,0,4,2,0,2,4), +(0,4,0,4,0,3,0,1,0,3,5,2,3,0,3,0,2,1,4,2,3,3,4,1,4,3,3,2,4,1,3,3,3,0,3,3,0,0,3,3,3,5,3,3,3,3,3,2,0,2,0,0,2,0,0,2,0,0,1,0,0,3,1,2,2,3,0,3,0,2,0,4,4,3,3,4,1,0,3,0,0,2,4), +(0,0,0,4,0,0,0,0,0,0,1,0,1,0,2,0,0,0,0,0,1,0,2,0,1,0,0,0,0,0,3,1,3,0,3,2,0,0,0,1,0,3,2,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,4,0,2,0,0,0,0,0,0,2), +(0,2,1,3,0,2,0,2,0,3,3,3,3,1,3,1,3,3,3,3,3,3,4,2,2,1,2,1,4,0,4,3,1,3,3,3,2,4,3,5,4,3,3,3,3,3,3,3,0,1,3,0,2,0,0,1,0,0,1,0,0,4,2,0,2,3,0,3,3,0,3,3,4,2,3,1,4,0,1,2,0,2,3), +(0,3,0,3,0,1,0,3,0,2,3,3,3,0,3,1,2,0,3,3,2,3,3,2,3,2,3,1,3,0,4,3,2,0,3,3,1,4,3,3,2,3,4,3,1,3,3,1,1,0,1,1,0,1,0,1,0,1,0,0,0,4,1,1,0,3,0,3,1,0,2,3,3,3,3,3,1,0,0,2,0,3,3), +(0,0,0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,3,0,3,0,3,1,0,1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,2,0,2,3,0,0,0,0,0,0,0,0,3), +(0,2,0,3,1,3,0,3,0,2,3,3,3,1,3,1,3,1,3,1,3,3,3,1,3,0,2,3,1,1,4,3,3,2,3,3,1,2,2,4,1,3,3,0,1,4,2,3,0,1,3,0,3,0,0,1,3,0,2,0,0,3,3,2,1,3,0,3,0,2,0,3,4,4,4,3,1,0,3,0,0,3,3), +(0,2,0,1,0,2,0,0,0,1,3,2,2,1,3,0,1,1,3,0,3,2,3,1,2,0,2,0,1,1,3,3,3,0,3,3,1,1,2,3,2,3,3,1,2,3,2,0,0,1,0,0,0,0,0,0,3,0,1,0,0,2,1,2,1,3,0,3,0,0,0,3,4,4,4,3,2,0,2,0,0,2,4), +(0,0,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,2,2,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,3,1,0,0,0,0,0,0,0,3), +(0,3,0,3,0,2,0,3,0,3,3,3,2,3,2,2,2,0,3,1,3,3,3,2,3,3,0,0,3,0,3,2,2,0,2,3,1,4,3,4,3,3,2,3,1,5,4,4,0,3,1,2,1,3,0,3,1,1,2,0,2,3,1,3,1,3,0,3,0,1,0,3,3,4,4,2,1,0,2,1,0,2,4), +(0,1,0,3,0,1,0,2,0,1,4,2,5,1,4,0,2,0,2,1,3,1,4,0,2,1,0,0,2,1,4,1,1,0,3,3,0,5,1,3,2,3,3,1,0,3,2,3,0,1,0,0,0,0,0,0,1,0,0,0,0,4,0,1,0,3,0,2,0,1,0,3,3,3,4,3,3,0,0,0,0,2,3), +(0,0,0,1,0,0,0,0,0,0,2,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,0,0,1,0,0,0,0,0,3), +(0,1,0,3,0,4,0,3,0,2,4,3,1,0,3,2,2,1,3,1,2,2,3,1,1,1,2,1,3,0,1,2,0,1,3,2,1,3,0,5,5,1,0,0,1,3,2,1,0,3,0,0,1,0,0,0,0,0,3,4,0,1,1,1,3,2,0,2,0,1,0,2,3,3,1,2,3,0,1,0,1,0,4), +(0,0,0,1,0,3,0,3,0,2,2,1,0,0,4,0,3,0,3,1,3,0,3,0,3,0,1,0,3,0,3,1,3,0,3,3,0,0,1,2,1,1,1,0,1,2,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,2,2,1,2,0,0,2,0,0,0,0,2,3,3,3,3,0,0,0,0,1,4), +(0,0,0,3,0,3,0,0,0,0,3,1,1,0,3,0,1,0,2,0,1,0,0,0,0,0,0,0,1,0,3,0,2,0,2,3,0,0,2,2,3,1,2,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,2,0,0,0,0,2,3), +(2,4,0,5,0,5,0,4,0,3,4,3,3,3,4,3,3,3,4,3,4,4,5,4,5,5,5,2,3,0,5,5,4,1,5,4,3,1,5,4,3,4,4,3,3,4,3,3,0,3,2,0,2,3,0,3,0,0,3,3,0,5,3,2,3,3,0,3,0,3,0,3,4,5,4,5,3,0,4,3,0,3,4), +(0,3,0,3,0,3,0,3,0,3,3,4,3,2,3,2,3,0,4,3,3,3,3,3,3,3,3,0,3,2,4,3,3,1,3,4,3,4,4,4,3,4,4,3,2,4,4,1,0,2,0,0,1,1,0,2,0,0,3,1,0,5,3,2,1,3,0,3,0,1,2,4,3,2,4,3,3,0,3,2,0,4,4), +(0,3,0,3,0,1,0,0,0,1,4,3,3,2,3,1,3,1,4,2,3,2,4,2,3,4,3,0,2,2,3,3,3,0,3,3,3,0,3,4,1,3,3,0,3,4,3,3,0,1,1,0,1,0,0,0,4,0,3,0,0,3,1,2,1,3,0,4,0,1,0,4,3,3,4,3,3,0,2,0,0,3,3), +(0,3,0,4,0,1,0,3,0,3,4,3,3,0,3,3,3,1,3,1,3,3,4,3,3,3,0,0,3,1,5,3,3,1,3,3,2,5,4,3,3,4,5,3,2,5,3,4,0,1,0,0,0,0,0,2,0,0,1,1,0,4,2,2,1,3,0,3,0,2,0,4,4,3,5,3,2,0,1,1,0,3,4), +(0,5,0,4,0,5,0,2,0,4,4,3,3,2,3,3,3,1,4,3,4,1,5,3,4,3,4,0,4,2,4,3,4,1,5,4,0,4,4,4,4,5,4,1,3,5,4,2,1,4,1,1,3,2,0,3,1,0,3,2,1,4,3,3,3,4,0,4,0,3,0,4,4,4,3,3,3,0,4,2,0,3,4), +(1,4,0,4,0,3,0,1,0,3,3,3,1,1,3,3,2,2,3,3,1,0,3,2,2,1,2,0,3,1,2,1,2,0,3,2,0,2,2,3,3,4,3,0,3,3,1,2,0,1,1,3,1,2,0,0,3,0,1,1,0,3,2,2,3,3,0,3,0,0,0,2,3,3,4,3,3,0,1,0,0,1,4), +(0,4,0,4,0,4,0,0,0,3,4,4,3,1,4,2,3,2,3,3,3,1,4,3,4,0,3,0,4,2,3,3,2,2,5,4,2,1,3,4,3,4,3,1,3,3,4,2,0,2,1,0,3,3,0,0,2,0,3,1,0,4,4,3,4,3,0,4,0,1,0,2,4,4,4,4,4,0,3,2,0,3,3), +(0,0,0,1,0,4,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,3,2,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,2), +(0,2,0,3,0,4,0,4,0,1,3,3,3,0,4,0,2,1,2,1,1,1,2,0,3,1,1,0,1,0,3,1,0,0,3,3,2,0,1,1,0,0,0,0,0,1,0,2,0,2,2,0,3,1,0,0,1,0,1,1,0,1,2,0,3,0,0,0,0,1,0,0,3,3,4,3,1,0,1,0,3,0,2), +(0,0,0,3,0,5,0,0,0,0,1,0,2,0,3,1,0,1,3,0,0,0,2,0,0,0,1,0,0,0,1,1,0,0,4,0,0,0,2,3,0,1,4,1,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,1,0,0,0,0,0,0,0,2,0,0,3,0,0,0,0,0,3), +(0,2,0,5,0,5,0,1,0,2,4,3,3,2,5,1,3,2,3,3,3,0,4,1,2,0,3,0,4,0,2,2,1,1,5,3,0,0,1,4,2,3,2,0,3,3,3,2,0,2,4,1,1,2,0,1,1,0,3,1,0,1,3,1,2,3,0,2,0,0,0,1,3,5,4,4,4,0,3,0,0,1,3), +(0,4,0,5,0,4,0,4,0,4,5,4,3,3,4,3,3,3,4,3,4,4,5,3,4,5,4,2,4,2,3,4,3,1,4,4,1,3,5,4,4,5,5,4,4,5,5,5,2,3,3,1,4,3,1,3,3,0,3,3,1,4,3,4,4,4,0,3,0,4,0,3,3,4,4,5,0,0,4,3,0,4,5), +(0,4,0,4,0,3,0,3,0,3,4,4,4,3,3,2,4,3,4,3,4,3,5,3,4,3,2,1,4,2,4,4,3,1,3,4,2,4,5,5,3,4,5,4,1,5,4,3,0,3,2,2,3,2,1,3,1,0,3,3,3,5,3,3,3,5,4,4,2,3,3,4,3,3,3,2,1,0,3,2,1,4,3), +(0,4,0,5,0,4,0,3,0,3,5,5,3,2,4,3,4,0,5,4,4,1,4,4,4,3,3,3,4,3,5,5,2,3,3,4,1,2,5,5,3,5,5,2,3,5,5,4,0,3,2,0,3,3,1,1,5,1,4,1,0,4,3,2,3,5,0,4,0,3,0,5,4,3,4,3,0,0,4,1,0,4,4), +(1,3,0,4,0,2,0,2,0,2,5,5,3,3,3,3,3,0,4,2,3,4,4,4,3,4,0,0,3,4,5,4,3,3,3,3,2,5,5,4,5,5,5,4,3,5,5,5,1,3,1,0,1,0,0,3,2,0,4,2,0,5,2,3,2,4,1,3,0,3,0,4,5,4,5,4,3,0,4,2,0,5,4), +(0,3,0,4,0,5,0,3,0,3,4,4,3,2,3,2,3,3,3,3,3,2,4,3,3,2,2,0,3,3,3,3,3,1,3,3,3,0,4,4,3,4,4,1,1,4,4,2,0,3,1,0,1,1,0,4,1,0,2,3,1,3,3,1,3,4,0,3,0,1,0,3,1,3,0,0,1,0,2,0,0,4,4), +(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0), +(0,3,0,3,0,2,0,3,0,1,5,4,3,3,3,1,4,2,1,2,3,4,4,2,4,4,5,0,3,1,4,3,4,0,4,3,3,3,2,3,2,5,3,4,3,2,2,3,0,0,3,0,2,1,0,1,2,0,0,0,0,2,1,1,3,1,0,2,0,4,0,3,4,4,4,5,2,0,2,0,0,1,3), +(0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,0,0,1,1,0,0,0,4,2,1,1,0,1,0,3,2,0,0,3,1,1,1,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,1,0,0,0,2,0,0,0,1,4,0,4,2,1,0,0,0,0,0,1), +(0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,3,1,0,0,0,2,0,2,1,0,0,1,2,1,0,1,1,0,0,3,0,0,0,0,0,0,0,0,0,0,0,1,3,1,0,0,0,0,0,1,0,0,2,1,0,0,0,0,0,0,0,0,2), +(0,4,0,4,0,4,0,3,0,4,4,3,4,2,4,3,2,0,4,4,4,3,5,3,5,3,3,2,4,2,4,3,4,3,1,4,0,2,3,4,4,4,3,3,3,4,4,4,3,4,1,3,4,3,2,1,2,1,3,3,3,4,4,3,3,5,0,4,0,3,0,4,3,3,3,2,1,0,3,0,0,3,3), +(0,4,0,3,0,3,0,3,0,3,5,5,3,3,3,3,4,3,4,3,3,3,4,4,4,3,3,3,3,4,3,5,3,3,1,3,2,4,5,5,5,5,4,3,4,5,5,3,2,2,3,3,3,3,2,3,3,1,2,3,2,4,3,3,3,4,0,4,0,2,0,4,3,2,2,1,2,0,3,0,0,4,1), +) + +class JapaneseContextAnalysis: + def __init__(self): + self.reset() + + def reset(self): + self._mTotalRel = 0 # total sequence received + self._mRelSample = [0] * NUM_OF_CATEGORY # category counters, each interger counts sequence in its category + self._mNeedToSkipCharNum = 0 # if last byte in current buffer is not the last byte of a character, we need to know how many bytes to skip in next buffer + self._mLastCharOrder = -1 # The order of previous char + self._mDone = constants.False # If this flag is set to constants.True, detection is done and conclusion has been made + + def feed(self, aBuf, aLen): + if self._mDone: return + + # The buffer we got is byte oriented, and a character may span in more than one + # buffers. In case the last one or two byte in last buffer is not complete, we + # record how many byte needed to complete that character and skip these bytes here. + # We can choose to record those bytes as well and analyse the character once it + # is complete, but since a character will not make much difference, by simply skipping + # this character will simply our logic and improve performance. + i = self._mNeedToSkipCharNum + while i < aLen: + order, charLen = self.get_order(aBuf[i:i+2]) + i += charLen + if i > aLen: + self._mNeedToSkipCharNum = i - aLen + self._mLastCharOrder = -1 + else: + if (order != -1) and (self._mLastCharOrder != -1): + self._mTotalRel += 1 + if self._mTotalRel > MAX_REL_THRESHOLD: + self._mDone = constants.True + break + self._mRelSample[jp2CharContext[self._mLastCharOrder][order]] += 1 + self._mLastCharOrder = order + + def got_enough_data(self): + return self._mTotalRel > ENOUGH_REL_THRESHOLD + + def get_confidence(self): + # This is just one way to calculate confidence. It works well for me. + if self._mTotalRel > MINIMUM_DATA_THRESHOLD: + return (self._mTotalRel - self._mRelSample[0]) / self._mTotalRel + else: + return DONT_KNOW + + def get_order(self, aStr): + return -1, 1 + +class SJISContextAnalysis(JapaneseContextAnalysis): + def get_order(self, aStr): + if not aStr: return -1, 1 + # find out current char's byte length + if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \ + ((aStr[0] >= '\xE0') and (aStr[0] <= '\xFC')): + charLen = 2 + else: + charLen = 1 + + # return its order if it is hiragana + if len(aStr) > 1: + if (aStr[0] == '\202') and \ + (aStr[1] >= '\x9F') and \ + (aStr[1] <= '\xF1'): + return ord(aStr[1]) - 0x9F, charLen + + return -1, charLen + +class EUCJPContextAnalysis(JapaneseContextAnalysis): + def get_order(self, aStr): + if not aStr: return -1, 1 + # find out current char's byte length + if (aStr[0] == '\x8E') or \ + ((aStr[0] >= '\xA1') and (aStr[0] <= '\xFE')): + charLen = 2 + elif aStr[0] == '\x8F': + charLen = 3 + else: + charLen = 1 + + # return its order if it is hiragana + if len(aStr) > 1: + if (aStr[0] == '\xA4') and \ + (aStr[1] >= '\xA1') and \ + (aStr[1] <= '\xF3'): + return ord(aStr[1]) - 0xA1, charLen + + return -1, charLen diff --git a/fanficdownloader/chardet/langbulgarianmodel.py b/fanficdownloader/chardet/langbulgarianmodel.py new file mode 100644 index 00000000..bf5641e7 --- /dev/null +++ b/fanficdownloader/chardet/langbulgarianmodel.py @@ -0,0 +1,228 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants + +# 255: Control characters that usually does not exist in any text +# 254: Carriage/Return +# 253: symbol (punctuation) that does not belong to word +# 252: 0 - 9 + +# Character Mapping Table: +# this table is modified base on win1251BulgarianCharToOrderMap, so +# only number <64 is sure valid + +Latin5_BulgarianCharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 77, 90, 99,100, 72,109,107,101, 79,185, 81,102, 76, 94, 82, # 40 +110,186,108, 91, 74,119, 84, 96,111,187,115,253,253,253,253,253, # 50 +253, 65, 69, 70, 66, 63, 68,112,103, 92,194,104, 95, 86, 87, 71, # 60 +116,195, 85, 93, 97,113,196,197,198,199,200,253,253,253,253,253, # 70 +194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209, # 80 +210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225, # 90 + 81,226,227,228,229,230,105,231,232,233,234,235,236, 45,237,238, # a0 + 31, 32, 35, 43, 37, 44, 55, 47, 40, 59, 33, 46, 38, 36, 41, 30, # b0 + 39, 28, 34, 51, 48, 49, 53, 50, 54, 57, 61,239, 67,240, 60, 56, # c0 + 1, 18, 9, 20, 11, 3, 23, 15, 2, 26, 12, 10, 14, 6, 4, 13, # d0 + 7, 8, 5, 19, 29, 25, 22, 21, 27, 24, 17, 75, 52,241, 42, 16, # e0 + 62,242,243,244, 58,245, 98,246,247,248,249,250,251, 91,252,253, # f0 +) + +win1251BulgarianCharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 77, 90, 99,100, 72,109,107,101, 79,185, 81,102, 76, 94, 82, # 40 +110,186,108, 91, 74,119, 84, 96,111,187,115,253,253,253,253,253, # 50 +253, 65, 69, 70, 66, 63, 68,112,103, 92,194,104, 95, 86, 87, 71, # 60 +116,195, 85, 93, 97,113,196,197,198,199,200,253,253,253,253,253, # 70 +206,207,208,209,210,211,212,213,120,214,215,216,217,218,219,220, # 80 +221, 78, 64, 83,121, 98,117,105,222,223,224,225,226,227,228,229, # 90 + 88,230,231,232,233,122, 89,106,234,235,236,237,238, 45,239,240, # a0 + 73, 80,118,114,241,242,243,244,245, 62, 58,246,247,248,249,250, # b0 + 31, 32, 35, 43, 37, 44, 55, 47, 40, 59, 33, 46, 38, 36, 41, 30, # c0 + 39, 28, 34, 51, 48, 49, 53, 50, 54, 57, 61,251, 67,252, 60, 56, # d0 + 1, 18, 9, 20, 11, 3, 23, 15, 2, 26, 12, 10, 14, 6, 4, 13, # e0 + 7, 8, 5, 19, 29, 25, 22, 21, 27, 24, 17, 75, 52,253, 42, 16, # f0 +) + +# Model Table: +# total sequences: 100% +# first 512 sequences: 96.9392% +# first 1024 sequences:3.0618% +# rest sequences: 0.2992% +# negative sequences: 0.0020% +BulgarianLangModel = ( \ +0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,2,3,3,3,3,3, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,3,3,3,2,2,3,2,2,1,2,2, +3,1,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,0,3,3,3,3,3,3,3,3,3,3,0,3,0,1, +0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,2,3,3,3,3,3,3,3,3,0,3,1,0, +0,1,0,0,0,0,0,0,0,0,1,1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,2,3,3,3,3,3,3,3,3,0,3,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,2,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,1,3,2,3,3,3,3,3,3,3,3,0,3,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,2,3,2,2,1,3,3,3,3,2,2,2,1,1,2,0,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,2,3,2,2,3,3,1,1,2,3,3,2,3,3,3,3,2,1,2,0,2,0,3,0,0, +0,0,0,0,0,0,0,1,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,1,3,3,3,3,3,2,3,2,3,3,3,3,3,2,3,3,1,3,0,3,0,2,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,1,3,3,2,3,3,3,1,3,3,2,3,2,2,2,0,0,2,0,2,0,2,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,3,0,3,3,3,2,2,3,3,3,1,2,2,3,2,1,1,2,0,2,0,0,0,0, +1,0,0,0,0,0,0,0,0,0,2,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,2,3,3,1,2,3,2,2,2,3,3,3,3,3,2,2,3,1,2,0,2,1,2,0,0, +0,0,0,0,0,0,0,0,0,0,3,0,0,1,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,1,3,3,3,3,3,2,3,3,3,2,3,3,2,3,2,2,2,3,1,2,0,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,3,3,3,1,1,1,2,2,1,3,1,3,2,2,3,0,0,1,0,1,0,1,0,0, +0,0,0,1,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,2,2,3,2,2,3,1,2,1,1,1,2,3,1,3,1,2,2,0,1,1,1,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,1,3,2,2,3,3,1,2,3,1,1,3,3,3,3,1,2,2,1,1,1,0,2,0,2,0,1, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,2,2,3,3,3,2,2,1,1,2,0,2,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,0,1,2,1,3,3,2,3,3,3,3,3,2,3,2,1,0,3,1,2,1,2,1,2,3,2,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,1,1,2,3,3,3,3,3,3,3,3,3,3,3,3,0,0,3,1,3,3,2,3,3,2,2,2,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,3,3,3,0,3,3,3,3,3,2,1,1,2,1,3,3,0,3,1,1,1,1,3,2,0,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,2,2,2,3,3,3,3,3,3,3,3,3,3,3,1,1,3,1,3,3,2,3,2,2,2,3,0,2,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,2,3,3,2,2,3,2,1,1,1,1,1,3,1,3,1,1,0,0,0,1,0,0,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,2,3,2,0,3,2,0,3,0,2,0,0,2,1,3,1,0,0,1,0,0,0,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,2,1,1,1,1,2,1,1,2,1,1,1,2,2,1,2,1,1,1,0,1,1,0,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,2,1,3,1,1,2,1,3,2,1,1,0,1,2,3,2,1,1,1,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,3,3,3,2,2,1,0,1,0,0,1,0,0,0,2,1,0,3,0,0,1,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,2,3,2,3,3,1,3,2,1,1,1,2,1,1,2,1,3,0,1,0,0,0,1,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,1,2,2,3,3,2,3,2,2,2,3,1,2,2,1,1,2,1,1,2,2,0,1,1,0,1,0,2,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,2,1,3,1,0,2,2,1,3,2,1,0,0,2,0,2,0,1,0,0,0,0,0,0,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,1,2,0,2,3,1,2,3,2,0,1,3,1,2,1,1,1,0,0,1,0,0,2,2,2,3, +2,2,2,2,1,2,1,1,2,2,1,1,2,0,1,1,1,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1, +3,3,3,3,3,2,1,2,2,1,2,0,2,0,1,0,1,2,1,2,1,1,0,0,0,1,0,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,2,3,3,1,1,3,1,0,3,2,1,0,0,0,1,2,0,2,0,1,0,0,0,1,0,1,2,1,2,2, +1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,0,1,2,1,1,1,0,0,0,0,0,1,1,0,0, +3,1,0,1,0,2,3,2,2,2,3,2,2,2,2,2,1,0,2,1,2,1,1,1,0,1,2,1,2,2,2,1, +1,1,2,2,2,2,1,2,1,1,0,1,2,1,2,2,2,1,1,1,0,1,1,1,1,2,0,1,0,0,0,0, +2,3,2,3,3,0,0,2,1,0,2,1,0,0,0,0,2,3,0,2,0,0,0,0,0,1,0,0,2,0,1,2, +2,1,2,1,2,2,1,1,1,2,1,1,1,0,1,2,2,1,1,1,1,1,0,1,1,1,0,0,1,2,0,0, +3,3,2,2,3,0,2,3,1,1,2,0,0,0,1,0,0,2,0,2,0,0,0,1,0,1,0,1,2,0,2,2, +1,1,1,1,2,1,0,1,2,2,2,1,1,1,1,1,1,1,0,1,1,1,0,0,0,0,0,0,1,1,0,0, +2,3,2,3,3,0,0,3,0,1,1,0,1,0,0,0,2,2,1,2,0,0,0,0,0,0,0,0,2,0,1,2, +2,2,1,1,1,1,1,2,2,2,1,0,2,0,1,0,1,0,0,1,0,1,0,0,1,0,0,0,0,1,0,0, +3,3,3,3,2,2,2,2,2,0,2,1,1,1,1,2,1,2,1,1,0,2,0,1,0,1,0,0,2,0,1,2, +1,1,1,1,1,1,1,2,2,1,1,0,2,0,1,0,2,0,0,1,1,1,0,0,2,0,0,0,1,1,0,0, +2,3,3,3,3,1,0,0,0,0,0,0,0,0,0,0,2,0,0,1,1,0,0,0,0,0,0,1,2,0,1,2, +2,2,2,1,1,2,1,1,2,2,2,1,2,0,1,1,1,1,1,1,0,1,1,1,1,0,0,1,1,1,0,0, +2,3,3,3,3,0,2,2,0,2,1,0,0,0,1,1,1,2,0,2,0,0,0,3,0,0,0,0,2,0,2,2, +1,1,1,2,1,2,1,1,2,2,2,1,2,0,1,1,1,0,1,1,1,1,0,2,1,0,0,0,1,1,0,0, +2,3,3,3,3,0,2,1,0,0,2,0,0,0,0,0,1,2,0,2,0,0,0,0,0,0,0,0,2,0,1,2, +1,1,1,2,1,1,1,1,2,2,2,0,1,0,1,1,1,0,0,1,1,1,0,0,1,0,0,0,0,1,0,0, +3,3,2,2,3,0,1,0,1,0,0,0,0,0,0,0,1,1,0,3,0,0,0,0,0,0,0,0,1,0,2,2, +1,1,1,1,1,2,1,1,2,2,1,2,2,1,0,1,1,1,1,1,0,1,0,0,1,0,0,0,1,1,0,0, +3,1,0,1,0,2,2,2,2,3,2,1,1,1,2,3,0,0,1,0,2,1,1,0,1,1,1,1,2,1,1,1, +1,2,2,1,2,1,2,2,1,1,0,1,2,1,2,2,1,1,1,0,0,1,1,1,2,1,0,1,0,0,0,0, +2,1,0,1,0,3,1,2,2,2,2,1,2,2,1,1,1,0,2,1,2,2,1,1,2,1,1,0,2,1,1,1, +1,2,2,2,2,2,2,2,1,2,0,1,1,0,2,1,1,1,1,1,0,0,1,1,1,1,0,1,0,0,0,0, +2,1,1,1,1,2,2,2,2,1,2,2,2,1,2,2,1,1,2,1,2,3,2,2,1,1,1,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,3,2,0,1,2,0,1,2,1,1,0,1,0,1,2,1,2,0,0,0,1,1,0,0,0,1,0,0,2, +1,1,0,0,1,1,0,1,1,1,1,0,2,0,1,1,1,0,0,1,1,0,0,0,0,1,0,0,0,1,0,0, +2,0,0,0,0,1,2,2,2,2,2,2,2,1,2,1,1,1,1,1,1,1,0,1,1,1,1,1,2,1,1,1, +1,2,2,2,2,1,1,2,1,2,1,1,1,0,2,1,2,1,1,1,0,2,1,1,1,1,0,1,0,0,0,0, +3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0, +1,1,0,1,0,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,3,2,0,0,0,0,1,0,0,0,0,0,0,1,1,0,2,0,0,0,0,0,0,0,0,1,0,1,2, +1,1,1,1,1,1,0,0,2,2,2,2,2,0,1,1,0,1,1,1,1,1,0,0,1,0,0,0,1,1,0,1, +2,3,1,2,1,0,1,1,0,2,2,2,0,0,1,0,0,1,1,1,1,0,0,0,0,0,0,0,1,0,1,2, +1,1,1,1,2,1,1,1,1,1,1,1,1,0,1,1,0,1,0,1,0,1,0,0,1,0,0,0,0,1,0,0, +2,2,2,2,2,0,0,2,0,0,2,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,2,0,2,2, +1,1,1,1,1,0,0,1,2,1,1,0,1,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,2,2,0,0,2,0,1,1,0,0,0,1,0,0,2,0,2,0,0,0,0,0,0,0,0,0,0,1,1, +0,0,0,1,1,1,1,1,1,1,1,1,1,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,3,2,0,0,1,0,0,1,0,0,0,0,0,0,1,0,2,0,0,0,1,0,0,0,0,0,0,0,2, +1,1,0,0,1,0,0,0,1,1,0,0,1,0,1,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +2,1,2,2,2,1,2,1,2,2,1,1,2,1,1,1,0,1,1,1,1,2,0,1,0,1,1,1,1,0,1,1, +1,1,2,1,1,1,1,1,1,0,0,1,2,1,1,1,1,1,1,0,0,1,1,1,0,0,0,0,0,0,0,0, +1,0,0,1,3,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,2,1,0,0,1,0,2,0,0,0,0,0,1,1,1,0,1,0,0,0,0,0,0,0,0,2,0,0,1, +0,2,0,1,0,0,1,1,2,0,1,0,1,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,2,2,0,1,1,0,2,1,0,1,1,1,0,0,1,0,2,0,1,0,0,0,0,0,0,0,0,0,1, +0,1,0,0,1,0,0,0,1,1,0,0,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,2,2,0,0,1,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1, +0,1,0,1,1,1,0,0,1,1,1,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +2,0,1,0,0,1,2,1,1,1,1,1,1,2,2,1,0,0,1,0,1,0,0,0,0,1,1,1,1,0,0,0, +1,1,2,1,1,1,1,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,1,2,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1, +0,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,1,2,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0, +0,1,1,0,1,1,1,0,0,1,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0, +1,0,1,0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,1,0,2,0,0,2,0,1,0,0,1,0,0,1, +1,1,0,0,1,1,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0, +1,1,1,1,1,1,1,2,0,0,0,0,0,0,2,1,0,1,1,0,0,1,1,1,0,1,0,0,0,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,1,0,1,1,1,1,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +) + +Latin5BulgarianModel = { \ + 'charToOrderMap': Latin5_BulgarianCharToOrderMap, + 'precedenceMatrix': BulgarianLangModel, + 'mTypicalPositiveRatio': 0.969392, + 'keepEnglishLetter': constants.False, + 'charsetName': "ISO-8859-5" +} + +Win1251BulgarianModel = { \ + 'charToOrderMap': win1251BulgarianCharToOrderMap, + 'precedenceMatrix': BulgarianLangModel, + 'mTypicalPositiveRatio': 0.969392, + 'keepEnglishLetter': constants.False, + 'charsetName': "windows-1251" +} diff --git a/fanficdownloader/chardet/langcyrillicmodel.py b/fanficdownloader/chardet/langcyrillicmodel.py new file mode 100644 index 00000000..e604cc73 --- /dev/null +++ b/fanficdownloader/chardet/langcyrillicmodel.py @@ -0,0 +1,329 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants + +# KOI8-R language model +# Character Mapping Table: +KOI8R_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253,142,143,144,145,146,147,148,149,150,151,152, 74,153, 75,154, # 40 +155,156,157,158,159,160,161,162,163,164,165,253,253,253,253,253, # 50 +253, 71,172, 66,173, 65,174, 76,175, 64,176,177, 77, 72,178, 69, # 60 + 67,179, 78, 73,180,181, 79,182,183,184,185,253,253,253,253,253, # 70 +191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206, # 80 +207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222, # 90 +223,224,225, 68,226,227,228,229,230,231,232,233,234,235,236,237, # a0 +238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253, # b0 + 27, 3, 21, 28, 13, 2, 39, 19, 26, 4, 23, 11, 8, 12, 5, 1, # c0 + 15, 16, 9, 7, 6, 14, 24, 10, 17, 18, 20, 25, 30, 29, 22, 54, # d0 + 59, 37, 44, 58, 41, 48, 53, 46, 55, 42, 60, 36, 49, 38, 31, 34, # e0 + 35, 43, 45, 32, 40, 52, 56, 33, 61, 62, 51, 57, 47, 63, 50, 70, # f0 +) + +win1251_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253,142,143,144,145,146,147,148,149,150,151,152, 74,153, 75,154, # 40 +155,156,157,158,159,160,161,162,163,164,165,253,253,253,253,253, # 50 +253, 71,172, 66,173, 65,174, 76,175, 64,176,177, 77, 72,178, 69, # 60 + 67,179, 78, 73,180,181, 79,182,183,184,185,253,253,253,253,253, # 70 +191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206, +207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222, +223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238, +239,240,241,242,243,244,245,246, 68,247,248,249,250,251,252,253, + 37, 44, 33, 46, 41, 48, 56, 51, 42, 60, 36, 49, 38, 31, 34, 35, + 45, 32, 40, 52, 53, 55, 58, 50, 57, 63, 70, 62, 61, 47, 59, 43, + 3, 21, 10, 19, 13, 2, 24, 20, 4, 23, 11, 8, 12, 5, 1, 15, + 9, 7, 6, 14, 39, 26, 28, 22, 25, 29, 54, 18, 17, 30, 27, 16, +) + +latin5_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253,142,143,144,145,146,147,148,149,150,151,152, 74,153, 75,154, # 40 +155,156,157,158,159,160,161,162,163,164,165,253,253,253,253,253, # 50 +253, 71,172, 66,173, 65,174, 76,175, 64,176,177, 77, 72,178, 69, # 60 + 67,179, 78, 73,180,181, 79,182,183,184,185,253,253,253,253,253, # 70 +191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206, +207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222, +223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238, + 37, 44, 33, 46, 41, 48, 56, 51, 42, 60, 36, 49, 38, 31, 34, 35, + 45, 32, 40, 52, 53, 55, 58, 50, 57, 63, 70, 62, 61, 47, 59, 43, + 3, 21, 10, 19, 13, 2, 24, 20, 4, 23, 11, 8, 12, 5, 1, 15, + 9, 7, 6, 14, 39, 26, 28, 22, 25, 29, 54, 18, 17, 30, 27, 16, +239, 68,240,241,242,243,244,245,246,247,248,249,250,251,252,255, +) + +macCyrillic_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253,142,143,144,145,146,147,148,149,150,151,152, 74,153, 75,154, # 40 +155,156,157,158,159,160,161,162,163,164,165,253,253,253,253,253, # 50 +253, 71,172, 66,173, 65,174, 76,175, 64,176,177, 77, 72,178, 69, # 60 + 67,179, 78, 73,180,181, 79,182,183,184,185,253,253,253,253,253, # 70 + 37, 44, 33, 46, 41, 48, 56, 51, 42, 60, 36, 49, 38, 31, 34, 35, + 45, 32, 40, 52, 53, 55, 58, 50, 57, 63, 70, 62, 61, 47, 59, 43, +191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206, +207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222, +223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238, +239,240,241,242,243,244,245,246,247,248,249,250,251,252, 68, 16, + 3, 21, 10, 19, 13, 2, 24, 20, 4, 23, 11, 8, 12, 5, 1, 15, + 9, 7, 6, 14, 39, 26, 28, 22, 25, 29, 54, 18, 17, 30, 27,255, +) + +IBM855_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253,142,143,144,145,146,147,148,149,150,151,152, 74,153, 75,154, # 40 +155,156,157,158,159,160,161,162,163,164,165,253,253,253,253,253, # 50 +253, 71,172, 66,173, 65,174, 76,175, 64,176,177, 77, 72,178, 69, # 60 + 67,179, 78, 73,180,181, 79,182,183,184,185,253,253,253,253,253, # 70 +191,192,193,194, 68,195,196,197,198,199,200,201,202,203,204,205, +206,207,208,209,210,211,212,213,214,215,216,217, 27, 59, 54, 70, + 3, 37, 21, 44, 28, 58, 13, 41, 2, 48, 39, 53, 19, 46,218,219, +220,221,222,223,224, 26, 55, 4, 42,225,226,227,228, 23, 60,229, +230,231,232,233,234,235, 11, 36,236,237,238,239,240,241,242,243, + 8, 49, 12, 38, 5, 31, 1, 34, 15,244,245,246,247, 35, 16,248, + 43, 9, 45, 7, 32, 6, 40, 14, 52, 24, 56, 10, 33, 17, 61,249, +250, 18, 62, 20, 51, 25, 57, 30, 47, 29, 63, 22, 50,251,252,255, +) + +IBM866_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253,142,143,144,145,146,147,148,149,150,151,152, 74,153, 75,154, # 40 +155,156,157,158,159,160,161,162,163,164,165,253,253,253,253,253, # 50 +253, 71,172, 66,173, 65,174, 76,175, 64,176,177, 77, 72,178, 69, # 60 + 67,179, 78, 73,180,181, 79,182,183,184,185,253,253,253,253,253, # 70 + 37, 44, 33, 46, 41, 48, 56, 51, 42, 60, 36, 49, 38, 31, 34, 35, + 45, 32, 40, 52, 53, 55, 58, 50, 57, 63, 70, 62, 61, 47, 59, 43, + 3, 21, 10, 19, 13, 2, 24, 20, 4, 23, 11, 8, 12, 5, 1, 15, +191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206, +207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222, +223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238, + 9, 7, 6, 14, 39, 26, 28, 22, 25, 29, 54, 18, 17, 30, 27, 16, +239, 68,240,241,242,243,244,245,246,247,248,249,250,251,252,255, +) + +# Model Table: +# total sequences: 100% +# first 512 sequences: 97.6601% +# first 1024 sequences: 2.3389% +# rest sequences: 0.1237% +# negative sequences: 0.0009% +RussianLangModel = ( \ +0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,1,3,3,3,2,3,2,3,3, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,3,2,2,2,2,2,0,0,2, +3,3,3,2,3,3,3,3,3,3,3,3,3,3,2,3,3,0,0,3,3,3,3,3,3,3,3,3,2,3,2,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,2,2,3,3,3,3,3,3,3,3,3,2,3,3,0,0,3,3,3,3,3,3,3,3,2,3,3,1,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,2,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,3,3,3,3,3,3,3,3,3,3,3,2,1, +0,0,0,0,0,0,0,2,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,0,0,3,3,3,3,3,3,3,3,3,3,3,2,1, +0,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,2,2,2,3,1,3,3,1,3,3,3,3,2,2,3,0,2,2,2,3,3,2,1,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,2,3,3,3,3,3,2,2,3,2,3,3,3,2,1,2,2,0,1,2,2,2,2,2,2,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,3,0,2,2,3,3,2,1,2,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,1,0,0,2,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,2,3,3,1,2,3,2,2,3,2,3,3,3,3,2,2,3,0,3,2,2,3,1,1,1,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,2,3,3,3,3,2,2,2,0,3,3,3,2,2,2,2,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,2,3,2,3,3,3,3,3,3,2,3,2,2,0,1,3,2,1,2,2,1,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,2,1,1,3,0,1,1,1,1,2,1,1,0,2,2,2,1,2,0,1,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,2,3,3,2,2,2,2,1,3,2,3,2,3,2,1,2,2,0,1,1,2,1,2,1,2,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,2,3,3,3,2,2,2,2,0,2,2,2,2,3,1,1,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0, +3,2,3,2,2,3,3,3,3,3,3,3,3,3,1,3,2,0,0,3,3,3,3,2,3,3,3,3,2,3,2,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,3,3,3,3,2,2,3,3,0,2,1,0,3,2,3,2,3,0,0,1,2,0,0,1,0,1,2,1,1,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,3,0,2,3,3,3,3,2,3,3,3,3,1,2,2,0,0,2,3,2,2,2,3,2,3,2,2,3,0,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,2,3,0,2,3,2,3,0,1,2,3,3,2,0,2,3,0,0,2,3,2,2,0,1,3,1,3,2,2,1,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,3,0,2,3,3,3,3,3,3,3,3,2,1,3,2,0,0,2,2,3,3,3,2,3,3,0,2,2,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,2,2,3,3,2,2,2,3,3,0,0,1,1,1,1,1,2,0,0,1,1,1,1,0,1,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,0,3,2,3,3,2,3,2,0,2,1,0,1,1,0,1,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,2,3,3,3,2,2,2,2,3,1,3,2,3,1,1,2,1,0,2,2,2,2,1,3,1,0, +0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0, +2,2,3,3,3,3,3,1,2,2,1,3,1,0,3,0,0,3,0,0,0,1,1,0,1,2,1,0,0,0,0,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,2,2,1,1,3,3,3,2,2,1,2,2,3,1,1,2,0,0,2,2,1,3,0,0,2,1,1,2,1,1,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,2,3,3,3,3,1,2,2,2,1,2,1,3,3,1,1,2,1,2,1,2,2,0,2,0,0,1,1,0,1,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,3,3,3,3,2,1,3,2,2,3,2,0,3,2,0,3,0,1,0,1,1,0,0,1,1,1,1,0,1,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,2,3,3,3,2,2,2,3,3,1,2,1,2,1,0,1,0,1,1,0,1,0,0,2,1,1,1,0,1,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0, +3,1,1,2,1,2,3,3,2,2,1,2,2,3,0,2,1,0,0,2,2,3,2,1,2,2,2,2,2,3,1,0, +0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,1,1,0,1,1,2,2,1,1,3,0,0,1,3,1,1,1,0,0,0,1,0,1,1,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,1,3,3,3,2,0,0,0,2,1,0,1,0,2,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,0,1,0,0,2,3,2,2,2,1,2,2,2,1,2,1,0,0,1,1,1,0,2,0,1,1,1,0,0,1,1, +1,0,0,0,0,0,1,2,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0, +2,3,3,3,3,0,0,0,0,1,0,0,0,0,3,0,1,2,1,0,0,0,0,0,0,0,1,1,0,0,1,1, +1,0,1,0,1,2,0,0,1,1,2,1,0,1,1,1,1,0,1,1,1,1,0,1,0,0,1,0,0,1,1,0, +2,2,3,2,2,2,3,1,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,0,1,0,1,1,1,0,2,1, +1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,0,1,0,1,1,0,1,1,1,0,1,1,0, +3,3,3,2,2,2,2,3,2,2,1,1,2,2,2,2,1,1,3,1,2,1,2,0,0,1,1,0,1,0,2,1, +1,1,1,1,1,2,1,0,1,1,1,1,0,1,0,0,1,1,0,0,1,0,1,0,0,1,0,0,0,1,1,0, +2,0,0,1,0,3,2,2,2,2,1,2,1,2,1,2,0,0,0,2,1,2,2,1,1,2,2,0,1,1,0,2, +1,1,1,1,1,0,1,1,1,2,1,1,1,2,1,0,1,2,1,1,1,1,0,1,1,1,0,0,1,0,0,1, +1,3,2,2,2,1,1,1,2,3,0,0,0,0,2,0,2,2,1,0,0,0,0,0,0,1,0,0,0,0,1,1, +1,0,1,1,0,1,0,1,1,0,1,1,0,2,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0, +2,3,2,3,2,1,2,2,2,2,1,0,0,0,2,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,2,1, +1,1,2,1,0,2,0,0,1,0,1,0,0,1,0,0,1,1,0,1,1,0,0,0,0,0,1,0,0,0,0,0, +3,0,0,1,0,2,2,2,3,2,2,2,2,2,2,2,0,0,0,2,1,2,1,1,1,2,2,0,0,0,1,2, +1,1,1,1,1,0,1,2,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,1, +2,3,2,3,3,2,0,1,1,1,0,0,1,0,2,0,1,1,3,1,0,0,0,0,0,0,0,1,0,0,2,1, +1,1,1,1,1,1,1,0,1,0,1,1,1,1,0,1,1,1,0,0,1,1,0,1,0,0,0,0,0,0,1,0, +2,3,3,3,3,1,2,2,2,2,0,1,1,0,2,1,1,1,2,1,0,1,1,0,0,1,0,1,0,0,2,0, +0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,3,3,2,0,0,1,1,2,2,1,0,0,2,0,1,1,3,0,0,1,0,0,0,0,0,1,0,1,2,1, +1,1,2,0,1,1,1,0,1,0,1,1,0,1,0,1,1,1,1,0,1,0,0,0,0,0,0,1,0,1,1,0, +1,3,2,3,2,1,0,0,2,2,2,0,1,0,2,0,1,1,1,0,1,0,0,0,3,0,1,1,0,0,2,1, +1,1,1,0,1,1,0,0,0,0,1,1,0,1,0,0,2,1,1,0,1,0,0,0,1,0,1,0,0,1,1,0, +3,1,2,1,1,2,2,2,2,2,2,1,2,2,1,1,0,0,0,2,2,2,0,0,0,1,2,1,0,1,0,1, +2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,2,1,1,1,0,1,0,1,1,0,1,1,1,0,0,1, +3,0,0,0,0,2,0,1,1,1,1,1,1,1,0,1,0,0,0,1,1,1,0,1,0,1,1,0,0,1,0,1, +1,1,0,0,1,0,0,0,1,0,1,1,0,0,1,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1, +1,3,3,2,2,0,0,0,2,2,0,0,0,1,2,0,1,1,2,0,0,0,0,0,0,0,0,1,0,0,2,1, +0,1,1,0,0,1,1,0,0,0,1,1,0,1,1,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0, +2,3,2,3,2,0,0,0,0,1,1,0,0,0,2,0,2,0,2,0,0,0,0,0,1,0,0,1,0,0,1,1, +1,1,2,0,1,2,1,0,1,1,2,1,1,1,1,1,2,1,1,0,1,0,0,1,1,1,1,1,0,1,1,0, +1,3,2,2,2,1,0,0,2,2,1,0,1,2,2,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,1,1, +0,0,1,1,0,1,1,0,0,1,1,0,1,1,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,1,0,2,3,1,2,2,2,2,2,2,1,1,0,0,0,1,0,1,0,2,1,1,1,0,0,0,0,1, +1,1,0,1,1,0,1,1,1,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0, +2,0,2,0,0,1,0,3,2,1,2,1,2,2,0,1,0,0,0,2,1,0,0,2,1,1,1,1,0,2,0,2, +2,1,1,1,1,1,1,1,1,1,1,1,1,2,1,0,1,1,1,1,0,0,0,1,1,1,1,0,1,0,0,1, +1,2,2,2,2,1,0,0,1,0,0,0,0,0,2,0,1,1,1,1,0,0,0,0,1,0,1,2,0,0,2,0, +1,0,1,1,1,2,1,0,1,0,1,1,0,0,1,0,1,1,1,0,1,0,0,0,1,0,0,1,0,1,1,0, +2,1,2,2,2,0,3,0,1,1,0,0,0,0,2,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +0,0,0,1,1,1,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0, +1,2,2,3,2,2,0,0,1,1,2,0,1,2,1,0,1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1, +0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,1,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0, +2,2,1,1,2,1,2,2,2,2,2,1,2,2,0,1,0,0,0,1,2,2,2,1,2,1,1,1,1,1,2,1, +1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,0,1,1,1,0,0,0,0,1,1,1,0,1,1,0,0,1, +1,2,2,2,2,0,1,0,2,2,0,0,0,0,2,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,2,0, +0,0,1,0,0,1,0,0,0,0,1,0,1,1,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,2,2,0,0,0,2,2,2,0,1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1, +0,1,1,0,0,1,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,2,2,0,0,0,0,1,0,0,1,1,2,0,0,0,0,1,0,1,0,0,1,0,0,2,0,0,0,1, +0,0,1,0,0,1,0,0,0,1,1,0,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,2,1,1,2,0,2,1,1,1,1,0,2,2,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,1, +0,0,1,0,1,1,0,0,0,0,1,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +1,0,2,1,2,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0, +0,0,1,0,1,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0, +1,0,0,0,0,2,0,1,2,1,0,1,1,1,0,1,0,0,0,1,0,1,0,0,1,0,1,0,0,0,0,1, +0,0,0,0,0,1,0,0,1,1,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1, +2,2,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +1,0,0,0,1,0,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +1,1,1,0,1,0,1,0,0,1,1,1,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,1,0,0,0,0, +1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +1,1,0,1,1,0,1,0,1,0,0,0,0,1,1,0,1,1,0,0,0,0,0,1,0,1,1,0,1,0,0,0, +0,1,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0, +) + +Koi8rModel = { \ + 'charToOrderMap': KOI8R_CharToOrderMap, + 'precedenceMatrix': RussianLangModel, + 'mTypicalPositiveRatio': 0.976601, + 'keepEnglishLetter': constants.False, + 'charsetName': "KOI8-R" +} + +Win1251CyrillicModel = { \ + 'charToOrderMap': win1251_CharToOrderMap, + 'precedenceMatrix': RussianLangModel, + 'mTypicalPositiveRatio': 0.976601, + 'keepEnglishLetter': constants.False, + 'charsetName': "windows-1251" +} + +Latin5CyrillicModel = { \ + 'charToOrderMap': latin5_CharToOrderMap, + 'precedenceMatrix': RussianLangModel, + 'mTypicalPositiveRatio': 0.976601, + 'keepEnglishLetter': constants.False, + 'charsetName': "ISO-8859-5" +} + +MacCyrillicModel = { \ + 'charToOrderMap': macCyrillic_CharToOrderMap, + 'precedenceMatrix': RussianLangModel, + 'mTypicalPositiveRatio': 0.976601, + 'keepEnglishLetter': constants.False, + 'charsetName': "MacCyrillic" +}; + +Ibm866Model = { \ + 'charToOrderMap': IBM866_CharToOrderMap, + 'precedenceMatrix': RussianLangModel, + 'mTypicalPositiveRatio': 0.976601, + 'keepEnglishLetter': constants.False, + 'charsetName': "IBM866" +} + +Ibm855Model = { \ + 'charToOrderMap': IBM855_CharToOrderMap, + 'precedenceMatrix': RussianLangModel, + 'mTypicalPositiveRatio': 0.976601, + 'keepEnglishLetter': constants.False, + 'charsetName': "IBM855" +} diff --git a/fanficdownloader/chardet/langgreekmodel.py b/fanficdownloader/chardet/langgreekmodel.py new file mode 100644 index 00000000..ec6d49e8 --- /dev/null +++ b/fanficdownloader/chardet/langgreekmodel.py @@ -0,0 +1,225 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants + +# 255: Control characters that usually does not exist in any text +# 254: Carriage/Return +# 253: symbol (punctuation) that does not belong to word +# 252: 0 - 9 + +# Character Mapping Table: +Latin7_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 82,100,104, 94, 98,101,116,102,111,187,117, 92, 88,113, 85, # 40 + 79,118,105, 83, 67,114,119, 95, 99,109,188,253,253,253,253,253, # 50 +253, 72, 70, 80, 81, 60, 96, 93, 89, 68,120, 97, 77, 86, 69, 55, # 60 + 78,115, 65, 66, 58, 76,106,103, 87,107,112,253,253,253,253,253, # 70 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 80 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 90 +253,233, 90,253,253,253,253,253,253,253,253,253,253, 74,253,253, # a0 +253,253,253,253,247,248, 61, 36, 46, 71, 73,253, 54,253,108,123, # b0 +110, 31, 51, 43, 41, 34, 91, 40, 52, 47, 44, 53, 38, 49, 59, 39, # c0 + 35, 48,250, 37, 33, 45, 56, 50, 84, 57,120,121, 17, 18, 22, 15, # d0 +124, 1, 29, 20, 21, 3, 32, 13, 25, 5, 11, 16, 10, 6, 30, 4, # e0 + 9, 8, 14, 7, 2, 12, 28, 23, 42, 24, 64, 75, 19, 26, 27,253, # f0 +) + +win1253_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 82,100,104, 94, 98,101,116,102,111,187,117, 92, 88,113, 85, # 40 + 79,118,105, 83, 67,114,119, 95, 99,109,188,253,253,253,253,253, # 50 +253, 72, 70, 80, 81, 60, 96, 93, 89, 68,120, 97, 77, 86, 69, 55, # 60 + 78,115, 65, 66, 58, 76,106,103, 87,107,112,253,253,253,253,253, # 70 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 80 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 90 +253,233, 61,253,253,253,253,253,253,253,253,253,253, 74,253,253, # a0 +253,253,253,253,247,253,253, 36, 46, 71, 73,253, 54,253,108,123, # b0 +110, 31, 51, 43, 41, 34, 91, 40, 52, 47, 44, 53, 38, 49, 59, 39, # c0 + 35, 48,250, 37, 33, 45, 56, 50, 84, 57,120,121, 17, 18, 22, 15, # d0 +124, 1, 29, 20, 21, 3, 32, 13, 25, 5, 11, 16, 10, 6, 30, 4, # e0 + 9, 8, 14, 7, 2, 12, 28, 23, 42, 24, 64, 75, 19, 26, 27,253, # f0 +) + +# Model Table: +# total sequences: 100% +# first 512 sequences: 98.2851% +# first 1024 sequences:1.7001% +# rest sequences: 0.0359% +# negative sequences: 0.0148% +GreekLangModel = ( \ +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,3,2,2,3,3,3,3,3,3,3,3,1,3,3,3,0,2,2,3,3,0,3,0,3,2,0,3,3,3,0, +3,0,0,0,2,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,0,3,3,0,3,2,3,3,0,3,2,3,3,3,0,0,3,0,3,0,3,3,2,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0, +0,2,3,2,2,3,3,3,3,3,3,3,3,0,3,3,3,3,0,2,3,3,0,3,3,3,3,2,3,3,3,0, +2,0,0,0,2,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,2,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,0,2,1,3,3,3,3,2,3,3,2,3,3,2,0, +0,0,0,0,2,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,0,3,3,3,3,3,3,0,3,3,0,3,3,3,3,3,3,3,3,3,3,0,3,2,3,3,0, +2,0,1,0,2,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,2,3,0,0,0,0,3,3,0,3,1,3,3,3,0,3,3,0,3,3,3,3,0,0,0,0, +2,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,0,3,0,3,3,3,3,3,0,3,2,2,2,3,0,2,3,3,3,3,3,2,3,3,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,3,2,2,2,3,3,3,3,0,3,1,3,3,3,3,2,3,3,3,3,3,3,3,2,2,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,2,0,3,0,0,0,3,3,2,3,3,3,3,3,0,0,3,2,3,0,2,3,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,0,3,3,3,3,0,0,3,3,0,2,3,0,3,0,3,3,3,0,0,3,0,3,0,2,2,3,3,0,0, +0,0,1,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,2,0,3,2,3,3,3,3,0,3,3,3,3,3,0,3,3,2,3,2,3,3,2,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,2,3,2,3,3,3,3,3,3,0,2,3,2,3,2,2,2,3,2,3,3,2,3,0,2,2,2,3,0, +2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,3,0,0,0,3,3,3,2,3,3,0,0,3,0,3,0,0,0,3,2,0,3,0,3,0,0,2,0,2,0, +0,0,0,0,2,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,0,3,3,3,3,3,3,0,3,3,0,3,0,0,0,3,3,0,3,3,3,0,0,1,2,3,0, +3,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,2,0,0,3,2,2,3,3,0,3,3,3,3,3,2,1,3,0,3,2,3,3,2,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,3,3,0,2,3,3,3,3,3,3,0,0,3,0,3,0,0,0,3,3,0,3,2,3,0,0,3,3,3,0, +3,0,0,0,2,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,0,3,3,3,3,3,3,0,0,3,0,3,0,0,0,3,2,0,3,2,3,0,0,3,2,3,0, +2,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,3,1,2,2,3,3,3,3,3,3,0,2,3,0,3,0,0,0,3,3,0,3,0,2,0,0,2,3,1,0, +2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,0,3,3,3,3,0,3,0,3,3,2,3,0,3,3,3,3,3,3,0,3,3,3,0,2,3,0,0,3,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,0,3,3,3,0,0,3,0,0,0,3,3,0,3,0,2,3,3,0,0,3,0,3,0,3,3,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,3,0,0,0,3,3,3,3,3,3,0,0,3,0,2,0,0,0,3,3,0,3,0,3,0,0,2,0,2,0, +0,0,0,0,1,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,3,0,3,0,2,0,3,2,0,3,2,3,2,3,0,0,3,2,3,2,3,3,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,3,0,0,2,3,3,3,3,3,0,0,0,3,0,2,1,0,0,3,2,2,2,0,3,0,0,2,2,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,0,3,3,3,2,0,3,0,3,0,3,3,0,2,1,2,3,3,0,0,3,0,3,0,3,3,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,2,3,3,3,0,3,3,3,3,3,3,0,2,3,0,3,0,0,0,2,1,0,2,2,3,0,0,2,2,2,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,3,0,0,2,3,3,3,2,3,0,0,1,3,0,2,0,0,0,0,3,0,1,0,2,0,0,1,1,1,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,1,0,3,0,0,0,3,2,0,3,2,3,3,3,0,0,3,0,3,2,2,2,1,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,0,3,3,3,0,0,3,0,0,0,0,2,0,2,3,3,2,2,2,2,3,0,2,0,2,2,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,3,3,3,2,0,0,0,0,0,0,2,3,0,2,0,2,3,2,0,0,3,0,3,0,3,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,3,2,3,3,2,2,3,0,2,0,3,0,0,0,2,0,0,0,0,1,2,0,2,0,2,0, +0,2,0,2,0,2,2,0,0,1,0,2,2,2,0,2,2,2,0,2,2,2,0,0,2,0,0,1,0,0,0,0, +0,2,0,3,3,2,0,0,0,0,0,0,1,3,0,2,0,2,2,2,0,0,2,0,3,0,0,2,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,0,2,3,2,0,2,2,0,2,0,2,2,0,2,0,2,2,2,0,0,0,0,0,0,2,3,0,0,0,2, +0,1,2,0,0,0,0,2,2,0,0,0,2,1,0,2,2,0,0,0,0,0,0,1,0,2,0,0,0,0,0,0, +0,0,2,1,0,2,3,2,2,3,2,3,2,0,0,3,3,3,0,0,3,2,0,0,0,1,1,0,2,0,2,2, +0,2,0,2,0,2,2,0,0,2,0,2,2,2,0,2,2,2,2,0,0,2,0,0,0,2,0,1,0,0,0,0, +0,3,0,3,3,2,2,0,3,0,0,0,2,2,0,2,2,2,1,2,0,0,1,2,2,0,0,3,0,0,0,2, +0,1,2,0,0,0,1,2,0,0,0,0,0,0,0,2,2,0,1,0,0,2,0,0,0,2,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,2,3,3,2,2,0,0,0,2,0,2,3,3,0,2,0,0,0,0,0,0,2,2,2,0,2,2,0,2,0,2, +0,2,2,0,0,2,2,2,2,1,0,0,2,2,0,2,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0, +0,2,0,3,2,3,0,0,0,3,0,0,2,2,0,2,0,2,2,2,0,0,2,0,0,0,0,0,0,0,0,2, +0,0,2,2,0,0,2,2,2,0,0,0,0,0,0,2,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,2,0,0,3,2,0,2,2,2,2,2,0,0,0,2,0,0,0,0,2,0,1,0,0,2,0,1,0,0,0, +0,2,2,2,0,2,2,0,1,2,0,2,2,2,0,2,2,2,2,1,2,2,0,0,2,0,0,0,0,0,0,0, +0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, +0,2,0,2,0,2,2,0,0,0,0,1,2,1,0,0,2,2,0,0,2,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,3,2,3,0,0,2,0,0,0,2,2,0,2,0,0,0,1,0,0,2,0,2,0,2,2,0,0,0,0, +0,0,2,0,0,0,0,2,2,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0, +0,2,2,3,2,2,0,0,0,0,0,0,1,3,0,2,0,2,2,0,0,0,1,0,2,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,2,0,2,0,3,2,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +0,0,2,0,0,0,0,1,1,0,0,2,1,2,0,2,2,0,1,0,0,1,0,0,0,2,0,0,0,0,0,0, +0,3,0,2,2,2,0,0,2,0,0,0,2,0,0,0,2,3,0,2,0,0,0,0,0,0,2,2,0,0,0,2, +0,1,2,0,0,0,1,2,2,1,0,0,0,2,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,2,1,2,0,2,2,0,2,0,0,2,0,0,0,0,1,2,1,0,2,1,0,0,0,0,0,0,0,0,0,0, +0,0,2,0,0,0,3,1,2,2,0,2,0,0,0,0,2,0,0,0,2,0,0,3,0,0,0,0,2,2,2,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,2,1,0,2,0,1,2,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,1,0,0,0,0,0,0,2, +0,2,2,0,0,2,2,2,2,2,0,1,2,0,0,0,2,2,0,1,0,2,0,0,2,2,0,0,0,0,0,0, +0,0,0,0,1,0,0,0,0,0,0,0,3,0,0,2,0,0,0,0,0,0,0,0,2,0,2,0,0,0,0,2, +0,1,2,0,0,0,0,2,2,1,0,1,0,1,0,2,2,2,1,0,0,0,0,0,0,1,0,0,0,0,0,0, +0,2,0,1,2,0,0,0,0,0,0,0,0,0,0,2,0,0,2,2,0,0,0,0,1,0,0,0,0,0,0,2, +0,2,2,0,0,0,0,2,2,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,2,0,0,0, +0,2,2,2,2,0,0,0,3,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,1, +0,0,2,0,0,0,0,1,2,0,0,0,0,0,0,2,2,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0, +0,2,0,2,2,2,0,0,2,0,0,0,0,0,0,0,2,2,2,0,0,0,2,0,0,0,0,0,0,0,0,2, +0,0,1,0,0,0,0,2,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0, +0,3,0,2,0,0,0,0,0,0,0,0,2,0,0,0,0,0,2,0,0,0,0,0,0,0,2,0,0,0,0,2, +0,0,2,0,0,0,0,2,2,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,2,0,2,2,1,0,0,0,0,0,0,2,0,0,2,0,2,2,2,0,0,0,0,0,0,2,0,0,0,0,2, +0,0,2,0,0,2,0,2,2,0,0,0,0,2,0,2,0,0,0,0,0,2,0,0,0,2,0,0,0,0,0,0, +0,0,3,0,0,0,2,2,0,2,2,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,2,0,0,0,0,0, +0,2,2,2,2,2,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1, +0,0,0,0,0,0,0,2,1,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,2,2,0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, +0,2,0,0,0,2,0,0,0,0,0,1,0,0,0,0,2,2,0,0,0,1,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,2,0,0,0, +0,2,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,2,0,2,0,0,0, +0,0,0,0,0,0,0,0,2,1,0,0,0,0,0,0,2,0,0,0,1,2,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +) + +Latin7GreekModel = { \ + 'charToOrderMap': Latin7_CharToOrderMap, + 'precedenceMatrix': GreekLangModel, + 'mTypicalPositiveRatio': 0.982851, + 'keepEnglishLetter': constants.False, + 'charsetName': "ISO-8859-7" +} + +Win1253GreekModel = { \ + 'charToOrderMap': win1253_CharToOrderMap, + 'precedenceMatrix': GreekLangModel, + 'mTypicalPositiveRatio': 0.982851, + 'keepEnglishLetter': constants.False, + 'charsetName': "windows-1253" +} diff --git a/fanficdownloader/chardet/langhebrewmodel.py b/fanficdownloader/chardet/langhebrewmodel.py new file mode 100644 index 00000000..a8bcc65b --- /dev/null +++ b/fanficdownloader/chardet/langhebrewmodel.py @@ -0,0 +1,201 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Simon Montagu +# Portions created by the Initial Developer are Copyright (C) 2005 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# Shoshannah Forbes - original C code (?) +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants + +# 255: Control characters that usually does not exist in any text +# 254: Carriage/Return +# 253: symbol (punctuation) that does not belong to word +# 252: 0 - 9 + +# Windows-1255 language model +# Character Mapping Table: +win1255_CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 69, 91, 79, 80, 92, 89, 97, 90, 68,111,112, 82, 73, 95, 85, # 40 + 78,121, 86, 71, 67,102,107, 84,114,103,115,253,253,253,253,253, # 50 +253, 50, 74, 60, 61, 42, 76, 70, 64, 53,105, 93, 56, 65, 54, 49, # 60 + 66,110, 51, 43, 44, 63, 81, 77, 98, 75,108,253,253,253,253,253, # 70 +124,202,203,204,205, 40, 58,206,207,208,209,210,211,212,213,214, +215, 83, 52, 47, 46, 72, 32, 94,216,113,217,109,218,219,220,221, + 34,116,222,118,100,223,224,117,119,104,125,225,226, 87, 99,227, +106,122,123,228, 55,229,230,101,231,232,120,233, 48, 39, 57,234, + 30, 59, 41, 88, 33, 37, 36, 31, 29, 35,235, 62, 28,236,126,237, +238, 38, 45,239,240,241,242,243,127,244,245,246,247,248,249,250, + 9, 8, 20, 16, 3, 2, 24, 14, 22, 1, 25, 15, 4, 11, 6, 23, + 12, 19, 13, 26, 18, 27, 21, 17, 7, 10, 5,251,252,128, 96,253, +) + +# Model Table: +# total sequences: 100% +# first 512 sequences: 98.4004% +# first 1024 sequences: 1.5981% +# rest sequences: 0.087% +# negative sequences: 0.0015% +HebrewLangModel = ( \ +0,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,2,3,2,1,2,0,1,0,0, +3,0,3,1,0,0,1,3,2,0,1,1,2,0,2,2,2,1,1,1,1,2,1,1,1,2,0,0,2,2,0,1, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2, +1,2,1,2,1,2,0,0,2,0,0,0,0,0,1,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2, +1,2,1,3,1,1,0,0,2,0,0,0,1,0,1,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,0,1,2,2,1,3, +1,2,1,1,2,2,0,0,2,2,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,2,2,2,2,3,2, +1,2,1,2,2,2,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,2,3,2,2,3,2,2,2,1,2,2,2,2, +1,2,1,1,2,2,0,1,2,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,0,2,2,2,2,2, +0,2,0,2,2,2,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,0,2,2,2, +0,2,1,2,2,2,0,0,2,1,0,0,0,0,1,0,1,0,0,0,0,0,0,2,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,2,1,2,3,2,2,2, +1,2,1,2,2,2,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0, +3,3,3,3,3,3,3,3,3,2,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,1,0,2,0,2, +0,2,1,2,2,2,0,0,1,2,0,0,0,0,1,0,1,0,0,0,0,0,0,1,0,0,0,2,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,2,3,2,2,3,2,1,2,1,1,1, +0,1,1,1,1,1,3,0,1,0,0,0,0,2,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,0,1,1,0,0,1,0,0,1,0,0,0,0, +0,0,1,0,0,0,0,0,2,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2, +0,2,0,1,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,2,3,3,3,2,1,2,3,3,2,3,3,3,3,2,3,2,1,2,0,2,1,2, +0,2,0,2,2,2,0,0,1,2,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0, +3,3,3,3,3,3,3,3,3,2,3,3,3,1,2,2,3,3,2,3,2,3,2,2,3,1,2,2,0,2,2,2, +0,2,1,2,2,2,0,0,1,2,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,2,3,3,2,2,2,3,3,3,3,1,3,2,2,2, +0,2,0,1,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,2,3,2,2,2,1,2,2,0,2,2,2,2, +0,2,0,2,2,2,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,1,3,2,3,3,2,3,3,2,2,1,2,2,2,2,2,2, +0,2,1,2,1,2,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,2,3,2,3,3,2,3,3,3,3,2,3,2,3,3,3,3,3,2,2,2,2,2,2,2,1, +0,2,0,1,2,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,2,1,2,3,3,3,3,3,3,3,2,3,2,3,2,1,2,3,0,2,1,2,2, +0,2,1,1,2,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,2,0, +3,3,3,3,3,3,3,3,3,2,3,3,3,3,2,1,3,1,2,2,2,1,2,3,3,1,2,1,2,2,2,2, +0,1,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,2,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,0,2,3,3,3,1,3,3,3,1,2,2,2,2,1,1,2,2,2,2,2,2, +0,2,0,1,1,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,2,3,3,3,2,2,3,3,3,2,1,2,3,2,3,2,2,2,2,1,2,1,1,1,2,2, +0,2,1,1,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,0,0,1,0,0,0,0,0, +1,0,1,0,0,0,0,0,2,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,2,3,3,2,3,1,2,2,2,2,3,2,3,1,1,2,2,1,2,2,1,1,0,2,2,2,2, +0,1,0,1,2,2,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0, +3,0,0,1,1,0,1,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,2,0, +0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,0,0,1,1,0,1,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, +3,2,2,1,2,2,2,2,2,2,2,1,2,2,1,2,2,1,1,1,1,1,1,1,1,2,1,1,0,3,3,3, +0,3,0,2,2,2,2,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +2,2,2,3,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,1,1,1,2,0,1, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,0,2,2,0,0,0,0,0,0, +0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,1,0,2,1,0, +0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0, +0,3,1,1,2,2,2,2,2,1,2,2,2,1,1,2,2,2,2,2,2,2,1,2,2,1,0,1,1,1,1,0, +0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,2,1,1,1,1,2,1,1,2,1,0,1,1,1,1,1,1,1,1,1,1,1,0,1,0,0,0,0,0,0,0, +0,0,2,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,0,0,1,0,0, +2,1,1,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,1,2,1,2,1,1,1,1,0,0,0,0, +0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,2,1,2,2,2,2,2,2,2,2,2,2,1,2,1,2,1,1,2,1,1,1,2,1,2,1,2,0,1,0,1, +0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,3,1,2,2,2,1,2,2,2,2,2,2,2,2,1,2,1,1,1,1,1,1,2,1,2,1,1,0,1,0,1, +0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,1,2,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2, +0,2,0,1,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,1,1,1,1,1,1,1,0,1,1,0,1,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,2,0,1,1,1,0,1,0,0,0,1,1,0,1,1,0,0,0,0,0,1,1,0,0, +0,1,1,1,2,1,2,2,2,0,2,0,2,0,1,1,2,1,1,1,1,2,1,0,1,1,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,1,0,0,0,0,0,1,0,1,2,2,0,1,0,0,1,1,2,2,1,2,0,2,0,0,0,1,2,0,1, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,2,0,2,1,2,0,2,0,0,1,1,1,1,1,1,0,1,0,0,0,1,0,0,1, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,1,0,0,0,0,0,1,0,2,1,1,0,1,0,0,1,1,1,2,2,0,0,1,0,0,0,1,0,0,1, +1,1,2,1,0,1,1,1,0,1,0,1,1,1,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,2,2,1, +0,2,0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,1,0,0,1,0,1,1,1,1,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,1,1,1,1,1,1,1,1,2,1,0,1,1,1,1,1,1,1,1,1,1,1,0,1,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,0,1,1,0,1,0,0,0,1,1,0,1, +2,0,1,0,1,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,0,1,1,1,0,1,0,0,1,1,2,1,1,2,0,1,0,0,0,1,1,0,1, +1,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,0,1,1,2,0,1,0,0,0,0,2,1,1,2,0,2,0,0,0,1,1,0,1, +1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,0,2,1,1,0,1,0,0,2,2,1,2,1,1,0,1,0,0,0,1,1,0,1, +2,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,2,2,0,0,0,0,0,1,1,0,1,0,0,1,0,0,0,0,1,0,1, +1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,2,2,0,0,0,0,2,1,1,1,0,2,1,1,0,0,0,2,1,0,1, +1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,0,1,1,2,0,1,0,0,1,1,0,2,1,1,0,1,0,0,0,1,1,0,1, +2,2,1,1,1,0,1,1,0,1,1,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,0,2,1,1,0,1,0,0,1,1,0,1,2,1,0,2,0,0,0,1,1,0,1, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0, +0,1,0,0,2,0,2,1,1,0,1,0,1,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,0,1,1,2,0,1,0,0,1,1,1,0,1,0,0,1,0,0,0,1,0,0,1, +1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,0,0,0,0,0,1,0,1,1,0,0,1,0,0,2,1,1,1,1,1,0,1,0,0,0,0,1,0,1, +0,1,1,1,2,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,2,1,0,0,0,0,0,1,1,1,1,1,0,1,0,0,0,1,1,0,0, +) + +Win1255HebrewModel = { \ + 'charToOrderMap': win1255_CharToOrderMap, + 'precedenceMatrix': HebrewLangModel, + 'mTypicalPositiveRatio': 0.984004, + 'keepEnglishLetter': constants.False, + 'charsetName': "windows-1255" +} diff --git a/fanficdownloader/chardet/langhungarianmodel.py b/fanficdownloader/chardet/langhungarianmodel.py new file mode 100644 index 00000000..d635f03c --- /dev/null +++ b/fanficdownloader/chardet/langhungarianmodel.py @@ -0,0 +1,225 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants + +# 255: Control characters that usually does not exist in any text +# 254: Carriage/Return +# 253: symbol (punctuation) that does not belong to word +# 252: 0 - 9 + +# Character Mapping Table: +Latin2_HungarianCharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 28, 40, 54, 45, 32, 50, 49, 38, 39, 53, 36, 41, 34, 35, 47, + 46, 71, 43, 33, 37, 57, 48, 64, 68, 55, 52,253,253,253,253,253, +253, 2, 18, 26, 17, 1, 27, 12, 20, 9, 22, 7, 6, 13, 4, 8, + 23, 67, 10, 5, 3, 21, 19, 65, 62, 16, 11,253,253,253,253,253, +159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174, +175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190, +191,192,193,194,195,196,197, 75,198,199,200,201,202,203,204,205, + 79,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220, +221, 51, 81,222, 78,223,224,225,226, 44,227,228,229, 61,230,231, +232,233,234, 58,235, 66, 59,236,237,238, 60, 69, 63,239,240,241, + 82, 14, 74,242, 70, 80,243, 72,244, 15, 83, 77, 84, 30, 76, 85, +245,246,247, 25, 73, 42, 24,248,249,250, 31, 56, 29,251,252,253, +) + +win1250HungarianCharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 28, 40, 54, 45, 32, 50, 49, 38, 39, 53, 36, 41, 34, 35, 47, + 46, 72, 43, 33, 37, 57, 48, 64, 68, 55, 52,253,253,253,253,253, +253, 2, 18, 26, 17, 1, 27, 12, 20, 9, 22, 7, 6, 13, 4, 8, + 23, 67, 10, 5, 3, 21, 19, 65, 62, 16, 11,253,253,253,253,253, +161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176, +177,178,179,180, 78,181, 69,182,183,184,185,186,187,188,189,190, +191,192,193,194,195,196,197, 76,198,199,200,201,202,203,204,205, + 81,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220, +221, 51, 83,222, 80,223,224,225,226, 44,227,228,229, 61,230,231, +232,233,234, 58,235, 66, 59,236,237,238, 60, 70, 63,239,240,241, + 84, 14, 75,242, 71, 82,243, 73,244, 15, 85, 79, 86, 30, 77, 87, +245,246,247, 25, 74, 42, 24,248,249,250, 31, 56, 29,251,252,253, +) + +# Model Table: +# total sequences: 100% +# first 512 sequences: 94.7368% +# first 1024 sequences:5.2623% +# rest sequences: 0.8894% +# negative sequences: 0.0009% +HungarianLangModel = ( \ +0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3, +3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,2,2,3,3,1,1,2,2,2,2,2,1,2, +3,2,2,3,3,3,3,3,2,3,3,3,3,3,3,1,2,3,3,3,3,2,3,3,1,1,3,3,0,1,1,1, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0, +3,2,1,3,3,3,3,3,2,3,3,3,3,3,1,1,2,3,3,3,3,3,3,3,1,1,3,2,0,1,1,1, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,1,1,2,3,3,3,1,3,3,3,3,3,1,3,3,2,2,0,3,2,3, +0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0, +3,3,3,3,3,3,2,3,3,3,2,3,3,2,3,3,3,3,3,2,3,3,2,2,3,2,3,2,0,3,2,2, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0, +3,3,3,3,3,3,2,3,3,3,3,3,2,3,3,3,1,2,3,2,2,3,1,2,3,3,2,2,0,3,3,3, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,2,3,3,3,3,2,3,3,3,3,0,2,3,2, +0,0,0,1,1,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,1,1,1,3,3,2,1,3,2,2,3,2,1,3,2,2,1,0,3,3,1, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,2,2,3,3,3,3,3,1,2,3,3,3,3,1,2,1,3,3,3,3,2,2,3,1,1,3,2,0,1,1,1, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,2,1,3,3,3,3,3,2,2,1,3,3,3,0,1,1,2, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,2,3,3,2,3,3,3,2,0,3,2,3, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,1,0, +3,3,3,3,3,3,2,3,3,3,2,3,2,3,3,3,1,3,2,2,2,3,1,1,3,3,1,1,0,3,3,2, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,2,3,3,3,2,3,2,3,3,3,2,3,3,3,3,3,1,2,3,2,2,0,2,2,2, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,3,3,2,2,2,3,1,3,3,2,2,1,3,3,3,1,1,3,1,2,3,2,3,2,2,2,1,0,2,2,2, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0, +3,1,1,3,3,3,3,3,1,2,3,3,3,3,1,2,1,3,3,3,2,2,3,2,1,0,3,2,0,1,1,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,1,3,3,3,3,3,1,2,3,3,3,3,1,1,0,3,3,3,3,0,2,3,0,0,2,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,2,2,3,3,2,2,2,2,3,3,0,1,2,3,2,3,2,2,3,2,1,2,0,2,2,2, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0, +3,3,3,3,3,3,1,2,3,3,3,2,1,2,3,3,2,2,2,3,2,3,3,1,3,3,1,1,0,2,3,2, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,3,3,1,2,2,2,2,3,3,3,1,1,1,3,3,1,1,3,1,1,3,2,1,2,3,1,1,0,2,2,2, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,3,3,2,1,2,1,1,3,3,1,1,1,1,3,3,1,1,2,2,1,2,1,1,2,2,1,1,0,2,2,1, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,3,3,1,1,2,1,1,3,3,1,0,1,1,3,3,2,0,1,1,2,3,1,0,2,2,1,0,0,1,3,2, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,2,1,3,3,3,3,3,1,2,3,2,3,3,2,1,1,3,2,3,2,1,2,2,0,1,2,1,0,0,1,1, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, +3,3,3,3,2,2,2,2,3,1,2,2,1,1,3,3,0,3,2,1,2,3,2,1,3,3,1,1,0,2,1,3, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,3,3,2,2,2,3,2,3,3,3,2,1,1,3,3,1,1,1,2,2,3,2,3,2,2,2,1,0,2,2,1, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +1,0,0,3,3,3,3,3,0,0,3,3,2,3,0,0,0,2,3,3,1,0,1,2,0,0,1,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,2,3,3,3,3,3,1,2,3,3,2,2,1,1,0,3,3,2,2,1,2,2,1,0,2,2,0,1,1,1, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,2,2,1,3,1,2,3,3,2,2,1,1,2,2,1,1,1,1,3,2,1,1,1,1,2,1,0,1,2,1, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0, +2,3,3,1,1,1,1,1,3,3,3,0,1,1,3,3,1,1,1,1,1,2,2,0,3,1,1,2,0,2,1,1, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, +3,1,0,1,2,1,2,2,0,1,2,3,1,2,0,0,0,2,1,1,1,1,1,2,0,0,1,1,0,0,0,0, +1,2,1,2,2,2,1,2,1,2,0,2,0,2,2,1,1,2,1,1,2,1,1,1,0,1,0,0,0,1,1,0, +1,1,1,2,3,2,3,3,0,1,2,2,3,1,0,1,0,2,1,2,2,0,1,1,0,0,1,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,3,3,2,2,1,0,0,3,2,3,2,0,0,0,1,1,3,0,0,1,1,0,0,2,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,1,2,2,3,3,1,0,1,3,2,3,1,1,1,0,1,1,1,1,1,3,1,0,0,2,2,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,1,1,2,2,2,1,0,1,2,3,3,2,0,0,0,2,1,1,1,2,1,1,1,0,1,1,1,0,0,0, +1,2,2,2,2,2,1,1,1,2,0,2,1,1,1,1,1,2,1,1,1,1,1,1,0,1,1,1,0,0,1,1, +3,2,2,1,0,0,1,1,2,2,0,3,0,1,2,1,1,0,0,1,1,1,0,1,1,1,1,0,2,1,1,1, +2,2,1,1,1,2,1,2,1,1,1,1,1,1,1,2,1,1,1,2,3,1,1,1,1,1,1,1,1,1,0,1, +2,3,3,0,1,0,0,0,3,3,1,0,0,1,2,2,1,0,0,0,0,2,0,0,1,1,1,0,2,1,1,1, +2,1,1,1,1,1,1,2,1,1,0,1,1,0,1,1,1,0,1,2,1,1,0,1,1,1,1,1,1,1,0,1, +2,3,3,0,1,0,0,0,2,2,0,0,0,0,1,2,2,0,0,0,0,1,0,0,1,1,0,0,2,0,1,0, +2,1,1,1,1,2,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,2,0,1,1,1,1,1,0,1, +3,2,2,0,1,0,1,0,2,3,2,0,0,1,2,2,1,0,0,1,1,1,0,0,2,1,0,1,2,2,1,1, +2,1,1,1,1,1,1,2,1,1,1,1,1,1,0,2,1,0,1,1,0,1,1,1,0,1,1,2,1,1,0,1, +2,2,2,0,0,1,0,0,2,2,1,1,0,0,2,1,1,0,0,0,1,2,0,0,2,1,0,0,2,1,1,1, +2,1,1,1,1,2,1,2,1,1,1,2,2,1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1,1,0,1, +1,2,3,0,0,0,1,0,3,2,1,0,0,1,2,1,1,0,0,0,0,2,1,0,1,1,0,0,2,1,2,1, +1,1,0,0,0,1,0,1,1,1,1,1,2,0,0,1,0,0,0,2,0,0,1,1,1,1,1,1,1,1,0,1, +3,0,0,2,1,2,2,1,0,0,2,1,2,2,0,0,0,2,1,1,1,0,1,1,0,0,1,1,2,0,0,0, +1,2,1,2,2,1,1,2,1,2,0,1,1,1,1,1,1,1,1,1,2,1,1,0,0,1,1,1,1,0,0,1, +1,3,2,0,0,0,1,0,2,2,2,0,0,0,2,2,1,0,0,0,0,3,1,1,1,1,0,0,2,1,1,1, +2,1,0,1,1,1,0,1,1,1,1,1,1,1,0,2,1,0,0,1,0,1,1,0,1,1,1,1,1,1,0,1, +2,3,2,0,0,0,1,0,2,2,0,0,0,0,2,1,1,0,0,0,0,2,1,0,1,1,0,0,2,1,1,0, +2,1,1,1,1,2,1,2,1,2,0,1,1,1,0,2,1,1,1,2,1,1,1,1,0,1,1,1,1,1,0,1, +3,1,1,2,2,2,3,2,1,1,2,2,1,1,0,1,0,2,2,1,1,1,1,1,0,0,1,1,0,1,1,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,0,0,0,0,0,2,2,0,0,0,0,2,2,1,0,0,0,1,1,0,0,1,2,0,0,2,1,1,1, +2,2,1,1,1,2,1,2,1,1,0,1,1,1,1,2,1,1,1,2,1,1,1,1,0,1,2,1,1,1,0,1, +1,0,0,1,2,3,2,1,0,0,2,0,1,1,0,0,0,1,1,1,1,0,1,1,0,0,1,0,0,0,0,0, +1,2,1,2,1,2,1,1,1,2,0,2,1,1,1,0,1,2,0,0,1,1,1,0,0,0,0,0,0,0,0,0, +2,3,2,0,0,0,0,0,1,1,2,1,0,0,1,1,1,0,0,0,0,2,0,0,1,1,0,0,2,1,1,1, +2,1,1,1,1,1,1,2,1,0,1,1,1,1,0,2,1,1,1,1,1,1,0,1,0,1,1,1,1,1,0,1, +1,2,2,0,1,1,1,0,2,2,2,0,0,0,3,2,1,0,0,0,1,1,0,0,1,1,0,1,1,1,0,0, +1,1,0,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,2,1,1,1,0,0,1,1,1,0,1,0,1, +2,1,0,2,1,1,2,2,1,1,2,1,1,1,0,0,0,1,1,0,1,1,1,1,0,0,1,1,1,0,0,0, +1,2,2,2,2,2,1,1,1,2,0,2,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,0,0,0,1,0, +1,2,3,0,0,0,1,0,2,2,0,0,0,0,2,2,0,0,0,0,0,1,0,0,1,0,0,0,2,0,1,0, +2,1,1,1,1,1,0,2,0,0,0,1,2,1,1,1,1,0,1,2,0,1,0,1,0,1,1,1,0,1,0,1, +2,2,2,0,0,0,1,0,2,1,2,0,0,0,1,1,2,0,0,0,0,1,0,0,1,1,0,0,2,1,0,1, +2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,0,1,1,1,1,1,0,1, +1,2,2,0,0,0,1,0,2,2,2,0,0,0,1,1,0,0,0,0,0,1,1,0,2,0,0,1,1,1,0,1, +1,0,1,1,1,1,1,1,0,1,1,1,1,0,0,1,0,0,1,1,0,1,0,1,1,1,1,1,0,0,0,1, +1,0,0,1,0,1,2,1,0,0,1,1,1,2,0,0,0,1,1,0,1,0,1,1,0,0,1,0,0,0,0,0, +0,2,1,2,1,1,1,1,1,2,0,2,0,1,1,0,1,2,1,0,1,1,1,0,0,0,0,0,0,1,0,0, +2,1,1,0,1,2,0,0,1,1,1,0,0,0,1,1,0,0,0,0,0,1,0,0,1,0,0,0,2,1,0,1, +2,2,1,1,1,1,1,2,1,1,0,1,1,1,1,2,1,1,1,2,1,1,0,1,0,1,1,1,1,1,0,1, +1,2,2,0,0,0,0,0,1,1,0,0,0,0,2,1,0,0,0,0,0,2,0,0,2,2,0,0,2,0,0,1, +2,1,1,1,1,1,1,1,0,1,1,0,1,1,0,1,0,0,0,1,1,1,1,0,0,1,1,1,1,0,0,1, +1,1,2,0,0,3,1,0,2,1,1,1,0,0,1,1,1,0,0,0,1,1,0,0,0,1,0,0,1,0,1,0, +1,2,1,0,1,1,1,2,1,1,0,1,1,1,1,1,0,0,0,1,1,1,1,1,0,1,0,0,0,1,0,0, +2,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,2,0,0,0, +2,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,2,1,1,0,0,1,1,1,1,1,0,1, +2,1,1,1,2,1,1,1,0,1,1,2,1,0,0,0,0,1,1,1,1,0,1,0,0,0,0,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,1,0,1,1,1,1,1,0,0,1,1,2,1,0,0,0,1,1,0,0,0,1,1,0,0,1,0,1,0,0,0, +1,2,1,1,1,1,1,1,1,1,0,1,0,1,1,1,1,1,1,0,1,1,1,0,0,0,0,0,0,1,0,0, +2,0,0,0,1,1,1,1,0,0,1,1,0,0,0,0,0,1,1,1,2,0,0,1,0,0,1,0,1,0,0,0, +0,1,1,1,1,1,1,1,1,2,0,1,1,1,1,0,1,1,1,0,1,1,1,0,0,0,0,0,0,0,0,0, +1,0,0,1,1,1,1,1,0,0,2,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0,1,0,0,0,0,0, +0,1,1,1,1,1,1,0,1,1,0,1,0,1,1,0,1,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0, +1,0,0,1,1,1,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0, +0,1,1,1,1,1,0,0,1,1,0,1,0,1,0,0,1,1,1,0,1,1,1,0,0,0,0,0,0,0,0,0, +0,0,0,1,0,0,0,0,0,0,1,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,1,1,1,0,1,0,0,1,1,0,1,0,1,1,0,1,1,1,0,1,1,1,0,0,0,0,0,0,0,0,0, +2,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,0,0,1,0,0,1,0,1,0,1,1,1,0,0,1,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,1,1,1,1,0,0,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0, +0,1,1,1,1,1,1,0,1,1,0,1,0,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0, +) + +Latin2HungarianModel = { \ + 'charToOrderMap': Latin2_HungarianCharToOrderMap, + 'precedenceMatrix': HungarianLangModel, + 'mTypicalPositiveRatio': 0.947368, + 'keepEnglishLetter': constants.True, + 'charsetName': "ISO-8859-2" +} + +Win1250HungarianModel = { \ + 'charToOrderMap': win1250HungarianCharToOrderMap, + 'precedenceMatrix': HungarianLangModel, + 'mTypicalPositiveRatio': 0.947368, + 'keepEnglishLetter': constants.True, + 'charsetName': "windows-1250" +} diff --git a/fanficdownloader/chardet/langthaimodel.py b/fanficdownloader/chardet/langthaimodel.py new file mode 100644 index 00000000..96ec054f --- /dev/null +++ b/fanficdownloader/chardet/langthaimodel.py @@ -0,0 +1,200 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants + +# 255: Control characters that usually does not exist in any text +# 254: Carriage/Return +# 253: symbol (punctuation) that does not belong to word +# 252: 0 - 9 + +# The following result for thai was collected from a limited sample (1M). + +# Character Mapping Table: +TIS620CharToOrderMap = ( \ +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253,182,106,107,100,183,184,185,101, 94,186,187,108,109,110,111, # 40 +188,189,190, 89, 95,112,113,191,192,193,194,253,253,253,253,253, # 50 +253, 64, 72, 73,114, 74,115,116,102, 81,201,117, 90,103, 78, 82, # 60 + 96,202, 91, 79, 84,104,105, 97, 98, 92,203,253,253,253,253,253, # 70 +209,210,211,212,213, 88,214,215,216,217,218,219,220,118,221,222, +223,224, 99, 85, 83,225,226,227,228,229,230,231,232,233,234,235, +236, 5, 30,237, 24,238, 75, 8, 26, 52, 34, 51,119, 47, 58, 57, + 49, 53, 55, 43, 20, 19, 44, 14, 48, 3, 17, 25, 39, 62, 31, 54, + 45, 9, 16, 2, 61, 15,239, 12, 42, 46, 18, 21, 76, 4, 66, 63, + 22, 10, 1, 36, 23, 13, 40, 27, 32, 35, 86,240,241,242,243,244, + 11, 28, 41, 29, 33,245, 50, 37, 6, 7, 67, 77, 38, 93,246,247, + 68, 56, 59, 65, 69, 60, 70, 80, 71, 87,248,249,250,251,252,253, +) + +# Model Table: +# total sequences: 100% +# first 512 sequences: 92.6386% +# first 1024 sequences:7.3177% +# rest sequences: 1.0230% +# negative sequences: 0.0436% +ThaiLangModel = ( \ +0,1,3,3,3,3,0,0,3,3,0,3,3,0,3,3,3,3,3,3,3,3,0,0,3,3,3,0,3,3,3,3, +0,3,3,0,0,0,1,3,0,3,3,2,3,3,0,1,2,3,3,3,3,0,2,0,2,0,0,3,2,1,2,2, +3,0,3,3,2,3,0,0,3,3,0,3,3,0,3,3,3,3,3,3,3,3,3,0,3,2,3,0,2,2,2,3, +0,2,3,0,0,0,0,1,0,1,2,3,1,1,3,2,2,0,1,1,0,0,1,0,0,0,0,0,0,0,1,1, +3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,3,3,2,3,2,3,3,2,2,2, +3,1,2,3,0,3,3,2,2,1,2,3,3,1,2,0,1,3,0,1,0,0,1,0,0,0,0,0,0,0,1,1, +3,3,2,2,3,3,3,3,1,2,3,3,3,3,3,2,2,2,2,3,3,2,2,3,3,2,2,3,2,3,2,2, +3,3,1,2,3,1,2,2,3,3,1,0,2,1,0,0,3,1,2,1,0,0,1,0,0,0,0,0,0,1,0,1, +3,3,3,3,3,3,2,2,3,3,3,3,2,3,2,2,3,3,2,2,3,2,2,2,2,1,1,3,1,2,1,1, +3,2,1,0,2,1,0,1,0,1,1,0,1,1,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0, +3,3,3,2,3,2,3,3,2,2,3,2,3,3,2,3,1,1,2,3,2,2,2,3,2,2,2,2,2,1,2,1, +2,2,1,1,3,3,2,1,0,1,2,2,0,1,3,0,0,0,1,1,0,0,0,0,0,2,3,0,0,2,1,1, +3,3,2,3,3,2,0,0,3,3,0,3,3,0,2,2,3,1,2,2,1,1,1,0,2,2,2,0,2,2,1,1, +0,2,1,0,2,0,0,2,0,1,0,0,1,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,1,0, +3,3,2,3,3,2,0,0,3,3,0,2,3,0,2,1,2,2,2,2,1,2,0,0,2,2,2,0,2,2,1,1, +0,2,1,0,2,0,0,2,0,1,1,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0, +3,3,2,3,2,3,2,0,2,2,1,3,2,1,3,2,1,2,3,2,2,3,0,2,3,2,2,1,2,2,2,2, +1,2,2,0,0,0,0,2,0,1,2,0,1,1,1,0,1,0,3,1,1,0,0,0,0,0,0,0,0,0,1,0, +3,3,2,3,3,2,3,2,2,2,3,2,2,3,2,2,1,2,3,2,2,3,1,3,2,2,2,3,2,2,2,3, +3,2,1,3,0,1,1,1,0,2,1,1,1,1,1,0,1,0,1,1,0,0,0,0,0,0,0,0,0,2,0,0, +1,0,0,3,0,3,3,3,3,3,0,0,3,0,2,2,3,3,3,3,3,0,0,0,1,1,3,0,0,0,0,2, +0,0,1,0,0,0,0,0,0,0,2,3,0,0,0,3,0,2,0,0,0,0,0,3,0,0,0,0,0,0,0,0, +2,0,3,3,3,3,0,0,2,3,0,0,3,0,3,3,2,3,3,3,3,3,0,0,3,3,3,0,0,0,3,3, +0,0,3,0,0,0,0,2,0,0,2,1,1,3,0,0,1,0,0,2,3,0,1,0,0,0,0,0,0,0,1,0, +3,3,3,3,2,3,3,3,3,3,3,3,1,2,1,3,3,2,2,1,2,2,2,3,1,1,2,0,2,1,2,1, +2,2,1,0,0,0,1,1,0,1,0,1,1,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0, +3,0,2,1,2,3,3,3,0,2,0,2,2,0,2,1,3,2,2,1,2,1,0,0,2,2,1,0,2,1,2,2, +0,1,1,0,0,0,0,1,0,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,2,1,3,3,1,1,3,0,2,3,1,1,3,2,1,1,2,0,2,2,3,2,1,1,1,1,1,2, +3,0,0,1,3,1,2,1,2,0,3,0,0,0,1,0,3,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0, +3,3,1,1,3,2,3,3,3,1,3,2,1,3,2,1,3,2,2,2,2,1,3,3,1,2,1,3,1,2,3,0, +2,1,1,3,2,2,2,1,2,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2, +3,3,2,3,2,3,3,2,3,2,3,2,3,3,2,1,0,3,2,2,2,1,2,2,2,1,2,2,1,2,1,1, +2,2,2,3,0,1,3,1,1,1,1,0,1,1,0,2,1,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,2,3,2,2,1,1,3,2,3,2,3,2,0,3,2,2,1,2,0,2,2,2,1,2,2,2,2,1, +3,2,1,2,2,1,0,2,0,1,0,0,1,1,0,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,2,3,1,2,3,3,2,2,3,0,1,1,2,0,3,3,2,2,3,0,1,1,3,0,0,0,0, +3,1,0,3,3,0,2,0,2,1,0,0,3,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,2,3,2,3,3,0,1,3,1,1,2,1,2,1,1,3,1,1,0,2,3,1,1,1,1,1,1,1,1, +3,1,1,2,2,2,2,1,1,1,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,2,2,1,1,2,1,3,3,2,3,2,2,3,2,2,3,1,2,2,1,2,0,3,2,1,2,2,2,2,2,1, +3,2,1,2,2,2,1,1,1,1,0,0,1,1,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,1,3,3,0,2,1,0,3,2,0,0,3,1,0,1,1,0,1,0,0,0,0,0,1, +1,0,0,1,0,3,2,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,2,2,2,3,0,0,1,3,0,3,2,0,3,2,2,3,3,3,3,3,1,0,2,2,2,0,2,2,1,2, +0,2,3,0,0,0,0,1,0,1,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,0,2,3,1,3,3,2,3,3,0,3,3,0,3,2,2,3,2,3,3,3,0,0,2,2,3,0,1,1,1,3, +0,0,3,0,0,0,2,2,0,1,3,0,1,2,2,2,3,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1, +3,2,3,3,2,0,3,3,2,2,3,1,3,2,1,3,2,0,1,2,2,0,2,3,2,1,0,3,0,0,0,0, +3,0,0,2,3,1,3,0,0,3,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,3,2,2,2,1,2,0,1,3,1,1,3,1,3,0,0,2,1,1,1,1,2,1,1,1,0,2,1,0,1, +1,2,0,0,0,3,1,1,0,0,0,0,1,0,1,0,0,1,0,1,0,0,0,0,0,3,1,0,0,0,1,0, +3,3,3,3,2,2,2,2,2,1,3,1,1,1,2,0,1,1,2,1,2,1,3,2,0,0,3,1,1,1,1,1, +3,1,0,2,3,0,0,0,3,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,2,3,0,3,3,0,2,0,0,0,0,0,0,0,3,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,2,3,1,3,0,0,1,2,0,0,2,0,3,3,2,3,3,3,2,3,0,0,2,2,2,0,0,0,2,2, +0,0,1,0,0,0,0,3,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0, +0,0,0,3,0,2,0,0,0,0,0,0,0,0,0,0,1,2,3,1,3,3,0,0,1,0,3,0,0,0,0,0, +0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,1,2,3,1,2,3,1,0,3,0,2,2,1,0,2,1,1,2,0,1,0,0,1,1,1,1,0,1,0,0, +1,0,0,0,0,1,1,0,3,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,2,1,0,1,1,1,3,1,2,2,2,2,2,2,1,1,1,1,0,3,1,0,1,3,1,1,1,1, +1,1,0,2,0,1,3,1,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,2,0,1, +3,0,2,2,1,3,3,2,3,3,0,1,1,0,2,2,1,2,1,3,3,1,0,0,3,2,0,0,0,0,2,1, +0,1,0,0,0,0,1,2,0,1,1,3,1,1,2,2,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, +0,0,3,0,0,1,0,0,0,3,0,0,3,0,3,1,0,1,1,1,3,2,0,0,0,3,0,0,0,0,2,0, +0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,2,0,0,0,0,0,0,0,0,0, +3,3,1,3,2,1,3,3,1,2,2,0,1,2,1,0,1,2,0,0,0,0,0,3,0,0,0,3,0,0,0,0, +3,0,0,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,1,2,0,3,3,3,2,2,0,1,1,0,1,3,0,0,0,2,2,0,0,0,0,3,1,0,1,0,0,0, +0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,2,3,1,2,0,0,2,1,0,3,1,0,1,2,0,1,1,1,1,3,0,0,3,1,1,0,2,2,1,1, +0,2,0,0,0,0,0,1,0,1,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,0,3,1,2,0,0,2,2,0,1,2,0,1,0,1,3,1,2,1,0,0,0,2,0,3,0,0,0,1,0, +0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,1,1,2,2,0,0,0,2,0,2,1,0,1,1,0,1,1,1,2,1,0,0,1,1,1,0,2,1,1,1, +0,1,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,1, +0,0,0,2,0,1,3,1,1,1,1,0,0,0,0,3,2,0,1,0,0,0,1,2,0,0,0,1,0,0,0,0, +0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,3,3,3,3,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,2,3,2,2,0,0,0,1,0,0,0,0,2,3,2,1,2,2,3,0,0,0,2,3,1,0,0,0,1,1, +0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0, +3,3,2,2,0,1,0,0,0,0,2,0,2,0,1,0,0,0,1,1,0,0,0,2,1,0,1,0,1,1,0,0, +0,1,0,2,0,0,1,0,3,0,1,0,0,0,2,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,1,0,0,1,0,0,0,0,0,1,1,2,0,0,0,0,1,0,0,1,3,1,0,0,0,0,1,1,0,0, +0,1,0,0,0,0,3,0,0,0,0,0,0,3,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0, +3,3,1,1,1,1,2,3,0,0,2,1,1,1,1,1,0,2,1,1,0,0,0,2,1,0,1,2,1,1,0,1, +2,1,0,3,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,3,1,0,0,0,0,0,0,0,3,0,0,0,3,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1, +0,0,0,2,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,2,0,0,0,0,0,0,1,2,1,0,1,1,0,2,0,0,1,0,0,2,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,2,0,0,0,1,3,0,1,0,0,0,2,0,0,0,0,0,0,0,1,2,0,0,0,0,0, +3,3,0,0,1,1,2,0,0,1,2,1,0,1,1,1,0,1,1,0,0,2,1,1,0,1,0,0,1,1,1,0, +0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,1,0,0,0,0,1,0,0,0,0,3,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0, +2,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,0,0,1,1,0,0,0,2,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,1,0,1,2,0,1,2,0,0,1,1,0,2,0,1,0,0,1,0,0,0,0,1,0,0,0,2,0,0,0,0, +1,0,0,1,0,1,1,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,1,0,0,0,0,0,0,0,1,1,0,1,1,0,2,1,3,0,0,0,0,1,1,0,0,0,0,0,0,0,3, +1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,0,1,0,1,0,0,2,0,0,2,0,0,1,1,2,0,0,1,1,0,0,0,1,0,0,0,1,1,0,0,0, +1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0, +1,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,1,1,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,0,0,0,0,2,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,3,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,1,0,0,0,0, +1,0,0,0,0,0,0,0,0,1,0,0,0,0,2,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,1,1,0,0,2,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +) + +TIS620ThaiModel = { \ + 'charToOrderMap': TIS620CharToOrderMap, + 'precedenceMatrix': ThaiLangModel, + 'mTypicalPositiveRatio': 0.926386, + 'keepEnglishLetter': constants.False, + 'charsetName': "TIS-620" +} diff --git a/fanficdownloader/chardet/latin1prober.py b/fanficdownloader/chardet/latin1prober.py new file mode 100644 index 00000000..b46129ba --- /dev/null +++ b/fanficdownloader/chardet/latin1prober.py @@ -0,0 +1,136 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from charsetprober import CharSetProber +import constants +import operator + +FREQ_CAT_NUM = 4 + +UDF = 0 # undefined +OTH = 1 # other +ASC = 2 # ascii capital letter +ASS = 3 # ascii small letter +ACV = 4 # accent capital vowel +ACO = 5 # accent capital other +ASV = 6 # accent small vowel +ASO = 7 # accent small other +CLASS_NUM = 8 # total classes + +Latin1_CharToClass = ( \ + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 00 - 07 + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 08 - 0F + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 10 - 17 + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 18 - 1F + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 20 - 27 + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 28 - 2F + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 30 - 37 + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 38 - 3F + OTH, ASC, ASC, ASC, ASC, ASC, ASC, ASC, # 40 - 47 + ASC, ASC, ASC, ASC, ASC, ASC, ASC, ASC, # 48 - 4F + ASC, ASC, ASC, ASC, ASC, ASC, ASC, ASC, # 50 - 57 + ASC, ASC, ASC, OTH, OTH, OTH, OTH, OTH, # 58 - 5F + OTH, ASS, ASS, ASS, ASS, ASS, ASS, ASS, # 60 - 67 + ASS, ASS, ASS, ASS, ASS, ASS, ASS, ASS, # 68 - 6F + ASS, ASS, ASS, ASS, ASS, ASS, ASS, ASS, # 70 - 77 + ASS, ASS, ASS, OTH, OTH, OTH, OTH, OTH, # 78 - 7F + OTH, UDF, OTH, ASO, OTH, OTH, OTH, OTH, # 80 - 87 + OTH, OTH, ACO, OTH, ACO, UDF, ACO, UDF, # 88 - 8F + UDF, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # 90 - 97 + OTH, OTH, ASO, OTH, ASO, UDF, ASO, ACO, # 98 - 9F + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # A0 - A7 + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # A8 - AF + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # B0 - B7 + OTH, OTH, OTH, OTH, OTH, OTH, OTH, OTH, # B8 - BF + ACV, ACV, ACV, ACV, ACV, ACV, ACO, ACO, # C0 - C7 + ACV, ACV, ACV, ACV, ACV, ACV, ACV, ACV, # C8 - CF + ACO, ACO, ACV, ACV, ACV, ACV, ACV, OTH, # D0 - D7 + ACV, ACV, ACV, ACV, ACV, ACO, ACO, ACO, # D8 - DF + ASV, ASV, ASV, ASV, ASV, ASV, ASO, ASO, # E0 - E7 + ASV, ASV, ASV, ASV, ASV, ASV, ASV, ASV, # E8 - EF + ASO, ASO, ASV, ASV, ASV, ASV, ASV, OTH, # F0 - F7 + ASV, ASV, ASV, ASV, ASV, ASO, ASO, ASO, # F8 - FF +) + +# 0 : illegal +# 1 : very unlikely +# 2 : normal +# 3 : very likely +Latin1ClassModel = ( \ +# UDF OTH ASC ASS ACV ACO ASV ASO + 0, 0, 0, 0, 0, 0, 0, 0, # UDF + 0, 3, 3, 3, 3, 3, 3, 3, # OTH + 0, 3, 3, 3, 3, 3, 3, 3, # ASC + 0, 3, 3, 3, 1, 1, 3, 3, # ASS + 0, 3, 3, 3, 1, 2, 1, 2, # ACV + 0, 3, 3, 3, 3, 3, 3, 3, # ACO + 0, 3, 1, 3, 1, 1, 1, 3, # ASV + 0, 3, 1, 3, 1, 1, 3, 3, # ASO +) + +class Latin1Prober(CharSetProber): + def __init__(self): + CharSetProber.__init__(self) + self.reset() + + def reset(self): + self._mLastCharClass = OTH + self._mFreqCounter = [0] * FREQ_CAT_NUM + CharSetProber.reset(self) + + def get_charset_name(self): + return "windows-1252" + + def feed(self, aBuf): + aBuf = self.filter_with_english_letters(aBuf) + for c in aBuf: + charClass = Latin1_CharToClass[ord(c)] + freq = Latin1ClassModel[(self._mLastCharClass * CLASS_NUM) + charClass] + if freq == 0: + self._mState = constants.eNotMe + break + self._mFreqCounter[freq] += 1 + self._mLastCharClass = charClass + + return self.get_state() + + def get_confidence(self): + if self.get_state() == constants.eNotMe: + return 0.01 + + total = reduce(operator.add, self._mFreqCounter) + if total < 0.01: + confidence = 0.0 + else: + confidence = (self._mFreqCounter[3] / total) - (self._mFreqCounter[1] * 20.0 / total) + if confidence < 0.0: + confidence = 0.0 + # lower the confidence of latin1 so that other more accurate detector + # can take priority. + confidence = confidence * 0.5 + return confidence diff --git a/fanficdownloader/chardet/mbcharsetprober.py b/fanficdownloader/chardet/mbcharsetprober.py new file mode 100644 index 00000000..a8131445 --- /dev/null +++ b/fanficdownloader/chardet/mbcharsetprober.py @@ -0,0 +1,82 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# Proofpoint, Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, sys +from constants import eStart, eError, eItsMe +from charsetprober import CharSetProber + +class MultiByteCharSetProber(CharSetProber): + def __init__(self): + CharSetProber.__init__(self) + self._mDistributionAnalyzer = None + self._mCodingSM = None + self._mLastChar = ['\x00', '\x00'] + + def reset(self): + CharSetProber.reset(self) + if self._mCodingSM: + self._mCodingSM.reset() + if self._mDistributionAnalyzer: + self._mDistributionAnalyzer.reset() + self._mLastChar = ['\x00', '\x00'] + + def get_charset_name(self): + pass + + def feed(self, aBuf): + aLen = len(aBuf) + for i in range(0, aLen): + codingState = self._mCodingSM.next_state(aBuf[i]) + if codingState == eError: + if constants._debug: + sys.stderr.write(self.get_charset_name() + ' prober hit error at byte ' + str(i) + '\n') + self._mState = constants.eNotMe + break + elif codingState == eItsMe: + self._mState = constants.eFoundIt + break + elif codingState == eStart: + charLen = self._mCodingSM.get_current_charlen() + if i == 0: + self._mLastChar[1] = aBuf[0] + self._mDistributionAnalyzer.feed(self._mLastChar, charLen) + else: + self._mDistributionAnalyzer.feed(aBuf[i-1:i+1], charLen) + + self._mLastChar[0] = aBuf[aLen - 1] + + if self.get_state() == constants.eDetecting: + if self._mDistributionAnalyzer.got_enough_data() and \ + (self.get_confidence() > constants.SHORTCUT_THRESHOLD): + self._mState = constants.eFoundIt + + return self.get_state() + + def get_confidence(self): + return self._mDistributionAnalyzer.get_confidence() diff --git a/fanficdownloader/chardet/mbcsgroupprober.py b/fanficdownloader/chardet/mbcsgroupprober.py new file mode 100644 index 00000000..941cc3e3 --- /dev/null +++ b/fanficdownloader/chardet/mbcsgroupprober.py @@ -0,0 +1,50 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# Proofpoint, Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from charsetgroupprober import CharSetGroupProber +from utf8prober import UTF8Prober +from sjisprober import SJISProber +from eucjpprober import EUCJPProber +from gb2312prober import GB2312Prober +from euckrprober import EUCKRProber +from big5prober import Big5Prober +from euctwprober import EUCTWProber + +class MBCSGroupProber(CharSetGroupProber): + def __init__(self): + CharSetGroupProber.__init__(self) + self._mProbers = [ \ + UTF8Prober(), + SJISProber(), + EUCJPProber(), + GB2312Prober(), + EUCKRProber(), + Big5Prober(), + EUCTWProber()] + self.reset() diff --git a/fanficdownloader/chardet/mbcssm.py b/fanficdownloader/chardet/mbcssm.py new file mode 100644 index 00000000..e46c1ffe --- /dev/null +++ b/fanficdownloader/chardet/mbcssm.py @@ -0,0 +1,514 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from constants import eStart, eError, eItsMe + +# BIG5 + +BIG5_cls = ( \ + 1,1,1,1,1,1,1,1, # 00 - 07 #allow 0x00 as legal value + 1,1,1,1,1,1,0,0, # 08 - 0f + 1,1,1,1,1,1,1,1, # 10 - 17 + 1,1,1,0,1,1,1,1, # 18 - 1f + 1,1,1,1,1,1,1,1, # 20 - 27 + 1,1,1,1,1,1,1,1, # 28 - 2f + 1,1,1,1,1,1,1,1, # 30 - 37 + 1,1,1,1,1,1,1,1, # 38 - 3f + 2,2,2,2,2,2,2,2, # 40 - 47 + 2,2,2,2,2,2,2,2, # 48 - 4f + 2,2,2,2,2,2,2,2, # 50 - 57 + 2,2,2,2,2,2,2,2, # 58 - 5f + 2,2,2,2,2,2,2,2, # 60 - 67 + 2,2,2,2,2,2,2,2, # 68 - 6f + 2,2,2,2,2,2,2,2, # 70 - 77 + 2,2,2,2,2,2,2,1, # 78 - 7f + 4,4,4,4,4,4,4,4, # 80 - 87 + 4,4,4,4,4,4,4,4, # 88 - 8f + 4,4,4,4,4,4,4,4, # 90 - 97 + 4,4,4,4,4,4,4,4, # 98 - 9f + 4,3,3,3,3,3,3,3, # a0 - a7 + 3,3,3,3,3,3,3,3, # a8 - af + 3,3,3,3,3,3,3,3, # b0 - b7 + 3,3,3,3,3,3,3,3, # b8 - bf + 3,3,3,3,3,3,3,3, # c0 - c7 + 3,3,3,3,3,3,3,3, # c8 - cf + 3,3,3,3,3,3,3,3, # d0 - d7 + 3,3,3,3,3,3,3,3, # d8 - df + 3,3,3,3,3,3,3,3, # e0 - e7 + 3,3,3,3,3,3,3,3, # e8 - ef + 3,3,3,3,3,3,3,3, # f0 - f7 + 3,3,3,3,3,3,3,0) # f8 - ff + +BIG5_st = ( \ + eError,eStart,eStart, 3,eError,eError,eError,eError,#00-07 + eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eError,#08-0f + eError,eStart,eStart,eStart,eStart,eStart,eStart,eStart)#10-17 + +Big5CharLenTable = (0, 1, 1, 2, 0) + +Big5SMModel = {'classTable': BIG5_cls, + 'classFactor': 5, + 'stateTable': BIG5_st, + 'charLenTable': Big5CharLenTable, + 'name': 'Big5'} + +# EUC-JP + +EUCJP_cls = ( \ + 4,4,4,4,4,4,4,4, # 00 - 07 + 4,4,4,4,4,4,5,5, # 08 - 0f + 4,4,4,4,4,4,4,4, # 10 - 17 + 4,4,4,5,4,4,4,4, # 18 - 1f + 4,4,4,4,4,4,4,4, # 20 - 27 + 4,4,4,4,4,4,4,4, # 28 - 2f + 4,4,4,4,4,4,4,4, # 30 - 37 + 4,4,4,4,4,4,4,4, # 38 - 3f + 4,4,4,4,4,4,4,4, # 40 - 47 + 4,4,4,4,4,4,4,4, # 48 - 4f + 4,4,4,4,4,4,4,4, # 50 - 57 + 4,4,4,4,4,4,4,4, # 58 - 5f + 4,4,4,4,4,4,4,4, # 60 - 67 + 4,4,4,4,4,4,4,4, # 68 - 6f + 4,4,4,4,4,4,4,4, # 70 - 77 + 4,4,4,4,4,4,4,4, # 78 - 7f + 5,5,5,5,5,5,5,5, # 80 - 87 + 5,5,5,5,5,5,1,3, # 88 - 8f + 5,5,5,5,5,5,5,5, # 90 - 97 + 5,5,5,5,5,5,5,5, # 98 - 9f + 5,2,2,2,2,2,2,2, # a0 - a7 + 2,2,2,2,2,2,2,2, # a8 - af + 2,2,2,2,2,2,2,2, # b0 - b7 + 2,2,2,2,2,2,2,2, # b8 - bf + 2,2,2,2,2,2,2,2, # c0 - c7 + 2,2,2,2,2,2,2,2, # c8 - cf + 2,2,2,2,2,2,2,2, # d0 - d7 + 2,2,2,2,2,2,2,2, # d8 - df + 0,0,0,0,0,0,0,0, # e0 - e7 + 0,0,0,0,0,0,0,0, # e8 - ef + 0,0,0,0,0,0,0,0, # f0 - f7 + 0,0,0,0,0,0,0,5) # f8 - ff + +EUCJP_st = ( \ + 3, 4, 3, 5,eStart,eError,eError,eError,#00-07 + eError,eError,eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,#08-0f + eItsMe,eItsMe,eStart,eError,eStart,eError,eError,eError,#10-17 + eError,eError,eStart,eError,eError,eError, 3,eError,#18-1f + 3,eError,eError,eError,eStart,eStart,eStart,eStart)#20-27 + +EUCJPCharLenTable = (2, 2, 2, 3, 1, 0) + +EUCJPSMModel = {'classTable': EUCJP_cls, + 'classFactor': 6, + 'stateTable': EUCJP_st, + 'charLenTable': EUCJPCharLenTable, + 'name': 'EUC-JP'} + +# EUC-KR + +EUCKR_cls = ( \ + 1,1,1,1,1,1,1,1, # 00 - 07 + 1,1,1,1,1,1,0,0, # 08 - 0f + 1,1,1,1,1,1,1,1, # 10 - 17 + 1,1,1,0,1,1,1,1, # 18 - 1f + 1,1,1,1,1,1,1,1, # 20 - 27 + 1,1,1,1,1,1,1,1, # 28 - 2f + 1,1,1,1,1,1,1,1, # 30 - 37 + 1,1,1,1,1,1,1,1, # 38 - 3f + 1,1,1,1,1,1,1,1, # 40 - 47 + 1,1,1,1,1,1,1,1, # 48 - 4f + 1,1,1,1,1,1,1,1, # 50 - 57 + 1,1,1,1,1,1,1,1, # 58 - 5f + 1,1,1,1,1,1,1,1, # 60 - 67 + 1,1,1,1,1,1,1,1, # 68 - 6f + 1,1,1,1,1,1,1,1, # 70 - 77 + 1,1,1,1,1,1,1,1, # 78 - 7f + 0,0,0,0,0,0,0,0, # 80 - 87 + 0,0,0,0,0,0,0,0, # 88 - 8f + 0,0,0,0,0,0,0,0, # 90 - 97 + 0,0,0,0,0,0,0,0, # 98 - 9f + 0,2,2,2,2,2,2,2, # a0 - a7 + 2,2,2,2,2,3,3,3, # a8 - af + 2,2,2,2,2,2,2,2, # b0 - b7 + 2,2,2,2,2,2,2,2, # b8 - bf + 2,2,2,2,2,2,2,2, # c0 - c7 + 2,3,2,2,2,2,2,2, # c8 - cf + 2,2,2,2,2,2,2,2, # d0 - d7 + 2,2,2,2,2,2,2,2, # d8 - df + 2,2,2,2,2,2,2,2, # e0 - e7 + 2,2,2,2,2,2,2,2, # e8 - ef + 2,2,2,2,2,2,2,2, # f0 - f7 + 2,2,2,2,2,2,2,0) # f8 - ff + +EUCKR_st = ( + eError,eStart, 3,eError,eError,eError,eError,eError,#00-07 + eItsMe,eItsMe,eItsMe,eItsMe,eError,eError,eStart,eStart)#08-0f + +EUCKRCharLenTable = (0, 1, 2, 0) + +EUCKRSMModel = {'classTable': EUCKR_cls, + 'classFactor': 4, + 'stateTable': EUCKR_st, + 'charLenTable': EUCKRCharLenTable, + 'name': 'EUC-KR'} + +# EUC-TW + +EUCTW_cls = ( \ + 2,2,2,2,2,2,2,2, # 00 - 07 + 2,2,2,2,2,2,0,0, # 08 - 0f + 2,2,2,2,2,2,2,2, # 10 - 17 + 2,2,2,0,2,2,2,2, # 18 - 1f + 2,2,2,2,2,2,2,2, # 20 - 27 + 2,2,2,2,2,2,2,2, # 28 - 2f + 2,2,2,2,2,2,2,2, # 30 - 37 + 2,2,2,2,2,2,2,2, # 38 - 3f + 2,2,2,2,2,2,2,2, # 40 - 47 + 2,2,2,2,2,2,2,2, # 48 - 4f + 2,2,2,2,2,2,2,2, # 50 - 57 + 2,2,2,2,2,2,2,2, # 58 - 5f + 2,2,2,2,2,2,2,2, # 60 - 67 + 2,2,2,2,2,2,2,2, # 68 - 6f + 2,2,2,2,2,2,2,2, # 70 - 77 + 2,2,2,2,2,2,2,2, # 78 - 7f + 0,0,0,0,0,0,0,0, # 80 - 87 + 0,0,0,0,0,0,6,0, # 88 - 8f + 0,0,0,0,0,0,0,0, # 90 - 97 + 0,0,0,0,0,0,0,0, # 98 - 9f + 0,3,4,4,4,4,4,4, # a0 - a7 + 5,5,1,1,1,1,1,1, # a8 - af + 1,1,1,1,1,1,1,1, # b0 - b7 + 1,1,1,1,1,1,1,1, # b8 - bf + 1,1,3,1,3,3,3,3, # c0 - c7 + 3,3,3,3,3,3,3,3, # c8 - cf + 3,3,3,3,3,3,3,3, # d0 - d7 + 3,3,3,3,3,3,3,3, # d8 - df + 3,3,3,3,3,3,3,3, # e0 - e7 + 3,3,3,3,3,3,3,3, # e8 - ef + 3,3,3,3,3,3,3,3, # f0 - f7 + 3,3,3,3,3,3,3,0) # f8 - ff + +EUCTW_st = ( \ + eError,eError,eStart, 3, 3, 3, 4,eError,#00-07 + eError,eError,eError,eError,eError,eError,eItsMe,eItsMe,#08-0f + eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eError,eStart,eError,#10-17 + eStart,eStart,eStart,eError,eError,eError,eError,eError,#18-1f + 5,eError,eError,eError,eStart,eError,eStart,eStart,#20-27 + eStart,eError,eStart,eStart,eStart,eStart,eStart,eStart)#28-2f + +EUCTWCharLenTable = (0, 0, 1, 2, 2, 2, 3) + +EUCTWSMModel = {'classTable': EUCTW_cls, + 'classFactor': 7, + 'stateTable': EUCTW_st, + 'charLenTable': EUCTWCharLenTable, + 'name': 'x-euc-tw'} + +# GB2312 + +GB2312_cls = ( \ + 1,1,1,1,1,1,1,1, # 00 - 07 + 1,1,1,1,1,1,0,0, # 08 - 0f + 1,1,1,1,1,1,1,1, # 10 - 17 + 1,1,1,0,1,1,1,1, # 18 - 1f + 1,1,1,1,1,1,1,1, # 20 - 27 + 1,1,1,1,1,1,1,1, # 28 - 2f + 3,3,3,3,3,3,3,3, # 30 - 37 + 3,3,1,1,1,1,1,1, # 38 - 3f + 2,2,2,2,2,2,2,2, # 40 - 47 + 2,2,2,2,2,2,2,2, # 48 - 4f + 2,2,2,2,2,2,2,2, # 50 - 57 + 2,2,2,2,2,2,2,2, # 58 - 5f + 2,2,2,2,2,2,2,2, # 60 - 67 + 2,2,2,2,2,2,2,2, # 68 - 6f + 2,2,2,2,2,2,2,2, # 70 - 77 + 2,2,2,2,2,2,2,4, # 78 - 7f + 5,6,6,6,6,6,6,6, # 80 - 87 + 6,6,6,6,6,6,6,6, # 88 - 8f + 6,6,6,6,6,6,6,6, # 90 - 97 + 6,6,6,6,6,6,6,6, # 98 - 9f + 6,6,6,6,6,6,6,6, # a0 - a7 + 6,6,6,6,6,6,6,6, # a8 - af + 6,6,6,6,6,6,6,6, # b0 - b7 + 6,6,6,6,6,6,6,6, # b8 - bf + 6,6,6,6,6,6,6,6, # c0 - c7 + 6,6,6,6,6,6,6,6, # c8 - cf + 6,6,6,6,6,6,6,6, # d0 - d7 + 6,6,6,6,6,6,6,6, # d8 - df + 6,6,6,6,6,6,6,6, # e0 - e7 + 6,6,6,6,6,6,6,6, # e8 - ef + 6,6,6,6,6,6,6,6, # f0 - f7 + 6,6,6,6,6,6,6,0) # f8 - ff + +GB2312_st = ( \ + eError,eStart,eStart,eStart,eStart,eStart, 3,eError,#00-07 + eError,eError,eError,eError,eError,eError,eItsMe,eItsMe,#08-0f + eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eError,eError,eStart,#10-17 + 4,eError,eStart,eStart,eError,eError,eError,eError,#18-1f + eError,eError, 5,eError,eError,eError,eItsMe,eError,#20-27 + eError,eError,eStart,eStart,eStart,eStart,eStart,eStart)#28-2f + +# To be accurate, the length of class 6 can be either 2 or 4. +# But it is not necessary to discriminate between the two since +# it is used for frequency analysis only, and we are validing +# each code range there as well. So it is safe to set it to be +# 2 here. +GB2312CharLenTable = (0, 1, 1, 1, 1, 1, 2) + +GB2312SMModel = {'classTable': GB2312_cls, + 'classFactor': 7, + 'stateTable': GB2312_st, + 'charLenTable': GB2312CharLenTable, + 'name': 'GB2312'} + +# Shift_JIS + +SJIS_cls = ( \ + 1,1,1,1,1,1,1,1, # 00 - 07 + 1,1,1,1,1,1,0,0, # 08 - 0f + 1,1,1,1,1,1,1,1, # 10 - 17 + 1,1,1,0,1,1,1,1, # 18 - 1f + 1,1,1,1,1,1,1,1, # 20 - 27 + 1,1,1,1,1,1,1,1, # 28 - 2f + 1,1,1,1,1,1,1,1, # 30 - 37 + 1,1,1,1,1,1,1,1, # 38 - 3f + 2,2,2,2,2,2,2,2, # 40 - 47 + 2,2,2,2,2,2,2,2, # 48 - 4f + 2,2,2,2,2,2,2,2, # 50 - 57 + 2,2,2,2,2,2,2,2, # 58 - 5f + 2,2,2,2,2,2,2,2, # 60 - 67 + 2,2,2,2,2,2,2,2, # 68 - 6f + 2,2,2,2,2,2,2,2, # 70 - 77 + 2,2,2,2,2,2,2,1, # 78 - 7f + 3,3,3,3,3,3,3,3, # 80 - 87 + 3,3,3,3,3,3,3,3, # 88 - 8f + 3,3,3,3,3,3,3,3, # 90 - 97 + 3,3,3,3,3,3,3,3, # 98 - 9f + #0xa0 is illegal in sjis encoding, but some pages does + #contain such byte. We need to be more error forgiven. + 2,2,2,2,2,2,2,2, # a0 - a7 + 2,2,2,2,2,2,2,2, # a8 - af + 2,2,2,2,2,2,2,2, # b0 - b7 + 2,2,2,2,2,2,2,2, # b8 - bf + 2,2,2,2,2,2,2,2, # c0 - c7 + 2,2,2,2,2,2,2,2, # c8 - cf + 2,2,2,2,2,2,2,2, # d0 - d7 + 2,2,2,2,2,2,2,2, # d8 - df + 3,3,3,3,3,3,3,3, # e0 - e7 + 3,3,3,3,3,4,4,4, # e8 - ef + 4,4,4,4,4,4,4,4, # f0 - f7 + 4,4,4,4,4,0,0,0) # f8 - ff + +SJIS_st = ( \ + eError,eStart,eStart, 3,eError,eError,eError,eError,#00-07 + eError,eError,eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,#08-0f + eItsMe,eItsMe,eError,eError,eStart,eStart,eStart,eStart)#10-17 + +SJISCharLenTable = (0, 1, 1, 2, 0, 0) + +SJISSMModel = {'classTable': SJIS_cls, + 'classFactor': 6, + 'stateTable': SJIS_st, + 'charLenTable': SJISCharLenTable, + 'name': 'Shift_JIS'} + +# UCS2-BE + +UCS2BE_cls = ( \ + 0,0,0,0,0,0,0,0, # 00 - 07 + 0,0,1,0,0,2,0,0, # 08 - 0f + 0,0,0,0,0,0,0,0, # 10 - 17 + 0,0,0,3,0,0,0,0, # 18 - 1f + 0,0,0,0,0,0,0,0, # 20 - 27 + 0,3,3,3,3,3,0,0, # 28 - 2f + 0,0,0,0,0,0,0,0, # 30 - 37 + 0,0,0,0,0,0,0,0, # 38 - 3f + 0,0,0,0,0,0,0,0, # 40 - 47 + 0,0,0,0,0,0,0,0, # 48 - 4f + 0,0,0,0,0,0,0,0, # 50 - 57 + 0,0,0,0,0,0,0,0, # 58 - 5f + 0,0,0,0,0,0,0,0, # 60 - 67 + 0,0,0,0,0,0,0,0, # 68 - 6f + 0,0,0,0,0,0,0,0, # 70 - 77 + 0,0,0,0,0,0,0,0, # 78 - 7f + 0,0,0,0,0,0,0,0, # 80 - 87 + 0,0,0,0,0,0,0,0, # 88 - 8f + 0,0,0,0,0,0,0,0, # 90 - 97 + 0,0,0,0,0,0,0,0, # 98 - 9f + 0,0,0,0,0,0,0,0, # a0 - a7 + 0,0,0,0,0,0,0,0, # a8 - af + 0,0,0,0,0,0,0,0, # b0 - b7 + 0,0,0,0,0,0,0,0, # b8 - bf + 0,0,0,0,0,0,0,0, # c0 - c7 + 0,0,0,0,0,0,0,0, # c8 - cf + 0,0,0,0,0,0,0,0, # d0 - d7 + 0,0,0,0,0,0,0,0, # d8 - df + 0,0,0,0,0,0,0,0, # e0 - e7 + 0,0,0,0,0,0,0,0, # e8 - ef + 0,0,0,0,0,0,0,0, # f0 - f7 + 0,0,0,0,0,0,4,5) # f8 - ff + +UCS2BE_st = ( \ + 5, 7, 7,eError, 4, 3,eError,eError,#00-07 + eError,eError,eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,#08-0f + eItsMe,eItsMe, 6, 6, 6, 6,eError,eError,#10-17 + 6, 6, 6, 6, 6,eItsMe, 6, 6,#18-1f + 6, 6, 6, 6, 5, 7, 7,eError,#20-27 + 5, 8, 6, 6,eError, 6, 6, 6,#28-2f + 6, 6, 6, 6,eError,eError,eStart,eStart)#30-37 + +UCS2BECharLenTable = (2, 2, 2, 0, 2, 2) + +UCS2BESMModel = {'classTable': UCS2BE_cls, + 'classFactor': 6, + 'stateTable': UCS2BE_st, + 'charLenTable': UCS2BECharLenTable, + 'name': 'UTF-16BE'} + +# UCS2-LE + +UCS2LE_cls = ( \ + 0,0,0,0,0,0,0,0, # 00 - 07 + 0,0,1,0,0,2,0,0, # 08 - 0f + 0,0,0,0,0,0,0,0, # 10 - 17 + 0,0,0,3,0,0,0,0, # 18 - 1f + 0,0,0,0,0,0,0,0, # 20 - 27 + 0,3,3,3,3,3,0,0, # 28 - 2f + 0,0,0,0,0,0,0,0, # 30 - 37 + 0,0,0,0,0,0,0,0, # 38 - 3f + 0,0,0,0,0,0,0,0, # 40 - 47 + 0,0,0,0,0,0,0,0, # 48 - 4f + 0,0,0,0,0,0,0,0, # 50 - 57 + 0,0,0,0,0,0,0,0, # 58 - 5f + 0,0,0,0,0,0,0,0, # 60 - 67 + 0,0,0,0,0,0,0,0, # 68 - 6f + 0,0,0,0,0,0,0,0, # 70 - 77 + 0,0,0,0,0,0,0,0, # 78 - 7f + 0,0,0,0,0,0,0,0, # 80 - 87 + 0,0,0,0,0,0,0,0, # 88 - 8f + 0,0,0,0,0,0,0,0, # 90 - 97 + 0,0,0,0,0,0,0,0, # 98 - 9f + 0,0,0,0,0,0,0,0, # a0 - a7 + 0,0,0,0,0,0,0,0, # a8 - af + 0,0,0,0,0,0,0,0, # b0 - b7 + 0,0,0,0,0,0,0,0, # b8 - bf + 0,0,0,0,0,0,0,0, # c0 - c7 + 0,0,0,0,0,0,0,0, # c8 - cf + 0,0,0,0,0,0,0,0, # d0 - d7 + 0,0,0,0,0,0,0,0, # d8 - df + 0,0,0,0,0,0,0,0, # e0 - e7 + 0,0,0,0,0,0,0,0, # e8 - ef + 0,0,0,0,0,0,0,0, # f0 - f7 + 0,0,0,0,0,0,4,5) # f8 - ff + +UCS2LE_st = ( \ + 6, 6, 7, 6, 4, 3,eError,eError,#00-07 + eError,eError,eError,eError,eItsMe,eItsMe,eItsMe,eItsMe,#08-0f + eItsMe,eItsMe, 5, 5, 5,eError,eItsMe,eError,#10-17 + 5, 5, 5,eError, 5,eError, 6, 6,#18-1f + 7, 6, 8, 8, 5, 5, 5,eError,#20-27 + 5, 5, 5,eError,eError,eError, 5, 5,#28-2f + 5, 5, 5,eError, 5,eError,eStart,eStart)#30-37 + +UCS2LECharLenTable = (2, 2, 2, 2, 2, 2) + +UCS2LESMModel = {'classTable': UCS2LE_cls, + 'classFactor': 6, + 'stateTable': UCS2LE_st, + 'charLenTable': UCS2LECharLenTable, + 'name': 'UTF-16LE'} + +# UTF-8 + +UTF8_cls = ( \ + 1,1,1,1,1,1,1,1, # 00 - 07 #allow 0x00 as a legal value + 1,1,1,1,1,1,0,0, # 08 - 0f + 1,1,1,1,1,1,1,1, # 10 - 17 + 1,1,1,0,1,1,1,1, # 18 - 1f + 1,1,1,1,1,1,1,1, # 20 - 27 + 1,1,1,1,1,1,1,1, # 28 - 2f + 1,1,1,1,1,1,1,1, # 30 - 37 + 1,1,1,1,1,1,1,1, # 38 - 3f + 1,1,1,1,1,1,1,1, # 40 - 47 + 1,1,1,1,1,1,1,1, # 48 - 4f + 1,1,1,1,1,1,1,1, # 50 - 57 + 1,1,1,1,1,1,1,1, # 58 - 5f + 1,1,1,1,1,1,1,1, # 60 - 67 + 1,1,1,1,1,1,1,1, # 68 - 6f + 1,1,1,1,1,1,1,1, # 70 - 77 + 1,1,1,1,1,1,1,1, # 78 - 7f + 2,2,2,2,3,3,3,3, # 80 - 87 + 4,4,4,4,4,4,4,4, # 88 - 8f + 4,4,4,4,4,4,4,4, # 90 - 97 + 4,4,4,4,4,4,4,4, # 98 - 9f + 5,5,5,5,5,5,5,5, # a0 - a7 + 5,5,5,5,5,5,5,5, # a8 - af + 5,5,5,5,5,5,5,5, # b0 - b7 + 5,5,5,5,5,5,5,5, # b8 - bf + 0,0,6,6,6,6,6,6, # c0 - c7 + 6,6,6,6,6,6,6,6, # c8 - cf + 6,6,6,6,6,6,6,6, # d0 - d7 + 6,6,6,6,6,6,6,6, # d8 - df + 7,8,8,8,8,8,8,8, # e0 - e7 + 8,8,8,8,8,9,8,8, # e8 - ef + 10,11,11,11,11,11,11,11, # f0 - f7 + 12,13,13,13,14,15,0,0) # f8 - ff + +UTF8_st = ( \ + eError,eStart,eError,eError,eError,eError, 12, 10,#00-07 + 9, 11, 8, 7, 6, 5, 4, 3,#08-0f + eError,eError,eError,eError,eError,eError,eError,eError,#10-17 + eError,eError,eError,eError,eError,eError,eError,eError,#18-1f + eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,#20-27 + eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,eItsMe,#28-2f + eError,eError, 5, 5, 5, 5,eError,eError,#30-37 + eError,eError,eError,eError,eError,eError,eError,eError,#38-3f + eError,eError,eError, 5, 5, 5,eError,eError,#40-47 + eError,eError,eError,eError,eError,eError,eError,eError,#48-4f + eError,eError, 7, 7, 7, 7,eError,eError,#50-57 + eError,eError,eError,eError,eError,eError,eError,eError,#58-5f + eError,eError,eError,eError, 7, 7,eError,eError,#60-67 + eError,eError,eError,eError,eError,eError,eError,eError,#68-6f + eError,eError, 9, 9, 9, 9,eError,eError,#70-77 + eError,eError,eError,eError,eError,eError,eError,eError,#78-7f + eError,eError,eError,eError,eError, 9,eError,eError,#80-87 + eError,eError,eError,eError,eError,eError,eError,eError,#88-8f + eError,eError, 12, 12, 12, 12,eError,eError,#90-97 + eError,eError,eError,eError,eError,eError,eError,eError,#98-9f + eError,eError,eError,eError,eError, 12,eError,eError,#a0-a7 + eError,eError,eError,eError,eError,eError,eError,eError,#a8-af + eError,eError, 12, 12, 12,eError,eError,eError,#b0-b7 + eError,eError,eError,eError,eError,eError,eError,eError,#b8-bf + eError,eError,eStart,eStart,eStart,eStart,eError,eError,#c0-c7 + eError,eError,eError,eError,eError,eError,eError,eError)#c8-cf + +UTF8CharLenTable = (0, 1, 0, 0, 0, 0, 2, 3, 3, 3, 4, 4, 5, 5, 6, 6) + +UTF8SMModel = {'classTable': UTF8_cls, + 'classFactor': 16, + 'stateTable': UTF8_st, + 'charLenTable': UTF8CharLenTable, + 'name': 'UTF-8'} diff --git a/fanficdownloader/chardet/sbcharsetprober.py b/fanficdownloader/chardet/sbcharsetprober.py new file mode 100644 index 00000000..da071163 --- /dev/null +++ b/fanficdownloader/chardet/sbcharsetprober.py @@ -0,0 +1,106 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, sys +from charsetprober import CharSetProber + +SAMPLE_SIZE = 64 +SB_ENOUGH_REL_THRESHOLD = 1024 +POSITIVE_SHORTCUT_THRESHOLD = 0.95 +NEGATIVE_SHORTCUT_THRESHOLD = 0.05 +SYMBOL_CAT_ORDER = 250 +NUMBER_OF_SEQ_CAT = 4 +POSITIVE_CAT = NUMBER_OF_SEQ_CAT - 1 +#NEGATIVE_CAT = 0 + +class SingleByteCharSetProber(CharSetProber): + def __init__(self, model, reversed=constants.False, nameProber=None): + CharSetProber.__init__(self) + self._mModel = model + self._mReversed = reversed # TRUE if we need to reverse every pair in the model lookup + self._mNameProber = nameProber # Optional auxiliary prober for name decision + self.reset() + + def reset(self): + CharSetProber.reset(self) + self._mLastOrder = 255 # char order of last character + self._mSeqCounters = [0] * NUMBER_OF_SEQ_CAT + self._mTotalSeqs = 0 + self._mTotalChar = 0 + self._mFreqChar = 0 # characters that fall in our sampling range + + def get_charset_name(self): + if self._mNameProber: + return self._mNameProber.get_charset_name() + else: + return self._mModel['charsetName'] + + def feed(self, aBuf): + if not self._mModel['keepEnglishLetter']: + aBuf = self.filter_without_english_letters(aBuf) + aLen = len(aBuf) + if not aLen: + return self.get_state() + for c in aBuf: + order = self._mModel['charToOrderMap'][ord(c)] + if order < SYMBOL_CAT_ORDER: + self._mTotalChar += 1 + if order < SAMPLE_SIZE: + self._mFreqChar += 1 + if self._mLastOrder < SAMPLE_SIZE: + self._mTotalSeqs += 1 + if not self._mReversed: + self._mSeqCounters[self._mModel['precedenceMatrix'][(self._mLastOrder * SAMPLE_SIZE) + order]] += 1 + else: # reverse the order of the letters in the lookup + self._mSeqCounters[self._mModel['precedenceMatrix'][(order * SAMPLE_SIZE) + self._mLastOrder]] += 1 + self._mLastOrder = order + + if self.get_state() == constants.eDetecting: + if self._mTotalSeqs > SB_ENOUGH_REL_THRESHOLD: + cf = self.get_confidence() + if cf > POSITIVE_SHORTCUT_THRESHOLD: + if constants._debug: + sys.stderr.write('%s confidence = %s, we have a winner\n' % (self._mModel['charsetName'], cf)) + self._mState = constants.eFoundIt + elif cf < NEGATIVE_SHORTCUT_THRESHOLD: + if constants._debug: + sys.stderr.write('%s confidence = %s, below negative shortcut threshhold %s\n' % (self._mModel['charsetName'], cf, NEGATIVE_SHORTCUT_THRESHOLD)) + self._mState = constants.eNotMe + + return self.get_state() + + def get_confidence(self): + r = 0.01 + if self._mTotalSeqs > 0: +# print self._mSeqCounters[POSITIVE_CAT], self._mTotalSeqs, self._mModel['mTypicalPositiveRatio'] + r = (1.0 * self._mSeqCounters[POSITIVE_CAT]) / self._mTotalSeqs / self._mModel['mTypicalPositiveRatio'] +# print r, self._mFreqChar, self._mTotalChar + r = r * self._mFreqChar / self._mTotalChar + if r >= 1.0: + r = 0.99 + return r diff --git a/fanficdownloader/chardet/sbcsgroupprober.py b/fanficdownloader/chardet/sbcsgroupprober.py new file mode 100644 index 00000000..d19160c8 --- /dev/null +++ b/fanficdownloader/chardet/sbcsgroupprober.py @@ -0,0 +1,64 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, sys +from charsetgroupprober import CharSetGroupProber +from sbcharsetprober import SingleByteCharSetProber +from langcyrillicmodel import Win1251CyrillicModel, Koi8rModel, Latin5CyrillicModel, MacCyrillicModel, Ibm866Model, Ibm855Model +from langgreekmodel import Latin7GreekModel, Win1253GreekModel +from langbulgarianmodel import Latin5BulgarianModel, Win1251BulgarianModel +from langhungarianmodel import Latin2HungarianModel, Win1250HungarianModel +from langthaimodel import TIS620ThaiModel +from langhebrewmodel import Win1255HebrewModel +from hebrewprober import HebrewProber + +class SBCSGroupProber(CharSetGroupProber): + def __init__(self): + CharSetGroupProber.__init__(self) + self._mProbers = [ \ + SingleByteCharSetProber(Win1251CyrillicModel), + SingleByteCharSetProber(Koi8rModel), + SingleByteCharSetProber(Latin5CyrillicModel), + SingleByteCharSetProber(MacCyrillicModel), + SingleByteCharSetProber(Ibm866Model), + SingleByteCharSetProber(Ibm855Model), + SingleByteCharSetProber(Latin7GreekModel), + SingleByteCharSetProber(Win1253GreekModel), + SingleByteCharSetProber(Latin5BulgarianModel), + SingleByteCharSetProber(Win1251BulgarianModel), + SingleByteCharSetProber(Latin2HungarianModel), + SingleByteCharSetProber(Win1250HungarianModel), + SingleByteCharSetProber(TIS620ThaiModel), + ] + hebrewProber = HebrewProber() + logicalHebrewProber = SingleByteCharSetProber(Win1255HebrewModel, constants.False, hebrewProber) + visualHebrewProber = SingleByteCharSetProber(Win1255HebrewModel, constants.True, hebrewProber) + hebrewProber.set_model_probers(logicalHebrewProber, visualHebrewProber) + self._mProbers.extend([hebrewProber, logicalHebrewProber, visualHebrewProber]) + + self.reset() diff --git a/fanficdownloader/chardet/sjisprober.py b/fanficdownloader/chardet/sjisprober.py new file mode 100644 index 00000000..fea2690c --- /dev/null +++ b/fanficdownloader/chardet/sjisprober.py @@ -0,0 +1,85 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from mbcharsetprober import MultiByteCharSetProber +from codingstatemachine import CodingStateMachine +from chardistribution import SJISDistributionAnalysis +from jpcntx import SJISContextAnalysis +from mbcssm import SJISSMModel +import constants, sys +from constants import eStart, eError, eItsMe + +class SJISProber(MultiByteCharSetProber): + def __init__(self): + MultiByteCharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(SJISSMModel) + self._mDistributionAnalyzer = SJISDistributionAnalysis() + self._mContextAnalyzer = SJISContextAnalysis() + self.reset() + + def reset(self): + MultiByteCharSetProber.reset(self) + self._mContextAnalyzer.reset() + + def get_charset_name(self): + return "SHIFT_JIS" + + def feed(self, aBuf): + aLen = len(aBuf) + for i in range(0, aLen): + codingState = self._mCodingSM.next_state(aBuf[i]) + if codingState == eError: + if constants._debug: + sys.stderr.write(self.get_charset_name() + ' prober hit error at byte ' + str(i) + '\n') + self._mState = constants.eNotMe + break + elif codingState == eItsMe: + self._mState = constants.eFoundIt + break + elif codingState == eStart: + charLen = self._mCodingSM.get_current_charlen() + if i == 0: + self._mLastChar[1] = aBuf[0] + self._mContextAnalyzer.feed(self._mLastChar[2 - charLen :], charLen) + self._mDistributionAnalyzer.feed(self._mLastChar, charLen) + else: + self._mContextAnalyzer.feed(aBuf[i + 1 - charLen : i + 3 - charLen], charLen) + self._mDistributionAnalyzer.feed(aBuf[i - 1 : i + 1], charLen) + + self._mLastChar[0] = aBuf[aLen - 1] + + if self.get_state() == constants.eDetecting: + if self._mContextAnalyzer.got_enough_data() and \ + (self.get_confidence() > constants.SHORTCUT_THRESHOLD): + self._mState = constants.eFoundIt + + return self.get_state() + + def get_confidence(self): + contxtCf = self._mContextAnalyzer.get_confidence() + distribCf = self._mDistributionAnalyzer.get_confidence() + return max(contxtCf, distribCf) diff --git a/fanficdownloader/chardet/test.py b/fanficdownloader/chardet/test.py new file mode 100644 index 00000000..2ebf3a4d --- /dev/null +++ b/fanficdownloader/chardet/test.py @@ -0,0 +1,20 @@ +import sys, glob +sys.path.insert(0, '..') +from chardet.universaldetector import UniversalDetector + +count = 0 +u = UniversalDetector() +for f in glob.glob(sys.argv[1]): + print f.ljust(60), + u.reset() + for line in file(f, 'rb'): + u.feed(line) + if u.done: break + u.close() + result = u.result + if result['encoding']: + print result['encoding'], 'with confidence', result['confidence'] + else: + print '******** no result' + count += 1 +print count, 'tests' diff --git a/fanficdownloader/chardet/universaldetector.py b/fanficdownloader/chardet/universaldetector.py new file mode 100644 index 00000000..809df227 --- /dev/null +++ b/fanficdownloader/chardet/universaldetector.py @@ -0,0 +1,154 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, sys +from latin1prober import Latin1Prober # windows-1252 +from mbcsgroupprober import MBCSGroupProber # multi-byte character sets +from sbcsgroupprober import SBCSGroupProber # single-byte character sets +from escprober import EscCharSetProber # ISO-2122, etc. +import re + +MINIMUM_THRESHOLD = 0.20 +ePureAscii = 0 +eEscAscii = 1 +eHighbyte = 2 + +class UniversalDetector: + def __init__(self): + self._highBitDetector = re.compile(r'[\x80-\xFF]') + self._escDetector = re.compile(r'(\033|~{)') + self._mEscCharSetProber = None + self._mCharSetProbers = [] + self.reset() + + def reset(self): + self.result = {'encoding': None, 'confidence': 0.0} + self.done = constants.False + self._mStart = constants.True + self._mGotData = constants.False + self._mInputState = ePureAscii + self._mLastChar = '' + if self._mEscCharSetProber: + self._mEscCharSetProber.reset() + for prober in self._mCharSetProbers: + prober.reset() + + def feed(self, aBuf): + if self.done: return + + aLen = len(aBuf) + if not aLen: return + + if not self._mGotData: + # If the data starts with BOM, we know it is UTF + if aBuf[:3] == '\xEF\xBB\xBF': + # EF BB BF UTF-8 with BOM + self.result = {'encoding': "UTF-8", 'confidence': 1.0} + elif aBuf[:4] == '\xFF\xFE\x00\x00': + # FF FE 00 00 UTF-32, little-endian BOM + self.result = {'encoding': "UTF-32LE", 'confidence': 1.0} + elif aBuf[:4] == '\x00\x00\xFE\xFF': + # 00 00 FE FF UTF-32, big-endian BOM + self.result = {'encoding': "UTF-32BE", 'confidence': 1.0} + elif aBuf[:4] == '\xFE\xFF\x00\x00': + # FE FF 00 00 UCS-4, unusual octet order BOM (3412) + self.result = {'encoding': "X-ISO-10646-UCS-4-3412", 'confidence': 1.0} + elif aBuf[:4] == '\x00\x00\xFF\xFE': + # 00 00 FF FE UCS-4, unusual octet order BOM (2143) + self.result = {'encoding': "X-ISO-10646-UCS-4-2143", 'confidence': 1.0} + elif aBuf[:2] == '\xFF\xFE': + # FF FE UTF-16, little endian BOM + self.result = {'encoding': "UTF-16LE", 'confidence': 1.0} + elif aBuf[:2] == '\xFE\xFF': + # FE FF UTF-16, big endian BOM + self.result = {'encoding': "UTF-16BE", 'confidence': 1.0} + + self._mGotData = constants.True + if self.result['encoding'] and (self.result['confidence'] > 0.0): + self.done = constants.True + return + + if self._mInputState == ePureAscii: + if self._highBitDetector.search(aBuf): + self._mInputState = eHighbyte + elif (self._mInputState == ePureAscii) and self._escDetector.search(self._mLastChar + aBuf): + self._mInputState = eEscAscii + + self._mLastChar = aBuf[-1] + + if self._mInputState == eEscAscii: + if not self._mEscCharSetProber: + self._mEscCharSetProber = EscCharSetProber() + if self._mEscCharSetProber.feed(aBuf) == constants.eFoundIt: + self.result = {'encoding': self._mEscCharSetProber.get_charset_name(), + 'confidence': self._mEscCharSetProber.get_confidence()} + self.done = constants.True + elif self._mInputState == eHighbyte: + if not self._mCharSetProbers: + self._mCharSetProbers = [MBCSGroupProber(), SBCSGroupProber(), Latin1Prober()] + for prober in self._mCharSetProbers: + if prober.feed(aBuf) == constants.eFoundIt: + self.result = {'encoding': prober.get_charset_name(), + 'confidence': prober.get_confidence()} + self.done = constants.True + break + + def close(self): + if self.done: return + if not self._mGotData: + if constants._debug: + sys.stderr.write('no data received!\n') + return + self.done = constants.True + + if self._mInputState == ePureAscii: + self.result = {'encoding': 'ascii', 'confidence': 1.0} + return self.result + + if self._mInputState == eHighbyte: + proberConfidence = None + maxProberConfidence = 0.0 + maxProber = None + for prober in self._mCharSetProbers: + if not prober: continue + proberConfidence = prober.get_confidence() + if proberConfidence > maxProberConfidence: + maxProberConfidence = proberConfidence + maxProber = prober + if maxProber and (maxProberConfidence > MINIMUM_THRESHOLD): + self.result = {'encoding': maxProber.get_charset_name(), + 'confidence': maxProber.get_confidence()} + return self.result + + if constants._debug: + sys.stderr.write('no probers hit minimum threshhold\n') + for prober in self._mCharSetProbers[0].mProbers: + if not prober: continue + sys.stderr.write('%s confidence = %s\n' % \ + (prober.get_charset_name(), \ + prober.get_confidence())) diff --git a/fanficdownloader/chardet/utf8prober.py b/fanficdownloader/chardet/utf8prober.py new file mode 100644 index 00000000..c1792bb3 --- /dev/null +++ b/fanficdownloader/chardet/utf8prober.py @@ -0,0 +1,76 @@ +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import constants, sys +from constants import eStart, eError, eItsMe +from charsetprober import CharSetProber +from codingstatemachine import CodingStateMachine +from mbcssm import UTF8SMModel + +ONE_CHAR_PROB = 0.5 + +class UTF8Prober(CharSetProber): + def __init__(self): + CharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(UTF8SMModel) + self.reset() + + def reset(self): + CharSetProber.reset(self) + self._mCodingSM.reset() + self._mNumOfMBChar = 0 + + def get_charset_name(self): + return "utf-8" + + def feed(self, aBuf): + for c in aBuf: + codingState = self._mCodingSM.next_state(c) + if codingState == eError: + self._mState = constants.eNotMe + break + elif codingState == eItsMe: + self._mState = constants.eFoundIt + break + elif codingState == eStart: + if self._mCodingSM.get_current_charlen() >= 2: + self._mNumOfMBChar += 1 + + if self.get_state() == constants.eDetecting: + if self.get_confidence() > constants.SHORTCUT_THRESHOLD: + self._mState = constants.eFoundIt + + return self.get_state() + + def get_confidence(self): + unlike = 0.99 + if self._mNumOfMBChar < 6: + for i in range(0, self._mNumOfMBChar): + unlike = unlike * ONE_CHAR_PROB + return 1.0 - unlike + else: + return unlike diff --git a/fanficdownloader/configurable.py b/fanficdownloader/configurable.py new file mode 100644 index 00000000..9b6d35f4 --- /dev/null +++ b/fanficdownloader/configurable.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import ConfigParser, re + +# All of the writers(epub,html,txt) and adapters(ffnet,twlt,etc) +# inherit from Configurable. The config file(s) uses ini format: +# [sections] with key:value settings. +# +# [defaults] +# titlepage_entries: category,genre, status +# [www.whofic.com] +# titlepage_entries: category,genre, status,dateUpdated,rating +# [epub] +# titlepage_entries: category,genre, status,datePublished,dateUpdated,dateCreated +# [www.whofic.com:epub] +# titlepage_entries: category,genre, status,datePublished +# [overrides] +# titlepage_entries: category + +class Configuration(ConfigParser.SafeConfigParser): + + def __init__(self, site, fileform): + ConfigParser.SafeConfigParser.__init__(self) + self.sectionslist = ['defaults'] + + if site.startswith("www."): + sitewith = site + sitewithout = site.replace("www.","") + else: + sitewith = "www."+site + sitewithout = site + + self.addConfigSection(sitewith) + self.addConfigSection(sitewithout) + if fileform: + self.addConfigSection(fileform) + self.addConfigSection(sitewith+":"+fileform) + self.addConfigSection(sitewithout+":"+fileform) + self.addConfigSection("overrides") + + self.listTypeEntries = [ + 'category', + 'genre', + 'characters', + 'ships', + 'warnings', + 'extratags', + 'author', + 'authorId', + 'authorUrl', + 'lastupdate', + ] + + self.validEntries = self.listTypeEntries + [ + 'series', + 'seriesUrl', + 'language', + 'status', + 'datePublished', + 'dateUpdated', + 'dateCreated', + 'rating', + 'numChapters', + 'numWords', + 'site', + 'storyId', + 'title', + 'storyUrl', + 'description', + 'formatname', + 'formatext', + 'siteabbrev', + 'version', + # internal stuff. + 'authorHTML', + 'seriesHTML', + 'langcode', + 'output_css', + ] + + def addConfigSection(self,section): + self.sectionslist.insert(0,section) + + def isListType(self,key): + return key in self.listTypeEntries or self.hasConfig("include_in_"+key) + + def isValidMetaEntry(self, key): + return key in self.getValidMetaList() + + def getValidMetaList(self): + return self.validEntries + self.getConfigList("extra_valid_entries") + + # used by adapters & writers, non-convention naming style + def hasConfig(self, key): + return self.has_config(self.sectionslist, key) + + def has_config(self, sections, key): + for section in sections: + try: + self.get(section,key) + #print("found %s in section [%s]"%(key,section)) + return True + except: + try: + self.get(section,"add_to_"+key) + #print("found add_to_%s in section [%s]"%(key,section)) + return True + except: + pass + + return False + + # used by adapters & writers, non-convention naming style + def getConfig(self, key, default=""): + return self.get_config(self.sectionslist,key,default) + + def get_config(self, sections, key, default=""): + val = default + for section in sections: + try: + val = self.get(section,key) + if val and val.lower() == "false": + val = False + #print "getConfig(%s)=[%s]%s" % (key,section,val) + break + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError), e: + pass + + for section in sections[::-1]: + # 'martian smiley' [::-1] reverses list by slicing whole list with -1 step. + try: + val = val + self.get(section,"add_to_"+key) + #print "getConfig(add_to_%s)=[%s]%s" % (key,section,val) + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError), e: + pass + + return val + + # split and strip each. + def get_config_list(self, sections, key): + vlist = re.split(r'(?<!\\),',self.get_config(sections,key)) # don't split on \, + vlist = filter( lambda x : x !='', [ v.strip().replace('\,',',') for v in vlist ]) + #print "vlist("+key+"):"+str(vlist) + return vlist + + # used by adapters & writers, non-convention naming style + def getConfigList(self, key): + return self.get_config_list(self.sectionslist, key) + +# extended by adapter, writer and story for ease of calling configuration. +class Configurable(object): + + def __init__(self, configuration): + self.configuration = configuration + + def isListType(self,key): + return self.configuration.isListType(key) + + def isValidMetaEntry(self, key): + return self.configuration.isValidMetaEntry(key) + + def getValidMetaList(self): + return self.configuration.getValidMetaList() + + def hasConfig(self, key): + return self.configuration.hasConfig(key) + + def has_config(self, sections, key): + return self.configuration.has_config(sections, key) + + def getConfig(self, key, default=""): + return self.configuration.getConfig(key,default) + + def get_config(self, sections, key, default=""): + return self.configuration.get_config(sections,key,default) + + def getConfigList(self, key): + return self.configuration.getConfigList(key) + + def get_config_list(self, sections, key): + return self.configuration.get_config_list(sections,key) diff --git a/fanficdownloader/epubutils.py b/fanficdownloader/epubutils.py new file mode 100644 index 00000000..6d2e6ff0 --- /dev/null +++ b/fanficdownloader/epubutils.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Jim Miller' +__docformat__ = 'restructuredtext en' + +import logging +logger = logging.getLogger(__name__) + +import re, os, traceback +from zipfile import ZipFile +from xml.dom.minidom import parseString + +from . import BeautifulSoup as bs + +def get_dcsource(inputio): + return get_update_data(inputio,getfilecount=False,getsoups=False)[0] + +def get_dcsource_chaptercount(inputio): + return get_update_data(inputio,getfilecount=True,getsoups=False)[:2] # (source,filecount) + +def get_update_data(inputio, + getfilecount=True, + getsoups=True): + epub = ZipFile(inputio, 'r') # works equally well with inputio as a path or a blob + + ## Find the .opf file. + container = epub.read("META-INF/container.xml") + containerdom = parseString(container) + rootfilenodelist = containerdom.getElementsByTagName("rootfile") + rootfilename = rootfilenodelist[0].getAttribute("full-path") + + contentdom = parseString(epub.read(rootfilename)) + firstmetadom = contentdom.getElementsByTagName("metadata")[0] + try: + source=firstmetadom.getElementsByTagName("dc:source")[0].firstChild.data.encode("utf-8") + except: + source=None + + ## Save the path to the .opf file--hrefs inside it are relative to it. + relpath = get_path_part(rootfilename) + + oldcover = None + calibrebookmark = None + logfile = None + # Looking for pre-existing cover. + for item in contentdom.getElementsByTagName("reference"): + if item.getAttribute("type") == "cover": + # there is a cover (x)html file, save the soup for it. + href=relpath+item.getAttribute("href") + oldcoverhtmlhref = href + oldcoverhtmldata = epub.read(href) + oldcoverhtmltype = "application/xhtml+xml" + for item in contentdom.getElementsByTagName("item"): + if( relpath+item.getAttribute("href") == oldcoverhtmlhref ): + oldcoverhtmltype = item.getAttribute("media-type") + break + soup = bs.BeautifulSoup(oldcoverhtmldata.decode("utf-8")) + src = None + # first img or image tag. + imgs = soup.findAll('img') + if imgs: + src = get_path_part(href)+imgs[0]['src'] + else: + imgs = soup.findAll('image') + if imgs: + src=get_path_part(href)+imgs[0]['xlink:href'] + + if not src: + continue + try: + # remove all .. and the path part above it, if present. + # Mostly for epubs edited by Sigil. + src = re.sub(r"([^/]+/\.\./)","",src) + #print("epubutils: found pre-existing cover image:%s"%src) + oldcoverimghref = src + oldcoverimgdata = epub.read(src) + for item in contentdom.getElementsByTagName("item"): + if( relpath+item.getAttribute("href") == oldcoverimghref ): + oldcoverimgtype = item.getAttribute("media-type") + break + oldcover = (oldcoverhtmlhref,oldcoverhtmltype,oldcoverhtmldata,oldcoverimghref,oldcoverimgtype,oldcoverimgdata) + except Exception as e: + logger.warn("Cover Image %s not found"%src) + logger.warn("Exception: %s"%(unicode(e))) + traceback.print_exc() + + filecount = 0 + soups = [] # list of xhmtl blocks + images = {} # dict() longdesc->data + if getfilecount: + # spin through the manifest--only place there are item tags. + for item in contentdom.getElementsByTagName("item"): + # First, count the 'chapter' files. FFDL uses file0000.xhtml, + # but can also update epubs downloaded from Twisting the + # Hellmouth, which uses chapter0.html. + if( item.getAttribute("media-type") == "application/xhtml+xml" ): + href=relpath+item.getAttribute("href") + #print("---- item href:%s path part: %s"%(href,get_path_part(href))) + if re.match(r'.*/log_page\.x?html',href): + try: + logfile = epub.read(href).decode("utf-8") + except: + pass # corner case I bumped into while testing. + if re.match(r'.*/(file|chapter)\d+\.x?html',href): + if getsoups: + soup = bs.BeautifulSoup(epub.read(href).decode("utf-8")) + for img in soup.findAll('img'): + newsrc='' + longdesc='' + try: + newsrc=get_path_part(href)+img['src'] + # remove all .. and the path part above it, if present. + # Mostly for epubs edited by Sigil. + newsrc = re.sub(r"([^/]+/\.\./)","",newsrc) + longdesc=img['longdesc'] + data = epub.read(newsrc) + images[longdesc] = data + img['src'] = img['longdesc'] + except Exception as e: + logger.warn("Image %s not found!\n(originally:%s)"%(newsrc,longdesc)) + logger.warn("Exception: %s"%(unicode(e))) + traceback.print_exc() + soup = soup.find('body') + # ffdl epubs have chapter title h3 + h3 = soup.find('h3') + if h3: + h3.extract() + # TtH epubs have chapter title h2 + h2 = soup.find('h2') + if h2: + h2.extract() + + for skip in soup.findAll(attrs={'class':'skip_on_ffdl_update'}): + skip.extract() + + soups.append(soup) + + 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]))) + return (source,filecount,soups,images,oldcover,calibrebookmark,logfile) + +def get_path_part(n): + relpath = os.path.dirname(n) + if( len(relpath) > 0 ): + relpath=relpath+"/" + return relpath + +def get_story_url_from_html(inputio,_is_good_url=None): + + #print("get_story_url_from_html called") + epub = ZipFile(inputio, 'r') # works equally well with inputio as a path or a blob + + ## Find the .opf file. + container = epub.read("META-INF/container.xml") + containerdom = parseString(container) + rootfilenodelist = containerdom.getElementsByTagName("rootfile") + rootfilename = rootfilenodelist[0].getAttribute("full-path") + + contentdom = parseString(epub.read(rootfilename)) + #firstmetadom = contentdom.getElementsByTagName("metadata")[0] + + ## 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. FFDL uses file0000.xhtml, + # but can also update epubs downloaded from Twisting the + # Hellmouth, which uses chapter0.html. + #print("---- item:%s"%item) + if( item.getAttribute("media-type") == "application/xhtml+xml" ): + filehref=relpath+item.getAttribute("href") + soup = bs.BeautifulSoup(epub.read(filehref).decode("utf-8")) + for link in soup.findAll('a',href=re.compile(r'^http.*')): + ahref=link['href'] + #print("href:(%s)"%ahref) + # hack for bad ficsaver ffnet URLs. + m = re.match(r"^http://www.fanfiction.net/s(?P<id>\d+)//$",ahref) + if m != None: + ahref="http://www.fanfiction.net/s/%s/1/"%m.group('id') + if _is_good_url == None or _is_good_url(ahref): + return ahref + return None diff --git a/fanficdownloader/exceptions.py b/fanficdownloader/exceptions.py new file mode 100644 index 00000000..08ce1f2d --- /dev/null +++ b/fanficdownloader/exceptions.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +## A few exceptions for different things for adapters + +class FailedToDownload(Exception): + def __init__(self,error): + self.error=error + + def __str__(self): + return self.error + +class InvalidStoryURL(Exception): + def __init__(self,url,domain,example): + self.url=url + self.domain=domain + self.example=example + + def __str__(self): + return "Bad Story URL: (%s) for site: (%s) Example: (%s)" % (self.url, self.domain, self.example) + +class FailedToLogin(Exception): + def __init__(self,url, username, passwdonly=False): + self.url=url + self.username=username + self.passwdonly=passwdonly + + def __str__(self): + if self.passwdonly: + return "URL Failed, password required: (%s) " % (self.url) + else: + return "Failed to Login for URL: (%s) with username: (%s)" % (self.url, self.username) + +class AdultCheckRequired(Exception): + def __init__(self,url): + self.url=url + + def __str__(self): + return "Story requires confirmation of adult status: (%s)" % self.url + +class StoryDoesNotExist(Exception): + def __init__(self,url): + self.url=url + + def __str__(self): + return "Story does not exist: (%s)" % self.url + +class UnknownSite(Exception): + def __init__(self,url,supported_sites_list): + self.url=url + self.supported_sites_list=supported_sites_list + self.supported_sites_list.sort() + + def __str__(self): + return "Unknown Site(%s). Supported sites: (%s)" % (self.url, ", ".join(self.supported_sites_list)) + +class FailedToWriteOutput(Exception): + def __init__(self,error): + self.error=error + + def __str__(self): + return self.error + diff --git a/fanficdownloader/geturls.py b/fanficdownloader/geturls.py new file mode 100644 index 00000000..ed567a1c --- /dev/null +++ b/fanficdownloader/geturls.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import re +import urlparse +import urllib2 as u2 + +from BeautifulSoup import BeautifulSoup +from gziphttp import GZipProcessor + +import adapters +from configurable import Configuration +from exceptions import UnknownSite + +def get_urls_from_page(url,configuration=None,normalize=False): + + if not configuration: + configuration = Configuration("test1.com","EPUB") + + data = None + adapter = None + try: + adapter = adapters.getAdapter(configuration,url,anyurl=True) + + # special stuff to log into archiveofourown.org, if possible. + # Unlike most that show the links to 'adult' stories, but protect + # them, AO3 doesn't even show them if not logged in. Only works + # with saved user/pass--not going to prompt for list. + if 'archiveofourown.org' in url: + if adapter.getConfig("username"): + if adapter.getConfig("is_adult"): + addurl = "?view_adult=true" + else: + addurl="" + # just to get an authenticity_token. + data = adapter._fetchUrl(url+addurl) + # login the session. + adapter.performLogin(url,data) + # get the list page with logged in session. + + # this way it uses User-Agent or other special settings. Only AO3 + # is doing login. + data = adapter._fetchUrl(url) + except UnknownSite: + # no adapter with anyurl=True, must be a random site. + opener = u2.build_opener(u2.HTTPCookieProcessor(),GZipProcessor()) + data = opener.open(url).read() + + # kludge because I don't see it on enough sites to be worth generalizing yet. + restrictsearch=None + if 'scarvesandcoffee.net' in url: + restrictsearch=('div',{'id':'mainpage'}) + + return get_urls_from_html(data,url,configuration,normalize,restrictsearch) + +def get_urls_from_html(data,url=None,configuration=None,normalize=False,restrictsearch=None): + + normalized = [] # normalized url + retlist = [] # orig urls. + + if not configuration: + configuration = Configuration("test1.com","EPUB") + + soup = BeautifulSoup(data) + if restrictsearch: + soup = soup.find(*restrictsearch) + #print("restrict search:%s"%soup) + + for a in soup.findAll('a'): + if a.has_key('href'): + #print("a['href']:%s"%a['href']) + href = form_url(url,a['href']) + #print("1 urlhref:%s"%href) + # this (should) catch normal story links, some javascript + # 'are you old enough' links, and 'Report This' links. + # The 'normalized' set prevents duplicates. + if 'story.php' in a['href']: + #print("trying:%s"%a['href']) + m = re.search(r"(?P<sid>(view)?story\.php\?(sid|psid|no|story|stid)=\d+)",a['href']) + if m != None: + href = form_url(a['href'] if '//' in a['href'] else url, + m.group('sid')) + + try: + href = href.replace('&index=1','') + #print("2 urlhref:%s"%href) + adapter = adapters.getAdapter(configuration,href) + #print("found adapter") + if adapter.story.getMetadata('storyUrl') not in normalized: + normalized.append(adapter.story.getMetadata('storyUrl')) + retlist.append(href) + except Exception, e: + #print e + pass + + if normalize: + return normalized + else: + return retlist + +def get_urls_from_text(data,configuration=None,normalize=False): + + normalized = [] # normalized url + retlist = [] # orig urls. + data=unicode(data) + + if not configuration: + configuration = Configuration("test1.com","EPUB") + + for href in re.findall('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', data): + # this (should) catch normal story links, some javascript + # 'are you old enough' links, and 'Report This' links. + # The 'normalized' set prevents duplicates. + if 'story.php' in href: + m = re.search(r"(?P<sid>(view)?story\.php\?(sid|psid|no|story|stid)=\d+)",href) + if m != None: + href = form_url(href,m.group('sid')) + try: + href = href.replace('&index=1','') + adapter = adapters.getAdapter(configuration,href) + if adapter.story.getMetadata('storyUrl') not in normalized: + normalized.append(adapter.story.getMetadata('storyUrl')) + retlist.append(href) + except: + pass + + if normalize: + return normalized + else: + return retlist + +def form_url(parenturl,url): + url = url.strip() # ran across an image with a space in the + # src. Browser handled it, so we'd better, too. + + if "//" in url or parenturl == None: + returl = url + else: + parsedUrl = urlparse.urlparse(parenturl) + if url.startswith("/") : + returl = urlparse.urlunparse( + (parsedUrl.scheme, + parsedUrl.netloc, + url, + '','','')) + else: + toppath="" + if parsedUrl.path.endswith("/"): + toppath = parsedUrl.path + else: + toppath = parsedUrl.path[:parsedUrl.path.rindex('/')] + returl = urlparse.urlunparse( + (parsedUrl.scheme, + parsedUrl.netloc, + toppath + '/' + url, + '','','')) + return returl + diff --git a/fanficdownloader/gziphttp.py b/fanficdownloader/gziphttp.py new file mode 100644 index 00000000..76049eea --- /dev/null +++ b/fanficdownloader/gziphttp.py @@ -0,0 +1,38 @@ +## Borrowed from http://techknack.net/python-urllib2-handlers/ + +import urllib2 +from gzip import GzipFile +from StringIO import StringIO + +class GZipProcessor(urllib2.BaseHandler): + """A handler to add gzip capabilities to urllib2 requests + """ + def http_request(self, req): + req.add_header("Accept-Encoding", "gzip") + return req + https_request = http_request + + def http_response(self, req, resp): + #print("Content-Encoding:%s"%resp.headers.get("Content-Encoding")) + if resp.headers.get("Content-Encoding") == "gzip": + gz = GzipFile( + fileobj=StringIO(resp.read()), + mode="r" + ) +# resp.read = gz.read +# resp.readlines = gz.readlines +# resp.readline = gz.readline +# resp.next = gz.next + old_resp = resp + resp = urllib2.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code) + resp.msg = old_resp.msg + return resp + https_response = http_response + +# brave new world - 1:30 w/o, 1:10 with? 40 chapters, so 20s from sleeps. +# with gzip, no sleep: 47.469 +# w/o gzip, no sleep: 47.736 + +# I Am What I Am 67 chapters +# w/o gzip: 57.168 +# w/ gzip: 40.692 diff --git a/fanficdownloader/html.py b/fanficdownloader/html.py new file mode 100644 index 00000000..22fb40af --- /dev/null +++ b/fanficdownloader/html.py @@ -0,0 +1,126 @@ +#!/usr/bin/python +# Copyright(c) 2009 Andrew Chatham and Vijay Pandurangan + +import re +import sys +import StringIO +import urllib + +from BeautifulSoup import BeautifulSoup + +class HtmlProcessor: + WHITESPACE_RE = re.compile(r'\s') + # Look for </blockquote <p> + BAD_TAG_RE = re.compile(r'<[^>]+<', re.MULTILINE) + + def __init__(self, html, unfill=0): + self.unfill = unfill + html = self._ProcessRawHtml(html) + self._soup = BeautifulSoup(html) + if self._soup.title.contents: + self.title = self._soup.title.contents[0] + else: + self.title = None + + def _ProcessRawHtml(self, html): + new_html, count = HtmlProcessor.BAD_TAG_RE.subn('<', html) + if count: + print >>sys.stderr, 'Replaced %d bad tags' % count + return new_html + + def _StubInternalAnchors(self): + '''Replace each internal anchor with a fixed-size filepos anchor. + + Looks for every anchor with <a href="#myanchor"> and replaces that + with <a filepos="00000000050">. Stores anchors in self._anchor_references''' + self._anchor_references = [] + anchor_num = 0 + # anchor links + anchorlist = self._soup.findAll('a', href=re.compile('^#')) + # treat reference tags like a tags for TOCTOP. + anchorlist.extend(self._soup.findAll('reference', href=re.compile('^#'))) + for anchor in anchorlist: + self._anchor_references.append((anchor_num, anchor['href'])) + del anchor['href'] + anchor['filepos'] = '%.10d' % anchor_num + anchor_num += 1 + + def _ReplaceAnchorStubs(self): + # TODO: Browsers allow extra whitespace in the href names. + # use __str__ instead of prettify--it inserts extra spaces. + assembled_text = self._soup.__str__('utf8') + del self._soup # shouldn't touch this anymore + for anchor_num, original_ref in self._anchor_references: + ref = urllib.unquote(original_ref[1:]) # remove leading '#' + # Find the position of ref in the utf-8 document. + # TODO(chatham): Using regexes and looking for name= would be better. + newpos = assembled_text.rfind(ref.encode('utf-8')) + if newpos == -1: + print >>sys.stderr, 'Could not find anchor "%s"' % original_ref + continue + newpos += len(ref) + 2 # don't point into the middle of the <a name> tag + old_filepos = 'filepos="%.10d"' % anchor_num + new_filepos = 'filepos="%.10d"' % newpos + assert assembled_text.find(old_filepos) != -1 + assembled_text = assembled_text.replace(old_filepos, new_filepos, 1) + return assembled_text + + def _FixPreTags(self): + '''Replace <pre> tags with HTML-ified text.''' + pres = self._soup.findAll('pre') + for pre in pres: + pre.replaceWith(self._FixPreContents(str(pre.contents[0]))) + + def _FixPreContents(self, text): + if self.unfill: + line_splitter = '\n\n' + line_joiner = '<p>' + else: + line_splitter = '\n' + line_joiner = '<br>' + lines = [] + for line in text.split(line_splitter): + lines.append(self.WHITESPACE_RE.subn(' ', line)[0]) + return line_joiner.join(lines) + + def _RemoveUnsupported(self): + '''Remove any tags which the kindle cannot handle.''' + # TODO(chatham): <link> tags to script? + unsupported_tags = ('script', 'style') + for tag_type in unsupported_tags: + for element in self._soup.findAll(tag_type): + element.extract() + + def RenameAnchors(self, prefix): + '''Rename every internal anchor to have the given prefix, then + return the contents of the body tag.''' + for anchor in self._soup.findAll('a', href=re.compile('^#')): + anchor['href'] = '#' + prefix + anchor['href'][1:] + for a in self._soup.findAll('a'): + if a.get('name'): + a['name'] = prefix + a['name'] + + # TODO(chatham): figure out how to fix this. sometimes body comes out + # as NoneType. + content = [] + if self._soup.body is not None: + content = [unicode(c) for c in self._soup.body.contents] + return '\n'.join(content) + + def CleanHtml(self): + # TODO(chatham): fix_html_br, fix_html + self._RemoveUnsupported() + self._StubInternalAnchors() + self._FixPreTags() + return self._ReplaceAnchorStubs() + + +if __name__ == '__main__': + FILE ='/tmp/documentation.html' + #FILE = '/tmp/multipre.html' + FILE = '/tmp/view.html' + import codecs + d = open(FILE).read() + h = HtmlProcessor(d) + s = h.CleanHtml() + #print s diff --git a/fanficdownloader/html2text.py b/fanficdownloader/html2text.py new file mode 100644 index 00000000..19965276 --- /dev/null +++ b/fanficdownloader/html2text.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""html2text: Turn HTML into equivalent Markdown-structured text.""" +__version__ = "2.37" +__author__ = "Aaron Swartz (me@aaronsw.com)" +__copyright__ = "(C) 2004-2008 Aaron Swartz. GNU GPL 3." +__contributors__ = ["Martin 'Joey' Schulze", "Ricardo Reyes", "Kevin Jay North"] + +# TODO: +# Support decoded entities with unifiable. + +if not hasattr(__builtins__, 'True'): True, False = 1, 0 +import re, sys, urllib, htmlentitydefs, codecs, StringIO, types +import sgmllib +import urlparse +sgmllib.charref = re.compile('&#([xX]?[0-9a-fA-F]+)[^0-9a-fA-F]') + +try: from textwrap import wrap +except: pass + +# Use Unicode characters instead of their ascii psuedo-replacements +UNICODE_SNOB = 0 + +# Put the links after each paragraph instead of at the end. +LINKS_EACH_PARAGRAPH = 0 + +# Wrap long lines at position. 0 for no wrapping. (Requires Python 2.3.) +BODY_WIDTH = 78 + +# Don't show internal links (href="#local-anchor") -- corresponding link targets +# won't be visible in the plain text file anyway. +SKIP_INTERNAL_LINKS = False + +### Entity Nonsense ### + +def name2cp(k): + if k == 'apos': return ord("'") + if hasattr(htmlentitydefs, "name2codepoint"): # requires Python 2.3 + return htmlentitydefs.name2codepoint[k] + else: + k = htmlentitydefs.entitydefs[k] + if k.startswith("&#") and k.endswith(";"): return int(k[2:-1]) # not in latin-1 + return ord(codecs.latin_1_decode(k)[0]) + +unifiable = {'rsquo':"'", 'lsquo':"'", 'rdquo':'"', 'ldquo':'"', +'copy':'(C)', 'mdash':'--', 'nbsp':' ', 'rarr':'->', 'larr':'<-', 'middot':'*', +'ndash':'-', 'oelig':'oe', 'aelig':'ae', +'agrave':'a', 'aacute':'a', 'acirc':'a', 'atilde':'a', 'auml':'a', 'aring':'a', +'egrave':'e', 'eacute':'e', 'ecirc':'e', 'euml':'e', +'igrave':'i', 'iacute':'i', 'icirc':'i', 'iuml':'i', +'ograve':'o', 'oacute':'o', 'ocirc':'o', 'otilde':'o', 'ouml':'o', +'ugrave':'u', 'uacute':'u', 'ucirc':'u', 'uuml':'u'} + +unifiable_n = {} + +for k in unifiable.keys(): + unifiable_n[name2cp(k)] = unifiable[k] + +def charref(name): + if name[0] in ['x','X']: + c = int(name[1:], 16) + else: + c = int(name) + + if not UNICODE_SNOB and c in unifiable_n.keys(): + return unifiable_n[c] + else: + return unichr(c) + +def entityref(c): + if not UNICODE_SNOB and c in unifiable.keys(): + return unifiable[c] + else: + try: name2cp(c) + except KeyError: return "&" + c + else: return unichr(name2cp(c)) + +def replaceEntities(s): + s = s.group(1) + if s[0] == "#": + return charref(s[1:]) + else: return entityref(s) + +r_unescape = re.compile(r"&(#?[xX]?(?:[0-9a-fA-F]+|\w{1,8}));") +def unescape(s): + return r_unescape.sub(replaceEntities, s) + +def fixattrs(attrs): + # Fix bug in sgmllib.py + if not attrs: return attrs + newattrs = [] + for attr in attrs: + newattrs.append((attr[0], unescape(attr[1]))) + return newattrs + +### End Entity Nonsense ### + +def onlywhite(line): + """Return true if the line does only consist of whitespace characters.""" + for c in line: + if c is not ' ' and c is not ' ': + return c is ' ' + return line + +def optwrap(text,wrap_width=BODY_WIDTH): + """Wrap all paragraphs in the provided text.""" + + if not wrap_width: + return text + + assert wrap, "Requires Python 2.3." + result = '' + newlines = 0 + for para in text.split("\n"): + if len(para) > 0: + if para[0] is not ' ' and para[0] is not '-' and para[0] is not '*': + for line in wrap(para, wrap_width): + result += line + "\n" + result += "\n" + newlines = 2 + else: + if not onlywhite(para): + result += para + "\n" + newlines = 1 + else: + if newlines < 2: + result += "\n" + newlines += 1 + return result + +def hn(tag): + if tag[0] == 'h' and len(tag) == 2: + try: + n = int(tag[1]) + if n in range(1, 10): return n + except ValueError: return 0 + +class _html2text(sgmllib.SGMLParser): + def __init__(self, out=None, baseurl=''): + sgmllib.SGMLParser.__init__(self) + + if out is None: self.out = self.outtextf + else: self.out = out + self.outtext = u'' + self.quiet = 0 + self.p_p = 0 + self.outcount = 0 + self.start = 1 + self.space = 0 + self.a = [] + self.astack = [] + self.acount = 0 + self.list = [] + self.blockquote = 0 + self.pre = 0 + self.startpre = 0 + self.lastWasNL = 0 + self.abbr_title = None # current abbreviation definition + self.abbr_data = None # last inner HTML (for abbr being defined) + self.abbr_list = {} # stack of abbreviations to write later + self.baseurl = baseurl + + def outtextf(self, s): + self.outtext += s + + def close(self): + sgmllib.SGMLParser.close(self) + + self.pbr() + self.o('', 0, 'end') + + return self.outtext + + def handle_charref(self, c): + self.o(charref(c)) + + def handle_entityref(self, c): + self.o(entityref(c)) + + def unknown_starttag(self, tag, attrs): + self.handle_tag(tag, attrs, 1) + + def unknown_endtag(self, tag): + self.handle_tag(tag, None, 0) + + def previousIndex(self, attrs): + """ returns the index of certain set of attributes (of a link) in the + self.a list + + If the set of attributes is not found, returns None + """ + if not attrs.has_key('href'): return None + + i = -1 + for a in self.a: + i += 1 + match = 0 + + if a.has_key('href') and a['href'] == attrs['href']: + if a.has_key('title') or attrs.has_key('title'): + if (a.has_key('title') and attrs.has_key('title') and + a['title'] == attrs['title']): + match = True + else: + match = True + + if match: return i + + def handle_tag(self, tag, attrs, start): + attrs = fixattrs(attrs) + + if hn(tag): + self.p() + if start: self.o(hn(tag)*"#" + ' ') + + if tag in ['p', 'div']: self.p() + + if tag == "br" and start: self.o(" \n") + + if tag == "hr" and start: + self.p() + self.o("* * *") + self.p() + + if tag in ["head", "style", 'script']: + if start: self.quiet += 1 + else: self.quiet -= 1 + + if tag in ["body"]: + self.quiet = 0 # sites like 9rules.com never close <head> + + if tag == "blockquote": + if start: + self.p(); self.o('> ', 0, 1); self.start = 1 + self.blockquote += 1 + else: + self.blockquote -= 1 + self.p() + + if tag in ['em', 'i', 'u']: self.o("_") + if tag in ['strong', 'b']: self.o("**") + if tag == "code" and not self.pre: self.o('`') #TODO: `` `this` `` + if tag == "abbr": + if start: + attrsD = {} + for (x, y) in attrs: attrsD[x] = y + attrs = attrsD + + self.abbr_title = None + self.abbr_data = '' + if attrs.has_key('title'): + self.abbr_title = attrs['title'] + else: + if self.abbr_title != None: + self.abbr_list[self.abbr_data] = self.abbr_title + self.abbr_title = None + self.abbr_data = '' + + if tag == "a": + if start: + attrsD = {} + for (x, y) in attrs: attrsD[x] = y + attrs = attrsD + if attrs.has_key('href') and not (SKIP_INTERNAL_LINKS and attrs['href'].startswith('#')): + self.astack.append(attrs) + self.o("[") + else: + self.astack.append(None) + else: + if self.astack: + a = self.astack.pop() + if a: + i = self.previousIndex(a) + if i is not None: + a = self.a[i] + else: + self.acount += 1 + a['count'] = self.acount + a['outcount'] = self.outcount + self.a.append(a) + self.o("][" + `a['count']` + "]") + + if tag == "img" and start: + attrsD = {} + for (x, y) in attrs: attrsD[x] = y + attrs = attrsD + if attrs.has_key('src'): + attrs['href'] = attrs['src'] + alt = attrs.get('alt', '') + i = self.previousIndex(attrs) + if i is not None: + attrs = self.a[i] + else: + self.acount += 1 + attrs['count'] = self.acount + attrs['outcount'] = self.outcount + self.a.append(attrs) + self.o("![") + self.o(alt) + self.o("]["+`attrs['count']`+"]") + + if tag == 'dl' and start: self.p() + if tag == 'dt' and not start: self.pbr() + if tag == 'dd' and start: self.o(' ') + if tag == 'dd' and not start: self.pbr() + + if tag in ["ol", "ul"]: + if start: + self.list.append({'name':tag, 'num':0}) + else: + if self.list: self.list.pop() + + self.p() + + if tag == 'li': + if start: + self.pbr() + if self.list: li = self.list[-1] + else: li = {'name':'ul', 'num':0} + self.o(" "*len(self.list)) #TODO: line up <ol><li>s > 9 correctly. + if li['name'] == "ul": self.o("* ") + elif li['name'] == "ol": + li['num'] += 1 + self.o(`li['num']`+". ") + self.start = 1 + else: + self.pbr() + + if tag in ["table", "tr"] and start: self.p() + if tag == 'td': self.pbr() + + if tag == "pre": + if start: + self.startpre = 1 + self.pre = 1 + else: + self.pre = 0 + self.p() + + def pbr(self): + if self.p_p == 0: self.p_p = 1 + + def p(self): self.p_p = 2 + + def o(self, data, puredata=0, force=0): + if self.abbr_data is not None: self.abbr_data += data + + if not self.quiet: + if puredata and not self.pre: + data = re.sub('\s+', ' ', data) + if data and data[0] == ' ': + self.space = 1 + data = data[1:] + if not data and not force: return + + if self.startpre: + #self.out(" :") #TODO: not output when already one there + self.startpre = 0 + + bq = (">" * self.blockquote) + if not (force and data and data[0] == ">") and self.blockquote: bq += " " + + if self.pre: + bq += " " + data = data.replace("\n", "\n"+bq) + + if self.start: + self.space = 0 + self.p_p = 0 + self.start = 0 + + if force == 'end': + # It's the end. + self.p_p = 0 + self.out("\n") + self.space = 0 + + + if self.p_p: + self.out(('\n'+bq)*self.p_p) + self.space = 0 + + if self.space: + if not self.lastWasNL: self.out(' ') + self.space = 0 + + if self.a and ((self.p_p == 2 and LINKS_EACH_PARAGRAPH) or force == "end"): + if force == "end": self.out("\n") + + newa = [] + for link in self.a: + if self.outcount > link['outcount']: + self.out(" ["+`link['count']`+"]: " + urlparse.urljoin(self.baseurl, link['href'])) + if link.has_key('title'): self.out(" ("+link['title']+")") + self.out("\n") + else: + newa.append(link) + + if self.a != newa: self.out("\n") # Don't need an extra line when nothing was done. + + self.a = newa + + if self.abbr_list and force == "end": + for abbr, definition in self.abbr_list.items(): + self.out(" *[" + abbr + "]: " + definition + "\n") + + self.p_p = 0 + self.out(data) + self.lastWasNL = data and data[-1] == '\n' + self.outcount += 1 + + def handle_data(self, data): + if r'\/script>' in data: self.quiet -= 1 + self.o(data, 1) + + def unknown_decl(self, data): pass + +def wrapwrite(text): sys.stdout.write(text.encode('utf8')) + +def html2text_file(html, out=wrapwrite, baseurl=''): + h = _html2text(out, baseurl) + h.feed(html) + h.feed("") + return h.close() + +def html2text(html, baseurl='', wrap_width=BODY_WIDTH): + return optwrap(html2text_file(html, None, baseurl),wrap_width) + +if __name__ == "__main__": + baseurl = '' + if sys.argv[1:]: + arg = sys.argv[1] + if arg.startswith('http://'): + baseurl = arg + j = urllib.urlopen(baseurl) + try: + from feedparser import _getCharacterEncoding as enc + except ImportError: + enc = lambda x, y: ('utf-8', 1) + text = j.read() + encoding = enc(j.headers, text)[0] + if encoding == 'us-ascii': encoding = 'utf-8' + data = text.decode(encoding) + + else: + encoding = 'utf8' + if len(sys.argv) > 2: + encoding = sys.argv[2] + data = open(arg, 'r').read().decode(encoding) + else: + data = sys.stdin.read().decode('utf8') + wrapwrite(html2text(data, baseurl)) diff --git a/fanficdownloader/htmlcleanup.py b/fanficdownloader/htmlcleanup.py new file mode 100644 index 00000000..09d29855 --- /dev/null +++ b/fanficdownloader/htmlcleanup.py @@ -0,0 +1,482 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +logger = logging.getLogger(__name__) + +import re + +def _unirepl(match): + "Return the unicode string for a decimal number" + if match.group(1).startswith('x'): + radix=16 + s = match.group(1)[1:] + else: + radix=10 + s = match.group(1) + try: + value = int(s, radix) + retval = "%s%s"%(unichr(value),match.group(2)) + except: + # This way, at least if there's more of entities out there + # that fail, it doesn't blow the entire download. + logger.warn("Numeric entity translation failed, skipping: &#x%s%s"%(match.group(1),match.group(2))) + retval = "" + return retval + +def _replaceNumberEntities(data): + # The same brokenish entity parsing in SGMLParser that inserts ';' + # after non-entities will also insert ';' incorrectly after number + # entities, including part of the next word if it's a-z. + # "Don't—ever—do—that—again," becomes + # "Don't—e;ver—d;o—that—a;gain," + # Also need to allow for 5 digit decimal entities 法 + # Last expression didn't allow for 2 digit hex correctly: é + p = re.compile(r'&#(x[0-9a-fA-F]{,4}|[0-9]{,5})([0-9a-fA-F]*?);') + return p.sub(_unirepl, data) + +def _replaceNotEntities(data): + # not just \w or \S. regexp from c:\Python25\lib\sgmllib.py + # (or equiv), SGMLParser, entityref + p = re.compile(r'&([a-zA-Z][-.a-zA-Z0-9]*);') + return p.sub(r'&\1', data) + +def stripHTML(soup): + return removeAllEntities(re.sub(r'<[^>]+>','',"%s" % soup)).strip() + +def conditionalRemoveEntities(value): + if isinstance(value,str) or isinstance(value,unicode) : + return removeEntities(value).strip() + else: + return value + +def removeAllEntities(text): + # Remove < < and & + return removeEntities(text).replace('<', '<').replace('>', '>').replace('&', '&') + +def removeEntities(text): + + if text is None: + return "" + + if not isinstance(text,basestring): + return unicode(text) + + try: + t = text.decode('utf-8') + except UnicodeEncodeError, e: + try: + t = text.encode ('ascii', 'xmlcharrefreplace') + except UnicodeEncodeError, e: + t = text + text = t + # replace numeric versions of [&<>] with named versions, + # then replace named versions with actual characters, + text = re.sub(r'�*38;','&',text) + text = re.sub(r'�*60;','<',text) + text = re.sub(r'�*62;','>',text) + + # replace remaining � entities with unicode value, such as ' -> ' + text = _replaceNumberEntities(text) + + # replace several named entities with character, such as — -> - + # see constants.py for the list. + # reverse sort will put entities with ; before the same one without, when valid. + for e in reversed(sorted(entities.keys())): + v = entities[e] + try: + text = text.replace(e, v) + except UnicodeDecodeError, ex: + # for the pound symbol in constants.py + text = text.replace(e, v.decode('utf-8')) + + # SGMLParser, and in turn, BeautifulStoneSoup doesn't parse + # entities terribly well and inserts (;) after something that + # it thinks might be an entity. AT&T becomes AT&T; All of my + # attempts to fix this by changing the input to + # BeautifulStoneSoup break something else instead. But at + # this point, there should be *no* real entities left, so find + # these not-entities and removing them here should be safe. + text = _replaceNotEntities(text) + + # < < and & are the only html entities allowed in xhtml, put those back. + return text.replace('&', '&').replace('&lt', '<').replace('&gt', '>') + +# entity list from http://code.google.com/p/doctype/wiki/CharacterEntitiesConsistent +entities = { 'á' : 'á', + 'Á' : 'Á', + 'Á' : 'Á', + 'á' : 'á', + 'â' : 'â', + 'Â' : 'Â', + 'Â' : 'Â', + 'â' : 'â', + '´' : '´', + '´' : '´', + 'Æ' : 'Æ', + 'æ' : 'æ', + 'Æ' : 'Æ', + 'æ' : 'æ', + 'à' : 'à', + 'À' : 'À', + 'À' : 'À', + 'à' : 'à', + 'ℵ' : 'ℵ', + 'α' : 'α', + 'Α' : 'Α', + '&' : '&', + '&' : '&', + '&' : '&', + '&' : '&', + '∧' : '∧', + '∠' : '∠', + 'å' : 'å', + 'Å' : 'Å', + 'Å' : 'Å', + 'å' : 'å', + '≈' : '≈', + 'ã' : 'ã', + 'Ã' : 'Ã', + 'Ã' : 'Ã', + 'ã' : 'ã', + 'ä' : 'ä', + 'Ä' : 'Ä', + 'Ä' : 'Ä', + 'ä' : 'ä', + '„' : '„', + 'β' : 'β', + 'Β' : 'Β', + '¦' : '¦', + '¦' : '¦', + '•' : '•', + '∩' : '∩', + 'ç' : 'ç', + 'Ç' : 'Ç', + 'Ç' : 'Ç', + 'ç' : 'ç', + '¸' : '¸', + '¸' : '¸', + '¢' : '¢', + '¢' : '¢', + 'χ' : 'χ', + 'Χ' : 'Χ', + 'ˆ' : 'ˆ', + '♣' : '♣', + '≅' : '≅', + '©' : '©', + '©' : '©', + '©' : '©', + '©' : '©', + '↵' : '↵', + '∪' : '∪', + '¤' : '¤', + '¤' : '¤', + '†' : '†', + '‡' : '‡', + '↓' : '↓', + '⇓' : '⇓', + '°' : '°', + '°' : '°', + 'δ' : 'δ', + 'Δ' : 'Δ', + '♦' : '♦', + '÷' : '÷', + '÷' : '÷', + 'é' : 'é', + 'É' : 'É', + 'É' : 'É', + 'é' : 'é', + 'ê' : 'ê', + 'Ê' : 'Ê', + 'Ê' : 'Ê', + 'ê' : 'ê', + 'è' : 'è', + 'È' : 'È', + 'È' : 'È', + 'è' : 'è', + '∅' : '∅', + ' ' : ' ', + ' ' : ' ', + 'ε' : 'ε', + 'Ε' : 'Ε', + '≡' : '≡', + 'η' : 'η', + 'Η' : 'Η', + 'ð' : 'ð', + 'Ð' : 'Ð', + 'Ð' : 'Ð', + 'ð' : 'ð', + 'ë' : 'ë', + 'Ë' : 'Ë', + 'Ë' : 'Ë', + 'ë' : 'ë', + '€' : '€', + '∃' : '∃', + 'ƒ' : 'ƒ', + '∀' : '∀', + '½' : '½', + '½' : '½', + '¼' : '¼', + '¼' : '¼', + '¾' : '¾', + '¾' : '¾', + '⁄' : '⁄', + 'γ' : 'γ', + 'Γ' : 'Γ', + '≥' : '≥', + #'>' : '>', + #'>' : '>', + #'>' : '>', + #'>' : '>', + '↔' : '↔', + '⇔' : '⇔', + '♥' : '♥', + '…' : '…', + 'í' : 'í', + 'Í' : 'Í', + 'Í' : 'Í', + 'í' : 'í', + 'î' : 'î', + 'Î' : 'Î', + 'Î' : 'Î', + 'î' : 'î', + '¡' : '¡', + '¡' : '¡', + 'ì' : 'ì', + 'Ì' : 'Ì', + 'Ì' : 'Ì', + 'ì' : 'ì', + 'ℑ' : 'ℑ', + '∞' : '∞', + '∫' : '∫', + 'ι' : 'ι', + 'Ι' : 'Ι', + '¿' : '¿', + '¿' : '¿', + '∈' : '∈', + 'ï' : 'ï', + 'Ï' : 'Ï', + 'Ï' : 'Ï', + 'ï' : 'ï', + 'κ' : 'κ', + 'Κ' : 'Κ', + 'λ' : 'λ', + 'Λ' : 'Λ', + '«' : '«', + '«' : '«', + '←' : '←', + '⇐' : '⇐', + '⌈' : '⌈', + '“' : '“', + '≤' : '≤', + '⌊' : '⌊', + '∗' : '∗', + '◊' : '◊', + '‎' : '‎', + '‹' : '‹', + '‘' : '‘', + #'<' : '<', + #'<' : '<', + #'<' : '<', + #'<' : '<', + '¯' : '¯', + '¯' : '¯', + '—' : '—', + 'µ' : 'µ', + 'µ' : 'µ', + '·' : '·', + '·' : '·', + '−' : '−', + 'μ' : 'μ', + 'Μ' : 'Μ', + '∇' : '∇', + ' ' : ' ', + ' ' : ' ', + '–' : '–', + '≠' : '≠', + '∋' : '∋', + '¬' : '¬', + '¬' : '¬', + '∉' : '∉', + '⊄' : '⊄', + 'ñ' : 'ñ', + 'Ñ' : 'Ñ', + 'Ñ' : 'Ñ', + 'ñ' : 'ñ', + 'ν' : 'ν', + 'Ν' : 'Ν', + 'ó' : 'ó', + 'Ó' : 'Ó', + 'Ó' : 'Ó', + 'ó' : 'ó', + 'ô' : 'ô', + 'Ô' : 'Ô', + 'Ô' : 'Ô', + 'ô' : 'ô', + 'Œ' : 'Œ', + 'œ' : 'œ', + 'ò' : 'ò', + 'Ò' : 'Ò', + 'Ò' : 'Ò', + 'ò' : 'ò', + '‾' : '‾', + 'ω' : 'ω', + 'Ω' : 'Ω', + 'ο' : 'ο', + 'Ο' : 'Ο', + '⊕' : '⊕', + '∨' : '∨', + 'ª' : 'ª', + 'ª' : 'ª', + 'º' : 'º', + 'º' : 'º', + 'ø' : 'ø', + 'Ø' : 'Ø', + 'Ø' : 'Ø', + 'ø' : 'ø', + 'õ' : 'õ', + 'Õ' : 'Õ', + 'Õ' : 'Õ', + 'õ' : 'õ', + '⊗' : '⊗', + 'ö' : 'ö', + 'Ö' : 'Ö', + 'Ö' : 'Ö', + 'ö' : 'ö', + '¶' : '¶', + '¶' : '¶', + '∂' : '∂', + '‰' : '‰', + '⊥' : '⊥', + 'φ' : 'φ', + 'Φ' : 'Φ', + 'π' : 'π', + 'Π' : 'Π', + 'ϖ' : 'ϖ', + '±' : '±', + '±' : '±', + '£' : '£', + '£' : '£', + '′' : '′', + '″' : '″', + '∏' : '∏', + '∝' : '∝', + 'ψ' : 'ψ', + 'Ψ' : 'Ψ', + '"' : '"', + '"' : '"', + '"' : '"', + '"' : '"', + '√' : '√', + '»' : '»', + '»' : '»', + '→' : '→', + '⇒' : '⇒', + '⌉' : '⌉', + '”' : '”', + 'ℜ' : 'ℜ', + '®' : '®', + '®' : '®', + '®' : '®', + '®' : '®', + '⌋' : '⌋', + 'ρ' : 'ρ', + 'Ρ' : 'Ρ', + '‏' : '‏', + '›' : '›', + '’' : '’', + '‚' : '‚', + 'š' : 'š', + 'Š' : 'Š', + '⋅' : '⋅', + '§' : '§', + '§' : '§', + '­' : '­', # strange optional hyphenation control character, not just a dash + '­' : '­', + 'σ' : 'σ', + 'Σ' : 'Σ', + 'ς' : 'ς', + '∼' : '∼', + '♠' : '♠', + '⊂' : '⊂', + '⊆' : '⊆', + '∑' : '∑', + '¹' : '¹', + '¹' : '¹', + '²' : '²', + '²' : '²', + '³' : '³', + '³' : '³', + '⊃' : '⊃', + '⊇' : '⊇', + 'ß' : 'ß', + 'ß' : 'ß', + 'τ' : 'τ', + 'Τ' : 'Τ', + '∴' : '∴', + 'θ' : 'θ', + 'Θ' : 'Θ', + 'ϑ' : 'ϑ', + ' ' : ' ', + 'þ' : 'þ', + 'Þ' : 'Þ', + 'Þ' : 'Þ', + 'þ' : 'þ', + '˜' : '˜', + '×' : '×', + '×' : '×', + '™' : '™', + 'ú' : 'ú', + 'Ú' : 'Ú', + 'Ú' : 'Ú', + 'ú' : 'ú', + '↑' : '↑', + '⇑' : '⇑', + 'û' : 'û', + 'Û' : 'Û', + 'Û' : 'Û', + 'û' : 'û', + 'ù' : 'ù', + 'Ù' : 'Ù', + 'Ù' : 'Ù', + 'ù' : 'ù', + '¨' : '¨', + '¨' : '¨', + 'ϒ' : 'ϒ', + 'υ' : 'υ', + 'Υ' : 'Υ', + 'ü' : 'ü', + 'Ü' : 'Ü', + 'Ü' : 'Ü', + 'ü' : 'ü', + '℘' : '℘', + 'ξ' : 'ξ', + 'Ξ' : 'Ξ', + 'ý' : 'ý', + 'Ý' : 'Ý', + 'Ý' : 'Ý', + 'ý' : 'ý', + '¥' : '¥', + '¥' : '¥', + 'ÿ' : 'ÿ', + 'Ÿ' : 'Ÿ', + 'ÿ' : 'ÿ', + 'ζ' : 'ζ', + 'Ζ' : 'Ζ', + '‍' : '‍', # strange spacing control character, not just a space + '‌' : '‌', # strange spacing control character, not just a space + } diff --git a/fanficdownloader/htmlheuristics.py b/fanficdownloader/htmlheuristics.py new file mode 100644 index 00000000..52a78df4 --- /dev/null +++ b/fanficdownloader/htmlheuristics.py @@ -0,0 +1,348 @@ +# -*- 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 logging +logger = logging.getLogger(__name__) +import re +import codecs +import BeautifulSoup as bs +import HtmlTagStack as stack + +from . import exceptions as exceptions + +def replace_br_with_p(body): + + # Ascii character (and Unicode as well) xA0 is a non-breaking space, ascii code 160. + # However, Python Regex does not recognize it as a whitespace, so we'll be changing it to a reagular space. + body = body.replace(u'\xa0', u' ') + + if body.find('>') == -1 or body.rfind('<') == -1: + return body + + # logger.debug(u'BODY start.: ' + body[:250]) + # logger.debug(u'BODY end...: ' + body[-250:]) + # logger.debug(u'BODY.......: ' + body) + + # clean breaks (<br />), removing whitespaces between them. + body = re.sub(r'\s*<br[^>]*>\s*', r'<br />', body) + + # change surrounding div to a p and remove attrs Top surrounding + # tag in all cases now should be div, to just strip the first and + # last tags. + if is_valid_block(body) and body.find('<div') == 0: + body = body[body.index('>')+1:body.rindex('<')] + + body = soup_up_div(u'<div>' + body + u'</div>') + + body = body[body.index('>')+1:body.rindex('<')] + + # Find all bexisting blocks with p, pre and blockquote tags, we need to shields break tags inside those. + # This is for "lenient" mode, however it is also used to clear break tags before and after the block elements. + blocksRegex = re.compile(r'(\s*<br\ />\s*)*\s*<(pre|p|blockquote|table)([^>]*)>(.+?)</\2>\s*(\s*<br\ />\s*)*', re.DOTALL) + body = blocksRegex.sub(r'\n<\2\3>\4</\2>\n', body) + + # if aggressive mode = true + # blocksRegex = re.compile(r'(\s*<br\ */*>\s*)*\s*<(pre)([^>]*)>(.+?)</\2>\s*(\s*<br\ */*>\s*)*', re.DOTALL) + # In aggressive mode, we also check breakes inside blockquotes, meaning we can get orphaned paragraph tags. + # body = re.sub(r'<blockquote([^>]*)>(.+?)</blockquote>', r'<blockquote\1><p>\2</p></blockquote>', body, re.DOTALL) + # end aggressive mode + + blocks = blocksRegex.finditer(body) + # For our replacements to work, we need to work backwards, so we reverse the iterator. + blocksList = [] + for match in blocks: + blocksList.insert(0, match) + + for match in blocksList: + group4 = match.group(4).replace(u'<br />', u'{br /}') + body = body[:match.start(4)] + group4 + body[match.end(4):] + + # change surrounding div to a p and remove attrs Top surrounding + # tag in all cases now should be div, to just strip the first and + # last tags. + # body = u'<p>' + body + u'</p>' + + # Nuke div tags surrounding a HR tag. + body = re.sub(r'<div[^>]+>\s*<hr[^>]+>\s*</div>', r'\n<hr />\n', body) + + # So many people add formatting to their HR tags, and ePub does not allow those, we are supposed to use css. + # This nukes the hr tag attributes. + body = re.sub(r'\s*<hr[^>]+>\s*', r'\n<hr />\n', body) + + # Remove leading and trailing breaks from HR tags + body = re.sub(r'\s*(<br\ \/>)*\s*<hr\ \/>\s*(<br\ \/>)*\s*', r'\n<hr />\n', body) + # Nuking breaks leading paragraps that may be in the body. They are eventually treated as <p><br /></p> + body = re.sub(r'\s*(<br\ \/>)+\s*<p', r'\n<p></p>\n<p', body) + # Nuking breaks trailing paragraps that may be in the body. They are eventually treated as <p><br /></p> + body = re.sub(r'</p>\s*(<br\ \/>)+\s*', r'</p>\n<p></p>\n', body) + + # Because a leading or trailing non break tag will break the following code, we have to mess around rather badly for a few lines. + body = body.replace(u'[',u'&squareBracketStart;') + body = body.replace(u']',u'&squareBracketEnd;') + body = body.replace(u'<br />',u'[br /]') + + breaksRegexp = [ + re.compile(r'([^\]])(\[br\ \/\])([^\[])'), + re.compile(r'([^\]])(\[br\ \/\]){2}([^\[])'), + re.compile(r'([^\]])(\[br\ \/\]){3}([^\[])'), + re.compile(r'([^\]])(\[br\ \/\]){4}([^\[])'), + re.compile(r'([^\]])(\[br\ \/\]){5}([^\[])'), + re.compile(r'([^\]])(\[br\ \/\]){6}([^\[])'), + re.compile(r'([^\]])(\[br\ \/\]){7}([^\[])'), + re.compile(r'([^\]])(\[br\ \/\]){8}([^\[])'), + re.compile(r'(\[br\ \/\]){9,}')] + + breaksCount = [ + len(breaksRegexp[0].findall(body)), + len(breaksRegexp[1].findall(body)), + len(breaksRegexp[2].findall(body)), + len(breaksRegexp[3].findall(body)), + len(breaksRegexp[4].findall(body)), + len(breaksRegexp[5].findall(body)), + len(breaksRegexp[6].findall(body)), + len(breaksRegexp[7].findall(body))] + + breaksMax = 0 + breaksMaxIndex = 0; + + for i in range(1,len(breaksCount)): + if breaksCount[i] >= breaksMax: + breaksMax = breaksCount[i] + breaksMaxIndex = i + + lines = body.split(u'[br /]') + contentLines = 0; + contentLinesSum = 0; + longestLineLength = 0; + averageLineLength = 0; + + for line in lines: + lineLen = len(line.strip()) + if lineLen > 0: + contentLines += 1 + contentLinesSum += lineLen + if lineLen > longestLineLength: + longestLineLength = lineLen + + if contentLines == 0: + contentLines = 1 + + averageLineLength = contentLinesSum/contentLines + + logger.debug(u'---') + logger.debug(u'Lines.............: ' + unicode(len(lines))) + logger.debug(u'contentLines......: ' + unicode(contentLines)) + logger.debug(u'contentLinesSum...: ' + unicode(contentLinesSum)) + logger.debug(u'longestLineLength.: ' + unicode(longestLineLength)) + logger.debug(u'averageLineLength.: ' + unicode(averageLineLength)) + + if breaksMaxIndex == len(breaksCount)-1 and breaksMax < 2: + breaksMaxIndex = 0 + breaksMax = breaksCount[0] + + logger.debug(u'---') + logger.debug(u'breaks 1: ' + unicode(breaksCount[0])) + logger.debug(u'breaks 2: ' + unicode(breaksCount[1])) + logger.debug(u'breaks 3: ' + unicode(breaksCount[2])) + logger.debug(u'breaks 4: ' + unicode(breaksCount[3])) + logger.debug(u'breaks 5: ' + unicode(breaksCount[4])) + logger.debug(u'breaks 6: ' + unicode(breaksCount[5])) + logger.debug(u'breaks 7: ' + unicode(breaksCount[6])) + logger.debug(u'breaks 8: ' + unicode(breaksCount[7])) + logger.debug(u'----') + logger.debug(u'max found: ' + unicode(breaksMax)) + logger.debug(u'max Index: ' + unicode(breaksMaxIndex)) + logger.debug(u'----') + + if breaksMaxIndex > 0 and breaksCount[0] > breaksMax and averageLineLength < 90: + body = breaksRegexp[0].sub(r'\1 \n\3', body) + + # Find all instances of consecutive breaks less than otr equal to the max count use most often + # replase those tags to inverted p tag pairs, those with more connsecutive breaks are replaced them with a horisontal line + for i in range(len(breaksCount)): + # if i > 0 or breaksMaxIndex == 0: + if i <= breaksMaxIndex: + logger.debug(unicode(i) + u' <= breaksMaxIndex (' + unicode(breaksMaxIndex) + u')') + body = breaksRegexp[i].sub(r'\1</p>\n<p>\3', body) + elif i == breaksMaxIndex+1: + logger.debug(unicode(i) + u' == breaksMaxIndex+1 (' + unicode(breaksMaxIndex+1) + u')') + body = breaksRegexp[i].sub(r'\1</p>\n<p><br/></p>\n<p>\3', body) + else: + logger.debug(unicode(i) + u' > breaksMaxIndex+1 (' + unicode(breaksMaxIndex+1) + u')') + body = breaksRegexp[i].sub(r'\1</p>\n<hr />\n<p>\3', body) + + body = breaksRegexp[8].sub(r'</p>\n<hr />\n<p>', body) + + # Reverting the square brackets + body = body.replace(u'[', u'<') + body = body.replace(u']', u'>') + body = body.replace(u'&squareBracketStart;', u'[') + body = body.replace(u'&squareBracketEnd;', u']') + + body = body.replace(u'{p}', u'<p>') + body = body.replace(u'{/p}', u'</p>') + + # If for some reason, a third break makes its way inside the paragraph, preplace that with the empty paragraph for the additional linespaing. + body = re.sub(r'<p>\s*(<br\ \/>)+', r'<p><br /></p>\n<p>', body) + + # change empty p tags to include a br to force spacing. + body = re.sub(r'<p>\s*</p>', r'<p><br/></p>', body) + + # Clean up hr tags, and add inverted p tag pairs + body = re.sub(r'(<div[^>]+>)*\s*<hr\ \/>\s*(</div>)*', r'\n<hr />\n', body) + + # Clean up hr tags, and add inverted p tag pairs + body = re.sub(r'\s*<hr\ \/>\s*', r'</p>\n<hr />\n<p>', body) + + # Because the previous regexp may cause trouble if the hr tag already had a p tag pair around it, w nee dot repair that. + # Repeated opening p tags are condenced to one. As we added the extra leading opening p tags, we can safely assume that + # the last in such a chain must be the original. Lets keep its attributes if they are there. + body = re.sub(r'\s*(<p[^>]*>\s*)+<p([^>]*)>\s*', r'\n<p\2>', body) + # Repeated closing p tags are condenced to one + body = re.sub(r'\s*(<\/\s*p>\s*){2,}', r'</p>\n', body) + + # superflous cleaning, remove whitespaces traling opening p tags. These does affect formatting. + body = re.sub(r'\s*<p([^>]*)>\s*', r'\n<p\1>', body) + # superflous cleaning, remove whitespaces leading closing p tags. These does not affect formatting. + body = re.sub(r'\s*</p>\s*', r'</p>\n', body) + + # Remove empty tag pairs + body = re.sub(r'\s*<(\S+)[^>]*>\s*</\1>', r'', body) + + body = body.replace(u'{br /}', u'<br />') + body = body.strip() + + # re-wrap in div tag. + body = u'<div>\n' + body + u'</div>\n' + + # return body + return tag_sanitizer(body) + +def is_valid_block(block): + return unicode(block).find('<') == 0 and unicode(block).find('<!') != 0 + +def soup_up_div(body): + blockTags = ['address', 'blockquote', 'del', 'div', 'dl', 'fieldset', 'form', 'ins', 'noscript', 'ol', 'p', 'pre', 'table', 'ul'] + recurseTags = ['blockquote', 'div', 'noscript'] + + tag = body[:body.index('>')+1] + tagend = body[body.rindex('<'):] + + body = body.replace(u'<br />', u'[br /]') + + soup = bs.BeautifulSoup(body) + + body = u'' + lastElement = 1 # 1 = block, 2 = nested, 3 = invalid + + for i in soup.contents[0]: + if unicode(i).strip().__len__() > 0: + s = unicode(i) + if type(i) == bs.Tag: + if i.name in blockTags: + if lastElement > 1: + body = body.strip(r'\s*(\[br\ \/\]\s*)*\s*') + body += u'{/p}' + + lastElement = 1 + + if i.name in recurseTags: + s = soup_up_div(s) + + body += s.strip() + '\n' + else: + if lastElement == 1: + body = body.strip(r'\s*(\[br\ \/\]\s*)*\s*') + body += u'{p}' + + lastElement = 2 + body += s + elif type(i) == bs.Comment: + body += s + else: + if lastElement == 1: + body = body.strip(r'\s*(\[br\ \/\]\s*)*\s*') + body += u'{p}' + + lastElement = 3 + body += s + + if lastElement > 1: + body = body.strip(r'\s*(\[br\ \/\]\s*)*\s*') + body += u'{/p}' + + body = body.replace(u'[br /]', u'<br />') + + return tag + body + tagend + + +def is_end_tag(tag): + return re.match(r'</([^\ >]+)>', tag) != None + +def is_comment_tag(tag): + return re.match(r'<\!\-\-([^>]+)>', tag) != None + +def is_closed_tag(tag): + return re.match(r'<(.+?)/>', tag) != None + +def tag_sanitizer(html): + blockTags = ['address', 'blockquote', 'del', 'div', 'dl', 'fieldset', 'form', 'ins', 'noscript', 'ol', 'pre', 'table', 'ul'] + + body = u'' + tags = re.findall(r'(<[^>]+>)([^<]*)', html) + + for rTag in tags: + name = stack.get_tag_name(rTag[0]) + is_end = is_end_tag(rTag[0]) + is_closed = is_closed_tag(rTag[0]) or is_comment_tag(rTag[0]) + + # is_comment = is_comment_tag(rTag[0]) + # logger.debug(u'%s > isEnd: %s > isClosed: %s > isComment: %s'%(name, unicode(is_end), unicode(is_closed), unicode(is_comment))) + # logger.debug(u'> %s%s\n'%(rTag[0], rTag[1])) + + if name in blockTags: + body += rTag[0] + body += rTag[1] + elif name == u'p': + if is_end: + body += stack.spool_end() + body += rTag[0] + body += rTag[1] + elif is_closed: + body += rTag[0] + body += rTag[1] + else: + body += rTag[0] + body += stack.spool_start() + body += rTag[1] + else: + if is_end: + t = stack.get_last() + tn = stack.get_tag_name(t) + rTn = stack.get_tag_name(rTag[0]) + if tn == rTn: + body += rTag[0] + stack.pop() + elif not is_closed: + stack.push(rTag[0]) + body += rTag[0] + else: + body += rTag[0] + + body += rTag[1] + stack.flush() + return body diff --git a/fanficdownloader/mobi.py b/fanficdownloader/mobi.py new file mode 100644 index 00000000..7a527154 --- /dev/null +++ b/fanficdownloader/mobi.py @@ -0,0 +1,386 @@ +#!/usr/bin/python +# Copyright(c) 2009 Andrew Chatham and Vijay Pandurangan + + +import StringIO +import struct +import time +import random +import logging + +logger = logging.getLogger(__name__) + +from html import HtmlProcessor + +# http://wiki.mobileread.com/wiki/MOBI +# http://membres.lycos.fr/microfirst/palm/pdb.html + +encoding = { + 'UTF-8' : 65001, + 'latin-1' : 1252, +} + +languages = {"en-us" : 0x0409, + "sv" : 0x041d, + "fi" : 0x000b, + "en" : 0x0009, + "en-gb" : 0x0809} + +def ToHex(s): + v = ['%.2x' % ord(c) for c in s] + return ' '.join(v) + +class _SubEntry: + def __init__(self, pos, html_data): + self.pos = pos + self.html = HtmlProcessor(html_data) + self.title = self.html.title + self._name = 'mobi_article_%d' % pos + if not self.title: + self.title = 'Article %d' % self.pos + + def TocLink(self): + return '<a href="#%s_MOBI_START">%.80s</a>' % (self._name, self.title) + + def Anchor(self): + return '<a name="%s_MOBI_START">' % self._name + + def Body(self): + return self.html.RenameAnchors(self._name + '_') + +class Converter: + def __init__(self, refresh_url='', title='Unknown', author='Unknown', publisher='Unknown'): + self._header = Header() + self._header.SetTitle(title) + self._header.SetAuthor(author) + self._header.SetPublisher(publisher) + self._refresh_url = refresh_url + + def ConvertString(self, s): + out = StringIO.StringIO() + self._ConvertStringToFile(s, out) + return out.getvalue() + + def ConvertStrings(self, html_strs): + out = StringIO.StringIO() + self._ConvertStringsToFile(html_strs, out) + return out.getvalue() + + def ConvertFile(self, html_file, out_file): + self._ConvertStringToFile(open(html_file,'rb').read(), + open(out_file, 'wb')) + + def ConvertFiles(self, html_files, out_file): + html_strs = [open(f,'rb').read() for f in html_files] + self._ConvertStringsToFile(html_strs, open(out_file, 'wb')) + + def MakeOneHTML(self, html_strs): + """This takes a list of HTML strings and returns a big HTML file with + all contents consolidated. It constructs a table of contents and adds + anchors within the text + """ + title_html = [] + toc_html = [] + body_html = [] + + PAGE_BREAK = '<mbp:pagebreak>' + + # pull out the title page, assumed first html_strs. + htmltitle = html_strs[0] + entrytitle = _SubEntry(1, htmltitle) + title_html.append(entrytitle.Body()) + + title_html.append(PAGE_BREAK) + toc_html.append('<a name="TOCTOP"><h3>Table of Contents</h3><br />') + + for pos, html in enumerate(html_strs[1:]): + entry = _SubEntry(pos+1, html) + toc_html.append('%s<br />' % entry.TocLink()) + + # give some space between bodies of work. + body_html.append(PAGE_BREAK) + + body_html.append(entry.Anchor()) + + body_html.append(entry.Body()) + + # TODO: this title can get way too long with RSS feeds. Not sure how to fix + # cheat slightly and use the <a href> code to set filepos in references. + header = '''<html> +<head> +<title>Bibliorize %s GMT + + + + + +''' % time.ctime(time.time()) + + footer = '' + all_html = header + '\n'.join(title_html + toc_html + body_html) + footer + #print "%s" % all_html.encode('utf8') + return all_html + + def _ConvertStringsToFile(self, html_strs, out_file): + try: + tmp = self.MakeOneHTML(html_strs) + self._ConvertStringToFile(tmp, out_file) + except Exception, e: + logger.error('Error %s', e) + #logger.debug('Details: %s' % html_strs) + + def _ConvertStringToFile(self, html_data, out): + html = HtmlProcessor(html_data) + data = html.CleanHtml() + + # collect offsets of '' tags, use to make index list. + # indexlist = [] # list of (offset,length) tuples. + # not in current use. + + # j=0 + # lastj=0 + # while True: + # j=data.find('',lastj+10) # plus a bit so we find the next. + # if j < 0: + # break + # indexlist.append((lastj,j-lastj)) + # print "index offset: %d length: %d" % (lastj,j-lastj) + # lastj=j + + records = [] +# title = html.title +# if title: +# self._header.SetTitle(title) + record_id = 1 + for start_pos in range(0, len(data), Record.MAX_SIZE): + end = min(len(data), start_pos + Record.MAX_SIZE) + record_data = data[start_pos:end] + records.append(self._header.AddRecord(record_data, record_id)) + #print "HTML Record %03d: (size:%d) [[%s ... %s]]" % ( record_id, len(record_data), record_data[:20], record_data[-20:] ) + record_id += 1 + self._header.SetImageRecordIndex(record_id) + records[0:0] = [self._header.MobiHeader()] + + header, rec_offset = self._header.PDBHeader(len(records)) + out.write(header) + for record in records: + record.WriteHeader(out, rec_offset) + #print "rec_offset: %d len(record.data): %d" % (rec_offset,len(record.data)) + rec_offset += (len(record.data)+1) # plus one for trailing null + + # Write to nuls for some reason + out.write('\0\0') + for record in records: + record.WriteData(out) + out.write('\0') + # needs a trailing null, I believe it indicates zero length 'overlap'. + # otherwise, the readers eat the last char of each html record. + # Calibre writes another 6-7 bytes of stuff after that, but we seem + # to be getting along without it. + +class Record: + MAX_SIZE = 4096 + INDEX_LEN = 8 + _unique_id_seed = 28 # should be arbitrary, but taken from MobiHeader + + # TODO(chatham): Record compression doesn't look that hard. + + def __init__(self, data, record_id): + assert len(data) <= self.MAX_SIZE + self.data = data + if record_id != 0: + self._id = record_id + else: + Record._unique_id_seed += 1 + self._id = 0 + + def __repr__(self): + return 'Record: id=%d len=%d' % (self._id, len(self.data)) + + def _SetUniqueId(self): + Record._unique_id_seed += 1 + # TODO(chatham): Wraparound crap + self._id = Record._unique_id_seed + + def WriteData(self, out): + out.write(self.data) + + def WriteHeader(self, out, rec_offset): + attributes = 64 # dirty? + header = struct.pack('>IbbH', + rec_offset, + attributes, + 0, self._id) + assert len(header) == Record.INDEX_LEN + out.write(header) + +EXTH_HEADER_FIELDS = { + 'author' : 100, + 'publisher' : 101, +} + +class Header: + EPOCH_1904 = 2082844800 + + def __init__(self): + self._length = 0 + self._record_count = 0 + self._title = '2008_2_34' + self._author = 'Unknown author' + self._publisher = 'Unknown publisher' + self._first_image_index = 0 + + def SetAuthor(self, author): + self._author = author.encode('ascii','ignore') + + def SetTitle(self, title): + # TODO(chatham): Reevaluate whether this needs to be ASCII. + # maybe just do sys.setdefaultencoding('utf-8')? Problems + # appending self._title with other things. + self._title = title.encode('ascii','ignore') + + def SetPublisher(self, publisher): + self._publisher = publisher.encode('ascii','ignore') + + def AddRecord(self, data, record_id): + self.max_record_size = max(Record.MAX_SIZE, len(data)) + self._record_count += 1 + self._length += len(data) + return Record(data, record_id) + + def _ReplaceWord(self, data, pos, word): + return data[:pos] + struct.pack('>I', word) + data[pos+4:] + + def PalmDocHeader(self): + compression = 1 # no compression + unused = 0 + encryption_type = 0 # no ecryption + records = self._record_count + 1 # the header record itself + palmdoc_header = struct.pack('>HHIHHHH', + compression, + unused, + self._length, + records, + Record.MAX_SIZE, + encryption_type, + unused) + assert len(palmdoc_header) == 16 + return palmdoc_header + + def PDBHeader(self, num_records): + HEADER_LEN = 32+2+2+9*4 + RECORD_INDEX_HEADER_LEN = 6 + RESOURCE_INDEX_LEN = 10 + + index_len = RECORD_INDEX_HEADER_LEN + num_records * Record.INDEX_LEN + rec_offset = HEADER_LEN + index_len + 2 + + short_title = self._title[0:31] + attributes = 0 + version = 0 + ctime = self.EPOCH_1904 + int(time.time()) + mtime = self.EPOCH_1904 + int(time.time()) + backup_time = self.EPOCH_1904 + int(time.time()) + modnum = 0 + appinfo_offset = 0 + sort_offset = 0 + type = 'BOOK' + creator = 'MOBI' + id_seed = 36 + header = struct.pack('>32sHHII', + short_title, attributes, version, + ctime, mtime) + header += struct.pack('>IIII', backup_time, modnum, + appinfo_offset, sort_offset) + header += struct.pack('>4s4sI', + type, creator, id_seed) + next_record = 0 # not used? + header += struct.pack('>IH', next_record, num_records) + return header, rec_offset + + def _GetExthHeader(self): + # They set author, publisher, coveroffset, thumboffset + data = {'author' : self._author, + 'publisher' : self._publisher, + } + # Turn string type names into EXTH typeids. + r = [] + for key, value in data.items(): + typeid = EXTH_HEADER_FIELDS[key] + length_encoding_len = 8 + r.append(struct.pack('>LL', typeid, len(value) + length_encoding_len,) + value) + content = ''.join(r) + + # Pad to word boundary + while len(content) % 4: + content += '\0' + TODO_mysterious = 12 + exth = 'EXTH' + struct.pack('>LL', len(content) + TODO_mysterious, len(data)) + content + return exth + + def SetImageRecordIndex(self, idx): + self._first_image_index = idx + + def MobiHeader(self): + exth_header = self._GetExthHeader(); + palmdoc_header = self.PalmDocHeader() + + fs = 0xffffffff + + # Record 0 + header_len = 0xE4 # TODO + mobi_type = 2 # BOOK + text_encoding = encoding['UTF-8'] + unique_id = random.randint(1, 1<<32) + creator_version = 4 + reserved = '%c' % 0xff * 40 + nonbook_index = fs + full_name_offset = header_len + len(palmdoc_header) + len(exth_header) # put full name after header + language = languages['en-us'] + unused = 0 + mobi_header = struct.pack('>4sIIIII40sIIIIII', + 'MOBI', + header_len, + mobi_type, + text_encoding, + unique_id, + creator_version, + reserved, + nonbook_index, + full_name_offset, + len(self._title), + language, + fs, fs) + assert len(mobi_header) == 104 - 16 + + unknown_fields = chr(0) * 32 + drm_offset = 0 + drm_count = 0 + drm_size = 0 + drm_flags = 0 + exth_flags = 0x50 + header_end = chr(0) * 64 + mobi_header += struct.pack('>IIIIIII', + creator_version, + self._first_image_index, + fs, + unused, + fs, + unused, + exth_flags) + mobi_header += '\0' * 112 # TODO: Why this much padding? + # Set some magic offsets to be 0xFFFFFFF. + for pos in (0x94, 0x98, 0xb0, 0xb8, 0xc0, 0xc8, 0xd0, 0xd8, 0xdc): + mobi_header = self._ReplaceWord(mobi_header, pos, fs) + + # 16 bytes? + padding = '\0' * 48 * 4 # why? + total_header = palmdoc_header + mobi_header + exth_header + self._title + padding + + return self.AddRecord(total_header, 0) + +if __name__ == '__main__': + import sys + m = Converter(title='Testing Mobi', author='Mobi Author', publisher='mobi converter') + m.ConvertFiles(sys.argv[1:], 'test.mobi') + #m.ConvertFile(sys.argv[1], 'test.mobi') diff --git a/fanficdownloader/story.py b/fanficdownloader/story.py new file mode 100644 index 00000000..76a644c9 --- /dev/null +++ b/fanficdownloader/story.py @@ -0,0 +1,865 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os, re +import urlparse +import string +from math import floor +from functools import partial +import logging +logger = logging.getLogger(__name__) +import urlparse as up + +import exceptions +from htmlcleanup import conditionalRemoveEntities, removeAllEntities +from configurable import Configurable + +SPACE_REPLACE=u'\s' +SPLIT_META=u'\,' + +# Create convert_image method depending on which graphics lib we can +# load. Preferred: calibre, PIL, none + +imagetypes = { + 'jpg':'image/jpeg', + 'jpeg':'image/jpeg', + 'png':'image/png', + 'gif':'image/gif', + 'svg':'image/svg+xml', + } + +try: + from calibre.utils.magick import Image + convtype = {'jpg':'JPG', 'png':'PNG'} + + def convert_image(url,data,sizes,grayscale, + removetrans,imgtype="jpg",background='#ffffff'): + export = False + img = Image() + img.load(data) + + owidth, oheight = img.size + nwidth, nheight = sizes + scaled, nwidth, nheight = fit_image(owidth, oheight, nwidth, nheight) + if scaled: + img.size = (nwidth, nheight) + export = True + + if normalize_format_name(img.format) != imgtype: + export = True + + if removetrans and img.has_transparent_pixels(): + canvas = Image() + canvas.create_canvas(int(img.size[0]), int(img.size[1]), str(background)) + canvas.compose(img) + img = canvas + export = True + + if grayscale and img.type != "GrayscaleType": + img.type = "GrayscaleType" + export = True + + if export: + return (img.export(convtype[imgtype]),imgtype,imagetypes[imgtype]) + else: + logger.debug("image used unchanged") + return (data,imgtype,imagetypes[imgtype]) + +except: + + # No calibre routines, try for PIL for CLI. + try: + import Image + from StringIO import StringIO + convtype = {'jpg':'JPEG', 'png':'PNG'} + def convert_image(url,data,sizes,grayscale, + removetrans,imgtype="jpg",background='#ffffff'): + export = False + img = Image.open(StringIO(data)) + + owidth, oheight = img.size + nwidth, nheight = sizes + scaled, nwidth, nheight = fit_image(owidth, oheight, nwidth, nheight) + if scaled: + img = img.resize((nwidth, nheight),Image.ANTIALIAS) + export = True + + if normalize_format_name(img.format) != imgtype: + if img.mode == "P": + # convert pallete gifs to RGB so jpg save doesn't fail. + img = img.convert("RGB") + export = True + + if removetrans and img.mode == "RGBA": + background = Image.new('RGBA', img.size, background) + # Paste the image on top of the background + background.paste(img, img) + img = background.convert('RGB') + export = True + + if grayscale and img.mode != "L": + img = img.convert("L") + export = True + + if export: + outsio = StringIO() + img.save(outsio,convtype[imgtype]) + return (outsio.getvalue(),imgtype,imagetypes[imgtype]) + else: + logger.debug("image used unchanged") + return (data,imgtype,imagetypes[imgtype]) + + except: + # No calibre or PIL, simple pass through with mimetype. + def convert_image(url,data,sizes,grayscale, + removetrans,imgtype="jpg",background='#ffffff'): + return no_convert_image(url,data) + +## also used for explicit no image processing. +def no_convert_image(url,data): + parsedUrl = up.urlparse(url) + + ext=parsedUrl.path[parsedUrl.path.rfind('.')+1:].lower() + + if ext not in imagetypes: + logger.debug("no_convert_image url:%s - no known extension"%url) + # doesn't have extension? use jpg. + ext='jpg' + + return (data,ext,imagetypes[ext]) + +def normalize_format_name(fmt): + if fmt: + fmt = fmt.lower() + if fmt == 'jpeg': + fmt = 'jpg' + return fmt + +def fit_image(width, height, pwidth, pheight): + ''' + Fit image in box of width pwidth and height pheight. + @param width: Width of image + @param height: Height of image + @param pwidth: Width of box + @param pheight: Height of box + @return: scaled, new_width, new_height. scaled is True iff new_width and/or new_height is different from width or height. + ''' + scaled = height > pheight or width > pwidth + if height > pheight: + corrf = pheight/float(height) + width, height = floor(corrf*width), pheight + if width > pwidth: + corrf = pwidth/float(width) + width, height = pwidth, floor(corrf*height) + if height > pheight: + corrf = pheight/float(height) + width, height = floor(corrf*width), pheight + + return scaled, int(width), int(height) + +try: + # doesn't really matter what, just checking for appengine. + from google.appengine.api import apiproxy_stub_map + + is_appengine = True +except: + is_appengine = False + + +# The list comes from ffnet, the only multi-language site we support +# at the time of writing. Values are taken largely from pycountry, +# but with some corrections and guesses. +langs = { + "English":"en", + "Spanish":"es", + "French":"fr", + "German":"de", + "Chinese":"zh", + "Japanese":"ja", + "Dutch":"nl", + "Portuguese":"pt", + "Russian":"ru", + "Italian":"it", + "Bulgarian":"bg", + "Polish":"pl", + "Hungarian":"hu", + "Hebrew":"he", + "Arabic":"ar", + "Swedish":"sv", + "Norwegian":"no", + "Danish":"da", + "Finnish":"fi", + "Filipino":"fil", + "Esperanto":"eo", + "Hindi":"hi", + "Punjabi":"pa", + "Farsi":"fa", + "Greek":"el", + "Romanian":"ro", + "Albanian":"sq", + "Serbian":"sr", + "Turkish":"tr", + "Czech":"cs", + "Indonesian":"id", + "Croatian":"hr", + "Catalan":"ca", + "Latin":"la", + "Korean":"ko", + "Vietnamese":"vi", + "Thai":"th", + "Devanagari":"hi", + } + +class InExMatch: + keys = [] + regex = None + match = None + negate = False + + def __init__(self,line): + if "=~" in line: + (self.keys,self.match) = line.split("=~") + self.match = self.match.replace(SPACE_REPLACE,' ') + self.regex = re.compile(self.match) + elif "!~" in line: + (self.keys,self.match) = line.split("!~") + self.match = self.match.replace(SPACE_REPLACE,' ') + self.regex = re.compile(self.match) + self.negate = True + elif "==" in line: + (self.keys,self.match) = line.split("==") + self.match = self.match.replace(SPACE_REPLACE,' ') + elif "!=" in line: + (self.keys,self.match) = line.split("!=") + self.match = self.match.replace(SPACE_REPLACE,' ') + self.negate = True + self.keys = map( lambda x: x.strip(), self.keys.split(",") ) + + # For conditional, only one key + def is_key(self,key): + return key == self.keys[0] + + # For conditional, only one key + def key(self): + return self.keys[0] + + def in_keys(self,key): + return key in self.keys + + def is_match(self,value): + retval = False + if self.regex: + if self.regex.search(value): + retval = True + #print(">>>>>>>>>>>>>%s=~%s r: %s,%s=%s"%(self.match,value,self.negate,retval,self.negate != retval)) + else: + retval = self.match == value + #print(">>>>>>>>>>>>>%s==%s r: %s,%s=%s"%(self.match,value,self.negate,retval, self.negate != retval)) + + return self.negate != retval + + def __str__(self): + if self.negate: + f='!' + else: + f='=' + if self.regex: + s='~' + else: + s='=' + return u'InExMatch(%s %s%s %s)'%(self.keys,f,s,self.match) + +class Story(Configurable): + + def __init__(self, configuration): + Configurable.__init__(self, configuration) + try: + ## calibre plugin will set externally to match PI version. + self.metadata = {'version':os.environ['CURRENT_VERSION_ID']} + except: + self.metadata = {'version':'4.4'} + self.replacements = [] + self.in_ex_cludes = {} + self.chapters = [] # chapters will be tuples of (title,html) + self.imgurls = [] + self.imgtuples = [] + + self.cover=None # *href* of new cover image--need to create html. + self.oldcover=None # (oldcoverhtmlhref,oldcoverhtmltype,oldcoverhtmldata,oldcoverimghref,oldcoverimgtype,oldcoverimgdata) + self.calibrebookmark=None # cheesy way to carry calibre bookmark file forward across update. + self.logfile=None # cheesy way to carry log file forward across update. + + ## Look for config parameter, split and add each to metadata field. + for (config,metadata) in [("extracategories","category"), + ("extragenres","genre"), + ("extracharacters","characters"), + ("extraships","ships"), + ("extrawarnings","warnings")]: + for val in self.getConfigList(config): + self.addToList(metadata,val) + + self.setReplace(self.getConfig('replace_metadata')) + + in_ex_clude_list = ['include_metadata_pre','exclude_metadata_pre', + 'include_metadata_post','exclude_metadata_post'] + for ie in in_ex_clude_list: + ies = self.getConfig(ie) + # print("%s %s"%(ie,ies)) + if ies: + iel = [] + self.in_ex_cludes[ie] = self.set_in_ex_clude(ies) + + def join_list(self, key, vallist): + return self.getConfig("join_string_"+key,u", ").replace(SPACE_REPLACE,' ').join(map(unicode, vallist)) + + def setMetadata(self, key, value, condremoveentities=True): + + # keep as list type, but set as only value. + if self.isList(key): + self.addToList(key,value,condremoveentities=condremoveentities,clear=True) + else: + ## still keeps < < and & + if condremoveentities: + self.metadata[key]=conditionalRemoveEntities(value) + else: + self.metadata[key]=value + + if key == "language": + try: + # getMetadata not just self.metadata[] to do replace_metadata. + self.setMetadata('langcode',langs[self.getMetadata(key)]) + except: + self.setMetadata('langcode','en') + + if key == 'dateUpdated' and value: + # Last Update tags for Bill. + self.addToList('lastupdate',value.strftime("Last Update Year/Month: %Y/%m")) + self.addToList('lastupdate',value.strftime("Last Update: %Y/%m/%d")) + + + ## metakey[,metakey]=~pattern + ## metakey[,metakey]==string + ## *for* part lines. Effect only when trailing conditional key=~regexp matches + ## metakey[,metakey]=~pattern[&&metakey=~regexp] + ## metakey[,metakey]==string[&&metakey=~regexp] + ## metakey[,metakey]=~pattern[&&metakey==string] + ## metakey[,metakey]==string[&&metakey==string] + def set_in_ex_clude(self,setting): + dest = [] + # print("set_in_ex_clude:"+setting) + for line in setting.splitlines(): + if line: + (match,condmatch)=(None,None) + if "&&" in line: + (line,conditional) = line.split("&&") + condmatch = InExMatch(conditional) + match = InExMatch(line) + dest.append([match,condmatch]) + return dest + + def do_in_ex_clude(self,which,value,key): + if value and which in self.in_ex_cludes: + include = 'include' in which + keyfound = False + found = False + for (match,condmatch) in self.in_ex_cludes[which]: + keyfndnow = False + if match.in_keys(key): + # key in keys and either no conditional, or conditional matched + if condmatch == None or condmatch.is_key(key): + keyfndnow = True + else: + condval = self.getMetadata(condmatch.key()) + keyfndnow = condmatch.is_match(condval) + keyfound |= keyfndnow + # print("match:%s %s\ncondmatch:%s %s\n\tkeyfound:%s\n\tfound:%s"%( + # match,value,condmatch,condval,keyfound,found)) + if keyfndnow: + found = isinstance(value,basestring) and match.is_match(value) + if found: + # print("match:%s %s\n\tkeyfndnow:%s\n\tfound:%s"%( + # match,value,keyfndnow,found)) + if not include: + value = None + break + if include and keyfound and not found: + value = None + return value + + + ## Two or three part lines. Two part effect everything. + ## Three part effect only those key(s) lists. + ## pattern=>replacement + ## metakey,metakey=>pattern=>replacement + ## *Five* part lines. Effect only when trailing conditional key=>regexp matches + ## metakey[,metakey]=>pattern=>replacement[&&metakey=>regexp] + def setReplace(self,replace): + for line in replace.splitlines(): + # print("replacement line:%s"%line) + (metakeys,regexp,replacement,condkey,condregexp)=(None,None,None,None,None) + if "&&" in line: + (line,conditional) = line.split("&&") + (condkey,condregexp) = conditional.split("=>") + if "=>" in line: + parts = line.split("=>") + if len(parts) > 2: + metakeys = map( lambda x: x.strip(), parts[0].split(",") ) + (regexp,replacement)=parts[1:] + else: + (regexp,replacement)=parts + + if regexp: + regexp = re.compile(regexp) + if condregexp: + condregexp = re.compile(condregexp) + # A way to explicitly include spaces in the + # replacement string. The .ini parser eats any + # trailing spaces. + replacement=replacement.replace(SPACE_REPLACE,' ') + self.replacements.append([metakeys,regexp,replacement,condkey,condregexp]) + + def doReplacements(self,value,key,return_list=False,seen_list=[]): + value = self.do_in_ex_clude('include_metadata_pre',value,key) + value = self.do_in_ex_clude('exclude_metadata_pre',value,key) + + retlist = [value] + for replaceline in self.replacements: + if replaceline in seen_list: # recursion on pattern, bail + # print("bailing on %s"%replaceline) + continue + #print("replacement tuple:%s"%replaceline) + (metakeys,regexp,replacement,condkey,condregexp) = replaceline + if (metakeys == None or key in metakeys) \ + and isinstance(value,basestring) \ + and regexp.search(value): + doreplace=True + if condkey and condkey != key: # prevent infinite recursion. + condval = self.getMetadata(condkey) + doreplace = condval != None and condregexp.search(condval) + + if doreplace: + # split into more than one list entry if + # SPLIT_META present in replacement string. Split + # first, then regex sub, then recurse call replace + # on each. Break out of loop, each split element + # handled individually by recursion call. + if SPLIT_META in replacement: + retlist = [] + for splitrepl in replacement.split(SPLIT_META): + retlist.extend(self.doReplacements(regexp.sub(splitrepl,value), + key, + return_list=True, + seen_list=seen_list+[replaceline])) + break + else: + # print("replacement,value:%s,%s->%s"%(replacement,value,regexp.sub(replacement,value))) + value = regexp.sub(replacement,value) + retlist = [value] + + for val in retlist: + retlist = map(partial(self.do_in_ex_clude,'include_metadata_post',key=key),retlist) + retlist = map(partial(self.do_in_ex_clude,'exclude_metadata_post',key=key),retlist) + # value = self.do_in_ex_clude('include_metadata_post',value,key) + # value = self.do_in_ex_clude('exclude_metadata_post',value,key) + + if return_list: + return retlist + else: + return self.join_list(key,retlist) + + def getMetadataRaw(self,key): + if self.isValidMetaEntry(key) and self.metadata.has_key(key): + return self.metadata[key] + + def getMetadata(self, key, + removeallentities=False, + doreplacements=True): + value = None + if not self.isValidMetaEntry(key): + return value + + if self.isList(key): + # join_string = self.getConfig("join_string_"+key,u", ").replace(SPACE_REPLACE,' ') + # value = join_string.join(self.getList(key, removeallentities, doreplacements=True)) + value = self.join_list(key,self.getList(key, removeallentities, doreplacements=True)) + if doreplacements: + value = self.doReplacements(value,key+"_LIST") + return value + elif self.metadata.has_key(key): + value = self.metadata[key] + if value: + if key == "numWords": + value = commaGroups(value) + if key == "numChapters": + value = commaGroups("%d"%value) + if key in ("dateCreated"): + value = value.strftime(self.getConfig(key+"_format","%Y-%m-%d %H:%M:%S")) + if key in ("datePublished","dateUpdated"): + value = value.strftime(self.getConfig(key+"_format","%Y-%m-%d")) + + if doreplacements: + value=self.doReplacements(value,key) + if removeallentities and value != None: + return removeAllEntities(value) + else: + return value + else: #if self.getConfig("default_value_"+key): + return self.getConfig("default_value_"+key) + + def getAllMetadata(self, + removeallentities=False, + doreplacements=True, + keeplists=False): + ''' + All single value *and* list value metadata as strings (unless + keeplists=True, then keep lists). + ''' + allmetadata = {} + + # special handling for authors/authorUrls + linkhtml="%s" + if self.isList('author'): # more than one author, assume multiple authorUrl too. + htmllist=[] + for i, v in enumerate(self.getList('author')): + aurl = self.getList('authorUrl')[i] + auth = v + # make sure doreplacements & removeallentities are honored. + if doreplacements: + aurl=self.doReplacements(aurl,'authorUrl') + auth=self.doReplacements(auth,'author') + if removeallentities: + aurl=removeAllEntities(aurl) + auth=removeAllEntities(auth) + + htmllist.append(linkhtml%('author',aurl,auth)) + # join_string = self.getConfig("join_string_authorHTML",u", ").replace(SPACE_REPLACE,' ') + self.setMetadata('authorHTML',self.join_list("join_string_authorHTML",htmllist)) + else: + self.setMetadata('authorHTML',linkhtml%('author',self.getMetadata('authorUrl', removeallentities, doreplacements), + self.getMetadata('author', removeallentities, doreplacements))) + + if self.getMetadataRaw('seriesUrl'): + self.setMetadata('seriesHTML',linkhtml%('series',self.getMetadata('seriesUrl', removeallentities, doreplacements), + self.getMetadata('series', removeallentities, doreplacements))) + elif self.getMetadataRaw('series'): + self.setMetadata('seriesHTML',self.getMetadataRaw('series')) + + # logger.debug("make_linkhtml_entries:%s"%self.getConfig('make_linkhtml_entries')) + for k in self.getConfigList('make_linkhtml_entries'): + # Assuming list, because it has to be site specific and + # they are all lists. Bail if kUrl list not the same + # length. + # logger.debug("\nk:%s\nlist:%s\nlistURL:%s"%(k,self.getList(k),self.getList(k+'Url'))) + if len(self.getList(k+'Url')) != len(self.getList(k)): + continue + htmllist=[] + for i, v in enumerate(self.getList(k)): + url = self.getList(k+'Url')[i] + # make sure doreplacements & removeallentities are honored. + if doreplacements: + url=self.doReplacements(url,k+'Url') + v=self.doReplacements(v,k) + if removeallentities: + url=removeAllEntities(url) + v=removeAllEntities(v) + + htmllist.append(linkhtml%(k,url,v)) + # join_string = self.getConfig("join_string_"+k+"HTML",u", ").replace(SPACE_REPLACE,' ') + self.setMetadata(k+'HTML',self.join_list("join_string_"+k+"HTML",htmllist)) + + for k in self.getValidMetaList(): + if self.isList(k) and keeplists: + allmetadata[k] = self.getList(k, removeallentities, doreplacements) + else: + allmetadata[k] = self.getMetadata(k, removeallentities, doreplacements) + + return allmetadata + + # just for less clutter in adapters. + def extendList(self,listname,l): + for v in l: + self.addToList(listname,v.strip()) + + def addToList(self,listname,value,condremoveentities=True,clear=False): + if value==None: + return + if condremoveentities: + value = conditionalRemoveEntities(value) + if clear or not self.isList(listname) or not listname in self.metadata: + # Calling addToList to a non-list meta will overwrite it. + self.metadata[listname]=[] + # prevent duplicates. + if not value in self.metadata[listname]: + self.metadata[listname].append(value) + + if listname == 'category' and self.getConfig('add_genre_when_multi_category') and len(self.metadata[listname]) > 1: + self.addToList('genre',self.getConfig('add_genre_when_multi_category')) + + def isList(self,listname): + 'Everything set with an include_in_* is considered a list.' + return self.isListType(listname) or \ + ( self.isValidMetaEntry(listname) and self.metadata.has_key(listname) \ + and isinstance(self.metadata[listname],list) ) + + def getList(self,listname, + removeallentities=False, + doreplacements=True, + includelist=[]): + #print("getList(%s,%s)"%(listname,includelist)) + retlist = [] + + if not self.isValidMetaEntry(listname): + return retlist + + # includelist prevents infinite recursion of include_in_'s + if self.hasConfig("include_in_"+listname) and listname not in includelist: + for k in self.getConfigList("include_in_"+listname): + retlist.extend(self.getList(k,removeallentities=False, + doreplacements=doreplacements,includelist=includelist+[listname])) + else: + + if not self.isList(listname): + retlist = [self.getMetadata(listname,removeallentities=False, + doreplacements=doreplacements)] + else: + retlist = self.getMetadataRaw(listname) + + if retlist: + if doreplacements: + newretlist = [] + for val in retlist: + newretlist.extend(self.doReplacements(val,listname,return_list=True)) + retlist = newretlist + + if removeallentities: + retlist = map(removeAllEntities,retlist) + + retlist = filter( lambda x : x!=None and x!='' ,retlist) + + # reorder ships so b/a and c/b/a become a/b and a/b/c. Only on '/', + # use replace_metadata to change separator first if needed. + # ships=>[ ]*(/|&|&)[ ]*=>/ + if listname == 'ships' and self.getConfig('sort_ships') and retlist: + retlist = [ '/'.join(sorted(x.split('/'))) for x in retlist ] + + if retlist: + if listname in ('author','authorUrl','authorId') or self.getConfig('keep_in_order_'+listname): + # need to retain order for author & authorUrl so the + # two match up. + return retlist + else: + # remove dups and sort. + return sorted(list(set(retlist))) + else: + return [] + + def getSubjectTags(self, removeallentities=False): + # set to avoid duplicates subject tags. + subjectset = set() + + tags_list = self.getConfigList("include_subject_tags") + self.getConfigList("extra_subject_tags") + + # metadata all go into dc:subject tags, but only if they are configured. + for (name,value) in self.getAllMetadata(removeallentities=removeallentities,keeplists=True).iteritems(): + if name in tags_list: + if isinstance(value,list): + for tag in value: + subjectset.add(tag) + else: + subjectset.add(value) + + if None in subjectset: + subjectset.remove(None) + if '' in subjectset: + subjectset.remove('') + + return list(subjectset | set(self.getConfigList("extratags"))) + + def addChapter(self, url, title, html): + if self.getConfig('strip_chapter_numbers') and \ + self.getConfig('chapter_title_strip_pattern'): + title = re.sub(self.getConfig('chapter_title_strip_pattern'),"",title) + self.chapters.append( (url,title,html) ) + + def getChapters(self,fortoc=False): + "Chapters will be tuples of (title,html)" + retval = [] + ## only add numbers if more than one chapter. + if len(self.chapters) > 1 and \ + (self.getConfig('add_chapter_numbers') == "true" \ + or (self.getConfig('add_chapter_numbers') == "toconly" and fortoc)) \ + and self.getConfig('chapter_title_add_pattern'): + for index, (url,title,html) in enumerate(self.chapters): + retval.append( (url, + string.Template(self.getConfig('chapter_title_add_pattern')).substitute({'index':index+1,'title':title}), + html) ) + else: + retval = self.chapters + + return retval + + def formatFileName(self,template,allowunsafefilename=True): + values = origvalues = self.getAllMetadata() + # fall back default: + if not template: + template="${title}-${siteabbrev}_${storyId}${formatext}" + + if not allowunsafefilename: + values={} + pattern = re.compile(self.getConfig("output_filename_safepattern",r"[^a-zA-Z0-9_\. \[\]\(\)&'-]+")) + for k in origvalues.keys(): + values[k]=re.sub(pattern,'_', removeAllEntities(self.getMetadata(k))) + + return string.Template(template).substitute(values).encode('utf8') + + # pass fetch in from adapter in case we need the cookies collected + # as well as it's a base_story class method. + def addImgUrl(self,parenturl,url,fetch,cover=False,coverexclusion=None): + + # otherwise it saves the image in the epub even though it + # isn't used anywhere. + if cover and self.getConfig('never_make_cover'): + return + + url = url.strip() # ran across an image with a space in the + # src. Browser handled it, so we'd better, too. + + # appengine (web version) isn't allowed to do images--just + # gets too big too fast and breaks things. + if is_appengine: + return + + if url.startswith("http") or url.startswith("file") or parenturl == None: + imgurl = url + else: + parsedUrl = urlparse.urlparse(parenturl) + if url.startswith("//") : + imgurl = urlparse.urlunparse( + (parsedUrl.scheme, + '', + url, + '','','')) + elif url.startswith("/") : + imgurl = urlparse.urlunparse( + (parsedUrl.scheme, + parsedUrl.netloc, + url, + '','','')) + else: + toppath="" + if parsedUrl.path.endswith("/"): + toppath = parsedUrl.path + else: + toppath = parsedUrl.path[:parsedUrl.path.rindex('/')] + imgurl = urlparse.urlunparse( + (parsedUrl.scheme, + parsedUrl.netloc, + toppath + '/' + url, + '','','')) + #print("\n===========\nparsedUrl.path:%s\ntoppath:%s\nimgurl:%s\n\n"%(parsedUrl.path,toppath,imgurl)) + + # apply coverexclusion to explicit covers, too. Primarily for ffnet imageu. + if cover and coverexclusion and re.search(coverexclusion,imgurl): + return + + prefix='ffdl' + if imgurl not in self.imgurls: + parsedUrl = urlparse.urlparse(imgurl) + + try: + if self.getConfig('no_image_processing'): + (data,ext,mime) = no_convert_image(imgurl, + fetch(imgurl)) + else: + try: + sizes = [ int(x) for x in self.getConfigList('image_max_size') ] + except Exception, e: + raise exceptions.FailedToDownload("Failed to parse image_max_size from personal.ini:%s\nException: %s"%(self.getConfigList('image_max_size'),e)) + grayscale = self.getConfig('grayscale_images') + imgtype = self.getConfig('convert_images_to') + if not imgtype: + imgtype = "jpg" + removetrans = self.getConfig('remove_transparency') + removetrans = removetrans or grayscale or imgtype=="jpg" + (data,ext,mime) = convert_image(imgurl, + fetch(imgurl), + sizes, + grayscale, + removetrans, + imgtype, + background="#"+self.getConfig('background_color')) + except Exception, e: + logger.info("Failed to load or convert image, skipping:\n%s\nException: %s"%(imgurl,e)) + return "failedtoload" + + # explicit cover, make the first image. + if cover: + if len(self.imgtuples) > 0 and 'cover' in self.imgtuples[0]['newsrc']: + # remove existing cover, if there is one. + del self.imgurls[0] + del self.imgtuples[0] + self.imgurls.insert(0,imgurl) + newsrc = "images/cover.%s"%ext + self.cover=newsrc + self.imgtuples.insert(0,{'newsrc':newsrc,'mime':mime,'data':data}) + else: + self.imgurls.append(imgurl) + # First image, copy not link because calibre will replace with it's cover. + # Only if: No cover already AND + # make_firstimage_cover AND + # NOT never_make_cover AND + # either no coverexclusion OR coverexclusion doesn't match + if self.cover == None and \ + self.getConfig('make_firstimage_cover') and \ + not self.getConfig('never_make_cover') and \ + not (coverexclusion and re.search(coverexclusion,imgurl)): + newsrc = "images/cover.%s"%ext + self.cover=newsrc + self.imgtuples.append({'newsrc':newsrc,'mime':mime,'data':data}) + self.imgurls.append(imgurl) + + newsrc = "images/%s-%s.%s"%( + prefix, + self.imgurls.index(imgurl), + ext) + self.imgtuples.append({'newsrc':newsrc,'mime':mime,'data':data}) + + #logger.debug("\nimgurl:%s\nnewsrc:%s\nimage size:%d\n"%(imgurl,newsrc,len(data))) + else: + newsrc = self.imgtuples[self.imgurls.index(imgurl)]['newsrc'] + + #print("===============\n%s\nimg url:%s\n============"%(newsrc,self.imgurls[-1])) + + return newsrc + + def getImgUrls(self): + retlist = [] + for i, url in enumerate(self.imgurls): + #parsedUrl = urlparse.urlparse(url) + retlist.append(self.imgtuples[i]) + return retlist + + def __str__(self): + return "Metadata: " +str(self.metadata) + +def commaGroups(s): + groups = [] + while s and s[-1].isdigit(): + groups.append(s[-3:]) + s = s[:-3] + return s + ','.join(reversed(groups)) + diff --git a/fanficdownloader/translit.py b/fanficdownloader/translit.py new file mode 100644 index 00000000..bf205a6d --- /dev/null +++ b/fanficdownloader/translit.py @@ -0,0 +1,57 @@ +#-*-coding:utf-8-*- +# Code taken from http://python.su/forum/viewtopic.php?pid=66946 +import unicodedata +def is_syllable(letter): + syllables = ("A", "E", "I", "O", "U", "a", "e", "i", "o", "u") + if letter in syllables: + return True + return False +def is_consonant(letter): + return not is_syllable(letter) +def romanize(letter): + try: + str(letter) + except UnicodeEncodeError: + pass + else: + return str(letter) + unid = unicodedata.name(letter) + exceptions = {"NUMERO SIGN": "No", "LEFT-POINTING DOUBLE ANGLE QUOTATION MARK": "\"", "RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK": "\"", "DASH": "-"} + for name_contains in exceptions: + if unid.find(name_contains)!=-1: + return exceptions[name_contains] + assert(unid.startswith("CYRILLIC"))# Not ready to romanize anything but cyrillics + transformation_pairs = {"CYRILLIC CAPITAL LETTER ": str.capitalize, "CYRILLIC SMALL LETTER ": str.lower} + func = str.lower + for name_contains in transformation_pairs: + if unid.find(name_contains)!=-1: + func = transformation_pairs[name_contains] + unid = unid.replace(name_contains, "") + cyrillic_exceptions = {"YERU": "y", "SHORT I": "y", "HARD SIGN": "\'", "SOFT SIGN": "\'", "BYELORUSSIAN-UKRAINIAN I": "i", "GHE WITH UPTURN": "g", "UKRAINIAN IE": "ie", "YU": "yu", "YA": "ya"} + for name_contains in cyrillic_exceptions: + if unid.find(name_contains)!=-1: + return cyrillic_exceptions[name_contains] + if all(map(is_syllable, unid)): + return func(unid) + else: + return func(filter(is_consonant, unid)) +def translit(text): + output = "" + for letter in text: + output += romanize(letter) + return output +#def main(): + #text = u"русск.: Любя, съешь щипцы, — вздохнёт мэр, — кайф жгуч." + #print translit(text) + #text = u"укр.: Гей, хлопці, не вспію - на ґанку ваша файна їжа знищується бурундучком." + #print translit(text) + #text = u"болг.: Ах, чудна българска земьо, полюшквай цъфтящи жита." + #print translit(text) + #text = u"серб.: Неуредне ноћне даме досађивале су Џеку К." + #print translit(text) + #russk.: Lyubya, s'iesh' shchiptsy, - vzdohniot mer, - kayf zhghuch. + #ukr.: Ghiey, hloptsi, nie vspiyu - na ganku vasha fayna yzha znishchuiet'sya burunduchkom. + #bolgh.: Ah, chudna b'lgharska ziem'o, polyushkvay ts'ftyashchi zhita. + #sierb.: Nieuriednie notshnie damie dosadjivalie su Dzhieku K. +if __name__=="__main__": + main() \ No newline at end of file diff --git a/fanficdownloader/writers/__init__.py b/fanficdownloader/writers/__init__.py new file mode 100644 index 00000000..7d9faf64 --- /dev/null +++ b/fanficdownloader/writers/__init__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +## This could (should?) use a dynamic loader like adapters, but for +## now, it's static, since there's so few of them. + +from ..exceptions import FailedToDownload + +from writer_html import HTMLWriter +from writer_txt import TextWriter +from writer_epub import EpubWriter +from writer_mobi import MobiWriter + +def getWriter(type,config,story): + if type == "html": + return HTMLWriter(config,story) + if type == "txt": + return TextWriter(config,story) + if type == "epub": + return EpubWriter(config,story) + if type == "mobi": + return MobiWriter(config,story) + + raise FailedToDownload("(%s) is not a supported download format."%type) diff --git a/fanficdownloader/writers/base_writer.py b/fanficdownloader/writers/base_writer.py new file mode 100644 index 00000000..c26d4095 --- /dev/null +++ b/fanficdownloader/writers/base_writer.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import re +import os.path +import datetime +import string +import StringIO +import zipfile +from zipfile import ZipFile, ZIP_DEFLATED +import logging + +from ..configurable import Configurable +from ..htmlcleanup import removeEntities, removeAllEntities, stripHTML + +logger = logging.getLogger(__name__) + +class BaseStoryWriter(Configurable): + + @staticmethod + def getFormatName(): + return 'base' + + @staticmethod + def getFormatExt(): + return '.bse' + + def __init__(self, configuration, adapter): + Configurable.__init__(self, configuration) + + self.adapter = adapter + self.story = adapter.getStoryMetadataOnly() # only cache the metadata initially. + + # fall back labels. + self.titleLabels = { + 'category':'Category', + 'genre':'Genre', + 'language':'Language', + 'status':'Status', + 'series':'Series', + 'characters':'Characters', + 'ships':'Relationships', + 'datePublished':'Published', + 'dateUpdated':'Updated', + 'dateCreated':'Packaged', + 'rating':'Rating', + 'warnings':'Warnings', + 'numChapters':'Chapters', + 'numWords':'Words', + 'site':'Site', + 'storyId':'Story ID', + 'authorId':'Author ID', + 'extratags':'Extra Tags', + 'title':'Title', + 'storyUrl':'Story URL', + 'description':'Summary', + 'author':'Author', + 'authorUrl':'Author URL', + 'formatname':'File Format', + 'formatext':'File Extension', + 'siteabbrev':'Site Abbrev', + 'version':'FFDL Version' + } + self.story.setMetadata('formatname',self.getFormatName()) + self.story.setMetadata('formatext',self.getFormatExt()) + + def getMetadata(self,key, removeallentities=False): + return stripHTML(self.story.getMetadata(key, removeallentities)) + + def getOutputFileName(self): + if self.getConfig('zip_output'): + return self.getZipFileName() + else: + return self.getBaseFileName() + + def getBaseFileName(self): + return self.story.formatFileName(self.getConfig('output_filename'),self.getConfig('allow_unsafe_filename')) + + def getZipFileName(self): + return self.story.formatFileName(self.getConfig('zip_filename'),self.getConfig('allow_unsafe_filename')) + + def _write(self, out, text): + out.write(text.encode('utf8')) + + def writeTitlePage(self, out, START, ENTRY, END, WIDE_ENTRY=None, NO_TITLE_ENTRY=None): + """ + Write the title page, but only include entries that there's + metadata for. START, ENTRY and END are expected to already by + string.Template(). START and END are expected to use the same + names as Story.metadata, but ENTRY should use label and value. + """ + if self.getConfig("include_titlepage"): + + if self.hasConfig("titlepage_start"): + START = string.Template(self.getConfig("titlepage_start")) + + if self.hasConfig("titlepage_entry"): + ENTRY = string.Template(self.getConfig("titlepage_entry")) + + if self.hasConfig("titlepage_end"): + END = string.Template(self.getConfig("titlepage_end")) + + if self.hasConfig("titlepage_wide_entry"): + WIDE_ENTRY = string.Template(self.getConfig("titlepage_wide_entry")) + + if self.hasConfig("titlepage_no_title_entry"): + NO_TITLE_ENTRY = string.Template(self.getConfig("titlepage_no_title_entry")) + + self._write(out,START.substitute(self.story.getAllMetadata())) + + if WIDE_ENTRY==None: + WIDE_ENTRY=ENTRY + + titleEntriesList = self.getConfigList("titlepage_entries") + self.getConfigList("extra_titlepage_entries") + wideTitleEntriesList = self.getConfigList("wide_titlepage_entries") + + for entry in titleEntriesList: + if self.isValidMetaEntry(entry): + if self.story.getMetadata(entry): + if entry in wideTitleEntriesList: + TEMPLATE=WIDE_ENTRY + else: + TEMPLATE=ENTRY + + if self.hasConfig(entry+"_label"): + label=self.getConfig(entry+"_label") + elif entry in self.titleLabels: + logger.debug("Using fallback label for %s_label"%entry) + label=self.titleLabels[entry] + else: + label="%s"%entry.title() + logger.debug("No known label for %s, fallback to '%s'"%(entry,label)) + + # If the label for the title entry is empty, use the + # 'no title' option if there is one. + if label == "" and NO_TITLE_ENTRY: + TEMPLATE= NO_TITLE_ENTRY + + self._write(out,TEMPLATE.substitute({'label':label, + 'id':entry, + 'value':self.story.getMetadata(entry)})) + else: + self._write(out, entry) + + self._write(out,END.substitute(self.story.getAllMetadata())) + + def writeTOCPage(self, out, START, ENTRY, END): + """ + Write the Table of Contents page. START, ENTRY and END are expected to already by + string.Template(). START and END are expected to use the same + names as Story.metadata, but ENTRY should use index and chapter. + """ + # Only do TOC if there's more than one chapter and it's configured. + if len(self.story.getChapters()) > 1 and self.getConfig("include_tocpage") and not self.metaonly : + if self.hasConfig("tocpage_start"): + START = string.Template(self.getConfig("tocpage_start")) + + if self.hasConfig("tocpage_entry"): + ENTRY = string.Template(self.getConfig("tocpage_entry")) + + if self.hasConfig("tocpage_end"): + END = string.Template(self.getConfig("tocpage_end")) + + self._write(out,START.substitute(self.story.getAllMetadata())) + + for index, (url,title,html) in enumerate(self.story.getChapters(fortoc=True)): + if html: + self._write(out,ENTRY.substitute({'chapter':title, + 'number':index+1, + 'index':"%04d"%(index+1), + 'url':url})) + + self._write(out,END.substitute(self.story.getAllMetadata())) + + # if no outstream is given, write to file. + def writeStory(self,outstream=None, metaonly=False, outfilename=None, forceOverwrite=False): + + self.metaonly = metaonly + if outfilename == None: + outfilename=self.getOutputFileName() + + self.outfilename = outfilename + + # minor cheat, tucking css into metadata. + if self.getConfig("output_css"): + self.story.setMetadata("output_css", + self.getConfig("output_css"), + condremoveentities=False) + else: + self.story.setMetadata("output_css",'') + + if not outstream: + close=True + logger.info("Save directly to file: %s" % outfilename) + if self.getConfig('make_directories'): + path="" + outputdirs = os.path.dirname(outfilename).split('/') + for dir in outputdirs: + path+=dir+"/" + if not os.path.exists(path): + os.mkdir(path) ## os.makedirs() doesn't work in 2.5.2? + + ## Check for output file date vs updated date here + if not (self.getConfig('always_overwrite') or forceOverwrite): + if os.path.exists(outfilename): + ## date() truncs off time, which files have, but sites don't report. + lastupdated=self.story.getMetadataRaw('dateUpdated').date() + fileupdated=datetime.datetime.fromtimestamp(os.stat(outfilename)[8]).date() + if fileupdated > lastupdated: + logger.warn("File(%s) Updated(%s) more recently than Story(%s) - Skipping" % (outfilename,fileupdated,lastupdated)) + return + if not metaonly: + self.story = self.adapter.getStory() # get full story + # now, just + # before writing. + # Fetch before + # opening file. + outstream = open(outfilename,"wb") + else: + close=False + logger.debug("Save to stream") + + if not metaonly: + self.story = self.adapter.getStory() # get full story now, + # just before + # writing. Okay if + # double called with + # above, it will only + # fetch once. + if self.getConfig('zip_output'): + out = StringIO.StringIO() + self.zipout = ZipFile(outstream, 'w', compression=ZIP_DEFLATED) + self.writeStoryImpl(out) + self.zipout.writestr(self.getBaseFileName(),out.getvalue()) + # declares all the files created by Windows. otherwise, when + # it runs in appengine, windows unzips the files as 000 perms. + for zf in self.zipout.filelist: + zf.create_system = 0 + self.zipout.close() + out.close() + else: + self.writeStoryImpl(outstream) + + if close: + outstream.close() + + def writeFile(self, filename, data): + logger.debug("writeFile:%s"%filename) + + if self.getConfig('zip_output'): + outputdirs = os.path.dirname(self.getBaseFileName()) + if outputdirs: + filename=outputdirs+'/'+filename + self.zipout.writestr(filename,data) + else: + outputdirs = os.path.dirname(self.outfilename) + if outputdirs: + filename=outputdirs+'/'+filename + + dir = os.path.dirname(filename) + if not os.path.exists(dir): + os.mkdir(dir) ## os.makedirs() doesn't work in 2.5.2? + + outstream = open(filename,"wb") + outstream.write(data) + outstream.close() + + def writeStoryImpl(self, out): + "Must be overriden by sub classes." + pass + diff --git a/fanficdownloader/writers/writer_epub.py b/fanficdownloader/writers/writer_epub.py new file mode 100644 index 00000000..fb405a3e --- /dev/null +++ b/fanficdownloader/writers/writer_epub.py @@ -0,0 +1,690 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +import string +import StringIO +import zipfile +from zipfile import ZipFile, ZIP_STORED, ZIP_DEFLATED +import urllib +import re + +## XML isn't as forgiving as HTML, so rather than generate as strings, +## use DOM to generate the XML files. +from xml.dom.minidom import parse, parseString, getDOMImplementation + +from base_writer import * +from ..htmlcleanup import stripHTML + +logger = logging.getLogger(__name__) + +class EpubWriter(BaseStoryWriter): + + @staticmethod + def getFormatName(): + return 'epub' + + @staticmethod + def getFormatExt(): + return '.epub' + + def __init__(self, config, story): + BaseStoryWriter.__init__(self, config, story) + + self.EPUB_CSS = string.Template('''${output_css}''') + + self.EPUB_TITLE_PAGE_START = string.Template(''' + + + +${title} by ${author} + + + +

    ${title} by ${authorHTML}

    +
    +''') + + self.EPUB_TITLE_ENTRY = string.Template(''' +${label}: ${value}
    +''') + + self.EPUB_NO_TITLE_ENTRY = string.Template(''' +${value}
    +''') + + self.EPUB_TITLE_PAGE_END = string.Template(''' +
    + + + +''') + + self.EPUB_TABLE_TITLE_PAGE_START = string.Template(''' + + + +${title} by ${author} + + + +

    ${title} by ${authorHTML}

    + +''') + + self.EPUB_TABLE_TITLE_ENTRY = string.Template(''' + +''') + + self.EPUB_TABLE_TITLE_WIDE_ENTRY = string.Template(''' + +''') + + self.EPUB_TABLE_NO_TITLE_ENTRY = string.Template(''' + +''') + + self.EPUB_TABLE_TITLE_PAGE_END = string.Template(''' +
    ${label}:${value}
    ${label}: ${value}
    ${label}${value}
    + + + +''') + + self.EPUB_TOC_PAGE_START = string.Template(''' + + + +${title} by ${author} + + + +
    +

    Table of Contents

    +''') + + self.EPUB_TOC_ENTRY = string.Template(''' +${chapter}
    +''') + + self.EPUB_TOC_PAGE_END = string.Template(''' +
    + + +''') + + self.EPUB_CHAPTER_START = string.Template(''' + + + +${chapter} + + + +

    ${chapter}

    +''') + + self.EPUB_CHAPTER_END = string.Template(''' + + +''') + + self.EPUB_LOG_PAGE_START = string.Template(''' + + + +Update Log + + + +

    Update Log

    +''') + + self.EPUB_LOG_UPDATE_START = string.Template(''' +

    +''') + + self.EPUB_LOG_ENTRY = string.Template(''' +${label}: ${value} +''') + + self.EPUB_LOG_UPDATE_END = string.Template(''' +


    +''') + + self.EPUB_LOG_PAGE_END = string.Template(''' + + +''') + + self.EPUB_LOG_PAGE_END = string.Template(''' + + +''') + + self.EPUB_COVER = string.Template(''' +Cover
    +cover +
    +''') + + def writeLogPage(self, out): + """ + Write the log page, but only include entries that there's + metadata for. START, ENTRY and END are expected to already be + string.Template(). START and END are expected to use the same + names as Story.metadata, but ENTRY should use id, label and value. + """ + if self.hasConfig("logpage_start"): + START = string.Template(self.getConfig("logpage_start")) + else: + START = self.EPUB_LOG_PAGE_START + + if self.hasConfig("logpage_end"): + END = string.Template(self.getConfig("logpage_end")) + else: + END = self.EPUB_LOG_PAGE_END + + # if there's a self.story.logfile, there's an existing log + # to add to. + if self.story.logfile: + logger.debug("existing logfile found, appending") + logger.debug("existing data:%s"%self._getLastLogData(self.story.logfile)) + replace_string = "" # "" + self._write(out,self.story.logfile.replace(replace_string,self._makeLogEntry(self._getLastLogData(self.story.logfile))+replace_string)) + else: + # otherwise, write a new one. + self._write(out,START.substitute(self.story.getAllMetadata())) + self._write(out,self._makeLogEntry()) + self._write(out,END.substitute(self.story.getAllMetadata())) + + # self parsing instead of Soup because it should be simple and not + # worth the overhead. + def _getLastLogData(self,logfile): + """ + Make a dict() of the most recent(last) log entry for each piece of metadata. + Switch rindex to index to search from top instead of bottom. + """ + values = {} + for entry in self.getConfigList("logpage_entries") + self.getConfigList("extra_logpage_entries"): + try: + # 1975-04-15 + span = ''%entry + idx = logfile.rindex(span)+len(span) + values[entry] = logfile[idx:logfile.index('',idx)] + except Exception, e: + #print("e:%s"%e) + pass + + return values + + def _makeLogEntry(self, oldvalues={}): + if self.hasConfig("logpage_update_start"): + START = string.Template(self.getConfig("logpage_update_start")) + else: + START = self.EPUB_LOG_UPDATE_START + + if self.hasConfig("logpage_entry"): + ENTRY = string.Template(self.getConfig("logpage_entry")) + else: + ENTRY = self.EPUB_LOG_ENTRY + + if self.hasConfig("logpage_update_end"): + END = string.Template(self.getConfig("logpage_update_end")) + else: + END = self.EPUB_LOG_UPDATE_END + + retval = START.substitute(self.story.getAllMetadata()) + + for entry in self.getConfigList("logpage_entries") + self.getConfigList("extra_logpage_entries"): + if self.isValidMetaEntry(entry): + val = self.story.getMetadata(entry) + if val and ( entry not in oldvalues or val != oldvalues[entry] ): + if self.hasConfig(entry+"_label"): + label=self.getConfig(entry+"_label") + elif entry in self.titleLabels: + logger.debug("Using fallback label for %s_label"%entry) + label=self.titleLabels[entry] + else: + label="%s"%entry.title() + logger.debug("No known label for %s, fallback to '%s'"%(entry,label)) + + retval = retval + ENTRY.substitute({'id':entry, + 'label':label, + 'value':val}) + else: + # could be useful for introducing extra text, but + # mostly it makes it easy to tell when you get the + # keyword wrong. + retval = retval + entry + + retval = retval + END.substitute(self.story.getAllMetadata()) + + if self.getConfig('replace_hr'): + retval = retval.replace("
    ","
    * * *
    ") + + return retval + + def writeStoryImpl(self, out): + + ## Python 2.5 ZipFile is rather more primative than later + ## versions. It can operate on a file, or on a StringIO, but + ## not on an open stream. OTOH, I suspect we would have had + ## problems with closing and opening again to change the + ## compression type anyway. + zipio = StringIO.StringIO() + + ## mimetype must be first file and uncompressed. Python 2.5 + ## ZipFile can't change compression type file-by-file, so we + ## have to close and re-open + outputepub = ZipFile(zipio, 'w', compression=ZIP_STORED) + outputepub.debug=3 + outputepub.writestr('mimetype','application/epub+zip') + outputepub.close() + + ## Re-open file for content. + outputepub = ZipFile(zipio, 'a', compression=ZIP_DEFLATED) + outputepub.debug=3 + + ## Create META-INF/container.xml file. The only thing it does is + ## point to content.opf + containerdom = getDOMImplementation().createDocument(None, "container", None) + containertop = containerdom.documentElement + containertop.setAttribute("version","1.0") + containertop.setAttribute("xmlns","urn:oasis:names:tc:opendocument:xmlns:container") + rootfiles = containerdom.createElement("rootfiles") + containertop.appendChild(rootfiles) + rootfiles.appendChild(newTag(containerdom,"rootfile",{"full-path":"content.opf", + "media-type":"application/oebps-package+xml"})) + outputepub.writestr("META-INF/container.xml",containerdom.toxml(encoding='utf-8')) + containerdom.unlink() + del containerdom + + ## Epub has two metadata files with real data. We're putting + ## them in content.opf (pointed to by META-INF/container.xml) + ## and toc.ncx (pointed to by content.opf) + + ## content.opf contains metadata, a 'manifest' list of all + ## other included files, and another 'spine' list of the items in the + ## file + + uniqueid= 'fanficdownloader-uid:%s-u%s-s%s' % ( + self.getMetadata('site'), + self.story.getList('authorId')[0], + self.getMetadata('storyId')) + + contentdom = getDOMImplementation().createDocument(None, "package", None) + package = contentdom.documentElement + package.setAttribute("version","2.0") + package.setAttribute("xmlns","http://www.idpf.org/2007/opf") + package.setAttribute("unique-identifier","fanficdownloader-uid") + metadata=newTag(contentdom,"metadata", + attrs={"xmlns:dc":"http://purl.org/dc/elements/1.1/", + "xmlns:opf":"http://www.idpf.org/2007/opf"}) + package.appendChild(metadata) + + metadata.appendChild(newTag(contentdom,"dc:identifier", + text=uniqueid, + attrs={"id":"fanficdownloader-uid"})) + + if self.getMetadata('title'): + metadata.appendChild(newTag(contentdom,"dc:title",text=self.getMetadata('title'))) + + if self.getMetadata('author'): + if self.story.isList('author'): + for auth in self.story.getList('author'): + metadata.appendChild(newTag(contentdom,"dc:creator", + attrs={"opf:role":"aut"}, + text=auth)) + else: + metadata.appendChild(newTag(contentdom,"dc:creator", + attrs={"opf:role":"aut"}, + text=self.getMetadata('author'))) + + metadata.appendChild(newTag(contentdom,"dc:contributor",text="fanficdownloader [http://fanficdownloader.googlecode.com]",attrs={"opf:role":"bkp"})) + metadata.appendChild(newTag(contentdom,"dc:rights",text="")) + if self.story.getMetadata('langcode'): + metadata.appendChild(newTag(contentdom,"dc:language",text=self.story.getMetadata('langcode'))) + else: + metadata.appendChild(newTag(contentdom,"dc:language",text='en')) + + # published, created, updated, calibre + # Leave calling self.story.getMetadataRaw directly in case date format changes. + if self.story.getMetadataRaw('datePublished'): + metadata.appendChild(newTag(contentdom,"dc:date", + attrs={"opf:event":"publication"}, + text=self.story.getMetadataRaw('datePublished').strftime("%Y-%m-%d"))) + + if self.story.getMetadataRaw('dateCreated'): + metadata.appendChild(newTag(contentdom,"dc:date", + attrs={"opf:event":"creation"}, + text=self.story.getMetadataRaw('dateCreated').strftime("%Y-%m-%d"))) + + if self.story.getMetadataRaw('dateUpdated'): + metadata.appendChild(newTag(contentdom,"dc:date", + attrs={"opf:event":"modification"}, + text=self.story.getMetadataRaw('dateUpdated').strftime("%Y-%m-%d"))) + metadata.appendChild(newTag(contentdom,"meta", + attrs={"name":"calibre:timestamp", + "content":self.story.getMetadataRaw('dateUpdated').strftime("%Y-%m-%dT%H:%M:%S")})) + + if self.getMetadata('description'): + metadata.appendChild(newTag(contentdom,"dc:description",text= + self.getMetadata('description'))) + + for subject in self.story.getSubjectTags(): + metadata.appendChild(newTag(contentdom,"dc:subject",text=subject)) + + + if self.getMetadata('site'): + metadata.appendChild(newTag(contentdom,"dc:publisher", + text=self.getMetadata('site'))) + + if self.getMetadata('storyUrl'): + metadata.appendChild(newTag(contentdom,"dc:identifier", + attrs={"opf:scheme":"URL"}, + text=self.getMetadata('storyUrl'))) + metadata.appendChild(newTag(contentdom,"dc:source", + text=self.getMetadata('storyUrl'))) + + ## end of metadata, create manifest. + items = [] # list of (id, href, type, title) tuples(all strings) + itemrefs = [] # list of strings -- idrefs from .opfs' spines + items.append(("ncx","toc.ncx","application/x-dtbncx+xml",None)) ## we'll generate the toc.ncx file, + ## but it needs to be in the items manifest. + + guide = None + coverIO = None + + coverimgid = "image0000" + if not self.story.cover and self.story.oldcover: + logger.debug("writer_epub: no new cover, has old cover, write image.") + (oldcoverhtmlhref, + oldcoverhtmltype, + oldcoverhtmldata, + oldcoverimghref, + oldcoverimgtype, + oldcoverimgdata) = self.story.oldcover + outputepub.writestr(oldcoverhtmlhref,oldcoverhtmldata) + outputepub.writestr(oldcoverimghref,oldcoverimgdata) + + coverimgid = "image0" + items.append((coverimgid, + oldcoverimghref, + oldcoverimgtype, + None)) + items.append(("cover",oldcoverhtmlhref,oldcoverhtmltype,None)) + itemrefs.append("cover") + metadata.appendChild(newTag(contentdom,"meta",{"content":"image0", + "name":"cover"})) + guide = newTag(contentdom,"guide") + guide.appendChild(newTag(contentdom,"reference",attrs={"type":"cover", + "title":"Cover", + "href":oldcoverhtmlhref})) + + + + if self.getConfig('include_images'): + imgcount=0 + for imgmap in self.story.getImgUrls(): + imgfile = "OEBPS/"+imgmap['newsrc'] + outputepub.writestr(imgfile,imgmap['data']) + items.append(("image%04d"%imgcount, + imgfile, + imgmap['mime'], + None)) + imgcount+=1 + if 'cover' in imgfile: + # make sure coverimgid is set to the cover, not + # just the first image. + coverimgid = items[-1][0] + + + items.append(("style","OEBPS/stylesheet.css","text/css",None)) + + if self.story.cover: + # Note that the id of the cover xhmtl *must* be 'cover' + # for it to work on Nook. + items.append(("cover","OEBPS/cover.xhtml","application/xhtml+xml",None)) + itemrefs.append("cover") + # + # + metadata.appendChild(newTag(contentdom,"meta",{"content":coverimgid, + "name":"cover"})) + # cover stuff for later: + # at end of : + # + # + # + guide = newTag(contentdom,"guide") + guide.appendChild(newTag(contentdom,"reference",attrs={"type":"cover", + "title":"Cover", + "href":"OEBPS/cover.xhtml"})) + + if self.hasConfig("cover_content"): + COVER = string.Template(self.getConfig("cover_content")) + else: + COVER = self.EPUB_COVER + coverIO = StringIO.StringIO() + coverIO.write(COVER.substitute(dict(self.story.getAllMetadata().items()+{'coverimg':self.story.cover}.items()))) + + if self.getConfig("include_titlepage"): + items.append(("title_page","OEBPS/title_page.xhtml","application/xhtml+xml","Title Page")) + itemrefs.append("title_page") + if len(self.story.getChapters()) > 1 and self.getConfig("include_tocpage") and not self.metaonly : + items.append(("toc_page","OEBPS/toc_page.xhtml","application/xhtml+xml","Table of Contents")) + itemrefs.append("toc_page") + + dologpage = ( self.getConfig("include_logpage") == "smart" and \ + (self.story.logfile or self.story.getMetadataRaw("status") == "In-Progress") ) \ + or self.getConfig("include_logpage") == "true" + + if dologpage: + items.append(("log_page","OEBPS/log_page.xhtml","application/xhtml+xml","Update Log")) + itemrefs.append("log_page") + + for index, (url,title,html) in enumerate(self.story.getChapters(fortoc=True)): + if html: + i=index+1 + items.append(("file%04d"%i, + "OEBPS/file%04d.xhtml"%i, + "application/xhtml+xml", + title)) + itemrefs.append("file%04d"%i) + + manifest = contentdom.createElement("manifest") + package.appendChild(manifest) + for item in items: + (id,href,type,title)=item + manifest.appendChild(newTag(contentdom,"item", + attrs={'id':id, + 'href':href, + 'media-type':type})) + + spine = newTag(contentdom,"spine",attrs={"toc":"ncx"}) + package.appendChild(spine) + for itemref in itemrefs: + spine.appendChild(newTag(contentdom,"itemref", + attrs={"idref":itemref, + "linear":"yes"})) + # guide only exists if there's a cover. + if guide: + package.appendChild(guide) + + # write content.opf to zip. + contentxml = contentdom.toxml(encoding='utf-8') + + # tweak for brain damaged Nook STR. Nook insists on name before content. + contentxml = contentxml.replace(''%coverimgid, + ''%coverimgid) + outputepub.writestr("content.opf",contentxml) + + contentdom.unlink() + del contentdom + + ## create toc.ncx file + tocncxdom = getDOMImplementation().createDocument(None, "ncx", None) + ncx = tocncxdom.documentElement + ncx.setAttribute("version","2005-1") + ncx.setAttribute("xmlns","http://www.daisy.org/z3986/2005/ncx/") + head = tocncxdom.createElement("head") + ncx.appendChild(head) + head.appendChild(newTag(tocncxdom,"meta", + attrs={"name":"dtb:uid", "content":uniqueid})) + head.appendChild(newTag(tocncxdom,"meta", + attrs={"name":"dtb:depth", "content":"1"})) + head.appendChild(newTag(tocncxdom,"meta", + attrs={"name":"dtb:totalPageCount", "content":"0"})) + head.appendChild(newTag(tocncxdom,"meta", + attrs={"name":"dtb:maxPageNumber", "content":"0"})) + + docTitle = tocncxdom.createElement("docTitle") + docTitle.appendChild(newTag(tocncxdom,"text",text=self.getMetadata('title'))) + ncx.appendChild(docTitle) + + tocnavMap = tocncxdom.createElement("navMap") + ncx.appendChild(tocnavMap) + + # + # + # + # + # + # + index=0 + for item in items: + (id,href,type,title)=item + # only items to be skipped, cover.xhtml, images, toc.ncx, stylesheet.css, should have no title. + if title : + navPoint = newTag(tocncxdom,"navPoint", + attrs={'id':id, + 'playOrder':str(index)}) + tocnavMap.appendChild(navPoint) + navLabel = newTag(tocncxdom,"navLabel") + navPoint.appendChild(navLabel) + ## the xml library will re-escape as needed. + navLabel.appendChild(newTag(tocncxdom,"text",text=stripHTML(title))) + navPoint.appendChild(newTag(tocncxdom,"content",attrs={"src":href})) + index=index+1 + + # write toc.ncx to zip file + outputepub.writestr("toc.ncx",tocncxdom.toxml(encoding='utf-8')) + tocncxdom.unlink() + del tocncxdom + + # write stylesheet.css file. + outputepub.writestr("OEBPS/stylesheet.css",self.EPUB_CSS.substitute(self.story.getAllMetadata())) + + # write title page. + if self.getConfig("titlepage_use_table"): + TITLE_PAGE_START = self.EPUB_TABLE_TITLE_PAGE_START + TITLE_ENTRY = self.EPUB_TABLE_TITLE_ENTRY + WIDE_TITLE_ENTRY = self.EPUB_TABLE_TITLE_WIDE_ENTRY + NO_TITLE_ENTRY = self.EPUB_TABLE_NO_TITLE_ENTRY + TITLE_PAGE_END = self.EPUB_TABLE_TITLE_PAGE_END + else: + TITLE_PAGE_START = self.EPUB_TITLE_PAGE_START + TITLE_ENTRY = self.EPUB_TITLE_ENTRY + WIDE_TITLE_ENTRY = self.EPUB_TITLE_ENTRY # same, only wide in tables. + NO_TITLE_ENTRY = self.EPUB_NO_TITLE_ENTRY + TITLE_PAGE_END = self.EPUB_TITLE_PAGE_END + + if coverIO: + outputepub.writestr("OEBPS/cover.xhtml",coverIO.getvalue()) + coverIO.close() + + titlepageIO = StringIO.StringIO() + self.writeTitlePage(out=titlepageIO, + START=TITLE_PAGE_START, + ENTRY=TITLE_ENTRY, + WIDE_ENTRY=WIDE_TITLE_ENTRY, + END=TITLE_PAGE_END, + NO_TITLE_ENTRY=NO_TITLE_ENTRY) + if titlepageIO.getvalue(): # will be false if no title page. + outputepub.writestr("OEBPS/title_page.xhtml",titlepageIO.getvalue()) + titlepageIO.close() + + # write toc page. + tocpageIO = StringIO.StringIO() + self.writeTOCPage(tocpageIO, + self.EPUB_TOC_PAGE_START, + self.EPUB_TOC_ENTRY, + self.EPUB_TOC_PAGE_END) + if tocpageIO.getvalue(): # will be false if no toc page. + outputepub.writestr("OEBPS/toc_page.xhtml",tocpageIO.getvalue()) + tocpageIO.close() + + if dologpage: + # write log page. + logpageIO = StringIO.StringIO() + self.writeLogPage(logpageIO) + outputepub.writestr("OEBPS/log_page.xhtml",logpageIO.getvalue()) + logpageIO.close() + + if self.hasConfig('chapter_start'): + CHAPTER_START = string.Template(self.getConfig("chapter_start")) + else: + CHAPTER_START = self.EPUB_CHAPTER_START + + if self.hasConfig('chapter_end'): + CHAPTER_END = string.Template(self.getConfig("chapter_end")) + else: + CHAPTER_END = self.EPUB_CHAPTER_END + + for index, (url,title,html) in enumerate(self.story.getChapters()): + if html: + logger.debug('Writing chapter text for: %s' % title) + vals={'url':url, 'chapter':title, 'index':"%04d"%(index+1), 'number':index+1} + fullhtml = CHAPTER_START.substitute(vals) + html + CHAPTER_END.substitute(vals) + # ffnet(& maybe others) gives the whole chapter text + # as one line. This causes problems for nook(at + # least) when the chapter size starts getting big + # (200k+) + #fullhtml = fullhtml.replace('

    ','

    \n').replace('
    ','
    \n') + # The replaces above added tons of extra newlines + # during *each* epub update. The regexp version adds + # only one and removes any extra. + fullhtml = re.sub(r'(

    |
    )\n*',r'\1\n',fullhtml) + + outputepub.writestr("OEBPS/file%04d.xhtml"%(index+1),fullhtml.encode('utf-8')) + del fullhtml + + if self.story.calibrebookmark: + outputepub.writestr("META-INF/calibre_bookmarks.txt",self.story.calibrebookmark) + + # declares all the files created by Windows. otherwise, when + # it runs in appengine, windows unzips the files as 000 perms. + for zf in outputepub.filelist: + zf.create_system = 0 + outputepub.close() + out.write(zipio.getvalue()) + zipio.close() + +## Utility method for creating new tags. +def newTag(dom,name,attrs=None,text=None): + tag = dom.createElement(name) + if( attrs is not None ): + for attr in attrs.keys(): + tag.setAttribute(attr,attrs[attr]) + if( text is not None ): + tag.appendChild(dom.createTextNode(text)) + return tag + diff --git a/fanficdownloader/writers/writer_html.py b/fanficdownloader/writers/writer_html.py new file mode 100644 index 00000000..41b01754 --- /dev/null +++ b/fanficdownloader/writers/writer_html.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +import string + +from base_writer import * + +class HTMLWriter(BaseStoryWriter): + + @staticmethod + def getFormatName(): + return 'html' + + @staticmethod + def getFormatExt(): + return '.html' + + def __init__(self, config, story): + BaseStoryWriter.__init__(self, config, story) + + self.HTML_FILE_START = string.Template(''' + + + +${title} by ${author} + + + +

    ${title} by ${authorHTML}

    +''') + + self.HTML_COVER = string.Template(''' +cover +''') + + self.HTML_TITLE_PAGE_START = string.Template(''' + +''') + + self.HTML_TITLE_ENTRY = string.Template(''' + +''') + + self.HTML_TITLE_PAGE_END = string.Template(''' +
    ${label}:${value}
    +''') + + self.HTML_TOC_PAGE_START = string.Template(''' +

    Table of Contents

    +

    +''') + + self.HTML_TOC_ENTRY = string.Template(''' +${chapter}
    +''') + + self.HTML_TOC_PAGE_END = string.Template(''' +

    +''') + + self.HTML_CHAPTER_START = string.Template(''' +

    ${chapter}

    +''') + + self.HTML_CHAPTER_END = string.Template('') + + self.HTML_FILE_END = string.Template(''' + +''') + + + def writeStoryImpl(self, out): + + if self.hasConfig("cover_content"): + COVER = string.Template(self.getConfig("cover_content")) + else: + COVER = self.HTML_COVER + + if self.hasConfig('file_start'): + FILE_START = string.Template(self.getConfig("file_start")) + else: + FILE_START = self.HTML_FILE_START + + if self.hasConfig('file_end'): + FILE_END = string.Template(self.getConfig("file_end")) + else: + FILE_END = self.HTML_FILE_END + + self._write(out,FILE_START.substitute(self.story.getAllMetadata())) + + if self.getConfig('include_images') and self.story.cover: + self._write(out,COVER.substitute(dict(self.story.getAllMetadata().items()+{'coverimg':self.story.cover}.items()))) + + self.writeTitlePage(out, + self.HTML_TITLE_PAGE_START, + self.HTML_TITLE_ENTRY, + self.HTML_TITLE_PAGE_END) + + self.writeTOCPage(out, + self.HTML_TOC_PAGE_START, + self.HTML_TOC_ENTRY, + self.HTML_TOC_PAGE_END) + + if self.hasConfig('chapter_start'): + CHAPTER_START = string.Template(self.getConfig("chapter_start")) + else: + CHAPTER_START = self.HTML_CHAPTER_START + + if self.hasConfig('chapter_end'): + CHAPTER_END = string.Template(self.getConfig("chapter_end")) + else: + CHAPTER_END = self.HTML_CHAPTER_END + + for index, (url,title,html) in enumerate(self.story.getChapters()): + if html: + logging.debug('Writing chapter text for: %s' % title) + vals={'url':url, 'chapter':title, 'index':"%04d"%(index+1), 'number':index+1} + self._write(out,CHAPTER_START.substitute(vals)) + self._write(out,html) + self._write(out,CHAPTER_END.substitute(vals)) + + self._write(out,FILE_END.substitute(self.story.getAllMetadata())) + + if self.getConfig('include_images'): + for imgmap in self.story.getImgUrls(): + self.writeFile(imgmap['newsrc'],imgmap['data']) + diff --git a/fanficdownloader/writers/writer_mobi.py b/fanficdownloader/writers/writer_mobi.py new file mode 100644 index 00000000..4396ab08 --- /dev/null +++ b/fanficdownloader/writers/writer_mobi.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +import string +import StringIO + +from base_writer import * +from ..htmlcleanup import stripHTML +from ..mobi import Converter +from ..exceptions import FailedToWriteOutput + +logger = logging.getLogger(__name__) + +class MobiWriter(BaseStoryWriter): + + @staticmethod + def getFormatName(): + return 'mobi' + + @staticmethod + def getFormatExt(): + return '.mobi' + + def __init__(self, config, story): + BaseStoryWriter.__init__(self, config, story) + + self.MOBI_TITLE_PAGE_START = string.Template(''' + + + +${title} by ${author} + + +

    ${title} by ${authorHTML}

    +
    +''') + + self.MOBI_TITLE_ENTRY = string.Template(''' +${label}: ${value}
    +''') + + self.MOBI_NO_TITLE_ENTRY = string.Template(''' +${value}
    +''') + + self.MOBI_TITLE_PAGE_END = string.Template(''' +
    + + + +''') + + self.MOBI_TABLE_TITLE_PAGE_START = string.Template(''' + + + +${title} by ${author} + + +

    ${title} by ${authorHTML}

    + +''') + + self.MOBI_TABLE_TITLE_ENTRY = string.Template(''' + +''') + + self.MOBI_TABLE_TITLE_WIDE_ENTRY = string.Template(''' + +''') + + self.MOBI_TABLE_NO_TITLE_WIDE_ENTRY = string.Template(''' + +''') + + self.MOBI_TABLE_TITLE_PAGE_END = string.Template(''' +
    ${label}:${value}
    ${label}: ${value}
    ${value}
    + + + +''') + + self.MOBI_CHAPTER_START = string.Template(''' + + + +${chapter} + + +

    ${chapter}

    +''') + + self.MOBI_CHAPTER_END = string.Template(''' + + +''') + + def writeStoryImpl(self, out): + + files = [] + + # write title page. + if self.getConfig("titlepage_use_table"): + TITLE_PAGE_START = self.MOBI_TABLE_TITLE_PAGE_START + TITLE_ENTRY = self.MOBI_TABLE_TITLE_ENTRY + WIDE_TITLE_ENTRY = self.MOBI_TABLE_TITLE_WIDE_ENTRY + NO_TITLE_ENTRY = self.MOBI_TABLE_NO_TITLE_ENTRY + TITLE_PAGE_END = self.MOBI_TABLE_TITLE_PAGE_END + else: + TITLE_PAGE_START = self.MOBI_TITLE_PAGE_START + TITLE_ENTRY = self.MOBI_TITLE_ENTRY + WIDE_TITLE_ENTRY = self.MOBI_TITLE_ENTRY # same, only wide in tables. + NO_TITLE_ENTRY = self.MOBI_NO_TITLE_ENTRY + TITLE_PAGE_END = self.MOBI_TITLE_PAGE_END + + titlepageIO = StringIO.StringIO() + self.writeTitlePage(out=titlepageIO, + START=TITLE_PAGE_START, + ENTRY=TITLE_ENTRY, + WIDE_ENTRY=WIDE_TITLE_ENTRY, + END=TITLE_PAGE_END, + NO_TITLE_ENTRY=NO_TITLE_ENTRY) + if titlepageIO.getvalue(): # will be false if no title page. + files.append(titlepageIO.getvalue()) + titlepageIO.close() + + ## MOBI always has a TOC injected by mobi.py because there's + ## no meta-data TOC. + # # write toc page. + # tocpageIO = StringIO.StringIO() + # self.writeTOCPage(tocpageIO, + # self.MOBI_TOC_PAGE_START, + # self.MOBI_TOC_ENTRY, + # self.MOBI_TOC_PAGE_END) + # if tocpageIO.getvalue(): # will be false if no toc page. + # files.append(tocpageIO.getvalue()) + # tocpageIO.close() + + if self.hasConfig('chapter_start'): + CHAPTER_START = string.Template(self.getConfig("chapter_start")) + else: + CHAPTER_START = self.MOBI_CHAPTER_START + + if self.hasConfig('chapter_end'): + CHAPTER_END = string.Template(self.getConfig("chapter_end")) + else: + CHAPTER_END = self.MOBI_CHAPTER_END + + for index, (url,title,html) in enumerate(self.story.getChapters()): + if html: + logger.debug('Writing chapter text for: %s' % title) + vals={'url':url, 'chapter':title, 'index':"%04d"%(index+1), 'number':index+1} + fullhtml = CHAPTER_START.substitute(vals) + html + CHAPTER_END.substitute(vals) + # ffnet(& maybe others) gives the whole chapter text + # as one line. This causes problems for nook(at + # least) when the chapter size starts getting big + # (200k+) + fullhtml = fullhtml.replace('

    ','

    \n').replace('
    ','
    \n') + files.append(fullhtml.encode('utf-8')) + del fullhtml + + c = Converter(title=self.getMetadata('title'), + author=self.getMetadata('author'), + publisher=self.getMetadata('site')) + mobidata = c.ConvertStrings(files) + if len(mobidata) < 1: + raise FailedToWriteOutput("Zero length mobi output") + out.write(mobidata) + + del files + del mobidata + +## Utility method for creating new tags. +def newTag(dom,name,attrs=None,text=None): + tag = dom.createElement(name) + if( attrs is not None ): + for attr in attrs.keys(): + tag.setAttribute(attr,attrs[attr]) + if( text is not None ): + tag.appendChild(dom.createTextNode(text)) + return tag + diff --git a/fanficdownloader/writers/writer_txt.py b/fanficdownloader/writers/writer_txt.py new file mode 100644 index 00000000..8705a41f --- /dev/null +++ b/fanficdownloader/writers/writer_txt.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +import string +from textwrap import wrap + +from base_writer import * + +from ..html2text import html2text + +## In BaseStoryWriter, we define _write to encode objects +## back into for true output. But txt needs to write the +## title page and TOC to a buffer first to wordwrap. And StringIO +## gets pissy about unicode bytes in its buflist. This decodes the +## unicode containing object passed in back to a +## object so they join up properly. Could override _write to not +## encode and do out.write(whatever.encode('utf8') instead. Honestly +## not sure which is uglier. +class KludgeStringIO(): + def __init__(self, buf = ''): + self.buflist=[] + def write(self,s): + try: + s=s.decode('utf-8') + except: + pass + self.buflist.append(s) + def getvalue(self): + return u''.join(self.buflist) + def close(self): + pass + +class TextWriter(BaseStoryWriter): + + @staticmethod + def getFormatName(): + return 'txt' + + @staticmethod + def getFormatExt(): + return '.txt' + + def __init__(self, config, story): + + BaseStoryWriter.__init__(self, config, story) + + self.TEXT_FILE_START = string.Template(u''' + + +${title} + +by ${author} + + +''') + + self.TEXT_TITLE_PAGE_START = string.Template(u''' +''') + + self.TEXT_TITLE_ENTRY = string.Template(u'''${label}: ${value} +''') + + self.TEXT_TITLE_PAGE_END = string.Template(u''' + + +''') + + self.TEXT_TOC_PAGE_START = string.Template(u''' + +TABLE OF CONTENTS + +''') + + self.TEXT_TOC_ENTRY = string.Template(u''' +${chapter} +''') + + self.TEXT_TOC_PAGE_END = string.Template(u''' +''') + + self.TEXT_CHAPTER_START = string.Template(u''' + +\t${chapter} + +''') + self.TEXT_CHAPTER_END = string.Template(u'') + + self.TEXT_FILE_END = string.Template(u''' + +End file. +''') + + def writeStoryImpl(self, out): + + self.wrap_width = self.getConfig('wrap_width') + if self.wrap_width == '' or self.wrap_width == '0': + self.wrap_width = None + else: + self.wrap_width = int(self.wrap_width) + + wrapout = KludgeStringIO() + + if self.hasConfig("file_start"): + FILE_START = string.Template(self.getConfig("file_start")) + else: + FILE_START = self.TEXT_FILE_START + + if self.hasConfig("file_end"): + FILE_END = string.Template(self.getConfig("file_end")) + else: + FILE_END = self.TEXT_FILE_END + + wrapout.write(FILE_START.substitute(self.story.getAllMetadata())) + + self.writeTitlePage(wrapout, + self.TEXT_TITLE_PAGE_START, + self.TEXT_TITLE_ENTRY, + self.TEXT_TITLE_PAGE_END) + towrap = wrapout.getvalue() + + self.writeTOCPage(wrapout, + self.TEXT_TOC_PAGE_START, + self.TEXT_TOC_ENTRY, + self.TEXT_TOC_PAGE_END) + + towrap = wrapout.getvalue() + wrapout.close() + towrap = removeAllEntities(towrap) + + self._write(out,self.lineends(self.wraplines(towrap))) + + if self.hasConfig('chapter_start'): + CHAPTER_START = string.Template(self.getConfig("chapter_start")) + else: + CHAPTER_START = self.TEXT_CHAPTER_START + + if self.hasConfig('chapter_end'): + CHAPTER_END = string.Template(self.getConfig("chapter_end")) + else: + CHAPTER_END = self.TEXT_CHAPTER_END + + for index, (url, title,html) in enumerate(self.story.getChapters()): + if html: + logging.debug('Writing chapter text for: %s' % title) + vals={'url':url, 'chapter':title, 'index':"%04d"%(index+1), 'number':index+1} + self._write(out,self.lineends(self.wraplines(removeAllEntities(CHAPTER_START.substitute(vals))))) + self._write(out,self.lineends(html2text(html,wrap_width=self.wrap_width))) + self._write(out,self.lineends(self.wraplines(removeAllEntities(CHAPTER_END.substitute(vals))))) + + self._write(out,self.lineends(self.wraplines(FILE_END.substitute(self.story.getAllMetadata())))) + + def wraplines(self, text): + + if not self.wrap_width: + return text + + result='' + for para in text.split("\n"): + first=True + for line in wrap(para, self.wrap_width): + if first: + first=False + else: + result += u"\n" + result += line + result += u"\n" + return result + + ## The appengine will return unix line endings. + def lineends(self, txt): + txt = txt.replace('\r','') + if self.getConfig("windows_eol"): + txt = txt.replace('\n',u'\r\n') + return txt + diff --git a/ffstorage.py b/ffstorage.py new file mode 100644 index 00000000..bad9b4a4 --- /dev/null +++ b/ffstorage.py @@ -0,0 +1,63 @@ +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pickle, copy +from google.appengine.ext import db + +class ObjectProperty(db.Property): + data_type = db.Blob + + def get_value_for_datastore(self, model_instance): + value = self.__get__(model_instance, model_instance.__class__) + pickled_val = pickle.dumps(value,protocol=pickle.HIGHEST_PROTOCOL) + if value is not None: return db.Blob(pickled_val) + + def make_value_from_datastore(self, value): + if value is not None: return pickle.loads(value) + + def default_value(self): + return copy.copy(self.default) + +class DownloadMeta(db.Model): + user = db.UserProperty() + url = db.StringProperty() + name = db.StringProperty() + title = db.StringProperty() + author = db.StringProperty() + format = db.StringProperty() + failure = db.TextProperty() + completed = db.BooleanProperty(default=False) + date = db.DateTimeProperty(auto_now_add=True) + version = db.StringProperty() + # data_chunks is implicit from DownloadData def. + +class DownloadData(db.Model): + download = db.ReferenceProperty(DownloadMeta, + collection_name='data_chunks') + blob = db.BlobProperty() + index = db.IntegerProperty() + +class UserConfig(db.Model): + user = db.UserProperty() + config = db.BlobProperty() + +class SavedMeta(db.Model): + url = db.StringProperty() + title = db.StringProperty() + author = db.StringProperty() + date = db.DateTimeProperty(auto_now_add=True) + count = db.IntegerProperty() + meta = ObjectProperty() + diff --git a/index-ajax.html b/index-ajax.html new file mode 100644 index 00000000..62eba47c --- /dev/null +++ b/index-ajax.html @@ -0,0 +1,109 @@ + + + + + + + FanFictionDownLoader (fanfiction.net, fictionalley, ficwad to epub and HTML) + + + + + + + + + +
    +

    + FanFictionDownLoader +

    + + +
    +
    + Hi, {{ nickname }}! This is a fan fiction downloader, which makes reading stories from various websites much easier. Please paste a URL of the first chapter in the box to start. Alternatively, see your personal list of previously downloaded fanfics. +
    + +
    + Ebook format   +
    + +
    + +
    + + + +
    + + + +
    +
    + +

    + Login and Password +

    +
    + If the story requires a login and password to download (e.g. marked as Mature on FFA), you may need to provide your credentials to download it, otherwise just leave it empty +
    +
    +
    +
    Login
    +
    +
    + +
    +
    Password
    +
    +
    +
    +
    + + +
    + + +
    + +
    +
    + Few things to know, which will make your life substantially easier: +
      +
    1. Small post written by me — how to read fiction in Stanza or any other ebook reader.
    2. +
    3. Currently we support fanfiction.net, fictionpress.com, fanficauthors.net and ficwad.com
    4. +
    5. Paste a URL of the first chapter of the fanfic, not the index page
    6. +
    7. Fics with a single chapter are not supported (you can just copy and paste it)
    8. +
    9. Stories which are too long may not be downloaded correctly and application will report a time-out error — this is a limitation which is currently imposed by Google AppEngine on a long-running activities
    10. +
    11. FicWad support is somewhat flaky — if you feel it doesn't work for you, send all the details to me
    12. +
    13. You can download fanfics and store them for 'later' by just downloading them and visiting recent downloads section, but in future they will be deleted after 5 days to save the space
    14. +
    15. If Downloader simply opens a download file window rather than saves the fanfic and gives you a link, it means it is too large to save in the database and you need to download it straight away
    16. +
    17. If you think that something that should work in fact doesn't, drop me a mail to sigizmund@gmail.com
    18. +
    + Otherwise, just have fun, and if you want to say thank you — use the email above. +
    +
    + Powered by Google App Engine +

    + This is a web front-end to FanFictionDownLoader
    + Copyright © Fanficdownloader team +
    + +
    + + + + diff --git a/index.html b/index.html new file mode 100644 index 00000000..67e5e09e --- /dev/null +++ b/index.html @@ -0,0 +1,210 @@ + + + + + FanFictionDownLoader - read fanfiction from twilighted.net, fanfiction.net, fictionpress.com, fictionalley.org, ficwad.com, potionsandsnitches.net, harrypotterfanfiction.com, mediaminer.org on Kindle, Nook, Sony Reader, iPad, iPhone, Android, Aldiko, Stanza + + + + + + + + + +
    +

    + FanFictionDownLoader +

    + +
    + + +
    + + {{yourfile}} + + + {% if authorized %} +
    +
    +
    +

    Hi, {{ nickname }}! This is FanFictionDownLoader, which makes reading stories from various websites + much easier.

    +
    + +

    Changes:

    +

    + Now supporting over 100 different sites! Thanks, cryzed, for pushing us over the top. +

    +

    +

      +
    • New German language site bdsm-geschichten.net, thanks to doe5716.
    • +
    • New site tolkienfanfiction.com, thanks to doe5716.
    • +
    • Add other languages for literotica.com, thanks to doe5716.
    • +
    • Fixes for bloodshedverse & spikelover having html tags in title, etc.
    • +
    • Known issue: Password protected FimFiction.net stories aren't working. FimF changed API access.
    • +
    • Known issue: Specific metadata 'eroticatags' for literotica.com doesn't work on all stories.
    • +
    +

    +

    + Questions? Check out our + FAQs. +

    +

    + If you have any problems with this application, please + report them in + the FanFictionDownLoader Google Group. The + Previous Version is also available for you to use if necessary. +

    +
    + {{ error_message }} +
    +
    + +
    +
    URL:
    +
    +
    Ebook format
    +
    + EPub + HTML + Plain Text + Mobi(Kindle) +
    +
    +
    + +

    For most readers, including Sony Reader, Nook and iPad, use EPub.

    +
    +
    +
    +

    + Customize your User Configuration. +

    +

    + Or see your personal list of previously downloaded fanfics. +

    +

    + See a list of downloaded fanfics by all users by most popular or most recent. +

    +
    +
    + {% else %} +
    +
    +

    + This is a FanFictionDownLoader, which makes reading stories from various websites much easier. Before you + can start downloading fanfics, you need to login, so FanFictionDownLoader can remember your fanfics and store them. +

    +

    Login using Google account

    +
    +
    + {% endif %} + +
    +

    + FanFictionDownLoader calibre Plugin +

    + + There's also a version of this downloader that runs inside + the popular calibre + ebook management package as a plugin. + +

    + + Once you have calibre installed and running, inside + calibre, you can go to 'Get plugins to enhance calibre' or + 'Get new plugins' and + install FanFictionDownLoader. + +

    +
    +
    +

    Supported sites:

    +

    + There's a + Supported + Sites page in our wiki. If you have a site you'd like + to see supported, please check there first. +

    + + {% autoescape off %}{{ supported_sites }}{% endautoescape %} + +

    + A few additional things to know, which will make your life substantially easier: +

    +
      +
    1. + First thing to know: We do not use your Google login and password. In fact, all we know about it is your ID – password + is being verified by Google and is absolutely, totally unknown to anyone but you. +
    2. +
    3. + + Small post written by Roman + — how to read fiction in Stanza or any other ebook reader. +
    4. +
    5. + You can download fanfiction directly from your iPhone, Kindle or (possibly) other ebook reader. +
    6. +
    7. + Downloaded stories are deleted after some time (which should give you enough of time to download it and will keep + Google happy about the app not going over the storage limit). +
    8. +
    9. + If you see some funny characters in downloaded Plain Text file, make sure you choose text file encoding UTF-8 and + not something else. +
    10. +
    11. + If you think that something that should work in fact doesn't, post a message to + our Google Group. we also encourage you to join it so + you will find out about latest updates and fixes as soon as possible +
    12. +
    + Otherwise, just have fun, and if you want to say thank you — use the contacts above. +
    +
    + Powered by Google App Engine +

    + This is a web front-end to FanFictionDownLoader
    + Copyright © FanFictionDownLoader team +
    + +
    + + +
    +
    + + diff --git a/index.yaml b/index.yaml new file mode 100644 index 00000000..a55512f1 --- /dev/null +++ b/index.yaml @@ -0,0 +1,28 @@ +indexes: + +# notAUTOGENERATED + +# This index.yaml is automatically updated whenever the dev_appserver +# detects that a new type of query is run. If you want to manage the +# index.yaml file manually, remove the above marker line (the line +# saying "# AUTOGENERATED"). If you want to manage some indexes +# manually, move them above the marker line. The index.yaml file is +# automatically uploaded to the admin console when you next deploy +# your application using appcfg.py. + +- kind: DownloadData + properties: + - name: download + - name: index + +- kind: DownloadMeta + properties: + - name: user + - name: date + direction: desc + +- kind: SavedMeta + properties: + - name: count + - name: date + direction: desc diff --git a/js/fdownloader.js b/js/fdownloader.js new file mode 100644 index 00000000..8f6ab0a8 --- /dev/null +++ b/js/fdownloader.js @@ -0,0 +1,116 @@ +var g_CurrentKey = null; +var g_Counter = 0; + +var COUNTER_MAX = 50; + + +function setErrorState(error) +{ + olderr = error; + error = error + "
    " + "Complain about this error"; + $('#error').html(error); +} + +function clearErrorState() +{ + $('#error').html(''); +} + +function showFile(data) +{ + $('#yourfile').html('' + data.name + " by " + data.author + ""); + $('#yourfile').show(); +} + +function hideFile() +{ + $('#yourfile').hide(); +} + +function checkResults() +{ + if ( g_Counter >= COUNTER_MAX ) + { + return; + } + + g_Counter+=1; + + $.getJSON('/progress', { 'key' : g_CurrentKey }, function(data) + { + if ( data.result != "Nope") + { + if ( data.result != "OK" ) + { + leaveLoadingState(); + setErrorState(data.result); + } + else + { + showFile(data); + leaveLoadingState(); + // result = data.split("|"); + // showFile(result[1], result[2], result[3]); + } + + $("#progressbar").progressbar('destroy'); + g_Counter = 101; + } + }); + + if ( g_Counter < COUNTER_MAX ) + setTimeout("checkResults()", 1000); + else + { + leaveLoadingState(); + setErrorState("Operation takes too long - terminating by timeout (story too long?)"); + } +} + +function enterLoadingState() +{ + $('#submit_button').hide(); + $('#ajax_loader').show(); +} + +function leaveLoadingState() +{ + $('#submit_button').show(); + $('#ajax_loader').hide(); +} + +function downloadFanfic() +{ + clearErrorState(); + hideFile(); + + + format = $("#format").val(); + alert(format); + + return; + + var url = $('#url').val(); + var login = $('#login').val(); + var password = $('#password').val(); + + if ( url == '' ) + { + setErrorState('URL shouldn\'t be empty'); + return; + } + + if ( (url.indexOf('fanfiction.net') == -1 && url.indexOf('fanficauthors') == -1 && url.indexOf('ficwad') == -1 && url.indexOf('fictionpress') == -1) || (url.indexOf('adultfanfiction.net') != -1) ) + { + setErrorState("This source is not yet supported. Ping me if you want it!"); + return; + } + + $.post('/submitDownload', {'url' : url, 'login' : login, 'password' : password, 'format' : format}, function(data) + { + g_CurrentKey = data; + g_Counter = 0; + setTimeout("checkResults()", 1000); + enterLoadingState(); + }) +} \ No newline at end of file diff --git a/js/jquery-1.3.2.js b/js/jquery-1.3.2.js new file mode 100644 index 00000000..92635743 --- /dev/null +++ b/js/jquery-1.3.2.js @@ -0,0 +1,4376 @@ +/*! + * jQuery JavaScript Library v1.3.2 + * http://jquery.com/ + * + * Copyright (c) 2009 John Resig + * Dual licensed under the MIT and GPL licenses. + * http://docs.jquery.com/License + * + * Date: 2009-02-19 17:34:21 -0500 (Thu, 19 Feb 2009) + * Revision: 6246 + */ +(function(){ + +var + // Will speed up references to window, and allows munging its name. + window = this, + // Will speed up references to undefined, and allows munging its name. + undefined, + // Map over jQuery in case of overwrite + _jQuery = window.jQuery, + // Map over the $ in case of overwrite + _$ = window.$, + + jQuery = window.jQuery = window.$ = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' + return new jQuery.fn.init( selector, context ); + }, + + // A simple way to check for HTML strings or ID strings + // (both of which we optimize for) + quickExpr = /^[^<]*(<(.|\s)+>)[^>]*$|^#([\w-]+)$/, + // Is it a simple selector + isSimple = /^.[^:#\[\.,]*$/; + +jQuery.fn = jQuery.prototype = { + init: function( selector, context ) { + // Make sure that a selection was provided + selector = selector || document; + + // Handle $(DOMElement) + if ( selector.nodeType ) { + this[0] = selector; + this.length = 1; + this.context = selector; + return this; + } + // Handle HTML strings + if ( typeof selector === "string" ) { + // Are we dealing with HTML string or an ID? + var match = quickExpr.exec( selector ); + + // Verify a match, and that no context was specified for #id + if ( match && (match[1] || !context) ) { + + // HANDLE: $(html) -> $(array) + if ( match[1] ) + selector = jQuery.clean( [ match[1] ], context ); + + // HANDLE: $("#id") + else { + var elem = document.getElementById( match[3] ); + + // Handle the case where IE and Opera return items + // by name instead of ID + if ( elem && elem.id != match[3] ) + return jQuery().find( selector ); + + // Otherwise, we inject the element directly into the jQuery object + var ret = jQuery( elem || [] ); + ret.context = document; + ret.selector = selector; + return ret; + } + + // HANDLE: $(expr, [context]) + // (which is just equivalent to: $(content).find(expr) + } else + return jQuery( context ).find( selector ); + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) + return jQuery( document ).ready( selector ); + + // Make sure that old selector state is passed along + if ( selector.selector && selector.context ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return this.setArray(jQuery.isArray( selector ) ? + selector : + jQuery.makeArray(selector)); + }, + + // Start with an empty selector + selector: "", + + // The current version of jQuery being used + jquery: "1.3.2", + + // The number of elements contained in the matched element set + size: function() { + return this.length; + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + return num === undefined ? + + // Return a 'clean' array + Array.prototype.slice.call( this ) : + + // Return just the object + this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems, name, selector ) { + // Build a new jQuery matched element set + var ret = jQuery( elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + ret.context = this.context; + + if ( name === "find" ) + ret.selector = this.selector + (this.selector ? " " : "") + selector; + else if ( name ) + ret.selector = this.selector + "." + name + "(" + selector + ")"; + + // Return the newly-formed element set + return ret; + }, + + // Force the current matched set of elements to become + // the specified array of elements (destroying the stack in the process) + // You should use pushStack() in order to do this, but maintain the stack + setArray: function( elems ) { + // Resetting the length to 0, then using the native Array push + // is a super-fast way to populate an object with array-like properties + this.length = 0; + Array.prototype.push.apply( this, elems ); + + return this; + }, + + // Execute a callback for every element in the matched set. + // (You can seed the arguments with an array of args, but this is + // only used internally.) + each: function( callback, args ) { + return jQuery.each( this, callback, args ); + }, + + // Determine the position of an element within + // the matched set of elements + index: function( elem ) { + // Locate the position of the desired element + return jQuery.inArray( + // If it receives a jQuery object, the first element is used + elem && elem.jquery ? elem[0] : elem + , this ); + }, + + attr: function( name, value, type ) { + var options = name; + + // Look for the case where we're accessing a style value + if ( typeof name === "string" ) + if ( value === undefined ) + return this[0] && jQuery[ type || "attr" ]( this[0], name ); + + else { + options = {}; + options[ name ] = value; + } + + // Check to see if we're setting style values + return this.each(function(i){ + // Set all the styles + for ( name in options ) + jQuery.attr( + type ? + this.style : + this, + name, jQuery.prop( this, options[ name ], type, i, name ) + ); + }); + }, + + css: function( key, value ) { + // ignore negative width and height values + if ( (key == 'width' || key == 'height') && parseFloat(value) < 0 ) + value = undefined; + return this.attr( key, value, "curCSS" ); + }, + + text: function( text ) { + if ( typeof text !== "object" && text != null ) + return this.empty().append( (this[0] && this[0].ownerDocument || document).createTextNode( text ) ); + + var ret = ""; + + jQuery.each( text || this, function(){ + jQuery.each( this.childNodes, function(){ + if ( this.nodeType != 8 ) + ret += this.nodeType != 1 ? + this.nodeValue : + jQuery.fn.text( [ this ] ); + }); + }); + + return ret; + }, + + wrapAll: function( html ) { + if ( this[0] ) { + // The elements to wrap the target around + var wrap = jQuery( html, this[0].ownerDocument ).clone(); + + if ( this[0].parentNode ) + wrap.insertBefore( this[0] ); + + wrap.map(function(){ + var elem = this; + + while ( elem.firstChild ) + elem = elem.firstChild; + + return elem; + }).append(this); + } + + return this; + }, + + wrapInner: function( html ) { + return this.each(function(){ + jQuery( this ).contents().wrapAll( html ); + }); + }, + + wrap: function( html ) { + return this.each(function(){ + jQuery( this ).wrapAll( html ); + }); + }, + + append: function() { + return this.domManip(arguments, true, function(elem){ + if (this.nodeType == 1) + this.appendChild( elem ); + }); + }, + + prepend: function() { + return this.domManip(arguments, true, function(elem){ + if (this.nodeType == 1) + this.insertBefore( elem, this.firstChild ); + }); + }, + + before: function() { + return this.domManip(arguments, false, function(elem){ + this.parentNode.insertBefore( elem, this ); + }); + }, + + after: function() { + return this.domManip(arguments, false, function(elem){ + this.parentNode.insertBefore( elem, this.nextSibling ); + }); + }, + + end: function() { + return this.prevObject || jQuery( [] ); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: [].push, + sort: [].sort, + splice: [].splice, + + find: function( selector ) { + if ( this.length === 1 ) { + var ret = this.pushStack( [], "find", selector ); + ret.length = 0; + jQuery.find( selector, this[0], ret ); + return ret; + } else { + return this.pushStack( jQuery.unique(jQuery.map(this, function(elem){ + return jQuery.find( selector, elem ); + })), "find", selector ); + } + }, + + clone: function( events ) { + // Do the clone + var ret = this.map(function(){ + if ( !jQuery.support.noCloneEvent && !jQuery.isXMLDoc(this) ) { + // IE copies events bound via attachEvent when + // using cloneNode. Calling detachEvent on the + // clone will also remove the events from the orignal + // In order to get around this, we use innerHTML. + // Unfortunately, this means some modifications to + // attributes in IE that are actually only stored + // as properties will not be copied (such as the + // the name attribute on an input). + var html = this.outerHTML; + if ( !html ) { + var div = this.ownerDocument.createElement("div"); + div.appendChild( this.cloneNode(true) ); + html = div.innerHTML; + } + + return jQuery.clean([html.replace(/ jQuery\d+="(?:\d+|null)"/g, "").replace(/^\s*/, "")])[0]; + } else + return this.cloneNode(true); + }); + + // Copy the events from the original to the clone + if ( events === true ) { + var orig = this.find("*").andSelf(), i = 0; + + ret.find("*").andSelf().each(function(){ + if ( this.nodeName !== orig[i].nodeName ) + return; + + var events = jQuery.data( orig[i], "events" ); + + for ( var type in events ) { + for ( var handler in events[ type ] ) { + jQuery.event.add( this, type, events[ type ][ handler ], events[ type ][ handler ].data ); + } + } + + i++; + }); + } + + // Return the cloned set + return ret; + }, + + filter: function( selector ) { + return this.pushStack( + jQuery.isFunction( selector ) && + jQuery.grep(this, function(elem, i){ + return selector.call( elem, i ); + }) || + + jQuery.multiFilter( selector, jQuery.grep(this, function(elem){ + return elem.nodeType === 1; + }) ), "filter", selector ); + }, + + closest: function( selector ) { + var pos = jQuery.expr.match.POS.test( selector ) ? jQuery(selector) : null, + closer = 0; + + return this.map(function(){ + var cur = this; + while ( cur && cur.ownerDocument ) { + if ( pos ? pos.index(cur) > -1 : jQuery(cur).is(selector) ) { + jQuery.data(cur, "closest", closer); + return cur; + } + cur = cur.parentNode; + closer++; + } + }); + }, + + not: function( selector ) { + if ( typeof selector === "string" ) + // test special case where just one selector is passed in + if ( isSimple.test( selector ) ) + return this.pushStack( jQuery.multiFilter( selector, this, true ), "not", selector ); + else + selector = jQuery.multiFilter( selector, this ); + + var isArrayLike = selector.length && selector[selector.length - 1] !== undefined && !selector.nodeType; + return this.filter(function() { + return isArrayLike ? jQuery.inArray( this, selector ) < 0 : this != selector; + }); + }, + + add: function( selector ) { + return this.pushStack( jQuery.unique( jQuery.merge( + this.get(), + typeof selector === "string" ? + jQuery( selector ) : + jQuery.makeArray( selector ) + ))); + }, + + is: function( selector ) { + return !!selector && jQuery.multiFilter( selector, this ).length > 0; + }, + + hasClass: function( selector ) { + return !!selector && this.is( "." + selector ); + }, + + val: function( value ) { + if ( value === undefined ) { + var elem = this[0]; + + if ( elem ) { + if( jQuery.nodeName( elem, 'option' ) ) + return (elem.attributes.value || {}).specified ? elem.value : elem.text; + + // We need to handle select boxes special + if ( jQuery.nodeName( elem, "select" ) ) { + var index = elem.selectedIndex, + values = [], + options = elem.options, + one = elem.type == "select-one"; + + // Nothing was selected + if ( index < 0 ) + return null; + + // Loop through all the selected options + for ( var i = one ? index : 0, max = one ? index + 1 : options.length; i < max; i++ ) { + var option = options[ i ]; + + if ( option.selected ) { + // Get the specifc value for the option + value = jQuery(option).val(); + + // We don't need an array for one selects + if ( one ) + return value; + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + } + + // Everything else, we just grab the value + return (elem.value || "").replace(/\r/g, ""); + + } + + return undefined; + } + + if ( typeof value === "number" ) + value += ''; + + return this.each(function(){ + if ( this.nodeType != 1 ) + return; + + if ( jQuery.isArray(value) && /radio|checkbox/.test( this.type ) ) + this.checked = (jQuery.inArray(this.value, value) >= 0 || + jQuery.inArray(this.name, value) >= 0); + + else if ( jQuery.nodeName( this, "select" ) ) { + var values = jQuery.makeArray(value); + + jQuery( "option", this ).each(function(){ + this.selected = (jQuery.inArray( this.value, values ) >= 0 || + jQuery.inArray( this.text, values ) >= 0); + }); + + if ( !values.length ) + this.selectedIndex = -1; + + } else + this.value = value; + }); + }, + + html: function( value ) { + return value === undefined ? + (this[0] ? + this[0].innerHTML.replace(/ jQuery\d+="(?:\d+|null)"/g, "") : + null) : + this.empty().append( value ); + }, + + replaceWith: function( value ) { + return this.after( value ).remove(); + }, + + eq: function( i ) { + return this.slice( i, +i + 1 ); + }, + + slice: function() { + return this.pushStack( Array.prototype.slice.apply( this, arguments ), + "slice", Array.prototype.slice.call(arguments).join(",") ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map(this, function(elem, i){ + return callback.call( elem, i, elem ); + })); + }, + + andSelf: function() { + return this.add( this.prevObject ); + }, + + domManip: function( args, table, callback ) { + if ( this[0] ) { + var fragment = (this[0].ownerDocument || this[0]).createDocumentFragment(), + scripts = jQuery.clean( args, (this[0].ownerDocument || this[0]), fragment ), + first = fragment.firstChild; + + if ( first ) + for ( var i = 0, l = this.length; i < l; i++ ) + callback.call( root(this[i], first), this.length > 1 || i > 0 ? + fragment.cloneNode(true) : fragment ); + + if ( scripts ) + jQuery.each( scripts, evalScript ); + } + + return this; + + function root( elem, cur ) { + return table && jQuery.nodeName(elem, "table") && jQuery.nodeName(cur, "tr") ? + (elem.getElementsByTagName("tbody")[0] || + elem.appendChild(elem.ownerDocument.createElement("tbody"))) : + elem; + } + } +}; + +// Give the init function the jQuery prototype for later instantiation +jQuery.fn.init.prototype = jQuery.fn; + +function evalScript( i, elem ) { + if ( elem.src ) + jQuery.ajax({ + url: elem.src, + async: false, + dataType: "script" + }); + + else + jQuery.globalEval( elem.text || elem.textContent || elem.innerHTML || "" ); + + if ( elem.parentNode ) + elem.parentNode.removeChild( elem ); +} + +function now(){ + return +new Date; +} + +jQuery.extend = jQuery.fn.extend = function() { + // copy reference to target object + var target = arguments[0] || {}, i = 1, length = arguments.length, deep = false, options; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + target = arguments[1] || {}; + // skip the boolean and the target + i = 2; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !jQuery.isFunction(target) ) + target = {}; + + // extend jQuery itself if only one argument is passed + if ( length == i ) { + target = this; + --i; + } + + for ( ; i < length; i++ ) + // Only deal with non-null/undefined values + if ( (options = arguments[ i ]) != null ) + // Extend the base object + for ( var name in options ) { + var src = target[ name ], copy = options[ name ]; + + // Prevent never-ending loop + if ( target === copy ) + continue; + + // Recurse if we're merging object values + if ( deep && copy && typeof copy === "object" && !copy.nodeType ) + target[ name ] = jQuery.extend( deep, + // Never move original objects, clone them + src || ( copy.length != null ? [ ] : { } ) + , copy ); + + // Don't bring in undefined values + else if ( copy !== undefined ) + target[ name ] = copy; + + } + + // Return the modified object + return target; +}; + +// exclude the following css properties to add px +var exclude = /z-?index|font-?weight|opacity|zoom|line-?height/i, + // cache defaultView + defaultView = document.defaultView || {}, + toString = Object.prototype.toString; + +jQuery.extend({ + noConflict: function( deep ) { + window.$ = _$; + + if ( deep ) + window.jQuery = _jQuery; + + return jQuery; + }, + + // See test/unit/core.js for details concerning isFunction. + // Since version 1.3, DOM methods and functions like alert + // aren't supported. They return false on IE (#2968). + isFunction: function( obj ) { + return toString.call(obj) === "[object Function]"; + }, + + isArray: function( obj ) { + return toString.call(obj) === "[object Array]"; + }, + + // check if an element is in a (or is an) XML document + isXMLDoc: function( elem ) { + return elem.nodeType === 9 && elem.documentElement.nodeName !== "HTML" || + !!elem.ownerDocument && jQuery.isXMLDoc( elem.ownerDocument ); + }, + + // Evalulates a script in a global context + globalEval: function( data ) { + if ( data && /\S/.test(data) ) { + // Inspired by code by Andrea Giammarchi + // http://webreflection.blogspot.com/2007/08/global-scope-evaluation-and-dom.html + var head = document.getElementsByTagName("head")[0] || document.documentElement, + script = document.createElement("script"); + + script.type = "text/javascript"; + if ( jQuery.support.scriptEval ) + script.appendChild( document.createTextNode( data ) ); + else + script.text = data; + + // Use insertBefore instead of appendChild to circumvent an IE6 bug. + // This arises when a base node is used (#2709). + head.insertBefore( script, head.firstChild ); + head.removeChild( script ); + } + }, + + nodeName: function( elem, name ) { + return elem.nodeName && elem.nodeName.toUpperCase() == name.toUpperCase(); + }, + + // args is for internal usage only + each: function( object, callback, args ) { + var name, i = 0, length = object.length; + + if ( args ) { + if ( length === undefined ) { + for ( name in object ) + if ( callback.apply( object[ name ], args ) === false ) + break; + } else + for ( ; i < length; ) + if ( callback.apply( object[ i++ ], args ) === false ) + break; + + // A special, fast, case for the most common use of each + } else { + if ( length === undefined ) { + for ( name in object ) + if ( callback.call( object[ name ], name, object[ name ] ) === false ) + break; + } else + for ( var value = object[0]; + i < length && callback.call( value, i, value ) !== false; value = object[++i] ){} + } + + return object; + }, + + prop: function( elem, value, type, i, name ) { + // Handle executable functions + if ( jQuery.isFunction( value ) ) + value = value.call( elem, i ); + + // Handle passing in a number to a CSS property + return typeof value === "number" && type == "curCSS" && !exclude.test( name ) ? + value + "px" : + value; + }, + + className: { + // internal only, use addClass("class") + add: function( elem, classNames ) { + jQuery.each((classNames || "").split(/\s+/), function(i, className){ + if ( elem.nodeType == 1 && !jQuery.className.has( elem.className, className ) ) + elem.className += (elem.className ? " " : "") + className; + }); + }, + + // internal only, use removeClass("class") + remove: function( elem, classNames ) { + if (elem.nodeType == 1) + elem.className = classNames !== undefined ? + jQuery.grep(elem.className.split(/\s+/), function(className){ + return !jQuery.className.has( classNames, className ); + }).join(" ") : + ""; + }, + + // internal only, use hasClass("class") + has: function( elem, className ) { + return elem && jQuery.inArray( className, (elem.className || elem).toString().split(/\s+/) ) > -1; + } + }, + + // A method for quickly swapping in/out CSS properties to get correct calculations + swap: function( elem, options, callback ) { + var old = {}; + // Remember the old values, and insert the new ones + for ( var name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + callback.call( elem ); + + // Revert the old values + for ( var name in options ) + elem.style[ name ] = old[ name ]; + }, + + css: function( elem, name, force, extra ) { + if ( name == "width" || name == "height" ) { + var val, props = { position: "absolute", visibility: "hidden", display:"block" }, which = name == "width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ]; + + function getWH() { + val = name == "width" ? elem.offsetWidth : elem.offsetHeight; + + if ( extra === "border" ) + return; + + jQuery.each( which, function() { + if ( !extra ) + val -= parseFloat(jQuery.curCSS( elem, "padding" + this, true)) || 0; + if ( extra === "margin" ) + val += parseFloat(jQuery.curCSS( elem, "margin" + this, true)) || 0; + else + val -= parseFloat(jQuery.curCSS( elem, "border" + this + "Width", true)) || 0; + }); + } + + if ( elem.offsetWidth !== 0 ) + getWH(); + else + jQuery.swap( elem, props, getWH ); + + return Math.max(0, Math.round(val)); + } + + return jQuery.curCSS( elem, name, force ); + }, + + curCSS: function( elem, name, force ) { + var ret, style = elem.style; + + // We need to handle opacity special in IE + if ( name == "opacity" && !jQuery.support.opacity ) { + ret = jQuery.attr( style, "opacity" ); + + return ret == "" ? + "1" : + ret; + } + + // Make sure we're using the right name for getting the float value + if ( name.match( /float/i ) ) + name = styleFloat; + + if ( !force && style && style[ name ] ) + ret = style[ name ]; + + else if ( defaultView.getComputedStyle ) { + + // Only "float" is needed here + if ( name.match( /float/i ) ) + name = "float"; + + name = name.replace( /([A-Z])/g, "-$1" ).toLowerCase(); + + var computedStyle = defaultView.getComputedStyle( elem, null ); + + if ( computedStyle ) + ret = computedStyle.getPropertyValue( name ); + + // We should always get a number back from opacity + if ( name == "opacity" && ret == "" ) + ret = "1"; + + } else if ( elem.currentStyle ) { + var camelCase = name.replace(/\-(\w)/g, function(all, letter){ + return letter.toUpperCase(); + }); + + ret = elem.currentStyle[ name ] || elem.currentStyle[ camelCase ]; + + // From the awesome hack by Dean Edwards + // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 + + // If we're not dealing with a regular pixel number + // but a number that has a weird ending, we need to convert it to pixels + if ( !/^\d+(px)?$/i.test( ret ) && /^\d/.test( ret ) ) { + // Remember the original values + var left = style.left, rsLeft = elem.runtimeStyle.left; + + // Put in the new values to get a computed value out + elem.runtimeStyle.left = elem.currentStyle.left; + style.left = ret || 0; + ret = style.pixelLeft + "px"; + + // Revert the changed values + style.left = left; + elem.runtimeStyle.left = rsLeft; + } + } + + return ret; + }, + + clean: function( elems, context, fragment ) { + context = context || document; + + // !context.createElement fails in IE with an error but returns typeof 'object' + if ( typeof context.createElement === "undefined" ) + context = context.ownerDocument || context[0] && context[0].ownerDocument || document; + + // If a single string is passed in and it's a single tag + // just do a createElement and skip the rest + if ( !fragment && elems.length === 1 && typeof elems[0] === "string" ) { + var match = /^<(\w+)\s*\/?>$/.exec(elems[0]); + if ( match ) + return [ context.createElement( match[1] ) ]; + } + + var ret = [], scripts = [], div = context.createElement("div"); + + jQuery.each(elems, function(i, elem){ + if ( typeof elem === "number" ) + elem += ''; + + if ( !elem ) + return; + + // Convert html string into DOM nodes + if ( typeof elem === "string" ) { + // Fix "XHTML"-style tags in all browsers + elem = elem.replace(/(<(\w+)[^>]*?)\/>/g, function(all, front, tag){ + return tag.match(/^(abbr|br|col|img|input|link|meta|param|hr|area|embed)$/i) ? + all : + front + ">"; + }); + + // Trim whitespace, otherwise indexOf won't work as expected + var tags = elem.replace(/^\s+/, "").substring(0, 10).toLowerCase(); + + var wrap = + // option or optgroup + !tags.indexOf("", "" ] || + + !tags.indexOf("", "" ] || + + tags.match(/^<(thead|tbody|tfoot|colg|cap)/) && + [ 1, "", "
    " ] || + + !tags.indexOf("
    " ] || + + // matched above + (!tags.indexOf("", "" ] || + + !tags.indexOf("", "" ] || + + // IE can't serialize and + + +

    +

    + FanFictionDownLoader +

    + +
    + + +
    + + {% if fic.failure %} +
    + {{ fic.failure }} +
    + {% endif %} +
    + + +
    + + {% if is_login %} + +

    Login and Password

    +
    + {{ site }} requires a Login/Password for this story. + You need to provide your Login/Password for {{ site }} + to download it. +
    +
    +
    Login
    +
    +
    + +
    +
    Password
    +
    +
    + + {% else %} + + + +
    +
    Are you an Adult?
    +
    + + {% endif %} + +
    + +
    + +
    +
    + +
    + Powered by Google App Engine +

    + This is a web front-end to FanFictionDownLoader
    + Copyright © FanFictionDownLoader team +
    + +
    + + +
    +
    + + diff --git a/main.py b/main.py new file mode 100644 index 00000000..59148b49 --- /dev/null +++ b/main.py @@ -0,0 +1,633 @@ +#!/usr/bin/env python +# +# Copyright 2007 Google Inc. +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +logging.getLogger().setLevel(logging.DEBUG) + +import os +from os.path import dirname, basename, normpath +import re +import sys +import zlib +import urllib +import datetime + +import traceback +from StringIO import StringIO + +## Just to shut up the appengine warning about "You are using the +## default Django version (0.96). The default Django version will +## change in an App Engine release in the near future. Please call +## use_library() to explicitly select a Django version. For more +## information see +## http://code.google.com/appengine/docs/python/tools/libraries.html#Django" +## Note that if you are using the SDK App Engine Launcher and hit an SDK +## Console page first, you will get a django version mismatch error when you +## to go hit one of the application pages. Just change a file again, and +## make sure to hit an app page before the SDK page to clear it. +#os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' +#from google.appengine.dist import use_library +#use_library('django', '1.2') + +from google.appengine.ext import db +from google.appengine.api import taskqueue +from google.appengine.api import users +#from google.appengine.ext import webapp +import webapp2 +from google.appengine.ext.webapp import template +#from google.appengine.ext.webapp2 import util +from google.appengine.runtime import DeadlineExceededError + +from ffstorage import * + +from fanficdownloader import adapters, writers, exceptions +from fanficdownloader.configurable import Configuration + +class UserConfigServer(webapp2.RequestHandler): + + def getUserConfig(self,user,url,fileformat): + + configuration = Configuration(adapters.getConfigSectionFor(url),fileformat) + + logging.debug('reading defaults.ini config file') + configuration.read('defaults.ini') + + ## Pull user's config record. + l = UserConfig.all().filter('user =', user).fetch(1) + if l and l[0].config: + uconfig=l[0] + #logging.debug('reading config from UserConfig(%s)'%uconfig.config) + configuration.readfp(StringIO(uconfig.config)) + + return configuration + +class MainHandler(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if user: + error = self.request.get('error') + template_values = {'nickname' : user.nickname(), 'authorized': True} + url = self.request.get('url') + template_values['url'] = url + + if error: + if error == 'login_required': + template_values['error_message'] = 'This story (or one of the chapters) requires you to be logged in.' + elif error == 'bad_url': + template_values['error_message'] = 'Unsupported URL: ' + url + elif error == 'custom': + template_values['error_message'] = 'Error happened: ' + self.request.get('errtext') + elif error == 'configsaved': + template_values['error_message'] = 'Configuration Saved' + elif error == 'recentcleared': + template_values['error_message'] = 'Your Recent Downloads List has been Cleared' + + filename = self.request.get('file') + if len(filename) > 1: + template_values['yourfile'] = '''''' % (filename, self.request.get('name'), self.request.get('author')) + + self.response.headers['Content-Type'] = 'text/html' + path = os.path.join(os.path.dirname(__file__), 'index.html') + + else: + logging.debug(users.create_login_url('/')) + url = users.create_login_url(self.request.uri) + template_values = {'login_url' : url, 'authorized': False} + path = os.path.join(os.path.dirname(__file__), 'index.html') + + + template_values['supported_sites'] = '
    \n' + for (site,examples) in adapters.getSiteExamples(): + template_values['supported_sites'] += "
    %s
    \n
    Example Story URLs:
    "%site + for u in examples: + template_values['supported_sites'] += "%s
    \n"%(u,u) + template_values['supported_sites'] += "
    \n" + template_values['supported_sites'] += '
    \n' + + self.response.out.write(template.render(path, template_values)) + + +class EditConfigServer(UserConfigServer): + def get(self): + self.post() + + def post(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + template_values = {'nickname' : user.nickname(), 'authorized': True} + + ## Pull user's config record. + l = UserConfig.all().filter('user =', user).fetch(1) + if l: + uconfig=l[0] + else: + uconfig=None + + if self.request.get('update'): + if uconfig is None: + uconfig = UserConfig() + uconfig.user = user + uconfig.config = self.request.get('config').encode('utf8')[:10000] ## just in case. + uconfig.put() + try: + # just getting config for testing purposes. + configuration = self.getUserConfig(user,"test1.com","epub") + self.redirect("/?error=configsaved") + except Exception, e: + logging.info("Saved Config Failed:%s"%e) + self.redirect("/?error=custom&errtext=%s"%urlEscape(str(e))) + else: # not update, assume display for edit + if uconfig is not None and uconfig.config: + config = uconfig.config + else: + configfile = open("example.ini","rb") + config = configfile.read() + configfile.close() + template_values['config'] = config + + configfile = open("defaults.ini","rb") + config = configfile.read() + configfile.close() + template_values['defaultsini'] = config + + path = os.path.join(os.path.dirname(__file__), 'editconfig.html') + self.response.headers['Content-Type'] = 'text/html' + self.response.out.write(template.render(path, template_values)) + + +class FileServer(webapp2.RequestHandler): + + def get(self): + fileId = self.request.get('id') + + if fileId == None or len(fileId) < 3: + self.redirect('/') + return + + try: + download = getDownloadMeta(id=fileId) + + name = download.name.encode('utf-8') + + logging.info("Serving file: %s" % name) + + if name.endswith('.epub'): + self.response.headers['Content-Type'] = 'application/epub+zip' + elif name.endswith('.html'): + self.response.headers['Content-Type'] = 'text/html' + elif name.endswith('.txt'): + self.response.headers['Content-Type'] = 'text/plain' + elif name.endswith('.mobi'): + self.response.headers['Content-Type'] = 'application/x-mobipocket-ebook' + elif name.endswith('.zip'): + self.response.headers['Content-Type'] = 'application/zip' + else: + self.response.headers['Content-Type'] = 'application/octet-stream' + + self.response.headers['Content-disposition'] = 'attachment; filename="%s"' % name + + data = DownloadData.all().filter("download =", download).order("index") + # epubs are all already compressed. + # Each chunk is compress individually to avoid having + # to hold the whole in memory just for the + # compress/uncompress + if download.format != 'epub': + def dc(data): + try: + return zlib.decompress(data) + # if error, assume it's a chunk from before we started compessing. + except zlib.error: + return data + else: + def dc(data): + return data + + for datum in data: + self.response.out.write(dc(datum.blob)) + + except Exception, e: + fic = DownloadMeta() + fic.failure = unicode(e) + + template_values = dict(fic = fic, + #nickname = user.nickname(), + #escaped_url = escaped_url + ) + path = os.path.join(os.path.dirname(__file__), 'status.html') + self.response.out.write(template.render(path, template_values)) + +class FileStatusServer(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + fileId = self.request.get('id') + + if fileId == None or len(fileId) < 3: + self.redirect('/') + + escaped_url=False + + try: + download = getDownloadMeta(id=fileId) + + if download: + logging.info("Status url: %s" % download.url) + if download.completed and download.format=='epub': + escaped_url = urlEscape(self.request.host_url+"/file/"+download.name+"."+download.format+"?id="+fileId+"&fake=file."+download.format) + else: + download = DownloadMeta() + download.failure = "Download not found" + + except Exception, e: + download = DownloadMeta() + download.failure = unicode(e) + + template_values = dict(fic = download, + nickname = user.nickname(), + escaped_url = escaped_url + ) + path = os.path.join(os.path.dirname(__file__), 'status.html') + self.response.out.write(template.render(path, template_values)) + +class ClearRecentServer(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + logging.info("Clearing Recent List for user: "+user.nickname()) + q = DownloadMeta.all() + q.filter('user =', user) + num=0 + while( True ): + results = q.fetch(100) + if results: + for d in results: + d.delete() + for c in d.data_chunks: + c.delete() + num = num + 1 + logging.debug('Delete '+d.url) + else: + break + logging.info('Deleted %d instances download.' % num) + self.redirect("/?error=recentcleared") + +class RecentFilesServer(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + q = DownloadMeta.all() + q.filter('user =', user).order('-date') + fics = q.fetch(100) + logging.info("Recent fetched %d downloads for user %s."%(len(fics),user.nickname())) + + for fic in fics: + if fic.completed and fic.format == 'epub': + fic.escaped_url = urlEscape(self.request.host_url+"/file/"+fic.name+"."+fic.format+"?id="+str(fic.key())+"&fake=file."+fic.format) + + template_values = dict(fics = fics, nickname = user.nickname()) + path = os.path.join(os.path.dirname(__file__), 'recent.html') + self.response.out.write(template.render(path, template_values)) + +class AllRecentFilesServer(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + q = SavedMeta.all() + if self.request.get('bydate'): + q.order('-date') + else: + q.order('-count') + + fics = q.fetch(200) + logging.info("Recent fetched %d downloads for user %s."%(len(fics),user.nickname())) + + sendslugs = [] + + for fic in fics: + ficslug = FicSlug(fic) + sendslugs.append(ficslug) + + template_values = dict(fics = sendslugs, nickname = user.nickname()) + path = os.path.join(os.path.dirname(__file__), 'allrecent.html') + self.response.out.write(template.render(path, template_values)) + +class FicSlug(): + def __init__(self,savedmeta): + self.url = savedmeta.url + self.count = savedmeta.count + for k, v in savedmeta.meta.iteritems(): + setattr(self,k,v) + +class FanfictionDownloader(UserConfigServer): + def get(self): + self.post() + + def post(self): + logging.getLogger().setLevel(logging.DEBUG) + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + format = self.request.get('format') + url = self.request.get('url') + + if not url or url.strip() == "": + self.redirect('/') + return + + logging.info("Queuing Download: %s" % url) + login = self.request.get('login') + password = self.request.get('password') + is_adult = self.request.get('is_adult') == "on" + + # use existing record if available. Fetched/Created before + # the adapter can normalize the URL in case we need to record + # an exception. + download = getDownloadMeta(url=url,user=user,format=format,new=True) + + adapter = None + try: + try: + configuration = self.getUserConfig(user,url,format) + except Exception, e: + self.redirect("/?error=custom&errtext=%s"%urlEscape("There's an error in your User Configuration: "+str(e))) + return + + adapter = adapters.getAdapter(configuration,url) + logging.info('Created an adaper: %s' % adapter) + + if len(login) > 1: + adapter.username=login + adapter.password=password + adapter.is_adult=is_adult + + ## This scrapes the metadata, which will be + ## duplicated in the queue task, but it + ## detects bad URLs, bad login, bad story, etc + ## without waiting for the queue. So I think + ## it's worth the double up. Could maybe save + ## it all in the download object someday. + story = adapter.getStoryMetadataOnly() + + ## Fetch again using normalized story URL. The one + ## fetched/created above, if different, will not be saved. + download = getDownloadMeta(url=story.getMetadata('storyUrl'), + user=user,format=format,new=True) + + download.title = story.getMetadata('title') + download.author = story.getMetadata('author') + download.url = story.getMetadata('storyUrl') + download.put() + + taskqueue.add(url='/fdowntask', + queue_name="download", + params={'id':str(download.key()), + 'format':format, + 'url':download.url, + 'login':login, + 'password':password, + 'user':user.email(), + 'is_adult':is_adult}) + + logging.info("enqueued download key: " + str(download.key())) + + except (exceptions.FailedToLogin,exceptions.AdultCheckRequired), e: + download.failure = unicode(e) + download.put() + logging.info(unicode(e)) + is_login= ( isinstance(e, exceptions.FailedToLogin) ) + template_values = dict(nickname = user.nickname(), + url = url, + format = format, + site = adapter.getConfigSection(), + fic = download, + is_login=is_login, + ) + # thewriterscoffeeshop.com can do adult check *and* user required. + if isinstance(e,exceptions.AdultCheckRequired): + template_values['login']=login + template_values['password']=password + + path = os.path.join(os.path.dirname(__file__), 'login.html') + self.response.out.write(template.render(path, template_values)) + return + except (exceptions.InvalidStoryURL,exceptions.UnknownSite,exceptions.StoryDoesNotExist), e: + logging.warn(unicode(e)) + download.failure = unicode(e) + download.put() + except Exception, e: + logging.error("Failure Queuing Download: url:%s" % url) + logging.exception(e) + download.failure = unicode(e) + download.put() + + self.redirect('/status?id='+str(download.key())) + + return + + +class FanfictionDownloaderTask(UserConfigServer): + + def post(self): + logging.getLogger().setLevel(logging.DEBUG) + fileId = self.request.get('id') + # User object can't pass, just email address + user = users.User(self.request.get('user')) + format = self.request.get('format') + url = self.request.get('url') + login = self.request.get('login') + password = self.request.get('password') + is_adult = self.request.get('is_adult') + + logging.info("Downloading: " + url + " for user: "+user.nickname()) + logging.info("ID: " + fileId) + + adapter = None + writerClass = None + + # use existing record if available. + # fileId should have record from /fdown. + download = getDownloadMeta(id=fileId,url=url,user=user,format=format,new=True) + for c in download.data_chunks: + c.delete() + download.put() + + logging.info('Creating adapter...') + + try: + configuration = self.getUserConfig(user,url,format) + adapter = adapters.getAdapter(configuration,url) + + logging.info('Created an adapter: %s' % adapter) + + if len(login) > 1: + adapter.username=login + adapter.password=password + adapter.is_adult=is_adult + + # adapter.getStory() is what does all the heavy lifting. + # adapter.getStoryMetadataOnly() only fetches enough to + # get metadata. writer.writeStory() will call + # adapter.getStory(), too. + writer = writers.getWriter(format,configuration,adapter) + download.name = writer.getOutputFileName() + #logging.debug('output_filename:'+writer.getConfig('output_filename')) + logging.debug('getOutputFileName:'+writer.getOutputFileName()) + download.title = adapter.getStory().getMetadata('title') + download.author = adapter.getStory().getMetadata('author') + download.url = adapter.getStory().getMetadata('storyUrl') + download.put() + + allmeta = adapter.getStory().getAllMetadata(removeallentities=True,doreplacements=False) + + outbuffer = StringIO() + writer.writeStory(outbuffer) + data = outbuffer.getvalue() + outbuffer.close() + del outbuffer + #del writer.adapter + #del writer.story + del writer + #del adapter.story + del adapter + + # epubs are all already compressed. Each chunk is + # compressed individually to avoid having to hold the + # whole in memory just for the compress/uncompress. + if format != 'epub': + def c(data): + return zlib.compress(data) + else: + def c(data): + return data + + index=0 + while( len(data) > 0 ): + DownloadData(download=download, + index=index, + blob=c(data[:1000000])).put() + index += 1 + data = data[1000000:] + download.completed=True + download.put() + + smetal = SavedMeta.all().filter('url =', allmeta['storyUrl'] ).fetch(1) + if smetal and smetal[0]: + smeta = smetal[0] + smeta.count += 1 + else: + smeta=SavedMeta() + smeta.count = 1 + + smeta.url = allmeta['storyUrl'] + smeta.title = allmeta['title'] + smeta.author = allmeta['author'] + smeta.meta = allmeta + smeta.date = datetime.datetime.now() + smeta.put() + + logging.info("Download finished OK") + del data + + except Exception, e: + logging.exception(e) + download.failure = unicode(e) + download.put() + return + + return + +def getDownloadMeta(id=None,url=None,user=None,format=None,new=False): + ## try to get download rec from passed id first. then fall back + ## to user/url/format + download = None + if id: + try: + download = db.get(db.Key(id)) + logging.info("DownloadMeta found by ID:"+id) + except: + pass + + if not download and url and user and format: + try: + q = DownloadMeta.all().filter('user =', user).filter('url =',url).filter('format =',format).fetch(1) + if( q is not None and len(q) > 0 ): + logging.debug("DownloadMeta found by user:%s url:%s format:%s"%(user,url,format)) + download = q[0] + except: + pass + + if new: + # NOT clearing existing chunks here, because this record may + # never be saved. + if not download: + logging.debug("New DownloadMeta") + download = DownloadMeta() + + download.completed=False + download.failure=None + download.date=datetime.datetime.now() + + download.version = "%s:%s" % (os.environ['APPLICATION_ID'],os.environ['CURRENT_VERSION_ID']) + if user: + download.user = user + if url: + download.url = url + if format: + download.format = format + + return download + +def toPercentDecimal(match): + "Return the %decimal number for the character for url escaping" + s = match.group(1) + return "%%%02x" % ord(s) + +def urlEscape(data): + "Escape text, including unicode, for use in URLs" + p = re.compile(r'([^\w])') + return p.sub(toPercentDecimal, data.encode("utf-8")) + +logging.getLogger().setLevel(logging.DEBUG) +app = webapp2.WSGIApplication([('/', MainHandler), + ('/fdowntask', FanfictionDownloaderTask), + ('/fdown', FanfictionDownloader), + (r'/file.*', FileServer), + ('/status', FileStatusServer), + ('/allrecent', AllRecentFilesServer), + ('/recent', RecentFilesServer), + ('/editconfig', EditConfigServer), + ('/clearrecent', ClearRecentServer), + ], + debug=False) diff --git a/makeplugin.py b/makeplugin.py new file mode 100644 index 00000000..b6e588c7 --- /dev/null +++ b/makeplugin.py @@ -0,0 +1,38 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# epubmerge.py 1.0 + +# Copyright 2011, Jim Miller + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from glob import glob + +from makezip import createZipFile + +if __name__=="__main__": + filename="FanFictionDownLoader.zip" + exclude=['*.pyc','*~','*.xcf','*[0-9].png','*.po','*.pot','*default.mo'] + # from top dir. 'w' for overwrite + createZipFile(filename,"w", + ['plugin-defaults.ini','plugin-example.ini','fanficdownloader','downloader.py','defaults.ini'], + exclude=exclude) + #from calibre-plugin dir. 'a' for append + os.chdir('calibre-plugin') + files=['about.txt','images','translations'] + files.extend(glob('*.py')) + files.extend(glob('plugin-import-name-*.txt')) + createZipFile("../"+filename,"a", + files,exclude=exclude) diff --git a/makezip.py b/makezip.py new file mode 100644 index 00000000..fbab1d4c --- /dev/null +++ b/makezip.py @@ -0,0 +1,52 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2014, Jim Miller + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os, zipfile, sys +from glob import glob + +def addFolderToZip(myZipFile,folder,exclude=[]): + folder = folder.encode('ascii') #convert path to ascii for ZipFile Method + excludelist=[] + for ex in exclude: + excludelist.extend(glob(folder+"/"+ex)) + for file in glob(folder+"/*"): + if file in excludelist: + continue + if os.path.isfile(file): + #print file + myZipFile.write(file, file, zipfile.ZIP_DEFLATED) + elif os.path.isdir(file): + addFolderToZip(myZipFile,file,exclude=exclude) + +def createZipFile(filename,mode,files,exclude=[]): + myZipFile = zipfile.ZipFile( filename, mode ) # Open the zip file for writing + excludelist=[] + for ex in exclude: + excludelist.extend(glob(ex)) + for file in files: + if file in excludelist: + continue + file = file.encode('ascii') #convert path to ascii for ZipFile Method + if os.path.isfile(file): + (filepath, filename) = os.path.split(file) + #print file + myZipFile.write( file, filename, zipfile.ZIP_DEFLATED ) + if os.path.isdir(file): + addFolderToZip(myZipFile,file,exclude=exclude) + myZipFile.close() + return (1,filename) + diff --git a/plugin-defaults.ini b/plugin-defaults.ini new file mode 100644 index 00000000..b37b7911 --- /dev/null +++ b/plugin-defaults.ini @@ -0,0 +1,1878 @@ +# 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. +# + +[defaults] + +## [defaults] section applies to all formats and sites but may be +## overridden at several levels. Example: + +## [defaults] +## titlepage_entries: category,genre, status +## [www.whofic.com] +## # overrides defaults. +## titlepage_entries: category,genre, status,dateUpdated,rating +## [epub] +## # overrides defaults & site section +## titlepage_entries: category,genre, status,datePublished,dateUpdated,dateCreated +## [www.whofic.com:epub] +## # overrides defaults, site section & format section +## titlepage_entries: category,genre, status,datePublished +## [overrides] +## # overrides all other sections +## titlepage_entries: category + +## Some sites also require the user to confirm they are adult for +## adult content. Uncomment by removing '#' in front of is_adult. +#is_adult:true + +## All available titlepage_entries and the label used for them: +## _label: