Created
April 3, 2026 00:53
-
-
Save capripot/8fd0c771c60361788a0b08d0e4760f05 to your computer and use it in GitHub Desktop.
Export Connected Recognition (Workhuman)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| require "csv" | |
| require "fileutils" | |
| require "httpx" | |
| require "json" | |
| require "optparse" | |
| require "pathname" | |
| require "securerandom" | |
| require "sqlite3" | |
| require "time" | |
| require "tmpdir" | |
| require "uri" | |
| WORKHUMAN_APP_URL = "https://cloud.workhuman.com/static-apps/wh-host/#/my-activity?tab=feed" | |
| WORKHUMAN_APP_REFERER = "https://cloud.workhuman.com/static-apps/wh-host/" | |
| AUTH_URL = "https://cloud.workhuman.com/microsites/login/userSessionAuthToken" | |
| FEED_URL = "https://cloud.workhuman.com/api/recognition/home-page-service/v1/newsfeed" | |
| USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0" | |
| SESSIONSTORE_FILES = %w[recovery.jsonlz4 recovery.baklz4 previous.jsonlz4].freeze | |
| CLIENT_COOKIE_NAMES = %w[client cf_client].freeze | |
| DEFAULT_OUTPUT = Pathname("tmp/workhuman_my_activity_feed.csv").freeze | |
| FEED_LIMIT = 20 | |
| class ExportError < StandardError; end | |
| class WorkhumanHttpxClient | |
| def initialize | |
| @client = HTTPX.with(timeout: { connect_timeout: 10, operation_timeout: 30 }) | |
| end | |
| def get_json(url, headers:) | |
| response = @client.with(headers: headers).get(url) | |
| [parse_json_body(response.body), response.status] | |
| rescue HTTPX::Error => e | |
| raise ExportError, "HTTP request failed: #{e.message}" | |
| end | |
| private | |
| def parse_json_body(body) | |
| return {} if body.to_s.strip.empty? | |
| JSON.parse(body) | |
| rescue JSON::ParserError => e | |
| raise ExportError, "Unexpected JSON response: #{e.message}" | |
| end | |
| end | |
| class WorkhumanMyActivityExporter | |
| attr_reader :client, :output_path, :person_id, :profile_dir, :verbose | |
| def initialize(options) | |
| @profile_dir = options[:profile_dir] || detect_firefox_profile | |
| @output_path = options[:output] | |
| @client = options[:client] | |
| @person_id = options[:person_id] | |
| @verbose = options[:verbose] | |
| end | |
| def call | |
| session_data = load_latest_sessionstore | |
| workhuman_context = extract_workhuman_context(session_data) | |
| cookies = collect_cookies(session_data) | |
| @client ||= workhuman_context["client"] || cookies["client"] | |
| raise ExportError, "Could not determine the Workhuman client slug." unless client | |
| auth = fetch_auth(cookies) | |
| resolved_person_id = person_id || auth["userId"] || workhuman_context["userId"] | |
| raise ExportError, "Could not determine the Workhuman person ID." unless resolved_person_id | |
| items = fetch_feed_items( | |
| client_id: auth.fetch("clientId").to_s, | |
| cookies: cookies, | |
| person_id: resolved_person_id, | |
| ) | |
| write_csv(items) | |
| end | |
| private | |
| def detect_firefox_profile | |
| candidates = [ | |
| Pathname("~/Library/Application Support/Firefox/profiles.ini").expand_path, | |
| Pathname("~/.mozilla/firefox/profiles.ini").expand_path, | |
| ] | |
| profiles_ini = candidates.find(&:exist?) | |
| raise ExportError, "Could not find Firefox profiles.ini." unless profiles_ini | |
| sections = parse_profiles_ini(profiles_ini) | |
| profile_sections = sections.select { |section| section[:name].start_with?("Profile") } | |
| install_sections = sections.select { |section| section[:name].start_with?("Install") } | |
| preferred_profiles = install_sections.filter_map do |section| | |
| default_path = section[:values]["Default"] | |
| next unless default_path | |
| resolve_profile_path(profiles_ini, "Path" => default_path, "IsRelative" => "1") | |
| end | |
| preferred_profiles.concat( | |
| profile_sections.filter_map do |section| | |
| next unless section[:values]["Default"] == "1" | |
| resolve_profile_path(profiles_ini, section[:values]) | |
| end, | |
| ) | |
| preferred_profiles.concat(profile_sections.filter_map do |section| | |
| resolve_profile_path(profiles_ini, section[:values]) | |
| end) | |
| chosen = preferred_profiles.uniq.select(&:exist?).max_by { |path| profile_rank(path) } | |
| raise ExportError, "Could not determine the active Firefox profile." unless chosen | |
| chosen | |
| end | |
| def load_latest_sessionstore | |
| available_files = sessionstore_candidates(profile_dir) | |
| raise ExportError, "No Firefox sessionstore files were found in #{profile_dir}." if available_files.empty? | |
| decoded = available_files.sort_by(&:mtime).reverse.filter_map do |path| | |
| JSON.parse(decode_mozlz4(path)) | |
| rescue JSON::ParserError, ExportError | |
| nil | |
| end | |
| raise ExportError, "Could not decode any Firefox sessionstore backup." if decoded.empty? | |
| decoded.max_by { |session_data| sessionstore_rank(session_data) } | |
| end | |
| def parse_profiles_ini(profiles_ini) | |
| sections = [] | |
| current_name = nil | |
| current_values = {} | |
| profiles_ini.read.each_line do |raw_line| | |
| line = raw_line.strip | |
| next if line.empty? || line.start_with?(";") | |
| if line.start_with?("[") && line.end_with?("]") | |
| sections << { name: current_name, values: current_values } if current_name | |
| current_name = line[1..-2] | |
| current_values = {} | |
| next | |
| end | |
| key, value = line.split("=", 2) | |
| next unless key && value | |
| current_values[key] = value | |
| end | |
| sections << { name: current_name, values: current_values } if current_name | |
| sections | |
| end | |
| def resolve_profile_path(profiles_ini, values) | |
| return unless values["Path"] | |
| profile_path = Pathname(values.fetch("Path")) | |
| profile_path = profiles_ini.dirname.join(profile_path) if values.fetch("IsRelative", "1") == "1" | |
| profile_path | |
| end | |
| def profile_rank(path) | |
| sessionstore_files = sessionstore_candidates(path) | |
| latest_sessionstore_mtime = sessionstore_files.map(&:mtime).max&.to_i || 0 | |
| [ | |
| sessionstore_files.empty? ? 0 : 1, | |
| path.join("cookies.sqlite").exist? ? 1 : 0, | |
| latest_sessionstore_mtime, | |
| ] | |
| end | |
| def sessionstore_candidates(base_profile_dir) | |
| backups_dir = base_profile_dir.join("sessionstore-backups") | |
| backup_files = if backups_dir.exist? | |
| SESSIONSTORE_FILES.filter_map do |filename| | |
| candidate = backups_dir.join(filename) | |
| candidate if candidate.exist? | |
| end | |
| else | |
| [] | |
| end | |
| root_sessionstore = base_profile_dir.join("sessionstore.jsonlz4") | |
| backup_files << root_sessionstore if root_sessionstore.exist? | |
| backup_files | |
| end | |
| def sessionstore_rank(session_data) | |
| workhuman_tab = find_latest_workhuman_tab(session_data) | |
| last_accessed = workhuman_tab&.fetch("lastAccessed", 0).to_i | |
| recent_count = session_data.fetch("windows", []).sum do |window| | |
| window.fetch("tabs", []).count { |tab| workhuman_tab?(tab) } | |
| end | |
| [last_accessed, recent_count] | |
| end | |
| def decode_mozlz4(path) | |
| payload = path.binread | |
| raise ExportError, "Unexpected sessionstore format: #{path}" unless payload.start_with?("mozLz40\0") | |
| target_size = payload.byteslice(8, 4).unpack1("L<") | |
| decoded = lz4_decompress_block(payload.byteslice(12..), target_size) | |
| decoded.force_encoding("UTF-8") | |
| end | |
| def lz4_decompress_block(data, target_size) | |
| output = (+"").b | |
| cursor = 0 | |
| while cursor < data.bytesize | |
| token = data.getbyte(cursor) | |
| cursor += 1 | |
| literal_length = token >> 4 | |
| literal_length, cursor = extend_lz4_length(data, cursor, literal_length) | |
| output << data.byteslice(cursor, literal_length) | |
| cursor += literal_length | |
| break if cursor >= data.bytesize | |
| offset = data.getbyte(cursor) | (data.getbyte(cursor + 1) << 8) | |
| cursor += 2 | |
| raise ExportError, "Encountered invalid LZ4 offset." if offset.zero? | |
| match_length = token & 0x0F | |
| match_length, cursor = extend_lz4_length(data, cursor, match_length) | |
| copy_match_bytes(output, offset, match_length + 4) | |
| end | |
| raise ExportError, "Decoded sessionstore length did not match expected size." unless output.bytesize == target_size | |
| output | |
| end | |
| def extend_lz4_length(data, cursor, length) | |
| return [length, cursor] unless length == 15 | |
| total = length | |
| loop do | |
| value = data.getbyte(cursor) | |
| cursor += 1 | |
| total += value | |
| break if value != 255 | |
| end | |
| [total, cursor] | |
| end | |
| def copy_match_bytes(output, offset, length) | |
| length.times do | |
| source_index = output.bytesize - offset | |
| raise ExportError, "Encountered invalid LZ4 back-reference." if source_index.negative? | |
| output << output.getbyte(source_index).chr | |
| end | |
| end | |
| def extract_workhuman_context(session_data) | |
| tab = find_latest_workhuman_tab(session_data) | |
| raise ExportError, missing_workhuman_tab_message unless tab | |
| storage = tab.fetch("storage", {}).fetch("https://cloud.workhuman.com", {}) | |
| local_storage = storage.fetch("local", {}) | |
| session_storage = storage.fetch("session", {}) | |
| context = {} | |
| [local_storage, session_storage].each do |storage_hash| | |
| storage_hash.each do |key, raw_value| | |
| next unless raw_value.is_a?(String) | |
| parsed = parse_json_maybe(raw_value) | |
| if parsed.is_a?(Hash) | |
| context["client"] ||= parsed["client"] || parsed["clientName"] || parsed["tenant"] | |
| context["userId"] ||= parsed["userId"] | |
| end | |
| context["client"] ||= raw_value if CLIENT_COOKIE_NAMES.include?(key.downcase) | |
| end | |
| end | |
| context["client"] ||= session_data.fetch("cookies", []).find do |cookie| | |
| cookie["host"] == "cloud.workhuman.com" && CLIENT_COOKIE_NAMES.include?(cookie["name"]) | |
| end&.fetch("value", nil) | |
| context | |
| end | |
| def missing_workhuman_tab_message | |
| "No Workhuman tab was found in Firefox session data. Open #{WORKHUMAN_APP_URL}, " \ | |
| "wait for the feed to load, and retry." | |
| end | |
| def find_latest_workhuman_tab(session_data) | |
| tabs = session_data.fetch("windows", []).flat_map { |window| window.fetch("tabs", []) } | |
| matching_tabs = tabs.select { |tab| workhuman_tab?(tab) } | |
| matching_tabs.max_by { |tab| tab.fetch("lastAccessed", 0).to_i } | |
| end | |
| def workhuman_tab?(tab) | |
| tab.fetch("entries", []).any? do |entry| | |
| entry.fetch("url", "").include?("cloud.workhuman.com/static-apps/wh-host") | |
| end | |
| end | |
| def parse_json_maybe(value) | |
| return value unless value.start_with?("{", "[") | |
| JSON.parse(value) | |
| rescue JSON::ParserError | |
| value | |
| end | |
| def collect_cookies(session_data) | |
| cookies = {} | |
| session_data.fetch("cookies", []).each do |cookie| | |
| next unless valid_cookie?(cookie) | |
| cookies[cookie.fetch("name")] = cookie.fetch("value") | |
| end | |
| persistent_cookies.each do |name, value| | |
| cookies[name] ||= value | |
| end | |
| cookies["cf_client"] ||= cookies["client"] | |
| missing_cookies = %w[JSESSIONID client].reject { |name| cookies.key?(name) } | |
| return cookies if missing_cookies.empty? | |
| raise ExportError, | |
| "Missing required Workhuman cookies: #{missing_cookies.join(", ")}. " \ | |
| "Open #{WORKHUMAN_APP_URL}, wait for the feed to load, and retry." | |
| end | |
| def valid_cookie?(cookie) | |
| %w[cloud.workhuman.com .cloud.workhuman.com idp.workhuman.com].include?(cookie["host"]) && | |
| !cookie["name"].to_s.empty? | |
| end | |
| def persistent_cookies | |
| cookies_db = profile_dir.join("cookies.sqlite") | |
| return {} unless cookies_db.exist? | |
| query = <<~SQL | |
| SELECT name, value | |
| FROM moz_cookies | |
| WHERE host IN ('cloud.workhuman.com', '.cloud.workhuman.com') | |
| SQL | |
| Dir.mktmpdir("workhuman-cookies") do |tmpdir| | |
| copied_db = copy_cookie_database(cookies_db, Pathname(tmpdir)) | |
| database = SQLite3::Database.new("file:#{copied_db}?mode=ro", readonly: true, uri: true) | |
| begin | |
| database.execute(query).each_with_object({}) do |(name, value), cookies| | |
| cookies[name] = value if name && value | |
| end | |
| ensure | |
| database.close | |
| end | |
| end | |
| rescue SQLite3::Exception => e | |
| warn_verbose("sqlite3 lookup failed, skipping persistent cookie lookup: #{e.message}") | |
| {} | |
| end | |
| def copy_cookie_database(cookies_db, tmpdir) | |
| copied_db = tmpdir.join("cookies.sqlite") | |
| FileUtils.cp(cookies_db, copied_db) | |
| %w[-wal -shm].each do |suffix| | |
| sidecar = Pathname("#{cookies_db}#{suffix}") | |
| FileUtils.cp(sidecar, tmpdir.join("cookies.sqlite#{suffix}")) if sidecar.exist? | |
| end | |
| copied_db | |
| end | |
| def fetch_auth(cookies) | |
| warn_verbose("Refreshing Workhuman auth token...") | |
| response, status = http_client.get_json( | |
| AUTH_URL, | |
| headers: [ | |
| "accept: application/json, text/plain, */*", | |
| "accept-language: en-US,en;q=0.9", | |
| "user-agent: #{USER_AGENT}", | |
| "cookie: #{cookie_header(cookies)}", | |
| ].each_with_object({}) do |header, result| | |
| key, value = header.split(": ", 2) | |
| result[key] = value | |
| end, | |
| ) | |
| raise ExportError, "Auth request failed with status #{status}." unless status == 200 | |
| return response if response["token"] && response["clientId"] | |
| raise ExportError, | |
| "Failed to refresh Workhuman auth. Open #{WORKHUMAN_APP_URL}, wait for the feed to load, and retry." | |
| end | |
| def fetch_feed_items(client_id:, cookies:, person_id:) | |
| items = [] | |
| seen_item_ids = {} | |
| auth = fetch_auth(cookies) | |
| page = 1 | |
| warn_verbose("Resolved client=#{client}, client_id=#{client_id}, person_id=#{person_id}") | |
| loop do | |
| warn_verbose("Fetching page #{page}...") | |
| response, status = fetch_feed_page( | |
| client_id: client_id, | |
| cookies: cookies, | |
| page: page, | |
| person_id: person_id, | |
| token: auth.fetch("token"), | |
| ) | |
| if status == 401 | |
| auth = fetch_auth(cookies) | |
| response, status = fetch_feed_page( | |
| client_id: client_id, | |
| cookies: cookies, | |
| page: page, | |
| person_id: person_id, | |
| token: auth.fetch("token"), | |
| ) | |
| end | |
| raise ExportError, "Feed request failed for page #{page} with status #{status}." unless status == 200 | |
| page_items = extract_page_items(response) | |
| has_more = response_has_more?(response) | |
| response_keys = response.keys.sort.join(",") | |
| warn_verbose("Page #{page} returned items=#{page_items.length}, has_more=#{has_more}, keys=#{response_keys}") | |
| break if page_items.empty? | |
| unique_page_items = page_items.reject do |item| | |
| next false unless item.is_a?(Hash) && item["id"] | |
| seen_item_ids.key?(item["id"]) | |
| end | |
| break if unique_page_items.empty? | |
| unique_page_items.each do |item| | |
| seen_item_ids[item["id"]] = true if item.is_a?(Hash) && item["id"] | |
| end | |
| items.concat(unique_page_items) | |
| continue_paging = has_more || unique_page_items.length >= FEED_LIMIT | |
| break unless continue_paging | |
| page += 1 | |
| raise ExportError, "Stopping after 500 pages to avoid an infinite loop." if page > 500 | |
| auth = fetch_auth(cookies) if token_expires_soon?(auth) | |
| end | |
| items | |
| end | |
| def fetch_feed_page(client_id:, cookies:, page:, person_id:, token:) | |
| query = URI.encode_www_form( | |
| itemType: "RECOGNITION_MOMENT", | |
| personId: person_id, | |
| page: page, | |
| limit: FEED_LIMIT, | |
| shownMomentsCount: 0, | |
| ) | |
| url = "#{FEED_URL}?#{query}" | |
| http_client.get_json( | |
| url, | |
| headers: { | |
| "accept" => "application/json", | |
| "accept-language" => "en-US,en;q=0.9", | |
| "authorization" => "Bearer #{token}", | |
| "cookie" => cookie_header(cookies), | |
| "wh-client-id" => client_id, | |
| "wh-api-consumer" => "newsfeedMfe|v1.0.0", | |
| "wh-interchange-id" => SecureRandom.uuid, | |
| "referer" => WORKHUMAN_APP_REFERER, | |
| "user-agent" => USER_AGENT, | |
| }, | |
| ) | |
| end | |
| def token_expires_soon?(auth) | |
| expires = auth["expires"] | |
| expires.is_a?(Integer) && expires <= ((Time.now.to_f * 1000).to_i + 5_000) | |
| end | |
| def extract_page_items(response) | |
| return response if response.is_a?(Array) | |
| return [] unless response.is_a?(Hash) | |
| return response["items"] if response["items"].is_a?(Array) | |
| return response["content"] if response["content"].is_a?(Array) | |
| data = response["data"] | |
| return data if data.is_a?(Array) | |
| return [] unless data.is_a?(Hash) | |
| return data["items"] if data["items"].is_a?(Array) | |
| return data["content"] if data["content"].is_a?(Array) | |
| [] | |
| end | |
| def response_has_more?(response) | |
| return false unless response.is_a?(Hash) | |
| return response["hasMore"] unless response["hasMore"].nil? | |
| data = response["data"] | |
| return false unless data.is_a?(Hash) | |
| return data["hasMore"] unless data["hasMore"].nil? | |
| false | |
| end | |
| def http_client | |
| @http_client ||= WorkhumanHttpxClient.new | |
| end | |
| def cookie_header(cookies) | |
| preferred_order = %w[JSESSIONID client cf_client retiree AWSALB AWSALBCORS PF].freeze | |
| ordered_names = preferred_order + (cookies.keys - preferred_order).sort | |
| ordered_names.filter_map do |name| | |
| next unless cookies.key?(name) | |
| "#{name}=#{cookies.fetch(name)}" | |
| end.join("; ") | |
| end | |
| def write_csv(items) | |
| sorted_items = items.sort_by do |item| | |
| [item.fetch("displayDateTime", "").to_s, item.fetch("id", "").to_s] | |
| end.reverse | |
| output_path.dirname.mkpath | |
| CSV.open(output_path, "w", row_sep: "\r\n") do |csv| | |
| csv << %w[date from to content amount_awarded link] | |
| sorted_items.each do |item| | |
| row = transform_item(item) | |
| csv << row.values_at("date", "from", "to", "content", "amount_awarded", "link") | |
| end | |
| end | |
| end | |
| def transform_item(item) | |
| recognitions = item.fetch("recognitions", []) | |
| recipients = recognitions.filter_map { |recognition| format_person(recognition["recipient"]) } | |
| amounts = recognitions.filter_map { |recognition| format_amount(recognition["level"]) } | |
| content_parts = [normalize_text(item["title"]), normalize_text(item["message"])].reject(&:empty?) | |
| content = content_parts.empty? ? nil : content_parts.join(" | ") | |
| amount_awarded = amounts.empty? ? nil : amounts.join(" | ") | |
| { | |
| "date" => item["displayDateTime"].to_s, | |
| "from" => format_person(item["nominator"]), | |
| "to" => recipients.join(" | "), | |
| "content" => content, | |
| "amount_awarded" => amount_awarded, | |
| "link" => recognition_link(item["id"]), | |
| } | |
| end | |
| def format_person(person) | |
| return unless person.is_a?(Hash) | |
| [normalize_text(person["firstName"]), normalize_text(person["lastName"])].reject(&:empty?).join(" ") | |
| end | |
| def format_amount(level) | |
| return unless level.is_a?(Hash) | |
| value = level["value"] | |
| currency = normalize_text(level["currency"]) | |
| return if value.nil? || currency.empty? | |
| "#{value} #{currency}" | |
| end | |
| def normalize_text(value) | |
| value.to_s.gsub(/\r\n?/, "\n").tr("\n\t", " ").strip | |
| end | |
| def recognition_link(item_id) | |
| return "" if item_id.to_s.empty? | |
| "https://cloud.workhuman.com/microsites/t/il/#{client}/recognition-moment/#{item_id}" | |
| end | |
| def warn_verbose(message) | |
| warn(message) if verbose | |
| end | |
| end | |
| def parse_options | |
| options = { | |
| output: DEFAULT_OUTPUT, | |
| verbose: false, | |
| } | |
| OptionParser.new do |parser| | |
| parser.banner = "Usage: scripts/export_workhuman_my_activity.rb [options]" | |
| parser.on("--profile-dir PATH", "Firefox profile directory") do |value| | |
| options[:profile_dir] = Pathname(value).expand_path | |
| end | |
| parser.on("--output PATH", "CSV output path (default: #{DEFAULT_OUTPUT})") do |value| | |
| options[:output] = Pathname(value) | |
| end | |
| parser.on("--client CLIENT", "Workhuman client slug") do |value| | |
| options[:client] = value | |
| end | |
| parser.on("--person-id PERSON_ID", Integer, "Workhuman person ID") do |value| | |
| options[:person_id] = value | |
| end | |
| parser.on("--verbose", "Print progress details") do | |
| options[:verbose] = true | |
| end | |
| end.parse! | |
| options | |
| end | |
| begin | |
| exporter = WorkhumanMyActivityExporter.new(parse_options) | |
| exporter.call | |
| puts("Wrote #{exporter.output_path}") | |
| rescue ExportError => e | |
| warn("error: #{e.message}") | |
| exit(1) | |
| rescue StandardError => e | |
| warn("error: #{e.class}: #{e.message}") | |
| exit(1) | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment