@chronicle.app/goodreads 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/connectors/GoodreadsExtractor.d.ts +18 -0
- package/dist/connectors/GoodreadsExtractor.js +44 -0
- package/dist/connectors/GoodreadsTransformer.d.ts +48 -0
- package/dist/connectors/GoodreadsTransformer.js +225 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +73 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Louis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @chronicle.app/goodreads
|
|
2
|
+
|
|
3
|
+
Chronicle plugin for goodreads
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @chronicle.app/goodreads
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Basic extraction
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
chronicle extract goodreads --input /path/to/goodreads_library_export.csv
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### With date filtering
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
chronicle extract goodreads \
|
|
23
|
+
--input /path/to/goodreads_library_export.csv \
|
|
24
|
+
--since 2024-01-01 \
|
|
25
|
+
--until 2024-12-31 \
|
|
26
|
+
--limit 1000
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
The extractor supports the following options:
|
|
32
|
+
|
|
33
|
+
- `--input` (required): Path to the Goodreads library export CSV
|
|
34
|
+
- `--display-name`: Your name, used as the reading agent's display label
|
|
35
|
+
- `--user-id`: Your Goodreads review-list id — the `{id}-{slug}` (or bare numeric
|
|
36
|
+
id) in your My Books URL, e.g. `1234567-pat-example`. Keys the user agent on
|
|
37
|
+
the real id and gives each shelf its proper permalink; without it the agent and
|
|
38
|
+
shelves fall back to name-based identity.
|
|
39
|
+
- `--since`: Extract records from this date onwards
|
|
40
|
+
- `--until`: Extract records up to this date
|
|
41
|
+
- `--limit`: Maximum number of records to extract
|
|
42
|
+
|
|
43
|
+
## File Format
|
|
44
|
+
|
|
45
|
+
The standard **Goodreads library export** CSV (Goodreads → _My Books_ → _Import
|
|
46
|
+
and export_ → _Export Library_). The first row is the header; the columns used
|
|
47
|
+
are `Book Id`, `Title`, `Author`, `Publisher`, `My Review`, `Bookshelves`,
|
|
48
|
+
`Date Added`, `Date Read`, and `Exclusive Shelf`. Other columns are read through
|
|
49
|
+
but not currently mapped.
|
|
50
|
+
|
|
51
|
+
The export is a re-read of your current shelf state, so the extractor declares
|
|
52
|
+
`temporality = 'snapshot'`: a book's mutable attributes (its review, the shelf
|
|
53
|
+
it sits on) are sighted at read time rather than back-dated to its add/read date,
|
|
54
|
+
so re-exporting after moving a book from _to-read_ to _read_ supersedes cleanly.
|
|
55
|
+
|
|
56
|
+
## Schema
|
|
57
|
+
|
|
58
|
+
Each row maps to a `Book` (keyed on the Goodreads `Book Id`) authored by a
|
|
59
|
+
`Person`, plus actions by the exporting user keyed off the row's shelf and dates:
|
|
60
|
+
|
|
61
|
+
| Goodreads row | Chronicle action | Notes |
|
|
62
|
+
| --------------------------------- | ---------------- | ------------------------------------------------- |
|
|
63
|
+
| Any book with a `Date Added` | `BookmarkAction` | The "saved it" signal, placed at the add date |
|
|
64
|
+
| `Exclusive Shelf` = `read` | `CompleteAction` | Finish, placed at `Date Read` (falls back to add) |
|
|
65
|
+
| `currently-reading` / `to-finish` | `ConsumeAction` | In-progress status, undated (no session start) |
|
|
66
|
+
|
|
67
|
+
On the persistence side these fold into the derived `encountered` (every
|
|
68
|
+
bookmarked book), `completed` (finished books), and `consuming` (in-progress)
|
|
69
|
+
runs. The in-progress shelf is a status, not a reading session: Goodreads has no
|
|
70
|
+
start time, so the bare `ConsumeAction` opens the `consuming` run with an unknown
|
|
71
|
+
start, and a later finish closes it without asserting a reading interval.
|
|
72
|
+
`ReadAction` is reserved for sources that report real sessions.
|
|
73
|
+
|
|
74
|
+
The `Book` also carries metadata from the row: `My Review` → `description`,
|
|
75
|
+
`Author` plus `Additional Authors` → the `author` list (translators, co-authors),
|
|
76
|
+
`Publisher` → a name-keyed `Organization` on `publisher`, and `Number of Pages` →
|
|
77
|
+
`pageCount` (an edition-level count). When an `ISBN13` is present it becomes a
|
|
78
|
+
`sameAs` edge to the `isbn` namespace (a portable standard id), so the edition
|
|
79
|
+
collapses onto the same book read through any other ISBN-bearing source.
|
|
80
|
+
|
|
81
|
+
Each entry in `Bookshelves` becomes a `Collection` the book `isPartOf` — a shelf
|
|
82
|
+
is a real user-owned list, not a free-text tag. With `--user-id` set, the shelf
|
|
83
|
+
gets its true permalink (`…/review/list/{id}?shelf={name}`) and is keyed on that
|
|
84
|
+
url, so the same shelf merges across books and stays distinct across users;
|
|
85
|
+
without it, shelves are keyed on name. The exclusive shelf is dropped, since
|
|
86
|
+
reading state is already the actions above. Because the export is a snapshot,
|
|
87
|
+
removing a book from a shelf closes the membership on the next import.
|
|
88
|
+
|
|
89
|
+
The 1–5 `My Rating` is not yet mapped.
|
|
90
|
+
|
|
91
|
+
## Tests
|
|
92
|
+
|
|
93
|
+
Tests write a small synthetic library export CSV to a temp directory and run it
|
|
94
|
+
through the extractor and transformer, so they never read the host's Goodreads
|
|
95
|
+
data or accounts.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CsvExtractor, Record } from '@chronicle.app/etl';
|
|
2
|
+
import GoodreadsTransformer from './GoodreadsTransformer.js';
|
|
3
|
+
export declare class GoodreadsExtractor extends CsvExtractor {
|
|
4
|
+
static source: string;
|
|
5
|
+
static description: string;
|
|
6
|
+
static delivery: "export";
|
|
7
|
+
static strategy: string;
|
|
8
|
+
static recordTypes: string[];
|
|
9
|
+
static default: boolean;
|
|
10
|
+
static temporality: "snapshot";
|
|
11
|
+
static defaultTransformer: typeof GoodreadsTransformer;
|
|
12
|
+
static schema: any;
|
|
13
|
+
/** The CSV's `Book Id` column — Goodreads' own book id, identical in any export. */
|
|
14
|
+
keyOf(record: Record): string | null;
|
|
15
|
+
constructor(config: any);
|
|
16
|
+
instantiateDefaultTransformer(): GoodreadsTransformer;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=GoodreadsExtractor.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CsvExtractor, configForIo } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import GoodreadsTransformer from './GoodreadsTransformer.js';
|
|
4
|
+
export class GoodreadsExtractor extends CsvExtractor {
|
|
5
|
+
static source = 'goodreads';
|
|
6
|
+
static description = 'Shelves and reading history from a CSV export';
|
|
7
|
+
static delivery = 'export';
|
|
8
|
+
static strategy = 'csv';
|
|
9
|
+
static recordTypes = ['shelves'];
|
|
10
|
+
static default = true;
|
|
11
|
+
// A Goodreads export is a re-read of current shelf state: re-exporting
|
|
12
|
+
// re-reports the same books with their latest shelf/review. So attributes are
|
|
13
|
+
// sighted at read time rather than back-dated to a Date Added/Read event,
|
|
14
|
+
// letting a book moving to-read → read supersede cleanly. See
|
|
15
|
+
// Extractor.temporality.
|
|
16
|
+
static temporality = 'snapshot';
|
|
17
|
+
static defaultTransformer = GoodreadsTransformer;
|
|
18
|
+
static schema = CsvExtractor.schema.extend({
|
|
19
|
+
displayName: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Your name, used as the reading agent's display label"),
|
|
23
|
+
userId: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Goodreads review-list id (the `{id}-{slug}` in your My Books URL, e.g. 1234567-pat-example). Enables real shelf permalinks.'),
|
|
27
|
+
});
|
|
28
|
+
/** The CSV's `Book Id` column — Goodreads' own book id, identical in any export. */
|
|
29
|
+
keyOf(record) {
|
|
30
|
+
return record.data['Book Id'] ?? null;
|
|
31
|
+
}
|
|
32
|
+
constructor(config) {
|
|
33
|
+
const ioConfig = configForIo(config);
|
|
34
|
+
super({
|
|
35
|
+
...config,
|
|
36
|
+
...ioConfig,
|
|
37
|
+
columns: true,
|
|
38
|
+
trimColumns: true,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
instantiateDefaultTransformer() {
|
|
42
|
+
return new GoodreadsTransformer(this.config);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
export default class GoodreadsTransformer extends ChronicleTransformer {
|
|
4
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
5
|
+
private buildGoodreadsActions;
|
|
6
|
+
private buildBook;
|
|
7
|
+
/**
|
|
8
|
+
* The ISBN-13 as a `sameAs` edge to the `isbn` namespace (a portable, standard
|
|
9
|
+
* id → protocol source), so this edition collapses onto the same book read
|
|
10
|
+
* through any other ISBN-bearing source. Carries the title so the edge still
|
|
11
|
+
* names the book if it's what mints it. The keyset is type-free — the ISBN
|
|
12
|
+
* names one work however a source typed it — and must stay identical across
|
|
13
|
+
* every plugin minting into the `isbn` namespace.
|
|
14
|
+
*/
|
|
15
|
+
private buildIsbnSameAs;
|
|
16
|
+
/** Goodreads wraps ISBNs as `="9780…"`; return the bare 13-digit id, or none. */
|
|
17
|
+
private isbn13;
|
|
18
|
+
/**
|
|
19
|
+
* The user's custom shelves, as Collections the book `isPartOf`. A shelf is a
|
|
20
|
+
* real user-owned list with a permalink, not a free-text tag. The `Bookshelves`
|
|
21
|
+
* column repeats the exclusive shelf (the reading state, already modeled as
|
|
22
|
+
* actions), so drop it.
|
|
23
|
+
*/
|
|
24
|
+
private buildShelves;
|
|
25
|
+
/**
|
|
26
|
+
* A shelf as a Collection. When the user's review-list id is configured we can
|
|
27
|
+
* build the real per-user permalink and key on it (distinct across users);
|
|
28
|
+
* otherwise the shelf is keyed on its name within the source.
|
|
29
|
+
*/
|
|
30
|
+
private buildShelf;
|
|
31
|
+
private buildPublisher;
|
|
32
|
+
/**
|
|
33
|
+
* The primary author followed by any `Additional Authors` (translators,
|
|
34
|
+
* co-authors, illustrators — a comma-separated list), deduped by name.
|
|
35
|
+
*/
|
|
36
|
+
private buildAuthors;
|
|
37
|
+
private buildAuthor;
|
|
38
|
+
private buildUser;
|
|
39
|
+
/**
|
|
40
|
+
* The user's numeric Goodreads id from config, the stable handle behind both
|
|
41
|
+
* the profile and shelf permalinks. Accepts either the bare id or the
|
|
42
|
+
* `{id}-{slug}` token (the slug is cosmetic and can change), normalizing to
|
|
43
|
+
* the numeric part.
|
|
44
|
+
*/
|
|
45
|
+
private userId;
|
|
46
|
+
private parseDate;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=GoodreadsTransformer.d.ts.map
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { ChronicleTransformer } from '@chronicle.app/etl';
|
|
2
|
+
export default class GoodreadsTransformer extends ChronicleTransformer {
|
|
3
|
+
async transform(record) {
|
|
4
|
+
const actions = [];
|
|
5
|
+
if (record.extraction.recordType === 'shelves') {
|
|
6
|
+
actions.push(...(await this.buildGoodreadsActions(record)));
|
|
7
|
+
}
|
|
8
|
+
return actions;
|
|
9
|
+
}
|
|
10
|
+
async buildGoodreadsActions(record) {
|
|
11
|
+
const actions = [];
|
|
12
|
+
const { data } = record;
|
|
13
|
+
const user = this.buildUser();
|
|
14
|
+
const book = this.buildBook(data);
|
|
15
|
+
const dateAdded = data['Date Added'] ? this.parseDate(data['Date Added']) : undefined;
|
|
16
|
+
const dateRead = data['Date Read']?.trim() ? this.parseDate(data['Date Read']) : undefined;
|
|
17
|
+
// Adding a book to any shelf is a bookmark (the "saved it" signal), placed
|
|
18
|
+
// at when it was added.
|
|
19
|
+
if (dateAdded) {
|
|
20
|
+
actions.push({
|
|
21
|
+
'@type': 'BookmarkAction',
|
|
22
|
+
'@key': ['@type', 'source', 'object.sourceId', 'timestamp'],
|
|
23
|
+
source: 'goodreads',
|
|
24
|
+
agent: user,
|
|
25
|
+
object: book,
|
|
26
|
+
timestamp: dateAdded,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// The exclusive shelf is the book's reading state. `read` is a finish (a
|
|
30
|
+
// CompleteAction, which opens the `completed` run on the persistence side).
|
|
31
|
+
// An in-progress shelf is a status, not a session: Goodreads knows the book
|
|
32
|
+
// is being read but not when reading started, so it opens the `consuming`
|
|
33
|
+
// run with a bare, undated ConsumeAction (unknown start). ReadAction is
|
|
34
|
+
// reserved for genuine reading sessions with a real start.
|
|
35
|
+
const shelf = data['Exclusive Shelf'];
|
|
36
|
+
if (shelf === 'read') {
|
|
37
|
+
const finishedAt = dateRead ?? dateAdded;
|
|
38
|
+
actions.push({
|
|
39
|
+
'@type': 'CompleteAction',
|
|
40
|
+
'@key': ['@type', 'source', 'object.sourceId', 'timestamp'],
|
|
41
|
+
source: 'goodreads',
|
|
42
|
+
agent: user,
|
|
43
|
+
object: book,
|
|
44
|
+
...(finishedAt && { timestamp: finishedAt }),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
else if (shelf === 'currently-reading' || shelf === 'to-finish') {
|
|
48
|
+
actions.push({
|
|
49
|
+
'@type': 'ConsumeAction',
|
|
50
|
+
'@key': ['@type', 'source', 'object.sourceId'],
|
|
51
|
+
source: 'goodreads',
|
|
52
|
+
agent: user,
|
|
53
|
+
object: book,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return actions;
|
|
57
|
+
}
|
|
58
|
+
buildBook(data) {
|
|
59
|
+
const book = {
|
|
60
|
+
'@type': 'Book',
|
|
61
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
62
|
+
source: 'goodreads',
|
|
63
|
+
sourceId: data['Book Id'],
|
|
64
|
+
name: data.Title,
|
|
65
|
+
url: `https://www.goodreads.com/book/show/${data['Book Id']}`,
|
|
66
|
+
};
|
|
67
|
+
const review = data['My Review']?.trim();
|
|
68
|
+
if (review)
|
|
69
|
+
book.description = review;
|
|
70
|
+
const authors = this.buildAuthors(data.Author, data['Additional Authors']);
|
|
71
|
+
if (authors.length > 0)
|
|
72
|
+
book.author = authors;
|
|
73
|
+
const pages = Number.parseInt(data['Number of Pages'], 10);
|
|
74
|
+
if (Number.isFinite(pages) && pages > 0)
|
|
75
|
+
book.pageCount = pages;
|
|
76
|
+
const shelves = this.buildShelves(data.Bookshelves, data['Exclusive Shelf']);
|
|
77
|
+
if (shelves.length > 0) {
|
|
78
|
+
book.isPartOf = shelves;
|
|
79
|
+
// A book removed from all custom shelves drops `isPartOf` from the payload,
|
|
80
|
+
// which the snapshot `'*'` default wouldn't close — list it so un-shelving
|
|
81
|
+
// closes the old memberships.
|
|
82
|
+
book['@asserts'] = ['isPartOf'];
|
|
83
|
+
}
|
|
84
|
+
const publisher = data.Publisher?.trim();
|
|
85
|
+
if (publisher)
|
|
86
|
+
book.publisher = [this.buildPublisher(publisher)];
|
|
87
|
+
const isbn13 = this.isbn13(data.ISBN13);
|
|
88
|
+
if (isbn13)
|
|
89
|
+
book.sameAs = [this.buildIsbnSameAs(isbn13, data.Title)];
|
|
90
|
+
return book;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The ISBN-13 as a `sameAs` edge to the `isbn` namespace (a portable, standard
|
|
94
|
+
* id → protocol source), so this edition collapses onto the same book read
|
|
95
|
+
* through any other ISBN-bearing source. Carries the title so the edge still
|
|
96
|
+
* names the book if it's what mints it. The keyset is type-free — the ISBN
|
|
97
|
+
* names one work however a source typed it — and must stay identical across
|
|
98
|
+
* every plugin minting into the `isbn` namespace.
|
|
99
|
+
*/
|
|
100
|
+
buildIsbnSameAs(isbn13, title) {
|
|
101
|
+
return {
|
|
102
|
+
'@type': 'Book',
|
|
103
|
+
'@key': ['source', 'sourceId'],
|
|
104
|
+
source: 'isbn',
|
|
105
|
+
sourceId: isbn13,
|
|
106
|
+
name: title,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Goodreads wraps ISBNs as `="9780…"`; return the bare 13-digit id, or none. */
|
|
110
|
+
isbn13(raw) {
|
|
111
|
+
const digits = (raw ?? '').replaceAll(/\D/g, '');
|
|
112
|
+
return digits.length === 13 ? digits : undefined;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The user's custom shelves, as Collections the book `isPartOf`. A shelf is a
|
|
116
|
+
* real user-owned list with a permalink, not a free-text tag. The `Bookshelves`
|
|
117
|
+
* column repeats the exclusive shelf (the reading state, already modeled as
|
|
118
|
+
* actions), so drop it.
|
|
119
|
+
*/
|
|
120
|
+
buildShelves(bookshelves, exclusiveShelf) {
|
|
121
|
+
if (!bookshelves)
|
|
122
|
+
return [];
|
|
123
|
+
const exclusive = exclusiveShelf?.trim();
|
|
124
|
+
return bookshelves
|
|
125
|
+
.split(',')
|
|
126
|
+
.map(s => s.trim())
|
|
127
|
+
.filter(s => s && s !== exclusive)
|
|
128
|
+
.map(shelf => this.buildShelf(shelf));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A shelf as a Collection. When the user's review-list id is configured we can
|
|
132
|
+
* build the real per-user permalink and key on it (distinct across users);
|
|
133
|
+
* otherwise the shelf is keyed on its name within the source.
|
|
134
|
+
*/
|
|
135
|
+
buildShelf(shelf) {
|
|
136
|
+
const userId = this.userId();
|
|
137
|
+
if (userId) {
|
|
138
|
+
return {
|
|
139
|
+
'@type': 'Collection',
|
|
140
|
+
'@key': ['@type', 'url'],
|
|
141
|
+
source: 'goodreads',
|
|
142
|
+
name: shelf,
|
|
143
|
+
url: `https://www.goodreads.com/review/list/${userId}?shelf=${encodeURIComponent(shelf)}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
'@type': 'Collection',
|
|
148
|
+
'@key': ['@type', 'source', 'name'],
|
|
149
|
+
source: 'goodreads',
|
|
150
|
+
name: shelf,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
buildPublisher(name) {
|
|
154
|
+
return {
|
|
155
|
+
'@type': 'Organization',
|
|
156
|
+
'@key': ['@type', 'source', 'name'],
|
|
157
|
+
source: 'goodreads',
|
|
158
|
+
name,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The primary author followed by any `Additional Authors` (translators,
|
|
163
|
+
* co-authors, illustrators — a comma-separated list), deduped by name.
|
|
164
|
+
*/
|
|
165
|
+
buildAuthors(primary, additional) {
|
|
166
|
+
const names = [primary ?? '', ...(additional ?? '').split(',')]
|
|
167
|
+
.map(n => n.trim())
|
|
168
|
+
.filter(n => n.length > 0);
|
|
169
|
+
return [...new Set(names)].map(name => this.buildAuthor(name));
|
|
170
|
+
}
|
|
171
|
+
buildAuthor(authorName) {
|
|
172
|
+
return {
|
|
173
|
+
'@type': 'Person',
|
|
174
|
+
'@key': ['@type', 'source', 'name'],
|
|
175
|
+
source: 'goodreads',
|
|
176
|
+
name: authorName,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
buildUser() {
|
|
180
|
+
const displayName = this.config.displayName?.trim();
|
|
181
|
+
// The numeric Goodreads id is the user's real, stable identity. When it's
|
|
182
|
+
// configured, key on it (and link the profile) so the agent survives a
|
|
183
|
+
// display-name change and stays distinct across imported users; the name is
|
|
184
|
+
// then just an optional label. Without an id we can only key on the name.
|
|
185
|
+
const userId = this.userId();
|
|
186
|
+
if (userId) {
|
|
187
|
+
return {
|
|
188
|
+
'@type': 'Person',
|
|
189
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
190
|
+
source: 'goodreads',
|
|
191
|
+
sourceId: userId,
|
|
192
|
+
...(displayName && { name: displayName }),
|
|
193
|
+
url: `https://www.goodreads.com/user/show/${userId}`,
|
|
194
|
+
sameAs: ['@me'],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
'@type': 'Person',
|
|
199
|
+
'@key': ['@type', 'source', 'name'],
|
|
200
|
+
source: 'goodreads',
|
|
201
|
+
name: displayName || 'Unknown User',
|
|
202
|
+
sameAs: ['@me'],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The user's numeric Goodreads id from config, the stable handle behind both
|
|
207
|
+
* the profile and shelf permalinks. Accepts either the bare id or the
|
|
208
|
+
* `{id}-{slug}` token (the slug is cosmetic and can change), normalizing to
|
|
209
|
+
* the numeric part.
|
|
210
|
+
*/
|
|
211
|
+
userId() {
|
|
212
|
+
const raw = this.config.userId?.trim();
|
|
213
|
+
return raw ? raw.split('-')[0] : undefined;
|
|
214
|
+
}
|
|
215
|
+
parseDate(dateString) {
|
|
216
|
+
if (!dateString)
|
|
217
|
+
return new Date();
|
|
218
|
+
// Handle Goodreads date format: YYYY/MM/DD
|
|
219
|
+
const parts = dateString.split('/');
|
|
220
|
+
if (parts.length === 3) {
|
|
221
|
+
return new Date(Number.parseInt(parts[0]), Number.parseInt(parts[1]) - 1, Number.parseInt(parts[2]));
|
|
222
|
+
}
|
|
223
|
+
return new Date(dateString);
|
|
224
|
+
}
|
|
225
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronicle.app/goodreads",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**/*.js",
|
|
8
|
+
"dist/**/*.d.ts"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -b tsconfig.lib.json",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
13
|
+
"lint": "eslint src --ext .ts,.js",
|
|
14
|
+
"test": "node --test src/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"chronicle",
|
|
18
|
+
"goodreads"
|
|
19
|
+
],
|
|
20
|
+
"author": "Andrew Louis",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"description": "Chronicle plugin for reading history and book data from Goodreads",
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@chronicle.app/etl": "0.3.0",
|
|
26
|
+
"@chronicle.app/schema": "0.3.0",
|
|
27
|
+
"@chronicle.app/tsconfig": "0.3.0",
|
|
28
|
+
"@types/node": "^22.10.1",
|
|
29
|
+
"typescript": "^5.7.2"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://chronicle.app",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/chronicle-app/chronicle.git",
|
|
35
|
+
"directory": "plugins/goodreads"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"zod": "^3.24.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@chronicle.app/etl": ">=0.1.0 <1.0.0",
|
|
46
|
+
"@chronicle.app/schema": ">=0.1.0 <1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.13.0"
|
|
50
|
+
},
|
|
51
|
+
"exports": {
|
|
52
|
+
".": {
|
|
53
|
+
"types": "./dist/index.d.ts",
|
|
54
|
+
"import": "./dist/index.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"chronicle": {
|
|
58
|
+
"plugin": true,
|
|
59
|
+
"sources": {
|
|
60
|
+
"goodreads": {
|
|
61
|
+
"strategies": {
|
|
62
|
+
"csv": {
|
|
63
|
+
"delivery": "export",
|
|
64
|
+
"recordTypes": [
|
|
65
|
+
"shelves"
|
|
66
|
+
],
|
|
67
|
+
"default": true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|