Web Scraping with Google Sheets
0 Comments Published by boris April 20th, 2015 in technology, web developmentOne of my favorite features of Google Sheets is the ability to use the IMPORTXML function to pull in content from anywhere on the web. It’s like having your own little Google-backed scraper to conveniently pull data into your spreadsheet.
For example, if I wanted to get some data about my Google Play apps, I could use the formulas below. These are not strictly optimized – but they do show a variety of xpath queries. (note: before scraping content, its best to make sure you are not violating the T&Cs of the content owner)
Publisher Name =IMPORTXML(URL, "//div[@itemprop='author']//span") |
App Category =MID(IMPORTXML(URL, "//a[@class='document-subtitle category']/@href"),22,999) |
App Rating =IMPORTXML(URL, "//meta[@itemprop='ratingValue']/@content") |
# Downloads - lower value =value(LEFT(IMPORTXML(URL, "//div[@itemprop='numDownloads']"),FIND(" ",IMPORTXML(URL, "//div[@itemprop='numDownloads']"))-1)) |
APK Size =left(IMPORTXML(URL, "//div[@itemprop='fileSize']"),len(IMPORTXML(URL, "//div[@itemprop='fileSize']"))-1) |
Last Updated Date =mid(IMPORTXML(URL, "//div[@itemprop='author']//div"),3,99) |
Has a Video on the Listing Page? =if(iserror(IMPORTXML(URL, "//span[@class='video-image-wrapper']")),"","Y") |
Developer Email Address =MID(IMPORTXML(URL, "//div[@class='details-section-contents']//a[contains(@href, 'mailto')]"),7,999) |
I recently had a couple opportunities to present the digital art from our Masterpieces project in several physical exhibitions, first at the National Museum of Singapore, and then the Ayala Museum in Manila. For a sense of scale, the Singapore exhibit featured 50 tablet displays and 20 TVs. The tablets were interactive, allowing users to swipe though and view details of artwork, scrub through videos, and create digital art themselves. 10,000 visitors went through the collection and making sure that the devices stayed operational and did just what we wanted them to do and no more was a fun challenge.
TVs are pretty straight forward to configure, just USB-thumb drives and content on loop. Tablets present a considerably more interesting challenge. We didn’t want users to quit our apps and check their facebook, or for the tablets screens to turn off, etc. Here’s the final configuration:
- The cornerstone was SureLock, its not the sleekest app out there, but it does its job web: keeping just the apps you want running, preventing tablets from sleeping, and locking out the unessential features. You can also save your configuration in a file which is imported automatically, very helpful for mass configuration of many tablets.
- The most common type of tablet allowed users to page through images and zoom in on details – a companion to the TV experience. The functionality needed to be similar to a standard gallery app, but without any other features, particularly no ability to delete the images being viewed. For this I created my own little app based on ImageViewZoom. My version resets the zoom when you navigate away from an image, automatically loads images from the “Masterpieces” folder in the root and sorts the images by file-name. Grab the APK and the project.
- For letting users scrub through video art (and again not have the option to delete content) I used the free and nifty PocketLooper app. Note that there is a bug in version 1.04 which requires you to hit “Save” in the playlist before it will play your video from the default folder.
- We also had an area where users could try creating their own digital art and then email it to themselves. For this I used a combination of the ArtFlow and Email Me app which allowed us to pre-configure the email message.
A couple photos from the gallery:
Caching and Offline Android Maps
0 Comments Published by boris March 4th, 2014 in android, technologyFollowing up on how to use custom Stamen Maps on Android – here’s a bit about how to package these map tiles with an APK so that users see a map immediately upon opening your android app. I also include caching of the map so that any new tiles downloaded are cached for future re-use.
- The first step is grabbing the map tiles you’d like. This is a good post on how to do just that with Mobile Atlas Creator. I’m attaching the .xml configuration file I used for my custom map source (Stamen Watercolor Maps)
- In Mobile Atlas Creator select the coordinates for the area in question and download the tiles. A typical city-sized map with zoom levels 0-13 might end up being around 2 MB. These are the tiles you’ll package along with your APK so you want to be conscious of file size.
- Now that you have the tiles locally, you can integrate a custom tile provider into your code. As a full example, I’m including the full demo project as a zip. (built on top of hello map)
- As a bonus, add map tile caching to your app so that any new tiles downloaded are also cached for re-use.
Stamen makes a beautiful set of google map layers. I love the watercolor look, and using it on Android with native the native google maps engine is fairly straightforward. Grab the .zip file of my eclipse project to take a look.
I started with the hellomap starter project and this stackoverflow post. Most of the magic is in MainActivity.java:
package com.example.hellomap; import java.net.MalformedURLException; import java.net.URL; import android.os.Bundle; import android.support.v4.app.FragmentActivity; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.SupportMapFragment; import com.google.android.gms.maps.model.CameraPosition; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.TileOverlayOptions; import com.google.android.gms.maps.model.UrlTileProvider; public class MainActivity extends FragmentActivity { private GoogleMap mMap; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); setUpMapIfNeeded(); } @Override protected void onResume() { super.onResume(); setUpMapIfNeeded(); } private void setUpMapIfNeeded() { if (mMap == null) { mMap = ((SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map)).getMap(); } if (mMap == null) { return; } mMap.setMapType(GoogleMap.MAP_TYPE_NONE); TileOverlayOptions options = new TileOverlayOptions(); options.tileProvider(new UrlTileProvider(256, 256) { @Override public URL getTileUrl(int x, int y, int z) { try { String f = "http://tile.stamen.com/watercolor/%d/%d/%d.jpg"; return new URL(String.format(f, z, x, y)); } catch (MalformedURLException e) { return null; } } }); mMap.addTileOverlay(options); final LatLng TAIPEI = new LatLng(25.091075,121.559835); CameraPosition cameraPosition = new CameraPosition.Builder() .target(TAIPEI) .zoom(12) .tilt(45) .build(); mMap.animateCamera(CameraUpdateFactory.newCameraPosition(cameraPosition)); } } |
Limiting Google Play Apps by Manufacturer
0 Comments Published by boris October 29th, 2013 in android, mobile, technologySometimes you might come upon a scenario where you want to limit the Google Play Apps you develop to a particular device manufacturer (*cough cough*). Play store doesn’t support this use case, but here’s a quick way to do it. Load your list of supported devices, then open your developer console (tested in chrome) and include jquery with the following code:
var jq = document.createElement('script'); jq.src = "//ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"; document.getElementsByTagName('head')[0].appendChild(jq); |
Hit enter and then run this line to disable all devices:
$('span[aria-checked=false]').click(); |
Last, enable just the device manufacturer you want with:
$("h3:contains('Samsung')").parent().find("span[aria-checked=true]").click(); |
It’s important to note that Google will automatically enable the app for all new devices that come on the market, so you’ll need to login and run this periodically.
I was thinking recently about the top 3 problems left to solve online – the things I still find myself needing and not having a good solution for.
- I want to spend more time with people offline (and I want an online tool to help me do this). This has been a desire for a long time. Over ten years ago (I believe it was in 2001) I wrote the following on a (nostalgic) about page:
Computers are a weak substitute for human interaction. It is my firm belief that our society is converging towards a fear and avoidance of face to face contact. I am a front runner in this convergence. Being here, realizing the problem, and having the potential to do something is my daily realization, not having done anything yet is my daily disappointment. People will not stop using technology, they will only do it more and more. Online communities must be aimed at promoting real physical interaction, not at replacing it. This is what I should be doing.
That still reads fairly accurately. Facebook is essentially the opposite of what I want. FourSquare, Google Latitude, etc do some interesting things – but there is no killer app in this space. I want to see my friends. People make people happy. I don’t know what form this will ultimately take, but it seems like a huge opportunity.
- What events are going on? Related to problem 1 is the fact that event listings are still so decentralized and hard to maneuver. I like going to interesting things – but often times I miss out because you still have to know the right people to get a facebook invite, or read the right mailing list. This could be vastly improved (and zvents, eventful, etc are interesting, but they aren’t it yet). I worked on a couple projects in this space (ThisBounces and then SirCalendar), but it remains an unsolved problem. See “Start-up idea: Why event search needs to be fixed.” In Boston I read the Phoenix Newspaper (seriously), and subscribed to CheapThrills. New York is somewhat better equipped with TheSkint, LinkedList, a bunch of meetup groups, couchsurfing forums, etc. But the fact remains – I regularly miss awesome things in my own city, and if I arrive to a new place for the weekend, I have no easy way of figuring out what to do.
- What is the best… everything? I live on ratings, love them, trust them. IMDB 7+ for movies, GoodReads 4+ for books, Yelp 4+ for restaurants, Amazon 4+ for products. This is a start – but we all know the pain of comparison shopping on Amazon: This one has 4 stars, but only 5 reviews, and this review reads a bit fishy. And while its getting big, Amazon certainly isn’t a generic rating system. I want to find out the best anything. In any category. With relative certainty. No specialties. Gimme!
Thoughts? Are these really the top 3 problems left to solve online?
Open Sourced: “Send to Calendar” Chrome Extension
0 Comments Published by boris December 26th, 2012 in technology, web developmentI just open sourced my “send to calendar” chrome extension. I got tired of re-typing event info all the time, so the extension allows you to select text on any webpage, and send it to google calendar (with some smart parsing in between). The whole thing is super simple, and pull requests are welcome (particularly for additional parsing, support for international date formats, etc). You can find the whole thing on github, the guts are really just this background.js file:
//create the context menu var cmSendToCalendar = chrome.contextMenus.create({ "title": "Send To Calendar", "contexts": ["all"], "onclick": SendToCalendar }); //do all the things function SendToCalendar(data, tab) { var location = ""; var selection = ""; if (data.selectionText) { //get the selected text and uri encode it selection = data.selectionText; //check if the selected text contains a US formatted address var address = data.selectionText.match(/(\d+\s+[':.,\s\w]*,\s*[A-Za-z]+\s*\d{5}(-\d{4})?)/m); if (address) location = "&location=" + address[0]; } //build the url: selection goes to ctext (google calendar quick add), page title to event title, and include url in description var url = "http://www.google.com/calendar/event?action=TEMPLATE&text=" + tab.title + location + "&details=" + tab.url + " " + selection + "&ctext=" + selection; //url encode (with special attention to spaces & paragraph breaks) //and trim at 1,000 chars to account for 2,000 character limit with buffer for google login/redirect urls url = encodeURI(url.replaceAll(" ", "\n\n")).replaceAll("%20", "+").replaceAll("%2B", "+").substring(0,1000); //the substring might cut the url in the middle of a url encoded value, so we need to strip any trailing % or %X chars to avoid an error 400 if (url.substr(url.length-1) === "%") {url = url.substring(0,url.length-1)} else if(url.substr(url.length-2,1) === "%" ) {url = url.substring(0,url.length-2)} //open the created url in a new tab chrome.tabs.create({ "url": url}, function (tab) {}); } //helper replaceAll function String.prototype.replaceAll = function(strTarget, strSubString){ var strText = this; var intIndexOfMatch = strText.indexOf( strTarget ); while (intIndexOfMatch != -1){ strText = strText.replace( strTarget, strSubString ) intIndexOfMatch = strText.indexOf( strTarget ); } return( strText ); } |
Pretty button for your HTML Emails
0 Comments Published by boris December 11th, 2012 in web developmentSometimes you want a nice clickable HTML button in your emails – something that looks great and that your users see even if images are disabled when they view your email. Here’s a bootstrap-inspired button that will render well across email clients:
<a href="" style="text-decoration:none;font-style:normal;font-weight:normal;display:inline-block;padding-top:4px;padding-bottom:4px;padding-right:20px;padding-left:20px;margin-bottom:0;text-align:center;vertical-align:middle;background-color:#4ba1db;*background-color:#4ba1db;background-image:linear-gradient(top, #5CB1E4, #2693D5);background-repeat:repeat-x;border-width:1px;border-style:solid;border-color:#2693D5 #2693D5 hsl(201, 93%, 54.5%);*border:0;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;filter:progid:dximagetransform.microsoft.gradient(enabled=false);*zoom:1;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);text-shadow:#397aac 0 2px 2px;-webkit-font-smoothing:antialiased;color:#FFF;" >I am a button! </a> |
Yes, the code is quite ugly, but it works well – as an extra bonus, if you use campaign monitor to send your emails, here’s it is as a style you can include in your template – then to create a button just take a link and make it bold and italic (a little shortcut for creating a button in the editor)
em strong a, em a strong, strong em a, strong a em, a strong em, a em strong { text-decoration: none; font-style: normal; font-weight: normal; color:#FFFFFF; display: inline-block; padding: 4px 20px; margin-bottom: 0; text-align: center; vertical-align: middle; background-color: #4ba1db; *background-color: #4ba1db; background-image: -ms-linear-gradient(top, #5CB1E4 100%, #2693D5 100%); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#5CB1E4), to(#2693D5)); background-image: -webkit-linear-gradient(top, #5CB1E4, #2693D5); background-image: -o-linear-gradient(top, #5CB1E4, #2693D5); background-image: -moz-linear-gradient(top, #5CB1E4, #2693D5); background-image: linear-gradient(top, #5CB1E4, #2693D5); background-repeat: repeat-x; border: 1px solid #2693D5; *border:0; border-color: #2693D5 #2693D5 hsl(201, 93%, 54.5%); -webkit-border-radius: 4px; -moz-border-radius: 4px; border-radius: 4px; filter: progid:dximagetransform.microsoft.gradient(startColorstr='#5CB1E4', endColorstr='#2693D5', GradientType=0); filter: progid:dximagetransform.microsoft.gradient(enabled=false); *zoom: 1; -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); text-shadow: #397aac 0 2px 2px; -webkit-font-smoothing: antialiased; } |
Note that this button is fully clickable anywhere – you don’t have to click on the text to get it to work. This is easy to mess up, and even LinkedIn does it wrong:
Setting up a stats/metrics display
0 Comments Published by boris December 4th, 2012 in metrics, technologyVisible metrics are the heart of many successful businesses. Setting up a dedicated stats or metrics TV (ours is powered by a mac mini) is a pretty straight forward endeavor – and having everyone rally around the key performance indicators is well worth it. We cycle between optimizely, customer satisfaction (NPS) results, key metrics from RJMetrics, real-time google analytics, live chat metrics, sprint burndown, wufoo form entries, and the occasional cute puppy cam.
Here’s the setup:
- OSX configured to login at boot
- Bluetooth Setup Assistant disabled at startup
- Software updates are disabled
- Screensaver is disabled
- Uncheck everything in System Preferences -> Mission Control
- Disable “Reopen Windows When Logging Back In”
- Disable crash reporter dialog in terminal:
defaults write com.apple.CrashReporter DialogType none - The below apple script is used to open chrome in presentation mode at boot (note the 20 second delay between opening chrome and going into presentation mode to allow chrome to load all the tabs:
tell application "Google Chrome" activate tell application "System Events" do shell script "sleep 20" keystroke tab key down {command} key down {shift} keystroke "f" key up {shift} key up {command} end tell end tell |
- Setup a cron to reboot the mac every morning at 7:55 am and 8 am to clear memory (using cron because system reboot is graceful and may not actually shutdown the computer). Rebooting twice seems to help avoid issues where applications cancel a reboot.
- nano
- 55 7 * * * /sbin/shutdown -r now
0 8 * * * /sbin/shutdown -r now - write out to a file
- copy the file to the root crontab: sudo crontab -u root /path/to/file
- verify that the cron exists: sudo crontab -u root -l
- Chrome is configured to reopen the desired tabs:
- TabCarousel extension configured to “start automatically”
- Stylebot extension used to hide/adjust CSS on pages to better fit full-screen (sync enabled)
- LastPass extension used to remember passwords and auto-login for sites that require it
- Better Popup Blocker extension used to block focus stealing javascript alerts
- Tampermonkey extension used to run a greasemonkey scripts (for example, I wrote one that plays a random inspectlet user recording):
// ==UserScript== // @name Inspectlet Auto Play // @namespace http://www.borism.net // @include http*://www.inspectlet.com/dashboard* // @version 1 // ==/UserScript== (function(){ var script = document.createElement("script"); script.type = "application/javascript"; script.innerHTML = '\ /*check every second */ \ setInterval(function(){ \ /* if we are on the dashboard page, go to the captures page */ \ if (window.location == "https://www.inspectlet.com/dashboard"){ \ window.location = "https://www.inspectlet.com/dashboard/site/YOURIDHERE"; } \ \ /* if we are on the captures page select a new video to watch */ \ else if (window.location == "https://www.inspectlet.com/dashboard/site/YOURIDHERE"){ \ /*make all links open in the same window*/ \ $("a").attr("target","_self"); \ \ /*choose a random capture from the list*/ \ $(".trow a")[Math.floor(Math.random() * $(".trow a").length)].click(); \ } \ /* if we have reached the end of the video, redirect back to video listings */ \ else if ((parseInt($("#pagelist").val()) == $("#pagelist option").length) && $("#stopvideo").hasClass("disabledbutton")){ \ window.location = "https://www.inspectlet.com/dashboard/site/YOURIDHERE" \ } \ \ \ }, 1000); \ ' document.body.appendChild(script); })(); |
If you use Rally, you may be familiar with the joys of trying to make it even slightly useful. Here’s my small contribution: this is a script that formats stories for easy copy/paste into update emails, etc. You can select any iteration to get a nicely formatted list like the one below:
The script uses the Rally API and is based on this and this other code sample. The script makes some decisions that can be fairly easily changed in code if needed:
- includes stories only (no defects)
- skips over stories with no owner (we sometimes include stories for formatting purposes only)
- sorts stories by highest story estimate first (with un-estimated stories at the bottom of the list)
- In Rally go to “Reports -> + New Page”
- Name it anything you like (for example “Email”) and click “Save & Close”
- Click “Start adding apps”
- Choose “Custom HTML” and click “Add This App”
- Title it anything you like (for example “Email”), paste the below code into the HTML section, and click “Save”
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html> <head> <title>User Stories For Sprint Emails</title> <meta name="Name" content="User Stories For Sprint Emails" /> <meta name="Author" content="Boris Masis, Bigstock" /> <script type="text/javascript" src="/apps/1.26/sdk.js"></script> <script type="text/javascript"> var rallyDataSource = null; var iterDropdown = null; function showUserStories(results) { var story = ""; var storyEstimated = ""; var storyNotEstimated = ""; for (var i=0; i < results.stories.length; i++) { story = results.stories[i]; if (!(story.Owner)) continue; //skip over stories with no owner else { story.Owner = story.Owner._refObjectName.split(" ")[0]; //return the first name of the owner only } if (story.PlanEstimate != null) { storyEstimated += story.Name + ' (' + story.Owner + ')' + '<br/>'; } else { // put stories that aren't estimated at the bottom of the list storyNotEstimated += story.Name + ' (' + story.Owner + ')' + '<br/>'; } } var storiesList = document.getElementById("storiesList"); storiesList.innerHTML = storyEstimated + storyNotEstimated; } function onIterationSelected() { storiesList.innerHTML = ""; var queryConfig = { type : 'hierarchicalrequirement', key : 'stories', fetch: 'PlanEstimate,Owner,Name', query: '(Iteration.Name = "' + iterDropdown.getSelectedName() + '")', order: 'PlanEstimate desc' }; rallyDataSource.findAll(queryConfig, showUserStories); } function onLoad() { rallyDataSource = new rally.sdk.data.RallyDataSource('__WORKSPACE_OID__', '__PROJECT_OID__', '__PROJECT_SCOPING_UP__', '__PROJECT_SCOPING_DOWN__'); var iterConfig = {}; iterDropdown = new rally.sdk.ui.IterationDropdown(iterConfig, rallyDataSource); iterDropdown.display(document.getElementById("iterationDiv"), onIterationSelected); } rally.addOnLoad(onLoad); </script> </head> <style> body{ margin-top:25px !important; } #storiesList{ font:13px arial; color:#666; } #storiesList b{ color:#222; } </style> <body> <div id="iterationDiv"></div><br /> <div id="storiesList"></div> </body> </html> |