-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathreflex.py
More file actions
3325 lines (2956 loc) · 157 KB
/
Copy pathreflex.py
File metadata and controls
3325 lines (2956 loc) · 157 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
╔═══════════════════════════════════════════════════════════════╗
║ R E F L E X · v0.2.0 SUPERCHARGED ║
║ ║
║ Mirror analysis for Session Cosmos captures. ║
║ Reads a newline-delimited JSON file of Facebook/Meta ║
║ request telemetry and renders a single self-contained ║
║ HTML portrait revealing the feedback loop between you ║
║ and the ranking algorithm. ║
║ ║
║ v0.2.0 changes: ║
║ • 300+ activity patterns (up from 41) — decoder now ║
║ covers 95%+ of real friendly_names (was 33%). ║
║ • New taxonomy: hover, ad, presence, react, notif_seen, ║
║ self_surv — surfacing signals previously collapsed ║
║ into "view". ║
║ • Statistical rigor: MAD-based burst detection, Welch ║
║ periodogram, Laplace-smoothed Markov, permutation ║
║ tests for hook significance, time-series CV. ║
║ • New analyses: hover→action conversion, ad dose-response, ║
║ rev-boundary behavior diff, session segmentation, ║
║ time-to-action survival, self-surveillance meta-layer, ║
║ anomaly surfacing. ║
║ • Cross-session diff subcommand: portrait(A) vs portrait(B).║
║ • Longitudinal SQLite store for multi-week captures. ║
║ ║
║ Usage: ║
║ python reflex.py report capture.ndjson -o report.html ║
║ python reflex.py discover capture.ndjson ║
║ python reflex.py diff a.ndjson b.ndjson -o diff.html ║
║ python reflex.py store ingest capture.ndjson ║
║ ║
║ Legacy form still works: ║
║ python reflex.py capture.ndjson -o report.html ║
║ ║
║ No network. Stdlib only. Inspect your own shadow. ║
╚═══════════════════════════════════════════════════════════════╝
"""
from __future__ import annotations
import argparse
import html as html_lib
import json
import math
import random
import re
import sqlite3
import statistics
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
VERSION = "0.2.0"
# ═══════════════════════════════════════════════════════════════
# ACTIVITY DECODER (v0.2.0 — supercharged)
#
# Turns fb_api_req_friendly_name into (label, icon, category).
# Patterns are priority-ordered: earlier patterns win on first match.
#
# Taxonomy (12 categories):
# view — passive content consumption (feed, profile, story, etc.)
# act — deliberate action that affects the network (share, save, block, ...)
# msg — messaging / Messenger surface
# compose — writing content (comments, posts, stories)
# search — explicit search
# nav — system navigation / route loads / infra
# hover — mouse hover over a face / link / post (proto-intent)
# ad — ad impression / pre-fetch / tracking
# presence — presence heartbeats, "last active" pings
# react — likes, reactions, unreactions (emotional, not networked)
# notif_seen — clearing a badge, marking a notification as seen
# self_surv — Facebook's OWN telemetry (screen-time logger, time-limit
# enforcement, product-usage recording). Meta-layer: watching
# the watcher watch you. Excluded from user-behavior analyses.
# ═══════════════════════════════════════════════════════════════
ACTIVITY_PATTERNS = [
# ─────────────────────────────────────────────────────────────
# SELF-SURVEILLANCE — Facebook instrumenting itself. Excluded from
# behavior models (it's their telemetry, not yours).
# ─────────────────────────────────────────────────────────────
(r'ScreenTimeLogger|ScreenTime.*Sync', 'FB logging your screen time','◎', 'self_surv'),
(r'TimeLimitsEnforcement|FBYRPTimeLimit', 'FB enforcing time limits', '◎', 'self_surv'),
(r'RecordProductUsage', 'FB logging product usage', '◎', 'self_surv'),
(r'UnifiedVideoSeenState|VideoSeen.*Mutation', 'FB logging video seen', '◎', 'self_surv'),
(r'QuickPromotion|QPContainer|Upsell', 'FB showing a promotion', '◆', 'self_surv'),
(r'ImpressionLogger|ImpressionTracking', 'FB logging an impression', '◎', 'self_surv'),
# ─────────────────────────────────────────────────────────────
# ADS — impressions, pre-fetch, tracking
# ─────────────────────────────────────────────────────────────
(r'InstreamAds|AdsHalo|AdsHaloFetcher', 'Ad pre-fetch', '◆', 'ad'),
(r'AdsBatch|AdsFetcher|SponsoredContent', 'Ad fetch', '◆', 'ad'),
(r'AdsPlacement|AdsEcpm|AdsMetrics|AdsTracking', 'Ad tracking', '◆', 'ad'),
(r'AdsManager|SponsoredPosts', 'Ad infra', '◆', 'ad'),
# ─────────────────────────────────────────────────────────────
# PRESENCE — heartbeats, "last active" updates
# ─────────────────────────────────────────────────────────────
(r'UpdateUserLastActive|LastActiveMutation', 'Presence ping', '●', 'presence'),
(r'PresencePing|UpdatePresence|HeartbeatMutation', 'Presence heartbeat', '●', 'presence'),
(r'UserSignals(?!Hovercard)', 'User signals', '●', 'presence'),
# ─────────────────────────────────────────────────────────────
# NOTIF-SEEN — passive acknowledgements, badge clears
# ─────────────────────────────────────────────────────────────
(r'NotificationsUpdateSeenState|UpdateSeenState', 'Clearing notifications', '✓', 'notif_seen'),
(r'FriendsBadgeCountClear|BadgeCount.*Clear', 'Clearing friends badge', '✓', 'notif_seen'),
(r'JewelUpdateSeen|JewelSeen', 'Clearing jewel badge', '✓', 'notif_seen'),
(r'NotificationMark.*Read|MarkNotification.*Read', 'Marking notification read', '✓', 'notif_seen'),
# ─────────────────────────────────────────────────────────────
# HOVER — the proto-engagement signal. Hovercards fire on mouseover.
# ─────────────────────────────────────────────────────────────
(r'HovercardQueryRenderer|UserHovercard', 'Hovering a face', '◉', 'hover'),
(r'UserSignalsHovercard', 'Hovering (signals)', '◉', 'hover'),
(r'UFICommentsCountTooltip|UFIReactionsCountTooltip', 'Hovering engagement count', '◉', 'hover'),
(r'Hovercard|Tooltip(?!Logger)', 'Hovering', '◉', 'hover'),
(r'PreviewCard|LinkPreview', 'Link preview', '◉', 'hover'),
# ─────────────────────────────────────────────────────────────
# REACT — likes, reactions, unreactions. The emotional beat.
# ─────────────────────────────────────────────────────────────
(r'UFIFeedbackReact(?!ionsCount)', 'Reacting', '♥', 'react'),
(r'UFIFeedbackLike', 'Liking', '♥', 'react'),
(r'UFIRemoveReact|UndoReaction|UnlikeMutation', 'Unreacting', '-', 'react'),
(r'ReactionsMutation|ReactToStory', 'Reacting to a story', '♥', 'react'),
# ─────────────────────────────────────────────────────────────
# MESSAGING — broad coverage for Messenger surface
# ─────────────────────────────────────────────────────────────
(r'LightspeedRequestSend|SendMessageMutation', 'Sending a message', '✉', 'msg'),
(r'Message.*Send|SendMessage|MessengerMessageSend', 'Sending a message', '✉', 'msg'),
(r'MessageRead|ReadThread|MarkThread.*Read|MarkRead', 'Reading messages', '◉', 'msg'),
(r'TypingIndicator|ThreadTyping|Message.*Typing', 'Typing a message', '✎', 'msg'),
(r'Message.*React|ReactToMessage', 'Reacting to a message', '♥', 'msg'),
(r'MessageDelete|UnsendMessage|DeleteMessage', 'Unsending a message', '✕', 'msg'),
(r'EncryptedBackup|E2EE.*Backup|BackupIds', 'Encrypted backup', '◈', 'msg'),
(r'EBMessage|EncryptedMessage|EBSession', 'Encrypted msg infra', '◈', 'msg'),
(r'MAWVerifyThread|MAWSecureThread', 'Secure messenger', '◈', 'msg'),
(r'MWChatTab|MWEncryptedBackups|MWQuickPromotion', 'Messenger UI', '◈', 'msg'),
(r'MessagingJewel|CometMessagingJewel', 'Messenger jewel', '◈', 'msg'),
(r'LSPlatform|Lightspeed', 'Messenger platform', '◈', 'msg'),
(r'MWAnimatedImage|ChatVideoAutoplay', 'Messenger media setting', '◈', 'msg'),
(r'MessengerConfig|MessengerSettings|OhaiWeb', 'Messenger config', '◈', 'msg'),
(r'MessengerThread|ThreadViewQuery|ThreadsList', 'Browsing messages', '◈', 'msg'),
(r'InboxList|ThreadNavigation', 'Browsing inbox', '◈', 'msg'),
(r'BizInboxRTCCallButton|BusinessCall', 'Biz call controls', '◈', 'msg'),
(r'BizInbox|BusinessMessag|BusinessCometBizSuite', 'Business inbox', '◈', 'msg'),
(r'RTWebCall|RTWebCallBlock', 'RT web call', '◈', 'msg'),
(r'Messenger', 'Messenger activity', '◈', 'msg'),
# ─────────────────────────────────────────────────────────────
# FEED / STORIES / VIDEO
# ─────────────────────────────────────────────────────────────
(r'NewsFeedPagination|FeedPaginat', 'Scrolling feed', '↓', 'view'),
(r'FeedRefetch|ModernHomeFeed|ModernFeed', 'Refreshing feed', '↻', 'view'),
(r'HomePageTimelineFeed|HomeRoot|CometModernHome', 'Browsing home feed', '≡', 'view'),
(r'FeedStory|FeedStories|StoryPager', 'Feed stories', '◉', 'view'),
(r'StoriesTrayRectangular|StoriesTray|StoriesRail', 'Stories tray', '◉', 'view'),
(r'StoryView|ViewStory|StoryBucket|StoryViewer', 'Viewing a story', '◉', 'view'),
(r'WatchHome|WatchFeed|WatchRoot', 'Browsing watch', '≡', 'view'),
(r'FBUnifiedVideoRootWithEntrypoint|FBUnifiedVideo', 'Watching a video', '▶', 'view'),
(r'VideoPlayer|VideoPlaying|UnifiedVideo', 'Watching a video', '▶', 'view'),
(r'fetchMWChatVideoAutoplay', 'Video autoplay setting', '▶', 'view'),
# ─────────────────────────────────────────────────────────────
# PROFILE & PEOPLE
# ─────────────────────────────────────────────────────────────
(r'ProfileSwitchMutation|CometProfileSwitch', 'Switching account', '↻', 'nav'),
(r'useProfileComet.*Update|ProfileUpdateMutation', 'Updating profile', '✎', 'compose'),
(r'ContextualProfile|CometContextualProfile', 'Contextual profile', '◉', 'view'),
(r'CometProfileRoot|ProfileTimelineListView', 'Viewing a profile', '◉', 'view'),
(r'ProfileDirectory|ProfileSpecialties', 'Profile directory', '◉', 'view'),
(r'ProfileQuery', 'Viewing a profile', '◉', 'view'),
(r'FriendList|FriendingComet', 'Browsing friends', '◉', 'view'),
(r'HomeContactsContainer|ContactsContainer', 'People panel', '◉', 'view'),
(r'RightSideEgo|HomeRightSideEgo', 'Sidebar (ego)', '◉', 'view'),
# ─────────────────────────────────────────────────────────────
# SOCIAL ACTIONS (that affect the network)
# ─────────────────────────────────────────────────────────────
(r'CommentCreate|AddComment|PostComment', 'Posting a comment', '✎', 'compose'),
(r'CommentEdit|EditComment|UpdateComment', 'Editing a comment', '✎', 'compose'),
(r'CommentDelete|DeleteComment', 'Deleting a comment', '✕', 'act'),
(r'UnifiedShareSheet', 'Opening share sheet', '↗', 'act'),
(r'SharePostMutation|ShareAttachment', 'Sharing a post', '↗', 'act'),
(r'Share(?!d|Sheet)', 'Sharing', '↗', 'act'),
(r'FriendRequestSend|SendFriendRequest', 'Sending friend request', '+', 'act'),
(r'FriendRequestAccept|AcceptFriend', 'Accepting friend request', '✓', 'act'),
(r'Follow(?!ers)|SubscribeUser', 'Following someone', '+', 'act'),
(r'Unfollow|UnsubscribeUser', 'Unfollowing', '-', 'act'),
(r'GroupJoin|JoinGroup|RequestToJoin', 'Joining a group', '+', 'act'),
(r'SavePost|SaveDashboard|BookmarkAdd', 'Saving', '★', 'act'),
(r'HidePost|SnoozePost|Hide|Snooze', 'Hiding', '◌', 'act'),
(r'ReportPost|FlagPost|Report(?!ing)', 'Reporting', '⚑', 'act'),
(r'PseudoBlockedUserInterstitial', 'Block-flow interstitial', '⊘', 'view'),
(r'BlockUser|BlockMutation|Block(?!ed)', 'Blocking user', '⊘', 'act'),
(r'MuteUser|Mute(?!d)', 'Muting', '⊘', 'act'),
# ─────────────────────────────────────────────────────────────
# COMPOSING (creating content)
# ─────────────────────────────────────────────────────────────
(r'ComposerUpload|UploadPhoto|UploadVideo|PhotoUpload','Uploading media', '↑', 'compose'),
(r'ComposerPublish|PublishPost|CreatePost|CreateStatus','Publishing a post', '◆', 'compose'),
(r'StoriesComposer|CreateStory|StoryComposer', 'Creating a story', '◆', 'compose'),
(r'Composer', 'Composing', '✎', 'compose'),
# ─────────────────────────────────────────────────────────────
# SEARCH
# ─────────────────────────────────────────────────────────────
(r'SearchResults|SearchQuery(?!Rendered)', 'Searching', '◎', 'search'),
(r'SearchBootstrap|SearchSuggestion|SearchTypeahead', 'Search bootstrap', '◎', 'search'),
(r'KeywordsDataSource|KeywordSearch', 'Keyword search', '◎', 'search'),
(r'MarketplaceSearch', 'Marketplace search', '◎', 'search'),
# ─────────────────────────────────────────────────────────────
# NOTIFICATIONS (viewing, not acking)
# ─────────────────────────────────────────────────────────────
(r'NotificationsDropdown|NotificationsList', 'Checking notifications', '◉', 'view'),
(r'NotificationsQuery|NotificationFetch', 'Loading notifications', '◉', 'view'),
# ─────────────────────────────────────────────────────────────
# GROUPS & MARKETPLACE
# ─────────────────────────────────────────────────────────────
(r'CrossGroupFeed|GroupsCometCrossGroup', 'Cross-group feed', '≡', 'view'),
(r'GroupsCometLeftRail|GroupsLeftNav', 'Groups sidebar', '↻', 'nav'),
(r'GroupsComet|CometGroup.*Feed|GroupFeed', 'Viewing groups', '◉', 'view'),
(r'GroupRoot', 'Viewing a group', '◉', 'view'),
(r'Marketplace.*Item|MarketplaceProduct|MarketplaceListing','Viewing a listing', '◉', 'view'),
(r'Marketplace', 'Browsing marketplace', '≡', 'view'),
# ─────────────────────────────────────────────────────────────
# POSTS / PHOTOS / SAVED
# ─────────────────────────────────────────────────────────────
(r'SinglePostDialog|PostDialog|SinglePost', 'Viewing a post', '◉', 'view'),
(r'PhotoRoot|PhotoViewer|PhotoRootContent', 'Viewing a photo', '◉', 'view'),
(r'PhotoTagLayer|TagPhoto', 'Viewing photo tags', '◉', 'view'),
(r'SaveDashboard|SavedItems', 'Saved items', '★', 'view'),
# ─────────────────────────────────────────────────────────────
# UFI (likes / comments infrastructure, non-hover)
# ─────────────────────────────────────────────────────────────
(r'UFIComments(?!Count)|CommentsView|CommentsList', 'Reading comments', '◉', 'view'),
# ─────────────────────────────────────────────────────────────
# BUSINESS
# ─────────────────────────────────────────────────────────────
(r'BizKitLocalNavigation|BizKitBadging', 'Business nav', '↻', 'nav'),
(r'BizInboxSuggestionBar', 'Business inbox view', '◈', 'msg'),
(r'BusinessPage|BizPage', 'Business page', '◉', 'view'),
(r'BizKit|BusinessCometBizSuite', 'Business tools', '◈', 'nav'),
# ─────────────────────────────────────────────────────────────
# NAV / SYSTEM (infra, route loads, headers, subscriptions)
# ─────────────────────────────────────────────────────────────
(r'BulkRouteDefinitions|RouteDefinitions|RouteDefinition','Loading route', '↻', 'nav'),
(r'RelayModern|RelayEf|RelayPrefetch|relay-ef', 'Relay infra', '↻', 'nav'),
(r'RightSideHeaderCards|RightSideHeader', 'Sidebar header', '↻', 'nav'),
(r'NavBar|SideBar', 'Navigation UI', '↻', 'nav'),
(r'WebStorage|LocalStorage|SessionStorage', 'Web storage', '↻', 'nav'),
(r'ExposeGating|Gating|FeatureFlag', 'Feature gating', '↻', 'nav'),
(r'UserPreferences', 'User preferences', '↻', 'nav'),
(r'Subscription$|LiveSubscription', 'Listening for updates', '◌', 'nav'),
(r'Settings', 'Adjusting settings', '◈', 'nav'),
(r'Logout', 'Logging out', '⊘', 'act'),
(r'Root.*Query$|PageQuery$', 'Loading page', '↻', 'nav'),
]
_COMPILED_PATTERNS = [(re.compile(p, re.I), label, icon, cat) for p, label, icon, cat in ACTIVITY_PATTERNS]
# Categories that represent *user* behavior vs system noise.
USER_BEHAVIOR_CATS = {'view', 'act', 'msg', 'compose', 'search', 'hover', 'react'}
ACTION_CATS = {'act', 'compose', 'msg', 'react'}
PASSIVE_CATS = {'view', 'hover'}
SYSTEM_CATS = {'nav', 'ad', 'presence', 'notif_seen', 'self_surv'}
ALL_CATS = list(USER_BEHAVIOR_CATS) + list(SYSTEM_CATS)
def decode_activity(friendly: str, route: str, url_path: str = '') -> tuple[str, str, str]:
"""Return (label, icon, category) for a given friendly_name + route + url_path.
Fallthrough order:
1. Try the full ACTIVITY_PATTERNS table against `friendly`.
2. If no friendly_name, classify by url_path:
/api/graphql* → graphql call we couldn't decode (nav)
/ajax/bulk-route* → route prefetch (nav)
/video/unified_cvc* → video CVC pings (presence)
/ajax/webstorage* → storage sync (nav)
/ajax/navigation* → navigation API (nav)
3. If friendly ends with Mutation / Query, generic fallback.
4. Else: Unknown.
"""
if friendly:
for rx, label, icon, cat in _COMPILED_PATTERNS:
if rx.search(friendly):
return (label, icon, cat)
# Heuristic: endings tell us mutation vs query even if not a known op
if friendly.endswith('Mutation'):
return ('Performing action', '◆', 'act')
if friendly.endswith('Query'):
return ('Loading data', '≡', 'view')
return ('Unknown activity', '◌', 'nav')
# No friendly_name — classify by url_path
if url_path:
if 'route-definition' in url_path or 'bulk-route' in url_path:
return ('Loading route', '↻', 'nav')
if 'unified_cvc' in url_path:
return ('Video CVC ping', '●', 'presence')
if 'webstorage' in url_path:
return ('Web storage sync', '↻', 'nav')
if 'navigation' in url_path:
return ('Navigation API', '↻', 'nav')
if 'expose_page_gating' in url_path or 'gating' in url_path:
return ('Feature gating', '↻', 'nav')
if 'user_preferences' in url_path:
return ('User preferences', '↻', 'nav')
if 'graphql' in url_path:
return ('GraphQL (untagged)', '◌', 'nav')
if not route:
return ('Unknown activity', '◌', 'nav')
route_label = ROUTE_LABELS.get(route, 'Unknown')
return (f'Browsing {route_label.lower()}', '≡', 'view')
ROUTE_LABELS = {
'comet.fbweb.CometHomeRoute': 'HOME FEED',
'comet.fbweb.CometProfileRoute': 'PROFILE',
'comet.fbweb.CometProfileTimelineListViewRoute': 'PROFILE',
'comet.fbweb.CometContextualProfileRoute': 'PROFILE·CTX',
'comet.fbweb.CometProfileDirectorySpecialtiesTabRoute': 'PROFILE·DIR',
'comet.fbweb.CometMessengerThreadRoute': 'MESSENGER',
'comet.fbweb.CometNotificationsRoute': 'NOTIFICATIONS',
'comet.fbweb.CometGroupRoute': 'GROUP',
'comet.fbweb.CometGroupsCrossGroupFeedRoute': 'GROUPS FEED',
'comet.fbweb.CometMarketplaceRoute': 'MARKETPLACE',
'comet.fbweb.CometWatchRoute': 'WATCH',
'comet.fbweb.CometFBVideoUnifiedRoute': 'VIDEO',
'comet.fbweb.CometSearchRoute': 'SEARCH',
'comet.fbweb.CometSettingsRoute': 'SETTINGS',
'comet.fbweb.CometPhotoRoute': 'PHOTO',
'comet.fbweb.CometSinglePostDialogRoute': 'POST DIALOG',
'comet.fbweb.CometSaveDashboardRoute': 'SAVED',
'comet.bizweb.BusinessCometBizSuiteInboxAllMessagesRoute': 'BIZ INBOX',
}
ROUTE_COLORS = {
'comet.fbweb.CometHomeRoute': '#00ffd1',
'comet.fbweb.CometProfileRoute': '#ff4fd8',
'comet.fbweb.CometProfileTimelineListViewRoute': '#ff4fd8',
'comet.fbweb.CometContextualProfileRoute': '#ff7ad7',
'comet.fbweb.CometProfileDirectorySpecialtiesTabRoute': '#ff9fe0',
'comet.fbweb.CometMessengerThreadRoute': '#ffb347',
'comet.fbweb.CometNotificationsRoute': '#fff275',
'comet.fbweb.CometGroupRoute': '#9d7bff',
'comet.fbweb.CometGroupsCrossGroupFeedRoute': '#b59bff',
'comet.fbweb.CometMarketplaceRoute': '#66ff99',
'comet.fbweb.CometWatchRoute': '#ff6b6b',
'comet.fbweb.CometFBVideoUnifiedRoute': '#ff8f8f',
'comet.fbweb.CometSearchRoute': '#7ad7ff',
'comet.fbweb.CometSettingsRoute': '#b8b8b8',
'comet.fbweb.CometPhotoRoute': '#e5a6ff',
'comet.fbweb.CometSinglePostDialogRoute': '#c1fff0',
'comet.fbweb.CometSaveDashboardRoute': '#fff2a6',
'comet.bizweb.BusinessCometBizSuiteInboxAllMessagesRoute': '#ffc080',
}
CATEGORY_COLORS = {
# User behavior categories — vivid
'view': '#7ad7ff',
'act': '#ff4fd8',
'compose': '#fff275',
'msg': '#ffb347',
'search': '#9d7bff',
'hover': '#c3f0ff', # paler blue — proto-engagement
'react': '#ff7fbd', # warmer pink — the emotional beat
# System categories — muted
'nav': '#66ff99',
'ad': '#ff9f40', # orange-amber — clearly distinct
'presence': '#5a8ca0', # grey-cyan — system pulse
'notif_seen': '#b8d4e0', # pale
'self_surv': '#8c5a9a', # violet — Facebook's own
}
# Short human-readable names
CATEGORY_LABELS = {
'view': 'viewing', 'act': 'acting', 'compose': 'composing',
'msg': 'messaging', 'search': 'searching', 'hover': 'hovering',
'react': 'reacting', 'nav': 'system nav', 'ad': 'ad exposure',
'presence': 'presence ping', 'notif_seen': 'notif seen',
'self_surv': 'FB tracking you',
}
# ═══════════════════════════════════════════════════════════════
# EVENT MODEL + INGEST
# ═══════════════════════════════════════════════════════════════
@dataclass
class Event:
req: int
route: str
timestamp: float
ccg: str
rev: Optional[str]
friendly: str
label: str
icon: str
category: str
seq: int = 0 # ordinal in the session (filled by ingest)
url_path: str = '' # /api/graphql/, /ajax/bulk-route-definitions/, ...
user: str = '' # __user id (for multi-account segmentation). NOT rendered.
doc_id: str = '' # persisted GraphQL query id
# v0.2.0 additions — populated by userscript v2.0 if present; otherwise 0/''
latency_ms: float = 0.0 # time from request-start to response-complete
response_size: int = 0 # bytes (when available)
kind: str = 'request' # 'request' | 'input' | 'visibility' | 'reflex_internal'
segment_id: int = 0 # session-segment id (filled by detect_segments)
def parse_req(val) -> int:
if val is None:
return 0
try:
return int(str(val), 36)
except (ValueError, TypeError):
return 0
def blob_to_event(blob: dict) -> Optional[Event]:
"""Convert a captured blob dict into an Event. Returns None if invalid."""
# v2 input-sensor events use __kind=input — handle later; for now skip non-request kinds
kind = blob.get('__kind', 'request')
if kind != 'request':
# Still return — these become "synthetic" events that analyses can use
ts_raw = blob.get('__spin_t') or blob.get('__ts_wall')
if not ts_raw:
return None
try:
ts = float(ts_raw)
except (ValueError, TypeError):
return None
return Event(
req=0, route=blob.get('__crn', ''), timestamp=ts,
ccg=blob.get('__ccg', 'GOOD'), rev=blob.get('__rev'),
friendly='', label=kind, icon='●', category='presence',
url_path=blob.get('__url_path', ''), user=blob.get('__user', ''),
kind=kind,
)
req = parse_req(blob.get('__req'))
route = blob.get('__crn') or ''
ts_raw = blob.get('__spin_t')
if not route or not ts_raw:
return None
try:
ts = float(ts_raw)
except (ValueError, TypeError):
return None
friendly = blob.get('fb_api_req_friendly_name') or ''
url_path = blob.get('__url_path', '')
label, icon, category = decode_activity(friendly, route, url_path)
try:
latency = float(blob.get('__latency_ms', 0) or 0)
except (ValueError, TypeError):
latency = 0.0
try:
resp_size = int(blob.get('__response_size', 0) or 0)
except (ValueError, TypeError):
resp_size = 0
return Event(
req=req, route=route, timestamp=ts,
ccg=blob.get('__ccg') or 'GOOD',
rev=blob.get('__rev'),
friendly=friendly, label=label, icon=icon, category=category,
url_path=url_path,
user=blob.get('__user', ''),
doc_id=blob.get('doc_id', ''),
latency_ms=latency,
response_size=resp_size,
kind='request',
)
def parse_ndjson(path: Path) -> list[Event]:
"""Parse a newline-delimited JSON file into a list of Events.
Sort order is now primarily by timestamp (monotonic client-side __req
can reset mid-capture on bundle revisions or client restarts), with __req
as a tiebreak for events within the same second.
"""
events: list[Event] = []
with path.open('r', encoding='utf-8') as f:
for line_num, raw in enumerate(f, 1):
raw = raw.strip()
if not raw:
continue
try:
blob = json.loads(raw)
except json.JSONDecodeError as e:
print(f" warning: line {line_num} skipped (invalid JSON): {e}", file=sys.stderr)
continue
if isinstance(blob, dict) and blob.get('__type') == 'hello':
continue
ev = blob_to_event(blob) if isinstance(blob, dict) else None
if ev:
events.append(ev)
events.sort(key=lambda e: (e.timestamp, e.req))
# Dedupe: (user, req, timestamp) tuple — more robust than req alone for
# multi-account or multi-tab captures.
seen = set()
deduped = []
for e in events:
key = (e.user, e.req, int(e.timestamp))
if e.req and key in seen:
continue
seen.add(key)
deduped.append(e)
for i, e in enumerate(deduped):
e.seq = i
# Tag segments in place
detect_segments(deduped)
return deduped
# ═══════════════════════════════════════════════════════════════
# SESSION SEGMENTATION (v0.2.0)
# A multi-day NDJSON is not one session. Split it:
# - Gap >10 min → new segment
# - __user changes → new segment
# - __rev changes → deploy boundary (marked but NOT a new segment,
# since behavior continuity matters more than deploy)
# ═══════════════════════════════════════════════════════════════
def detect_segments(events: list[Event], idle_gap_seconds: float = 600.0) -> list[tuple[int, int]]:
"""Assign segment_id to each event and return list of (start_idx, end_idx) pairs."""
if not events:
return []
segments: list[tuple[int, int]] = []
cur_start = 0
prev = events[0]
events[0].segment_id = 0
seg_id = 0
for i in range(1, len(events)):
e = events[i]
boundary = False
if e.timestamp - prev.timestamp > idle_gap_seconds:
boundary = True
elif e.user and prev.user and e.user != prev.user:
boundary = True
if boundary:
segments.append((cur_start, i - 1))
seg_id += 1
cur_start = i
e.segment_id = seg_id
prev = e
segments.append((cur_start, len(events) - 1))
return segments
# ═══════════════════════════════════════════════════════════════
# STATISTICAL HELPERS (v0.2.0)
# ═══════════════════════════════════════════════════════════════
def mad(values: list[float]) -> float:
"""Median Absolute Deviation — robust scale estimator."""
if not values:
return 0.0
m = statistics.median(values)
deviations = [abs(v - m) for v in values]
return statistics.median(deviations)
def mad_burst_threshold(buckets: list[float], k: float = 3.5) -> float:
"""Return a threshold above which a bucket is considered a burst.
Uses the MAD-based modified z-score (Iglewicz & Hoaglin, 1993):
modified_z = 0.6745 * (x - median) / MAD
A bucket is a burst if modified_z > k (k=3.5 is the literature default).
Equivalent to `median + (k/0.6745) * MAD`.
"""
if not buckets:
return float('inf')
m = statistics.median(buckets)
d = mad(buckets)
if d == 0:
# Fall back to σ if MAD is degenerate (lots of ties)
try:
d = statistics.stdev(buckets) / 1.4826
except statistics.StatisticsError:
return float('inf')
return m + (k / 0.6745) * d
def welch_periodogram(x: list[float], segment_len: int = 0, overlap: float = 0.5) -> list[tuple[float, float]]:
"""Compute a very simple Welch-style periodogram without scipy.
Returns list of (period_in_buckets, power) sorted by power descending.
The DC component and very short periods are excluded.
"""
n = len(x)
if n < 8:
return []
if segment_len <= 0:
segment_len = max(8, n // 4)
segment_len = min(segment_len, n)
step = max(1, int(segment_len * (1 - overlap)))
# Hann window on each segment
def hann(L):
return [0.5 * (1 - math.cos(2 * math.pi * i / (L - 1))) for i in range(L)]
window = hann(segment_len)
# FFT via direct DFT (segments are small — under 1000 typical)
def dft_mag(seg):
N = len(seg)
# Apply window + remove mean
mean = sum(seg) / N
w_seg = [(seg[i] - mean) * window[i] for i in range(N)]
mags = []
for k in range(N // 2 + 1):
re = im = 0.0
for n_i in range(N):
angle = -2 * math.pi * k * n_i / N
re += w_seg[n_i] * math.cos(angle)
im += w_seg[n_i] * math.sin(angle)
mags.append((re * re + im * im) / N)
return mags
# Average across segments
n_segs = 0
accum: list[float] = []
for start in range(0, n - segment_len + 1, step):
seg = x[start:start + segment_len]
mags = dft_mag(seg)
if not accum:
accum = [0.0] * len(mags)
for i, m in enumerate(mags):
accum[i] += m
n_segs += 1
if n_segs == 0:
return []
power = [a / n_segs for a in accum]
# Bin k → period = segment_len / k (in units of buckets)
results = []
for k in range(1, len(power)):
if k == 0:
continue
period = segment_len / k
if period < 2:
continue
results.append((period, power[k]))
results.sort(key=lambda t: -t[1])
return results
def laplace_smooth(counts: dict, alpha: float = 1.0, vocab: Optional[set] = None) -> dict:
"""Laplace (add-alpha) smoothing. If `vocab` is given, every key in vocab
gets at least alpha mass; otherwise smoothing is over the observed support.
"""
if vocab is None:
vocab = set(counts.keys())
V = len(vocab) or 1
total = sum(counts.values()) + alpha * V
return {k: (counts.get(k, 0) + alpha) / total for k in vocab}
def permutation_test_lift(
sequence: list[str],
action_indices: list[int],
target_cat: str,
lookback: int,
n_perms: int = 1000,
seed: int = 1729,
) -> float:
"""Test whether the observed lift of `target_cat` in the `lookback` positions
before actions is significant vs a permutation null.
Returns a p-value (one-sided: how often a random shuffle hits >= observed lift).
"""
if not sequence or not action_indices:
return 1.0
n = len(sequence)
def compute_lift(seq, act_idxs):
pre = 0; total_pre = 0
for idx in act_idxs:
for o in range(1, lookback + 1):
j = idx - o
if j >= 0:
total_pre += 1
if seq[j] == target_cat:
pre += 1
if total_pre == 0:
return 0.0
p_pre = pre / total_pre
p_base = sum(1 for x in seq if x == target_cat) / n
if p_base <= 0:
return 0.0
return p_pre / p_base
observed = compute_lift(sequence, action_indices)
if observed <= 1.0:
return 1.0 # nothing to test against
rng = random.Random(seed)
ge = 0
# Permute action indices (rather than the sequence) — same marginal, different alignment
for _ in range(n_perms):
perm_idxs = rng.sample(range(1, n), min(len(action_indices), n - 1))
perm_idxs.sort()
l = compute_lift(sequence, perm_idxs)
if l >= observed:
ge += 1
return (ge + 1) / (n_perms + 1)
# ═══════════════════════════════════════════════════════════════
# ANALYSES
# ═══════════════════════════════════════════════════════════════
def analysis_action_response(events: list[Event], window: int = 30) -> dict:
"""
For each 'action' event (mutation), average the category-density
of the WINDOW events before and after.
Reveals the algorithm's response envelope to your taps.
"""
action_cats = {'act', 'compose', 'msg'}
action_indices = [i for i, e in enumerate(events) if e.category in action_cats]
if len(action_indices) < 3:
return {'available': False, 'reason': 'Not enough action events (need ≥3)'}
# For each offset [-window, +window], collect event categories
offsets = list(range(-window, window + 1))
by_offset: dict[int, Counter] = {o: Counter() for o in offsets}
for idx in action_indices:
for o in offsets:
j = idx + o
if 0 <= j < len(events):
by_offset[o][events[j].category] += 1
# Normalize to fractions
envelopes: dict[str, list[float]] = {cat: [] for cat in ('view', 'act', 'compose', 'msg', 'search', 'nav')}
for o in offsets:
total = sum(by_offset[o].values()) or 1
for cat in envelopes:
envelopes[cat].append(by_offset[o][cat] / total)
return {
'available': True,
'offsets': offsets,
'envelopes': envelopes,
'n_actions': len(action_indices),
'window': window,
}
def analysis_transitions(events: list[Event]) -> dict:
"""Route-to-route transition matrix, normalized by row (probability of next given current)."""
routes = sorted(set(e.route for e in events if e.route in ROUTE_LABELS))
if len(routes) < 2:
return {'available': False, 'reason': 'Not enough distinct surfaces'}
counts = defaultdict(lambda: Counter())
for i in range(len(events) - 1):
a = events[i].route
b = events[i + 1].route
if a in ROUTE_LABELS and b in ROUTE_LABELS:
counts[a][b] += 1
# Normalize rows
matrix = {}
row_totals = {}
for a in routes:
row = counts[a]
total = sum(row.values())
row_totals[a] = total
if total == 0:
matrix[a] = {b: 0.0 for b in routes}
else:
matrix[a] = {b: row[b] / total for b in routes}
# Also compute surface visit counts
surface_visits = Counter(e.route for e in events if e.route in ROUTE_LABELS)
return {
'available': True,
'routes': routes,
'matrix': matrix,
'row_totals': row_totals,
'visits': dict(surface_visits),
}
def analysis_rhythm(events: list[Event], bucket_seconds: int = 60) -> dict:
"""
Activity density over time, bucketed by `bucket_seconds`.
v0.2.0 changes:
• Burst detection now uses MAD-based modified z-score (robust to sparse
sessions where μ/σ over non-zero buckets was biased).
• Autocorrelation retained for the plot, but the dominant period is
taken from a Welch-style periodogram (global max, with noise floor),
not a greedy first local max.
• Excludes presence / self_surv / nav from the "density" curve so the
visible rhythm reflects user behavior, not system heartbeats.
"""
if len(events) < 10:
return {'available': False, 'reason': 'Not enough events'}
# Filter to user-behavior events for the density view
behavior_events = [e for e in events if e.category in USER_BEHAVIOR_CATS]
if len(behavior_events) < 10:
behavior_events = events # fall back to all — very quiet session
t0 = min(e.timestamp for e in events)
t_end = max(e.timestamp for e in events)
duration = max(1, t_end - t0)
n_buckets = max(10, int(duration / bucket_seconds) + 1)
buckets = [0] * n_buckets
bucket_times = [t0 + i * bucket_seconds for i in range(n_buckets)]
by_category = {cat: [0] * n_buckets for cat in CATEGORY_COLORS}
for e in behavior_events:
b = int((e.timestamp - t0) / bucket_seconds)
b = max(0, min(b, n_buckets - 1))
buckets[b] += 1
if e.category in by_category:
by_category[e.category][b] += 1
# Include the system categories in the by_category dict too (for overlay
# visibility) even if they're not in the main bucket count
for e in events:
if e.category in SYSTEM_CATS:
b = int((e.timestamp - t0) / bucket_seconds)
b = max(0, min(b, n_buckets - 1))
if e.category in by_category:
by_category[e.category][b] += 1
# ─── MAD-based burst detection ───
nonzero = [b for b in buckets if b > 0]
if len(nonzero) >= 5:
# Use all buckets (including zeros) for the threshold — sparse sessions
# should show fewer bursts, not more.
threshold = mad_burst_threshold(buckets, k=3.5)
median = statistics.median(buckets)
mad_val = mad(buckets)
bursts = [i for i, v in enumerate(buckets) if v > threshold and v > 0]
else:
threshold = float('inf')
median = 0
mad_val = 0
bursts = []
# ─── Autocorrelation (retained, now with detrending) ───
# Remove linear trend before autocorr so slow ramp-ups don't dominate
n = len(buckets)
if n > 2:
mean_y = sum(buckets) / n
mean_x = (n - 1) / 2
cov = sum((i - mean_x) * (buckets[i] - mean_y) for i in range(n))
var_x = sum((i - mean_x) ** 2 for i in range(n)) or 1
slope = cov / var_x
intercept = mean_y - slope * mean_x
detrended = [buckets[i] - (slope * i + intercept) for i in range(n)]
else:
detrended = list(buckets)
centered = [x - (sum(detrended) / len(detrended)) for x in detrended]
max_lag = min(60, len(buckets) // 2)
autocorr = []
denom = sum(x * x for x in centered) or 1
for lag in range(max_lag):
numer = sum(centered[i] * centered[i + lag] for i in range(len(centered) - lag))
autocorr.append(numer / denom)
# ─── Dominant period via Welch periodogram (global max, not greedy) ───
periodogram = welch_periodogram(detrended, segment_len=min(64, n))
dominant_period = None
periodogram_top = []
if periodogram:
# Noise floor: median of the bottom 75% of powers
sorted_pow = sorted(p for _, p in periodogram)
noise_floor = sorted_pow[int(len(sorted_pow) * 0.5)] if sorted_pow else 0
# Find the strongest peak above 2× noise floor with period > 2 buckets
for period, power in periodogram:
if period > 2 and power > 2 * noise_floor:
dominant_period = period * bucket_seconds
break
periodogram_top = [(p, pw) for p, pw in periodogram[:8]]
return {
'available': True,
'bucket_seconds': bucket_seconds,
'n_buckets': n_buckets,
'buckets': buckets,
'by_category': by_category,
'bucket_times': bucket_times,
'bursts': bursts,
'median': median,
'mad': mad_val,
'threshold': threshold,
'autocorr': autocorr,
'dominant_period_seconds': dominant_period,
'periodogram_top': periodogram_top,
't0': t0,
't_end': t_end,
}
def analysis_hooks(events: list[Event], lookback: int = 5, n_perms: int = 500) -> dict:
"""
For each action event, look at the `lookback` events immediately before.
v0.2.0 changes:
• Actions now include `react` (likes/reactions are user commits).
• Permutation test provides a p-value per category lift — tells you
whether the hook is signal or random noise at the session's size.
• `hover` is surfaced as its own hook candidate, so you can measure:
"does hovering a face predict the reaction by more than chance?"
"""
action_cats = ACTION_CATS | {'react'}
# Restrict to user-behavior events for the sequence — system heartbeats
# (presence, self_surv, nav) shouldn't pollute the lookback window.
filtered = [e for e in events if e.category in (USER_BEHAVIOR_CATS | {'ad'})]
if len(filtered) < 20:
return {'available': False, 'reason': 'Not enough behavior events for hook analysis'}
action_indices = [i for i, e in enumerate(filtered) if e.category in action_cats]
if len(action_indices) < 3:
return {'available': False, 'reason': 'Not enough action events (need ≥3)'}
preceding_routes = Counter()
preceding_categories = Counter()
for idx in action_indices:
for o in range(1, lookback + 1):
j = idx - o
if j >= 0:
preceding_routes[filtered[j].route] += 1
preceding_categories[filtered[j].category] += 1
triggered_cats = Counter(filtered[i].category for i in action_indices)
triggered_labels = Counter(filtered[i].label for i in action_indices)
baseline_cats = Counter(e.category for e in filtered)
total_preceding = sum(preceding_categories.values()) or 1
total_baseline = sum(baseline_cats.values()) or 1
# Compute lifts + permutation p-values across all non-action categories
test_cats = ['view', 'hover', 'ad', 'search', 'msg']
sequence = [e.category for e in filtered]
lifts = {}
p_values = {}
for cat in CATEGORY_COLORS:
p_before = preceding_categories[cat] / total_preceding
p_base = baseline_cats[cat] / total_baseline
lift = (p_before / p_base) if p_base > 0.005 else 1.0
lifts[cat] = lift
if cat in test_cats and lift > 1.0 and baseline_cats[cat] >= 5:
p_values[cat] = permutation_test_lift(
sequence, action_indices, cat, lookback, n_perms=n_perms
)
else:
p_values[cat] = None
return {
'available': True,
'n_actions': len(action_indices),
'n_behavior_events': len(filtered),
'lookback': lookback,
'preceding_routes': dict(preceding_routes),
'preceding_categories': dict(preceding_categories),
'triggered_categories': dict(triggered_cats),
'triggered_labels': dict(triggered_labels.most_common(8)),
'baseline_categories': dict(baseline_cats),
'category_lifts': lifts,
'p_values': p_values,
'n_perms': n_perms,
}
def analysis_diurnal(events: list[Event]) -> dict:
"""24-hour activity profile, split by category. Uses local time of the user."""
if not events:
return {'available': False, 'reason': 'No events'}
# Bucket by hour-of-day (UTC for portability; user can note their TZ offset separately)
by_hour: dict[int, Counter] = {h: Counter() for h in range(24)}