The definitive Cocoa framework for adding HackerNews to your iOS/Mac app. This mini library includes features such as grabbing Posts (including filtering by Top, Ask, New, Jobs, Best), Comments, Logging in, and Submitting new posts/comments!
- Installing
- JSON Configuration
- HNManager
- HN Web Calls
- HNManager Auxiliary Methods
- Designing for the Future, and beyond!
- Apps that use libHN
- License
Installing libHN is a breeze. First things first, add all of the classes in the top-level libHN Classes folder inside of this repository into your app. Done? Good. Now, just #import "libHN.h"
in any of your controllers, classes, or views you plan on using libHN in. That's it. We're done here.
Classes to add:
- libHN.h
- HNManager.{h,m}
- HNUtilities.{h,m}
- HNWebService.{h,m}
- HNPost.{h,m}
- HNComment.{h,m}
- HNUser.{h,m}
- HNCommentLink.{h,m}
CocoaPods
If CocoaPods suits your flavor of dependency management, then there is a .podspec here for you as well. Just add the following line to your Podfile, and install your pods to get started with libHN the easy way.
pod 'libHN'
Available in 4.0.1+
hn.json
is the culmination of 2 large annoyances that it hopes to solve. The first is that HN tends to change its markup fairly frequently now, which is no good because this library is a scraper. So the old way of doing things was updating what text to look for in the scanner to serialize into properties for posts, comments, etc. The other major annoyance is that after a change, it still takes Apple on average about 1 week to get that update live. This means that it took 1 week to get an update out to the store that handled one silly markup change. That's unmaintainable. So here's the fix.
Enter the JSON Configuration!
The JSON Configuration is a json file that describes how to build a post and comment object from the HTML markup, as well as how to handle replying and submitting new posts as far as the extra info that those web calls need. This JSON file is prebundled with the library, but is also fetched from the internet each time HNManager
gets initialized. This means that whenever HN changes markup, a simple pull-request to the hn.json
file should fix the problem across all apps that use this library - without an update to the App Store.
Notification on Change
When a new JSON Configuration is fetched from the internet, and it's different than the current one in use by the app, it will save the new configuration and send out an NSNotification
that anything that uses Posts or Comments should reload itself to use the new configuration. The name of the notification is kHNShouldReloadDataFromConfiguration
.
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(someSelector) name:kHNShouldReloadDataFromConfiguration object:nil];
Breakdown
The hn.json
configuration contains 4 top-level key/value pairs:
- Posts
- Comment
- Reply
- Submit
Posts handles how to build the array of HNPost
objects. Here's what each key/value pair inside this object represents:
CS
: the string that separates post objects from the total HTML (components separated)Vote
:R
: what the scanner is looking for if a vote arrow is presentS
: where to start scanningE
: where to end scanning
Parts
: an array of objects that contain all of the parts necessary to build a post, in order.S
: where to start scanningE
: where to end scanningI
: what to put the result into. If it's TRASH, then it doesn't get saved. Use TRASH to get garbage in between necessary properties.
Comments handles how to build the array of HNComment
objects, but is a little more robust in what it's looking for:
CS
: the string that separates post objects from the total HTML (components separated)Upvote
andDownvote
:R
: what the scanner is looking for if a vote arrow is presentS
: where to start scanningE
: where to end scanning
Level
: the way to figure out how nested this comment object isS
: where to start scanningE
: where to end scanning
ASK
,JOBS
, andREG
determine which order to find the parts necessary for a comment of its type:S
: where to start scanningE
: where to end scanningI
: what to put the result into. If it's TRASH, then it doesn't get saved. Use TRASH to get garbage in between necessary properties.
Reply handles how to build the POSTed action to HN signifying a reply to a submission or another comment.
Action
: the path to the form action for replyingParts
: the extra form values that are hidden from user-input but necessary for building the reply object.S
: where to start scanningE
: where to end scanningI
: what to put the result into. If it's TRASH, then it doesn't get saved. Use TRASH to get garbage in between necessary properties.
Submit handles how to build the POSTed action to HN signifying a new submission.
Action
: the path to the form action for submittingParts
: the extra form values that are hidden from user-input but necessary for building the reply object.S
: where to start scanningE
: where to end scanningI
: what to put the result into. If it's TRASH, then it doesn't get saved. Use TRASH to get garbage in between necessary properties.
Url
: the POST property name for the submission's url.Title
: the POST property name for the submission's title.Text
: the POST property name for the submission's body text.
HNManager is going to be your go-to class for using libHN. Every action flows through there - all web calls, session generation, etc. It's your conduit to HackerNews functionality. HNManager is a Singleton class, and has a sharedManager
initialization that you should use to make sure everything gets routed correctly through the Manager.
Starting a Session
Add the code snippet, [[HNManager sharedManager] startSession]
, to begin a session of the HNManager's sharedManager object. This method will log in a user if he/she has logged in on the app previously, using the Cookie that is stored on the device. For best practices, add this method to the - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
method in your AppDelegate.m
class before the method returns. This will ensure that setting up the manager is the first thing that happens when your app launches, and that any users that are logged in, will log in immediately as well.
Because of the way HackerNews is set up, there are two methods for getting posts. The first one loadPostsWithFilter:completion:
, is your beginning method to retrieving posts based on a filter. So if you go to the HN homepage this is what you'd get if you call this method and use PostFilterTypeTop
as the PostFilterType parameter.
If you notice on the homepage, at the very bottom, there's a "More" button. Click that then look at the URL Bar. Notice the funky string that looks like this: "fnid=kS3LAcKvtXPC85KnoQszPW" at the end of the URL? HackerNews works on assigning an fnid, or basically a SessionKey, to determine what page you are going to and the authenticity of its request/response. This is used for every action on the site except for getting the first 30 links of any post type. This is where the second method comes in, loadPostsWithUrlAddition:completion:
, which takes in a Url Addition string to determine what posts should come next.
loadPostsWithFilter
This method takes in a PostFilterType parameter and returns an NSArray of HNPost
objects. The various PostFilterTypes, and the types of posts you receive are listed below:
- PostFilterTypeTop - HomePage
- PostFilterTypeAsk - Ask HN
- PostFilterTypeNew - Newest Posts
- PostFilterTypeJobs - HN Jobs
- PostFilterTypeBest - Highest Rated Posts Recently
And here's how to use this:
[[HNManager sharedManager] loadPostsWithFilter:(PostFilterType)filter completion:(NSArray *posts){
if (posts) {
// Posts were successfuly retrieved
}
else {
// No posts retrieved, handle the error
}
}];
loadPostsWithUrlAddition
Now that you've gotten the first set of posts, use this method to keep retrieving posts in that Filter. The FNID parameter is mostly taken care of with the postUrlAddition
property of the HNManager. If you wanted to do something custom, you could pass in a string of your choosing here, but I recommend sticking with the default postUrlAddition property. Every time you load posts with any of these two methods, the postUrlAddition parameter is updated on the sharedManager.
[[HNManager sharedManager] loadPostsWithUrlAddition:[[HNManager sharedManager] postUrlAddition] completion:(NSArray *posts){
if (posts && posts.count > 0) {
// Posts were successfuly retrieved
}
else {
// No posts retrieved, handle the error
}
}];
HNpost.{h,m}
The actual HNPost object is fairly simple. It just contains the metadata about the post like Title, and the URL. There is a class method here that scans through the HTML passed in to return the array of posts that the two web methods above return. This is the low-level stuff that you should never have to mess with, but might be beneficial to pore over if you'd like to learn more or implement changes yourself.
// HNPost.h
// Enums
typedef NS_ENUM(NSInteger, PostType) {
PostTypeDefault,
PostTypeAskHN,
PostTypeJobs
};
// Properties
@property (nonatomic, assign) PostType *Type;
@property (nonatomic,retain) NSString *Username;
@property (nonatomic, retain) NSURL *Url;
@property (nonatomic, retain) NSString *UrlDomain;
@property (nonatomic, retain) NSString *Title;
@property (nonatomic, assign) int Points;
@property (nonatomic, assign) int CommentCount;
@property (nonatomic, retain) NSString *PostId;
@property (nonatomic, retain) NSString *TimeCreatedString;
// Methods
+ (NSArray *)parsedPostsFromHTML:(NSString *)html FNID:(NSString **)fnid;
There's only one method to load comments, and naturally, it follows from loading the Posts. After you load your Posts, you can pass one in to the following method to return an array of HNComment
objects. If you go to an AskHN post, you'll notice that the text is inline with the rest of the comments (separated by a text area for a reply), so I decided to include that self-post as the first comment in the returned array. You can tell what this is by using the Type
property of the HNComment. The same goes for an HNJobs post. Sometimes, a Jobs post will be a self-post to HN, instead of an external link, so you can capture this data in the exact same way as a regular comment. If the Type == HNCommentTypeJobs, then you know you have a self jobs post.
The main reason I did this for AskHN and Jobs was to get any Link data out of the post, and to present things nicely to the user inline with any other comments inside my own personal app.
[[HNManager sharedManager] loadCommentsFromPost:(HNPost *)post completion:(NSArray *comments){
if (comments) {
// Comments retrieved.
}
else {
// No comments retrieved, handle the error
}
}];
HNComment.{h,m}
Similar to the HNPost object, HNComment features a handy class method that generates an NSArray of HNComments by parsing the HTML itself. Again, I'd look this over just to get a feel for how it works.
// HNComment.h
// Enums
typedef NS_ENUM(NSInteger, HNCommentType) {
HNCommentTypeDefault,
HNCommentTypeAskHN,
HNCommentTypeJobs
};
// Properties
@property (nonatomic, assign) HNCommentType *Type;
@property (nonatomic, retain) NSString *Text;
@property (nonatomic, retain) NSString *Username;
@property (nonatomic, retain) NSString *CommentId;
@property (nonatomic, retain) NSString *ParentID;
@property (nonatomic, retain) NSString *TimeCreatedString;
@property (nonatomic, retain) NSString *ReplyURLString;
@property (nonatomic, assign) int Level;
@property (nonatomic, retain) NSArray *Links;
// Methods
+ (NSArray *)parsedCommentsFromHTML:(NSString *)html;
User related actions are a vital aspect of being part of the HackerNews community. I mean, if you can't be active in discussion or submit interesting links, then you might as well be a bystander. Unfortunately most HN Reader iOS/Mac apps neglect this part of the community and focus more on the interesting links themselves. There's a good reason for this - it's not trivial to implement; you have to think about Cookies and going through two web calls just to get a submission or comment to go through. It's annoying, and I've decided to make developers' lives easier by doing the annoying work myself and abstracting it away so you don't have to think about it again. It all starts with logging in.
The way HN operates in the browser is off of an HTTP Cookie. This Cookie is generated at login, and kept around for a pretty long time. Logging in on a different computer invalidates all Cookies for a user. Therefore, it's necessary to check if there's a cookie, and validate it before attempting to login. This is done automatically when the HNManager initializes itself using the method startSession
. It will find the Cookie on the device and attempt to validate it. If it does check out, it will set the cookie to the SessionCookie
parameter of the HNManager, as well as grab the correct HNUser so that the SessionUser
property is filled in as well. If it doesn't find a Cookie, or the cookie is no longer valid, you will need to login the old-fashioned way using the following method. Make sure to check that a user is logged in first like so:
// Check to make sure no user is logged in
if (![[HNManager sharedManager] userIsLoggedIn]) {
// No user is logged in; attempt to login
[[HNManager sharedManager] loginWithUsername:@"user" password:@"pass" completion:(HNUser *user){
if (user) {
// Login was successful!
}
else {
// Login failed, handle the error
}
}];
}
Logging out just deletes the SessionCookie property and the SessionUser property from memory, as well as the actual cookie from [NSHTTPCookieStorage sharedStorage]
, so you can't use them any more to make user-specific requests like submitting and commenting. Logging out is dead simple to implement.
[[HNManager sharedManager] logout];
Here's what the HNUser object looks like, for reference:
// HNUser.h
// Properties
@property (nonatomic, retain) NSString *Username;
@property (nonatomic, assign) int Karma;
@property (nonatomic, assign) int Age;
@property (nonatomic, retain) NSString *AboutInfo;
// Methods
+(HNUser *)userFromHTML:(NSString *)html;
Submitting a post is one of those crucial aspects of keeping the community going. Unfortunately, most of the good iOS/Mac clients don't feature this functionality - and so we're left with wallflowers, albeit beautiful, but still wallflowers. Not anymore though. On HackerNews, you can submit a link or a text post (Ask HN), and so we mimic this functionality inside of one single webservice call. To do a text post, just leave the link parameter nil. If both the link parameter AND the text parameter are filled in, the text will be ignored. If both are nil, then the completion block will fire with NO as the boolean value. Here's how you'd implement this:
// Submit a Link!
[[HNManager sharedManager] submitPostWithTitle:@"Hello World!" link:@"www.helloworld.com" text:nil completion:(BOOL success){
if (success) {
// Post was submitted
}
else {
// Post was not submitted
}
}];
// Submit a text post!
[[HNManager sharedManager] submitPostWithTitle:@"Hello World!" link:nil text:@"Hello World!" completion:(BOOL success){
if (success) {
// Post was submitted
}
else {
// Post was not submitted
}
}];
/////////////////
// This will use the LINK and not the text
[[HNManager sharedManager] submitPostWithTitle:@"Hello World!" link:@"www.helloworld.com" text:@"Hello World!" completion:(BOOL success){
if (success) {
// Post was submitted
}
else {
// Post was not submitted
}
}];
/////////////////
// These requests won't work!
[[HNManager sharedManager] submitPostWithTitle:@"Hello World!" link:nil text:nil completion:(BOOL success){
//
}];
// Must have a title!
[[HNManager sharedManager] submitPostWithTitle:nil link:nil text:@"Hello World!" completion:(BOOL success){
//
}];
Replying in HackerNews is the same regardless of the type of object you are replying to, post or another comment. This makes our lives a lot easier. Because of this, there's only one method to call when you want to reply to an object - you just feed it an HNPost or an HNComment and it figures out what to do from there. You must pass in an HNPost or HNComment as well as the text to comment with. If you don't do this, it will pass back a NO in the completion block, indicating failure. Here's the method:
[[HNManager sharedManager] replyToPostOrComment:(HNPost *)post withText:@"Comment to a post" completion:(BOOL success){
if (success) {
// Comment was submitted
}
else {
// Comment failed submitting
}
}];
// And of course, if you want to post a comment to a comment
[[HNManager sharedManager] replyToPostOrComment:(HNComment *)comment withText:@"Comment to a Comment" completion:(BOOL success){
if (success) {
// Comment was submitted
}
else {
// Comment failed submitting
}
}];
/////////////////
// This request won't work!
[[HNManager sharedManager] replyToPostOrComment:nil withText:@"Comment to a post" completion:(BOOL success){
//
}];
// And neither will this one!
[[HNManager sharedManager] replyToPostOrComment:(HNComment *)comment withText:nil completion:(BOOL success){
//
}];
There are a couple considerations to take when voting on a post. For a Post, you can only vote up. On comments, you can only vote up until you have >= 500 karma as a User. For this reason, this method might not let you vote up or down depending on your login status and the amount of karma you have on HN. If you get a NO in the completion block of this method - it did not successfuly let you vote.
[[HNManager sharedManager] voteOnPostOrComment:(HNPost *)post direction:VoteDirectionUp completion:(BOOL success){
if (success) {
// Voting worked!
}
else {
// Voting was not successful!
}
}];
Fetching posts for a user is kind of funky like fetching posts for the homepage based on a filter. The first 30 posts a user has can be achieved by navigating to the site like this: https://news.ycombinator.com/submitted?id=pg, but if you want any posts after this (assuming they have more than 30), you have to use an FNID again. For this reason, we're going to reuse a method from earlier to get any posts after the initial 30 and save the FNID as a property under HNManager called userSubmissionUrlAddition
. Here's how you'd use this:
// Fetch the first 30 posts for a User
[[HNManager sharedManager] fetchSubmissionsForUser:@"pg" completion:(NSArray *posts){
if (posts) {
// Posts were successfuly retrieved
}
else {
// No posts retrieved, handle the error
}
}];
// Fetch posts 31 - n
[[HNManager sharedManager] loadPostsWithUrlAddition:[[HNManager sharedManager] userSubmissionUrlAddition] completion:(NSArray *posts){
if (posts) {
// Posts were successfuly retrieved
}
else {
// No posts retrieved, handle the error
}
}];
HNManager
also handles a couple of different HN-related things, such as having the ability to keep track of which posts you have visited - allowing you to mark posts as read without implementing anything yourself. Here's a list of things you can do.
Mark Posts As Read
To mark a post as read, there is a dictionary inside of the HNManager that keeps track of everything for you. Using the manager, you can get a boolean on whether a post has been read or not, and also add an HNPost to the dictionary of read posts. Here's how you use this functionality:
// Methods
- (BOOL)hasUserReadPost:(HNPost *)post;
- (void)setMarkAsReadForPost:(HNPost *)post;
// Has User Read A Post?
BOOL hasRead = [[HNManager sharedManager] hasUserReadPost:(HNPost *)post];
if (hasRead) {
// User has read the post.
}
// Add a post
[[HNManager sharedManager] setMarkAsReadForPost:post];
Keep A Record of What A User has Voted On
HackerNews is a little goofy about figuring out what can and cannot be voted on - so keeping track of this can be burdensome if you're rolling your own. However, in conjuction with the method to vote on a post/comment, if you get success back, you can throw the post/comment to this method which will handle what you've voted on.
// Methods
- (BOOL)hasVotedOnObject:(id)hnObject;
- (void)addHNObjectToVotedOnDictionary:(id)hnObject direction:(VoteDirection)direction;
// Find out if a post has been voted on
BOOL hasVoted = [[HNManager sharedManager] hasVotedOnObject:(HNPost *)post];
if (hasVoted) {
// User has voted on the Post/Comment
}
// Add to the record
[[HNManager sharedManager] addHNObjectToVotedOnDictionary:(HNComment *)comment direction:VoteDirectionDown];
Killing Web Requests
Sometimes loading comments or posts can take a while, and you may want to navigate away in your app and stop the loading from continuing. To do this, you can make a simple method call that ends all web requests that are happening at the moment through HNManager.
[[HNManager sharedManager] cancelAllRequests];
Well basically, if you've dug in to the innards of how this works, you will have noticed that it relies very, very heavily on a parsing scheme to get the right info and make sesnse of it. As anyone who has every built something that scrapes knows, this is not a future-proof scheme. With that being said, I haven't seen HN change their innards. I think there are a few options to making this awesome and maintainble should HN do this, some of which are feasible and others probably less so.
- persuade PG to make a damn API
- use an online DB with the order of parsing and what parsing "tags" to look for so that if HN does change, nobody has to wait a week for Apple approval while their app crashes.
- write an API that scrapes HN every few minutes, but that costs money to provide volume to every app that may use it, and I'm broke.
Features to Add
I'm thinking of adding these features as things go along, but for v0.1, I wanted a bare-bones HN specific library.
- Editing A User - save their about/email basically.
Here's a list of iOS/Mac apps that use libHN to provide sweet functionality. Use this library in your app? Open an issue and I'll add it to the list here:
- News/YC -- also on GitHub
- The News (iOS)
- MackerNews (OSX)
- HNReader (iOS)
libHN is licensed under the standard MIT License.
Copyright (C) 2013 by Benjamin Gordon
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.