fetch-fic
Version:
Package up delicious, delicious fanfic from various sources into epub ebooks ready for reading in your ereader of choice.
442 lines (415 loc) • 12.6 kB
JavaScript
'use strict'
/* eslint-disable no-return-assign */
const qw = require('qw')
let Site
class Fic {
constructor (fetch) {
this._id = null
this.fetch = fetch
this.title = null
this.link = null
this.altlinks = null
this.updateFrom = null
this.author = null
this.authorUrl = null
this.created = null
this.modified = null
this.publisher = null
this.description = null
this.notes = null
this.cover = null
this.chapterHeadings = null
this.externals = null
this.spoilers = null
this.words = null
this.tags = []
this.fics = []
this.chapters = new ChapterList()
this.site = null
this._includeTOC = null
this._numberTOC = null
this.fetchMeta = null
this.scrapeMeta = null
}
get id () {
if (this._id) return this._id
const link = this.link || this.updateFrom
if (link) return 'url:' + link
}
set id (value) {
return this._id = value
}
get includeTOC () {
return this._includeTOC === null ? true : this._includeTOC
}
set includeTOC (value) {
this._includeTOC = value
}
get numberTOC () {
return this._numberTOC === null ? true : this._numberTOC
}
set numberTOC (value) {
this._numberTOC = value
}
get words () {
return this.chapters.filter(ch => ch.type === 'chapter').reduce((acc, ch) => acc + ch.words, 0)
}
set words (val) {
return
}
updateWith () {
return this.updateFrom || this.link
}
chapterExists (link) {
if (link == null) return false
if (this.chapters.chapterExists(link, this)) return true
if (this.fics.some(fic => fic.chapterExists(link))) return true
return false
}
normalizeLink (link) {
try {
const site = Site.fromUrl(link)
return site.normalizeLink(link)
} catch (_) {
return link
}
}
addChapter (opts) {
if (this.chapterExists(opts.link) || this.chapterExists(opts.fetchFrom)) return
if (opts.spoilers === null) opts.spoilers = this.spoilers
this.chapters.addChapter(opts)
}
importFromJSON (raw) {
const props = qw`id link altlinks title author authorUrl created modified
description notes tags publisher cover chapterHeadings words updateFrom
includeTOC numberTOC fetchMeta scrapeMeta`
for (let prop of props) {
if (prop in raw) this[prop] = raw[prop]
}
this.externals = raw.externals != null ? raw.externals : true
this.spoilers = raw.spoilers != null ? raw.spoilers : true
for (let prop of Object.keys(raw)) {
if (props.indexOf(prop) !== -1) continue
if (prop !== 'chapters' && prop !== 'fics' && prop !== 'externals' && prop !== 'spoilers') {
process.emit('warn', `Unknown property when importing fic: "${prop}"`)
}
}
this.chapters.importFromJSON(this, raw)
if (raw.fics) {
for (let fic of raw.fics) {
this.fics.push(SubFic.fromJSON(this, fic))
}
}
try {
this.site = Site.fromUrl(this.updateWith())
} catch (ex) {
process.emit('warn', ex)
}
return this
}
static async fromUrl (fetch, link) {
const fic = new this(fetch)
fic.site = Site.fromUrl(link)
fic.link = fic.site.link
try {
await fic.site.getFicMetadata(fetch, fic)
} catch (err) {
if (!fic.site.canScrape || !err.meta || err.meta.status !== 404) throw err
}
if (fic.chapters.length === 0 && fic.fics.length === 0) {
fic.scrapeMeta = true
if (fic.site.canScrape) {
await fic.site.scrapeFicMetadata(fetch, fic)
} else {
const err = new Error(`No chapters found in: ${link}`)
err.code = 404
err.url = link
throw err
}
} else {
fic.fetchMeta = true
}
return fic
}
static async fromOnlyUrl (fetch, link) {
const fic = new this(fetch)
fic.site = Site.fromUrl(link)
fic.link = fic.site.link
fic.fetchMeta = true
await fic.site.getFicMetadata(fetch, fic)
if (fic.chapters.length === 0 && fic.fics.length === 0) {
const err = new Error(`Could not find chapters in: ${link}`)
err.code = 404
err.url = link
throw err
}
return fic
}
static async fromUrlAndScrape (fetch, link) {
const fic = new this(fetch)
fic.site = Site.fromUrl(link)
fic.link = fic.site.link
fic.fetchMeta = true
await fic.site.getFicMetadata(fetch, fic)
if (fic.site.canScrape) {
fic.scrapeMeta = true
await fic.site.scrapeFicMetadata(fetch, fic)
}
return fic
}
static async scrapeFromUrl (fetch, link) {
const fic = new this()
fic.site = Site.fromUrl(link)
fic.link = fic.site.link
fic.scrapeMeta = true
if (!fic.site.canScrape) {
const err = new Error(`Site ${fic.site.publisherName || fic.site.publisher} does not support fetching via scraping for ${fic.title} @ ${fic.link}`)
err.code = 'ENOSCRAPE'
throw err
}
await fic.site.scrapeFicMetadata(fetch, fic)
return fic
}
static fromJSON (raw) {
const fic = new this()
return fic.importFromJSON(raw)
}
toJSON () {
const result = {}
for (let prop of qw`
title _id link altlinks updateFrom author authorUrl created modified publisher cover
description notes tags words fics chapters chapterHeadings _includeTOC _numberTOC fetchMeta scrapeMeta
`) {
if (this[prop] != null && (!Array.isArray(this[prop]) || this[prop].length)) result[prop.replace(/^_/,'')] = this[prop]
}
result.fics && result.fics.sort((a, b) => a.created > b.created ? 1 : a.created < b.created ? -1 : 0)
if (!this.externals) result.externals = this.externals
if (!this.spoilers) result.spoilers = this.spoilers
return result
}
}
class SubFic extends Fic {
constructor (parentFic) {
super()
this.parent = parentFic
delete this.fics
for (let prop of qw`_title _created _modified _description _notes _link _author _authorUrl _tags _chapterHeadings`) {
this[prop] = null
}
}
chapterExists (link) {
return this.chapters.chapterExists(link, this)
}
static fromJSON (parent, raw) {
const fic = new this(parent)
fic.importFromJSON(raw)
return fic
}
get author () {
return this._author || (this.chapters.length && this.chapters[0].author)|| this.parent.author
}
set author (value) {
return this._author = value
}
get authorUrl () {
return this._authorUrl || (this.chapters.length && this.chapters[0].authorurl)|| this.parent.authorUrl
}
set authorUrl (value) {
return this._authorUrl = value
}
get publisher () {
return this._publisher || this.parent.publisher
}
set publisher (value) {
return this._publisher = value
}
get title () {
return this._title || (this.chapters.length && this.chapters[0].name)
}
set title (value) {
return this._title = value
}
get link () {
return this._link || (this.chapters.length && this.chapters[0].link)
}
set link (value) {
return this._link = value
}
get description () {
return this._description || (this.chapters.length && this.chapters[0].description)
}
set description (value) {
return this._description = value
}
get notes () {
return this._notes || (this.chapters.length && this.chapters[0].notes)
}
set notes (value) {
return this._notes = value
}
get created () {
return this._created || (this.chapters.length && this.chapters[0].created)
}
set created (value) {
return this._created = value
}
get modified () {
const lastChapter = this.chapters.length && this.chapters[this.chapters.length-1]
return this._modified || (lastChapter && (lastChapter.modified || lastChapter.created))
}
set modified (value) {
return this._modified = value
}
get chapterHeadings () {
return this._chapterHeadings || this.parent.chapterHeadings
}
set chapterHeadings (value) {
return this._chapterHeadings = value
}
get externals () {
return this._externals || this.parent.externals
}
set externals (value) {
return this._externals = value
}
get spoilers () {
return this._spoilers || this.parent.spoilers
}
set spoilers (value) {
return this._spoilers = value
}
get tags () {
if (!this._tags) return Object.assign([], this.parent.tags)
return this._tags
}
set tags (value) {
if (value.length === 0) value = null
return this._tags = value
}
toJSON () {
const result = {}
for (let prop of qw`
_title _id _link altlinks _author _authorUrl _created _modified _publisher
_description _notes _tags chapters _chapterHeadings words _includeTOC _numberTOC
`) {
const assignTo = prop[0] === '_' ? prop.slice(1) : prop
if (this[prop] && (this[prop].length == null || this[prop].length)) result[assignTo] = this[prop]
}
return result
}
}
class ChapterList extends Array {
chapterExists (link, fic) {
if (link == null) {
return
} else if (fic) {
const normalizedLink = fic.normalizeLink(link)
return this.some(chap => fic.normalizeLink(chap.link) === normalizedLink || chap.fetchFrom === normalizedLink)
} else {
return this.some(chap => chap.link === link || chap.fetchFrom === link)
}
}
addChapter (opts) {
if (this.chapterExists(opts.fetchFrom) || this.chapterExists(opts.link)) return
let name = opts.name
let ctr = 0
while (this.some(chap => chap.name === name)) {
name = opts.name + ' (' + ++ctr + ')'
}
if (opts.created && (!this.created || opts.created < this.created)) this.created = opts.created
this.push(new Chapter(Object.assign({}, opts, {name, order: this.length})))
this.sort()
}
sort () {
const types = {}
types['chapter'] = 0
types['Sidestory'] = 50
types['Media'] = 75
types['Informational'] = 90
types['Apocrypha'] = 100
types['Staff Post'] = 9999
Array.prototype.sort.call(this, (a, b) => {
return (types[a.type] - types[b.type]) || a.order - b.order
})
}
importFromJSON (fic, raw) {
if (raw.fics && !raw.chapters) return
if (!raw.chapters) {
const err = new Error('Fic "' + raw.title + '" is missing any chapters.')
err.code = 'ENOCHAPTERS'
throw err
}
for (let chapter of raw.chapters) {
if (chapter.spoilers == null) chapter.spoilers = fic.spoilers
this.push(Chapter.fromJSON(this.length, chapter))
}
this.sort()
}
}
class Chapter {
constructor (opts) {
this.order = opts.order
this.name = opts.name
this.link = opts.link
if (opts.type) {
this.type = opts.type
} else if (/^Omake:/.test(this.name)) {
this.type = 'Sidestory'
} else if (/^Appendix:/.test(this.name)) {
this.type = 'Apocrypha'
} else if (/^Art:/.test(this.name)) {
this.type = 'Media'
} else {
this.type = 'chapter'
}
this.description = opts.description
this.notes = opts.notes
this.fetchFrom = opts.fetchFrom
this.created = opts.created
this.modified = opts.modified
this.author = opts.author
this.authorUrl = opts.authorUrl
this.tags = opts.tags
this.externals = opts.externals != null ? opts.externals : true
this.spoilers = opts.spoilers != null ? opts.spoilers : true
this.headings = opts.headings
this.words = opts.words || 0
}
toJSON () {
return {
name: this.name,
type: this.type !== 'chapter' ? this.type : undefined,
description: this.description,
notes: this.notes,
link: this.link,
fetchFrom: this.fetchFrom,
author: this.author,
authorUrl: this.authorUrl,
created: this.created === 'Invalid Date' ? null : this.created,
modified: this.modified === 'Invalid Date' ? null : this.modified,
tags: this.tags && this.tags.length > 0 ? this.tags : null,
externals: this.externals !== true ? this.externals : null,
spoilers: this.spoilers !== true ? this.spoilers: null,
headings: this.headings,
words: this.words
}
}
static fromJSON (order, opts) {
return new Chapter(Object.assign({order}, opts))
}
fetchWith () {
return this.fetchFrom || this.link
}
getContent (fetch) {
const site = Site.fromUrl(this.fetchWith())
return site.getChapter(fetch, this)
}
static getContent (fetch, href) {
return (new this({link: href})).getContent(fetch)
}
}
module.exports = Fic
module.exports.SubFic = SubFic
module.exports.Chapter = Chapter
// defer 'cause `class` definitions don't hoist
Site = use('site')