Do a better job of reading the fandom info
- ID
61f3824- date
2025-01-26 12:33:29+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
08fd9df- message
Do a better job of reading the fandom info- changed files
7 files, 199 additions, 156 deletions
Changed files
index.html (13878 → 13452)
diff --git a/index.html b/index.html
index 5415078..b9b3d81 100644
--- a/index.html
+++ b/index.html
@@ -205,20 +205,8 @@
// Find the `content
JSZip.loadAsync(file)
.then(async function(zip) {
- // Find the path to the `content.opf` file, which contains
- // some key metadata about this fic.
- const contentOpfPath = findContentOpfPath(zip);
-
- if (contentOpfPath === null) {
- reportError(`Unable to find content.opf in this EPUB! Did this EPUB come from AO3?`);
- return;
- }
-
- // Read the `content.opf` file, and get the key information
- // about this fic.
- const contentOpf = await zip.file(contentOpfPath).async('string');
- const ficInfo = getFicInfo(contentOpf);
- console.debug(`Got fic info: ${JSON.stringify(ficInfo)}`);
+ // Get the key information about this fic.
+ const ficInfo = await getKeyFicInfo(zip);
// Create a cover image for this book which shows the title
// and author on a solid colour background.
@@ -237,6 +225,9 @@
//
// This sort of text-based replacement is utterly cheating,
// but it also works.
+ const contentOpfPath = await findContentOpfPath(zip)
+ const contentOpf = await zip.file(contentOpfPath).async('string');
+
const updatedContentOpf =
contentOpf
.replace(
@@ -387,7 +378,7 @@
</dt>
<dd>
The colours are chosen randomly, but they’ll be the same for all the fics in the same fandom.
- For example, all your Star Wars stories will be red, while Star Trek is a navy blue.
+ For example, all your Star Wars stories will be green, while Star Trek is a navy blue.
This is to help you browse your stories by AO3 fandom.
</dd>
static/app.js (10218 → 14721)
diff --git a/static/app.js b/static/app.js
index 7529507..08976bb 100644
--- a/static/app.js
+++ b/static/app.js
@@ -8,79 +8,159 @@
* This returns the path, or `null` if there's no such file in
* the ePub.
*/
-function findContentOpfPath(zip) {
- const path =
- Object.keys(zip.files)
- .find(f =>
- f === 'content.opf' || f.endsWith('/content.opf')
- );
-
- return path || null;
+async function findContentOpfPath(zip) {
+ const parser = new DOMParser();
+
+ // First look for the mandatory file META-INF/container.xml,
+ // which contains a pointer to the `content.opf` file.
+ //
+ // The contents of this file will be XML of the form:
+ //
+ // <?xml version="1.0"?>
+ // <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
+ // <rootfiles>
+ // <rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
+ //
+ // </rootfiles>
+ // </container>
+ //
+ // In general AO3 stories put the `content.opf` at the top level,
+ // but let's go ahead and find it properly.
+ const containerXmlDoc = parser.parseFromString(
+ await zip.file('META-INF/container.xml').async('string'),
+ "text/xml"
+ );
+
+ const rootPath =
+ containerXmlDoc
+ .querySelector('rootfile')
+ .getAttribute('full-path');
+
+ console.debug(`Detected root path of EPUB file: ${rootPath}`);
+
+ return rootPath;
}
+
/**
- * Given the contents of the `content.opf` file, return certain key
- * information like the title, author, and fandom.
+ * Get key information about this fic from the unpacked EPUB file.
+ *
*/
-function getFicInfo(xml) {
- const parser = new DOMParser();
- const xmlDoc = parser.parseFromString(xml, "text/xml");
+async function getKeyFicInfo(zip) {
+ // Get the path to `content.opf`.
+ const rootPath = await findContentOpfPath(zip);
- const namespaceResolver = (prefix) =>
- prefix === 'dc' ? 'http://purl.org/dc/elements/1.1/' : null;
-
- const STRING_TYPE = 2; // XPathResult.STRING_TYPE
+ // Now go ahead and get the contents of `content.opf`.
+ //
+ // This is the rough strucutre of the contents:
+ //
+ // <?xml version='1.0' encoding='utf-8'?>
+ // <package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="uuid_id">
+ // <metadata xmlns:opf="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:calibre="http://calibre.kovidgoyal.net/2009/metadata">
+ // <dc:title>A Stab Wars Story</dc:title>
+ // <dc:creator opf:file-as="apricot" opf:role="aut">Apricot (Paradisi)</dc:creator>
+ // …
+ // </metadata>
+ // <manifest>
+ // <item id="html4" href="A_Stab_Wars_Story_split_000.xhtml" media-type="application/xhtml+xml"/>
+ // …
+ // </manifest>
+ // <spine toc="ncx">
+ // …
+ // </spine>
+ // </package>
+ //
+ // We want to get the title, creator, and href of that first HTML page.
+ const parser = new DOMParser();
+ const containerOpfXmlDoc = parser.parseFromString(
+ await zip.file(rootPath).async('string'),
+ 'text/xml'
+ );
// The title of the fic will be in a <dc:title> node, for example:
//
// <dc:title>Operation Cameo</dc:title>
//
- const title = xmlDoc.evaluate(
+ const namespaceResolver = (prefix) =>
+ prefix === 'dc' ? 'http://purl.org/dc/elements/1.1/' : null;
+
+ const title = containerOpfXmlDoc.evaluate(
'.//dc:title',
- xmlDoc,
+ containerOpfXmlDoc,
namespaceResolver,
- STRING_TYPE,
- null
+ XPathResult.STRING_TYPE
).stringValue;
+ console.debug(`Detected title of EPUB file: ${title}`);
+
// The author of the fic will be in a <dc:author> node, for example:
//
// <dc:creator …>alexwlchan</dc:creator>
//
- const author = xmlDoc.evaluate(
+ const author = containerOpfXmlDoc.evaluate(
'.//dc:creator',
- xmlDoc,
+ containerOpfXmlDoc,
namespaceResolver,
- STRING_TYPE,
- null
+ XPathResult.STRING_TYPE
).stringValue;
- // The fandom is the *third* of the <dc:subject> nodes, for example:
+ console.debug(`Detected author of EPUB file: ${author}`);
+
+ // The href of the first HTML file will be the first <item> node
+ // with an HTML media type, for example:
//
- // <dc:subject>Fanworks</dc:subject>
- // <dc:subject>General Audiences</dc:subject>
- // <dc:subject>Operation Mincemeat: A New Musical - SpitLip</dc:subject>
+ // <item
+ // id="html4"
+ // href="A_Stab_Wars_Story_split_000.xhtml"
+ // media-type="application/xhtml+xml"/>
//
- // The first subject is always "Fanworks", and it looks like the rating
- // is always the second subject.
+ const firstHtmlPath =
+ containerOpfXmlDoc
+ .querySelector('item[media-type="application/xhtml+xml"]')
+ .getAttribute("href");
+
+ console.debug(`Detected first HTML file in EPUB file: ${firstHtmlPath}`);
+
+ // Now go ahead and read that file as HTML. This contains the metadata
+ // we actually want.
+ const firstHtmlDoc = parser.parseFromString(
+ await zip.file(firstHtmlPath).async('string'),
+ 'text/html'
+ );
+
+ // Look for the contents of a <dl> which contains some metadata
+ // about this fic.
//
- // See comment in the AO3 codebase here:
- // https://github.com/otwcode/otwarchive/blob/2362dd772452a71dda9c62409b935f275a0d131f/app/models/download_writer.rb#L162-L164
- const fandom = xmlDoc.evaluate(
- './/dc:subject[3]',
- xmlDoc,
- namespaceResolver,
- STRING_TYPE,
- null
- ).stringValue;
+ // We're interested in the <dd> after "Fandom:"
+ //
+ // <dl class="tags">
+ // <dt class="calibre3">Rating:</dt>
+ // <dd class="calibre4"><a href="http://archiveofourown.org/tags/General%20Audiences">General Audiences</a></dd>
+ // <dt class="calibre3">Archive Warning:</dt>
+ // <dd class="calibre4"><a href="http://archiveofourown.org/tags/No%20Archive%20Warnings%20Apply">No Archive Warnings Apply</a></dd>
+ // <dt class="calibre3">Category:</dt>
+ // <dd class="calibre4"><a href="http://archiveofourown.org/tags/Gen">Gen</a></dd>
+ // <dt class="calibre3">Fandom:</dt>
+ // <dd class="calibre4"><a href="http://archiveofourown.org/tags/Rogue%20One:%20A%20Star%20Wars%20Story%20(2016)">Rogue One: A Star Wars Story (2016)</a></dd>
+ //
+ const fandom =
+ Array.from(firstHtmlDoc.querySelectorAll('dt'))
+ .find(dt => dt.innerText === 'Fandom:')
+ .nextSibling
+ .nextSibling
+ .innerText;
+
+ console.debug(`Detected fandom in first HTML file: ${fandom}`);
return { title, author, fandom };
}
+
+
/**
* Choose a colour to represent the fics in this fandom.
*
@@ -91,17 +171,16 @@ function getFicInfo(xml) {
*
*/
function chooseColour(fandom) {
+
+ // Choose the first two words of the fandom title. This is enough
+ // to distinguish e.g. "Star Wars" and "Star Trek", but also means
+ // we'll get similar titles for fandoms with the same prefix.
+ const fandomSlice = fandom.split(" ").slice(0, 2).join(" ").replace(/:$/, '');
- // This is a simple hashing function which gets us a random-ish
- // hue in the random 0–359.
- let hash = 0;
- for (let i = 0; i < fandom.length; i++) {
- const char = fandom.charCodeAt(i);
- hash = ((hash << 5) - hash) + char;
- hash = hash & hash;
- }
-
- const hue = (Math.abs(hash) % 360) / 360;
+ const seed = cyrb128(fandomSlice);
+ const getRand = sfc32(seed[0], seed[1], seed[2], seed[3]);
+
+ const hue = getRand();
// The saturation/lightness values are chosen to get a dark-ish
// shade that will look good with white text.
@@ -118,6 +197,46 @@ function chooseColour(fandom) {
/**
+ * A seeded implementation of a random number generator in JavaScript.
+ *
+ * Written by Stack Overflow user bryc:
+ * https://stackoverflow.com/a/47593316/1558022
+ */
+function cyrb128(str) {
+ let h1 = 1779033703, h2 = 3144134277,
+ h3 = 1013904242, h4 = 2773480762;
+ for (let i = 0, k; i < str.length; i++) {
+ k = str.charCodeAt(i);
+ h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
+ h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
+ h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
+ h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
+ }
+ h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
+ h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
+ h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
+ h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
+ h1 ^= (h2 ^ h3 ^ h4), h2 ^= h1, h3 ^= h1, h4 ^= h1;
+ return [h1>>>0, h2>>>0, h3>>>0, h4>>>0];
+}
+
+function sfc32(a, b, c, d) {
+ return function() {
+ a |= 0; b |= 0; c |= 0; d |= 0;
+ let t = (a + b | 0) + d | 0;
+ d = d + 1 | 0;
+ a = b ^ b >>> 9;
+ b = c + (c << 3) | 0;
+ c = (c << 21 | c >>> 11);
+ c = c + t | 0;
+ return (t >>> 0) / 4294967296;
+ }
+}
+
+
+
+
+/**
* Converts an HSL color value to RGB. Conversion formula
* adapted from https://en.wikipedia.org/wiki/HSL_color_space.
* Assumes h, s, and l are contained in the set [0, 1] and
test/app.js (3830 → 1853)
diff --git a/test/app.js b/test/app.js
index 2a2cfef..847c32f 100644
--- a/test/app.js
+++ b/test/app.js
@@ -1,103 +1,25 @@
-QUnit.module('findContentOpfPath');
-
-QUnit.module('findContentOpfPath', () => {
- function assertFindsCorrectOpfPath(assert, { zipPath, expectedPath }) {
+QUnit.module('getKeyFicInfo', () => {
+ QUnit.test('it finds the key info from one of my fics', (assert) => {
const done = assert.async();
- JSZipUtils.getBinaryContent(zipPath, function(err, data) {
+ JSZipUtils.getBinaryContent('test/fixtures/Operation_Cameo.epub', function(err, data) {
if (err) {
throw err;
}
- JSZip.loadAsync(data).then(function (zip) {
- assert.equal(
- findContentOpfPath(zip), expectedPath
+ JSZip.loadAsync(data).then(async function (zip) {
+ const ficInfo = await getKeyFicInfo(zip);
+ assert.deepEqual(
+ ficInfo,
+ {
+ title: 'Operation Cameo',
+ author: 'alexwlchan',
+ fandom: 'Operation Mincemeat: A New Musical - SpitLip',
+ }
);
done();
});
});
- }
-
- QUnit.test('it finds a file at the top level', (assert) => {
-
- // This test file was made with the following commands:
- //
- // $ touch content.opf
- // $ zip example1.zip content.opf
- //
- assertFindsCorrectOpfPath(assert, {
- zipPath: 'test/fixtures/example1.zip',
- expectedPath: 'content.opf'
- });
- });
-
- QUnit.test('it finds a file in a nested folder', (assert) => {
-
- // This test file was made with the following commands:
- //
- // $ mkdir subdir
- // $ touch subdir/content.opf
- // $ zip example2.zip subdir/content.opf
- //
- assertFindsCorrectOpfPath(assert, {
- zipPath: 'test/fixtures/example2.zip',
- expectedPath: 'subdir/content.opf'
- });
- });
-
- QUnit.test('it returns `null` if there’s no matching file', (assert) => {
-
- // This test file was made with the following commands:
- //
- // $ touch greeting.txt
- // $ zip example3.zip greeting.txt
- //
- assertFindsCorrectOpfPath(assert, {
- zipPath: 'test/fixtures/example3.zip',
- expectedPath: null
- });
- });
-});
-
-
-
-QUnit.module('getFicInfo', () => {
- QUnit.test('it finds the key info from a `content.opf file`', (assert) => {
- // I got this `content.opf` file from downloading the ePub
- // of one of my fics: https://archiveofourown.org/works/52250080
- //
- // Retrieved https://archiveofourown.org/works/52250080
- const xml = `
- <?xml version='1.0' encoding='utf-8'?>
- <package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="uuid_id">
- <metadata xmlns:opf="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
- <dc:title>Operation Cameo</dc:title>
- <dc:language>en</dc:language>
- <dc:creator opf:file-as="alexwlchan" opf:role="aut">alexwlchan</dc:creator>
- <dc:subject>Fanworks</dc:subject>
- <dc:subject>General Audiences</dc:subject>
- <dc:subject>Operation Mincemeat: A New Musical - SpitLip</dc:subject>
- <dc:subject>No Archive Warnings Apply</dc:subject>
- </metadata>
- <manifest>
- <item id="html4" href="Operation_Cameo_split_000.xhtml" media-type="application/xhtml+xml"/>
- …
- </manifest>
- <spine toc="ncx">
- <itemref idref="html4"/>
- …
- </spine>
- </package>
- `.trim();
-
- assert.deepEqual(
- getFicInfo(xml),
- {
- title: 'Operation Cameo',
- author: 'alexwlchan',
- fandom: 'Operation Mincemeat: A New Musical - SpitLip',
- }
- );
});
});
@@ -108,8 +30,19 @@ QUnit.module('getFicInfo', () => {
QUnit.module('chooseColour', () => {
QUnit.test('it chooses the colour in a reproducible way', (assert) => {
const fandom = 'Operation Mincemeat: A New Musical - SpitLip';
- assert.equal(chooseColour(fandom), '#800600');
+ assert.equal(chooseColour(fandom), '#00805d');
});
+
+ QUnit.test('it chooses different colours for Star Wars and Star Trek', (assert) => {
+ assert.equal(chooseColour('Star Wars'), '#580080');
+ assert.equal(chooseColour('Star Trek'), '#008051');
+ });
+
+ QUnit.test('it creates the same colour for all Star Trek shows', (assert) => {
+ assert.equal(chooseColour('Star Trek'), '#008051');
+ assert.equal(chooseColour('Star Trek TOS'), '#008051');
+ assert.equal(chooseColour('Star Trek: Lower Decks'), '#008051');
+ })
});
test/fixtures/Operation_Cameo.epub (0 → 7105)
diff --git a/test/fixtures/Operation_Cameo.epub b/test/fixtures/Operation_Cameo.epub
new file mode 100644
index 0000000..7076ba7
Binary files /dev/null and b/test/fixtures/Operation_Cameo.epub differ
test/fixtures/example1.zip (172 → 0)
diff --git a/test/fixtures/example1.zip b/test/fixtures/example1.zip
deleted file mode 100644
index 0020a87..0000000
Binary files a/test/fixtures/example1.zip and /dev/null differ
test/fixtures/example2.zip (186 → 0)
diff --git a/test/fixtures/example2.zip b/test/fixtures/example2.zip
deleted file mode 100644
index 85f6c4b..0000000
Binary files a/test/fixtures/example2.zip and /dev/null differ
test/fixtures/example3.zip (174 → 0)
diff --git a/test/fixtures/example3.zip b/test/fixtures/example3.zip
deleted file mode 100644
index 47ce373..0000000
Binary files a/test/fixtures/example3.zip and /dev/null differ