Script (JXA): Import Safari Reading List (bookmarks & webarchives)

As far as I’m aware, Safari doesn’t have any direct scripting interface to access the reading list. Another user asked for a way to import the reading list in DEVONthink as bookmarks, and I’ve been wanting something similar. So I explored a bit and wrote this script.

Safari’s Reading List is stored along with your bookmarks in ~/Library/Safari/Bookmarks.plist. The scripting dictionary for System Events includes a “Property List Suite” for reading .plist files. I never used it before, but it worked surprisingly well.

Each item in the reading list is represented as a dictionary, with a bunch of information like the date added, title, URL and such. Each item also has a UUID, and any downloaded assets are stored in ~/Library/Safari/ReadingListArchives/<UUID>. This will mostly be a thumbnail image, but if you use Safari’s option to download articles there will also be a web archive. The script has two flags at the start for specifying whether you want to use them.

The script creates a Safari Reading List group in the root of the current database and uses that as the import destination. The creation date of imported items is set to the date added in Safari and any preview text is stored in the comment field.

The script is written for DEVONthink 3, but should only need small adjustments for DEVONthink 4. (I will revisit this post when I’ve upgraded, hopefully soon.)

That should be enough of an introduction.
Here is the script, written in JavaScript for Automation (JXA):

Original script (DT3)
/* Import Safari’s Reading List to DEVONthink
as either bookmarks or webarchives,
keeping useful metadata from Safari.
	
Written by troejgaard on Nov 27, 2025 */

(() => {

	const DT = Application("DEVONthink 3"); // For DEVONthink 4 use `Application("DEVONthink")`
	DT.includeStandardAdditions = true;
	const Sys = Application("System Events");
	Sys.includeStandardAdditions = true;

	const importThumbnails = true;
	const importWebarchives = false;	
	const destDB = DT.currentDatabase();

	// Access Safari’s Bookmarks.plist
	const SafariLib = `${Sys.userDomain.libraryFolder.posixPath()}/Safari`;
	const bookmarksFile = Sys.propertyListFiles[`${SafariLib}/Bookmarks.plist`];

	// Find the nested dictionary representing the Reading List...
	const readingList = bookmarksFile.propertyListItems["Children"]
		.propertyListItems().filter(dict =>
			dict.propertyListItems["Title"].value() === "com.apple.ReadingList"
		)[0]
		// ...storing list items as an array of dictionaries:
		.propertyListItems["Children"];

	const itemCount = readingList.propertyListItems.length;
	if (itemCount === 0) {
		DT.displayAlert("Safari Reading List is empty");
		return;
	}
	const dest = DT.createLocation("/Safari Reading List", {in: destDB});
	DT.showProgressIndicator("Safari Reading List",
		{cancelButton: true, steps: itemCount});

	let i = 0; let userCancelled;
	while (i < itemCount) {

		DT.stepProgressIndicator(`Importing ${i+1} of ${itemCount}`);
		// Read list item’s dictionary:
		const dict = readingList.propertyListItems[i].value();
		// Map properties we’re interested in:
		const title = dict.URIDictionary.title;
		const link = dict.URLString;
		const date = dict.ReadingList.DateAdded;
		const desc = dict.ReadingList.PreviewText ?? '';
		const uid = dict.WebBookmarkUUID;
		// Check downloaded assets
		const assets = `${SafariLib}/ReadingListArchives/${uid}`;
		let img = `${assets}/thumbnail.png`;
		let webarchive = `${assets}/Page.webarchive`;
		if (!importThumbnails || !Sys.files[img].exists()) img = null;
		if (!importWebarchives || !Sys.files[webarchive].exists()) webarchive = null;

		if (webarchive) {

			const r = DT.import(webarchive, {name:title, to:dest});
			r.creationDate = date;
			r.comment = desc;
			if (img) r.thumbnail = img;

		} else {

			DT.createRecordWith({
				"type": "bookmark", // For DT4 use "record type"
				"name": title,
				"URL": link,
				"creation date": date,
				"comment": desc,
				"thumbnail": img
				},{ in: dest });

		}

		i++;
		if (DT.cancelledProgress()) {userCancelled = true; break;}

	}

	const msg = userCancelled ?
		`Imported ${i}/${itemCount} items (Cancelled by user)`
		: `Imported ${i} items`;
	
	DT.hideProgressIndicator();
	DT.logMessage(msg, {record:dest});

})()

