Ben Dodson

Freelance iOS, macOS, Apple Watch, and Apple TV Developer

Side Project #2: (Feed Me) Seymour

This is part of a monthly series of blog posts in which I showcase some of the side projects I work on for my own use. As with all of my side projects, I’m not focused on perfect code or UI; it just needs to run!

I’ve cheated slightly this month by working on two side projects; a simple iOS app and a SwiftUI watchOS app. The Apple Watch app isn’t quite ready yet so instead I’m going to show you “Seymour”, an app that sends me push notifications when relevant articles are posted online.

The Problem

I’m travelling to The Happiest Place On Earth™ next week and as I’m staying in a Disney hotel I was able to book fastpasses for rides 60 days in advance. The issue is that a new ride, Mickey & Minnie’s Runaway Railway, is due to open whilst I’m there but fastpasses were not yet available to be booked as the opening date hadn’t been formally announced. When the announcement finally came, I didn’t see it for 5 hours as I was busy and hadn’t been checking my numerous theme park RSS feeds1. Fortunately I was still able to grab one of the remaining fastpasses for later in my holiday but I was determined that this shouldn’t happen again…

I use the excellent Feedbin service2 to keep up with my feeds and it turns out they have an app called Feedbin Notifier, but it doesn’t seem to work. It only supports one search term and the examples all use single words which didn’t give me total faith that the phrase “rise of the resistence OR space 220 OR fastpass” would parse correctly. There was also no detail on how regularly the checking was done and whilst I managed to get a notification for the term “apple” I couldn’t get it to work for much else. As I couldn’t trust it would do what I needed it to, I decided it was quicker and easier to just build it myself.

“Couldn’t you just use Google News notifications?”. I already do for some things3 but it’s far too slow for my use case. The types of articles I’m interested in require me to act within around 15 minutes so too slow for Google News which typically takes 12-24 hours to send a notification.

The Name

Like most other developers, I start a side project with the most important decision; choosing a name. Sometimes this can be a days long process but for this app it was relatively quick. I wanted to do some kind of play on words with Feedbin and was looking at things like “Feed Trash Can” or “Feed Trough” but I didn’t really like any of them. I was saying them out loud in bed whilst working on the app and my wife instinctively said “Feed Me Seymour”. I shortened it down to just “Seymour” and decided this was a perfect name as it was letting me “See More” of the things I wanted to see.

As the app isn’t going to be released publicly I did a Google Image Search for “Feed Me Seymour” in the hope of finding some sort of silhouetted version of the singing plant. Instead I found this Mario crossover at Snorgtees which was perfect.

Feed Me

The iOS app is ridiculous simple so I’m not going to spend too much time describing that. It consists of a table view listing my search terms and each row can be swiped to delete the term. The + button in the top right presents a UIAlertController with a text field for adding a search term. The fetch, add, and delete commands are all sent to an incredibly basic PHP API I wrote which syncs to my MySQL database.

The real work is to fetch the latest feeds from my Feedbin account and then search each article for any matching text strings:

$pdo = new PDO("mysql:charset=utf8mb4;host=" . $db_host . ";dbname=" . $db_name, $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$headers = [];
$url = 'https://api.feedbin.com/v2/entries.json';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_USERPWD, 'ben@bendodson.com:keepitsecretkeepitsafe');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['If-None-Match: \''.file_get_contents('etag.txt').'\'']);
curl_setopt($ch, CURLOPT_HEADERFUNCTION,
  function($curl, $header) use (&$headers)
  {
    $len = strlen($header);
    $header = explode(':', $header, 2);
    if (count($header) < 2) // ignore invalid headers
      return $len;

    $headers[strtolower(trim($header[0]))][] = trim($header[1]);

    return $len;
  }
);
$json = curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);

file_put_contents('etag.txt', $headers['etag'][0]);

$stmt = $pdo->prepare('select * from keywords');
$stmt->execute();
$keywords = array_column($stmt->fetchAll(), 'phrase');

$ids = [];
$entries = [];
$array = json_decode($json);
$countStmt = $pdo->prepare('select count(id) as total from entries where id = :id');
foreach ($array as $entry) {
    $countStmt->execute(['id' => $entry->id]);
    $count = (int)$countStmt->fetch()['total'];
    if ($count) {
        continue;
    }

    foreach ($keywords as $keyword) {
        if (stripos($entry->title, $keyword) !== false || stripos($entry->content, $keyword) !== false) {
            $entries[] = $entry;
            break;
        }
    }

    $ids[] = $entry->id;
}

$notificationStmt = $pdo->prepare('insert into notifications (title, message, url, created_at) values (:title, :message, :url, :created)');
$idStmt = $pdo->prepare('insert into entries (id) values (:id)');

