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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +186 -0
  6. data/doc/CHANGELOG.md +9 -0
  7. data/doc/LinkedIn/MemberData/ApiError.md +52 -0
  8. data/doc/LinkedIn/MemberData/Authorization.md +30 -0
  9. data/doc/LinkedIn/MemberData/Changelog/Page.md +28 -0
  10. data/doc/LinkedIn/MemberData/Changelog.md +96 -0
  11. data/doc/LinkedIn/MemberData/Client.md +97 -0
  12. data/doc/LinkedIn/MemberData/ConfigurationError.md +8 -0
  13. data/doc/LinkedIn/MemberData/ConnectionError.md +15 -0
  14. data/doc/LinkedIn/MemberData/Domains.md +22 -0
  15. data/doc/LinkedIn/MemberData/Error.md +8 -0
  16. data/doc/LinkedIn/MemberData/Event.md +90 -0
  17. data/doc/LinkedIn/MemberData/Forbidden.md +8 -0
  18. data/doc/LinkedIn/MemberData/NotFound.md +8 -0
  19. data/doc/LinkedIn/MemberData/RateLimited.md +25 -0
  20. data/doc/LinkedIn/MemberData/ServerError.md +12 -0
  21. data/doc/LinkedIn/MemberData/Snapshot/Page.md +43 -0
  22. data/doc/LinkedIn/MemberData/Snapshot.md +90 -0
  23. data/doc/LinkedIn/MemberData/Unauthorized.md +8 -0
  24. data/doc/LinkedIn/MemberData/VersionError.md +8 -0
  25. data/doc/LinkedIn/MemberData.md +44 -0
  26. data/doc/LinkedIn.md +7 -0
  27. data/doc/README.md +186 -0
  28. data/exe/linkedin-member-data +6 -0
  29. data/lib/linkedin/member_data/authorization.rb +30 -0
  30. data/lib/linkedin/member_data/changelog/page.rb +28 -0
  31. data/lib/linkedin/member_data/changelog.rb +146 -0
  32. data/lib/linkedin/member_data/cli/changelog_command.rb +27 -0
  33. data/lib/linkedin/member_data/cli/command.rb +59 -0
  34. data/lib/linkedin/member_data/cli/global_options.rb +27 -0
  35. data/lib/linkedin/member_data/cli/simple_commands.rb +48 -0
  36. data/lib/linkedin/member_data/cli/since_parser.rb +36 -0
  37. data/lib/linkedin/member_data/cli/snapshot_command.rb +85 -0
  38. data/lib/linkedin/member_data/cli/support.rb +89 -0
  39. data/lib/linkedin/member_data/cli.rb +115 -0
  40. data/lib/linkedin/member_data/client.rb +107 -0
  41. data/lib/linkedin/member_data/connection.rb +165 -0
  42. data/lib/linkedin/member_data/domains.rb +43 -0
  43. data/lib/linkedin/member_data/errors.rb +154 -0
  44. data/lib/linkedin/member_data/event.rb +101 -0
  45. data/lib/linkedin/member_data/snapshot/page.rb +53 -0
  46. data/lib/linkedin/member_data/snapshot.rb +114 -0
  47. data/lib/linkedin/member_data/util.rb +52 -0
  48. data/lib/linkedin/member_data/version.rb +16 -0
  49. data/lib/linkedin/member_data.rb +12 -0
  50. data/llms.txt +44 -0
  51. metadata +95 -0
