@chronicle.app/safari 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 +9 -0
- package/dist/connectors/SafariExtractor.d.ts +21 -0
- package/dist/connectors/SafariExtractor.js +110 -0
- package/dist/connectors/SafariTransformer.d.ts +8 -0
- package/dist/connectors/SafariTransformer.js +36 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +78 -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,9 @@
|
|
|
1
|
+
# @chronicle.app/safari
|
|
2
|
+
|
|
3
|
+
Read Safari's `History.db` with `SafariExtractor`, then emit one `ViewAction` per page visit with `SafariTransformer`. `input` defaults to `~/Library/Safari/History.db`; reading it needs Full Disk Access. The connection is read-only via built-in `node:sqlite`.
|
|
4
|
+
|
|
5
|
+
Records are visits, newest first, with redirect hops left out. `since`/`until` are exclusive visit-time bounds, and `limit: 0` means unlimited.
|
|
6
|
+
|
|
7
|
+
Each `ViewAction` is keyed by its timestamp. The visited page is an `Entity` keyed by its URL and named after the page title, or the URL when there is none. The agent is the iCloud account owner from `@chronicle.app/icloud`, omitted when no account can be read. An explicit `account` object (or null) describes exported data without a host account lookup.
|
|
8
|
+
|
|
9
|
+
Tests use a synthetic history database and never read the host's browsing history or iCloud account.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
|
|
3
|
+
import SafariTransformer from './SafariTransformer.js';
|
|
4
|
+
export declare class SafariExtractor extends SqliteExtractor<typeof SafariExtractor> {
|
|
5
|
+
static source: string;
|
|
6
|
+
static delivery: "local";
|
|
7
|
+
static strategy: string;
|
|
8
|
+
static description: string;
|
|
9
|
+
static default: boolean;
|
|
10
|
+
static recordTypes: string[];
|
|
11
|
+
static newestFirst: boolean;
|
|
12
|
+
static schema: any;
|
|
13
|
+
static defaultTransformer: typeof SafariTransformer;
|
|
14
|
+
/** history_visits.id — Safari's own visit id, AUTOINCREMENT so never reused. */
|
|
15
|
+
keyOf(record: Record): string | null;
|
|
16
|
+
private visitFilter;
|
|
17
|
+
extract(): AsyncGenerator<Record>;
|
|
18
|
+
private processRow;
|
|
19
|
+
determineCount(): Promise<number | null>;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=SafariExtractor.d.ts.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, timeRangeConditions, unixToSafariTimestamp, iterateRows, } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import { getICloudAccount } from '@chronicle.app/icloud';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import SafariTransformer from './SafariTransformer.js';
|
|
5
|
+
// since/until bounds land on visit_time, which is seconds since the Apple epoch
|
|
6
|
+
const safariBound = (date) => unixToSafariTimestamp(Math.floor(date.getTime() / 1000));
|
|
7
|
+
export class SafariExtractor extends SqliteExtractor {
|
|
8
|
+
static source = 'safari';
|
|
9
|
+
static delivery = 'local';
|
|
10
|
+
static strategy = 'app-db';
|
|
11
|
+
static description = 'Browsing history';
|
|
12
|
+
static default = true;
|
|
13
|
+
static recordTypes = ['views'];
|
|
14
|
+
// extract() orders by history_visits.visit_time desc, which with keyOf opts
|
|
15
|
+
// this extractor into the frontier cursor (incremental catch-up import).
|
|
16
|
+
static newestFirst = true;
|
|
17
|
+
static schema = SqliteExtractor.schema.omit({ input: true }).extend({
|
|
18
|
+
input: z
|
|
19
|
+
.string()
|
|
20
|
+
.describe('Path to Safari History.db file')
|
|
21
|
+
.default(`${process.env.HOME}/Library/Safari/History.db`),
|
|
22
|
+
account: z
|
|
23
|
+
.object({
|
|
24
|
+
accountID: z.string(),
|
|
25
|
+
email: z.string(),
|
|
26
|
+
displayName: z.string(),
|
|
27
|
+
dsid: z.string().optional(),
|
|
28
|
+
})
|
|
29
|
+
.nullable()
|
|
30
|
+
.optional(),
|
|
31
|
+
});
|
|
32
|
+
static defaultTransformer = SafariTransformer;
|
|
33
|
+
/** history_visits.id — Safari's own visit id, AUTOINCREMENT so never reused. */
|
|
34
|
+
keyOf(record) {
|
|
35
|
+
const visitId = record.data.visit_id;
|
|
36
|
+
return visitId === null || visitId === undefined ? null : String(visitId);
|
|
37
|
+
}
|
|
38
|
+
// Visits that landed on a page (redirect hops excluded), within since/until.
|
|
39
|
+
visitFilter() {
|
|
40
|
+
const range = timeRangeConditions('history_visits.visit_time', this.config, {
|
|
41
|
+
convert: safariBound,
|
|
42
|
+
});
|
|
43
|
+
const conditions = ['history_visits.redirect_destination IS NULL', ...range.conditions];
|
|
44
|
+
return { where: conditions.join(' AND '), values: range.values };
|
|
45
|
+
}
|
|
46
|
+
async *extract() {
|
|
47
|
+
if (!this.db) {
|
|
48
|
+
throw new Error('Database not initialized');
|
|
49
|
+
}
|
|
50
|
+
// 0 is the "no limit" convention (null here); the CLI's --limit default is the safety net
|
|
51
|
+
const limit = this.getEffectiveLimit();
|
|
52
|
+
const { where, values } = this.visitFilter();
|
|
53
|
+
let sql = `
|
|
54
|
+
SELECT history_visits.id AS visit_id,
|
|
55
|
+
history_visits.history_item,
|
|
56
|
+
history_visits.visit_time,
|
|
57
|
+
history_visits.title,
|
|
58
|
+
history_items.url,
|
|
59
|
+
cast(history_visits.visit_time + ${APPLE_EPOCH_OFFSET_SECONDS} as integer) AS unix_timestamp,
|
|
60
|
+
datetime(history_visits.visit_time + ${APPLE_EPOCH_OFFSET_SECONDS}, 'unixepoch') AS visit_datetime,
|
|
61
|
+
history_visits.origin
|
|
62
|
+
FROM history_visits
|
|
63
|
+
LEFT JOIN history_items ON history_items.id = history_visits.history_item
|
|
64
|
+
WHERE ${where}
|
|
65
|
+
ORDER BY history_visits.visit_time DESC`;
|
|
66
|
+
if (limit !== null) {
|
|
67
|
+
sql += ` LIMIT ?`;
|
|
68
|
+
values.push(limit);
|
|
69
|
+
}
|
|
70
|
+
const account = this.config.account === undefined ? await getICloudAccount() : this.config.account;
|
|
71
|
+
for (const row of iterateRows(this.db.prepare(sql), ...values)) {
|
|
72
|
+
const processedRow = this.processRow(row);
|
|
73
|
+
yield this.createRecord(processedRow, {
|
|
74
|
+
source: 'safari',
|
|
75
|
+
account,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
processRow(row) {
|
|
80
|
+
return {
|
|
81
|
+
visit_id: row.visit_id,
|
|
82
|
+
history_item: row.history_item,
|
|
83
|
+
visit_time: row.visit_time,
|
|
84
|
+
title: row.title || null,
|
|
85
|
+
url: row.url,
|
|
86
|
+
unix_timestamp: row.unix_timestamp,
|
|
87
|
+
visit_datetime: row.visit_datetime,
|
|
88
|
+
origin: row.origin,
|
|
89
|
+
// Origin is an integer in Safari's schema - we can't determine device type from it directly
|
|
90
|
+
device_type: 'desktop', // Default to desktop since we can't reliably detect mobile vs desktop
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async determineCount() {
|
|
94
|
+
if (!this.db)
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
// Same filters as the main query, counted
|
|
98
|
+
const { where, values } = this.visitFilter();
|
|
99
|
+
const row = this.db
|
|
100
|
+
.prepare(`SELECT COUNT(*) AS n FROM history_visits
|
|
101
|
+
LEFT JOIN history_items ON history_items.id = history_visits.history_item
|
|
102
|
+
WHERE ${where}`)
|
|
103
|
+
.get(...values);
|
|
104
|
+
return row.n;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
export default class SafariTransformer extends ChronicleTransformer {
|
|
4
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
5
|
+
private buildViewActions;
|
|
6
|
+
private buildWebPage;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=SafariTransformer.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ChronicleTransformer } from '@chronicle.app/etl';
|
|
2
|
+
import { buildICloudPersonSchema } from '@chronicle.app/icloud';
|
|
3
|
+
export default class SafariTransformer extends ChronicleTransformer {
|
|
4
|
+
async transform(record) {
|
|
5
|
+
const actions = [];
|
|
6
|
+
if (record.extraction.recordType === 'views') {
|
|
7
|
+
actions.push(...(await this.buildViewActions(record)));
|
|
8
|
+
}
|
|
9
|
+
return actions;
|
|
10
|
+
}
|
|
11
|
+
async buildViewActions(record) {
|
|
12
|
+
const actions = [];
|
|
13
|
+
const user = await buildICloudPersonSchema(record.context.account);
|
|
14
|
+
const webpage = this.buildWebPage(record);
|
|
15
|
+
// Create a ViewAction for the browser visit
|
|
16
|
+
const viewAction = {
|
|
17
|
+
'@type': 'ViewAction',
|
|
18
|
+
timestamp: new Date(record.data.unix_timestamp * 1000),
|
|
19
|
+
'@key': ['@type', 'source', 'timestamp'],
|
|
20
|
+
source: 'safari',
|
|
21
|
+
// Without an iCloud account the viewer can't be identified, so omit them.
|
|
22
|
+
...(user && { agent: user }),
|
|
23
|
+
object: webpage,
|
|
24
|
+
};
|
|
25
|
+
actions.push(viewAction);
|
|
26
|
+
return actions;
|
|
27
|
+
}
|
|
28
|
+
buildWebPage(record) {
|
|
29
|
+
return {
|
|
30
|
+
'@type': 'Entity',
|
|
31
|
+
'@key': ['url'],
|
|
32
|
+
url: record.data.url,
|
|
33
|
+
name: record.data.title || record.data.url,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronicle.app/safari",
|
|
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
|
+
"author": "Andrew Louis",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"description": "Chronicle Safari browsing history plugin",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@chronicle.app/etl": "0.3.0",
|
|
23
|
+
"@chronicle.app/schema": "0.3.0",
|
|
24
|
+
"@chronicle.app/tsconfig": "0.3.0",
|
|
25
|
+
"@types/node": "^22.10.1",
|
|
26
|
+
"typescript": "^5.7.2"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://chronicle.app",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/chronicle-app/chronicle.git",
|
|
32
|
+
"directory": "plugins/safari"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@chronicle.app/etl-sqlite": "0.3.0",
|
|
40
|
+
"@chronicle.app/icloud": "0.3.0",
|
|
41
|
+
"zod": "^3.24.1"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@chronicle.app/etl": ">=0.1.0 <1.0.0",
|
|
45
|
+
"@chronicle.app/schema": ">=0.1.0 <1.0.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22.13.0"
|
|
49
|
+
},
|
|
50
|
+
"exports": {
|
|
51
|
+
".": {
|
|
52
|
+
"types": "./dist/index.d.ts",
|
|
53
|
+
"import": "./dist/index.js"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"chronicle": {
|
|
57
|
+
"plugin": true,
|
|
58
|
+
"sources": {
|
|
59
|
+
"safari": {
|
|
60
|
+
"strategies": {
|
|
61
|
+
"app-db": {
|
|
62
|
+
"delivery": "local",
|
|
63
|
+
"recordTypes": [
|
|
64
|
+
"views"
|
|
65
|
+
],
|
|
66
|
+
"default": true
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"platforms": [
|
|
70
|
+
"darwin"
|
|
71
|
+
],
|
|
72
|
+
"requires": [
|
|
73
|
+
"full-disk-access"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|