async function getNotesWithLinks (userPubkeys, timeIntervalHours, relayUrls, ignorePubkeys = []) {
  // Get the latest stored note timestamp
  const latestStoredTimestamp = await db.getLatestNoteTimestamp()

  // Calculate the cutoff time in seconds (Nostr uses UNIX timestamp)
  const now = Math.floor(Date.now() / 1000)
  // Use the later of: configured time interval or latest stored note
  const configuredCutoff = now - (timeIntervalHours * 60 * 60)
  const cutoffTime = Math.max(configuredCutoff, latestStoredTimestamp)

  logger.debug(`Using cutoff time: ${new Date(cutoffTime * 1000).toISOString()}`)

  const allNotesWithLinks = []
  const allFollowedPubkeys = new Set() // To collect all followed pubkeys
  const ignoreSet = new Set(ignorePubkeys) // Convert ignore list to Set for efficient lookups

  if (ignoreSet.size > 0) {
    logger.debug(`Ignoring ${ignoreSet.size} author(s) as requested`)
  }

  logger.info(`Fetching follow lists for ${userPubkeys.length} users...`)
  // First get the followings for each user
  for (const pubkey of userPubkeys) {
    try {
      // Skip if this pubkey is in the ignore list
      if (ignoreSet.has(pubkey)) {
        logger.debug(`Skipping user ${pubkey} as it's in the ignore list`)
        continue
      }

      logger.debug(`Fetching follow list for ${pubkey} from ${relayUrls.length} relays...`)

      // Get the most recent contact list (kind 3)
      const followListEvents = await fetchEvents(relayUrls, {
        kinds: [3],
        authors: [pubkey]
      })

      if (followListEvents.length === 0) {
        logger.debug(`No follow list found for user ${pubkey}. Verify this pubkey has contacts on these relays.`)
        continue
      }

      // Find the most recent follow list event
      const latestFollowList = followListEvents.reduce((latest, event) =>
        !latest || event.created_at > latest.created_at ? event : latest, null)

      if (!latestFollowList) {
        logger.debug(`No valid follow list found for user ${pubkey}`)
        continue
      }

      logger.debug(`Found follow list created at: ${new Date(latestFollowList.created_at * 1000).toISOString()}`)

      // Check if tags property exists
      if (!latestFollowList.tags) {
        logger.debug(`No tags found in follow list for user ${pubkey}`)
        logger.debug('Follow list data:', JSON.stringify(latestFollowList, null, 2))
        continue
      }

      // Extract followed pubkeys from the follow list (tag type 'p')
      const followedPubkeys = latestFollowList.tags
        .filter(tag => tag[0] === 'p')
        .map(tag => tag[1])
        .filter(pk => !ignoreSet.has(pk)) // Filter out pubkeys from the ignore list

      if (!followedPubkeys || followedPubkeys.length === 0) {
        logger.debug(`No followed users found for user ${pubkey} (after filtering ignore list)`)
        continue
      }

      // Add all followed pubkeys to our set
      followedPubkeys.forEach(pk => allFollowedPubkeys.add(pk))

      logger.debug(`Added ${followedPubkeys.length} followed users for ${pubkey} (total: ${allFollowedPubkeys.size})`)
    } catch (error) {
      logger.error(`Error processing user ${pubkey}: ${error}`)
    }
  }

  // If we found any followed pubkeys, fetch their notes in batches
  if (allFollowedPubkeys.size > 0) {
    // Convert Set to Array for the filter
    const followedPubkeysArray = Array.from(allFollowedPubkeys)
    const batchSize = config.batchSize || 100 // Use config batch size or default to 100
    const totalBatches = Math.ceil(followedPubkeysArray.length / batchSize)

    logger.progress(`Processing ${followedPubkeysArray.length} followed users in ${totalBatches} batches...`)

    // Process in batches
    for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
      const start = batchNum * batchSize
      const end = Math.min(start + batchSize, followedPubkeysArray.length)
      const batch = followedPubkeysArray.slice(start, end)

      logger.progress(`Fetching batch ${batchNum + 1}/${totalBatches} (${batch.length} authors)...`)

      // Fetch notes from the current batch of users
      const notes = await fetchEvents(relayUrls, {
        kinds: [1],
        authors: batch,
        since: cutoffTime
      }, 30000) // Use a longer timeout for this larger query

      logger.debug(`Retrieved ${notes.length} notes from batch ${batchNum + 1}`)

      // Filter notes that have URLs (excluding notes with only media URLs)
      const notesWithUrls = notes.filter(note => {
        // Extract all URLs from content
        const urlRegex = /(https?:\/\/[^\s]+)/g
        const matches = note.content.match(urlRegex) || []

        if (matches.length === 0) return false // No URLs at all

        // Check if any URL is not a media file
        const hasNonMediaUrl = matches.some(url => !isMediaUrl(url))

        return hasNonMediaUrl
      })

      logger.debug(`Found ${notesWithUrls.length} notes containing non-media URLs in batch ${batchNum + 1}`)

      // Get all unique authors from the filtered notes in this batch
      const authorsWithUrls = new Set(notesWithUrls.map(note => note.pubkey))

      // Fetch metadata for all relevant authors in this batch
      if (authorsWithUrls.size > 0) {
        logger.debug(`Fetching metadata for ${authorsWithUrls.size} authors from batch ${batchNum + 1}...`)
        const allMetadata = await fetchEvents(relayUrls, {
          kinds: [0],
          authors: Array.from(authorsWithUrls)
        })

        // Create a map of author pubkey to their latest metadata
        const metadataByAuthor = {}
        allMetadata.forEach(meta => {
          if (!metadataByAuthor[meta.pubkey] || meta.created_at > metadataByAuthor[meta.pubkey].created_at) {
            metadataByAuthor[meta.pubkey] = meta
          }
        })

        // Attach metadata to notes
        for (const note of notesWithUrls) {
          if (metadataByAuthor[note.pubkey]) {
            try {
              const metadata = JSON.parse(metadataByAuthor[note.pubkey].content)
              note.userMetadata = metadata
            } catch (e) {
              logger.debug(`Error parsing metadata for ${note.pubkey}: ${e.message}`)
            }
          }
        }
      }

      // Add all notes with URLs from this batch to our results
      allNotesWithLinks.push(...notesWithUrls)

      // Show incremental progress during batch processing
      if (allNotesWithLinks.length > 0 && batchNum < totalBatches - 1) {
        logger.progress(`Found ${allNotesWithLinks.length} notes with links so far...`)
      }
    }

    logger.progress(`Completed processing all ${totalBatches} batches`)
  }

  // After processing notes and before returning, save them to the database
  for (const note of allNotesWithLinks) {
    await db.saveNote(note)
  }

  return allNotesWithLinks
}
Get Nostr notes from followings of specified users that contain external links
and were posted within the specified time interval.

@param {Array} userPubkeys - Array of Nostr user public keys
@param {Number} timeIntervalHours - Number of hours to look back from now
@param {Array} relayUrls - Array of Nostr relay URLs
@param {Array} ignorePubkeys - Array of pubkeys to ignore (optional)
@returns {Promise<Array>} - Array of note objects containing external links within the time interval