@chronicle.app/google-reader 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 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,49 @@
1
+ # @chronicle.app/google-reader
2
+
3
+ Import personal actions from an unpacked Google Reader archive containing raw Reader API stream responses. This is a Chronicle workspace plugin.
4
+
5
+ ## Usage
6
+
7
+ From a built Chronicle checkout:
8
+
9
+ ```bash
10
+ chronicle extract google-reader --input /path/to/reader-archive
11
+ chronicle extract google-reader --input /path/to/reader-archive \
12
+ --since 2010-01-01 --until 2010-12-31 --limit 1000
13
+ ```
14
+
15
+ `--input` is a directory with this layout:
16
+
17
+ ```text
18
+ reader-archive/
19
+ data/user-info.json
20
+ _raw_data/www.google.com-reader-api-0-stream-contents-user-<id>-<stream>.json
21
+ ```
22
+
23
+ Each stream JSON has an `items` array. Items carry `id`, `title`, a microsecond `timestampUsec` string, and `categories` such as `user/123/state/com.google/read`. Optional `alternate`, `content`/`summary`, and `annotations` supply article URLs, bodies, and notes. Other archive files are ignored; this plugin does not parse OPML, ZIP files, preferences, or separate friend-activity files.
24
+
25
+ The archive owner's ID comes from `user_id` in `data/user-info.json`; `user_name` and `email` provide optional profile details. Use `--user-id 123` to explicitly select your account, including when user info is missing. This also restricts stream filenames to that user. Only that account's states and annotations become actions, and only that account is linked to `@me`. The owner also links to a `google-account` identity using the Reader user ID and, when matching user info supplies an email, an `email` identity keyed by that address. These `sameAs` references resolve to the same entity on import; no email identity is inferred when the address is missing.
26
+
27
+ `--since` and `--until` are inclusive bounds on the item's `timestampUsec`. `--limit` caps matching raw items across all files, not generated actions; `0` means unlimited. Files are processed in filename order, not chronological order. Invalid stream JSON or item timestamps fail the extraction rather than silently dropping a file.
28
+
29
+ ## Output
30
+
31
+ - `read` → `ReadAction`
32
+ - `broadcast` → `PublishAction`
33
+ - `starred` → `LikeAction`
34
+ - Owner annotations → `AnnotateAction` with a `Comment` result
35
+
36
+ Actions link the owner to an `Article`, identified by its Reader item ID even when its URL is missing. Annotations are distinguished by article, author, and text; identical notes by the same author on the same article deduplicate because the supported annotation format has no separate note ID. Raw records include the individual item and stream metadata, without repeating the full stream.
37
+
38
+ All actions on an item use its archive timestamp. Category state alone does not establish separate read, share, star, or annotation times; the output should not be interpreted as precise session timing.
39
+
40
+ ## Development
41
+
42
+ ```bash
43
+ npm run build -w @chronicle.app/google-reader
44
+ npm run test -w @chronicle.app/google-reader
45
+ npm run lint -w @chronicle.app/google-reader
46
+ npm run typecheck -w @chronicle.app/google-reader
47
+ ```
48
+
49
+ Tests build small synthetic archives (a `user-info.json` and raw stream JSON files) in a temporary directory, run them through the extractor and transformer, and validate the output against the Chronicle schema. They never read the host's data or Google account and make no network requests.
@@ -0,0 +1,37 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import GoogleReaderTransformer from './GoogleReaderTransformer.js';
4
+ export declare class GoogleReaderExtractor extends Extractor<typeof GoogleReaderExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "export";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static default: boolean;
11
+ static defaultTransformer: typeof GoogleReaderTransformer;
12
+ static schema: z.ZodObject<{
13
+ since: z.ZodOptional<z.ZodDate>;
14
+ until: z.ZodOptional<z.ZodDate>;
15
+ limit: z.ZodOptional<z.ZodNumber>;
16
+ } & {
17
+ input: z.ZodString;
18
+ userId: z.ZodOptional<z.ZodString>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ input: string;
21
+ userId?: string | undefined;
22
+ since?: Date | undefined;
23
+ until?: Date | undefined;
24
+ limit?: number | undefined;
25
+ }, {
26
+ input: string;
27
+ userId?: string | undefined;
28
+ since?: Date | undefined;
29
+ until?: Date | undefined;
30
+ limit?: number | undefined;
31
+ }>;
32
+ extract(): AsyncGenerator<Record>;
33
+ private processStreamFile;
34
+ private isPersonalUserAction;
35
+ determineCount(): Promise<number | null>;
36
+ }
37
+ //# sourceMappingURL=GoogleReaderExtractor.d.ts.map
@@ -0,0 +1,128 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { join, basename } from 'node:path';
4
+ import { z } from 'zod';
5
+ import GoogleReaderTransformer from './GoogleReaderTransformer.js';
6
+ export class GoogleReaderExtractor extends Extractor {
7
+ static source = 'google-reader';
8
+ static description = 'Read, starred, and shared items from Takeout';
9
+ static delivery = 'export';
10
+ static strategy = 'takeout';
11
+ static recordTypes = ['stream-contents'];
12
+ static default = true;
13
+ static defaultTransformer = GoogleReaderTransformer;
14
+ static schema = Extractor.schema.extend({
15
+ input: z.string().describe('Path to the google-reader archive directory'),
16
+ userId: z.string().optional().describe('Specific user ID to filter for (optional)'),
17
+ });
18
+ async *extract() {
19
+ const config = this.config;
20
+ this.logger.verboseInfo('Starting Google Reader archive extraction', {
21
+ archivePath: config.input,
22
+ userId: config.userId,
23
+ limit: config.limit,
24
+ });
25
+ try {
26
+ // Load user info
27
+ const userInfoPath = join(config.input, 'data', 'user-info.json');
28
+ let userInfo = null;
29
+ try {
30
+ const userInfoContent = await readFile(userInfoPath, 'utf-8');
31
+ userInfo = JSON.parse(userInfoContent);
32
+ this.logger.info('Loaded user info', {
33
+ userName: userInfo?.user_name,
34
+ userId: userInfo?.user_id,
35
+ });
36
+ }
37
+ catch (error) {
38
+ this.logger.warn('Could not load user-info.json', {
39
+ error: error instanceof Error ? error.message : String(error),
40
+ });
41
+ }
42
+ const ownerId = config.userId ?? userInfo?.user_id;
43
+ if (!ownerId) {
44
+ throw new Error('Provide --user-id when data/user-info.json has no user_id');
45
+ }
46
+ const rawDataPath = join(config.input, '_raw_data');
47
+ const files = (await readdir(rawDataPath))
48
+ .sort()
49
+ .filter(file => file.startsWith('www.google.com-reader-api-0-stream-contents-user-') &&
50
+ (!config.userId || file.includes(`-user-${config.userId}-`)));
51
+ let totalProcessed = 0;
52
+ for (const file of files) {
53
+ if (this.shouldStopExtracting(totalProcessed))
54
+ break;
55
+ // Stream sequentially so the global cap avoids reading later files.
56
+ // eslint-disable-next-line no-await-in-loop
57
+ for await (const record of this.processStreamFile(join(rawDataPath, file), ownerId, userInfo)) {
58
+ yield record;
59
+ totalProcessed++;
60
+ if (this.shouldStopExtracting(totalProcessed))
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ catch (error) {
66
+ this.logger.error('Failed to process Google Reader archive', {
67
+ error: error instanceof Error ? error.message : String(error),
68
+ });
69
+ throw new Error(`Failed to process Google Reader archive: ${error}`);
70
+ }
71
+ }
72
+ async *processStreamFile(filePath, userId, userInfo) {
73
+ try {
74
+ const content = await readFile(filePath, 'utf-8');
75
+ const streamData = JSON.parse(content);
76
+ if (!streamData.items) {
77
+ this.logger.warn('Stream file has no items', { filePath });
78
+ return;
79
+ }
80
+ this.logger.info(`Processing ${streamData.items.length} items from stream`, {
81
+ streamTitle: streamData.title,
82
+ itemCount: streamData.items.length,
83
+ });
84
+ const { items, ...streamMetadata } = streamData;
85
+ for (const item of items) {
86
+ if (!this.isPersonalUserAction(item, userId))
87
+ continue;
88
+ const timestamp = new Date(Number(item.timestampUsec) / 1000);
89
+ if (!Number.isFinite(timestamp.getTime())) {
90
+ throw new TypeError(`Invalid timestampUsec for item ${item.id}`);
91
+ }
92
+ if (this.config.since && timestamp < this.config.since)
93
+ continue;
94
+ if (this.config.until && timestamp > this.config.until)
95
+ continue;
96
+ yield this.createRecord({
97
+ type: 'stream-content',
98
+ userId,
99
+ userInfo,
100
+ streamData: streamMetadata,
101
+ item,
102
+ filePath: basename(filePath),
103
+ }, {
104
+ itemId: item.id,
105
+ timestamp: item.timestampUsec,
106
+ });
107
+ }
108
+ }
109
+ catch (error) {
110
+ this.logger.error('Failed to process stream file', {
111
+ filePath,
112
+ error: error instanceof Error ? error.message : String(error),
113
+ });
114
+ throw error;
115
+ }
116
+ }
117
+ isPersonalUserAction(item, userId) {
118
+ if (!item.categories)
119
+ return false;
120
+ return (item.categories.some(category => ['read', 'broadcast', 'starred'].some(state => category === `user/${userId}/state/com.google/${state}`)) || Boolean(item.annotations?.some(annotation => annotation.userId === userId)));
121
+ }
122
+ async determineCount() {
123
+ let count = 0;
124
+ for await (const _record of this.extract())
125
+ count++;
126
+ return count;
127
+ }
128
+ }
@@ -0,0 +1,11 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class GoogleReaderTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildStreamContentActions;
6
+ private buildUser;
7
+ private buildArticle;
8
+ private classifyActions;
9
+ private convertTimestamp;
10
+ }
11
+ //# sourceMappingURL=GoogleReaderTransformer.d.ts.map
@@ -0,0 +1,158 @@
1
+ import { ChronicleTransformer, selfAgent } from '@chronicle.app/etl';
2
+ export default class GoogleReaderTransformer extends ChronicleTransformer {
3
+ async transform(record) {
4
+ this.logger.debug('Transforming google-reader record', {
5
+ recordType: record.extraction.recordType,
6
+ });
7
+ const actions = [];
8
+ if (record.extraction.recordType === 'stream-contents') {
9
+ actions.push(...this.buildStreamContentActions(record));
10
+ }
11
+ this.logger.verboseInfo(`Generated ${actions.length} actions from google-reader record`);
12
+ return actions;
13
+ }
14
+ buildStreamContentActions(record) {
15
+ const actions = [];
16
+ const { item } = record.data;
17
+ if (!item.categories)
18
+ return actions;
19
+ const personalUserId = record.data.userId ?? record.data.userInfo?.user_id;
20
+ if (!personalUserId)
21
+ return actions;
22
+ const user = this.buildUser(personalUserId, record.data.userInfo);
23
+ const article = this.buildArticle(item);
24
+ // Detect action types from categories
25
+ const actionTypes = this.classifyActions(item, personalUserId);
26
+ // Convert microsecond timestamp to Date object
27
+ const timestamp = this.convertTimestamp(item.timestampUsec);
28
+ for (const actionType of actionTypes) {
29
+ switch (actionType) {
30
+ case 'ReadAction':
31
+ actions.push({
32
+ '@type': 'ReadAction',
33
+ '@key': ['@type', 'source', 'sourceId', 'agent.sourceId'],
34
+ source: 'google-reader',
35
+ sourceId: item.id,
36
+ timestamp,
37
+ agent: user,
38
+ object: article,
39
+ });
40
+ break;
41
+ case 'PublishAction':
42
+ actions.push({
43
+ '@type': 'PublishAction',
44
+ '@key': ['@type', 'source', 'sourceId', 'agent.sourceId'],
45
+ source: 'google-reader',
46
+ sourceId: item.id,
47
+ timestamp,
48
+ agent: user,
49
+ object: article,
50
+ });
51
+ break;
52
+ case 'LikeAction':
53
+ actions.push({
54
+ '@type': 'LikeAction',
55
+ '@key': ['@type', 'source', 'sourceId', 'agent.sourceId'],
56
+ source: 'google-reader',
57
+ sourceId: item.id,
58
+ timestamp,
59
+ agent: user,
60
+ object: article,
61
+ });
62
+ break;
63
+ }
64
+ }
65
+ // Handle annotations separately
66
+ if (item.annotations) {
67
+ const userAnnotations = item.annotations.filter((ann) => ann.userId === personalUserId);
68
+ for (const annotation of userAnnotations) {
69
+ const comment = {
70
+ '@type': 'Comment',
71
+ '@key': ['@type', 'source', 'body', 'about.0.sourceId', 'author.0.sourceId'],
72
+ source: 'google-reader',
73
+ body: annotation.content,
74
+ author: [user],
75
+ about: [article],
76
+ };
77
+ actions.push({
78
+ '@type': 'AnnotateAction',
79
+ '@key': ['@type', 'source', 'object.sourceId', 'agent.sourceId', 'result.body'],
80
+ source: 'google-reader',
81
+ timestamp,
82
+ agent: user,
83
+ object: article,
84
+ result: comment,
85
+ });
86
+ }
87
+ }
88
+ // Article is already included as the object of each action
89
+ return actions;
90
+ }
91
+ buildUser(userId, userInfo) {
92
+ const profile = userInfo?.user_id === userId ? userInfo : null;
93
+ const email = profile?.email?.trim() || undefined;
94
+ const name = profile?.user_name;
95
+ const sameAs = [];
96
+ if (email) {
97
+ sameAs.push({
98
+ '@type': 'Agent',
99
+ '@key': ['@type', 'source', 'handle'],
100
+ source: 'email',
101
+ handle: email,
102
+ ...(name && { name }),
103
+ });
104
+ }
105
+ sameAs.push({
106
+ '@type': 'Agent',
107
+ '@key': ['@type', 'source', 'sourceId'],
108
+ source: 'google-account',
109
+ sourceId: userId,
110
+ ...(name && { name }),
111
+ });
112
+ return selfAgent({
113
+ type: 'Agent',
114
+ source: 'google-reader',
115
+ sourceId: userId,
116
+ handle: email,
117
+ name,
118
+ sameAs,
119
+ });
120
+ }
121
+ buildArticle(item) {
122
+ const url = item.alternate?.[0]?.href || '';
123
+ return {
124
+ '@type': 'Article',
125
+ '@key': ['@type', 'source', 'sourceId'],
126
+ source: 'google-reader',
127
+ sourceId: item.id,
128
+ ...(url && { url }),
129
+ name: item.title,
130
+ body: item.content?.content || item.summary?.content || '',
131
+ // Note: author field expects EntityAndChildren[] but we only have string
132
+ // Could create Agent entities for authors if needed
133
+ };
134
+ }
135
+ classifyActions(item, userId) {
136
+ const actions = [];
137
+ if (!item.categories)
138
+ return actions;
139
+ for (const category of item.categories) {
140
+ // Only match exact personal states, not friend states
141
+ if (category === `user/${userId}/state/com.google/read`) {
142
+ actions.push('ReadAction');
143
+ }
144
+ if (category === `user/${userId}/state/com.google/broadcast`) {
145
+ actions.push('PublishAction');
146
+ }
147
+ if (category === `user/${userId}/state/com.google/starred`) {
148
+ actions.push('LikeAction');
149
+ }
150
+ }
151
+ return actions;
152
+ }
153
+ convertTimestamp(timestampUsec) {
154
+ // Convert microseconds to milliseconds and create Date object
155
+ const milliseconds = Math.floor(Number.parseInt(timestampUsec) / 1000);
156
+ return new Date(milliseconds);
157
+ }
158
+ }
@@ -0,0 +1,49 @@
1
+ export interface GoogleReaderItem {
2
+ id: string;
3
+ title: string;
4
+ published: number;
5
+ updated: number;
6
+ timestampUsec: string;
7
+ crawlTimeMsec: string;
8
+ categories: string[];
9
+ alternate?: Array<{
10
+ href: string;
11
+ type: string;
12
+ }>;
13
+ content?: {
14
+ content: string;
15
+ direction?: string;
16
+ };
17
+ summary?: {
18
+ content: string;
19
+ direction?: string;
20
+ };
21
+ annotations?: Array<{
22
+ content: string;
23
+ author: string;
24
+ userId: string;
25
+ }>;
26
+ author?: string;
27
+ origin: {
28
+ streamId: string;
29
+ title: string;
30
+ htmlUrl?: string;
31
+ };
32
+ }
33
+ export interface GoogleReaderStream {
34
+ id: string;
35
+ title: string;
36
+ author?: string;
37
+ items: GoogleReaderItem[];
38
+ }
39
+ export interface UserInfo {
40
+ user_id: string;
41
+ email: string;
42
+ profile_id: string;
43
+ user_name: string;
44
+ public_user_name: string;
45
+ is_blogger_user: boolean;
46
+ signup_time_sec: number;
47
+ is_multi_login_enabled: boolean;
48
+ }
49
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export { GoogleReaderExtractor } from './connectors/GoogleReaderExtractor.js';
2
+ export { default as GoogleReaderTransformer } from './connectors/GoogleReaderTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { GoogleReaderExtractor } from './connectors/GoogleReaderExtractor.js';
2
+ export { default as GoogleReaderTransformer } from './connectors/GoogleReaderTransformer.js';
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@chronicle.app/google-reader",
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
+ "google-reader"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Import personal actions from Google Reader archive streams",
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/google-reader"
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
+ "google-reader": {
61
+ "strategies": {
62
+ "takeout": {
63
+ "delivery": "export",
64
+ "recordTypes": [
65
+ "stream-contents"
66
+ ],
67
+ "default": true
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }