linkedin-member-data 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +186 -0
- data/doc/CHANGELOG.md +9 -0
- data/doc/LinkedIn/MemberData/ApiError.md +52 -0
- data/doc/LinkedIn/MemberData/Authorization.md +30 -0
- data/doc/LinkedIn/MemberData/Changelog/Page.md +28 -0
- data/doc/LinkedIn/MemberData/Changelog.md +96 -0
- data/doc/LinkedIn/MemberData/Client.md +97 -0
- data/doc/LinkedIn/MemberData/ConfigurationError.md +8 -0
- data/doc/LinkedIn/MemberData/ConnectionError.md +15 -0
- data/doc/LinkedIn/MemberData/Domains.md +22 -0
- data/doc/LinkedIn/MemberData/Error.md +8 -0
- data/doc/LinkedIn/MemberData/Event.md +90 -0
- data/doc/LinkedIn/MemberData/Forbidden.md +8 -0
- data/doc/LinkedIn/MemberData/NotFound.md +8 -0
- data/doc/LinkedIn/MemberData/RateLimited.md +25 -0
- data/doc/LinkedIn/MemberData/ServerError.md +12 -0
- data/doc/LinkedIn/MemberData/Snapshot/Page.md +43 -0
- data/doc/LinkedIn/MemberData/Snapshot.md +90 -0
- data/doc/LinkedIn/MemberData/Unauthorized.md +8 -0
- data/doc/LinkedIn/MemberData/VersionError.md +8 -0
- data/doc/LinkedIn/MemberData.md +44 -0
- data/doc/LinkedIn.md +7 -0
- data/doc/README.md +186 -0
- data/exe/linkedin-member-data +6 -0
- data/lib/linkedin/member_data/authorization.rb +30 -0
- data/lib/linkedin/member_data/changelog/page.rb +28 -0
- data/lib/linkedin/member_data/changelog.rb +146 -0
- data/lib/linkedin/member_data/cli/changelog_command.rb +27 -0
- data/lib/linkedin/member_data/cli/command.rb +59 -0
- data/lib/linkedin/member_data/cli/global_options.rb +27 -0
- data/lib/linkedin/member_data/cli/simple_commands.rb +48 -0
- data/lib/linkedin/member_data/cli/since_parser.rb +36 -0
- data/lib/linkedin/member_data/cli/snapshot_command.rb +85 -0
- data/lib/linkedin/member_data/cli/support.rb +89 -0
- data/lib/linkedin/member_data/cli.rb +115 -0
- data/lib/linkedin/member_data/client.rb +107 -0
- data/lib/linkedin/member_data/connection.rb +165 -0
- data/lib/linkedin/member_data/domains.rb +43 -0
- data/lib/linkedin/member_data/errors.rb +154 -0
- data/lib/linkedin/member_data/event.rb +101 -0
- data/lib/linkedin/member_data/snapshot/page.rb +53 -0
- data/lib/linkedin/member_data/snapshot.rb +114 -0
- data/lib/linkedin/member_data/util.rb +52 -0
- data/lib/linkedin/member_data/version.rb +16 -0
- data/lib/linkedin/member_data.rb +12 -0
- data/llms.txt +44 -0
- metadata +95 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "changelog/page"
|
|
4
|
+
|
|
5
|
+
module LinkedIn
|
|
6
|
+
module MemberData
|
|
7
|
+
# Lazy view over `GET /rest/memberChangeLogs` (last 28 days).
|
|
8
|
+
# Nothing is fetched until you iterate. Each iteration starts a new walk.
|
|
9
|
+
#
|
|
10
|
+
# The cursor is `startTime` = last `processedAt` seen. LinkedIn returns the
|
|
11
|
+
# cursor event again on the next page, so events already seen are skipped
|
|
12
|
+
# and iteration stops when a page brings nothing new, or when the last event
|
|
13
|
+
# has no processedAt (the cursor cannot move).
|
|
14
|
+
#
|
|
15
|
+
# Known limit: if more than `count` events share one processedAt, the ones
|
|
16
|
+
# beyond the first page are not reachable through the cursor.
|
|
17
|
+
# A higher `count` (max 50) makes this less likely.
|
|
18
|
+
#
|
|
19
|
+
# It includes `Enumerable`, so `first`, `to_a`, `lazy`, `select` and the rest work on events.
|
|
20
|
+
#
|
|
21
|
+
# @example Print recent events
|
|
22
|
+
# client.changelog(since: Time.now - 7 * 86_400).each do |event|
|
|
23
|
+
# puts "#{event.method} #{event.resource_name} at #{event.processed_at}"
|
|
24
|
+
# end
|
|
25
|
+
class Changelog
|
|
26
|
+
include Enumerable
|
|
27
|
+
|
|
28
|
+
# API path of the changelog resource.
|
|
29
|
+
# @return [String]
|
|
30
|
+
PATH = "/rest/memberChangeLogs"
|
|
31
|
+
|
|
32
|
+
# Allowed values for `count`.
|
|
33
|
+
# @return [Range<Integer>]
|
|
34
|
+
COUNT_RANGE = (1..50)
|
|
35
|
+
|
|
36
|
+
# Default for `count`.
|
|
37
|
+
# @return [Integer]
|
|
38
|
+
DEFAULT_COUNT = 10
|
|
39
|
+
|
|
40
|
+
# Cursor state of one walk. A new one is built for every iteration.
|
|
41
|
+
# @api private
|
|
42
|
+
Walk = Struct.new(:start_time, :seen_ids) do
|
|
43
|
+
# `page` is an undeduped Page. Returns a Page with only the fresh events
|
|
44
|
+
# and moves the cursor; nil when nothing is new.
|
|
45
|
+
def take(page)
|
|
46
|
+
fresh = fresh_events(page)
|
|
47
|
+
return if fresh.empty?
|
|
48
|
+
|
|
49
|
+
advance(page)
|
|
50
|
+
Page.new(events: fresh, raw: page.raw)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Events of `page` whose id was not on the previous page.
|
|
54
|
+
# @api private
|
|
55
|
+
def fresh_events(page) = page.events.reject { |event| seen_ids.include?(event.id) }
|
|
56
|
+
|
|
57
|
+
# Yields each start_time to the block to fetch a Page. Stops at the first
|
|
58
|
+
# page with nothing new or when the cursor cannot move.
|
|
59
|
+
def run(yielder)
|
|
60
|
+
while (fresh_page = take(yield(start_time)))
|
|
61
|
+
yielder << fresh_page
|
|
62
|
+
break if start_time.nil?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Only the previous page is remembered: that is the documented overlap.
|
|
67
|
+
def advance(page)
|
|
68
|
+
self.seen_ids = page.events.map(&:id)
|
|
69
|
+
self.start_time = page.next_start_time
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_constant :Walk
|
|
73
|
+
|
|
74
|
+
# Settings of this view. `since` is the first `processedAt` to fetch, in epoch milliseconds
|
|
75
|
+
# (`nil` starts at the oldest event). `count` is the number of events per request, from 1 to 50.
|
|
76
|
+
# @return [Integer, nil] `since` or `count`. `count` is never nil.
|
|
77
|
+
attr_reader :since, :count
|
|
78
|
+
|
|
79
|
+
# @api private
|
|
80
|
+
# @param connection [Connection] used for every request.
|
|
81
|
+
# @param since [Time, Date, Integer, nil] first `processedAt` to fetch.
|
|
82
|
+
# Integer is epoch milliseconds. A Date is midnight UTC.
|
|
83
|
+
# @param count [Integer] events per request, from 1 to 50.
|
|
84
|
+
# @raise [ArgumentError] when `count` is outside 1..50 or `since` has an unsupported type.
|
|
85
|
+
# Raised before any request.
|
|
86
|
+
def initialize(connection, since: nil, count: DEFAULT_COUNT)
|
|
87
|
+
@count = count
|
|
88
|
+
ensure_count
|
|
89
|
+
@connection = connection
|
|
90
|
+
@since = Util.epoch_ms(since)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Yields every new event of every page, oldest first. Pages are fetched one by one while you iterate.
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# client.changelog(count: 50).each { |event| puts event.id }
|
|
97
|
+
# @example Without a block, you get an Enumerator
|
|
98
|
+
# client.changelog.each.first(5)
|
|
99
|
+
# @yieldparam event [Event] one changelog event. Events already seen in the overlap are skipped.
|
|
100
|
+
# @return [Changelog] self, when a block is given.
|
|
101
|
+
# @return [Enumerator<Event>] when no block is given.
|
|
102
|
+
# @raise [ApiError] on a non-2xx response.
|
|
103
|
+
# @raise [ConnectionError] on a network failure after all retries.
|
|
104
|
+
def each(&block)
|
|
105
|
+
return enum_for(:each) unless block
|
|
106
|
+
|
|
107
|
+
pages.each { |page| page.events.each(&block) }
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Lazy Enumerator of pages with already-seen events removed.
|
|
112
|
+
# Stops on the first page with no new events, or when the last event has no `processedAt`.
|
|
113
|
+
# A yielded Page has filtered `events` but the unfiltered `raw` response.
|
|
114
|
+
#
|
|
115
|
+
# @example Cursor of each page
|
|
116
|
+
# client.changelog.pages.each { |page| puts "#{page.events.size} events, next #{page.next_start_time}" }
|
|
117
|
+
# @return [Enumerator<Changelog::Page>]
|
|
118
|
+
# @raise [ApiError] while iterating, on a non-2xx response.
|
|
119
|
+
# @raise [ConnectionError] while iterating, on a network failure after all retries.
|
|
120
|
+
def pages = Enumerator.new { |yielder| each_page(yielder) }
|
|
121
|
+
|
|
122
|
+
# Fetches one raw page. No overlap handling, so the cursor event comes back again.
|
|
123
|
+
#
|
|
124
|
+
# @param start_time [Integer, nil] cursor in epoch milliseconds. `nil` starts at the oldest event.
|
|
125
|
+
# @return [Changelog::Page]
|
|
126
|
+
# @raise [ApiError] on a non-2xx response.
|
|
127
|
+
# @raise [ConnectionError] on a network failure after all retries.
|
|
128
|
+
def page(start_time)
|
|
129
|
+
params = { q: "memberAndApplication", count: count, startTime: start_time }
|
|
130
|
+
Page.from_api(@connection.get(PATH, params))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def each_page(yielder)
|
|
136
|
+
Walk.new(since, []).run(yielder) { |start_time| page(start_time) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def ensure_count
|
|
140
|
+
return if count.is_a?(Integer) && COUNT_RANGE.cover?(count)
|
|
141
|
+
|
|
142
|
+
raise ArgumentError, "count must be between #{COUNT_RANGE.min} and #{COUNT_RANGE.max}, got #{count.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkedIn
|
|
4
|
+
module MemberData
|
|
5
|
+
class CLI
|
|
6
|
+
# Raw changelog events as a JSON array.
|
|
7
|
+
# @api private
|
|
8
|
+
class ChangelogCommand < Command
|
|
9
|
+
# @return [Array<Array>]
|
|
10
|
+
OPTIONS = [
|
|
11
|
+
["--since DATE", SinceParser, "ISO date or datetime"],
|
|
12
|
+
["--count N", Integer, "Events per request (1..50)"],
|
|
13
|
+
["--out FILE", "Write to FILE instead of stdout"]
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def execute
|
|
19
|
+
output.progress("Fetching changelog...")
|
|
20
|
+
events = client.changelog(**options.slice(:since, :count)).map(&:raw)
|
|
21
|
+
output.write(events, options[:out])
|
|
22
|
+
0
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module LinkedIn
|
|
6
|
+
module MemberData
|
|
7
|
+
class CLI
|
|
8
|
+
# Base for commands. A command parses its own options, then runs.
|
|
9
|
+
# `run` returns the exit code.
|
|
10
|
+
# @api private
|
|
11
|
+
class Command
|
|
12
|
+
# Option specs for OptionParser#on. Subclasses override.
|
|
13
|
+
# @return [Array<Array>]
|
|
14
|
+
OPTIONS = [].freeze
|
|
15
|
+
# Positional arguments the command accepts.
|
|
16
|
+
# @return [Integer]
|
|
17
|
+
MAX_ARGUMENTS = 0
|
|
18
|
+
|
|
19
|
+
# @param context [Context]
|
|
20
|
+
# @param argv [Array<String>] arguments after the command name. Options are removed from it.
|
|
21
|
+
def initialize(context, argv)
|
|
22
|
+
@context = context
|
|
23
|
+
@argv = argv
|
|
24
|
+
@options = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Parses options, checks arguments and executes.
|
|
28
|
+
# @return [Integer] exit code.
|
|
29
|
+
# @raise [UsageError] on bad arguments.
|
|
30
|
+
def run
|
|
31
|
+
parser.parse!(argv, into: options)
|
|
32
|
+
context.use_token(options[:token]) if options.key?(:token)
|
|
33
|
+
check_arguments
|
|
34
|
+
execute
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :context, :argv, :options
|
|
40
|
+
|
|
41
|
+
def check_arguments
|
|
42
|
+
extra = argv.drop(self.class::MAX_ARGUMENTS)
|
|
43
|
+
raise UsageError, "unexpected argument: #{extra.first}" unless extra.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def output = context.output
|
|
47
|
+
|
|
48
|
+
def client = context.client
|
|
49
|
+
|
|
50
|
+
def parser
|
|
51
|
+
parser = OptionParser.new("Usage: linkedin-member-data COMMAND [options]")
|
|
52
|
+
parser.on("--token TOKEN")
|
|
53
|
+
self.class::OPTIONS.each { |spec| parser.on(*spec) }
|
|
54
|
+
parser
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module LinkedIn
|
|
6
|
+
module MemberData
|
|
7
|
+
class CLI
|
|
8
|
+
# Options that come before the command name: --token and --help.
|
|
9
|
+
# @api private
|
|
10
|
+
class GlobalOptions
|
|
11
|
+
# @param argv [Array<String>] arguments. Global options are removed from it.
|
|
12
|
+
# @return [Hash{Symbol => Object}] options found, for example `{ token: "...", help: true }`.
|
|
13
|
+
# @raise [OptionParser::ParseError] on an unknown option.
|
|
14
|
+
def self.parse(argv) = {}.tap { |options| parser.order!(argv, into: options) }
|
|
15
|
+
|
|
16
|
+
def self.parser
|
|
17
|
+
OptionParser.new do |opts|
|
|
18
|
+
opts.on("--token TOKEN")
|
|
19
|
+
opts.on("-h", "--help")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private_class_method :parser
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkedIn
|
|
4
|
+
module MemberData
|
|
5
|
+
class CLI
|
|
6
|
+
# Prints the gem version.
|
|
7
|
+
# @api private
|
|
8
|
+
class VersionCommand < Command
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def execute
|
|
12
|
+
output.line(VERSION)
|
|
13
|
+
0
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Prints one snapshot domain per line. Needs no token.
|
|
18
|
+
# @api private
|
|
19
|
+
class DomainsCommand < Command
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def execute
|
|
23
|
+
output.line(Domains::ALL)
|
|
24
|
+
0
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Prints the authorization of the token as JSON.
|
|
29
|
+
# @api private
|
|
30
|
+
class AuthCommand < Command
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def execute
|
|
34
|
+
authorization = client.authorization
|
|
35
|
+
return missing if authorization.nil?
|
|
36
|
+
|
|
37
|
+
output.write(authorization.raw)
|
|
38
|
+
0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def missing
|
|
42
|
+
output.error("No authorization found for this token")
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "date"
|
|
5
|
+
require "optparse"
|
|
6
|
+
|
|
7
|
+
module LinkedIn
|
|
8
|
+
module MemberData
|
|
9
|
+
class CLI
|
|
10
|
+
# Reads --since: an ISO datetime, or an ISO date (midnight UTC).
|
|
11
|
+
# @api private
|
|
12
|
+
class SinceParser
|
|
13
|
+
# @param value [String] ISO 8601 datetime or date.
|
|
14
|
+
# @return [Time]
|
|
15
|
+
# @raise [UsageError] when `value` is neither.
|
|
16
|
+
def self.call(value)
|
|
17
|
+
Time.iso8601(value)
|
|
18
|
+
rescue ArgumentError
|
|
19
|
+
from_date(value)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_date(value)
|
|
23
|
+
date = Date.iso8601(value)
|
|
24
|
+
Time.utc(date.year, date.month, date.day)
|
|
25
|
+
rescue ArgumentError
|
|
26
|
+
raise UsageError, "--since must be an ISO date or datetime, got #{value.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private_class_method :from_date
|
|
30
|
+
|
|
31
|
+
# parser.on(..., SinceParser, ...) in ChangelogCommand relies on this.
|
|
32
|
+
OptionParser.accept(self) { |value| call(value) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module LinkedIn
|
|
6
|
+
module MemberData
|
|
7
|
+
class CLI
|
|
8
|
+
# One domain to a file or stdout, or every domain into a directory.
|
|
9
|
+
# @api private
|
|
10
|
+
class SnapshotCommand < Command
|
|
11
|
+
# @return [Array<Array>]
|
|
12
|
+
OPTIONS = [
|
|
13
|
+
["--all", "Download every domain"],
|
|
14
|
+
["--out FILE", "Write to FILE instead of stdout"],
|
|
15
|
+
["--out-dir DIR", "Directory for --all"]
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# @return [Integer]
|
|
19
|
+
MAX_ARGUMENTS = 1
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def check_arguments
|
|
24
|
+
super
|
|
25
|
+
options[:all] ? check_all : check_one
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_all
|
|
29
|
+
raise UsageError, "--all cannot be used with --out" if options[:out]
|
|
30
|
+
raise UsageError, "--all takes no DOMAIN" unless argv.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def check_one
|
|
34
|
+
raise UsageError, "--out-dir needs --all" if options[:"out-dir"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute = options[:all] ? download_all : download_one
|
|
38
|
+
|
|
39
|
+
def download_one
|
|
40
|
+
domain = argv.shift
|
|
41
|
+
raise UsageError, "snapshot needs a DOMAIN (or --all)" if domain.nil?
|
|
42
|
+
|
|
43
|
+
output.write(fetch(domain.upcase), options[:out])
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A failing domain is reported and the run goes on. Exit 1 at the end.
|
|
48
|
+
def download_all
|
|
49
|
+
FileUtils.mkdir_p(out_dir)
|
|
50
|
+
failed = Domains::ALL.reject { |domain| saved?(domain, out_dir) }
|
|
51
|
+
failed.empty? ? 0 : 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The key comes from OptionParser's long option name (--out-dir).
|
|
55
|
+
def out_dir = options.fetch(:"out-dir") { raise UsageError, "--all needs --out-dir DIR" }
|
|
56
|
+
|
|
57
|
+
# A bad token fails every domain the same way, so Unauthorized and
|
|
58
|
+
# Forbidden stop the run at once. Other errors are per domain.
|
|
59
|
+
def saved?(domain, dir)
|
|
60
|
+
written?(domain, dir)
|
|
61
|
+
rescue Unauthorized, Forbidden
|
|
62
|
+
raise
|
|
63
|
+
rescue ApiError, ConnectionError => error
|
|
64
|
+
!reported?(domain, error)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def written?(domain, dir)
|
|
68
|
+
output.write(fetch(domain), File.join(dir, "#{domain}.json"))
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reports the failure on stderr; true means the domain was not saved.
|
|
73
|
+
def reported?(domain, error)
|
|
74
|
+
output.error("#{domain}: #{error.message}")
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fetch(domain)
|
|
79
|
+
output.progress("Fetching #{domain}...")
|
|
80
|
+
client.snapshot(domain).to_a
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LinkedIn
|
|
6
|
+
module MemberData
|
|
7
|
+
class CLI
|
|
8
|
+
# Bad command line: unknown command, missing argument, bad value.
|
|
9
|
+
# @api private
|
|
10
|
+
class UsageError < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Data goes to a file or stdout. Progress and errors go to stderr.
|
|
13
|
+
# @api private
|
|
14
|
+
class Output
|
|
15
|
+
# @param stdout [IO]
|
|
16
|
+
# @param stderr [IO]
|
|
17
|
+
def initialize(stdout, stderr)
|
|
18
|
+
@stdout = stdout
|
|
19
|
+
@stderr = stderr
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Files get the same bytes as stdout, including the final newline.
|
|
23
|
+
# @param data [Object] anything JSON can encode.
|
|
24
|
+
# @param path [String, nil] file to write. `nil` means stdout.
|
|
25
|
+
# @return [void]
|
|
26
|
+
def write(data, path = nil)
|
|
27
|
+
json = JSON.pretty_generate(data)
|
|
28
|
+
path ? File.write(path, "#{json}\n") : @stdout.puts(json)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Prints a line to stdout.
|
|
32
|
+
# @param text [String, Array<String>]
|
|
33
|
+
# @return [void]
|
|
34
|
+
def line(text) = @stdout.puts(text)
|
|
35
|
+
|
|
36
|
+
# Prints a line to stderr.
|
|
37
|
+
# @param text [String]
|
|
38
|
+
# @return [void]
|
|
39
|
+
def error(text) = @stderr.puts(text)
|
|
40
|
+
|
|
41
|
+
alias progress error
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# What every command needs: the output and a lazy client.
|
|
45
|
+
# The token is only looked up when a command asks for the client.
|
|
46
|
+
# @api private
|
|
47
|
+
class Context
|
|
48
|
+
# Name of the environment variable that holds the token.
|
|
49
|
+
# @return [String]
|
|
50
|
+
TOKEN_ENV = "LINKEDIN_ACCESS_TOKEN"
|
|
51
|
+
|
|
52
|
+
# @return [Output]
|
|
53
|
+
attr_reader :output
|
|
54
|
+
|
|
55
|
+
# @param output [Output]
|
|
56
|
+
# @param env [Hash, #[]] environment.
|
|
57
|
+
# @param client_factory [#call] takes a token and returns a `Client`.
|
|
58
|
+
# @param token [String, nil] token from `--token`.
|
|
59
|
+
def initialize(output:, env:, client_factory:, token:)
|
|
60
|
+
@output = output
|
|
61
|
+
@env = env
|
|
62
|
+
@client_factory = client_factory
|
|
63
|
+
@token = token
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The client, built on first use.
|
|
67
|
+
# @return [Client]
|
|
68
|
+
# @raise [ConfigurationError] when no token is set.
|
|
69
|
+
def client = @client ||= @client_factory.call(access_token)
|
|
70
|
+
|
|
71
|
+
# Replaces the token from `--token`.
|
|
72
|
+
# @param value [String]
|
|
73
|
+
# @return [String]
|
|
74
|
+
def use_token(value)
|
|
75
|
+
@token = value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def access_token
|
|
81
|
+
value = (@token || @env[TOKEN_ENV]).to_s.strip
|
|
82
|
+
raise ConfigurationError, "no access token: pass --token or set #{TOKEN_ENV}" if value.empty?
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "../member_data"
|
|
6
|
+
|
|
7
|
+
require_relative "cli/global_options"
|
|
8
|
+
require_relative "cli/support"
|
|
9
|
+
require_relative "cli/since_parser"
|
|
10
|
+
require_relative "cli/command"
|
|
11
|
+
require_relative "cli/simple_commands"
|
|
12
|
+
require_relative "cli/snapshot_command"
|
|
13
|
+
require_relative "cli/changelog_command"
|
|
14
|
+
|
|
15
|
+
module LinkedIn
|
|
16
|
+
module MemberData
|
|
17
|
+
# Command line entry point. Data goes to stdout or files, progress and
|
|
18
|
+
# errors go to stderr. Exit codes: 0 ok, 1 API error, 2 usage error.
|
|
19
|
+
# @api private
|
|
20
|
+
class CLI
|
|
21
|
+
# Command name => command class.
|
|
22
|
+
# @return [Hash{String => Class}]
|
|
23
|
+
COMMANDS = {
|
|
24
|
+
"snapshot" => SnapshotCommand,
|
|
25
|
+
"changelog" => ChangelogCommand,
|
|
26
|
+
"domains" => DomainsCommand,
|
|
27
|
+
"auth" => AuthCommand,
|
|
28
|
+
"version" => VersionCommand
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Builds a `Client` from a token.
|
|
32
|
+
# @return [Proc]
|
|
33
|
+
DEFAULT_CLIENT_FACTORY = ->(token) { Client.new(access_token: token) }
|
|
34
|
+
|
|
35
|
+
# Help text.
|
|
36
|
+
# @return [String]
|
|
37
|
+
USAGE = <<~TEXT
|
|
38
|
+
Usage: linkedin-member-data COMMAND [options]
|
|
39
|
+
|
|
40
|
+
Commands:
|
|
41
|
+
snapshot DOMAIN [--out FILE] Download one snapshot domain as a JSON array
|
|
42
|
+
snapshot --all --out-dir DIR Download every domain, one <DOMAIN>.json per domain
|
|
43
|
+
changelog [--since DATE] [--count N] [--out FILE]
|
|
44
|
+
Download changelog events (last 28 days)
|
|
45
|
+
domains List snapshot domains
|
|
46
|
+
auth Show authorization status as JSON
|
|
47
|
+
version Print the gem version
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--token TOKEN Access token (or set LINKEDIN_ACCESS_TOKEN). Before or after the command.
|
|
51
|
+
-h, --help Show this help
|
|
52
|
+
TEXT
|
|
53
|
+
|
|
54
|
+
# @param argv [Array<String>] command line arguments. Not changed.
|
|
55
|
+
# @param stdout [IO] where data goes.
|
|
56
|
+
# @param stderr [IO] where progress and errors go.
|
|
57
|
+
# @param env [Hash, #[]] environment, read for `LINKEDIN_ACCESS_TOKEN`.
|
|
58
|
+
# @param client_factory [#call] takes a token and returns a `Client`.
|
|
59
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, env: ENV, client_factory: DEFAULT_CLIENT_FACTORY)
|
|
60
|
+
@argv = argv.dup
|
|
61
|
+
@output = Output.new(stdout, stderr)
|
|
62
|
+
@env = env
|
|
63
|
+
@client_factory = client_factory
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Runs the command.
|
|
67
|
+
# @return [Integer] exit code: 0 ok, 1 API, network or file error, 2 usage error.
|
|
68
|
+
def run
|
|
69
|
+
handling_errors { start }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def start
|
|
75
|
+
options = GlobalOptions.parse(@argv)
|
|
76
|
+
options[:help] ? help : dispatch(options[:token])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handling_errors
|
|
80
|
+
yield
|
|
81
|
+
rescue UsageError, OptionParser::ParseError, ConfigurationError, ArgumentError => error
|
|
82
|
+
usage_failure(error)
|
|
83
|
+
rescue Error, SystemCallError => error
|
|
84
|
+
failure(error)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dispatch(token)
|
|
88
|
+
name = @argv.shift
|
|
89
|
+
raise UsageError, "missing command" if name.nil?
|
|
90
|
+
|
|
91
|
+
COMMANDS.fetch(name) { raise UsageError, "unknown command: #{name}" }.new(context(token), @argv).run
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def context(token)
|
|
95
|
+
Context.new(output: @output, env: @env, client_factory: @client_factory, token: token)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def help
|
|
99
|
+
@output.line(USAGE)
|
|
100
|
+
0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def failure(error)
|
|
104
|
+
@output.error("error: #{error.message}")
|
|
105
|
+
1
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def usage_failure(error)
|
|
109
|
+
@output.error("error: #{error.message}")
|
|
110
|
+
@output.error(USAGE)
|
|
111
|
+
2
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|