Skip to content

Instantly share code, notes, and snippets.

@capripot
Created April 3, 2026 00:53
Show Gist options
  • Select an option

  • Save capripot/8fd0c771c60361788a0b08d0e4760f05 to your computer and use it in GitHub Desktop.

Select an option

Save capripot/8fd0c771c60361788a0b08d0e4760f05 to your computer and use it in GitHub Desktop.
Export Connected Recognition (Workhuman)
#!/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