this repo has no description
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(/"/,""").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