Bookmarks
Side Projects

Building a Link Feed for My Blog with Readwise Reader and Raindrop APIs

Hey all 0 of my readers! I saw the late Aaron Swartz had done this on his blog, so I thought I’d implement a version that pulls my links from Readwise Reader and Raindrop, my two main bookmarking services.

Reference: http://www.aaronsw.com/

What It Does

TL;DR - this script grabs links from Readwise Reader and Raindrop. It then combines them into one unified document structure, and writes to a local json file. That JSON file is then copied to an exposed web server, and can be downloaded when I build my blog.

Accessing service APIs

I’m using Readwise Reader and Raindrop APIs to access my bookmarks

API Documentation:

The Code

Data Structure Examples

Readwise Reader Document:

{
  "id": "01jh65n9vnqne5c3zka77b9ygg",
  "url": "https://read.readwise.io/read/01jh65n9vnqne5c3zka77b9ygg",
  "title": "Scientists uncover how the brain washes itself during sleep",
  "author": "ByMitch Leslie",
  "source": "Readwise web highlighter",
  "category": "article",
  "location": "new",
  "tags": {},
  "site_name": "Science Advances",
  "word_count": 812,
  "created_at": "2025-01-09T18:36:37.257536+00:00",
  "updated_at": "2025-01-09T18:36:37.422728+00:00",
  "published_date": 1736294400000,
  "summary": "Scientists have discovered that during non-REM sleep, norepinephrine helps blood vessels in the brain contract and push cerebrospinal fluid, which cleanses the brain of waste. A sleep drug called zolpidem may disrupt this cleansing process, indicating potential side effects for users. This research could lead to better sleep aids that support the brain's natural cleaning function.",
  "image_url": "https://www.science.org/do/10.1126/science.zye6h6x/abs/_20250110_nid_sleeping_mice.jpg",
  "content": null,
  "source_url": "https://www.science.org/content/article/scientists-uncover-how-brain-washes-itself-during-sleep",
  "notes": "",
  "parent_id": null,
  "reading_progress": 0,
  "first_opened_at": "2025-01-09T18:36:37.109000+00:00",
  "last_opened_at": "2025-01-09T18:36:37.109000+00:00",
  "saved_at": "2025-01-09T18:36:37.109000+00:00",
  "last_moved_at": "2025-01-09T18:36:37.109000+00:00"
}

Raindrop Bookmark:

{
  "_id": 941550705,
  "link": "https://www.science.org/content/article/scientists-uncover-how-brain-washes-itself-during-sleep",
  "title": "Scientists uncover how the brain washes itself during sleep",
  "excerpt": "Pulsating blood vessels push fluid into and out of the brains of slumbering mice",
  "note": "",
  "type": "link",
  "user": {
    "$ref": "users",
    "$id": 815800
  },
  "cover": "https://www.science.org/do/10.1126/science.zye6h6x/abs/_20250110_nid_sleeping_mice.jpg",
  "media": [
    {
      "link": "https://www.science.org/do/10.1126/science.zye6h6x/abs/_20250110_nid_sleeping_mice.jpg",
      "type": "image"
    },
    {
      "link": "https://www.science.org/do/10.1126/science.zye6h6x/full/_20250110_nid_sleeping_mice-1736361571250.jpg",
      "type": "image"
    },
    {
      "link": "https://www.science.org/do/10.1126/science.zwmauid/full/_20250107_on_mars_sample_return.jpg",
      "type": "image"
    }
  ],
  "tags": [
    "sleep",
    "brain",
    "science",
    "research",
    "study"
  ],
  "important": false,
  "reminder": {
    "date": null
  },
  "removed": false,
  "created": "2025-01-09T18:36:43.898Z",
  "collection": {
    "$ref": "collections",
    "$id": 26156198,
    "oid": 26156198
  },
  "highlights": [],
  "lastUpdate": "2025-01-09T18:36:50.275Z",
  "domain": "science.org",
  "creatorRef": {
    "_id": 815800,
    "avatar": "",
    "name": "BrianVia",
    "email": ""
  },
  "sort": 941550705,
  "cache": {
    "status": "invalid-origin"
  },
  "broken": false,
  "collectionId": 26156198
}

TypeScript Implementation

import axios from "axios";
import dotenv from "dotenv";
import { writeFileSync } from "node:fs";

dotenv.config();

// Unified interface for links
interface UnifiedLink {
  id: string;
  title: string;
  url: string;
  createdAt: Date;
  bookmarkSource: "Raindrop" | "ReadwiseReader";
  tags?: string[];
  summary?: string;
  author?: string;
  imageUrl?: string;
  originalUrl?: string;
}

// Raindrop types and client
interface Bookmark {
  _id: number;
  title: string;
  excerpt: string;
  link: string;
  domain: string;
  cover: string;
  created: string;
  lastUpdate: string;
  tags: string[];
  collectionId: number;
  type: string;
}

interface RaindropResponse {
  items: Bookmark[];
  count: number;
  page: number;
}