@@ -0,0 +1,90 @@
1
+ # Class LinkedIn::MemberData::Event <a id="class-LinkedIn-MemberData-Event"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | Data |
6
+ | **Defined in** | lib/linkedin/member_data/event.rb |
7
+
8
+ One Member Changelog event. Immutable. Times are UTC. `activity` and friends
9
+ stay raw. Fields missing in the API response are `nil`.
10
+
11
+ `method` is the API field (`CREATE`, `UPDATE`, ...). It shadows Ruby's
12
+ `Object#method` on purpose, so `event.method(:name)` does not work on an
13
+ Event.
14
+
15
+ ## Attributes
16
+ ### `activity` [R] <a id="attribute-i-activity"></a> <a id="activity-instance_method"></a>
17
+ - **@return** [Hash, nil] raw activity payload.
18
+
19
+ ### `activity_id` [R] <a id="attribute-i-activity_id"></a> <a id="activity_id-instance_method"></a>
20
+ - **@return** [String, nil] value as sent by the API.
21
+
22
+ ### `activity_status` [R] <a id="attribute-i-activity_status"></a> <a id="activity_status-instance_method"></a>
23
+ - **@return** [String, nil] value as sent by the API.
24
+
25
+ ### `actor` [R] <a id="attribute-i-actor"></a> <a id="actor-instance_method"></a>
26
+ - **@return** [String, nil] who did the action, as sent by the API.
27
+
28
+ ### `captured_at` [R] <a id="attribute-i-captured_at"></a> <a id="captured_at-instance_method"></a>
29
+ - **@return** [Time, nil] when LinkedIn captured the event. UTC.
30
+
31
+ ### `config_version` [R] <a id="attribute-i-config_version"></a> <a id="config_version-instance_method"></a>
32
+ - **@return** [Object, nil] value as sent by the API.
33
+
34
+ ### `id` [R] <a id="attribute-i-id"></a> <a id="id-instance_method"></a>
35
+ - **@return** [String, nil] event id. Used to skip events seen on the previous page.
36
+
37
+ ### `method` [R] <a id="attribute-i-method"></a> <a id="method-instance_method"></a>
38
+ - **@return** [String, nil] API method, for example `CREATE` or `UPDATE`.
39
+
40
+ ### `method_name` [R] <a id="attribute-i-method_name"></a> <a id="method_name-instance_method"></a>
41
+ - **@return** [String, nil] value as sent by the API.
42
+
43
+ ### `owner` [R] <a id="attribute-i-owner"></a> <a id="owner-instance_method"></a>
44
+ - **@return** [String, nil] owner of the event, as sent by the API.
45
+
46
+ ### `parent_sibling_activities` [R] <a id="attribute-i-parent_sibling_activities"></a> <a id="parent_sibling_activities-instance_method"></a>
47
+ - **@return** [Array<Hash>, nil] raw parent sibling activities.
48
+
49
+ ### `processed_activity` [R] <a id="attribute-i-processed_activity"></a> <a id="processed_activity-instance_method"></a>
50
+ - **@return** [Hash, nil] raw processed activity payload.
51
+
52
+ ### `processed_at` [R] <a id="attribute-i-processed_at"></a> <a id="processed_at-instance_method"></a>
53
+ - **@return** [Time, nil] when LinkedIn processed the event. UTC. Used as the cursor.
54
+
55
+ ### `raw` [R] <a id="attribute-i-raw"></a> <a id="raw-instance_method"></a>
56
+ - **@return** [Hash] the original event Hash from the API.
57
+
58
+ ### `resource_id` [R] <a id="attribute-i-resource_id"></a> <a id="resource_id-instance_method"></a>
59
+ - **@return** [String, nil] value as sent by the API.
60
+
61
+ ### `resource_name` [R] <a id="attribute-i-resource_name"></a> <a id="resource_name-instance_method"></a>
62
+ - **@return** [String, nil] kind of resource that changed.
63
+
64
+ ### `resource_uri` [R] <a id="attribute-i-resource_uri"></a> <a id="resource_uri-instance_method"></a>
65
+ - **@return** [String, nil] value as sent by the API.
66
+
67
+ ### `sibling_activities` [R] <a id="attribute-i-sibling_activities"></a> <a id="sibling_activities-instance_method"></a>
68
+ - **@return** [Array<Hash>, nil] raw sibling activities.
69
+
70
+ ## Public Class Methods
71
+ ### `from_api(hash)` <a id="method-c-from_api"></a> <a id="from_api-class_method"></a>
72
+ Builds an event from one element of the API response.
73
+ - **@param** `hash` [Hash] one element of `elements` from the changelog response.
74
+ - **@return** [Event]
75
+
76
+ **@example**
77
+ ```ruby
78
+ event = LinkedIn::MemberData::Event.from_api(
79
+ "id" => "1", "method" => "CREATE", "processedAt" => 1_788_000_000_000
80
+ )
81
+ event.method # => "CREATE"
82
+ event.processed_at # => 2026-08-29 10:40:00 UTC
83
+ event.processed_at_ms # => 1788000000000
84
+ ```
85
+
86
+ ## Public Instance Methods
87
+ ### `processed_at_ms()` <a id="method-i-processed_at_ms"></a> <a id="processed_at_ms-instance_method"></a>
88
+ Raw epoch milliseconds, used as the next `startTime` cursor. Pass it as
89
+ <code>since:</code> to `Client#changelog` to resume.
90
+ - **@return** [Integer, nil] `nil` when the event has no `processedAt`.
@@ -0,0 +1,8 @@
1
+ # Class LinkedIn::MemberData::Forbidden <a id="class-LinkedIn-MemberData-Forbidden"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [LinkedIn::MemberData::ApiError](ApiError.md) |
6
+ | **Defined in** | lib/linkedin/member_data/errors.rb |
7
+
8
+ HTTP 403. The token is not allowed to read this data.
@@ -0,0 +1,8 @@
1
+ # Class LinkedIn::MemberData::NotFound <a id="class-LinkedIn-MemberData-NotFound"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [LinkedIn::MemberData::ApiError](ApiError.md) |
6
+ | **Defined in** | lib/linkedin/member_data/errors.rb |
7
+
8
+ HTTP 404. For snapshots it can mean "No data found for this memberId".
@@ -0,0 +1,25 @@
1
+ # Class LinkedIn::MemberData::RateLimited <a id="class-LinkedIn-MemberData-RateLimited"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [LinkedIn::MemberData::ApiError](ApiError.md) |
6
+ | **Defined in** | lib/linkedin/member_data/errors.rb |
7
+
8
+ HTTP 429. Retryable.
9
+
10
+ ## Attributes
11
+ ### `retry_after` [R] <a id="attribute-i-retry_after"></a> <a id="retry_after-instance_method"></a>
12
+ Seconds to wait, from the <code>Retry-After</code> header.
13
+ - **@return** [Integer, nil] `nil` when the header is absent, is not a positive number, or is an HTTP date.
14
+
15
+ ## Public Instance Methods
16
+ ### `initialize(message, status:, code: = nil, body: = nil, retry_after: = nil)` <a id="method-i-initialize"></a> <a id="initialize-instance_method"></a>
17
+ - **@param** `message` [String] error message.
18
+ - **@param** `status` [Integer] HTTP status code.
19
+ - **@param** `code` [Integer, String, nil] error code from the body.
20
+ - **@param** `body` [Hash, nil] parsed response body.
21
+ - **@param** `retry_after` [Integer, nil] seconds from the `Retry-After` header.
22
+ - **@return** [RateLimited] a new instance of RateLimited
23
+
24
+ ### `retryable?()` <a id="method-i-retryable-3F"></a> <a id="retryable?-instance_method"></a>
25
+ - **@return** [true] a rate limit is worth a retry.
@@ -0,0 +1,12 @@
1
+ # Class LinkedIn::MemberData::ServerError <a id="class-LinkedIn-MemberData-ServerError"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [LinkedIn::MemberData::ApiError](ApiError.md) |
6
+ | **Defined in** | lib/linkedin/member_data/errors.rb |
7
+
8
+ HTTP 5xx. Retryable.
9
+
10
+ ## Public Instance Methods
11
+ ### `retryable?()` <a id="method-i-retryable-3F"></a> <a id="retryable?-instance_method"></a>
12
+ - **@return** [true] server errors are worth a retry.
@@ -0,0 +1,43 @@
1
+ # Class LinkedIn::MemberData::Snapshot::Page <a id="class-LinkedIn-MemberData-Snapshot-Page"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | Data |
6
+ | **Defined in** | lib/linkedin/member_data/snapshot/page.rb |
7
+
8
+ One response page of snapshot data. Immutable.
9
+
10
+ ## Attributes
11
+ ### `count` [R] <a id="attribute-i-count"></a> <a id="count-instance_method"></a>
12
+ - **@return** [Integer, nil] page size from the `paging` object.
13
+
14
+ ### `domain` [R] <a id="attribute-i-domain"></a> <a id="domain-instance_method"></a>
15
+ - **@return** [String, nil] `snapshotDomain` of the first element. An all-domains call may return several.
16
+
17
+ ### `has_next` [R] <a id="attribute-i-has_next"></a> <a id="has_next-instance_method"></a>
18
+ - **@return** [Boolean] true when `paging.links` has a link with rel `next`.
19
+
20
+ ### `raw` [R] <a id="attribute-i-raw"></a> <a id="raw-instance_method"></a>
21
+ - **@return** [Hash] the parsed response body.
22
+
23
+ ### `rows` [R] <a id="attribute-i-rows"></a> <a id="rows-instance_method"></a>
24
+ - **@return** [Array<Hash{String => Object}>] rows of every element on this page. Keys differ per domain.
25
+
26
+ ### `start` [R] <a id="attribute-i-start"></a> <a id="start-instance_method"></a>
27
+ - **@return** [Integer, nil] page index from the `paging` object.
28
+
29
+ ### `total` [R] <a id="attribute-i-total"></a> <a id="total-instance_method"></a>
30
+ - **@return** [Integer, nil] total number of items from the `paging` object.
31
+
32
+ ## Public Class Methods
33
+ ### `from_api(raw)` <a id="method-c-from_api"></a> <a id="from_api-class_method"></a>
34
+ Builds a page from a parsed response body. Missing keys give empty or nil
35
+ values.
36
+ - **@param** `raw` [Hash] parsed JSON body of the API response.
37
+ - **@return** [Snapshot::Page]
38
+
39
+ ## Public Instance Methods
40
+ ### `next?()` <a id="method-i-next-3F"></a> <a id="next?-instance_method"></a>
41
+ Tells if another page may follow. Same value as <code>#has_next</code>. The
42
+ last page can still carry a `next` link, so an empty next page is possible.
43
+ - **@return** [Boolean]
@@ -0,0 +1,90 @@
1
+ # Class LinkedIn::MemberData::Snapshot <a id="class-LinkedIn-MemberData-Snapshot"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | Object |
6
+ | **Includes** | Enumerable |
7
+ | **Defined in** | lib/linkedin/member_data/snapshot.rb, lib/linkedin/member_data/snapshot/page.rb |
8
+
9
+ Lazy view over `GET /rest/memberSnapshotData` for one domain (or all). Nothing
10
+ is fetched until you iterate. Each iteration starts a new walk from the first
11
+ page.
12
+
13
+ Pages are indexed by `start` (0, 1, 2, ...). The API says to keep going until
14
+ it answers "No data found for this memberId", so that error ends iteration
15
+ instead of raising. A page with no rows also ends iteration.
16
+
17
+ It includes `Enumerable`, so `first`, `to_a`, `lazy`, `select` and the rest
18
+ work on rows. `first` fetches one page only.
19
+
20
+ **@example Walk all rows of one domain**
21
+ ```ruby
22
+ snapshot = client.snapshot(:connections)
23
+ snapshot.each { |row| puts row["First Name"] }
24
+ snapshot.first # fetches one page
25
+ snapshot.lazy.select { |row| row["Company"] }.first(5)
26
+ ```
27
+
28
+ ## Constants
29
+ ### `NO_DATA_MESSAGE` <a id="constant-NO_DATA_MESSAGE"></a> <a id="NO_DATA_MESSAGE-constant"></a>
30
+ Text of the API error that means "no more data". It ends iteration.
31
+ - **@return** [String]
32
+
33
+ ### `PATH` <a id="constant-PATH"></a> <a id="PATH-constant"></a>
34
+ API path of the snapshot resource.
35
+ - **@return** [String]
36
+
37
+ ## Attributes
38
+ ### `domain` [R] <a id="attribute-i-domain"></a> <a id="domain-instance_method"></a>
39
+ The domain name sent to the API. `nil` means all domains.
40
+ - **@return** [String, nil]
41
+
42
+ ## Public Instance Methods
43
+ ### `each(&block)` <a id="method-i-each"></a> <a id="each-instance_method"></a>
44
+ Yields every row of every page. Pages are fetched one by one while you
45
+ iterate. Rows are plain Hashes with the keys LinkedIn returns. Keys differ per
46
+ domain.
47
+ - **@raise** [ApiError] on a non-2xx response, except the "No data found" answer.
48
+ - **@raise** [ConnectionError] on a network failure after all retries.
49
+ - **@return** [Snapshot] self, when a block is given.
50
+ - **@return** [Enumerator<Hash>] when no block is given.
51
+ - **@yieldparam** `row` [Hash{String => Object}] one row of snapshot data.
52
+
53
+ **@example Block form**
54
+ ```ruby
55
+ client.snapshot(:connections).each { |row| puts row["First Name"] }
56
+ ```
57
+
58
+ **@example Without a block, you get an Enumerator**
59
+ ```ruby
60
+ client.snapshot(:connections).each.first(3)
61
+ ```
62
+
63
+ ### `page(start)` <a id="method-i-page"></a> <a id="page-instance_method"></a>
64
+ Fetches one page by index. Does not walk. Does not hide the "No data found"
65
+ error.
66
+ - **@param** `start` [Integer] zero-based page index.
67
+ - **@raise** [NotFound] past the end of the data.
68
+ - **@raise** [ApiError] on any other non-2xx response.
69
+ - **@raise** [ConnectionError] on a network failure after all retries.
70
+ - **@return** [Snapshot::Page]
71
+
72
+ **@example**
73
+ ```ruby
74
+ client.snapshot(:profile).page(0).rows
75
+ ```
76
+
77
+ ### `pages()` <a id="method-i-pages"></a> <a id="pages-instance_method"></a>
78
+ Lazy Enumerator of pages. The walk stops at the first page without a `next`
79
+ link. It also stops at an empty page or at the "No data found" answer. Any
80
+ other API error raises.
81
+ - **@raise** [ApiError] while iterating, on a non-2xx response except "No data found".
82
+ - **@raise** [ConnectionError] while iterating, on a network failure after all retries.
83
+ - **@return** [Enumerator<Snapshot::Page>]
84
+
85
+ **@example Inspect paging data**
86
+ ```ruby
87
+ client.snapshot(:profile).pages.each do |page|
88
+ puts "#{page.domain}: #{page.rows.size} rows, total #{page.total}"
89
+ end
90
+ ```
@@ -0,0 +1,8 @@
1
+ # Class LinkedIn::MemberData::Unauthorized <a id="class-LinkedIn-MemberData-Unauthorized"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [LinkedIn::MemberData::ApiError](ApiError.md) |
6
+ | **Defined in** | lib/linkedin/member_data/errors.rb |
7
+
8
+ HTTP 401. The token is invalid or expired.
@@ -0,0 +1,8 @@
1
+ # Class LinkedIn::MemberData::VersionError <a id="class-LinkedIn-MemberData-VersionError"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [LinkedIn::MemberData::ApiError](ApiError.md) |
6
+ | **Defined in** | lib/linkedin/member_data/errors.rb |
7
+
8
+ HTTP 426. The API version sent by the gem is not supported.
@@ -0,0 +1,44 @@
1
+ # Module LinkedIn::MemberData <a id="module-LinkedIn-MemberData"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Defined in** | lib/linkedin/member_data/version.rb, lib/linkedin/member_data/cli.rb, lib/linkedin/member_data/util.rb, lib/linkedin/member_data/event.rb, lib/linkedin/member_data/client.rb, lib/linkedin/member_data/errors.rb, lib/linkedin/member_data/domains.rb, lib/linkedin/member_data/snapshot.rb, lib/linkedin/member_data/changelog.rb, lib/linkedin/member_data/connection.rb, lib/linkedin/member_data/cli/command.rb, lib/linkedin/member_data/cli/support.rb, lib/linkedin/member_data/authorization.rb, lib/linkedin/member_data/snapshot/page.rb, lib/linkedin/member_data/changelog/page.rb, lib/linkedin/member_data/cli/since_parser.rb, lib/linkedin/member_data/cli/global_options.rb, lib/linkedin/member_data/cli/simple_commands.rb, lib/linkedin/member_data/cli/snapshot_command.rb, lib/linkedin/member_data/cli/changelog_command.rb |
6
+
7
+ Ruby client and CLI for the LinkedIn Member Data Portability (Member) API.
8
+ Start with `Client`.
9
+
10
+ **@example**
11
+ ```ruby
12
+ client = LinkedIn::MemberData::Client.new(access_token: ENV["LINKEDIN_ACCESS_TOKEN"])
13
+ client.snapshot(:connections).each { |row| puts row["First Name"] }
14
+ ```
15
+
16
+ ## Constants
17
+ ### `EVENT_KEYS` <a id="constant-EVENT_KEYS"></a> <a id="EVENT_KEYS-constant"></a>
18
+ Data member name => JSON key. Time fields are handled separately.
19
+ - **@api** private
20
+
21
+ ### `VERSION` <a id="constant-VERSION"></a> <a id="VERSION-constant"></a>
22
+ Gem version.
23
+ - **@return** [String]
24
+
25
+ # Documentation
26
+
27
+ - [MemberData/ApiError.md](MemberData/ApiError.md)
28
+ - [MemberData/Authorization.md](MemberData/Authorization.md)
29
+ - [MemberData/Changelog/Page.md](MemberData/Changelog/Page.md)
30
+ - [MemberData/Changelog.md](MemberData/Changelog.md)
31
+ - [MemberData/Client.md](MemberData/Client.md)
32
+ - [MemberData/ConfigurationError.md](MemberData/ConfigurationError.md)
33
+ - [MemberData/ConnectionError.md](MemberData/ConnectionError.md)
34
+ - [MemberData/Domains.md](MemberData/Domains.md)
35
+ - [MemberData/Error.md](MemberData/Error.md)
36
+ - [MemberData/Event.md](MemberData/Event.md)
37
+ - [MemberData/Forbidden.md](MemberData/Forbidden.md)
38
+ - [MemberData/NotFound.md](MemberData/NotFound.md)
39
+ - [MemberData/RateLimited.md](MemberData/RateLimited.md)
40
+ - [MemberData/ServerError.md](MemberData/ServerError.md)
41
+ - [MemberData/Snapshot/Page.md](MemberData/Snapshot/Page.md)
42
+ - [MemberData/Snapshot.md](MemberData/Snapshot.md)
43
+ - [MemberData/Unauthorized.md](MemberData/Unauthorized.md)
44
+ - [MemberData/VersionError.md](MemberData/VersionError.md)
data/doc/LinkedIn.md ADDED
@@ -0,0 +1,7 @@
1
+ # Module LinkedIn <a id="module-LinkedIn"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Defined in** | lib/linkedin/member_data/version.rb, lib/linkedin/member_data/cli.rb, lib/linkedin/member_data/util.rb, lib/linkedin/member_data/event.rb, lib/linkedin/member_data/client.rb, lib/linkedin/member_data/errors.rb, lib/linkedin/member_data/domains.rb, lib/linkedin/member_data/snapshot.rb, lib/linkedin/member_data/changelog.rb, lib/linkedin/member_data/connection.rb, lib/linkedin/member_data/cli/command.rb, lib/linkedin/member_data/cli/support.rb, lib/linkedin/member_data/authorization.rb, lib/linkedin/member_data/snapshot/page.rb, lib/linkedin/member_data/changelog/page.rb, lib/linkedin/member_data/cli/since_parser.rb, lib/linkedin/member_data/cli/global_options.rb, lib/linkedin/member_data/cli/simple_commands.rb, lib/linkedin/member_data/cli/snapshot_command.rb, lib/linkedin/member_data/cli/changelog_command.rb |
6
+
7
+ Namespace of the LinkedIn gems.
data/doc/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # linkedin-member-data
2
+
3
+ Ruby client and CLI for the LinkedIn [Member Data Portability (Member) API](https://learn.microsoft.com/en-us/linkedin/dma/member-data-portability/member-data-portability-member/). Download your own LinkedIn data: 66 snapshot domains (profile, connections, messages, posts, ...) and the changelog of your activity from the last 28 days.
4
+
5
+ Zero runtime dependencies. Ruby 3.2+.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bundle add linkedin-member-data
11
+ ```
12
+
13
+ Or install the CLI only:
14
+
15
+ ```bash
16
+ gem install linkedin-member-data
17
+ ```
18
+
19
+ ## Getting a token
20
+
21
+ 1. Create an app in the [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps/) using the [Member Data Portability (Member) Default Company](https://www.linkedin.com/company/member-data-portability-member-default-company) page.
22
+ 2. Under Products, request access to **Member Data Portability API (Member)**.
23
+ 3. Open **Docs and tools > OAuth Token Tools**, create a token with scope `r_dma_portability_self_serve`, and consent.
24
+
25
+ Tokens last 60 days. Only EEA and Swiss members can consent today.
26
+
27
+ ## Quick start
28
+
29
+ ```ruby
30
+ require "linkedin/member_data"
31
+
32
+ client = LinkedIn::MemberData::Client.new(access_token: ENV["LINKEDIN_ACCESS_TOKEN"])
33
+
34
+ client.snapshot(:connections).each do |row|
35
+ puts row["First Name"], row["Company"]
36
+ end
37
+
38
+ client.changelog(since: Time.now - 7 * 86_400).each do |event|
39
+ puts "#{event.method} #{event.resource_name} at #{event.processed_at}"
40
+ end
41
+ ```
42
+
43
+ Row keys differ per domain. These are CONNECTIONS keys.
44
+
45
+ ## Snapshot
46
+
47
+ ```ruby
48
+ snap = client.snapshot(:profile) # symbol is upcased: "PROFILE"
49
+ snap = client.snapshot("ALL_COMMENTS") # strings are sent as given
50
+ snap = client.snapshot # all domains
51
+
52
+ snap.first # first row, fetches one page
53
+ snap.to_a # every row, walks all pages
54
+ snap.lazy.select { |row| row["Company"] }.first(5) # Enumerable, so anything goes
55
+
56
+ snap.pages.each do |page|
57
+ page.domain; page.rows; page.start; page.count; page.total; page.next?; page.raw
58
+ end
59
+
60
+ snap.page(0) # one page by index, no walking. Raises past the end.
61
+
62
+ LinkedIn::MemberData::Domains::ALL # list of domain names
63
+ ```
64
+
65
+ Rows are plain Hashes with the keys LinkedIn returns, for example `"First Name"`. Keys differ per domain.
66
+
67
+ Pages are walked until LinkedIn answers "No data found for this memberId". That answer ends iteration and is not raised. A page with no rows also ends iteration.
68
+
69
+ ## Changelog
70
+
71
+ ```ruby
72
+ log = client.changelog(since: Date.new(2026, 9, 1), count: 10) # since: Time, Date or epoch ms; count: 1..50
73
+
74
+ log.each do |event|
75
+ event.id; event.method; event.resource_name; event.resource_id
76
+ event.captured_at; event.processed_at # Time (UTC)
77
+ event.activity; event.processed_activity # Hash
78
+ event.raw # the original Hash
79
+ end
80
+
81
+ log.pages.each { |page| page.events; page.next_start_time }
82
+ ```
83
+
84
+ The API returns the cursor event again on the next page. The gem skips events it has already seen. Iteration stops when a page has no new events, or when the last event has no `processedAt`.
85
+
86
+ `event.method` is the API field (`CREATE`, `UPDATE`, ...). It shadows Ruby's `Object#method` on purpose, so `event.method(:name)` does not work on an Event.
87
+
88
+ `count` defaults to 10. A value outside 1..50 raises `ArgumentError` before any request.
89
+
90
+ Event fields: `id, activity_id, activity_status, config_version, owner, actor, resource_name, resource_id, resource_uri, method, method_name, captured_at, processed_at, activity, processed_activity, sibling_activities, parent_sibling_activities, raw`.
91
+
92
+ Known limit: when more than `count` events share one `processedAt`, the cursor cannot reach the rest. Raise `count` (max 50) to reduce the chance.
93
+
94
+ To resume later, store the last `event.processed_at_ms` and pass it as `since:`.
95
+
96
+ ```ruby
97
+ last_seen = client.changelog.to_a.last&.processed_at_ms
98
+ client.changelog(since: last_seen).each { |event| puts event.id }
99
+ ```
100
+
101
+ ## Authorization
102
+
103
+ ```ruby
104
+ client.authorization # => Authorization or nil
105
+ client.authorization.regulated_at
106
+ client.enable_changelog! # start changelog archiving (usually automatic)
107
+ ```
108
+
109
+ `Authorization` also has `member`, `developer_application`, `scopes` and `raw`.
110
+
111
+ ## Options
112
+
113
+ ```ruby
114
+ require "logger"
115
+
116
+ LinkedIn::MemberData::Client.new(
117
+ access_token: "...",
118
+ retries: 3, # retries on 429, 5xx and network errors, with backoff; 0 disables
119
+ timeout: 30, # seconds
120
+ logger: Logger.new($stderr) # logs "GET url -> status" at debug level
121
+ )
122
+ ```
123
+
124
+ Retries wait for `Retry-After` when it is present, capped at 60 seconds. Otherwise the wait grows with each try (exponential backoff).
125
+
126
+ ## Errors
127
+
128
+ ```
129
+ LinkedIn::MemberData::Error
130
+ ConfigurationError # missing token
131
+ ConnectionError # network failure after retries
132
+ ApiError # status, code, body
133
+ Unauthorized Forbidden NotFound VersionError RateLimited ServerError
134
+ ```
135
+
136
+ `ConnectionError` covers timeouts, reset connections, DNS and TLS failures. `retryable?` is true for `RateLimited`, `ServerError` and `ConnectionError`. `ApiError#code` comes from `serviceErrorCode` or `code` in the response body. `RateLimited#retry_after` is nil when the header is absent.
137
+
138
+ ```ruby
139
+ begin
140
+ client.snapshot(:profile).to_a
141
+ rescue LinkedIn::MemberData::Unauthorized
142
+ warn "Token is invalid or expired"
143
+ rescue LinkedIn::MemberData::ApiError => error
144
+ warn "#{error.status}: #{error.message}"
145
+ end
146
+ ```
147
+
148
+ ## CLI
149
+
150
+ ```bash
151
+ export LINKEDIN_ACCESS_TOKEN=...
152
+
153
+ linkedin-member-data snapshot CONNECTIONS --out connections.json
154
+ linkedin-member-data snapshot --all --out-dir ./export
155
+ linkedin-member-data changelog --since 2026-09-01 --count 50 --out changelog.json
156
+ linkedin-member-data domains
157
+ linkedin-member-data auth
158
+ linkedin-member-data version
159
+ ```
160
+
161
+ Use `-h` or `--help` to print usage. The DOMAIN argument is upcased, so `snapshot connections` works. The token comes from `--token TOKEN` (before or after the command) or `LINKEDIN_ACCESS_TOKEN`. Data goes to stdout or `--out FILE`. Progress and errors go to stderr. `--since` takes an ISO date (midnight UTC) or an ISO datetime.
162
+
163
+ `snapshot --all` writes one `<DOMAIN>.json` per domain. Without `--out-dir DIR` it is a usage error (exit 2). A failing domain is reported and the run continues. The exit code is 1 at the end. Unauthorized and Forbidden stop the run, because a bad token fails every domain.
164
+
165
+ `auth` exits 1 with "No authorization found for this token" when none exists.
166
+
167
+ Exit codes: 0 success, 1 API error, network error or file write error, 2 usage error.
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ bin/setup
173
+ bundle exec rake # tests + rubocop
174
+ bundle exec rake branchproof # MC/DC coverage (Ruby 4.0+)
175
+ bundle exec rake quality # quality_gate fast, verify, audit
176
+ bundle exec rake docs # YARD Markdown docs in doc/ and llms.txt
177
+ ruby bin/prepare_release # full gate, then builds pkg/<gem>.gem
178
+ ```
179
+
180
+ ## For AI agents
181
+
182
+ The gem ships its API reference as Markdown. Start at `llms.txt` in the gem root, which links every page under `doc/`. The same files are inside the installed gem (`gem contents linkedin-member-data | grep doc/`).
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "linkedin/member_data/cli"
5
+
6
+ exit LinkedIn::MemberData::CLI.new(ARGV).run
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkedIn
4
+ module MemberData
5
+ # The member's authorization record from `memberAuthorizations`. Immutable.
6
+ #
7
+ # @!attribute [r] member
8
+ # @return [String, nil] member, as sent by the API.
9
+ # @!attribute [r] developer_application
10
+ # @return [String, nil] developer application, as sent by the API.
11
+ # @!attribute [r] regulated_at
12
+ # @return [Time, nil] when the member gave consent. UTC.
13
+ # @!attribute [r] scopes
14
+ # @return [Array<String>] granted scopes. Empty when the API sends none.
15
+ # @!attribute [r] raw
16
+ # @return [Hash] the original element from the API.
17
+ Authorization = Data.define(:member, :developer_application, :regulated_at, :scopes, :raw) do
18
+ # Builds an authorization from one element of the API response.
19
+ #
20
+ # @param hash [Hash] one element of `elements` from the `memberAuthorizations` response.
21
+ # @return [Authorization]
22
+ def self.from_api(hash)
23
+ key = hash.fetch("memberComplianceAuthorizationKey", {})
24
+ new(member: key["member"], developer_application: key["developerApplication"],
25
+ regulated_at: Util.time_from_ms(hash["regulatedAt"]),
26
+ scopes: hash.fetch("memberComplianceScopes", []), raw: hash)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkedIn
4
+ module MemberData
5
+ class Changelog
6
+ # One response page of changelog events. Immutable.
7
+ #
8
+ # @!attribute [r] events
9
+ # @return [Array<Event>] events of this page, in API order (oldest first).
10
+ # @!attribute [r] raw
11
+ # @return [Hash] the parsed response body.
12
+ Page = Data.define(:events, :raw) do
13
+ # Builds a page from a parsed response body. A missing `elements` key gives no events.
14
+ #
15
+ # @param raw [Hash] parsed JSON body of the API response.
16
+ # @return [Changelog::Page]
17
+ def self.from_api(raw)
18
+ new(events: raw.fetch("elements", []).map { |element| Event.from_api(element) }, raw: raw)
19
+ end
20
+
21
+ # Cursor for the next request: `processedAt` of the last event, in epoch milliseconds.
22
+ #
23
+ # @return [Integer, nil] `nil` when the page has no events or the last event has no `processedAt`.
24
+ def next_start_time = events.last&.processed_at_ms
25
+ end
26
+ end
27
+ end
28
+ end