$pdo->beginTransaction();
foreach ($ids as $id) {
    $idStmt->execute(['id' => $id]);
}
$pdo->commit();

foreach ($entries as $entry) {
    $notificationStmt->execute(['title' => $entry->title, 'message' => $entry->summary, 'url' => $entry->url, 'created' => date('Y-m-d H:i:s')]);
    
    $data = ["app_id" => "my-onesignal-app-id", "contents" => ["en" => $entry->summary], "headings" => ["en" => $entry->title], "included_segments" => ["All"], "data" => ["url" => $entry->url]];
    $ch = curl_init('https://onesignal.com/api/v1/notifications');
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Authorization: Basic [OBFUSCATED]']);
    $response = curl_exec($ch);
    curl_close($ch);

}

Feedbin provide an API for fetching the latest articles from all of your feeds. It has support for ETag caching so I store the latest tag to avoid unnecessary loading on their end as this script will run every 60 seconds.

Once the articles are fetched, I ignore any that I’ve already seen before (worked out by storing the ids in my database after each run) and then proceed to do a case insensitive check for my keywords against both the title and content of the article. If there are any matches, these get added to an array which is later looped over in order to send a push notification via OneSignal containing the title, article summary, and the URL.

This runs on a 1 minute CRON job so in theory I should get alerted of any matching articles within 60 seconds of them being published. I don’t know how often Feedbin polls the various RSS feeds I track but in practice I have found that I’m getting notified almost immediately so evidently they’re doing some form of dark magic.

Viewing the content

Once the notification is delivered, I thought it would be a nice bonus to be able to tap to open up the article in a browser. It turns out that the OneSignal SDK can do this automatically if you send a “url” parameter as part of your Push Notification request but it has a few issues; there’s a short lag between the app opening and the browser being shown, the controller is within a modal dialogue that can only be dismissed by swiping at the top of the page (tricky on a big phone), and it’s using either a UIWebView or a WKWebView which means there is no support for content blockers, no Reader View, and no easy way to open the page in Safari or other apps. The solution is to send the URL as part of the data payload and parse it myself within the app to open an SFSafariViewController:

Can you see the difference in the image above? With the webview provided by OneSignal we can just see the start of the title of the article4 obscured by the home indicator, and this is on the Max-sized iPhone! The SFSafariViewController in Reader View is far superior showing the full title, the first paragraph, and an image. It also loaded faster thanks to the support for content blockers and I have easy access to open this in Safari or share it along with controls for changing fonts, etc.

Conclusion

This whole thing took about 2 hours of which the majority was fiddling around with ETags. At the moment I have only two search terms set up:

  • Rise of the Resistance: Quite possibly the best theme park attraction ever made, Rise of the Resistance opened in December 2019 and since then has been using a virtual queueing system which typically gets booked out for the day within an hour. Whilst I don’t expect this is going to be moved to FastPass any time soon, I keep this notification just in case!

  • Space 220: This is a new restaurant in Epcot that was supposed to be open last year and was then rumoured to be opening in February. That still hasn’t happened but it’s feasible the opening will happen whilst I’m there and I definitely want to be one of the first to book a reservation.

Whilst I could have set up a screen scraper of some sort to monitor these pages for changes5, I much prefer operating on top of RSS as it tends to be that the feeds I follow will post updates ridiculously quickly and are likely more reliable than tracking HTML changes. It’s also more flexible as I’m sure there will be things in future that I’ll want to monitor in this way. For example, I tested the app a few weeks ago to get notified when Pokémon HOME was released on the App Store.

As ever, I hope this will inspire you to work on your own side projects! Next month I’ll be talking about the watch app that has ended up taking a lot longer than I’d originally anticipated…

  1. I’ve blogged about this before but RSS is how I consume nearly everything I read online. Twitter doesn’t even come close. With RSS you can subscribe to nearly any website that publishes articles and get them all in a nice app with no ads, no Javascript, and no other superfluous crap. At the time of writing, I subscribe to around 500 different feeds encompassing everything from theme park news and video game updates to Swift developer blogs and philosophy articles. ↩︎

  2. In conjunction with the Reeder apps on iOS and macOS. ↩︎

  3. I use Google News Notifier to see if my name comes up anywhere and also to keep tabs on a court case I sat on as part of Jury Duty. ↩︎

  4. Disney Food Blog is one of the best theme park sites I follow but their website is very hard to view on mobile hence the need for Reader View. Whilst it’s more of a necessity for that page, I find myself using Reader View for most websites as it is nearly always far better. ↩︎

  5. I did do this when the AirPods were first announced; fetch the HTML of the store page every five minutes and then send a notification when it changed so I could jump in and buy a pair. ↩︎

« Side Project #1: Sealed