this repo has no description
at main 60 kB view raw
1#!/usr/bin/env ruby 2# encoding: utf-8 3 4SILENT = ENV['SL_SILENT'] =~ /false/i ? false : true || true 5VERSION = '2.2.8' 6 7# SearchLink by Brett Terpstra 2015 <http://brettterpstra.com/projects/searchlink/> 8# MIT License, please maintain attribution 9require 'net/https' 10require 'uri' 11require 'rexml/document' 12require 'shellwords' 13require 'yaml' 14require 'cgi' 15require 'fileutils' 16require 'zlib' 17require 'time' 18require 'json' 19 20if RUBY_VERSION.to_f > 1.9 21 Encoding.default_external = Encoding::UTF_8 22 Encoding.default_internal = Encoding::UTF_8 23end 24 25PINBOARD_CACHE = File.expand_path("~/.searchlink_cache") 26 27class String 28 def clean 29 gsub(/\n+/,' ').gsub(/"/,"&quot").gsub(/\|/,"-").gsub(/([&\?]utm_[scm].+=[^&\s!,\.\)\]]++?)+(&.*)/, '\2').sub(/\?&/,'').strip 30 end 31 32 def to_am # convert itunes to apple music link 33 input = self.dup 34 input.sub!(/\/itunes\.apple\.com/,'geo.itunes.apple.com') 35 append = input =~ /\?[^\/]+=/ ? '&app=music' : '?app=music' 36 input + append 37 end 38end 39 40class SearchLink 41 attr_reader :originput, :output, :clipboard 42 attr_accessor :cfg 43 # Values found in ~/.searchlink will override defaults in 44 # this script 45 46 def initialize(opt={}) 47 48 @printout = opt[:echo] || false 49 unless File.exists? File.expand_path("~/.searchlink") 50 default_config =<<ENDCONFIG 51# set to true to have an HTML comment included detailing any errors 52debug: true 53# set to true to have an HTML comment included reporting results 54report: true 55 56# use Notification Center to display progress 57notifications: false 58 59# when running on a file, back up original to *.bak 60backup: true 61 62# change this to set a specific country for search (default US) 63country_code: US 64 65# set to true to force inline Markdown links 66inline: false 67 68# set to true to include a random string in reference titles. 69# Avoids conflicts if you're only running on part of a document 70# or using SearchLink multiple times within a document 71prefix_random: true 72 73# set to true to add titles to links based on the page title 74# of the search result 75include_titles: false 76 77# confirm existence (200) of generated links. Can be disabled 78# per search with `--v`, or enabled with `++v`. 79validate_links: false 80 81# append affiliate link info to iTunes urls, empty quotes for none 82# example: 83# itunes_affiliate = "&at=10l4tL&ct=searchlink" 84itunes_affiliate: "&at=10l4tL&ct=searchlink" 85 86# to create Amazon affiliate links, set amazon_partner to your amazon 87# affiliate tag 88# amazon_partner: "bretttercom-20" 89amazon_partner: "bretttercom-20" 90 91# To create custom abbreviations for Google Site Searches, 92# add to (or replace) the hash below. 93# "abbreviation" => "site.url", 94# This allows you, for example to use [search term](!bt) 95# as a shortcut to search brettterpstra.com (using a site-specific 96# Google search). Keys in this list can override existing 97# search trigger abbreviations. 98# 99# If a custom search starts with "http" or "/", it becomes 100# a simple replacement. Any instance of "$term" is replaced 101# with a URL-escaped version of your search terms. 102# Use $term1, $term2, etc. to replace in sequence from 103# multiple search terms. No instances of "$term" functions 104# as a simple shortcut. "$term" followed by a "d" lowercases 105# the replacement. Use "$term1d," "$term2d" to downcase 106# sequential replacements (affected individually). 107# Long flags (e.g. --no-validate_links) can be used after 108# any url in the custom searches. 109custom_site_searches: 110 bt: brettterpstra.com 111 btt: http://brettterpstra.com/$term1d/$term2d 112 bts: /search/$term --no-validate_links 113 md: www.macdrifter.com 114 ms: macstories.net 115 dd: www.leancrew.com 116 spark: macsparky.com 117 man: http://man.cx/$term 118 dev: developer.apple.com 119 nq: http://nerdquery.com/?media_only=0&query=$term&search=1&category=-1&catid=&type=and&results=50&db=0&prefix=0 120 gs: http://scholar.google.com/scholar?btnI&hl=en&q=$term&btnG=&as_sdt=80006 121# Remove or comment (with #) history searches you don't want 122# performed by `!h`. You can force-enable them per search, e.g. 123# `!hsh` (Safari History only), `!hcb` (Chrome Bookmarks only), 124# etc. Multiple types can be strung together: !hshcb (Safari 125# History and Chrome bookmarks). 126history_types: 127- chrome_history 128- chrome_bookmarks 129- safari_bookmarks 130- safari_history 131# Pinboard search 132# You can find your api key here: https://pinboard.in/settings/password 133pinboard_api_key: '' 134 135ENDCONFIG 136 File.open(File.expand_path("~/.searchlink"), 'w') do |f| 137 f.puts default_config 138 end 139 end 140 141 @cfg = YAML.load_file(File.expand_path("~/.searchlink")) 142 143 # set to true to have an HTML comment inserted showing any errors 144 @cfg['debug'] ||= false 145 146 # set to true to get a verbose report at the end of multi-line processing 147 @cfg['report'] ||= false 148 149 @cfg['backup'] = true unless @cfg.has_key? 'backup' 150 151 # set to true to force inline links 152 @cfg['inline'] ||= false 153 154 # set to true to add titles to links based on site title 155 @cfg['include_titles'] ||= false 156 157 # change this to set a specific country for search (default US) 158 @cfg['country_code'] ||= "US" 159 160 # set to true to include a random string in ref titles 161 # allows running SearchLink multiple times w/out conflicts 162 @cfg['prefix_random'] = false unless @cfg['prefix_random'] 163 164 # append affiliate link info to iTunes urls, empty quotes for none 165 # example: 166 # $itunes_affiliate = "&at=10l4tL&ct=searchlink" 167 @cfg['itunes_affiliate'] ||= "&at=10l4tL&ct=searchlink" 168 169 # to create Amazon affiliate links, set amazon_partner to your amazon 170 # affiliate tag 171 # amazon_partner: "bretttercom-20" 172 @cfg['amazon_partner'] ||= '' 173 174 # To create custom abbreviations for Google Site Searches, 175 # add to (or replace) the hash below. 176 # "abbreviation" => "site.url", 177 # This allows you, for example to use [search term](!bt) 178 # as a shortcut to search brettterpstra.com. Keys in this 179 # hash can override existing search triggers. 180 @cfg['custom_site_searches'] ||= { 181 "bt" => "brettterpstra.com", 182 "md" => "www.macdrifter.com" 183 } 184 185 # confirm existence of links generated from custom search replacements 186 @cfg['validate_links'] ||= false 187 188 # use notification center to show progress 189 @cfg['notifications'] ||= false 190 @cfg['pinboard_api_key'] ||= false 191 192 @line_num = nil; 193 @match_column = nil; 194 @match_length = nil; 195 end 196 197 def available_searches 198 searches = [ 199 ["a", "Amazon"], 200 ["g", "Google"], 201 ["b", "Bing"], 202 ["wiki", "Wikipedia"], 203 ["s", "Software search (Google)"], 204 ["@t", "Twitter user link"], 205 ["@fb", "Facebook user link"], 206 ["am", "Apple Music"], 207 ["amart", "Apple Music Artist"], 208 ["amalb", "Apple Music Album"], 209 ["amsong", "Apple Music Song"], 210 ["ampod", "Apple Music Podcast"], 211 ["ipod", "iTunes podcast"], 212 ["isong", "iTunes song"], 213 ["iart", "iTunes artist"], 214 ["ialb", "iTunes album"], 215 ["lsong", "Last.fm song"], 216 ["lart", "Last.fm artist"], 217 ["mas", "Mac App Store"], 218 ["masd", "Mac App Store developer link"], 219 ["itu", "iTunes App Store"], 220 ["itud", "iTunes App Store developer link"], 221 ["imov","iTunes Movies"], 222 ["def", "Dictionary definition"], 223 ["sp", "Spelling"], 224 ["pb", "Pinboard"] 225 # ["h", "Web history"], 226 # ["hs[hb]", "Safari [history, bookmarks]"], 227 # ["hc[hb]", "Chrome [history, bookmarks]"] 228 ] 229 out = "" 230 searches.each {|s| 231 out += "!#{s[0]}#{spacer(s[0])}#{s[1]}\n" 232 } 233 out 234 end 235 236 def spacer(str) 237 len = str.length 238 str.scan(/[mwv]/).each do |tt| 239 len += 1 240 end 241 str.scan(/[t]/).each do |tt| 242 len -= 1 243 end 244 spacer = case len 245 when 0..3 246 "\t\t" 247 when 4..12 248 " \t" 249 end 250 spacer 251 end 252 253 def get_help_text 254 help_text =<<EOHELP 255-- [Available searches] ------------------- 256#{available_searches} 257EOHELP 258 259 if @cfg['custom_site_searches'] 260 help_text += "\n-- [Custom Searches] ----------------------\n" 261 @cfg['custom_site_searches'].each {|label, site| 262 help_text += "!#{label}#{spacer(label)} #{site}\n" 263 } 264 end 265 help_text 266 end 267 268 def help_dialog 269 help_text = "[SearchLink v#{VERSION}]\n\n" 270 help_text += get_help_text 271 help_text += "\nClick \\\"More Help\\\" for additional information" 272 res = %x{osascript <<'APPLESCRIPT' 273set _res to display dialog "#{help_text.gsub(/\n/,'\\\n')}" buttons {"OK", "More help"} default button "OK" with title "SearchLink Help" 274 275return button returned of _res 276APPLESCRIPT 277 }.strip 278 if res == "More help" 279 %x{open http://brettterpstra.com/projects/searchlink} 280 end 281 end 282 283 def help_cli 284 $stdout.puts get_help_text 285 end 286 287 def parse(input) 288 @output = '' 289 return false unless input && input.length > 0 290 parse_arguments(input, {:only_meta => true}) 291 @originput = input.dup 292 293 if input.strip =~ /^help$/i 294 if SILENT 295 help_dialog # %x{open http://brettterpstra.com/projects/searchlink/} 296 else 297 $stdout.puts "SearchLink v#{VERSION}" 298 $stdout.puts "See http://brettterpstra.com/projects/searchlink/ for help" 299 end 300 print input 301 Process.exit 302 end 303 304 @cfg['inline'] = true if input.scan(/\]\(/).length == 1 && input.split(/\n/).length == 1 305 @errors = {} 306 @report = [] 307 308 links = {} 309 @footer = [] 310 counter_links = 0 311 counter_errors = 0 312 313 input.sub!(/\n?<!-- Report:.*?-->\n?/m, '') 314 input.sub!(/\n?<!-- Errors:.*?-->\n?/m, '') 315 316 input.scan(/\[(.*?)\]:\s+(.*?)\n/).each {|match| 317 links[match[1].strip] = match[0] 318 } 319 320 if @cfg['prefix_random'] 321 if input =~ /\[(\d{4}-)\d+\]: \S+/ 322 prefix = $1 323 else 324 prefix = ("%04d" % rand(9999)).to_s + "-" 325 end 326 else 327 prefix = "" 328 end 329 330 highest_marker = 0 331 input.scan(/^\s{,3}\[(?:#{prefix})?(\d+)\]: /).each do |match| 332 highest_marker = $1.to_i if $1.to_i > highest_marker 333 end 334 335 footnote_counter = 0 336 input.scan(/^\s{,3}\[\^(?:#{prefix})?fn(\d+)\]: /).each do |match| 337 footnote_counter = $1.to_i if $1.to_i > footnote_counter 338 end 339 340 if input =~ /\[(.*?)\]\((.*?)\)/ 341 lines = input.split(/\n/) 342 out = [] 343 344 total_links = input.scan(/\[(.*?)\]\((.*?)\)/).length 345 in_code_block = false 346 line_difference = 0 347 lines.each_with_index {|line, num| 348 @line_num = num - line_difference 349 cursor_difference = 0 350 # ignore links in code blocks 351 if line =~ /^( {4,}|\t+)[^\*\+\-]/ 352 out.push(line) 353 next 354 end 355 if line =~ /^[~`]{3,}/ 356 if in_code_block 357 in_code_block = false 358 out.push(line) 359 next 360 else 361 in_code_block = true 362 end 363 end 364 if in_code_block 365 out.push(line) 366 next 367 end 368 369 # line.gsub!(/\(\$ (.*?)\)/) do |match| 370 # this_match = Regexp.last_match 371 # match_column = this_match.begin(0) 372 # match_string = this_match.to_s 373 # match_before = this_match.pre_match 374 # match_after = this_match.post_match 375 # # todo: inline searches in larger context 376 # end 377 378 delete_line = false 379 380 line.gsub!(/\[(.*?)\]\((.*?)\)/) do |match| 381 this_match = Regexp.last_match 382 @match_column = this_match.begin(0) - cursor_difference 383 match_string = this_match.to_s 384 @match_length = match_string.length 385 match_before = this_match.pre_match 386 387 invalid_search = false 388 ref_title = false 389 390 if match_before.scan(/(^|[^\\])`/).length % 2 == 1 391 add_report("Match '#{match_string}' within an inline code block") 392 invalid_search = true 393 end 394 395 counter_links += 1 396 $stderr.print("\033[0K\rProcessed: #{counter_links} of #{total_links}, #{counter_errors} errors. ") unless SILENT 397 398 link_text = this_match[1] || '' 399 link_info = parse_arguments(this_match[2].strip).strip || '' 400 401 if link_text == '' && link_info =~ /".*?"/ 402 link_info.gsub!(/\"(.*?)\"/) {|q| 403 link_text = $1 if link_text == '' 404 $1 405 } 406 end 407 408 if link_info.strip =~ /:$/ && line.strip == match 409 ref_title = true 410 link_info.sub!(/\s*:\s*$/,'') 411 end 412 413 unless link_text.length > 0 || link_info.sub(/^[!\^]\S+/,'').strip.length > 0 414 add_error('No input', match) 415 counter_errors += 1 416 invalid_search = true 417 end 418 419 if link_info =~ /^!(\S+)/ 420 search_type = $1 421 unless valid_search?(search_type) || search_type =~ /^(\S+\.)+\S+$/ 422 add_error('Invalid search', match) 423 invalid_search = true 424 end 425 end 426 427 428 if invalid_search 429 match 430 elsif link_info =~ /^\^(.+)/ 431 if $1.nil? || $1 == '' 432 match 433 else 434 note = $1.strip 435 footnote_counter += 1 436 if link_text.length > 0 && link_text.scan(/\s/).length == 0 437 ref = link_text 438 else 439 ref = prefix + "fn" + ("%04d" % footnote_counter).to_s 440 end 441 add_footer "[^#{ref}]: #{note}" 442 res = %Q{[^#{ref}]} 443 cursor_difference = cursor_difference + (@match_length - res.length) 444 @match_length = res.length 445 add_report("#{match_string} => Footnote #{ref}") 446 res 447 end 448 elsif (link_text == "" && link_info == "") || is_url?(link_info) 449 add_error('Invalid search', match) unless is_url?(link_info) 450 match 451 else 452 453 if link_text.length > 0 && link_info == "" 454 link_info = link_text 455 end 456 457 search_type = '' 458 search_terms = '' 459 link_only = false 460 @clipboard = false 461 462 463 if link_info =~ /^(?:[!\^](\S+))?\s*(.*)$/ 464 465 if $1.nil? 466 search_type = 'g' 467 else 468 search_type = $1 469 end 470 471 search_terms = $2.gsub(/(^["']|["']$)/, '') 472 search_terms.strip! 473 474 search_terms = link_text if search_terms == '' 475 476 # if the input starts with a +, append it to the link text as the search terms 477 search_terms = link_text + " " + search_terms.strip.sub(/^\+\s*/, '') if search_terms.strip =~ /^\+[^\+]/ 478 479 # if the end of input contain "^", copy to clipboard instead of STDOUT 480 @clipboard = true if search_terms =~ /(!!)?\^(!!)?$/ 481 482 # if the end of input contains "!!", only print the url 483 link_only = true if search_terms =~ /!!\^?$/ 484 485 search_terms.sub!(/(!!)?\^?(!!)?$/,"") 486 487 elsif link_info =~ /^\!/ 488 search_word = link_info.match(/^\!(\S+)/) 489 490 if search_word && valid_search?(search_word[1]) 491 search_type = search_word[1] unless search_word.nil? 492 search_terms = link_text 493 elsif search_word && search_word[1] =~ /^(\S+\.)+\S+$/ 494 search_type = 'g' 495 search_terms = "site:#{search_word[1]} #{link_text}" 496 else 497 add_error('Invalid search', match) 498 search_type = false 499 search_terms = false 500 end 501 502 elsif link_text && link_text.length > 0 && (link_info.nil? || link_info.length == 0) 503 search_type = 'g' 504 search_terms = link_text 505 else 506 add_error('Invalid search', match) 507 search_type = false 508 search_terms = false 509 end 510 511 @cfg['custom_site_searches'].each {|k,v| 512 if search_type == k 513 link_text = search_terms if link_text == '' 514 v = parse_arguments(v, {:no_restore => true}) 515 if v =~ /^(\/|http)/i 516 search_type = 'r' 517 tokens = v.scan(/\$term\d+d?/).sort.uniq 518 519 if tokens.length > 0 520 highest_token = 0 521 tokens.each {|token| 522 if token =~ /(\d+)d?$/ 523 highest_token = $1.to_i if $1.to_i > highest_token 524 end 525 } 526 terms_p = search_terms.split(/ +/) 527 if terms_p.length > highest_token 528 remainder = terms_p[highest_token-1..-1].join(" ") 529 terms_p = terms_p[0..highest_token - 2] 530 terms_p.push(remainder) 531 end 532 tokens.each {|t| 533 if t =~ /(\d+)d?$/ 534 int = $1.to_i - 1 535 replacement = terms_p[int] 536 if t =~ /d$/ 537 replacement.downcase! 538 re_down = "" 539 else 540 re_down = "(?!d)" 541 end 542 v.gsub!(/#{Regexp.escape(t)+re_down}/, CGI.escape(replacement)) 543 end 544 } 545 search_terms = v 546 547 548 else 549 search_terms = v.gsub(/\$termd?/i) {|m| 550 search_terms.downcase! if m =~ /d$/i 551 CGI.escape(search_terms) 552 } 553 end 554 555 else 556 search_type = 'g' 557 search_terms = "site:#{v} #{search_terms}" 558 end 559 560 break 561 end 562 } if search_type && search_terms && search_terms.length > 0 563 564 if search_type && search_terms 565 url = false 566 title = false 567 force_title = false 568 # $stderr.puts "Searching #{search_type} for #{search_terms}" 569 url, title, link_text = do_search(search_type, search_terms, link_text) 570 571 if url 572 link_text = title if link_text == '' && title 573 force_title = search_type =~ /def/ ? true : false 574 575 if link_only || search_type =~ /sp(ell)?/ || url == 'embed' 576 url = title if url == 'embed' 577 cursor_difference = cursor_difference + (@match_length - url.length) 578 @match_length = url.length 579 add_report("#{match_string} => #{url}") 580 url 581 582 583 elsif ref_title 584 unless links.has_key? url 585 links[url] = link_text 586 add_footer make_link('ref_title', link_text, url, title, force_title) 587 end 588 delete_line = true 589 elsif @cfg['inline'] 590 res = make_link('inline', link_text, url, title, force_title) 591 cursor_difference = cursor_difference + (@match_length - res.length) 592 @match_length = res.length 593 add_report("#{match_string} => #{url}") 594 res 595 else 596 unless links.has_key? url 597 highest_marker += 1 598 links[url] = prefix + ("%04d" % highest_marker).to_s 599 add_footer make_link('ref_title', links[url], url, title, force_title) 600 end 601 602 type = @cfg['inline'] ? 'inline' : 'ref_link' 603 res = make_link(type, link_text, links[url], false, force_title) 604 cursor_difference = cursor_difference + (@match_length - res.length) 605 @match_length = res.length 606 add_report("#{match_string} => #{url}") 607 res 608 end 609 else 610 add_error('No results', "#{search_terms} (#{match_string})") 611 counter_errors += 1 612 match 613 end 614 else 615 add_error('Invalid search', match) 616 counter_errors += 1 617 match 618 end 619 end 620 end 621 line_difference += 1 if delete_line 622 out.push(line) unless delete_line 623 delete_line = false 624 } 625 $stderr.puts("\n") unless SILENT 626 627 input = out.delete_if {|l| 628 l.strip =~ /^<!--DELETE-->$/ 629 }.join("\n") 630 631 if @cfg['inline'] 632 add_output input + "\n" 633 add_output "\n" + print_footer unless @footer.empty? 634 else 635 if @footer.empty? 636 add_output input 637 else 638 last_line = input.strip.split(/\n/)[-1] 639 if last_line =~ /^\[.*?\]: http/ 640 add_output input.rstrip + "\n" 641 elsif last_line =~ /^\[\^.*?\]: / 642 add_output input.rstrip 643 else 644 add_output input + "\n\n" 645 end 646 add_output print_footer + "\n\n" 647 end 648 end 649 @line_num = nil 650 add_report("Processed: #{total_links} links, #{counter_errors} errors.") 651 print_report 652 print_errors 653 else 654 link_only = false 655 @clipboard = false 656 657 input = parse_arguments(input.strip!).strip 658 659 # if the end of input contain "^", copy to clipboard instead of STDOUT 660 @clipboard = true if input =~ /\^[!~:]*$/ 661 662 # if the end of input contains "!!", only print the url 663 link_only = true if input =~ /!![\^~:]*$/ 664 665 reference_link = input =~ /:([!\^\s~]*)$/ 666 667 # if end of input contains ~, pull url from clipboard 668 if input =~ /~[:\^!\s]*$/ 669 input.sub!(/[:!\^\s~]*$/,'') 670 clipboard = %x{__CF_USER_TEXT_ENCODING=$UID:0x8000100:0x8000100 pbpaste}.strip 671 if is_url?(clipboard) 672 type = reference_link ? 'ref_title' : 'inline' 673 print make_link(type, input.strip, clipboard, false, false) 674 else 675 print @originput 676 end 677 Process.exit 678 end 679 680 input.sub!(/[:!\^\s~]*$/,'') 681 682 # check for additional search terms in parenthesis 683 additional_terms = '' 684 if input =~ /\((.*?)\)/ 685 additional_terms = " " + $1.strip 686 input.sub!(/\(.*?\)/,'') 687 end 688 689 link_text = false 690 691 if input =~ /"(.*?)"/ 692 link_text = $1 693 input.gsub!(/"(.*?)"/, '\1') 694 end 695 696 # remove quotes from terms, just in case 697 # input.sub!(/^(!\S+)?\s*(["'])(.*?)\2([\!\^]+)?$/, "\\1 \\3\\4") 698 699 if input =~ /^!(\S+)\s+(.*)$/ 700 type = $1 701 link_info = $2.strip 702 link_text = link_info unless link_text 703 terms = link_info + additional_terms 704 terms.strip! 705 706 if valid_search?(type) || type =~ /^(\S+\.)+\S+$/ 707 @cfg['custom_site_searches'].each {|k,v| 708 if type == k 709 link_text = terms if link_text == '' 710 v = parse_arguments(v, {:no_restore => true}) 711 if v =~ /^(\/|http)/i 712 type = 'r' 713 tokens = v.scan(/\$term\d+d?/).sort.uniq 714 715 if tokens.length > 0 716 highest_token = 0 717 tokens.each {|token| 718 if token =~ /(\d+)d?$/ 719 highest_token = $1.to_i if $1.to_i > highest_token 720 end 721 } 722 terms_p = terms.split(/ +/) 723 if terms_p.length > highest_token 724 remainder = terms_p[highest_token-1..-1].join(" ") 725 terms_p = terms_p[0..highest_token - 2] 726 terms_p.push(remainder) 727 end 728 tokens.each {|t| 729 if t =~ /(\d+)d?$/ 730 int = $1.to_i - 1 731 replacement = terms_p[int] 732 if t =~ /d$/ 733 replacement.downcase! 734 re_down = "" 735 else 736 re_down = "(?!d)" 737 end 738 v.gsub!(/#{Regexp.escape(t)+re_down}/, CGI.escape(replacement)) 739 end 740 } 741 terms = v 742 743 744 else 745 terms = v.gsub(/\$termd?/i) {|m| 746 terms.downcase! if m =~ /d$/i 747 CGI.escape(terms) 748 } 749 end 750 751 else 752 type = 'g' 753 terms = "site:#{v} #{terms}" 754 end 755 756 break 757 end 758 } if type && terms && terms.length > 0 759 760 if type =~ /^(\S+\.)+\S+$/ 761 terms = "site:#{type} #{terms}" 762 type = 'g' 763 end 764 765 url, title, link_text = do_search(type, terms, link_text) 766 else 767 add_error('Invalid search', input) 768 counter_errors += 1 769 end 770 elsif input =~ /^@(\S+)\s*$/ 771 link_text = input 772 url, title = social_handle('twitter', link_text) 773 else 774 link_text = input unless link_text 775 url, title = ddg(input) 776 end 777 778 if url 779 if type =~ /sp(ell)?/ 780 add_output(url) 781 elsif link_only 782 add_output(url) 783 else 784 type = reference_link ? 'ref_title' : 'inline' 785 add_output make_link(type, link_text, url, title, false) 786 print_errors 787 end 788 else 789 add_error('No results', title) 790 add_output @originput.chomp 791 print_errors 792 end 793 794 if @clipboard 795 if @output == @originput 796 $stderr.puts "No results found" 797 else 798 %x{echo #{Shellwords.escape(@output)}|tr -d "\n"|pbcopy} 799 $stderr.puts "Results in clipboard" 800 end 801 end 802 end 803 end 804 805 private 806 807 def parse_arguments(string, opt={}) 808 input = string.dup 809 skip_flags = opt[:only_meta] || false 810 no_restore = opt[:no_restore] || false 811 restore_prev_config unless no_restore 812 813 input.gsub!(/(\+\+|--)([dirtv]+)\b/) do |match| 814 bool = $1 == "++" ? "" : "no-" 815 output = " " 816 $2.split('').each {|arg| 817 output += case arg 818 when 'd' 819 "--#{bool}debug " 820 when 'i' 821 "--#{bool}inline " 822 when 'r' 823 "--#{bool}prefix_random " 824 when 't' 825 "--#{bool}include_titles " 826 when 'v' 827 "--#{bool}validate_links " 828 else 829 "" 830 end 831 } 832 output 833 end unless skip_flags 834 835 options = %w{ debug country_code inline prefix_random include_titles validate_links } 836 options.each {|o| 837 if input =~ /^#{o}:\s+(.*?)$/ 838 val = $1.strip 839 val = true if val =~ /true/i 840 val = false if val =~ /false/i 841 @cfg[o] = val 842 $stderr.print "\r\033[0KGlobal config: #{o} = #{@cfg[o]}\n" unless SILENT 843 end 844 845 unless skip_flags 846 while input =~ /^#{o}:\s+(.*?)$/ || input =~ /--(no-)?#{o}/ do 847 848 if input =~ /--(no-)?#{o}/ && !skip_flags 849 unless @prev_config.has_key? o 850 @prev_config[o] = @cfg[o] 851 bool = $1.nil? || $1 == '' ? true : false 852 @cfg[o] = bool 853 $stderr.print "\r\033[0KLine config: #{o} = #{@cfg[o]}\n" unless SILENT 854 end 855 input.sub!(/\s?--(no-)?#{o}/, '') 856 end 857 end 858 end 859 } 860 @clipboard ? string : input 861 end 862 863 def restore_prev_config 864 @prev_config.each {|k,v| 865 @cfg[k] = v 866 $stderr.print "\r\033[0KReset config: #{k} = #{@cfg[k]}\n" unless SILENT 867 } if @prev_config 868 @prev_config = {} 869 end 870 871 def make_link(type, text, url, title=false, force_title=false) 872 title = title && (cfg['include_titles'] || force_title) ? %Q{ "#{title.clean}"} : "" 873 case type 874 when 'ref_title' 875 %Q{\n[#{text.strip}]: #{url}#{title}} 876 when 'ref_link' 877 %Q{[#{text.strip}][#{url}]} 878 when 'inline' 879 %Q{[#{text.strip}](#{url}#{title})} 880 end 881 end 882 883 def add_output(str) 884 if @printout && !@clipboard 885 print str 886 end 887 @output += str 888 end 889 890 def add_footer(str) 891 @footer ||= [] 892 @footer.push(str.strip) 893 end 894 895 def print_footer 896 unless @footer.empty? 897 898 footnotes = [] 899 @footer.delete_if {|note| 900 note.strip! 901 if note =~ /^\[\^.+?\]/ 902 footnotes.push(note) 903 true 904 elsif note =~ /^\s*$/ 905 true 906 else 907 false 908 end 909 } 910 911 output = @footer.sort.join("\n").strip 912 output += "\n\n" if output.length > 0 && !footnotes.empty? 913 output += footnotes.join("\n\n") unless footnotes.empty? 914 return output.gsub(/\n{3,}/,"\n\n") 915 end 916 return "" 917 end 918 919 def add_report(str) 920 if @cfg['report'] 921 unless @line_num.nil? 922 position = @line_num.to_s + ':' 923 position += @match_column.nil? ? "0:" : "#{@match_column}:" 924 position += @match_length.nil? ? "0" : @match_length.to_s 925 end 926 @report.push("(#{position}): #{str}") 927 $stderr.puts "(#{position}): #{str}" unless SILENT 928 end 929 end 930 931 def add_error(type, str) 932 if @cfg['debug'] 933 unless @line_num.nil? 934 position = @line_num.to_s + ':' 935 position += @match_column.nil? ? "0:" : "#{@match_column}:" 936 position += @match_length.nil? ? "0" : @match_length.to_s 937 end 938 @errors[type] ||= [] 939 @errors[type].push("(#{position}): #{str}") 940 end 941 end 942 943 def print_report 944 return if (@cfg['inline'] && @originput.split(/\n/).length == 1) || @clipboard 945 unless @report.empty? 946 out = "\n<!-- Report:\n#{@report.join("\n")}\n-->\n" 947 add_output out 948 end 949 end 950 951 def print_errors(type = 'Errors') 952 return if @errors.empty? 953 out = '' 954 if @originput.split(/\n/).length > 1 955 inline = false 956 else 957 inline = @cfg['inline'] || @originput.split(/\n/).length == 1 958 end 959 960 @errors.each {|k,v| 961 unless v.empty? 962 v.each_with_index {|err, i| 963 out += "(#{k}) #{err}" 964 out += inline ? i == v.length - 1 ? " | " : ", " : "\n" 965 } 966 end 967 } 968 unless out == '' 969 sep = inline ? " " : "\n" 970 out.sub!(/\| /, '') 971 out = "#{sep}<!-- #{type}:#{sep}#{out}-->#{sep}" 972 end 973 if @clipboard 974 $stderr.puts out 975 else 976 add_output out 977 end 978 end 979 980 def print_or_copy(text) 981 # Process.exit unless text 982 if @clipboard 983 %x{echo #{Shellwords.escape(text)}|tr -d "\n"|pbcopy} 984 print @originput 985 else 986 print text 987 end 988 end 989 990 def notify(str, sub) 991 return unless @cfg['notifications'] 992 %x{osascript -e 'display notification "SearchLink" with title "#{str}" subtitle "#{sub}"'} 993 end 994 995 def valid_link?(uri_str, limit = 5) 996 begin 997 notify("Validating", uri_str) 998 return false if limit == 0 999 url = URI(uri_str) 1000 return true unless url.scheme 1001 if url.path == "" 1002 url.path = "/" 1003 end 1004 # response = Net::HTTP.get_response(URI(uri_str)) 1005 response = false 1006 1007 Net::HTTP.start(url.host, url.port, :use_ssl => url.scheme == 'https') {|http| response = http.request_head(url.path) } 1008 1009 case response 1010 when Net::HTTPMethodNotAllowed, Net::HTTPServiceUnavailable then 1011 unless /amazon\.com/ =~ url.host 1012 add_error('link validation', "Validation blocked: #{uri_str} (#{e})") 1013 end 1014 notify("Error validating", uri_str) 1015 true 1016 when Net::HTTPSuccess then 1017 true 1018 when Net::HTTPRedirection then 1019 location = response['location'] 1020 valid_link?(location, limit - 1) 1021 else 1022 notify("Error validating", uri_str) 1023 false 1024 end 1025 rescue => e 1026 notify("Error validating", uri_str) 1027 add_error('link validation', "Possibly invalid => #{uri_str} (#{e})") 1028 return true 1029 end 1030 end 1031 1032 def is_url?(input) 1033 input =~ /^(https?:\/\/\S+|\/\S+|\S+\/|[^!]\S+\.\S+)(\s+".*?")?$/ 1034 end 1035 1036 def valid_search?(term) 1037 valid = false 1038 valid = true if term =~ /(^h(([sc])([hb])?)*|^a$|^imov|^g$|^b$|^wiki$|^def$|^masd?$|^itud?$|^s$|^(i|am)(art|alb|song|pod)e?$|^lart|^@(t|adn|fb)|^r$|^sp(ell)?|pb$)/ 1039 valid = true if @cfg['custom_site_searches'].keys.include? term 1040 notify("Invalid search", term) unless valid 1041 valid 1042 end 1043 1044 def search_chrome_history(term) 1045 # Google history 1046 if File.exists?(File.expand_path('~/Library/Application Support/Google/Chrome/Default/History')) 1047 notify("Searching Chrome History", term) 1048 tmpfile = File.expand_path('~/Library/Application Support/Google/Chrome/Default/History.tmp') 1049 FileUtils.cp(File.expand_path('~/Library/Application Support/Google/Chrome/Default/History'), tmpfile) 1050 1051 terms = [] 1052 terms.push("(url NOT LIKE '%search/?%' AND url NOT LIKE '%?q=%' AND url NOT LIKE '%?s=%')") 1053 terms.concat(term.split(/\s+/).map {|t| "(url LIKE '%#{t.strip.downcase}%' OR title LIKE '%#{t.strip.downcase}%')" }) 1054 query = terms.join(" AND ") 1055 most_recent = %x{sqlite3 -separator ' ;;; ' '#{tmpfile}' "select title,url,datetime(last_visit_time / 1000000 + (strftime('%s', '1601-01-01')), 'unixepoch') from urls where #{query} AND NOT (url LIKE '%?s=%' OR url LIKE '%/search%') order by last_visit_time limit 1 COLLATE NOCASE;"}.strip 1056 FileUtils.rm_f(tmpfile) 1057 return false if most_recent.strip.length == 0 1058 title, url, date = most_recent.split(/\s*;;; /) 1059 date = Time.parse(date) 1060 [url, title, date] 1061 else 1062 false 1063 end 1064 end 1065 1066 def search_chrome_bookmarks(term) 1067 out = false 1068 if File.exists?(File.expand_path('~/Library/Application Support/Google/Chrome/Default/Bookmarks')) 1069 notify("Searching Chrome Bookmarks", term) 1070 chrome_bookmarks = JSON.parse(IO.read(File.expand_path('~/Library/Application Support/Google/Chrome/Default/Bookmarks'))) 1071 if chrome_bookmarks 1072 terms = term.split(/\s+/) 1073 roots = chrome_bookmarks['roots'] 1074 urls = extract_chrome_bookmarks(roots) 1075 urls.sort_by! {|bookmark| bookmark["date_added"]} 1076 urls.select {|u| 1077 found = false 1078 terms.each {|t| 1079 if u["url"] =~ /#{t}/i || u["title"] =~ /#{t}/ 1080 found = true 1081 end 1082 } 1083 found 1084 } 1085 unless urls.empty? 1086 lastest_bookmark = urls[-1] 1087 out = [lastest_bookmark['url'], lastest_bookmark['title'], lastest_bookmark['date']] 1088 end 1089 end 1090 end 1091 out 1092 end 1093 1094 def search_history(term,types = []) 1095 if types.empty? 1096 if @cfg['history_types'] 1097 types = @cfg['history_types'] 1098 else 1099 return false 1100 end 1101 end 1102 1103 1104 results = [] 1105 1106 if types.length > 0 1107 types.each {|type| 1108 1109 url, title, date = case type 1110 when 'chrome_history' 1111 search_chrome_history(term) 1112 when 'chrome_bookmarks' 1113 search_chrome_bookmarks(term) 1114 when 'safari_bookmarks' 1115 search_safari_urls(term, 'bookmark') 1116 when 'safari_history' 1117 search_safari_urls(term, 'history') 1118 when 'safari_all' 1119 search_safari_urls(term) 1120 else 1121 false 1122 end 1123 if url 1124 results << {'url' => url, 'title' => title, 'date' => date} 1125 end 1126 } 1127 1128 unless results.empty? 1129 out = results.sort_by! {|r| r['date'] }[-1] 1130 [out['url'], out['title']] 1131 else 1132 false 1133 end 1134 else 1135 false 1136 end 1137 end 1138 1139 # FIXME: These spotlight searches no longer work on 10.13 1140 # Search Safari Bookmarks and/or history using spotlight 1141 # 1142 # @param (String) term 1143 # @param (String) type ['history'|'bookmark'|(Bool) false] 1144 # 1145 # @return (Array) [url, title, access_date] on success 1146 # @return (Bool) false on error 1147 def search_safari_urls(term,type = false) 1148 notify("Searching Safari History", term) 1149 onlyin = "~/Library/Caches/Metadata/Safari" 1150 onlyin += type ? "/"+type.capitalize : "/" 1151 type = type ? ".#{type}" : "*" 1152 # created:>10/13/13 kind:safari filename:.webbookmark 1153 # Safari history/bookmarks 1154 terms = term.split(/\s+/).delete_if {|t| t.strip =~ /^\s*$/ }.map{|t| 1155 %Q{kMDItemTextContent = "*#{t}*"cdw} 1156 }.join(" && ") 1157 1158 date = type == ".history" ? "&& kMDItemContentCreationDate > $time.today(-182) " : "" 1159 1160 avoid_results = ["404", "not found", "chrome-extension"].map {|q| 1161 %Q{ kMDItemDisplayName != "*#{q}*"cdw } 1162 }.join(" && ") 1163 query = %Q{((kMDItemContentType = "com.apple.safari#{type}") #{date}&& (#{avoid_results}) && (#{terms}))} 1164 1165 search = %x{mdfind -onlyin #{onlyin.gsub(/ /,'\ ')} '#{query}'} 1166 if search.length > 0 1167 res = [] 1168 search.split(/\n/).each {|file| 1169 url = %x{mdls -raw -name kMDItemURL "#{file}"} 1170 date = %x{mdls -raw -name kMDItemDateAdded "#{file}"} 1171 date = Time.parse(date) 1172 title = %x{mdls -raw -name kMDItemDisplayName "#{file}"} 1173 res << {'url' => url, 'date' => date, 'title' => title} 1174 } 1175 res.delete_if {|k,el| el =~ /\(null\)/ } 1176 1177 latest = res.sort_by! {|r| r["date"] }[-1] 1178 [latest['url'], latest['title'], latest['date']] 1179 else 1180 false 1181 end 1182 end 1183 1184 def extract_chrome_bookmarks(json,urls = []) 1185 1186 if json.class == Array 1187 json.each {|item| 1188 urls = extract_chrome_bookmarks(item, urls) 1189 } 1190 elsif json.class == Hash 1191 if json.has_key? "children" 1192 urls = extract_chrome_bookmarks(json["children"],urls) 1193 elsif json["type"] == "url" 1194 date = Time.at(json["date_added"].to_i / 1000000 + (Time.new(1601,01,01).strftime('%s').to_i)) 1195 urls << {'url' => json["url"], 'title' => json["name"], 'date' => date} 1196 else 1197 json.each {|k,v| 1198 urls = extract_chrome_bookmarks(v,urls) 1199 } 1200 1201 end 1202 else 1203 return urls 1204 end 1205 urls 1206 end 1207 1208 1209 1210 def wiki(terms) 1211 ## Hack to scrape wikipedia result 1212 body = %x{/usr/bin/curl -sSL 'https://en.wikipedia.org/wiki/Special:Search?search=#{CGI.escape(terms)}&go=Go'} 1213 if body 1214 if RUBY_VERSION.to_f > 1.9 1215 body = body.force_encoding('utf-8') 1216 end 1217 1218 begin 1219 title = body.match(/"wgTitle":"(.*?)"/)[1] 1220 url = body.match(/<link rel="canonical" href="(.*?)"/)[1] 1221 rescue 1222 return false 1223 end 1224 return [url, title] 1225 end 1226 ## Removed because Ruby 2.0 does not like https connection to wikipedia without using gems? 1227 # uri = URI.parse("https://en.wikipedia.org/w/api.php?action=query&format=json&prop=info&inprop=url&titles=#{CGI.escape(terms)}") 1228 # req = Net::HTTP::Get.new(uri.path) 1229 # req['Referer'] = "http://brettterpstra.com" 1230 # req['User-Agent'] = "SearchLink (http://brettterpstra.com)" 1231 1232 # res = Net::HTTP.start(uri.host, uri.port, 1233 # :use_ssl => true, 1234 # :verify_mode => OpenSSL::SSL::VERIFY_NONE) do |https| 1235 # https.request(req) 1236 # end 1237 1238 1239 1240 # if RUBY_VERSION.to_f > 1.9 1241 # body = res.body.force_encoding('utf-8') 1242 # else 1243 # body = res.body 1244 # end 1245 1246 # result = JSON.parse(body) 1247 1248 # if result 1249 # result['query']['pages'].each do |page,info| 1250 # unless info.key? "missing" 1251 # return [info['fullurl'],info['title']] 1252 # end 1253 # end 1254 # end 1255 # return false 1256 end 1257 1258 def zero_click(terms) 1259 url = URI.parse("http://api.duckduckgo.com/?q=#{CGI.escape(terms)}&format=json&no_redirect=1&no_html=1&skip_disambig=1") 1260 res = Net::HTTP.get_response(url).body 1261 res = res.force_encoding('utf-8') if RUBY_VERSION.to_f > 1.9 1262 1263 result = JSON.parse(res) 1264 if result 1265 definition = result['Definition'] || false 1266 definition_link = result['DefinitionURL'] || false 1267 wiki_link = result['AbstractURL'] || false 1268 title = result['Heading'] || false 1269 return [title, definition, definition_link, wiki_link] 1270 else 1271 return false 1272 end 1273 end 1274 1275 # Search apple music 1276 # terms => search terms (unescaped) 1277 # media => music, podcast 1278 # entity => optional: artist, song, album, podcast 1279 # returns {:type=>,:id=>,:url=>,:title} 1280 def applemusic(terms, media='music', entity='') 1281 aff = @cfg['itunes_affiliate'] 1282 output = {} 1283 1284 url = URI.parse("http://itunes.apple.com/search?term=#{CGI.escape(terms)}&country=#{@cfg['country_code']}&media=#{media}&entity=#{entity}") 1285 res = Net::HTTP.get_response(url).body 1286 res = res.force_encoding('utf-8') if RUBY_VERSION.to_f > 1.9 1287 res.gsub!(/(?mi)[\x00-\x08\x0B-\x0C\x0E-\x1F]/,'') 1288 json = JSON.parse(res) 1289 if json['resultCount'] && json['resultCount'] > 0 1290 result = json['results'][0] 1291 1292 case result['wrapperType'] 1293 when 'track' 1294 if result['kind'] == 'podcast' 1295 output[:type] = 'podcast' 1296 output[:id] = result['collectionId'] 1297 output[:url] = result['collectionViewUrl'].to_am + aff 1298 output[:title] = result['collectionName'] 1299 else 1300 output[:type] = 'song' 1301 output[:id] = result['trackId'] 1302 output[:url] = result['trackViewUrl'].to_am + aff 1303 output[:title] = result['trackName'] + " by " + result['artistName'] 1304 end 1305 when 'collection' 1306 output[:type] = 'album' 1307 output[:id] = result['collectionId'] 1308 output[:url] = result['collectionViewUrl'].to_am + aff 1309 output[:title] = result['collectionName'] + " by " + result['artistName'] 1310 when 'artist' 1311 output[:type] = 'artist' 1312 output[:id] = result['artistId'] 1313 output[:url] = result['artistLinkUrl'].to_am + aff 1314 output[:title] = result['artistName'] 1315 end 1316 return false if output.empty? 1317 output 1318 else 1319 return false 1320 end 1321 end 1322 1323 def itunes(entity, terms, dev, aff='') 1324 aff = @cfg['itunes_affiliate'] 1325 1326 url = URI.parse("http://itunes.apple.com/search?term=#{CGI.escape(terms)}&country=#{@cfg['country_code']}&entity=#{entity}") 1327 res = Net::HTTP.get_response(url).body 1328 res = res.force_encoding('utf-8').encode # if RUBY_VERSION.to_f > 1.9 1329 1330 begin 1331 json = JSON.parse(res) 1332 rescue => e 1333 add_error('Invalid response', "Search for #{terms}: (#{e})") 1334 return false 1335 end 1336 return false unless json 1337 if json['resultCount'] && json['resultCount'] > 0 1338 result = json['results'][0] 1339 case entity 1340 when /movie/ 1341 # dev parameter probably not necessary in this case 1342 output_url = result['trackViewUrl'] 1343 output_title = result['trackName'] 1344 when /(mac|iPad)Software/ 1345 output_url = dev && result['sellerUrl'] ? result['sellerUrl'] : result['trackViewUrl'] 1346 output_title = result['trackName'] 1347 when /(musicArtist|song|album)/ 1348 case result['wrapperType'] 1349 when 'track' 1350 output_url = result['trackViewUrl'] 1351 output_title = result['trackName'] + " by " + result['artistName'] 1352 when 'collection' 1353 output_url = result['collectionViewUrl'] 1354 output_title = result['collectionName'] + " by " + result['artistName'] 1355 when 'artist' 1356 output_url = result['artistLinkUrl'] 1357 output_title = result['artistName'] 1358 end 1359 when /podcast/ 1360 output_url = result['collectionViewUrl'] 1361 output_title = result['collectionName'] 1362 end 1363 return false unless output_url and output_title 1364 if dev 1365 return [output_url, output_title] 1366 else 1367 return [output_url + aff, output_title] 1368 end 1369 else 1370 return false 1371 end 1372 end 1373 1374 def lastfm(entity, terms) 1375 url = URI.parse("http://ws.audioscrobbler.com/2.0/?method=#{entity}.search&#{entity}=#{CGI.escape(terms)}&api_key=2f3407ec29601f97ca8a18ff580477de&format=json") 1376 res = Net::HTTP.get_response(url).body 1377 res = res.force_encoding('utf-8') if RUBY_VERSION.to_f > 1.9 1378 json = JSON.parse(res) 1379 if json['results'] 1380 begin 1381 case entity 1382 when 'track' 1383 result = json['results']['trackmatches']['track'][0] 1384 url = result['url'] 1385 title = result['name'] + " by " + result['artist'] 1386 when 'artist' 1387 result = json['results']['artistmatches']['artist'][0] 1388 url = result['url'] 1389 title = result['name'] 1390 end 1391 return [url, title] 1392 rescue 1393 return false 1394 end 1395 else 1396 return false 1397 end 1398 end 1399 1400 def bing(terms, define = false) 1401 uri = URI.parse(%Q{https://api.datamarket.azure.com/Data.ashx/Bing/Search/v1/Web?Query=%27#{CGI.escape(terms)}%27&$format=json}) 1402 req = Net::HTTP::Get.new(uri) 1403 req.basic_auth '2b0c04b5-efa5-4362-9f4c-8cae5d470cef', 'M+B8HkyFfCAcdvh1g8bYST12R/3i46zHtVQRfx0L/6s' 1404 1405 res = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) {|http| 1406 http.request(req) 1407 } 1408 1409 if res 1410 begin 1411 json = res.body 1412 json.force_encoding('utf-8') if json.respond_to?('force_encoding') 1413 data = JSON.parse(json) 1414 result = data['d']['results'][0] 1415 return [result['Url'], result['Title']] 1416 rescue 1417 return false 1418 end 1419 else 1420 return false 1421 end 1422 end 1423 1424 def define(terms) 1425 begin 1426 def_url = "https://www.wordnik.com/words/#{CGI.escape(terms)}" 1427 body = %x{/usr/bin/curl -sSL '#{def_url}'} 1428 if body =~ /id="define"/ 1429 first_definition = body.match(/(?mi)(?:id="define"[\s\S]*?<li>)([\s\S]*?)<\/li>/)[1] 1430 parts = first_definition.match(/<abbr title="partOfSpeech">(.*?)<\/abbr> (.*?)$/) 1431 return [def_url, "(#{parts[1]}) #{parts[2]}"] 1432 end 1433 return false 1434 rescue 1435 return false 1436 end 1437 end 1438 1439 def pinboard_bookmarks 1440 bookmarks = %x{/usr/bin/curl -sSL "https://api.pinboard.in/v1/posts/all?auth_token=#{@cfg['pinboard_api_key']}&format=json"} 1441 bookmarks = bookmarks.force_encoding('utf-8') 1442 bookmarks.gsub!(/[^[:ascii:]]/) do |non_ascii| 1443 non_ascii.force_encoding('utf-8') 1444 .encode('utf-16be') 1445 .unpack('H*').first 1446 .gsub(/(....)/,'\u\1') 1447 end 1448 1449 bookmarks.gsub!(/[\u{1F600}-\u{1F6FF}]/,'') 1450 1451 bookmarks = JSON.parse(bookmarks) 1452 updated = Time.now 1453 result = {'update_time' => updated, 'bookmarks' => bookmarks} 1454 result 1455 end 1456 1457 def save_pinboard_cache(cache) 1458 cachefile = PINBOARD_CACHE 1459 1460 # file = File.new(cachefile,'w') 1461 # file = Zlib::GzipWriter.new(File.new(cachefile,'w')) 1462 begin 1463 marshal_dump = Marshal.dump(cache) 1464 File.write(cachefile, marshal_dump) 1465 rescue IOError => e 1466 add_error("Pinboard cache error","Failed to write stash to disk") 1467 return false 1468 end 1469 return true 1470 end 1471 1472 def get_pinboard_cache 1473 refresh_cache = false 1474 cachefile = PINBOARD_CACHE 1475 1476 if File.exists?(cachefile) 1477 begin 1478 file = IO.read(cachefile) # Zlib::GzipReader.open(cachefile) 1479 cache = Marshal.load file 1480 # file.close 1481 rescue IOError => e # Zlib::GzipFile::Error 1482 add_error("Error loading pinboard cache","Error reading #{cachefile}") 1483 return false 1484 end 1485 updated = JSON.parse(%x{/usr/bin/curl -sSL 'https://api.pinboard.in/v1/posts/update?auth_token=#{@cfg['pinboard_api_key']}&format=json'}) 1486 last_bookmark = Time.parse(updated['update_time']) 1487 if cache && cache.key?('update_time') 1488 last_update = Time.parse(cache['update_time']) 1489 if last_update < last_bookmark 1490 refresh_cache = true 1491 end 1492 else 1493 refresh_cache = true 1494 end 1495 else 1496 refresh_cache = true 1497 end 1498 1499 if refresh_cache 1500 cache = pinboard_bookmarks 1501 save_pinboard_cache(cache) 1502 end 1503 1504 return cache 1505 end 1506 1507 def pinboard(terms) 1508 unless @cfg['pinboard_api_key'] 1509 add_error('Missing Pinboard API token', "Find your api key at https://pinboard.in/settings/password and add it to your configuration (pinboard_api_key: YOURKEY)") 1510 return false 1511 end 1512 1513 result = false 1514 1515 regex = terms.split(/ /).map { |arg| Regexp.escape arg }.join(".*?") 1516 regex = /#{regex}/i 1517 1518 # cache = get_pinboard_cache 1519 cache = pinboard_bookmarks 1520 bookmarks = cache['bookmarks'] 1521 1522 bookmarks.each {|bm| 1523 text = [bm['description'],bm['tags']].join(" ") 1524 if text =~ regex 1525 result = [bm['href'],bm['description']] 1526 break 1527 end 1528 } 1529 return result 1530 1531 end 1532 1533 def google(terms, define = false) 1534 begin 1535 uri = URI.parse("http://ajax.googleapis.com/ajax/services/search/web?v=1.0&filter=1&rsz=small&q=#{CGI.escape(terms)}") 1536 req = Net::HTTP::Get.new(uri.request_uri) 1537 req['Referer'] = "http://brettterpstra.com" 1538 res = Net::HTTP.start(uri.host, uri.port) {|http| 1539 http.request(req) 1540 } 1541 if RUBY_VERSION.to_f > 1.9 1542 body = res.body.force_encoding('utf-8') 1543 else 1544 body = res.body 1545 end 1546 1547 json = JSON.parse(body) 1548 if json['responseData'] 1549 result = json['responseData']['results'][0] 1550 return false if result.nil? 1551 output_url = result['unescapedUrl'] 1552 if define && output_url =~ /dictionary/ 1553 output_title = result['content'].gsub(/<\/?.*?>/,'') 1554 else 1555 output_title = result['titleNoFormatting'] 1556 end 1557 return [output_url, output_title] 1558 else 1559 return bing(terms, define) 1560 end 1561 rescue 1562 return bing(terms, define) 1563 end 1564 end 1565 1566 def ddg(terms,type=false) 1567 1568 prefix = type ? "#{type.sub(/^!?/,'!')} " : "%5C" 1569 1570 begin 1571 body = %x{/usr/bin/curl -sSL 'http://duckduckgo.com/?q=#{prefix}#{CGI.escape(terms)}&t=hn&ia=web'} 1572 1573 url = body.match(/uddg=(.*?)'/) 1574 1575 if url && url[1] 1576 result = url[1] rescue false 1577 return false unless result 1578 output_url = URI.unescape(result) 1579 if @cfg['include_titles'] 1580 output_title = titleize(output_url) rescue '' 1581 else 1582 output_title = '' 1583 end 1584 return [output_url, output_title] 1585 else 1586 return false 1587 end 1588 end 1589 end 1590 1591 def titleize(url) 1592 1593 whitespace = url.match(/(\s*$)/)[0] || '' 1594 title = nil 1595 begin 1596 source = %x{/usr/bin/curl -sSL '#{url.strip}'} 1597 title = source.match(/<title>(.*)<\/title>/im) 1598 1599 title = title.nil? ? nil : title[1].strip 1600 1601 orig_title = false 1602 1603 if title.nil? || title =~ /^\s*$/ 1604 $stderr.puts "Warning: missing title for #{url.strip}" if $cfg['debug'] 1605 title = url.gsub(/(^https?:\/\/|\/.*$)/,'').strip 1606 else 1607 title = title.gsub(/\n/, ' ').gsub(/\s+/,' ').strip # .sub(/[^a-z]*$/i,'') 1608 end 1609 1610 title 1611 rescue Exception => e 1612 $stderr.puts "Error retrieving title for #{url.strip}" 1613 raise e 1614 end 1615 end 1616 1617 def spell(phrase) 1618 unless File.exists?("/usr/local/bin/aspell") 1619 add_error('Missing aspell', "Install aspell in /usr/local/bin/aspell to allow spelling corrections") 1620 return false 1621 end 1622 words = phrase.split(/\b/) 1623 output = "" 1624 words.each do |w| 1625 if w =~ /[A-Za-z]+/ 1626 spell_res = `echo "#{w}" | /usr/local/bin/aspell --sug-mode=bad-spellers -C pipe | head -n 2 | tail -n 1` 1627 if spell_res.strip == "\*" 1628 output += w 1629 else 1630 spell_res.sub!(/.*?: /,'') 1631 results = spell_res.split(/, /).delete_if { |w| phrase =~ /^[a-z]/ && w =~ /[A-Z]/ } 1632 output += results[0] 1633 end 1634 else 1635 output += w 1636 end 1637 end 1638 output 1639 end 1640 1641 # FIXME: Bing API stopped working for me 1642 # def spell(terms) 1643 # caps = [] 1644 # terms.split(" ").each {|w| 1645 # caps.push(w =~ /^[A-Z]/ ? true : false) 1646 # } 1647 1648 # uri = URI.parse("https://api.datamarket.azure.com/Data.ashx/Bing/Search/v1/SpellingSuggestions?Query=%27#{CGI.escape(terms)}%27&$format=json") 1649 # req = Net::HTTP::Get.new(uri) 1650 1651 # req.basic_auth '2b0c04b5-efa5-4362-9f4c-8cae5d470cef', 'M+B8HkyFfCAcdvh1g8bYST12R/3i46zHtVQRfx0L/6s' 1652 1653 # res = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) {|http| 1654 # http.request(req) 1655 # } 1656 # if res 1657 # begin 1658 # json = res.body 1659 # json.force_encoding('utf-8') if json.respond_to?('force_encoding') 1660 # data = JSON.parse(json) 1661 # return terms if data['d']['results'].empty? 1662 # result = data['d']['results'][0]['Value'] 1663 # output = [] 1664 # result.split(" ").each_with_index {|w, i| 1665 # output.push(caps[i] ? w.capitalize : w) 1666 # } 1667 # return output.join(" ") 1668 # rescue 1669 # return false 1670 # end 1671 # else 1672 # return false 1673 # end 1674 # end 1675 1676 def amazon_affiliatize(url, amazon_partner) 1677 return url if amazon_partner.nil? || amazon_partner.length == 0 1678 1679 if url =~ /https?:\/\/(?:.*?)amazon.com\/(?:(.*?)\/)?([dg])p\/([^\?]+)/ 1680 title = $1 1681 type = $2 1682 id = $3 1683 az_url = "http://www.amazon.com/#{type}p/product/#{id}/ref=as_li_ss_tl?ie=UTF8&linkCode=ll1&tag=#{amazon_partner}" 1684 return [az_url, title] 1685 else 1686 return [url,''] 1687 end 1688 end 1689 1690 def social_handle(type, term) 1691 handle = term.sub(/^@/,'').strip 1692 case type 1693 when /twitter/ 1694 url = "https://twitter.com/#{handle}" 1695 title = "@#{handle} on Twitter" 1696 when /adn/ 1697 url = "https://alpha.app.net/#{handle}" 1698 title = "@#{handle} on App.net" 1699 when /fb/ 1700 url = "https://www.facebook.com/#{handle}" 1701 title = "@#{handle} on Facebook" 1702 else 1703 [false, term, link_text] 1704 end 1705 [url, title] 1706 end 1707 1708 def do_search(search_type, search_terms, link_text='') 1709 notify("Searching", search_terms) 1710 return [false, search_terms, link_text] unless search_terms.length > 0 1711 1712 case search_type 1713 when /^r$/ # simple replacement 1714 if @cfg['validate_links'] 1715 unless valid_link?(search_terms) 1716 return [false, "Link not valid: #{search_terms}", link_text] 1717 end 1718 end 1719 link_text = search_terms if link_text == '' 1720 return [search_terms, link_text, link_text] 1721 when /^@t/ # twitter-ify username 1722 if search_terms.strip =~ /^@?[0-9a-z_$]+$/i 1723 url, title = social_handle('twitter', search_terms) 1724 link_text = search_terms 1725 else 1726 return [false, "#{search_terms} is not a valid Twitter handle", link_text] 1727 end 1728 when /^@fb/ # fb-nify username 1729 if search_terms.strip =~ /^@?[0-9a-z_]+$/i 1730 url, title = social_handle('fb', search_terms) 1731 link_text = search_terms if link_text == '' 1732 else 1733 return [false, "#{search_terms} is not a valid Facebook username", link_text] 1734 end 1735 when /^sp(ell)?$/ # replace with spelling suggestion 1736 res = spell(search_terms) 1737 if res 1738 return [res, res, ""] 1739 else 1740 url = false 1741 end 1742 when /^h(([sc])([hb])?)*$/ 1743 str = $1 1744 types = [] 1745 if str =~ /s([hb]*)/ 1746 if $1.length > 1 1747 types.push('safari_all') 1748 elsif $1 == 'h' 1749 types.push('safari_history') 1750 elsif $1 == 'b' 1751 types.push('safari_bookmarks') 1752 end 1753 end 1754 1755 if str =~ /c([hb]*)/ 1756 if $1.length > 1 1757 types.push('chrome_bookmarks') 1758 types.push('chrome_history') 1759 elsif $1 == 'h' 1760 types.push('chrome_history') 1761 elsif $1 == 'b' 1762 types.push('chrome_bookmarks') 1763 end 1764 end 1765 url, title = search_history(search_terms, types) 1766 when /^a$/ 1767 az_url, title = ddg(%Q{site:amazon.com #{search_terms}}) 1768 url, title = amazon_affiliatize(az_url, @cfg['amazon_partner']) 1769 1770 when /^g$/ # google lucky search 1771 url, title = ddg(search_terms) 1772 1773 when /^b$/ # bing 1774 url, title = bing(search_terms) 1775 when /^pb$/ 1776 url, title = pinboard(search_terms) 1777 when /^wiki$/ 1778 url, title = wiki(search_terms) 1779 1780 when /^def$/ # wikipedia/dictionary search 1781 # title, definition, definition_link, wiki_link = zero_click(search_terms) 1782 # if search_type == 'def' && definition_link != '' 1783 # url = definition_link 1784 # title = definition.gsub(/'+/,"'") 1785 # elsif wiki_link != '' 1786 # url = wiki_link 1787 # title = "Wikipedia: #{title}" 1788 # end 1789 fix = spell(search_terms) 1790 1791 if fix && search_terms.downcase != fix.downcase 1792 add_error('Spelling', "Spelling altered for '#{search_terms}' to '#{fix}'") 1793 search_terms = fix 1794 link_text = fix 1795 end 1796 1797 url, title = define(search_terms) 1798 when /^imov?$/ #iTunes movie search 1799 dev = false 1800 url, title = itunes('movie',search_terms,dev,@cfg['itunes_affiliate']) 1801 when /^masd?$/ # Mac App Store search (mas = itunes link, masd = developer link) 1802 dev = search_type =~ /d$/ 1803 url, title = itunes('macSoftware',search_terms, dev, @cfg['itunes_affiliate']) 1804 # Stopgap: 1805 #when /^masd?$/ 1806 # url, title = google("site:itunes.apple.com Mac App Store #{search_terms}") 1807 # url += $itunes_affiliate 1808 1809 when /^itud?$/ # iTunes app search 1810 dev = search_type =~ /d$/ 1811 url, title = itunes('iPadSoftware',search_terms, dev, @cfg['itunes_affiliate']) 1812 1813 when /^s$/ # software search (google) 1814 url, title = ddg(%Q{-site:postmates.com -site:download.cnet.com -site:softpedia.com -site:softonic.com -site:macupdate.com (software OR app OR mac) #{search_terms}}) 1815 link_text = title if link_text == '' 1816 1817 when /^am/ # apple music search 1818 stype = search_type.downcase.sub(/^am/,'') 1819 otype = 'link' 1820 if stype =~ /e$/ 1821 otype = 'embed' 1822 stype.sub!(/e$/,'') 1823 end 1824 case stype 1825 when /^pod$/ 1826 result = applemusic(search_terms, 'podcast') 1827 when /^art$/ 1828 result = applemusic(search_terms, 'music', 'musicArtist') 1829 when /^alb$/ 1830 result = applemusic(search_terms, 'music', 'album') 1831 when /^song$/ 1832 result = applemusic(search_terms, 'music', 'musicTrack') 1833 else 1834 result = applemusic(search_terms) 1835 end 1836 1837 # {:type=>,:id=>,:url=>,:title=>} 1838 if otype == 'embed' && result[:type] =~ /(album|song)/ 1839 url = 'embed' 1840 title = %Q{<iframe src="//tools.applemusic.com/embed/v1/#{result[:type]}/#{result[:id]}?country=#{@cfg['country_code']}#{@cfg['itunes_affiliate']}" height="500px" width="100%" frameborder="0"></iframe>} 1841 else 1842 url = result[:url] 1843 title = result[:title] 1844 end 1845 1846 when /^ipod$/ 1847 url, title = itunes('podcast', search_terms, false) 1848 1849 when /^isong$/ # iTunes Song Search 1850 url, title = itunes('song', search_terms, false) 1851 1852 when /^iart$/ # iTunes Artist Search 1853 url, title = itunes('musicArtist', search_terms, false) 1854 1855 when /^ialb$/ # iTunes Album Search 1856 url, title = itunes('album', search_terms, false) 1857 1858 when /^lsong$/ # Last.fm Song Search 1859 url, title = lastfm('track', search_terms) 1860 1861 when /^lart$/ # Last.fm Artist Search 1862 url, title = lastfm('artist', search_terms) 1863 else 1864 if search_terms 1865 if search_type =~ /.+?\.\w{2,4}$/ 1866 url, title = ddg(%Q{site:#{search_type} #{search_terms}}) 1867 else 1868 url, title = ddg(search_terms) 1869 end 1870 end 1871 end 1872 link_text = search_terms if link_text == '' 1873 if url && @cfg['validate_links'] && !valid_link?(url) && search_type !~ /^sp(ell)?/ 1874 [false, "Not found: #{url}", link_text] 1875 elsif !url 1876 [false, "No results: #{url}", link_text] 1877 else 1878 [url, title, link_text] 1879 end 1880 end 1881 1882end 1883 1884 1885 1886sl = SearchLink.new({:echo => false}) 1887overwrite = true 1888backup = sl.cfg['backup'] 1889 1890if ARGV.length > 0 1891 files = [] 1892 ARGV.each {|arg| 1893 if arg =~ /^(--?)?(h(elp)?|v(ersion)?)$/ 1894 $stdout.puts "SearchLink v#{VERSION}" 1895 sl.help_cli 1896 $stdout.puts "See http://brettterpstra.com/projects/searchlink/ for help" 1897 Process.exit 1898 elsif arg =~ /^--?(stdout)$/ 1899 overwrite = false 1900 elsif arg =~ /^--?no[\-_]backup$/ 1901 backup = false 1902 else 1903 files.push(arg) 1904 end 1905 } 1906 files.each {|file| 1907 if File.exists?(file) && %x{file -b "#{file}"|grep -c text}.to_i > 0 1908 if RUBY_VERSION.to_f > 1.9 1909 input = IO.read(file).force_encoding('utf-8') 1910 else 1911 input = IO.read(file) 1912 end 1913 FileUtils.cp(file,file+".bak") if backup && overwrite 1914 1915 sl.parse(input) 1916 1917 if overwrite 1918 File.open(file, 'w') do |f| 1919 f.puts sl.output 1920 end 1921 else 1922 puts sl.output 1923 end 1924 else 1925 $stderr.puts "Error reading #{file}" 1926 end 1927 } 1928else 1929 if RUBY_VERSION.to_f > 1.9 1930 input = STDIN.read.force_encoding('utf-8').encode 1931 else 1932 input = STDIN.read 1933 end 1934 1935 sl.parse(input) 1936 if sl.clipboard 1937 print input 1938 else 1939 print sl.output 1940 end 1941end