Edit, Dec 7: Here is an up-to-date version tested in DT4. I only had to make 3 small adjustments to comply with the new dictionary. Besides that, I added a count of thumbnails/webarchives to the log message and followed chrillek’s suggestion of using const for img and webarchive.

/* Import Safari’s Reading List to DEVONthink
as either bookmarks or webarchives,
keeping useful metadata from Safari.

Written by troejgaard on Nov 27, 2025
Updated on Dec 5, 2025 */

(() => {

	const DT = Application("DEVONthink");
	DT.includeStandardAdditions = true;
	const Sys = Application("System Events");
	Sys.includeStandardAdditions = true;

	const destDB = DT.currentDatabase(); // Alternatively, 'DT.inbox()' or 'DT.preferredImportDestination()'
	const importThumbnails = true;
	const importWebarchives = false;
	let imgCount = 0, webCount = 0;

	// Access Safari’s Bookmarks.plist
	const SafariLib = `${Sys.userDomain.libraryFolder.posixPath()}/Safari`;
	const bookmarksFile = Sys.propertyListFiles[`${SafariLib}/Bookmarks.plist`];

	// Find the nested dictionary representing the Reading List...
	const readingList = bookmarksFile.propertyListItems["Children"]
		.propertyListItems().filter(dict =>
			dict.propertyListItems["Title"].value() === "com.apple.ReadingList"
		)[0]
		// ...storing list items as an array of dictionaries:
		.propertyListItems["Children"];

	const itemCount = readingList.propertyListItems.length;
	if (itemCount === 0) {
		DT.displayAlert("Safari Reading List is empty");
		return;
	}
	const dest = DT.createLocation("/Safari Reading List", {in: destDB});
	DT.showProgressIndicator("Safari Reading List",
		{cancelButton:true, steps:itemCount});

	let i = 0; let userCancelled;
	while (i < itemCount) {

		DT.stepProgressIndicator(`Importing ${i+1} of ${itemCount}`);
		// Read list item’s dictionary:
		const dict = readingList.propertyListItems[i].value();
		// Map properties we’re interested in:
		const title = dict.URIDictionary.title;
		const link = dict.URLString;
		const date = dict.ReadingList.DateAdded;
		const desc = dict.ReadingList.PreviewText ?? '';
		const uid = dict.WebBookmarkUUID;
		// Check downloaded assets:
		const assets = `${SafariLib}/ReadingListArchives/${uid}`;
		const img = importThumbnails && Sys.files[`${assets}/thumbnail.png`].exists() ?
			`${assets}/thumbnail.png` : null;
		const webarchive = importWebarchives && Sys.files[`${assets}/Page.webarchive`].exists() ?
			`${assets}/Page.webarchive` : null;

		if (img) imgCount++;
		if (webarchive) {

			const r = DT.importPath(webarchive, {name:title, to:dest});
			r.creationDate = date;
			r.comment = desc;
			if (img) r.thumbnail = img;
			webCount++;

		} else {

			DT.createRecordWith({
				"record type": "bookmark",
				"name": title,
				"URL": link,
				"creation date": date,
				"comment": desc,
				"thumbnail": img
				},{ in: dest });

		}

		i++;
		if (DT.cancelledProgress()) {userCancelled = true; break;}

	}

	const msg = (userCancelled ?
		`Imported ${i}/${itemCount} items (Cancelled by user)`
		: `Imported ${i} items`)
	  + (imgCount ? `\n- ${imgCount} with thumbnail` : '')
	  + (webCount ? `\n- ${webCount} as Web Archive` : '');

	DT.hideProgressIndicator();
	DT.logMessage(msg, {record:dest});

})()

Nerdy details

If anyone wants to play around with this, here is an empty dictionary of a reading list item to show the keys and structure.

Example
<dict>
	<key>ReadingList</key>
	<dict>
		<key>DateAdded</key>
		<date></date>
		<key>PreviewText</key>
		<string></string>
	</dict>
	<key>ReadingListNonSync</key>
	<dict>
		<key>addedLocally</key>
		<true/>
		<key>DateLastFetched</key>
		<date></date>
		<key>FetchResult</key>
		<integer></integer>
		<key>NumberOfFailedLoadsWithUnknownOrNonRecoverableError</key>
		<integer></integer>
		<key>neverFetchMetadata</key>
		<false/>
		<key>topicQIDs</key>
		<array>
			<string></string>
			<string></string>
		</array>
		<key>didAttemptToFetchIconFromImageUrlKey</key>
		<true/>
		<key>siteName</key>
		<string></string>
	</dict>
	<key>Sync</key>
	<dict>
		<key>Data</key>
		<data>
		...
		</data>
		<key>ServerID</key>
		<string></string>
	</dict>
	<key>URIDictionary</key>
	<dict>
		<key>title</key>
		<string></string>
	</dict>
	<key>URLString</key>
	<string></string>
	<key>WebBookmarkType</key>
	<string>WebBookmarkTypeLeaf</string>
	<key>WebBookmarkUUID</key>
	<string></string>
	<key>imageURL</key>
	<string></string>