class RaindropClient {
  private readonly baseUrl = "https://api.raindrop.io/rest/v1";
  private readonly headers: Record<string, string>;

  constructor(token: string) {
    this.headers = {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    };
  }

  async getAllBookmarks(perPage: number = 50): Promise<Bookmark[]> {
    let allBookmarks: Bookmark[] = [];
    let currentPage = 0;
    let hasMore = true;

    while (hasMore) {
      try {
        const response = await axios.get<RaindropResponse>(`${this.baseUrl}/raindrops/0`, {
          headers: this.headers,
          params: {
            page: currentPage,
            perpage: perPage,
            sort: "-created",
          },
        });

        const { items, count } = response.data;
        allBookmarks = [...allBookmarks, ...items];

        hasMore = allBookmarks.length < count;
        currentPage++;
      } catch (error) {
        if (axios.isAxiosError(error)) {
          throw new Error(`Failed to fetch bookmarks: ${error.message}`);
        }
        throw error;
      }
    }

    return allBookmarks;
  }
}

// Readwise Reader types and client
interface ReadwiseReaderDocument {
  image_url: string;
  id: number;
  title: string;
  author: string;
  saved_at: string;
  summary: string;
  url: string;
}

class ReadwiseReaderClient {
  private readonly baseUrl = "https://readwise.io/api/v3";
  private readonly headers: Record<string, string>;

  constructor(token: string) {
    this.headers = {
      Authorization: `Token ${token}`,
    };
  }

  async getAllDocuments(): Promise<ReadwiseReaderDocument[]> {
    let allDocuments: ReadwiseReaderDocument[] = [];
    let nextPageCursor: string | null = null;

    while (true) {
      try {
        const queryParams = new URLSearchParams();
        if (nextPageCursor) {
          queryParams.append("pageCursor", nextPageCursor);
        }

        console.log("Querying Readwise Reader for documents");
        const response = await axios.get<{
          results: ReadwiseReaderDocument[];
          nextPageCursor: string | null;
        }>(`${this.baseUrl}/list/?${queryParams.toString()}`, {
          headers: this.headers,
        });

        allDocuments = [...allDocuments, ...response.data.results];
        nextPageCursor = response.data.nextPageCursor;

        if (!nextPageCursor) {
          break;
        }
      } catch (error) {
        if (axios.isAxiosError(error)) {
          throw new Error(`Failed to fetch Readwise Reader documents: ${error.message}`);
        }
        throw error;
      }
    }

    return allDocuments;
  }
}

// Function to normalize and combine links from both sources
async function getAllLinks(): Promise<UnifiedLink[]> {
  const raindropToken = process.env.RAINDROP_TOKEN;
  const readwiseToken = process.env.READWISE_TOKEN;

  if (!raindropToken || !readwiseToken) {
    throw new Error("RAINDROP_TOKEN or READWISE_TOKEN environment variable is not set");
  }

  const raindropClient = new RaindropClient(raindropToken);
  const readwiseClient = new ReadwiseReaderClient(readwiseToken);

  const [raindropBookmarks, readwiseDocuments] = await Promise.all([
    raindropClient.getAllBookmarks(),
    readwiseClient.getAllDocuments(),
  ]);

  console.log(`Raindrop Links`);
  console.log(JSON.stringify(raindropBookmarks[0], null, 2));
  console.log(`Readwise Links`);
  console.log(JSON.stringify(readwiseDocuments[0], null, 2));

  const raindropLinks: UnifiedLink[] = raindropBookmarks.map((bookmark) => ({
    id: bookmark._id.toString(),
    originalUrl: bookmark.link,
    summary: bookmark.excerpt,
    author: bookmark.domain,
    title: bookmark.title,
    url: bookmark.link,
    createdAt: new Date(bookmark.created),
    bookmarkSource: "Raindrop",
    tags: bookmark.tags,
    imageUrl: bookmark.cover,
  }));

  const readwiseLinks: UnifiedLink[] = readwiseDocuments.map((document) => ({
    id: document.id.toString(),
    originalUrl: document.url,
    title: document.title,
    url: document.url,
    createdAt: new Date(document.saved_at),
    bookmarkSource: "ReadwiseReader",
    summary: document.summary,
    author: document.author.startsWith("By") ? document.author.slice(2) : document.author,
    imageUrl: document.image_url,
  }));

  const allLinks = [...readwiseLinks, ...raindropLinks];

// Sort by most recent
  allLinks.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
  writeFileSync("all-bookmarks.json", JSON.stringify(allLinks, null, 2));

  return allLinks;
}

// Example usage
async function main() {
  try {
    const allLinks = await getAllLinks();
    console.log(`Found ${allLinks.length} links:\n`);
    allLinks.forEach((link) => {
// Console output logic here
    });
  } catch (error) {
    console.error("Error:", error instanceof Error ? error.message : error);
  }
}

main();

Continuous Deployments

I have a cron job running hourly on my server that pulls them and stores them to a local JSON file hosted on a webserver. If the file changes, it does a POST HTTP call to my cloudflare pages webhook, triggering a new deployment.