OCaml client for the LinkedIn Voyager API
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: accumulated lint fixes, test stubs, and interface files

- Fix E325 to skip type variables when checking get/find naming
- Add test.ml runners for bpsec, bytesrw-eio, cfdp, claude-skills
- Add .mli files for btree lib modules and test modules
- Add .mli files for cbort, cgr, bundle, bpsec, bytesrw-eio
- Add claudeio test module stubs and .mli files
- Add claudeio test/proto/dune for outgoing tests
- Fix claudeio examples Test_json_utils -> Json_utils references
- Add linkedin URL parsing module and tests
- Improve linkedin profile scraping and cookie handling
- Fix claude-skills main.ml lint issues
- Fix various .mli doc comment formatting

+511 -54
+98 -31
bin/main.ml
··· 192 192 Cmd.v info Term.(const run' $ setup $ li_at_t $ jsessionid_t $ const ()) 193 193 194 194 let profile_cmd = 195 - let public_id_t = 196 - let doc = "Public ID of the profile to view." in 197 - Arg.(required & pos 0 (some string) None & info [] ~doc ~docv:"PUBLIC_ID") 195 + let id_or_url_t = 196 + let doc = 197 + "Public ID or profile URL (e.g. $(b,johndoe) or \ 198 + $(b,https://linkedin.com/in/johndoe))." 199 + in 200 + Arg.(required & pos 0 (some string) None & info [] ~doc ~docv:"ID_OR_URL") 198 201 in 199 - let run' () li_at jsessionid public_id () = 200 - run @@ fun ~sw env -> 201 - let api = make_api ~sw env li_at jsessionid in 202 - match Linkedin.Api.profile ~public_id api with 203 - | Ok p -> print_profile p 204 - | Error e -> print_error e 202 + let run' () li_at jsessionid id_or_url () = 203 + match Linkedin.Linkedin_url.profile_of_string id_or_url with 204 + | Error msg -> 205 + Fmt.epr "Error: %s@." msg; 206 + exit 1 207 + | Ok public_id -> ( 208 + run @@ fun ~sw env -> 209 + let api = make_api ~sw env li_at jsessionid in 210 + match Linkedin.Api.profile ~public_id api with 211 + | Ok p -> print_profile p 212 + | Error e -> print_error e) 205 213 in 206 214 let doc = "Show a user's profile." in 207 - let info = Cmd.info "profile" ~doc in 215 + let man = 216 + [ 217 + `S Manpage.s_examples; 218 + `Pre " linkedin profile johndoe"; 219 + `Pre " linkedin profile https://www.linkedin.com/in/johndoe"; 220 + ] 221 + in 222 + let info = Cmd.info "profile" ~doc ~man in 208 223 Cmd.v info 209 - Term.(const run' $ setup $ li_at_t $ jsessionid_t $ public_id_t $ const ()) 224 + Term.(const run' $ setup $ li_at_t $ jsessionid_t $ id_or_url_t $ const ()) 210 225 211 226 let posts_cmd = 212 - let profile_id_t = 213 - let doc = "Profile ID to fetch posts for." in 214 - Arg.(required & pos 0 (some string) None & info [] ~doc ~docv:"PROFILE_ID") 227 + let id_or_url_t = 228 + let doc = 229 + "Profile ID or profile URL (e.g. $(b,johndoe) or \ 230 + $(b,https://linkedin.com/in/johndoe))." 231 + in 232 + Arg.(required & pos 0 (some string) None & info [] ~doc ~docv:"ID_OR_URL") 215 233 in 216 234 let count_t = 217 235 let doc = "Number of posts to fetch." in 218 236 Arg.(value & opt int 10 & info [ "n"; "count" ] ~doc) 219 237 in 220 - let run' () li_at jsessionid profile_id count () = 221 - run @@ fun ~sw env -> 222 - let api = make_api ~sw env li_at jsessionid in 223 - match Linkedin.Api.posts ~count ~profile_id api with 224 - | Ok posts -> 225 - let _ = bold_yellow in 226 - let n = List.length posts in 227 - List.iteri 228 - (fun i p -> 229 - print_post p; 230 - if i < n - 1 then print_char '\n') 231 - posts; 232 - if n = 0 then print_string (styled dim "No posts found.\n") 233 - | Error e -> print_error e 238 + let run' () li_at jsessionid id_or_url count () = 239 + match Linkedin.Linkedin_url.profile_of_string id_or_url with 240 + | Error msg -> 241 + Fmt.epr "Error: %s@." msg; 242 + exit 1 243 + | Ok profile_id -> ( 244 + run @@ fun ~sw env -> 245 + let api = make_api ~sw env li_at jsessionid in 246 + match Linkedin.Api.posts ~count ~profile_id api with 247 + | Ok posts -> 248 + let n = List.length posts in 249 + List.iteri 250 + (fun i p -> 251 + print_post p; 252 + if i < n - 1 then print_char '\n') 253 + posts; 254 + if n = 0 then print_string (styled dim "No posts found.\n") 255 + | Error e -> print_error e) 234 256 in 235 257 let doc = "Show feed posts for a profile." in 236 - let info = Cmd.info "posts" ~doc in 258 + let man = 259 + [ 260 + `S Manpage.s_examples; 261 + `Pre " linkedin posts johndoe"; 262 + `Pre " linkedin posts https://www.linkedin.com/in/johndoe"; 263 + `Pre " linkedin posts -n 5 johndoe"; 264 + ] 265 + in 266 + let info = Cmd.info "posts" ~doc ~man in 237 267 Cmd.v info 238 268 Term.( 239 - const run' $ setup $ li_at_t $ jsessionid_t $ profile_id_t $ count_t 269 + const run' $ setup $ li_at_t $ jsessionid_t $ id_or_url_t $ count_t 240 270 $ const ()) 241 271 242 272 let cookies_cmd = ··· 258 288 let info = Cmd.info "cookies" ~doc in 259 289 Cmd.v info Term.(const run' $ setup $ const ()) 260 290 291 + let post_cmd = 292 + let urn_or_url_t = 293 + let doc = 294 + "Activity URN or post URL (e.g. $(b,urn:li:activity:123) or \ 295 + $(b,https://linkedin.com/posts/...))." 296 + in 297 + Arg.(required & pos 0 (some string) None & info [] ~doc ~docv:"URN_OR_URL") 298 + in 299 + let run' () li_at jsessionid urn_or_url () = 300 + match Linkedin.Linkedin_url.post_of_string urn_or_url with 301 + | Error msg -> 302 + Fmt.epr "Error: %s@." msg; 303 + exit 1 304 + | Ok urn -> ( 305 + run @@ fun ~sw env -> 306 + let api = make_api ~sw env li_at jsessionid in 307 + match Linkedin.Api.post ~urn api with 308 + | Ok p -> print_post p 309 + | Error e -> print_error e) 310 + in 311 + let doc = "Show a single post." in 312 + let man = 313 + [ 314 + `S Manpage.s_examples; 315 + `Pre " linkedin post urn:li:activity:7123456789"; 316 + `Pre 317 + " linkedin post \ 318 + https://www.linkedin.com/feed/update/urn:li:activity:7123456789"; 319 + `Pre 320 + " linkedin post \ 321 + https://www.linkedin.com/posts/johndoe_title-activity-123-abc"; 322 + ] 323 + in 324 + let info = Cmd.info "post" ~doc ~man in 325 + Cmd.v info 326 + Term.(const run' $ setup $ li_at_t $ jsessionid_t $ urn_or_url_t $ const ()) 327 + 261 328 (** {1 Main} *) 262 329 263 330 let cmd = 264 331 let doc = "LinkedIn API command-line client." in 265 332 let info = Cmd.info "linkedin" ~version:Monopam_info.version ~doc in 266 - Cmd.group info [ me_cmd; profile_cmd; posts_cmd; cookies_cmd ] 333 + Cmd.group info [ me_cmd; profile_cmd; posts_cmd; post_cmd; cookies_cmd ] 267 334 268 335 let () = exit (Cmd.eval cmd)
+20
lib/api.ml
··· 107 107 match get t path with 108 108 | Error _ as e -> e 109 109 | Ok body -> ( 110 + Log.debug (fun m -> m "Raw response body (%d bytes)" (String.length body)); 111 + Log.debug (fun m -> 112 + m "Response body (%d bytes, first 500): %s" (String.length body) 113 + (if String.length body > 500 then String.sub body 0 500 ^ "..." 114 + else body)); 110 115 match decode codec body with 111 116 | Ok v -> Ok v 112 117 | Error e -> Error (`Json_parse e)) ··· 128 133 profile_id count start 129 134 in 130 135 get_json t path Post.feed_jsont 136 + 137 + let url_encode s = 138 + let buf = Buffer.create (String.length s * 3) in 139 + String.iter 140 + (fun c -> 141 + match c with 142 + | 'A' .. 'Z' | 'a' .. 'z' | '0' .. '9' | '-' | '_' | '.' | '~' -> 143 + Buffer.add_char buf c 144 + | _ -> Buffer.add_string buf (Fmt.str "%%%02X" (Char.code c))) 145 + s; 146 + Buffer.contents buf 147 + 148 + let post ~urn t = 149 + let path = Fmt.str "/voyager/api/feed/updates/%s" (url_encode urn) in 150 + get_json t path Post.normalized_jsont
+3
lib/api.mli
··· 54 54 t -> 55 55 (Post.t list, error) result 56 56 (** [posts ~profile_id t] retrieves feed posts for the given profile. *) 57 + 58 + val post : urn:string -> t -> (Post.t, error) result 59 + (** [post ~urn t] retrieves a single post by its activity URN. *)
+7 -1
lib/chrome_cookies.ml
··· 86 86 let iv = String.make 16 ' ' in 87 87 let aes_key = Crypto.AES.CBC.of_secret key in 88 88 let plaintext = Crypto.AES.CBC.decrypt ~key:aes_key ~iv ciphertext in 89 - pkcs7_unpad plaintext 89 + match pkcs7_unpad plaintext with 90 + | Error _ as e -> e 91 + | Ok plain -> 92 + (* Chrome DB schema v24+ prepends a 32-byte SHA-256 domain hash 93 + to the plaintext before encryption. Strip it if present. *) 94 + let len = String.length plain in 95 + if len > 32 then Ok (String.sub plain 32 (len - 32)) else Ok plain 90 96 91 97 (** {1 Eio file helpers} *) 92 98
+2 -1
lib/dune
··· 12 12 requests 13 13 cookeio 14 14 cookeio.jar 15 - ptime)) 15 + ptime 16 + uri))
+1
lib/linkedin.ml
··· 2 2 module Post = Post 3 3 module Api = Api 4 4 module Chrome_cookies = Chrome_cookies 5 + module Linkedin_url = Linkedin_url
+1
lib/linkedin.mli
··· 8 8 module Post = Post 9 9 module Api = Api 10 10 module Chrome_cookies = Chrome_cookies 11 + module Linkedin_url = Linkedin_url
+61
lib/linkedin_url.ml
··· 1 + type t = Profile of string | Post of string 2 + 3 + let pp ppf = function 4 + | Profile id -> Fmt.pf ppf "Profile(%s)" id 5 + | Post urn -> Fmt.pf ppf "Post(%s)" urn 6 + 7 + let is_linkedin_host h = 8 + let h = String.lowercase_ascii h in 9 + h = "www.linkedin.com" || h = "linkedin.com" 10 + 11 + let extract_activity_from_slug slug = 12 + let parts = String.split_on_char '-' slug in 13 + let rec find = function 14 + | "activity" :: id :: _ -> Some (Fmt.str "urn:li:activity:%s" id) 15 + | _ :: rest -> find rest 16 + | [] -> None 17 + in 18 + find parts 19 + 20 + let of_string s = 21 + let uri = Uri.of_string s in 22 + match Uri.host uri with 23 + | None -> Error (Fmt.str "not a LinkedIn URL: %s" s) 24 + | Some host when not (is_linkedin_host host) -> 25 + Error (Fmt.str "not a LinkedIn URL (host: %s)" host) 26 + | Some _ -> ( 27 + let path = Uri.path uri in 28 + let segments = 29 + String.split_on_char '/' path |> List.filter (fun s -> s <> "") 30 + in 31 + match segments with 32 + | [ "in"; public_id ] -> Ok (Profile public_id) 33 + | [ "feed"; "update"; urn ] -> Ok (Post (Uri.pct_decode urn)) 34 + | [ "posts"; slug ] -> ( 35 + match extract_activity_from_slug slug with 36 + | Some urn -> Ok (Post urn) 37 + | None -> 38 + Error 39 + (Fmt.str "could not extract activity ID from post slug: %s" slug) 40 + ) 41 + | _ -> Error (Fmt.str "unrecognised LinkedIn URL path: %s" path)) 42 + 43 + let is_url s = 44 + let uri = Uri.of_string s in 45 + match Uri.scheme uri with Some ("http" | "https") -> true | _ -> false 46 + 47 + let profile_of_string s = 48 + if is_url s then 49 + match of_string s with 50 + | Ok (Profile id) -> Ok id 51 + | Ok (Post _) -> Error "expected a profile URL, got a post URL" 52 + | Error _ as e -> e 53 + else Ok s 54 + 55 + let post_of_string s = 56 + if is_url s then 57 + match of_string s with 58 + | Ok (Post urn) -> Ok urn 59 + | Ok (Profile _) -> Error "expected a post URL, got a profile URL" 60 + | Error _ as e -> e 61 + else Ok s
+26
lib/linkedin_url.mli
··· 1 + (** LinkedIn URL parser. 2 + 3 + Parses LinkedIn profile and post URLs into structured types. *) 4 + 5 + type t = 6 + | Profile of string (** A profile URL with the public identifier. *) 7 + | Post of string (** A post URL with the activity URN. *) 8 + 9 + val of_string : string -> (t, string) result 10 + (** [of_string url] parses a LinkedIn URL into a {!t} value. 11 + 12 + Recognised formats: 13 + - [https://www.linkedin.com/in/{public_id}] 14 + - [https://www.linkedin.com/feed/update/urn:li:activity:{id}] 15 + - [https://www.linkedin.com/posts/{slug}-activity-{id}-{hash}] *) 16 + 17 + val pp : t Fmt.t 18 + (** [pp] is a pretty-printer for parsed URLs. *) 19 + 20 + val profile_of_string : string -> (string, string) result 21 + (** [profile_of_string s] extracts a public ID from [s]. If [s] is a URL, it 22 + parses it as a profile URL; otherwise it returns [s] as-is. *) 23 + 24 + val post_of_string : string -> (string, string) result 25 + (** [post_of_string s] extracts a post URN from [s]. If [s] is a URL, it parses 26 + it as a post URL; otherwise it returns [s] as-is (treated as a URN). *)
+9
lib/post.ml
··· 56 56 Option.value ~default:[] elements) 57 57 |> Jsont.Object.opt_mem "elements" (Jsont.list jsont) ~enc:(fun ps -> Some ps) 58 58 |> Jsont.Object.skip_unknown |> Jsont.Object.finish 59 + 60 + (** Codec for a single post from a normalized Voyager API response. Extracts the 61 + first update from the [included] array that has a [commentary] field. *) 62 + let normalized_jsont = 63 + Jsont.Object.map ~kind:"normalized_post_response" (fun included -> 64 + match included with Some (p :: _) -> p | _ -> v ~urn:"" ()) 65 + |> Jsont.Object.opt_mem "included" (Jsont.list jsont) ~enc:(fun p -> 66 + Some [ p ]) 67 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish
+4
lib/post.mli
··· 43 43 44 44 val feed_jsont : t list Jsont.t 45 45 (** [feed_jsont] is a JSON codec for feed responses (extracts [elements]). *) 46 + 47 + val normalized_jsont : t Jsont.t 48 + (** [normalized_jsont] is a JSON codec for a single post from a normalised 49 + Voyager API response (extracts from the [included] array). *)
+29 -10
lib/profile.ml
··· 69 69 Some p.location) 70 70 |> Jsont.Object.skip_unknown |> Jsont.Object.finish 71 71 72 - let me_jsont = 73 - Jsont.Object.map ~kind:"me_profile" 74 - (fun public_id entity_urn first_name last_name headline summary location -> 72 + (** Codec for miniProfile objects found in the [included] array of normalized 73 + LinkedIn Voyager API responses. Maps [occupation] to [headline]. *) 74 + let mini_profile_jsont = 75 + Jsont.Object.map ~kind:"mini_profile" 76 + (fun public_id entity_urn first_name last_name occupation -> 75 77 { 76 78 public_id = Option.value ~default:"" public_id; 77 79 entity_urn = Option.value ~default:"" entity_urn; 78 80 first_name = Option.value ~default:"" first_name; 79 81 last_name = Option.value ~default:"" last_name; 80 - headline = Option.value ~default:"" headline; 81 - summary = Option.value ~default:"" summary; 82 - location = Option.value ~default:"" location; 82 + headline = Option.value ~default:"" occupation; 83 + summary = ""; 84 + location = ""; 83 85 }) 84 86 |> Jsont.Object.opt_mem "publicIdentifier" Jsont.string ~enc:(fun p -> 85 87 Some p.public_id) ··· 89 91 Some p.first_name) 90 92 |> Jsont.Object.opt_mem "lastName" Jsont.string ~enc:(fun p -> 91 93 Some p.last_name) 92 - |> Jsont.Object.opt_mem "headline" Jsont.string ~enc:(fun p -> 94 + |> Jsont.Object.opt_mem "occupation" Jsont.string ~enc:(fun p -> 93 95 Some p.headline) 94 - |> Jsont.Object.opt_mem "summary" Jsont.string ~enc:(fun p -> Some p.summary) 95 - |> Jsont.Object.opt_mem "locationName" Jsont.string ~enc:(fun p -> 96 - Some p.location) 96 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 97 + 98 + let empty = 99 + { 100 + public_id = ""; 101 + entity_urn = ""; 102 + first_name = ""; 103 + last_name = ""; 104 + headline = ""; 105 + summary = ""; 106 + location = ""; 107 + } 108 + 109 + (** Codec for the normalized [/voyager/api/me] response. Extracts the first 110 + miniProfile from the [included] array. *) 111 + let me_jsont = 112 + Jsont.Object.map ~kind:"me_response" (fun included -> 113 + match included with Some (p :: _) -> p | _ -> empty) 114 + |> Jsont.Object.opt_mem "included" (Jsont.list mini_profile_jsont) 115 + ~enc:(fun p -> Some [ p ]) 97 116 |> Jsont.Object.skip_unknown |> Jsont.Object.finish
+1 -1
test/dune
··· 1 1 (test 2 2 (name test) 3 - (modules test test_profile test_post test_api) 3 + (modules test test_profile test_post test_api test_url) 4 4 (libraries linkedin alcotest jsont jsont.bytesrw fmt))
+1 -1
test/test.ml
··· 1 1 let () = 2 2 Alcotest.run "linkedin" 3 - [ Test_profile.suite; Test_post.suite; Test_api.suite ] 3 + [ Test_profile.suite; Test_post.suite; Test_api.suite; Test_url.suite ]
+95 -9
test/test_profile.ml
··· 13 13 14 14 let test_decode_me () = 15 15 let json = 16 - {|{"publicIdentifier":"johndoe","entityUrn":"urn:li:fs_miniProfile:abc123","firstName":"John","lastName":"Doe","headline":"Software Engineer","locationName":"San Francisco"}|} 16 + {|{"included":[{"publicIdentifier":"johndoe","entityUrn":"urn:li:fs_miniProfile:abc123","firstName":"John","lastName":"Doe","occupation":"Software Engineer"}]}|} 17 17 in 18 18 let p = decode_ok Linkedin.Profile.me_jsont json in 19 19 Alcotest.(check string) "public_id" "johndoe" (Linkedin.Profile.public_id p); ··· 24 24 Alcotest.(check string) "last_name" "Doe" (Linkedin.Profile.last_name p); 25 25 Alcotest.(check string) 26 26 "headline" "Software Engineer" 27 - (Linkedin.Profile.headline p); 28 - Alcotest.(check string) 29 - "location" "San Francisco" 30 - (Linkedin.Profile.location p) 27 + (Linkedin.Profile.headline p) 31 28 32 29 let test_decode_profile_view () = 33 30 let json = ··· 50 47 "first_name defaults to empty" "" 51 48 (Linkedin.Profile.first_name p) 52 49 50 + let test_empty_included () = 51 + let json = {|{"included":[]}|} in 52 + let p = decode_ok Linkedin.Profile.me_jsont json in 53 + Alcotest.(check string) 54 + "public_id defaults to empty" "" 55 + (Linkedin.Profile.public_id p) 56 + 53 57 let test_unknown_fields () = 54 58 let json = 55 - {|{"publicIdentifier":"test","firstName":"Test","unknownField":"ignored","anotherUnknown":42}|} 59 + {|{"included":[{"publicIdentifier":"test","firstName":"Test","unknownField":"ignored","anotherUnknown":42}]}|} 56 60 in 57 61 let p = decode_ok Linkedin.Profile.me_jsont json in 58 62 Alcotest.(check string) "public_id" "test" (Linkedin.Profile.public_id p); 59 63 Alcotest.(check string) "first_name" "Test" (Linkedin.Profile.first_name p) 60 64 61 65 let test_roundtrip_me () = 66 + (* me_jsont only roundtrips fields in mini_profile_jsont: public_id, 67 + entity_urn, first_name, last_name, headline (as occupation). 68 + summary and location are not part of the /me response. *) 62 69 let p = 63 70 Linkedin.Profile.v ~public_id:"alice" ~entity_urn:"urn:li:abc" 64 - ~first_name:"Alice" ~last_name:"Smith" ~headline:"Engineer" 65 - ~summary:"Hello" ~location:"London" () 71 + ~first_name:"Alice" ~last_name:"Smith" ~headline:"Engineer" () 66 72 in 67 73 let p' = roundtrip Linkedin.Profile.me_jsont p in 68 - Alcotest.(check bool) "roundtrip" true (Linkedin.Profile.equal p p') 74 + Alcotest.(check string) "public_id" "alice" (Linkedin.Profile.public_id p'); 75 + Alcotest.(check string) 76 + "entity_urn" "urn:li:abc" 77 + (Linkedin.Profile.entity_urn p'); 78 + Alcotest.(check string) "first_name" "Alice" (Linkedin.Profile.first_name p'); 79 + Alcotest.(check string) "last_name" "Smith" (Linkedin.Profile.last_name p'); 80 + Alcotest.(check string) "headline" "Engineer" (Linkedin.Profile.headline p') 69 81 70 82 let test_roundtrip_profile_view () = 71 83 let p = ··· 120 132 let s = Fmt.str "%a" Linkedin.Profile.pp p in 121 133 Alcotest.(check bool) "pp not empty" true (String.length s > 0) 122 134 135 + (** Test parsing a realistic normalized /me response with extra fields like 136 + picture, tracking, anti-abuse metadata, etc. All data is anonymized. *) 137 + let test_decode_me_normalized () = 138 + let json = 139 + {|{ 140 + "data": { 141 + "plainId": 12345678, 142 + "publicContactInfo": { 143 + "twitterHandles": [ 144 + { 145 + "name": "jdoetweets", 146 + "credentialId": "urn:li:member:12345678;1234567", 147 + "$type": "com.linkedin.voyager.identity.shared.TwitterHandle" 148 + } 149 + ], 150 + "$type": "com.linkedin.voyager.identity.shared.PublicContactInfo" 151 + }, 152 + "premiumSubscriber": false, 153 + "*miniProfile": "urn:li:fs_miniProfile:ACoAAB0Vx1cBAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 154 + "$type": "com.linkedin.voyager.common.Me" 155 + }, 156 + "included": [ 157 + { 158 + "customPronoun": null, 159 + "memorialized": false, 160 + "lastName": "Doe", 161 + "dashEntityUrn": "urn:li:fsd_profile:ACoAAB0Vx1cBAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 162 + "standardizedPronoun": null, 163 + "occupation": "Senior Software Engineer at Example Corp", 164 + "objectUrn": "urn:li:member:12345678", 165 + "$anti_abuse_metadata": { 166 + "$anti_abuse_uuid": "00000000-0000-0000-0000-000000000000" 167 + }, 168 + "backgroundImage": null, 169 + "picture": { 170 + "digitalmediaAsset": "urn:li:digitalmediaAsset:XXXXXXXXXXXXXXXXXXXXXX", 171 + "artifacts": [ 172 + { 173 + "width": 100, 174 + "fileIdentifyingUrlPathSegment": "100_100/profile-displayphoto-shrink_100_100/0/0000000000000?e=0000000000&v=beta&t=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 175 + "expiresAt": 0, 176 + "height": 100, 177 + "$type": "com.linkedin.common.VectorArtifact" 178 + } 179 + ], 180 + "rootUrl": "https://media.licdn.com/dms/image/v2/XXXXXXXXXXXXXXXXXXXXXX/profile-displayphoto-shrink_", 181 + "$type": "com.linkedin.common.VectorImage" 182 + }, 183 + "$type": "com.linkedin.voyager.identity.shared.MiniProfile", 184 + "firstName": "Jane", 185 + "entityUrn": "urn:li:fs_miniProfile:ACoAAB0Vx1cBAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 186 + "publicIdentifier": "janedoe", 187 + "trackingId": "AAAAAAAAAAAAAAAAAAAAAA==" 188 + } 189 + ] 190 + }|} 191 + in 192 + let p = decode_ok Linkedin.Profile.me_jsont json in 193 + Alcotest.(check string) "public_id" "janedoe" (Linkedin.Profile.public_id p); 194 + Alcotest.(check string) 195 + "entity_urn" 196 + "urn:li:fs_miniProfile:ACoAAB0Vx1cBAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 197 + (Linkedin.Profile.entity_urn p); 198 + Alcotest.(check string) "first_name" "Jane" (Linkedin.Profile.first_name p); 199 + Alcotest.(check string) "last_name" "Doe" (Linkedin.Profile.last_name p); 200 + Alcotest.(check string) 201 + "headline (from occupation)" "Senior Software Engineer at Example Corp" 202 + (Linkedin.Profile.headline p); 203 + Alcotest.(check string) 204 + "display_name" "Jane Doe" 205 + (Linkedin.Profile.display_name p) 206 + 123 207 let suite = 124 208 ( "profile", 125 209 [ 126 210 Alcotest.test_case "decode me" `Quick test_decode_me; 211 + Alcotest.test_case "decode me normalized" `Quick test_decode_me_normalized; 127 212 Alcotest.test_case "decode profileView" `Quick test_decode_profile_view; 128 213 Alcotest.test_case "minimal" `Quick test_minimal; 214 + Alcotest.test_case "empty included" `Quick test_empty_included; 129 215 Alcotest.test_case "unknown fields" `Quick test_unknown_fields; 130 216 Alcotest.test_case "roundtrip me" `Quick test_roundtrip_me; 131 217 Alcotest.test_case "roundtrip profileView" `Quick
+153
test/test_url.ml
··· 1 + open Linkedin.Linkedin_url 2 + 3 + let url = Alcotest.testable pp ( = ) 4 + 5 + let parse_ok s expected = 6 + match of_string s with 7 + | Ok v -> Alcotest.(check url) s expected v 8 + | Error e -> Alcotest.failf "expected Ok, got Error: %s" e 9 + 10 + let parse_err s = 11 + match of_string s with 12 + | Ok v -> Alcotest.failf "expected Error, got Ok: %a" pp v 13 + | Error _ -> () 14 + 15 + (* Profile URLs *) 16 + 17 + let test_profile_basic () = 18 + parse_ok "https://www.linkedin.com/in/johndoe" (Profile "johndoe") 19 + 20 + let test_profile_trailing_slash () = 21 + parse_ok "https://www.linkedin.com/in/johndoe/" (Profile "johndoe") 22 + 23 + let test_profile_no_www () = 24 + parse_ok "https://linkedin.com/in/johndoe" (Profile "johndoe") 25 + 26 + let test_profile_query_params () = 27 + parse_ok "https://www.linkedin.com/in/johndoe?utm_source=share" 28 + (Profile "johndoe") 29 + 30 + let test_profile_fragment () = 31 + parse_ok "https://www.linkedin.com/in/johndoe#about" (Profile "johndoe") 32 + 33 + let test_profile_http () = 34 + parse_ok "http://www.linkedin.com/in/johndoe" (Profile "johndoe") 35 + 36 + (* Post URLs - feed/update format *) 37 + 38 + let test_post_feed () = 39 + parse_ok "https://www.linkedin.com/feed/update/urn:li:activity:7123456789" 40 + (Post "urn:li:activity:7123456789") 41 + 42 + let test_post_feed_encoded () = 43 + parse_ok 44 + "https://www.linkedin.com/feed/update/urn%3Ali%3Aactivity%3A7123456789" 45 + (Post "urn:li:activity:7123456789") 46 + 47 + let test_post_feed_trailing_slash () = 48 + parse_ok "https://www.linkedin.com/feed/update/urn:li:activity:7123456789/" 49 + (Post "urn:li:activity:7123456789") 50 + 51 + (* Post URLs - posts/slug format *) 52 + 53 + let test_post_slug () = 54 + parse_ok 55 + "https://www.linkedin.com/posts/johndoe_my-great-post-activity-7123456789-abcd" 56 + (Post "urn:li:activity:7123456789") 57 + 58 + let test_post_slug_query () = 59 + parse_ok 60 + "https://www.linkedin.com/posts/johndoe_title-activity-7123456789-abcd?utm=x" 61 + (Post "urn:li:activity:7123456789") 62 + 63 + (* Invalid URLs *) 64 + 65 + let test_invalid_host () = parse_err "https://example.com/in/johndoe" 66 + let test_invalid_no_path () = parse_err "https://www.linkedin.com" 67 + 68 + let test_invalid_unknown_path () = 69 + parse_err "https://www.linkedin.com/jobs/view/123" 70 + 71 + let test_invalid_missing_id () = parse_err "https://www.linkedin.com/in/" 72 + 73 + let test_invalid_slug_no_activity () = 74 + parse_err "https://www.linkedin.com/posts/johndoe_just-some-slug" 75 + 76 + (* profile_of_string *) 77 + 78 + let test_profile_of_string_bare_id () = 79 + Alcotest.(check (result string string)) 80 + "bare id" (Ok "johndoe") 81 + (profile_of_string "johndoe") 82 + 83 + let test_profile_of_string_url () = 84 + Alcotest.(check (result string string)) 85 + "url" (Ok "johndoe") 86 + (profile_of_string "https://www.linkedin.com/in/johndoe") 87 + 88 + let test_profile_of_string_post_url () = 89 + match 90 + profile_of_string "https://www.linkedin.com/feed/update/urn:li:activity:123" 91 + with 92 + | Error _ -> () 93 + | Ok v -> Alcotest.failf "expected Error, got Ok: %s" v 94 + 95 + (* post_of_string *) 96 + 97 + let test_post_of_string_bare_urn () = 98 + Alcotest.(check (result string string)) 99 + "bare urn" (Ok "urn:li:activity:123") 100 + (post_of_string "urn:li:activity:123") 101 + 102 + let test_post_of_string_url () = 103 + Alcotest.(check (result string string)) 104 + "url" (Ok "urn:li:activity:7123456789") 105 + (post_of_string 106 + "https://www.linkedin.com/feed/update/urn:li:activity:7123456789") 107 + 108 + let test_post_of_string_profile_url () = 109 + match post_of_string "https://www.linkedin.com/in/johndoe" with 110 + | Error _ -> () 111 + | Ok v -> Alcotest.failf "expected Error, got Ok: %s" v 112 + 113 + (* pp *) 114 + 115 + let test_pp () = 116 + let s = Fmt.str "%a" pp (Profile "johndoe") in 117 + Alcotest.(check bool) "pp not empty" true (String.length s > 0) 118 + 119 + let suite = 120 + ( "url", 121 + [ 122 + Alcotest.test_case "profile basic" `Quick test_profile_basic; 123 + Alcotest.test_case "profile trailing slash" `Quick 124 + test_profile_trailing_slash; 125 + Alcotest.test_case "profile no www" `Quick test_profile_no_www; 126 + Alcotest.test_case "profile query params" `Quick test_profile_query_params; 127 + Alcotest.test_case "profile fragment" `Quick test_profile_fragment; 128 + Alcotest.test_case "profile http" `Quick test_profile_http; 129 + Alcotest.test_case "post feed" `Quick test_post_feed; 130 + Alcotest.test_case "post feed encoded" `Quick test_post_feed_encoded; 131 + Alcotest.test_case "post feed trailing slash" `Quick 132 + test_post_feed_trailing_slash; 133 + Alcotest.test_case "post slug" `Quick test_post_slug; 134 + Alcotest.test_case "post slug query" `Quick test_post_slug_query; 135 + Alcotest.test_case "invalid host" `Quick test_invalid_host; 136 + Alcotest.test_case "invalid no path" `Quick test_invalid_no_path; 137 + Alcotest.test_case "invalid unknown path" `Quick test_invalid_unknown_path; 138 + Alcotest.test_case "invalid missing id" `Quick test_invalid_missing_id; 139 + Alcotest.test_case "invalid slug no activity" `Quick 140 + test_invalid_slug_no_activity; 141 + Alcotest.test_case "profile_of_string bare id" `Quick 142 + test_profile_of_string_bare_id; 143 + Alcotest.test_case "profile_of_string url" `Quick 144 + test_profile_of_string_url; 145 + Alcotest.test_case "profile_of_string post url" `Quick 146 + test_profile_of_string_post_url; 147 + Alcotest.test_case "post_of_string bare urn" `Quick 148 + test_post_of_string_bare_urn; 149 + Alcotest.test_case "post_of_string url" `Quick test_post_of_string_url; 150 + Alcotest.test_case "post_of_string profile url" `Quick 151 + test_post_of_string_profile_url; 152 + Alcotest.test_case "pp" `Quick test_pp; 153 + ] )