</dict>

Some keys are not present in every dictionary (like ‌DateLastFetched, ‌topicQIDs, siteName, imageURL), but a majority of them are. The example should include all possible keys found in my own Bookmarks.plist, but I’m not sure if there are more.

3 Likes

I like your property list handling, never got around doing that with System Events myself.
This

let img = `${assets}/thumbnail.png`;
let webarchive = `${assets}/Page.webarchive`;
if (!importThumbnails || !Sys.files[img].exists()) img = null;
if (!importWebarchives || !Sys.files[webarchive].exists()) webarchive = null;

might be written more concisely as

const img = importThumbnails && Sys.files[img].exists()) ? `${assets}/thumbnail.png` : null;
const webarchive = importWebarchive && Sys.files[webarchive].exists()) ? `${assets}/Page.webarchive` : null;

Cons: dense code might be more difficult to read and undersatnd
Pros: using const instead of let and positive conditions instead of negative ones.

Even if one does not like the ternary operator, simpler conditions are always to be preferred, imo. !a || !b is the same as ! (a && b), which would already be easier to understand (I think).

My variant of a property list reader looks like this:

function propertyListToObject(path) {
    ObjC.import("Foundation");
    const err = Ref();
    const format = Ref();
    const data = $.NSData.dataWithContentsOfFile($(path));
    const result =
      $.NSPropertyListSerialization.propertyListWithDataOptionsFormatError(
        data,
        {},
        format,
        err
      );
    return ObjC.deepUnwrap(result);
  }

It returns the plist as a JavaScript object, which one would have to massage like you did in your code to get at the reading list proper.

wow thanks for following up on my post! as a novice/layperson, please do let me know when you make this work for DEVONthink4, as i’d love to try it!

I think all you need to know to make it work for DT4 is in the script’s comments already.

You’re welcome.
Did you actually look at the script? I was aware of two minor adjustments for DT4 and included them directly as comments, like chrillek said.

  • Application("DEVONthink 3")‌Application("DEVONthink")
  • The "type" in createRecordWith() should be "record type"

(After upgrading and checking out the new scripting dictionary, I did find a third one: import has become importPath . Only relevant if you set importWebarchives = true.)

I’ve now updated the script and confirmed that it works as expected in DT4. See the OP.

PS: In the meantime, a similar script was also made available in DT’s support assistant. It is more basic, though – it grabs only titles and URLs. If you don’t care about the extra stuff in my script, you can just use that. (Personally, I really want to retain the dates. The rest was icing on the cake.)

Thank you for the feedback and the ObjC example! I still feel like a JS novice and really value your thoughts.

You spotted exactly the lines I was least sure about.

I take your point on ! (a && b) over ‌!a || !b, and I don’t mind the ternary operator at all. After getting used to reading it, I often find it more elegant. (Honestly, I’ve begun to share the perspective that AppleScript is often pretty verbose…)

I got a little excited when I saw this:

const img = importThumbnails && Sys.files[img].exists() ? `${assets}/thumbnail.png` : null;

…because I didn’t see how that could work and hoped I was about to learn a cool trick. Shouldn’t it throw an error, since img isn’t declared/initialized yet? When I tried, I indeed got an error message to that effect.

I don’t know if expanding files[img] was implied? That works, of course:

const img = importThumbnails && Sys.files[`${assets}/thumbnail.png`].exists() ? `${assets}/thumbnail.png` : null;

I was originally trying to get around writing `${assets}/thumbnail.png` two times on the same line, but maybe that should rarely be a priority.

You are right, of course: Accessing Sys.files[img] before img is defined will throw an error. One must use the filename twice in this statement. Same for the webarchive. I was too eager to “simplify” the code, my bad.

All good, I still learned something and prefer the end result :slight_smile: