#and my most popular post is only about ~70k notes
Explore tagged Tumblr posts
perilegs · 10 days ago
Text
i swear if that kringlefucker post becomes my most popular post on this site
5 notes · View notes
skaruresonic · 5 months ago
Text
Vagueblogging because I encountered That Post(tm) again under the Tuscarora tag
Conflicted about the fact that the most popular Skarù·ręʔ post on this site has 70K notes and it was a refutation written by a white-passing Skarù·ręʔ person, posting their full family lineage (complete with photos of their childhood) in order to put an anon who told them they didn't "look Native" on blast.
On the one hand, good on them for showing people that you don't have to "look Native" to be Native. But you really shouldn't have to drag out photos of yourself in full regalia posed next to your mom in order to drive your point home.
On the other hand, nothing against them personally, but I don't think people should be listening to someone from this particular family. Their family has a... to put it mildly... spotty history.
And I don't mean they're a faker or anything, but rather that their yękhisutkę́·ʔhaʔnęhk may have been involved with some kačíkačiks, among other things, and it’s a little surreal to see so many people take someone from such a family as, like, a Spokesperson(tm) for our tribe. In truth, I think I've only seen this individual in person maybe once or twice.
Hell, I feel uncomfortable when people take me as some ambassador because my own white-passing, white-raised ass certainly doesn’t know everything. But there's also so few of us that somebody's got to speak up, and those who have always spoken loudest get heard first.
I may post quite a bit about my culture, but honestly I'm still pretty disconnected - the difference between me and them is that I was raised white, and I don't always feel comfortable in my lack of knowledge.
When I was a child, my mom put me in a white school in the suburbs because she feared I would be socially eviscerated at the Native school by her brother's in-laws (who are a part of That Family). Like, she was afraid her brother's sister-in-law, who was a teacher, would target me, a five-year-old, over these thorny clan politics.
Even so, I can't imagine thrusting the family scrapbook into people's faces to dunk on an anon and then have nearly 100K people reblog that for the whole world to see.
Like holy shit, the hellfire that would rain down on me. I merely dared to post language stuff on my previous blog and some asshole hounded me on Mother's Day with the most racist remarks you could imagine.
Suffice to say, our tribe has an unfortunate history of "big enchiladas" sliding in to make sure the history books record them as big enchiladas, and it turns out they're not big enchiladas at all. They just want to look important and by God, do non-Skarù·ręʔ people take them at their word.
Clout-chasing is the best-case scenario. Sometimes they turn out to be total slimeballs. You hear things, dude.
You'll always hear a certain name as a chief. He wasn't a chief; he got deposed. They took away his horns.
But because the published books Say So(tm), white people think he was a chief. He's called a chief in various documents to this day. And almost everyone who knows the truth and would cry bullshit are dead.
0 notes
five-wow · 5 years ago
Text
Author Asks
Rules: answer these questions and tag five other fic writers to do the same.
I was tagged by the wonderful @novemberhush. Thank you, omg, because I love rambling about writing and this is the best kind of opportunity to do so, handed on a silver platter, ahh. 😊
-
Author Name: Square / Squares / SquaresAreNotCircles
Fandoms You Write For: I’m a fandom hopper! In the past year or so it’s been Hawaii Five-0 (a truly ridiculous amount), Shadowhunters, Venom, Harry Potter, due South and Stargate Atlantis. Other fandoms I’ve written at least one fic for are Twilight, Doctor Who, Torchwood, Glee, BBC Merlin, BBC Atlantis, Teen Wolf, In The Flesh, Star Wars, Supernatural, the MCU and High School Musical. And uh, Alexander the Great/Voltaire fic (which would be... history fandom? RPF?) and one (1) Judas/Jesus Biblefic. If we’re getting really technical, also a tiny little bit of One Direction fic.
It should be noted that all of this is about fic that ended up getting posted somewhere on the interwebs - there are multiple Star Trek (TOS/AOS and DS9) fics lingering in my drafts (!! one day I will finish one of them), as well as some How To Train Your Dragon, The Good Place and Deadpool stuff, and definitely more I’ve forgotten.
Where You Post: Since I made the switch to writing in English everything has landed on ao3, but I used to write mostly in Dutch, so there’s still close to a million words, I think, under my name on quizlet.nl (not to be confused with quizlet.com, which is a very different website).
Most Popular One-Shot: That depends on how you’re measuring popularity! Going by kudos, it’s Tell me I’m perfect (but tell me the truth), a Magnus/Alec Shadowhunters fic. It’s the truth is a really old fic about Percy Weasley/Oliver Wood from Harry Potter that has the most hits out of all my works, and That time Steve kissed every single Avenger (and also Bucky), an MCU Steve/Bucky fic, has the greatest number of comment threads.
Also, since this is an h50 blog: for my fic in this fandom Wanted: partner (in crime) has the most kudos and hits; You had me at meow has the most comments.
Most Popular Multi-Chapter Story: I’m working on one for h50 (going slowly, so slowly), but I don’t have any posted to ao3. I used to write a lot of multi-chaptered work in my quizlet.nl days, and I think my most popular fic there was probably the second fic I ever wrote, when I was fourteen or fifteen, which was a next-gen Harry Potter fic with shifting and overlapping POVs from the three Potter kids. It was kind of, well, not great, but it’s probably what really cemented my writing habit, it’s still my longest fic ever (over a 100k!) and I got my first fandom friends out of it, including one I’m still in contact with to this day, even though neither of us writes much if anything for Harry Potter anymore.
Favourite Story You Wrote: Ohhh, that’s such an impossible question, especially because I’ve been churning out one-shots like I might actually be getting paid for it, so there’s so much to choose from, which is a thing I have difficulty doing at the best of times, holy shit. Uh, I once wrote a 70k Remus/Sirius (Harry Potter) modern college-ish AU in Dutch that I still like; weirdly, I think that Biblefic holds up (also Dutch), and the HSM fic is fun to reread once in a while because of the fourth wall break, as is That escalated quickly, a Percy/Oliver fic. Ooh, and the fic about Shuri and Stucky and a goat!
For h50, it’s even harder to choose, because my preferences change pretty much weekly (a combination of newer fic being shinier, looking back at fic from even just a few months ago and finding things I would have done differently now, and comments influencing the way I personally look at my own fic), but right now, I’d say I still really like the fic where Steve adopts some guinea pigs, the one with the slightly tipsy team bonding by talking about mutual crushes and this 9.11 coda fix fluff getting together thing.
Story You Were Nervous to Post: That Biblefic, haha, because it’s a very complicated topic and my aim was definitely not to offend. People were really sweet about it, though! Mostly, they were kind of shocked it wasn’t crack, but that’s fair, because so was I.
Also pretty much anything I post in a new fandom, really, and low key just... anything at all. I’m always a little scared I tagged something super badly or accidentally copy-pasted the wrong text or unknowingly wrote something super offensive or whatever, despite my double- and triplechecking of the posting form. (I’m also still kind of scared people on ao3 will randomly decide they hate my fic and my writing and me personally (ao3 is really big and very anonymous and coming from the small town that was quizlet.nl even in its heyday, that’s scary), but that fear has abated as I’ve posted more, just because the data is showing pretty conclusively that thought is as irrational as it sounds. Everyone is always so nice, gosh.)
How Do You Pick Your Titles: Mostly, I steal lines from random songs. I have a small pile of song lyrics to use as potential titles, because going on a seperate hunt for every new fic would take most of my waking hours. Sometimes, I’ll use a pun (like You had me at meow or Retail Therapy) or something else that I think sounds good, especially if the fic is mostly comedy and/or has a specific premise that would do well in a title (like Five times the Governor of Hawaii suspects his taskforce leaders are violating fraternization policies (and one time they tell him they are)).
Do You Outline: I’m mostly writing fic of (sometimes much) less than 5k at the moment, so not really. I do sometimes write tiny bits of a bunch of scenes and then fill in the rest around that, which is a kind of outline, in a way. For longer works, I usually make a one page bullet point list of things that need to happen and work from there, because I can’t do really extensive outlining or I’ll just get caught up in the details and lose all of the oversight a tool like that is supposed to give you, as well as most of my enthusiasm for the project.
How Many Of Your Stories Are Complete: Of the ones posted? On ao3, all of them, because unfinished posted one-shot works would require some strange bending of those concepts. On quizlet.nl, I do have some abandoned works, but I think 80% is finished.
In-Progress: SO MUCH. Seriously, just, so much, oh god. I’d really like to write another Stargate Atlantis fic (and I have 30% of one done), and something more for due South, too, and maybe a small Percy/Oliver thing again some time because they were my very first OTP and I kind of miss them, but mostly I have, like, 100+ half written things for h50. I really wish that number was an exaggeration. There’s no way they’ll all get finished, but maybe... a third? Mayhaps?
That One Truly Long H50 Fic that I was already talking about way back in October last year is also eternally “in progress”. The thing is that it has about 25k now, after a year, and I think it needs... at least four times that. Probably. So either I’ll have to stick with this fandom and my slow progress for another three years to have a shot at getting it finished, or I’ll need to find a way to up the speed a little. Maybe I could try working on it for NaNo this November? That would be pretty awesome, but honestly, part of why it’s moving this slowly is because NaNo-style fast and messy writing for this scares me a little, because I might end up writing a lot, decide it’s not what I wanted for it, and become too intimidated to ever edit and/or rewrite the entire thing. But idk, I probably just need to get over my own fears, because I really do want to write Longer Fic again. Short stuff is fun and feels really productive and that’s great, but I miss the actual slow burn and build-up that only 50k+ words can give you.
Coming Soon: Hopefully a lot? For h50, that is. I have no idea what’s getting posted next, because I’m never entirely sure what’s going to be finished next and something really random might come jumping in, but at the moment I’m trying to direct most of my energies at a slightly longer fic I’ve been working on for months (not The Long Fic, a different one), a fic labeled “9.01 memory loss fic”, another one temporarly entitled “Perfect Kauai beach house vacation”, and maybe an ace!Steve fic I’ve been working on, if I ever manage to uh, actually finish that, instead of rewriting three sentences during every round of editing and never actually adding anything to fill in the gaps it still has. There will also be more season 10 codas, in all likelihood.
Do You Accept Prompts: I’ve never done that before in the traditional way, but I’m thinking about it! I’d love to try (and it would be a breath of fresh air, in some ways!), but the main thing holding me back is that I have way too much on my plate with just my own ideas to work off of, and I don’t want to disappoint people. Maybe if I do drabble-ish prompt fills? It’s definitely been on my mind.
Upcoming Story You’re the Most Excited For: I’m excited for a lot of stuff, but honestly, the top spot right now probably goes to the ace!Steve fic. I’m not even sure it’s that good, necessarily, but it’s, idk, really cathartic, I suppose. Seriously self-indulgent in strange but very good ways. I really like writing it. (Second spot goes to the beach vacation fic, because I haven’t actually written that much for it, but it’s been my go-to easy happy place for the last few weeks.)
-
I’m tagging @love2hulksmash @thekristen999 @stephmcx @girlonastring @flowerfan2 and @pterawaters, which is six people because I can’t count, but I’m about to make it seven because I’m also tagging you, the person reading this (hi there!). Say I tagged you and tag me so I can read it! I know that kind of thing can feel awkward, but it won’t be, because I’m cheering you on. Go for it, if you want to do it. :D
12 notes · View notes
ageofavalon · 6 years ago
Text
Tumblr media
My WIPs
  ↪ Introducing: Mier School of the Arts. [8k of 70k]
❝ ‘What none of you seem to realize is: if we don’t have this school we have nothing. We don’t have each other, we don’t live out our dreams, and if MSA goes, then we become nothing. Raven, do you really want to go back to sulking around public school? Dal, could you imagine how smug your parents would be? And what about you, Dylan? Can you give up your one chance to become somebody?’ Ace’s words stared them in the face, sobering and unrelentingly true; if they couldn’t pull this off, their lives at MSA, their lives period, were through.❞  — an excerpt from MSA.
↪ Synopsis (work in progress)
Mier School of the Arts had once been the pride and joy of the Holt family. Founded in 1976 by Carolyn and Andrew Holt, Mier School of the Arts was the main attraction for the small coastal town of Mier, Washington. A prestigious academy, MSA was founded for the purpose of providing the best education for the gifted students in the surrounding area, and even for out-of-state transfer students. Eventually, the grandson of Carolyn and Andrew Holt, Elijah Holt, assumes the role of director and is left to shoulder the burden of past irresponsibility.
After the untimely death of Director Holt’s younger brother, Mier School of the Arts is in jeopardy. Funds are lacking, and Director Holt is questioning his passion for the arts. Cue Raven Lee, and her not-so-voluntary admittance to MSA. Through a series of unexpected relationships, peculiar learning experiences, and the growing bond of nine friends, Raven and her new-found companions must save MSA, as well as the students relying on it for their future.
↪ Characters
🌠 Raven- Raven is sent to MSA after getting into a bit of a ‘mishap’ with the law. A mural artist, Raven is admitted into the art department after her father pulls some strings with his many connections. While she’s at MSA, her family is moving across the country for her mother’s temporary new job. 
، female, 17, black hair, dark brown eyes
🌠 Ace- According to himself, his name is Ace because he’s good at everything. Ace is currently enrolled in four different departments at MSA: art, photography, acting, and music. He’s outgoing, goofy, and obsessed with music production. Prides himself in being Raven’s tour guide at MSA. 
، male, 17, dark brown hair, brown eyes
🌠 Leia- Star dancer of MSA. She is a lover of ballroom and freestyle dance, but forced to do ballet because of her ‘natural talent’. Her parents are pushy, and she despises them for it. She can be a bit snippy at times, but only because no one ever cares to ask about her instead of her dancing.
، female, 16, brown hair, gray eyes
🌠 Colton- He’s in the photography department. Colton can be uptight at times,but his personality isn’t too unbearable as long as he’s around his girlfriend, Grayson. He spends a lot of time doing wildlife photography, and knows many facts about animals because his father is a zoologist. Dreams of shooting for a big company like National Geographic. 
، male, 18, blond hair, brown eyes
🌠 Grayson- Also in the photography department, Grayson is the self-proclaimed ‘candid photographer’ for the group. She is extremely laid back and likable. Her favorite shots are candid portraits, and action shots of the performances. She puts orchestrates slideshows for fundraisers and other events at MSA. 
، female, 18, mousy brown hair, blue-green eyes
🌠 Dallas- Dallas is the best friend of Ace. Despite their somewhat conflicting personalities, the two of them get along well and cause a lot of trouble around MSA, though they never get caught. Dallas is in the music department. Though he is a little bit of a mess, Dallas brings charm to the group, while Ace works as the glue that holds them all together.
، male, 18, very light blond hair, nearly-black eyes
🌠 Corryn- Corryn is in the theater department. She is both a playwright and an amazing actress. She also sometimes works backstage and does directing for some of the plays, but for the most part she enjoys being in the center of things. Sometimes she can let people walk all over her, but has her friends there to watch her back and help her learn to defend herself. 
، female, 15, golden brown hair, brown eyes
🌠 Ross- Ross is one of the two living younger brothers of Director Holt. His passion lies in sculpting. He’s extremely laidback, and handles his grief very well. He’s popular around MSA for being likable and helpful, as well as being the Director’s younger sibling. He’s the twin of Dylan.
، male, 17, brown hair, hazel eyes
🌠 Dylan- Dylan is in the theater department. He’s an all around good kid, enjoys sports as well as acting, but is overwhelmed by everything going on in his life. The lack of funds as well as the recent death of his brother has been trying for him. To lose his brother and then MSA directly after would crush him.
، male, 17, brown hair, hazel eyes
🌠 Director Holt- Elijah Holt is the director of MSA. He is the older brother of Jack, Dylan, and Ross Holt. He is unsure if continuing his work at MSA is worth it after Jack is killed in a car crash, and so refuses to organize a fundraiser in order to keep the school afloat. At thirty years old, he is the legal guardian of the twins after his parents irresponsible actions while managing the school and parenting. Now, in his devastation from the loss of eighteen year old Jack, he is lost and unable to fathom why he should continue to live out his dreams when his brother cannot. 
، male, 30, brown hair, brown eyes
🌠 Rhea- Rhea is the younger sister of Raven. At thirteen years old, she has the attitude and mannerisms of a forty year old woman. She’s extremely overprotective and opinionated. She has an undiscovered talent for music, but refuses to attend MSA because she believes its a ‘rich kids’ school for ‘spoiled brats and band geeks’. 
، female, 13, black hair, brown eyes
↪ Notes
MSA is a major work in progress at the moment, and is on hold until I have time to flesh out the whole draft. I will provide links to moodboards/extra information on MSA once they have been posted. If you would liked to be tagged in any of my posts pertaining to Mier School of the Arts, please message me or let me know below. Feel free to provide feedback, suggestions, or anything else of importance. Happy writing!
16 notes · View notes
jellyfishjuliet · 8 years ago
Note
I just read your thing on Leorio and while I enjoy the "Leorio is a dad"-fanon and don't see anything wrong with it, It does undermine his canon characte a bit. And it is true that Kurapika gets hyperfeminized (which is often uncomfortable to me considering a lot of the fandom also sees him as a canon trans guy? It has an aspect to it that isn't about representation and more about fetishization and I dislike it a lot) and I personally am guilty of that, too. But I kinda disagree that (...)
Tumblr media
I understand where you’re coming from,and I admit, I haven’t read every Kurapika or Leorio centric fic inthe world, but from the stuff I have lookedat (I’m an unfortunate academic at heart), the content’s… what doyou say? It’s not that deep, fam.
Now,does that mean you should stop creating fancontent? Fuck no. It justmeans you do you and have a good time. My critique is entirely justthat- a critique. I’m not calling for a revolution for Hunter xHunter fanfiction because, for all intents and purposes, it’s beenaround longer than I have been reading it (I’ve only been a fan forseven months). Please don’t take my posts as discouragement forwriting things you’re passionate about and what makes you happy. I,myself, have a good time deconstructing and parsing through thingsbecause I’m a general academic and have inclination towards writingand reading faithful characterizations of characters. Does that meanOOC is bad? Nope, it just means I’m not interested in it and wouldpersonally like to see more canon characterizations. But again-that’s just me. Everyone is free to enjoy fandom to their own tastes,and so should you.
Now,most of my critique of Kurapika’s fetishistic hyperfeminization andLeorio being stripped of his canon qualities come from the reams ofLeopika, KuroKura, HisoPika, and the occasional rarepair fics I’velooked through on fanfiction.net and AO3. Now, hyperfeminization ofandrogynous characters isn’t anything new. It’s actually somethingthat’s been pervasive in fandom culture since… the beginning oftime, I guess. At least, in Western fandom spaces, it’s largelybecause of the inherent heteronormative culture practiced here, alongwith added doses of misogyny and the implicit leanings towardstraditional gender roles. Not to mention, hyperfeminization ofcharacters is a kink for some folks.
Now,is that wrong? Not necessarily. If it’s ingrained in the culture,then it’s gonna show up in the fancontent, as fandom culture isdeeply rooted to media culture and subcultural practices. Also, I’mvery aware that a lot of the people writing these hyperfeminizedKurapikas and woobie Leorios are young people just dabbling infanfiction for the first time, just like there are authors who onlywrite hyperfeminized Kurapikasand woobie Leories (and the occasional woobie Chrollos), just likethere are people who are trying to write issue fics but are still comingoff as fetishistic. You’re absolutely entitled to that if that’s whatyou’re into, and more than that, writing is half learning as you goand half understanding where you went wrong. So, anon, if you’reactively trying to be a better writer, you gucci, fam. Aint nuthin toworry about.
Now,have the times changed? Absolutely. Fanfiction from 2007 absolutelydoes not reflect fanfiction from 2017, just like it doesn’t representold ass stories from long-dead fandoms that were super popular in thelate 1990s and the early 2000s. Times change, tropes changes, and sodo the people dabbling in fandom. From the recent Kurapika fanfictionI’ve tried reading, I do see why people would use his character toextrapolate on and explore trangenderism and androgyny, but it shouldbe noted that many of the fics that are noted tobe of such nature are still incredibly fetishistic. It’s like puttinga issue fic hat on a 70k Leopika where Kura’s the emotional sub inthe relationship being chased by a very persistent Leorio, when inreality, it’s just a kinkfic about one dude chasing another andtrying to, unfortunately, “save” him from himself. Which,again, absolutely doesn’tmean people can’t write their kinkfics. Live and let live, I say.
At theend of the day, what’s there is there, and though I’d like to seebetter stuff, I’m OK if there isn’t. At the end of the day, a hoegotta write what a hoe wanna read in the world, so anon, if you gotshit you know deserves to be out there, write itand let a bitch know!!
2 notes · View notes
movingwrightalong · 7 years ago
Text
Blogging is my creative outlet.
I’ve been able to share some of the most fulfilling, challenging, and heartbreaking times of my life over the last four years on this blog. Living abroad, I worried that I wouldn’t be able to keep up with Moving Wright Along, although I had every intention to.
To my surprise, there is WiFi in Namibia, although many times it isn’t as speedy or reliable as the connection I am used to and sometimes it takes traveling between 40-70k to find decent internet. Many times this proves to be extremely frustrating, but it doesn’t mean that Moving Wright Along has to take a hiatus.
If  you are a Peace Corps volunteer and want to blog through your experience, here are some tips that I hope you can find useful:
Be Consistent
I say this after not posting consistenly in about a month. Do as I say, not as I do.
But, whether you post weekly, bi-weekly, or even monthly, be consistent. Your time abroad may be the only glimpse family and friends back home have to your new home. They trust your insight, perspective, and voice. My blog is hosted through WordPress which has a scheduling feature which is clutch. I can draft multiple blogs and schedule them to post at a future date (usually at times I know my friends and family are awake back home).
Draft Offline
Trust me on this one. I have lost many drafts and have had pretty much any blogging woe you can imagine. Like I said earlier, internet and data services abroad aren’t always as reliable, fast or friendly. I’ve found that drafting offline saves a lot of headache and heartache. I’ve found success in drafting offline using pen & paper, Word, or even notes on my phone. This gives me time to edit my ideas and thoughts prior to sharing with the world. Which leads me to my next tip…
Be Culturally Sensitive
I like to read blogs about other Peace Corps volunteers around the world. But, I find a fair share of blogs that RANT about customs and norms of their host country. I get it! You had a bad day and took it to your blog. Remember, if your friends and family can read your blog from a world away, ANYONE in the world can read your blog. As a Peace Corps volunteer, we encounter challenges within our host country. I, for one, have dealt with many which I choose not to share publicly in a blog, but rather leave as thoughts better suited for my journal.
Your experience abroad is your truth, and no one can take that from you. But, you should avoid painting that as the only truth. Avoid making generalizations and stereotypical comments in your postings. What’s worse than a culturally insensitive PCV? I really don’t know, but it can’t be good. Need ideas on what to write about? Join the Blogging Abroad Challenge.
Switch up your Style
Blogging doesn’t always have to be a long narrative. It can be done using pictures, videos, and even audio. Seeing the sights and hearing the sounds of your host country can be a wonderful addition to your blog. Linking other Peace Corps volunteer blogs is another great way to build cultural understanding as well as accomplishing Goal 3.
Choose a Friendly Host
I’ve been loyal to WordPress since 2009. Back then, I was interning for SportChassis and blogging about over-sized luxury pickup trucks driven by over-sized people such as Shaq and Dennis Rodman. I have dabbled a little bit with Blogger and Tumblr, both are pretty user-friendly especially for those new to blogging.
There are many other hosts to choose from, just do your research. I love WordPress because of my familiarity with the software (although, takes some getting used to), ease in ability to personalize website, low cost for the domain, and popularity, of course.
I hope these tips help and feel free to share!
❤ Krystal
Tips for the Peace Corps blogger. Blogging is my creative outlet. I've been able to share some of the most fulfilling, challenging, and heartbreaking times of my life over the last four years on this blog.
0 notes
luxus4me · 8 years ago
Link
SitePoint http://j.mp/2rGKte5
This article was sponsored by RestDB. Thank you for supporting the partners who make SitePoint possible.
Are you active on Twitter? If so, do you often wonder why some accounts seem to follow you only to unfollow you moments (or days) later? It’s probably not something you said – they’re just follower farming.
Follower farming is a known social media hack taking advantage of people who “#followback” as soon as someone follows them. The big brands, celebs, and wannabe celebs take advantage of this, as it keeps their followers count high but following count low, in turn making them look popular.
In this post, we’ll build an app which lets you log in via Twitter, grabs your followers, and compares the last fetched follower list with a refreshed list in order to identify the new unfollowers and calculate the duration of their follow, potentially auto-identifying the farmers.
Bootstrapping
As usual, we’ll be using Homestead Improved for a high quality local environment setup. Feel free to use your own setup instead if you’ve got one you feel comfortable in.
git clone http://j.mp/2rGkdjW hi_followfarmers cd hi_followfarmers bin/folderfix.sh vagrant up; vagrant ssh
Once the VM has been provisioned and we find ourselves inside it, let’s bootstrap a Laravel app.
composer create-project --prefer-dist laravel/laravel Code/Project cd Code/Project
Logging in with Twitter
To make logging in with Twitter possible, we’ll use the Socialite package.
composer require laravel/socialite
As per instructions, we should also register it in config/app.php:
'providers' => [ // Other service providers... Laravel\Socialite\SocialiteServiceProvider::class, ],
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
Finally, we need to register a new Twitter app at http://apps.twitter.com/app/new…
… and add the secret credentials into config/services.php:
'twitter' => [ 'client_id' => env('TWITTER_CLIENT_ID'), 'client_secret' => env('TWITTER_CLIENT_SECRET'), 'redirect' => env('TWITTER_CALLBACK_URL'), ],
Naturally, we need to add these environment variables into the .env file in the root of the project:
TWITTER_CLIENT_ID=keykeykeykeykeykeykeykeykey TWITTER_CLIENT_SECRET=secretsecretsecret TWITTER_CALLBACK_URL=http://j.mp/2rGsog2
We need to add some Login routes into routes/web.php next:
Route::get('auth/twitter', 'Auth\LoginController@redirectToProvider'); Route::get('auth/twitter/callback', 'Auth\LoginController@handleProviderCallback');
Finally, let’s add the methods these routes refer to into the LoginController class inside app/Http/Controllers/Auth:
/** * Redirect the user to the GitHub authentication page. * * @return Response */ public function redirectToProvider() { return Socialite::driver('twitter')->redirect(); } /** * Obtain the user information from GitHub. * * @return Response */ public function handleProviderCallback() { $user = Socialite::driver('twitter')->user(); dd($user); }
The dd($user); is there to easily test if the authentication went well, and sure enough, if you visit /auth/twitter, you should be able to authorize the app and see the basic information about your account on screen:
Follower Lists
There are many ways of getting an account’s follower list, and none of them pleasant.
Twitter Still Hates Developers
Ever since Twitter��s Great War on Developers (spoiler: very little has changed since that article came out), it’s been an outright nightmare to fetch full lists of people’s followers. In fact, the API rate limits are so low that people have resorted to third party data aggregators for actually buying that data, or even scraping the followers page. We’ll go the “white hat” route and suffer through their API, but if you have other means of getting followers, feel free to use that instead of the method outlined below.
The Twitter API offers the /followers/list endpoint, but as that one only returns 20 followers per call at most, and only allows 15 requests per 15 minutes, we would be able to, at most, extract 1200 followers per hour – unacceptable. Instead, we’ll use the followers/ids endpoint to fetch 5000 IDs at a time. This is subject to the same limit of 15 calls per 15 minutes, but gives us much more breathing room.
It’s important to keep in mind that ID != Twitter handle. IDs are numeric values representing a unique account across time, even across different handles. So for each unfollower’s ID, we’ll have to make an additional API call to find out who they were (the Users Lookup Bulk API will come in handy).
Basic API Communication
Socialite is only useful for logging in. Actually communicating with the API is less straightforward. Given that Laravel comes with Guzzle pre-installed, installing Guzzle’s Oauth Subscriber (which lets us use Guzzle with the Oauth1 protocol) is the simplest solution:
composer require guzzlehttp/oauth-subscriber
Once that’s in there, we can update our LoginController::handleProviderCallback method to test things out:
public function handleProviderCallback() { $user = Socialite::driver('twitter')->user(); $stack = HandlerStack::create(); $middleware = new Oauth1([ 'consumer_key' => getenv('TWITTER_CLIENT_ID'), 'consumer_secret' => getenv('TWITTER_CLIENT_SECRET'), 'token' => $user->token, 'token_secret' => $user->tokenSecret ]); $stack->push($middleware); $client = new Client([ 'base_uri' => 'https://api.twitter.com/1.1/', 'handler' => $stack, 'auth' => 'oauth' ]); $response = $client->get('followers/ids.json', [ 'query' => [ 'cursor' => '-1', 'screen_name' => $user->nickname, 'count' => 5000 ] ]); dd($response->getBody()->getContents()); }
In the above code, we first create a middleware stack which will chew through the request, pull it through all the middlewares, and output the final version. We can push other middlewares into this stack, but for now, we only need the Oauth1 one.
Next, we create the Oauth1 middleware and pass in the required parameters. The first two we’ve already got – they’re the keys we defined in .env previously. The last two we got from the authenticated Twitter user instance.
We then push the middleware into the stack, and attach the stack onto the Guzzle client. In layman’s terms, this means “when this client does requests, pull the requests through all the middlewares in the stack before sending them to their final destination”. We also tell the client to always authenticate with oauth.
Finally, we make the GET call to the API endpoint with the required query params: the page to start on (-1 is the first page), the user for whom to pull followers, and how many followers to pull. In the end, we die this output onto the screen to see if we’re getting what we need. Sure enough, here’s 5000 of the most recent followers for my account:
Now that we know our API calls are passing and we can talk to Twitter, it’s time for some loops to get the full list for the current user.
The PHP Side – Getting All Followers
Since there are 15 calls per 15 minutes allowed via the API, let’s limit the account size to 70k followers for now for simplicity.
$user = Socialite::driver('twitter')->user(); if ($user->user['followers_count'] > 70000) { return view( 'home.index', ['message' => 'Sorry, we currently only support accounts with up to 70k followers'] ); }
Note: home.index is an arbitrary view file I made just for this example, containing a single directive: .
Then, let’s iterate through the next_cursor_string value returned by the API, and paginate through other IDs.
Much numbers, very follow, wow.
With some luck, this should execute very quickly – depending on Twitter’s API responsiveness.
Everyone with up to 70k followers can now get a full list of followers generated upon authorization.
If we needed to support bigger accounts, it would be relatively simple to make it repeat the process every 15 minutes (after the API limit resets) for every 75k followers, and stitch the results together. Of course, someone is almost guaranteed to follow/unfollow in that window given the number of followers, so it would be very hard to stay accurate. In those cases, it’s easier to focus on the last 75k followers and only analyze those (the API auto-orders by last-followed), or to find another method of reliably fetching followers, bypassing the API.
Cleaning Up
It’s a bit awkward to have this logic in the LoginController, so let’s move this into a separate service. I created app/Services/Followers/Followers.php for this example, with the following contents:
<?php namespace App\Services\Followers; use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; use GuzzleHttp\Subscriber\Oauth\Oauth1; class Followers { /** @var string */ protected $token; /** @var string */ protected $tokenSecret; /** @var string */ protected $nickname; /** @var Client */ protected $client; public function __construct(string $token, string $tokenSecret, string $nickname) { $this->token = $token; $this->tokenSecret = $tokenSecret; $this->nickname = $nickname; $stack = HandlerStack::create(); $middleware = new Oauth1( [ 'consumer_key' => getenv('TWITTER_CLIENT_ID'), 'consumer_secret' => getenv('TWITTER_CLIENT_SECRET'), 'token' => $this->token, 'token_secret' => $this->tokenSecret, ] ); $stack->push($middleware); $this->client = new Client( [ 'base_uri' => 'https://api.twitter.com/1.1/', 'handler' => $stack, 'auth' => 'oauth', ] ); } public function getClient() { return $this->client; } /** * Returns an array of follower IDs for a given optional nickname. * * If no custom nickname is provided, the one used during the construction * of this service is used, usually defaulting to the same user authing * the application. * * @param string|null $nickname * @return array */ public function getFollowerIds(string $nickname = null) { $nickname = $nickname ?? $this->nickname; $response = $this->client->get( 'followers/ids.json', [ 'query' => [ 'cursor' => '-1', 'screen_name' => $nickname, 'count' => 5000, ], ] ); $data = json_decode($response->getBody()->getContents()); $ids = $data->ids; while ($data->next_cursor_str !== "0") { $response = $this->client->get( 'followers/ids.json', [ 'query' => [ 'cursor' => $data->next_cursor_str, 'screen_name' => $nickname, 'count' => 5000, ], ] ); $data = json_decode($response->getBody()->getContents()); $ids = array_merge($ids, $data->ids); } return $ids; } }
We can then clean up the LoginController’s handleProviderCallback method:
public function handleProviderCallback() { $user = Socialite::driver('twitter')->user(); if ($user->user['followers_count'] > 70000) { return view( 'home.index', ['message' => 'Sorry, we currently only support accounts with up to 70k followers'] ); } $flwrs = new Followers( $user->token, $user->tokenSecret, $user->nickname ); dd($flwrs->getFollowerIds()); }
It’s still the wrong method to be doing this, so let’s further improve things. To keep a user logged in, let’s save the token, secret, and nickname into the session.
/** * Get and store token data for authorized user. * * @param Request $request * @return Response */ public function handleProviderCallback(Request $request) { $user = Socialite::driver('twitter')->user(); if ($user->user['followers_count'] > 70000) { return view( 'home.index', ['message' => 'Sorry, we currently only support accounts with up to 70k followers'] ); } $request->session()->put('twitter_token', $user->token); $request->session()->put('twitter_secret', $user->tokenSecret); $request->session()->put('twitter_nickname', $user->nickname); $request->session()->put('twitter_id', $user->id); return redirect('/'); }
We save all the information into the session, making the user effectively logged in to our application, and then we redirect to the home page.
Let’s create a new controller now, and give it a simple method to use:
artisan make:controller HomeController
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; class HomeController extends Controller { public function index(Request $request) { $nick = $request->session()->get('twitter_nickname'); if (!$nick) { return view('home.loggedout'); } return view('home.index', $request->session()->all()); } }
Simple, right? The views are simple, too:
<h1>FollowerFarmers</h1> <h2>Hello, ! Not you? <a href="/logout">Log out!</a></h2> <p>I bet you'd like to see your follower stats, wouldn't you?</p>
<h1>FollowerFarmers</h1> <h2>Hello, stranger!</h2> <p>You're currently logged out. How about you <a href="/auth/twitter">log in with Twitter </a> to get started?</p>
We’ll need to add some routes to routes/web.php, too:
Route::get('/', 'HomeController@index'); Route::get('/logout', 'Auth\LoginController@logout');
With this, we can check if we’re logged in, and we can easily log out.
Note that for security, the logout route should only accept POST requests with CSRF tokens – for simplicity during development, we’re taking the GET approach and revamping it later.
Admittedly, it’s not the prettiest thing to look at, but we’re building a demo here – the real thing can get visually polished once the logic is done.
Registering a Service Provider
It’s common practice to register a service provider for easier access later on, so let’s do that. Our service can’t be instantiated without the token and secret (i.e. before the user logs in with Twitter) so we’ll need to make it deferred – in other words, it’ll only get created when needed, and we’ll make sure we don’t need it until we have those values.
artisan make:provider FollowerServiceProvider
<?php namespace App\Providers; use App\Services\Followers\Followers; use Illuminate\Support\ServiceProvider; class FollowerServiceProvider extends ServiceProvider { protected $defer = true; public function register() { $this->app->singleton( Followers::class, function ($app) { return new Followers( session('twitter_token'), session('twitter_secret'), session('twitter_nickname') ); } ); } public function provides() { return [Followers::class]; } }
If we put a simple count echo into our logged in view:
… and modify the HomeController to now use this ServiceProvider:
... return view( 'home.index', array_merge( $request->session()->all(), ['ids'=> resolve(Followers::class)->getFollowerIds()] ) );
… and then we test, sure enough, it works.
Database
Now that we have a neat service to extract follower lists with, we should probably save them somewhere. We could save this into a local MySQL database, or even a flat file, but for performance and portability, I went with something different this time: RestDB.
RestDB is a plug and play hosted database service that’s easy to configure and use, freeing up your choices of hosting platform. By not needing a database that writes to a local filesystem, you can easily push an app like the one we’re building to Google Cloud Engine or Heroku. With the help of its templates, you can instantly set up a blog, a landing page, a web form, a log analyzer, even a mailing system – heck, the service even supports MarkDown for inline field editing, letting you practically have a MarkDown-based blog right there on their service.
RestDB has a free tier, and the first month is virtually limitless so you can thoroughly test it. The database I’m developing this on is on a Basic plan (courtesy of the RestDB team).
Setting up RestDB
Unlike with other database services, with RestDB it’s important to consider record number limits. The Basic plan offers 10000 records, which would be quickly exhausted if we saved the follower of each logged in user as a separate entry, or even a list of followers for each user as a separate entry per 15 minute timeframe. That’s why I chose the following plan:
each new user will be a record in the accounts collection.
each new follower list will be a record in the follower-lists collection and will be a child record of accounts.
at a maximum rate of every 15 minutes (or more if user takes longer to come back and log into the app), a new list will be generated, compared to the last one, and a new list along with a diff towards the last one will be saved.
every user will be able to keep at most 100 histories
That said, let’s create the new follower-lists collection as per the quick-start docs. Once the collection has been created, let’s add some fields:
a required followers text field. The text field supports regular expression validations, and since we’re going to use a comma separated list to store the follower IDs, we can apply a regex like this one to make sure the data is always valid: ^(\d+,\s?)*(\d+)$. This will match only lines with comma separated digits, but without a trailing comma. You can see it in action here.
a diff_new field of text type, which will contain a list of new followers since the last entry. The same regex restriction as for followers will apply, only updated to be optional, becausge sometimes there will be no difference compared to the last entry: (^(\d+,\s?)*(\d+)$)?.
a diff_gone field of text type, which will contain a list of unfollowers since the last entry. The same regex restriction as for diff_new will apply.
Our collection should look like this:
Now let’s create the parent collection: accounts.
Note: you may be wondering why we don’t just use the built-in users collection. This is because that collection is only meant for authenticating Auth0 users. The fields that are in there would be useful for us, but as per the docs, we have no write access to that database, and we need it. So why not just go with Auth0 for logins and RestDB for data? Feel free take that approach – I personally feel like depending on one third party service for a crucial part of my app is enough, two would be too much for me.
The fields we need are:
twitter_id, the Twitter account ID of the user. Required number.
settings, a required JSON field. This will hold all the user’s account-specific settings, like refresh interval, emailing frequency, etc.
After adding these, let’s add a new follower_lists field, and define it as a child relation to our follower-lists collection. Under Properties, we should pick “child of…”. The naming is a little confusing – despite the option saying “child of follower-lists”, it is follower-lists who is the child.
You may have noticed we haven’t used timestamp fields anywhere, like created_at. That’s because RestDB automatically creates them for every collection, along with some other fields. To inspect those System fields, click the “Show System Fields” option in the top right corner of each collection’s Settings table:
Getting these fields in a payload when querying the database requires us to use the ?metafields=true param in the API URLs.
We are now ready to start combining the PHP and RestDB side.
Saving to and Reading from RestDB
To be able to interact with RestDB, we need an API key. We can get it by following instructions here. All options should be left at the default value, with all REST methods enabled. The key should then be saved into .env:
RESTDB_KEY=keykeykey
The idea for accounts is as follows:
when the user first authorizes Twitter, the app will read the accounts collection for the Twitter ID provided, and if it doesn’t exist, it will write a new entry.
the user is then redirected to the welcome screen, which will contain a message confirming account creation if one was created, and offer to redirect to the /dashboard.
Let’s first make a RestDB service for talking to the database.
<?php // Services/Followers/RestDB.php namespace App\Services\Followers; use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use Psr\Http\Message\ResponseInterface; class RestDB { /** @var ClientInterface */ protected $client; /** * Sets the Guzzle client to be used * * @param ClientInterface $client * @return $this */ public function setClient(ClientInterface $client) { $this->client = $client; return $this; } /** * @return ClientInterface */ public function getClient() { return $this->client; } /** * Configures a default Guzzle client so it doesn't need to be injected * @return $this */ public function setDefaultClient() { $client = new Client([ 'base_uri' => 'http://j.mp/2sErz4H', 'headers' => [ 'x-apikey' => getenv('RESTDB_KEY'), 'content-type' => 'application/json' ] ]); $this->client = $client; return $this; } /** * Returns user's account entry if it exists. Caches result for 5 minutes * unless told to be `$fresh`. * * @param int $twitter_id * @param bool $fresh * @return bool|\stdClass */ public function userAccount(int $twitter_id, bool $fresh = false) { /** @var ResponseInterface $request */ $response = $this->client->get( 'accounts', [ 'body' => '{"twitter_id": ' . $twitter_id . ', "max": 1}', 'query' => ['metafields' => true], 'headers' => ['cache-control' => $fresh ? 'no-cache' : 'max-age:300'], ] ); $bodyString = json_decode($response->getBody()->getContents()); if (empty($bodyString)) { return false; } return $bodyString[0]; } /** * Creates a new account in RestDB. * * @param array $user * @return bool */ public function createUserAccount(array $user) { /** @var ResponseInterface $request */ $response = $this->client->post('accounts', [ 'body' => json_encode([ 'twitter_id' => $user['id'], 'settings' => array_except($user, 'id') ]), 'headers' => ['cache-control' => 'no-cache'] ]); return $response->getStatusCode() === 201; } }
In this service, we define ways to set the Guzzle client to be used, along with a shortcut method to define a default one. This default one also includes the default authorization header, and sets content type as JSON which is what we’re communicating with. We also demonstrate basic reading and writing from and to RestDB.
The userAccount method directly searches for a Twitter ID in the accountsrecords, and returns a record if found, or false if not. Note the use of the metafields query param – this lets us fetch the _created and other system fields. Notice also that we cache the result for 5 minutes unless the $fresh param is passed in, because the user info will rarely change and we might need it multiple times during a session. The createUserAccount method takes an array of user data (the most important of which is the id key) and creates the account. Note that we’re looking for status 201 which means CREATED.
Let’s also make a ServiceProvider and register the service as a singleton.
artisan make:provider RestdbServiceProvider
<?php namespace App\Providers; use App\Services\Followers\RestDB; use Illuminate\Support\ServiceProvider; class RestdbServiceProvider extends ServiceProvider { /** * Register the application services. * * @return void */ public function register() { $this->app->singleton( 'restdb', function ($app) { $r = new RestDB(); $r->setDefaultClient(); return $r; } ); } }
Finally, let’s update our LoginController.
// ... $request->session()->put('twitter_id', $user->id); $rest = resolve('restdb'); if (!$rest->userAccount($user->id)) { if ($rest->createUserAccount( [ 'token' => $user->token, 'secret' => $user->tokenSecret, 'nickname' => $user->nickname, 'id' => $user->id, ] )) { $request->session()->flash( 'info', 'Your account has been created! Welcome!' ); } else { $request->session()->flash( 'error', 'Failed to create your account :(' ); } } // ... return redirect('/');
In the LoginController‘s handleProviderCallback method, we first grab (resolve) the service, use it to check if the user has an account, create it if not, and flash the message to session if either successful or not.
Let’s put these flash messages into the view:
@isset($info) <p></p> @endisset @isset($error) <p></p> @endisset ...
If we test this out, sure enough, our new record is created:
Now let’s offer a /dashboard. The idea is:
when a user logs in, they’ll be presented with a “Dashboard” link.
clicking this link will, in order:
grab their latest follower-lists entry from RestDB
if more than 15 minutes have elapsed since the last entry was created, or the user doesn’t have an entry at all, a new list of followers will be fetched. The new list will be saved. If it wasn’t the first entry, a diff is generated for new followers and unfollowers.
if the user has refreshed in the last 15 minutes, they will simply be redirected to the dashboard
when the user accesses this dashboard, all their follower-lists RestDB entries are fetched
the applications goes through all the diff entries in the records, and generates reports for unfollowers, displaying information on how long they had been following the user before leaving.
once these IDs for the report have been fetched, their information is fetched via the /users/lookup endpoint to grab their avatars and Twitter handles.
if an account had been following for a day or less, it is flagged with a red color, meaning a high certainty of follower farming. 1 – 5 days is orange, 5 – 10 days is yellow, and others are neutral.
Let’s update the index view first, and add a new route.
// routes/web.php Route::get('/dashboard', 'HomeController@dashboard');
... <p>I bet you'd like to see your follower stats, wouldn't you?</p> Go to <a href="/dashboard">dashboard</a>.
We need a way to fetch the last follower_lists entry of a user. Thus, in the RestDB service, we can add the following method:
/** * Get the last follower_lists entry of the user in question, or false if * none exists. * * @param int $twitter_id * @return bool|\stdClass */ public function getUsersLastEntry(int $twitter_id) { $id = $this->userAccount($twitter_id)->_id; /** @var ResponseInterface $request */ $response = $this->client->get( 'accounts/' . $id . '/follower_lists', [ 'query' => [ 'metafields' => true, 'sort' => '_id', 'dir' => -1, 'max' => 1, ], 'headers' => ['cache-control' => 'no-cache'], ] ); $bodyString = json_decode($response->getBody()->getContents()); return !empty($bodyString) ? $bodyString[0] : false; }
We either return false, or the last entry. Notice that we’re sorting by the _id metafield, from newest to oldest (dir=-1), and fetching a maximum of 1 entry. These params are all explained here.
Now let’s turn our attention to the dashboardmethod in HomeController:
public function dashboard(Request $request) { $twitter_id = $request->session()->get('twitter_id', 0); if (!$twitter_id) { return redirect('/'); } /** @var RestDB $rest */ $rest = resolve('restdb'); $lastEntry = $rest->getUsersLastEntry($twitter_id); if ($lastEntry) { $created = Carbon::createFromTimestamp( strtotime($lastEntry->_created) ); $diff = $created->diffInMinutes(Carbon::now()); } if ((isset($diff) && $diff > 14) || !$lastEntry) { $followerIds = resolve(Followers::class)->getFollowerIds(); $rest->addFollowerList($followerIds, $lastEntry, $twitter_id); } dd("Let's show all previous lists"); }
Ok, so what’s going on here? First, we do a primitive check if the user is still logged in – the twitter_id has to be in the session. If not, we redirect to homepage. Then, we fetch the Rest service, get the account’s last follower-lists entry (which is either an object or false) and then if it exists, we calculate how old it is. If it’s more than 14 minutes, or if the entry doesn’t exist at all (meaning it’s the very first one for that account), we fetch a new list of followers and save it. How do we save it? By adding a new addFollowerList method to the Rest service.
/** * Adds a new follower_lists entry to an account entry * * @param array $followerIds * @param \stdClass|bool $lastEntry * @param int $twitter_id * @return bool * @internal param array $newEntry */ public function addFollowerList( array $followerIds, $lastEntry, int $twitter_id ) { $account = $this->userAccount($twitter_id); $newEntry = ['followers' => implode(', ', $followerIds)]; if ($lastEntry !== false) { $lastFollowers = array_map( function ($el) { return (int)trim($el); }, explode(',', $lastEntry->followers) ); sort($lastFollowers); sort($followerIds); $newEntry['diff_gone'] = implode( ', ', array_diff($lastFollowers, $followerIds) ); $newEntry['diff_new'] = implode( ', ', array_diff($followerIds, $lastFollowers) ); } try { /** @var ResponseInterface $request */ $response = $this->client->post( 'accounts/' . $account->_id . '/follower_lists', [ 'body' => json_encode($newEntry), 'headers' => ['cache-control' => 'no-cache'], ] ); } catch (ClientException $e) { // Log the exception message or something } return $response->getStatusCode() === 201; }
This one first grabs the user account to find the ID of the account record in RestDB. Then, it initiates the $newEntry variable with a properly formatted (imploded) string of current follower IDs. Next, if there was a last entry, we:
get those IDs into a proper array by exploding the string and cleaning whitespace.
sort both current and past follower arrays for more effective diffing.
get the differences and add them to $newEntry.
We then save the entry, by targeting the specific account entry with the previously fetched ID, and continuing on into the sub-collection of follower_lists.
To test this, we can fake some data. Let’s alter the $followerIds part of HomeController::dashboard to this:
$count = rand(50, 75); $followerIds = []; while ($count--) { $flw = rand(1, 100); if (in_array($flw, $followerIds)) $count++; else $followerIds[] = $flw; }
This will generate 50-75 random numbers ranging from 1 to 100. Good enough for us to get some diffs. If we hit the url /dashboard while logged in now, we should get our initial entry.
If we remove the 15 minute limit from the if block and refresh two more times, we’ve generated 3 entries total, with good looking diffs:
It’s time for the final feature. Let’s analyze the entries, and identify some follower farmers.
Final Stretch
Because it contextually makes sense, we’ll put this logic into the Followers service. Let’s create an analyzeUnfollowers method. It will accept an arbitrary number of entries, and do its logic in a loop on all of them. Then, if we later want to provide a quicker way of just checking the last bit of information since the last login session, we can simply pass the two last entries instead of all of them, and the logic remains the same.
public function analyzeUnfollowers(array $entries) { ... }
To identify unfollowers, we look at the most recent diff_gone, for all who are gone since the last time we checked our follower list, and then find them in the diff_new arrays of previous entries. This then lets us find out how long they had been following us before leaving. While using the entries, we also need to turn the diff_gone and diff_new entries into arrays, for easy seeking.
/** * Accepts an array of entries (stdObjects) ordered from newest to oldest. * The objects must have the properties: diff_gone, diff_new, and followers, * all of which are comma delimited strings of integers, or arrays of integers. * The property `_created` is also essential. * * @param array $entries * @return array */ public function analyzeUnfollowers(array $entries) { $periods = []; $entries = array_map( function ($entry) { if (is_string($entry->diff_gone)) { $entry->diff_gone = $this->intArray($entry->diff_gone); } if (is_string($entry->diff_new)) { $entry->diff_new = $this->intArray($entry->diff_new); } return $entry; }, $entries ); $latest = array_shift($entries); for ($i = 0; $i < count($entries); $i++) { $cur = $entries[$i]; $curlast = array_last($entries) === $cur; if ($curlast) { $matches = $latest->diff_gone; } else { $matches = array_intersect( $cur->diff_new, $latest->diff_gone ); } if ($matches) { $periods[] = [ 'matches' => array_values($matches), 'from' => (!$curlast) ? Carbon::createFromTimestamp(strtotime($cur->_created)) : 'forever', 'to' => Carbon::createFromTimestamp(strtotime($latest->_created)) ]; } } return $periods; } /** * Turns a string of comma separated values, spaces or no, into an array of integers * * @param string $string * @return array */ protected function intArray(string $string): array { return array_map( function ($el) { return (int)trim($el); }, explode(',', $string) ); }
Of course, we need a way to fetch all the follower list entries. We put the getUserEntries method into the Rest service:
/** * Gets a twitter ID's full list of follower list entries * * @param int $twitter_id * @return array */ public function getUserEntries(int $twitter_id): array { $id = $this->userAccount($twitter_id)->_id; /** @var ResponseInterface $request */ $response = $this->client->get( 'accounts/' . $id . '/follower_lists', [ 'query' => [ 'metafields' => true, 'sort' => '_id', 'dir' => -1, 'max' => 100, ], 'headers' => ['cache-control' => 'no-cache'], ] ); $bodyString = json_decode($response->getBody()->getContents()); return !empty($bodyString) ? $bodyString : []; }
It’s possible that the number of followers on some accounts will create big downloads, thus slowing the app down. Since we only really need the diff fields, we can target only those with the h param, as described at the bottom of this page.
Then, if we, for debugging purposes, modify the dashboard method…
$entries = $rest->getUserEntries($twitter_id); dd($followers->analyzeUnfollowers($entries));
The output looks something like this. It’s obvious that 5 of our fake followers have only been following us for 5 seconds, while the rest of them have been following us since before we signed up for this service (i.e. forever).
Finally, we can analyze the periods we got back – it’s easy to identify short ones, and color-code them as described at the beginning of this post. As this is already a post of considerable length, I’ll leave that part, and the part about using Twitter’s Users Lookup API to turn the IDs into user handles as homework. Protip: if you run out of query calls for that part, you can crawl their mini profile with the user_id param!
Conclusion
We went through the process of building a simple application for tracking the amount of time a given person has followed you, and flagging them down as a follower farmer, all without using a local database – RestDB provided us with extreme performance, scalability, and independence from local services.
There are many upgrades we could apply to this app:
a cronjob to auto-refresh the follower lists behind the scenes
heavy caching to conserve API calls and increase speed
a premium account subscription which would let users keep more entries
a dashboard matching tweets with unfollows, showing you what may have prompted someone to leave your twittersphere
multi-account support
Instagram support
What other upgrades to this system can you think of? Feel free to contribute to the app on Github!
http://j.mp/2rGjrn0 via SitePoint URL : http://j.mp/2c7PqoM
0 notes
t-baba · 8 years ago
Photo
Tumblr media
How to Build a Twitter Follower-Farmer Detection App with RestDB
This article was sponsored by RestDB. Thank you for supporting the partners who make SitePoint possible.
Are you active on Twitter? If so, do you often wonder why some accounts seem to follow you only to unfollow you moments (or days) later? It's probably not something you said - they're just follower farming.
Follower farming is a known social media hack taking advantage of people who "#followback" as soon as someone follows them. The big brands, celebs, and wannabe celebs take advantage of this, as it keeps their followers count high but following count low, in turn making them look popular.
In this post, we'll build an app which lets you log in via Twitter, grabs your followers, and compares the last fetched follower list with a refreshed list in order to identify the new unfollowers and calculate the duration of their follow, potentially auto-identifying the farmers.
Bootstrapping
As usual, we'll be using Homestead Improved for a high quality local environment setup. Feel free to use your own setup instead if you've got one you feel comfortable in.
git clone http://ift.tt/1Lhem4x hi_followfarmers cd hi_followfarmers bin/folderfix.sh vagrant up; vagrant ssh
Once the VM has been provisioned and we find ourselves inside it, let's bootstrap a Laravel app.
composer create-project --prefer-dist laravel/laravel Code/Project cd Code/Project
Logging in with Twitter
To make logging in with Twitter possible, we'll use the Socialite package.
composer require laravel/socialite
As per instructions, we should also register it in config/app.php:
'providers' => [ // Other service providers... Laravel\Socialite\SocialiteServiceProvider::class, ],
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
Finally, we need to register a new Twitter app at http://apps.twitter.com/app/new...
... and add the secret credentials into config/services.php:
'twitter' => [ 'client_id' => env('TWITTER_CLIENT_ID'), 'client_secret' => env('TWITTER_CLIENT_SECRET'), 'redirect' => env('TWITTER_CALLBACK_URL'), ],
Naturally, we need to add these environment variables into the .env file in the root of the project:
TWITTER_CLIENT_ID=keykeykeykeykeykeykeykeykey TWITTER_CLIENT_SECRET=secretsecretsecret TWITTER_CALLBACK_URL=http://ift.tt/2s50Eyx
We need to add some Login routes into routes/web.php next:
Route::get('auth/twitter', 'Auth\LoginController@redirectToProvider'); Route::get('auth/twitter/callback', 'Auth\LoginController@handleProviderCallback');
Finally, let's add the methods these routes refer to into the LoginController class inside app/Http/Controllers/Auth:
/** * Redirect the user to the GitHub authentication page. * * @return Response */ public function redirectToProvider() { return Socialite::driver('twitter')->redirect(); } /** * Obtain the user information from GitHub. * * @return Response */ public function handleProviderCallback() { $user = Socialite::driver('twitter')->user(); dd($user); }
The dd($user); is there to easily test if the authentication went well, and sure enough, if you visit /auth/twitter, you should be able to authorize the app and see the basic information about your account on screen:
Follower Lists
There are many ways of getting an account's follower list, and none of them pleasant.
Twitter Still Hates Developers
Ever since Twitter's Great War on Developers (spoiler: very little has changed since that article came out), it's been an outright nightmare to fetch full lists of people's followers. In fact, the API rate limits are so low that people have resorted to third party data aggregators for actually buying that data, or even scraping the followers page. We'll go the "white hat" route and suffer through their API, but if you have other means of getting followers, feel free to use that instead of the method outlined below.
The Twitter API offers the /followers/list endpoint, but as that one only returns 20 followers per call at most, and only allows 15 requests per 15 minutes, we would be able to, at most, extract 1200 followers per hour - unacceptable. Instead, we'll use the followers/ids endpoint to fetch 5000 IDs at a time. This is subject to the same limit of 15 calls per 15 minutes, but gives us much more breathing room.
It's important to keep in mind that ID != Twitter handle. IDs are numeric values representing a unique account across time, even across different handles. So for each unfollower's ID, we'll have to make an additional API call to find out who they were (the Users Lookup Bulk API will come in handy).
Basic API Communication
Socialite is only useful for logging in. Actually communicating with the API is less straightforward. Given that Laravel comes with Guzzle pre-installed, installing Guzzle's Oauth Subscriber (which lets us use Guzzle with the Oauth1 protocol) is the simplest solution:
composer require guzzlehttp/oauth-subscriber
Once that's in there, we can update our LoginController::handleProviderCallback method to test things out:
public function handleProviderCallback() { $user = Socialite::driver('twitter')->user(); $stack = HandlerStack::create(); $middleware = new Oauth1([ 'consumer_key' => getenv('TWITTER_CLIENT_ID'), 'consumer_secret' => getenv('TWITTER_CLIENT_SECRET'), 'token' => $user->token, 'token_secret' => $user->tokenSecret ]); $stack->push($middleware); $client = new Client([ 'base_uri' => 'https://api.twitter.com/1.1/', 'handler' => $stack, 'auth' => 'oauth' ]); $response = $client->get('followers/ids.json', [ 'query' => [ 'cursor' => '-1', 'screen_name' => $user->nickname, 'count' => 5000 ] ]); dd($response->getBody()->getContents()); }
In the above code, we first create a middleware stack which will chew through the request, pull it through all the middlewares, and output the final version. We can push other middlewares into this stack, but for now, we only need the Oauth1 one.
Next, we create the Oauth1 middleware and pass in the required parameters. The first two we've already got - they're the keys we defined in .env previously. The last two we got from the authenticated Twitter user instance.
We then push the middleware into the stack, and attach the stack onto the Guzzle client. In layman's terms, this means "when this client does requests, pull the requests through all the middlewares in the stack before sending them to their final destination". We also tell the client to always authenticate with oauth.
Finally, we make the GET call to the API endpoint with the required query params: the page to start on (-1 is the first page), the user for whom to pull followers, and how many followers to pull. In the end, we die this output onto the screen to see if we're getting what we need. Sure enough, here's 5000 of the most recent followers for my account:
Now that we know our API calls are passing and we can talk to Twitter, it's time for some loops to get the full list for the current user.
The PHP side - Getting all Followers
Since there are 15 calls per 15 minutes allowed via the API, let's limit the account size to 70k followers for now for simplicity.
$user = Socialite::driver('twitter')->user(); if ($user->user['followers_count'] > 70000) { return view( 'home.index', ['message' => 'Sorry, we currently only support accounts with up to 70k followers'] ); }
Note: home.index is an arbitrary view file I made just for this example, containing a single directive: .
Then, let's iterate through the next_cursor_string value returned by the API, and paginate through other IDs.
Much numbers, very follow, wow.
With some luck, this should execute very quickly - depending on Twitter's API responsiveness.
Everyone with up to 70k followers can now get a full list of followers generated upon authorization.
If we needed to support bigger accounts, it would be relatively simple to make it repeat the process every 15 minutes (after the API limit resets) for every 75k followers, and stitch the results together. Of course, someone is almost guaranteed to follow/unfollow in that window given the number of followers, so it would be very hard to stay accurate. In those cases, it's easier to focus on the last 75k followers and only analyze those (the API auto-orders by last-followed), or to find another method of reliably fetching followers, bypassing the API.
Cleaning up
It's a bit awkward to have this logic in the LoginController, so let's move this into a separate service. I created app/Services/Followers/Followers.php for this example, with the following contents:
<?php namespace App\Services\Followers; use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; use GuzzleHttp\Subscriber\Oauth\Oauth1; class Followers { /** @var string */ protected $token; /** @var string */ protected $tokenSecret; /** @var string */ protected $nickname; /** @var Client */ protected $client; public function __construct(string $token, string $tokenSecret, string $nickname) { $this->token = $token; $this->tokenSecret = $tokenSecret; $this->nickname = $nickname; $stack = HandlerStack::create(); $middleware = new Oauth1( [ 'consumer_key' => getenv('TWITTER_CLIENT_ID'), 'consumer_secret' => getenv('TWITTER_CLIENT_SECRET'), 'token' => $this->token, 'token_secret' => $this->tokenSecret, ] ); $stack->push($middleware); $this->client = new Client( [ 'base_uri' => 'https://api.twitter.com/1.1/', 'handler' => $stack, 'auth' => 'oauth', ] ); } public function getClient() { return $this->client; } /** * Returns an array of follower IDs for a given optional nickname. * * If no custom nickname is provided, the one used during the construction * of this service is used, usually defaulting to the same user authing * the application. * * @param string|null $nickname * @return array */ public function getFollowerIds(string $nickname = null) { $nickname = $nickname ?? $this->nickname; $response = $this->client->get( 'followers/ids.json', [ 'query' => [ 'cursor' => '-1', 'screen_name' => $nickname, 'count' => 5000, ], ] ); $data = json_decode($response->getBody()->getContents()); $ids = $data->ids; while ($data->next_cursor_str !== "0") { $response = $this->client->get( 'followers/ids.json', [ 'query' => [ 'cursor' => $data->next_cursor_str, 'screen_name' => $nickname, 'count' => 5000, ], ] ); $data = json_decode($response->getBody()->getContents()); $ids = array_merge($ids, $data->ids); } return $ids; } }
We can then clean up the LoginController's handleProviderCallback method:
public function handleProviderCallback() { $user = Socialite::driver('twitter')->user(); if ($user->user['followers_count'] > 70000) { return view( 'home.index', ['message' => 'Sorry, we currently only support accounts with up to 70k followers'] ); } $flwrs = new Followers( $user->token, $user->tokenSecret, $user->nickname ); dd($flwrs->getFollowerIds()); }
It's still the wrong method to be doing this, so let's further improve things. To keep a user logged in, let's save the token, secret, and nickname into the session.
/** * Get and store token data for authorized user. * * @param Request $request * @return Response */ public function handleProviderCallback(Request $request) { $user = Socialite::driver('twitter')->user(); if ($user->user['followers_count'] > 70000) { return view( 'home.index', ['message' => 'Sorry, we currently only support accounts with up to 70k followers'] ); } $request->session()->put('twitter_token', $user->token); $request->session()->put('twitter_secret', $user->tokenSecret); $request->session()->put('twitter_nickname', $user->nickname); $request->session()->put('twitter_id', $user->id); return redirect('/'); }
We save all the information into the session, making the user effectively logged in to our application, and then we redirect to the home page.
Let's create a new controller now, and give it a simple method to use:
artisan make:controller HomeController
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; class HomeController extends Controller { public function index(Request $request) { $nick = $request->session()->get('twitter_nickname'); if (!$nick) { return view('home.loggedout'); } return view('home.index', $request->session()->all()); } }
Simple, right? The views are simple, too:
<h1>FollowerFarmers</h1> <h2>Hello, ! Not you? <a href="/logout">Log out!</a></h2> <p>I bet you'd like to see your follower stats, wouldn't you?</p>
<h1>FollowerFarmers</h1> <h2>Hello, stranger!</h2> <p>You're currently logged out. How about you <a href="/auth/twitter">log in with Twitter </a> to get started?</p>
We'll need to add some routes to routes/web.php, too:
Route::get('/', 'HomeController@index'); Route::get('/logout', 'Auth\LoginController@logout');
With this, we can check if we're logged in, and we can easily log out.
Note that for security, the logout route should only accept POST requests with CSRF tokens - for simplicity during development, we're taking the GET approach and revamping it later.
Admittedly, it's not the prettiest thing to look at, but we're building a demo here - the real thing can get visually polished once the logic is done.
Registering a Service Provider
It's common practice to register a service provider for easier access later on, so let's do that. Our service can't be instantiated without the token and secret (i.e. before the user logs in with Twitter) so we'll need to make it deferred - in other words, it'll only get created when needed, and we'll make sure we don't need it until we have those values.
artisan make:provider FollowerServiceProvider
<?php namespace App\Providers; use App\Services\Followers\Followers; use Illuminate\Support\ServiceProvider; class FollowerServiceProvider extends ServiceProvider { protected $defer = true; public function register() { $this->app->singleton( Followers::class, function ($app) { return new Followers( session('twitter_token'), session('twitter_secret'), session('twitter_nickname') ); } ); } public function provides() { return [Followers::class]; } }
If we put a simple count echo into our logged in view:
... and modify the HomeController to now use this ServiceProvider:
... return view( 'home.index', array_merge( $request->session()->all(), ['ids'=> resolve(Followers::class)->getFollowerIds()] ) );
... and then we test, sure enough, it works.
Database
Now that we have a neat service to extract follower lists with, we should probably save them somewhere. We could save this into a local MySQL database, or even a flat file, but for performance and portability, I went with something different this time: RestDB.
RestDB is a plug-and-play hosted database service that's easy to configure and use, freeing up your choices of hosting platform. By not needing a database that writes to a local filesystem, you can easily push an app like the one we're building to Google Cloud Engine or Heroku. With the help of its templates, you can instantly set up a blog, a landing page, a web form, a log analyzer, even a mailing system - heck, the service even supports MarkDown for inline field editing, letting you practically have a MarkDown-based blog right there on their service.
RestDB has a free tier, and the first month is virtually limitless so you can thoroughly test it. The database I'm developing this on is on a Basic plan (courtesy of the RestDB team).
Setting up RestDB
Unlike with other database services, with RestDB it's important to consider record number limits. The Basic plan offers 10000 records, which would be quickly exhausted if we saved the follower of each logged in user as a separate entry, or even a list of followers for each user as a separate entry per 15 minute timeframe. That's why I chose the following plan:
Continue reading %How to Build a Twitter Follower-Farmer Detection App with RestDB%
by Bruno Skvorc via SitePoint http://ift.tt/2rqjy6N
0 notes