Repository navigation
Expand file tree
/
Copy pathsmoke_test.py
More file actions
4182 lines (3762 loc) · 215 KB
/
Copy pathsmoke_test.py
File metadata and controls
4182 lines (3762 loc) · 215 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
"""
Smoke_test.py — the offline suite for wellfound-scraper.
One file of plain functions with inline fixtures. No pytest, no conftest, no
fixtures directory; `tests/test_smoke.py` wraps this as a
single pytest test so `pytest` works as an entry point without a second copy
of the checks.
python3 smoke_test.py run everything
python3 smoke_test.py -v print every check as it passes
It must pass with NO engine library installed at all: every
`import playwright_scraper` / `selenium_scraper` / `puppeteer_scraper` is
guarded and the skip is RECORDED, because "skipped, engine absent" reads
identically to a real import error. CI installs each engine in its own venv
and fails if that engine's group reports a skip.
The fixtures below are cut from real captures taken 2026-09-17 and trimmed to
the companies and jobs the checks read. Every trimmed fixture was verified to
parse IDENTICALLY to its untrimmed original, field for field, before being
committed — the only deliberate difference is that each `description` is cut
to its first 200 characters, because a job description runs to six kilobytes
and forty of them would be most of this file.
Nothing here carries personal data: Wellfound's job and company objects name
companies, not people, and the parser reads no field that could hold an
individual's name. `check_fixtures_carry_no_personal_names` guards the SHAPE
so a future capture from a route that does is caught.
"""
import argparse
import ast
import csv
import inspect
import io
import json
import os
import re
import subprocess
import sys
import pathlib
import tempfile
from contextlib import redirect_stderr, redirect_stdout
from dataclasses import asdict, fields
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
FAILURES = []
PASSED = 0
SKIPS = []
VERBOSE = False
def check(name, condition, detail=""):
global PASSED
if condition:
PASSED += 1
if VERBOSE:
print(" ok %s" % name)
else:
FAILURES.append("%s%s" % (name, (" — " + detail) if detail else ""))
print(" FAIL %s%s" % (name, (" — " + detail) if detail else ""))
def equal(name, got, want):
check(name, got == want, "got %r, want %r" % (got, want))
def skip(group, reason):
SKIPS.append("%s: %s" % (group, reason))
print(" SKIP %s — %s" % (group, reason))
LISTING_NEXT_DATA = r'''<html><head><script>turnstileLoad = function () { turnstile.render('#turnstile_widget', {sitekey: k}); };</script><script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=turnstileLoad" async="" defer=""></script><script>window.__ENV={"CLOUDFLARE_TURNSTILE_SITE_KEY":"0x4AAAAAAAgpA-Qx7SsJOW-g"};</script><script>var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';</script><link href="/_next/static/css/app.css"/></head><body><img src="https://photos.wellfound.com/startups/i/1-logo.jpg"/><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"apolloState":{"data":{"ROOT_QUERY":{"__typename":"Query","talent":{"__typename":"Talent","seoLandingPageJobSearchResults({\"page\":1,\"remote\":true,\"role\":\"software-engineer\"})":{"__typename":"Results","totalJobCount":1881,"totalStartupCount":923,"perPage":20,"pageCount":47,"startups":[{"__ref":"StartupResult:829771"},{"__ref":"StartupResult:5268250"},{"__ref":"StartupResult:8667152"}]}}},"StartupResult:829771":{"__typename":"StartupResult","id":"829771","badges":[{"__ref":"Badge:ACTIVELY_HIRING"},{"__ref":"Badge:TOP_RESPONDER-829771"},{"__ref":"Badge:QUICK_RESPONDER-829771"},{"__ref":"Badge:B2B-829771"},{"__ref":"Badge:COMPANY_STAGE-829771"},{"__ref":"Badge:TOP_INVESTORS-829771"},{"__ref":"Badge:YC-829771"}],"companySize":"SIZE_11_50","highConcept":"The easiest way to test","highlightedJobListings":[{"__ref":"JobListingSearchResult:4697947"}],"logoUrl":"https://photos.wellfound.com/startups/i/829771-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1742329133","name":"Confident LIMS","slug":"confidentlims"},"Badge:ACTIVELY_HIRING":{"__typename":"Badge","id":"ACTIVELY_HIRING","name":"ACTIVELY_HIRING_BADGE","label":"Actively Hiring","tooltip":"Actively processing applications","avatarUrl":null,"rating":null,"data":null},"Badge:TOP_RESPONDER-829771":{"__typename":"Badge","id":"TOP_RESPONDER-829771","name":"TOP_RESPONDER_BADGE","label":"Top 1% of responders","tooltip":"Confident LIMS is in the top 1% of companies in terms of response time to applications","avatarUrl":null,"rating":null,"data":null},"Badge:QUICK_RESPONDER-829771":{"__typename":"Badge","id":"QUICK_RESPONDER-829771","name":"QUICK_RESPONDER_BADGE","label":"Responds within a day","tooltip":"Based on past data, Confident LIMS usually responds to incoming applications within a day","avatarUrl":null,"rating":null,"data":null},"Badge:B2B-829771":{"__typename":"Badge","id":"B2B-829771","name":"B2B_BADGE","label":"B2B","tooltip":null,"avatarUrl":null,"rating":null,"data":null},"Badge:COMPANY_STAGE-829771":{"__typename":"Badge","id":"COMPANY_STAGE-829771","name":"COMPANY_STAGE_BADGE","label":"Early Stage","tooltip":"Startup in initial stages","avatarUrl":null,"rating":null,"data":"early_stage"},"Badge:TOP_INVESTORS-829771":{"__typename":"Badge","id":"TOP_INVESTORS-829771","name":"TOP_INVESTORS_BADGE","label":"Top Investors","tooltip":"This company has received a significant amount of investment from top investors","avatarUrl":null,"rating":null,"data":null},"Badge:YC-829771":{"__typename":"Badge","id":"YC-829771","name":"YC_BADGE","label":"YC Funded","tooltip":"Startup funded by Y Combinator","avatarUrl":null,"rating":null,"data":null},"StartupResult:5268250":{"__typename":"StartupResult","id":"5268250","badges":[{"__ref":"Badge:ACTIVELY_HIRING"},{"__ref":"Badge:B2B-5268250"},{"__ref":"Badge:COMPANY_STAGE-5268250"},{"__ref":"Badge:TOP_INVESTORS-5268250"},{"__ref":"Badge:YC-5268250"},{"__ref":"Badge:GROWING_FAST-5268250"}],"companySize":"SIZE_201_500","highConcept":"Making reactors people want","highlightedJobListings":[{"__ref":"JobListingSearchResult:4579450"},{"__ref":"JobListingSearchResult:3685218"},{"__ref":"JobListingSearchResult:4617952"}],"logoUrl":"https://photos.wellfound.com/startups/i/5268250-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1621284628","name":"Oklo","slug":"oklo"},"Badge:B2B-5268250":{"__typename":"Badge","id":"B2B-5268250","name":"B2B_BADGE","label":"B2B","tooltip":null,"avatarUrl":null,"rating":null,"data":null},"Badge:COMPANY_STAGE-5268250":{"__typename":"Badge","id":"COMPANY_STAGE-5268250","name":"COMPANY_STAGE_BADGE","label":"Growth Stage","tooltip":"Expanding market presence","avatarUrl":null,"rating":null,"data":"growth_stage"},"Badge:TOP_INVESTORS-5268250":{"__typename":"Badge","id":"TOP_INVESTORS-5268250","name":"TOP_INVESTORS_BADGE","label":"Top Investors","tooltip":"This company has received a significant amount of investment from top investors","avatarUrl":null,"rating":null,"data":null},"Badge:YC-5268250":{"__typename":"Badge","id":"YC-5268250","name":"YC_BADGE","label":"YC Funded","tooltip":"Startup funded by Y Combinator","avatarUrl":null,"rating":null,"data":null},"Badge:GROWING_FAST-5268250":{"__typename":"Badge","id":"GROWING_FAST-5268250","name":"GROWING_FAST_BADGE","label":"Growing fast","tooltip":"Showed strong hiring growth in the past month","avatarUrl":null,"rating":null,"data":null},"StartupResult:8667152":{"__typename":"StartupResult","id":"8667152","badges":[{"__ref":"Badge:ACTIVELY_HIRING"},{"__ref":"Badge:B2B-8667152"},{"__ref":"Badge:COMPANY_STAGE-8667152"}],"companySize":"SIZE_51_200","highConcept":"Sydecar is on a mission to transform the world of private markets","highlightedJobListings":[{"__ref":"JobListingSearchResult:3748205"},{"__ref":"JobListingSearchResult:3748202"}],"logoUrl":"https://photos.wellfound.com/startups/i/8667152-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1788984367","name":"Sydecar","slug":"sydecar-2"},"Badge:B2B-8667152":{"__typename":"Badge","id":"B2B-8667152","name":"B2B_BADGE","label":"B2B","tooltip":null,"avatarUrl":null,"rating":null,"data":null},"Badge:COMPANY_STAGE-8667152":{"__typename":"Badge","id":"COMPANY_STAGE-8667152","name":"COMPANY_STAGE_BADGE","label":"Growth Stage","tooltip":"Expanding market presence","avatarUrl":null,"rating":null,"data":"growth_stage"},"JobListingSearchResult:4697947":{"__typename":"JobListingSearchResult","autoPosted":false,"atsSource":null,"description":"Confident powers analytical testing labs — the labs that test samples on behalf of businesses and report product safety to consumers and regulators at high volume. When a lab publishes a result throug …[trimmed for the fixture]","jobType":"full-time","liveStartAt":1789063154,"locationNames":["San Francisco Bay Area"],"primaryRoleTitle":"Software Engineer","remote":true,"remoteConfig":{"__typename":"JobListingRemoteConfig","kind":"REMOTE","wfhFlexible":false},"acceptedRemoteLocationNames":["Canada","South America","United States","Latin America"],"slug":"senior-software-engineer","title":"Senior Software Engineer","compensation":"$100k – $180k • 0.05% – 0.25%","yearsExperienceMin":5,"yearsExperienceMax":null,"id":"4697947","isBookmarked":false},"JobListingSearchResult:4579450":{"__typename":"JobListingSearchResult","autoPosted":false,"atsSource":"AtsIntegration::Greenhouse::Listing","description":"Join us in pioneering the next generation of nuclear reactors! You'll leverage your software skills alongside nuclear engineers to model, simulate, design, and deploy advanced fission power technology …[trimmed for the fixture]","jobType":"full-time","liveStartAt":1786552154,"locationNames":["Santa Clara"],"primaryRoleTitle":"Software Engineer","remote":true,"remoteConfig":null,"acceptedRemoteLocationNames":["United States"],"slug":"software-engineer","title":"Software Engineer","compensation":"$110k – $200k","yearsExperienceMin":null,"yearsExperienceMax":null,"id":"4579450","isBookmarked":false},"JobListingSearchResult:3685218":{"__typename":"JobListingSearchResult","autoPosted":false,"atsSource":"AtsIntegration::Greenhouse::Listing","description":"Join us in pioneering the next generation of nuclear reactors! You'll leverage your software skills alongside nuclear engineers to model, simulate, design, and deploy advanced fission power technology …[trimmed for the fixture]","jobType":"full-time","liveStartAt":1775147834,"locationNames":["Santa Clara"],"primaryRoleTitle":"Software Engineer","remote":true,"remoteConfig":null,"acceptedRemoteLocationNames":["United States"],"slug":"senior-software-engineer","title":"Senior Software Engineer","compensation":"$200k – $250k","yearsExperienceMin":null,"yearsExperienceMax":null,"id":"3685218","isBookmarked":false},"JobListingSearchResult:4617952":{"__typename":"JobListingSearchResult","autoPosted":false,"atsSource":"AtsIntegration::Greenhouse::Listing","description":"Thanks for your interest in Oklo! We are searching for a Software Engineer to join our team. \n\nJoin us in pioneering the next generation of nuclear reactors! You'll leverage your software skills alon …[trimmed for the fixture]","jobType":"full-time","liveStartAt":1787346519,"locationNames":["Santa Clara"],"primaryRoleTitle":"Software Engineer","remote":true,"remoteConfig":{"__typename":"JobListingRemoteConfig","kind":"ONSITE_OR_REMOTE","wfhFlexible":true},"acceptedRemoteLocationNames":["United States"],"slug":"software-engineer-applied-ai-ml","title":"Software Engineer (Applied AI/ML)","compensation":"$200k – $250k","yearsExperienceMin":null,"yearsExperienceMax":null,"id":"4617952","isBookmarked":false},"JobListingSearchResult:3748205":{"__typename":"JobListingSearchResult","autoPosted":false,"atsSource":"AtsIntegration::Ashby::Listing","description":"## **About Us**\n\nSydecar is on a mission to transform the world of private markets. Our goal is to make these markets more accessible, transparent, and liquid, and we're achieving this by revolutioniz …[trimmed for the fixture]","jobType":"full-time","liveStartAt":1789083964,"locationNames":["San Francisco"],"primaryRoleTitle":"Software Engineer","remote":true,"remoteConfig":{"__typename":"JobListingRemoteConfig","kind":"ONSITE_OR_REMOTE","wfhFlexible":true},"acceptedRemoteLocationNames":["United States"],"slug":"software-engineer","title":"Software Engineer","compensation":"$150k – $170k","yearsExperienceMin":null,"yearsExperienceMax":null,"id":"3748205","isBookmarked":false},"JobListingSearchResult:3748202":{"__typename":"JobListingSearchResult","autoPosted":false,"atsSource":"AtsIntegration::Ashby::Listing","description":"## **About Us**\n\nSydecar is on a mission to transform the world of private markets. Our goal is to make these markets more accessible, transparent, and liquid, and we're achieving this by revolutioniz …[trimmed for the fixture]","jobType":"full-time","liveStartAt":1779321707,"locationNames":["San Francisco"],"primaryRoleTitle":"Software Engineer","remote":true,"remoteConfig":{"__typename":"JobListingRemoteConfig","kind":"ONSITE","wfhFlexible":true},"acceptedRemoteLocationNames":["United States"],"slug":"senior-software-engineer","title":"Senior Software Engineer","compensation":"$190k – $255k","yearsExperienceMin":null,"yearsExperienceMax":null,"id":"3748202","isBookmarked":false}}}}}}</script></body></html>'''
# A second landing capture, kept for the pay shapes the first one has none of:
# both its rows print "No equity", which is a STATEMENT the listing made and
# not a missing value.
LISTING_NO_EQUITY_NEXT_DATA = r'''<html><head><script>turnstileLoad = function () { turnstile.render('#turnstile_widget', {sitekey: k}); };</script><script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=turnstileLoad" async="" defer=""></script><script>window.__ENV={"CLOUDFLARE_TURNSTILE_SITE_KEY":"0x4AAAAAAAgpA-Qx7SsJOW-g"};</script><script>var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';</script><link href="/_next/static/css/app.css"/></head><body><img src="https://photos.wellfound.com/startups/i/1-logo.jpg"/><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"apolloState":{"data":{"ROOT_QUERY":{"__typename":"Query","talent":{"__typename":"Talent","seoLandingPageJobSearchResults({\"location\":\"london\",\"page\":1,\"role\":\"sales\"})":{"__typename":"Results","totalStartupCount":193,"totalJobCount":437,"perPage":20,"pageCount":10,"startups":[{"__ref":"StartupResult:10564596"},{"__ref":"StartupResult:10368792"}]}}},"StartupResult:10564596":{"__typename":"StartupResult","id":"10564596","badges":[{"__ref":"Badge:ACTIVELY_HIRING"},{"__ref":"Badge:QUICK_RESPONDER-10564596"},{"__ref":"Badge:GROWING_FAST-10564596"}],"companySize":"SIZE_11_50","highConcept":"Applied AI for businesses using records inside workflows it already runs, owned outright","highlightedJobListings":[{"__ref":"JobListingSearchResult:4656830"}],"logoUrl":"https://photos.wellfound.com/startups/i/10564596-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1749725201","name":"Briidge.one","slug":"briidgeone"},"Badge:ACTIVELY_HIRING":{"__typename":"Badge","id":"ACTIVELY_HIRING","name":"ACTIVELY_HIRING_BADGE","label":"Actively Hiring","tooltip":"Actively processing applications","avatarUrl":null,"rating":null,"data":null},"Badge:QUICK_RESPONDER-10564596":{"__typename":"Badge","id":"QUICK_RESPONDER-10564596","name":"QUICK_RESPONDER_BADGE","label":"Responds within three weeks","tooltip":"Based on past data, Briidge.one usually responds to incoming applications within three weeks","avatarUrl":null,"rating":null,"data":null},"Badge:GROWING_FAST-10564596":{"__typename":"Badge","id":"GROWING_FAST-10564596","name":"GROWING_FAST_BADGE","label":"Growing fast","tooltip":"Showed strong hiring growth in the past month","avatarUrl":null,"rating":null,"data":null},"StartupResult:10368792":{"__typename":"StartupResult","id":"10368792","badges":[{"__ref":"Badge:ACTIVELY_HIRING"}],"companySize":"SIZE_11_50","highConcept":"Enabling Entrepreneurship Globally..!!","highlightedJobListings":[{"__ref":"JobListingSearchResult:3193662"}],"logoUrl":"https://photos.wellfound.com/startups/i/10368792-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1736352172","name":"WeCommerce-Global","slug":"wecommerce-global-1"},"JobListingSearchResult:4656830":{"__typename":"JobListingSearchResult","autoPosted":false,"atsSource":null,"description":"**About Us**\n\nBriidge.one is a custom AI and software transformation company helping established businesses turn fragmented operations, legacy systems and proprietary data into intelligent digital inf …[trimmed for the fixture]","jobType":"full-time","liveStartAt":1788175613,"locationNames":["Atlanta","Austin","Boston","California","Chicago","Dallas","Denver","Los Angeles","Miami","Missouri","New York City","Ohio","Pennsylvania","Philadelphia","Phoenix","San Diego","Seattle","Texas","Virginia","Washington DC","San Francisco","San Jose","London","Berkeley","Florida","Toronto","Las Vegas","Amsterdam","Montreal","Manchester","New Jersey","Ontario","Melbourne","Washington","Colorado","Kentucky","San Mateo","Georgia","Calgary","Alberta","Stockholm","Nevada","Los Altos","Montana","Alaska","Rio de Janeiro","Hawaii","New York","Perth","Sydney","Naples","Milan","Edinburgh","Livermore","Virginia Beach","Brisbane","Birmingham","Stuttgart","Los Gatos","São Paulo","Eindhoven","Glasgow","Rotterdam","Adelaide","Liverpool","Dundee","Canberra","California City","San Francisco Bay Area","New South Wales","Greater London","Florianópolis","Los Angeles County","Las Vegas","Washington"],"primaryRoleTitle":"Sales Development Representative","remote":true,"remoteConfig":{"__typename":"JobListingRemoteConfig","kind":"REMOTE","wfhFlexible":false},"acceptedRemoteLocationNames":["Austin, Texas","Australia","Boston, Massachusetts","Brazil","California, United States","Canada","Dallas, Texas","Los Angeles, Los Angeles County","Miami, Florida","New York City, New York","Philadelphia, Pennsylvania","Phoenix, Arizona","San Diego, San Diego County","Seattle, Washington","Texas, United States","United States","Virginia, United States","Washington DC, District of Columbia","San Francisco, California","San Jose, California","London, Greater London","Florida, United States","Las Vegas, Nevada","Massachusetts, United States","Santa Monica, Los Angeles County","Manchester, United Kingdom","United Kingdom","New Jersey, United States","Ontario, Canada","Washington, United States","Colorado, United States","Calgary, Alberta","Alberta, Canada","Los Altos, California","Arizona, United States","Netherlands","Rio de Janeiro, State of Rio de Janeiro","New York, United States","Perth, Western Australia","Sydney, New South Wales","Colombia","New Zealand","Austria","Vienna, Austria","Brisbane, Queensland","Los Gatos, California","São Paulo, State of São Paulo","Hamilton, Ontario","London, Ontario","Adelaide, South Australia","Liverpool, United Kingdom","Canberra, Australian Capital Territory","California City, California","Auckland, New Zealand","Wellington, New Zealand","San Francisco Bay Area, United States","England, United Kingdom","New South Wales, Australia","Greater London, England","Brisbane City, Queensland","Christchurch, Christchurch City","Los Angeles County, United States","São Paulo, State of São Paulo","Sydney, New South Wales"],"slug":"lead-generation-executive-clone","title":"Sales Development Specialist","compensation":"$30k – $50k • No equity","yearsExperienceMin":3,"yearsExperienceMax":null,"id":"4656830","isBookmarked":false},"JobListingSearchResult:3193662":{"__typename":"JobListingSearchResult","autoPosted":false,"atsSource":null,"description":"WeCommerce.PK is an innovative startup dedicated to transforming the e-commerce landscape in Pakistan. Our mission is to empower local businesses by providing them with cutting-edge technology solutio …[trimmed for the fixture]","jobType":"full-time","liveStartAt":1786379450,"locationNames":["Dubai","San Francisco","London","Lahore"],"primaryRoleTitle":"Sales Development Representative","remote":true,"remoteConfig":{"__typename":"JobListingRemoteConfig","kind":"ONSITE_OR_REMOTE","wfhFlexible":false},"acceptedRemoteLocationNames":["Australia","China","Europe","Japan","South America","United States","United Kingdom","Eastern Europe","United Arab Emirates","South Korea","Pakistan"],"slug":"sales-office-coordinator","title":"Sales & Office Coordinator","compensation":"$12k – $36k • No equity","yearsExperienceMin":3,"yearsExperienceMax":null,"id":"3193662","isBookmarked":false}}}}}}</script></body></html>'''
# The /jobs feed, whose Apollo types are DIFFERENT — `JobListing`/`Startup`
# rather than `JobListingSearchResult`/`StartupResult` — and thinner: no
# description, no job type, no badges, and the company edge points the other
# way (the job holds a `__ref` to its company instead of the company listing
# its jobs). One parser reads both, and this fixture is what proves it.
FEED_NEXT_DATA = r'''<html><head><script>turnstileLoad = function () { turnstile.render('#turnstile_widget', {sitekey: k}); };</script><script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=turnstileLoad" async="" defer=""></script><script>window.__ENV={"CLOUDFLARE_TURNSTILE_SITE_KEY":"0x4AAAAAAAgpA-Qx7SsJOW-g"};</script><script>var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';</script><link href="/_next/static/css/app.css"/></head><body><img src="https://photos.wellfound.com/startups/i/1-logo.jpg"/><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"apolloState":{"data":{"ROOT_QUERY":{"__typename":"Query","talent":{"__typename":"Talent"}},"JobListing:4584538":{"__typename":"JobListing","id":"4584538","slug":"senior-data-scientist","title":"Senior Data Scientist","compensation":"$140k – $165k","locationNames":[],"acceptedRemoteLocationNames":["United States"],"remoteConfig":null,"liveStartAt":1789633434,"remote":true,"primaryRole":{"__typename":"Role","slug":"data-scientist-1"},"startup":{"__ref":"Startup:10232282"}},"Startup:10232282":{"__typename":"Startup","id":"10232282","name":"Nex","logoUrl":"https://photos.wellfound.com/startups/i/10232282-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1782760539","slug":"nex-7"},"JobListing:4238913":{"__typename":"JobListing","id":"4238913","slug":"software-engineer-agents-internal-audit","title":"Software Engineer, Agents (Internal Audit)","compensation":"$170k – $300k","locationNames":["San Francisco"],"acceptedRemoteLocationNames":["United States"],"remoteConfig":null,"liveStartAt":1789644174,"remote":true,"primaryRole":{"__typename":"Role","slug":"developer"},"startup":{"__ref":"Startup:7792161"}},"Startup:7792161":{"__typename":"Startup","id":"7792161","name":"Fieldguide","logoUrl":"https://photos.wellfound.com/startups/i/7792161-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1593118684","slug":"fieldguide"},"JobListing:4722726":{"__typename":"JobListing","id":"4722726","slug":"senior-software-engineer-robot-data-collection","title":"Senior Software Engineer - Robot Data Collection","compensation":"$43k – $43k","locationNames":["Santa Clara"],"acceptedRemoteLocationNames":[],"remoteConfig":{"__ref":"JobListingRemoteConfig:1418506"},"liveStartAt":1789612365,"remote":false,"primaryRole":{"__typename":"Role","slug":"developer"},"startup":{"__ref":"Startup:9471899"}},"Startup:9471899":{"__typename":"Startup","id":"9471899","name":"Welocalize","logoUrl":"https://photos.wellfound.com/startups/i/9471899-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1680890395","slug":"welocalize-2"},"JobListingRemoteConfig:1418506":{"__typename":"JobListingRemoteConfig","id":"1418506","kind":"ONSITE","wfhFlexible":false},"JobListing:4722595":{"__typename":"JobListing","id":"4722595","slug":"senior-software-engineer-ii-full-stack","title":"Senior Software Engineer II, Full-Stack","compensation":"$156k – $251k","locationNames":["New York City"],"acceptedRemoteLocationNames":[],"remoteConfig":null,"liveStartAt":1789589390,"remote":false,"primaryRole":{"__typename":"Role","slug":"developer"},"startup":{"__ref":"Startup:28470"}},"Startup:28470":{"__typename":"Startup","id":"28470","name":"Braze","logoUrl":"https://photos.wellfound.com/startups/i/28470-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1710992240","slug":"braze"}}}}}}</script></body></html>'''
# A job DETAIL page. No `__NEXT_DATA__` anywhere on it — a different
# application serves this route — and one schema.org JobPosting instead.
JOB_DETAIL_HTML = r'''<html><head><title>Senior Software Engineer at Confident LIMS | Wellfound</title><script type="application/ld+json">{"@context": "http://schema.org/", "@type": "JobPosting", "title": "Senior Software Engineer", "identifier": {"@type": "PropertyValue", "name": "Confident LIMS", "value": "4697947"}, "employmentType": "FULL_TIME", "hiringOrganization": {"@type": "Organization", "name": "Confident LIMS", "sameAs": "https://www.confidentlims.com/", "logo": "https://photos.wellfound.com/startups/i/829771-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1742329133"}, "image": "https://photos.wellfound.com/startups/i/829771-IMAGEHASHPLACEHOLDER0000000000ab-medium_jpg.jpg?buster=1742329133", "industry": "SaaS, Information Technology, Analytics, Software, and Web Development", "directApply": true, "description": "<p>Confident powers analytical testing labs — the labs that test samples on behalf of businesses and report product safety to consumers and regulators at high volume. When a lab pu …[trimmed for the fixture]</p>", "datePosted": "2026-09-10T17:59:14Z", "jobLocation": [{"@type": "Place", "address": {"@type": "PostalAddress", "addressRegion": "California", "addressCountry": "United States"}, "latitude": 37.8272, "longitude": -122.291}], "jobLocationType": "TELECOMMUTE", "applicantLocationRequirements": [{"@type": "Country", "name": "Canada"}, {"@type": "Country", "name": "United States"}, {"@type": "Country", "name": "Argentina"}, {"@type": "Country", "name": "Brazil"}, {"@type": "Country", "name": "Chile"}, {"@type": "Country", "name": "Colombia"}, {"@type": "Country", "name": "Uruguay"}, {"@type": "Country", "name": "Paraguay"}, {"@type": "Country", "name": "Peru"}, {"@type": "Country", "name": "Venezuela"}, {"@type": "Country", "name": "Ecuador"}, {"@type": "Country", "name": "Bolivia"}, {"@type": "Country", "name": "Guyana"}, {"@type": "Country", "name": "Suriname"}, {"@type": "Country", "name": "Falkland Islands (Islas Malvinas)"}, {"@type": "Country", "name": "French Guiana"}], "experienceRequirements": {"@type": "OccupationalExperienceRequirements", "monthsOfExperience": 60}, "baseSalary": {"@type": "MonetaryAmount", "currency": "USD", "value": {"@type": "QuantitativeValue", "unitText": "YEAR", "minValue": 100000.0, "maxValue": 180000.0}}, "jobBenefits": "Lots of paid time off - We grant paid time off for more than just regular vacation days. 18 company holidays + up to 20 …[trimmed]"}</script></head><body><div>photos.wellfound.com</div></body></html>'''
# The branded refusal a plain HTTP client gets: HTTP 403, the site's own
# styling, its name in the markup. A title or text check calls it a real page.
BRANDED_BLOCK_HTML = r'''<html><head><title>Security Check | Wellfound</title></head><body><h1>403 / Security check</h1><p>Enable JavaScript and cookies to continue</p>pan></div></noscript></div></div><script>(function(){window._cf_chl_opt = {cFPWv: 'g',cH: 'alPEM224TOnZPTnmhgUnIcWCUlMSnL_ZEwTkeV4U80Y-1789658100-1.2.1.1-Z_dzARmbIdvGsbc1unYVFM3TCi9r2H.hzqK4V9HMb3hjJrovIQ7lDlKI8AXMCP0i',cITimeS: '1789658100',cRay: 'a3c910584fbcc7e7',cTplB: '0',cTplC:1,cTplO:0,cTplV:<script src='/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1?ray=a3c910584fbcc7e7'></script><p>Cloudflare Ray ID</p></body></html>'''
# The interstitial a real browser gets instead, from the same address.
INTERSTITIAL_HTML = r'''<html><head><title>Just a moment...</title></head><body><div id='cf-wrapper'></div><script>window._cf_chl_opt={cType:'managed',cZone:'wellfound.com',cRay:'a3c910584fbcc7e7'};</script><script src='/cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1'></script></body></html>'''
# A page Wellfound SERVED, carrying the site's OWN Cloudflare Turnstile.
#
# This fixture exists for one check and it is the most important one in the
# file: `challenges.cloudflare.com` and `turnstile` are on this GOOD page and
# absent from both refusals above, so the marker other sites rely on is
# inverted here. Counted on the real captures (2026-09-17): 1 and 6 on every
# served landing page, 0 and 0 on both refusals.
SERVED_WITH_SITE_TURNSTILE_HTML = LISTING_NEXT_DATA
# ---------------------------------------------------------------------------
# The parser, asserted on VALUES rather than on coverage
# ---------------------------------------------------------------------------
# A column can be 100% populated and entirely wrong, so every
# check below pins a figure from the real capture rather than counting
# non-nulls.
def check_listing_parses():
import product_parser as P
page = P.parse_listing_page(LISTING_NEXT_DATA, page=1)
equal("listing: row count", len(page.rows), 6)
equal("listing: totalJobCount read from the site", page.total_jobs, 1881)
equal("listing: totalStartupCount read from the site",
page.total_companies, 923)
equal("listing: pageCount read from the site", page.pages_available, 47)
equal("listing: perPage", page.page_size, 20)
equal("listing: the server's own page number", page.echoed_page, 1)
check("listing: page not repeated", not page.page_repeated)
equal("listing: every row carries the page it came from",
sorted({r.page for r in page.rows}), [1])
equal("listing: positions are 1..n in the site's order",
[r.position for r in page.rows], [1, 2, 3, 4, 5, 6])
equal("listing: data_source is recorded",
sorted({r.data_source for r in page.rows}), ["apollo"])
equal("listing: source column", sorted({r.source for r in page.rows}),
["wellfound.com"])
def check_listing_values():
import product_parser as P
rows = {r.sku: r for r in P.parse_listing(LISTING_NEXT_DATA)}
r = rows["4697947"]
equal("row: title", r.title, "Senior Software Engineer")
equal("row: url is rebuilt from id and slug", r.url,
"https://wellfound.com/jobs/4697947-senior-software-engineer")
equal("row: company name", r.company_name, "Confident LIMS")
equal("row: company id", r.company_id, "829771")
equal("row: company url", r.company_url,
"https://wellfound.com/company/confidentlims")
# `SIZE_11_50` is an enum token; the column carries the range it denotes.
equal("row: company size is the range, not the token", r.company_size,
"11-50")
equal("row: company tagline", r.company_tagline, "The easiest way to test")
check("row: badges are display labels, not enum names",
"Actively Hiring" in (r.company_badges or []),
repr(r.company_badges))
check("row: badge enum names are NOT written through",
not any("_BADGE" in b for b in (r.company_badges or [])),
repr(r.company_badges))
equal("row: job type", r.job_type, "full-time")
equal("row: primary role", r.primary_role, "Software Engineer")
equal("row: years of experience", r.years_experience_min, 5)
# liveStartAt is a unix timestamp; the column is UTC ISO-8601.
equal("row: posted_at is ISO, not an epoch", r.posted_at,
"2026-09-10T17:59:14+00:00")
equal("row: locations", r.locations, ["San Francisco Bay Area"])
equal("row: remote", r.remote, True)
equal("row: remote kind", r.remote_kind, "REMOTE")
check("row: accepted remote locations",
"United States" in (r.remote_locations or []),
repr(r.remote_locations))
def check_compensation_is_split_into_numbers():
"""The site publishes pay as ONE rendered string. These are the nine
shapes measured across the 312 non-null values in the 2026-09-17
captures, with their counts; each is pinned because getting any of them
wrong writes a wrong number into a column a consumer will average."""
import product_parser as P
def c(text):
return P.parse_compensation(text)
# 202 of 312
equal("pay: plain range, low", c("$140k – $250k").salary_min, 140000.0)
equal("pay: plain range, high", c("$140k – $250k").salary_max, 250000.0)
equal("pay: plain range, currency", c("$140k – $250k").currency, "USD")
# 41 of 312 — salary AND equity, split on U+2022
both = c("$180k – $200k • 0.5% – 1.0%")
equal("pay: with equity, salary low", both.salary_min, 180000.0)
equal("pay: with equity, equity low", both.equity_min, 0.5)
equal("pay: with equity, equity high", both.equity_max, 1.0)
equal("pay: with equity, has_equity", both.has_equity, True)
# 45 of 312 — "No equity" is a STATEMENT, not a missing value
ne = c("$36k – $60k • No equity")
equal("pay: no-equity keeps the salary", ne.salary_min, 36000.0)
equal("pay: no-equity is False, not None", ne.has_equity, False)
# 9 of 312 — equity clause alone
equal("pay: bare 'No equity' has no salary", c("No equity").salary_min, None)
equal("pay: bare 'No equity' is False", c("No equity").has_equity, False)
# 2 of 312 — an equity range with NO salary. Splitting on the bullet
# and taking [0] as the salary would write 0.5 into a salary column.
eq_only = c("0.5% – 1.0%")
equal("pay: equity-only has no salary", eq_only.salary_min, None)
equal("pay: equity-only reads as equity", eq_only.equity_min, 0.5)
# 8 of 312
equal("pay: GBP", c("£120k – £160k").currency, "GBP")
# 1 of 312
equal("pay: EUR", c("€60k – €90k").currency, "EUR")
# 3 of 312 — `L` is the Indian lakh, 1e5. Stripping non-digits would
# read this as 30 to 80.
inr = c("₹30L – ₹80L")
equal("pay: INR lakh, low", inr.salary_min, 3000000.0)
equal("pay: INR lakh, high", inr.salary_max, 8000000.0)
equal("pay: INR currency", inr.currency, "INR")
# 1 of 312 — Indian grouping is 2-2-3, so a three-digit group pattern
# would not match it.
mixed = c("₹1,50,000 – ₹30L • No equity")
equal("pay: Indian grouping", mixed.salary_min, 150000.0)
equal("pay: mixed grouping and lakh", mixed.salary_max, 3000000.0)
# absence
equal("pay: None in, nothing out", c(None).salary_min, None)
equal("pay: empty string in, nothing out", c("").has_equity, None)
# A currency this repo has never seen must NOT be guessed at.
equal("pay: no symbol means no currency", c("100k – 200k").currency, None)
def check_no_equity_on_a_real_capture():
"""`has_equity` False on rows that really say so — and the salary kept."""
import product_parser as P
rows = P.parse_listing(LISTING_NO_EQUITY_NEXT_DATA)
check("no-equity fixture parses", len(rows) >= 2, "got %d" % len(rows))
for r in rows:
equal("no-equity row %s: has_equity is False" % r.sku, r.has_equity,
False)
check("no-equity row %s: salary survived the split" % r.sku,
r.salary_min is not None, repr(r.compensation))
def check_salary_period_is_null_on_a_listing_row():
"""The listing string carries no period, so the column must not invent one.
`$140k` on a job board reads as a year, but the PAGE does not say so.
Never present a guess as a fact. The detail page does state
one, and the next check pins that it is read.
"""
import product_parser as P
rows = P.parse_listing(LISTING_NEXT_DATA)
equal("listing rows never claim a salary period",
sorted({r.salary_period for r in rows}), [None])
def check_feed_uses_different_types_and_one_parser_reads_both():
"""The /jobs feed's Apollo types differ from a landing page's.
`JobListing`/`Startup` rather than `JobListingSearchResult`/
`StartupResult`, the company edge points the other way, and three columns
are legitimately absent. A parser that knew only the landing shape would
return zero rows here — in silence.
"""
import product_parser as P
rows = P.parse_listing(FEED_NEXT_DATA)
equal("feed: row count", len(rows), 4)
check("feed: every row has an id", all(r.sku for r in rows))
check("feed: every row has a title", all(r.title for r in rows))
check("feed: every row resolved its company",
all(r.company_name for r in rows),
repr([r.company_name for r in rows]))
# Measured, and the reason `description` is NOT in CORE_FIELDS: the feed's
# thinner type carries none on any row.
equal("feed: no descriptions, and that is the site",
sorted({bool(r.description) for r in rows}), [False])
equal("feed: no badges either",
sorted({r.company_badges is None for r in rows}), [True])
# And no pagination arithmetic at all — there is no Results node.
page = P.parse_listing_page(FEED_NEXT_DATA, page=1)
equal("feed: publishes no pageCount", page.pages_available, None)
equal("feed: publishes no totalJobCount", page.total_jobs, None)
def check_detail_page_is_a_different_mechanism():
"""A job page has no `__NEXT_DATA__` and one schema.org JobPosting.
A DETAIL page may publish a different JSON-LD type than the listing,
here in its sharpest form: a different MECHANISM. The
failure it guards is silent — the listing parser returns [] rather than
raising — so it is pinned directly, in both directions.
"""
import product_parser as P
check("detail: carries no __NEXT_DATA__",
P.extract_next_data(JOB_DETAIL_HTML) is None)
equal("detail: the LISTING parser finds nothing on it",
P.parse_listing(JOB_DETAIL_HTML), [])
row = P.parse_detail(JOB_DETAIL_HTML,
"https://wellfound.com/jobs/4697947-senior-software-engineer")
check("detail: the detail parser finds the job", row is not None)
equal("detail: sku from the identifier block", row.sku, "4697947")
equal("detail: title", row.title, "Senior Software Engineer")
equal("detail: company", row.company_name, "Confident LIMS")
equal("detail: data_source", row.data_source, "jsonld")
equal("detail: employmentType mapped to the site's own word",
row.job_type, "full-time")
# monthsOfExperience 60 -> 5 years, the same figure the listing states.
equal("detail: months of experience become years",
row.years_experience_min, 5)
equal("detail: salary period IS stated here", row.salary_period, "YEAR")
check("detail: benefits text is read", bool(row.benefits))
check("detail: industry is read", bool(row.industry))
# schema.org has no expression for equity, and the column says so rather
# than reporting zero.
equal("detail: equity is absent, not zero", row.equity_min, None)
equal("detail: and the DETAIL parser finds nothing on a listing",
P.parse_detail(LISTING_NEXT_DATA), None)
def check_the_two_sources_agree_about_pay():
"""Where both views publish a salary they must agree.
Structured and displayed prices are checked for disagreement on every
new site. Here they do not: job 4697947 is
`'$100k – $180k • 0.05% – 0.25%'` on the listing and 100000-180000 USD in
the detail page's `baseSalary`. That agreement is what lets `--mode job`
enrich a `--mode role` run instead of contradicting it, and it is pinned
so a future parser change cannot break it quietly.
"""
import product_parser as P
listing = {r.sku: r for r in P.parse_listing(LISTING_NEXT_DATA)}["4697947"]
detail = P.parse_detail(JOB_DETAIL_HTML,
"https://wellfound.com/jobs/4697947-senior-software-engineer")
equal("cross-source: same sku", listing.sku, detail.sku)
equal("cross-source: salary low agrees", listing.salary_min,
detail.salary_min)
equal("cross-source: salary high agrees", listing.salary_max,
detail.salary_max)
equal("cross-source: currency agrees", listing.salary_currency,
detail.salary_currency)
equal("cross-source: years of experience agree",
listing.years_experience_min, detail.years_experience_min)
equal("cross-source: url agrees", listing.url, detail.url)
def check_an_out_of_range_page_is_detected_from_the_payload():
"""`?page=48` of a 47-page listing answers 200 with page 1 AGAIN.
Measured 2026-09-17: the response carried the same 37 job ids as page 1.
A run that trusted its own request would collect page 1 for as long as it
was asked to and report success. The server states which page it really
used inside the Apollo cache key, and that is an unambiguous signal
rather than the weaker "no new sku" heuristic.
"""
import product_parser as P
page = P.parse_listing_page(LISTING_NEXT_DATA, page=48)
equal("page echo: the server said page 1", page.echoed_page, 1)
equal("page echo: we asked for 48", page.requested_page, 48)
check("page echo: detected as a repeat", page.page_repeated)
# ...and the same payload read as page 1 is NOT a repeat.
check("page echo: no false positive on a real page 1",
not P.parse_listing_page(LISTING_NEXT_DATA, page=1).page_repeated)
def check_pagination_is_planned_from_the_sites_own_number():
import product_parser as P
import page_flow
# The site says 47; asking for 60 plans 47, not 60.
equal("pages: capped by the site's own pageCount",
P.pages_to_fetch(60, 47), 47)
equal("pages: a smaller request is honoured", P.pages_to_fetch(3, 47), 3)
equal("pages: unknown ceiling honours the request",
P.pages_to_fetch(5, None), 5)
equal("pages: page_flow agrees with the parser",
page_flow.pages_to_plan(60, 47), P.pages_to_fetch(60, 47))
# There is no hard ceiling on top of the site's figure. Unlike a sibling
# repo, Wellfound does not cap what it will serve, and a cap here would
# silently truncate a legitimate run.
equal("pages: no invented ceiling", page_flow.pages_to_plan(200, 200), 200)
def check_only_the_landing_routes_are_addressable():
"""The two page kinds give OPPOSITE answers, so the question is per URL.
`?page=2` on /jobs does not fail and does not empty — it returns the
identical 46 job ids as page 1. A `page_url()` used unconditionally there
would find no new sku, conclude the listing was exhausted, and report a
COMPLETE run holding page 1.
"""
import page_flow
for url, want in (
("https://wellfound.com/role/r/software-engineer", True),
("https://wellfound.com/role/software-engineer", True),
("https://wellfound.com/role/l/sales/london", True),
("https://wellfound.com/jobs", False),
("https://wellfound.com/jobs/4697947-senior-software-engineer", False)):
equal("addressable: %s" % url, page_flow.pagination_is_addressable(url),
want)
def check_url_building():
import product_parser as P
equal("url: page 1 carries no ?page",
P.page_url("https://wellfound.com/role/r/software-engineer", 1),
"https://wellfound.com/role/r/software-engineer")
equal("url: page N",
P.page_url("https://wellfound.com/role/r/software-engineer", 3),
"https://wellfound.com/role/r/software-engineer?page=3")
# Replaced, not duplicated, and the other parameters survive.
equal("url: ?page is replaced, not appended",
P.page_url("https://wellfound.com/role/r/x?page=2&utm=a", 5),
"https://wellfound.com/role/r/x?utm=a&page=5")
equal("url: role", P.role_url("software-engineer"),
"https://wellfound.com/role/software-engineer")
equal("url: remote role", P.role_url("software-engineer", remote=True),
"https://wellfound.com/role/r/software-engineer")
equal("url: role + location",
P.role_url("data-scientist", location="new-york"),
"https://wellfound.com/role/l/data-scientist/new-york")
# A location wins over --remote: /role/l/ is the only shape that takes one.
equal("url: location wins over remote",
P.role_url("sales", location="london", remote=True),
"https://wellfound.com/role/l/sales/london")
equal("url: job", P.job_url("4697947", "senior-software-engineer"),
"https://wellfound.com/jobs/4697947-senior-software-engineer")
equal("url: sku recovered from a job URL",
P.sku_from_url("https://wellfound.com/jobs/4697947-senior-software-engineer"),
"4697947")
def check_route_and_mode_are_derived_from_the_url():
import product_parser as P
# `/role/r/x` must beat `/role/x`, or the literal "r" becomes a role slug.
equal("route: /role/r/ is not a role named 'r'",
P.route_of("https://wellfound.com/role/r/software-engineer"),
"role_remote")
equal("route: /role/l/", P.route_of("https://wellfound.com/role/l/sales/london"),
"role_location")
equal("route: /role/", P.route_of("https://wellfound.com/role/sales"),
"role_plain")
equal("route: /jobs", P.route_of("https://wellfound.com/jobs"), "jobs_feed")
equal("route: /jobs/{id}-{slug}",
P.route_of("https://wellfound.com/jobs/4697947-x"), "job_detail")
equal("route: /company/", P.route_of("https://wellfound.com/company/openai"),
"company")
for url, mode in (("https://wellfound.com/role/r/x", "role"),
("https://wellfound.com/jobs", "jobs"),
("https://wellfound.com/jobs/1-x", "job")):
equal("mode from %s" % url, P.mode_for_url(url), mode)
# Wellfound's own robots.txt, fetched 2026-10-13 through a residential exit
# (it answers 403 to a datacentre address, like every other route here).
# Inline rather than in a fixtures directory, like every fixture here.
ROBOTS_TXT = """#
# Hey human,
#
# We like robots too. Come talk robot with us.
#
# https://wellfound.com/company/wellfound/jobs
#
User-agent: *
Disallow: /_jobs/
Disallow: /*?after_sign_in=*
Disallow: /*?inFrame=*
Disallow: /*?jobId=*
Disallow: /*?jobSlug=*
Disallow: /*?preview=*
Disallow: /*?role=*
Disallow: /*&inFrame=*
Disallow: /*&jobId=*
Disallow: /*&jobSlug=*
Disallow: /*&preview=*
Disallow: /auth/
Disallow: /cdn-cgi/
Disallow: /documents/
Disallow: /embed/
Disallow: /job_listings/report_company
Disallow: /job_pairings/howitworks
Disallow: /job_profiles/embed
Disallow: /jobs/applications
Disallow: /jobs/signup
Disallow: /onboarding
Disallow: /profile/edit
Disallow: /profile/notifications
Disallow: /profile/review
Disallow: /profile/resume
Disallow: /projects/
Disallow: /re/
Disallow: /recruit/dashboard
Disallow: /search
Disallow: /social/share_modal
Disallow: /u/
Sitemap: https://wellfound.com/sitemap.xml.gz
Sitemap: https://wellfound.com/blog-index.xml.gz
"""
def check_every_route_this_scraper_reads_is_robots_allowed():
"""The README says this scraper reads only routes `robots.txt` allows.
That was a claim about BEHAVIOUR with no mechanism behind it — the word
appeared in comments and nowhere in the code — which a third-party audit
raised on 2026-10-13 and was right to raise.
It is a CHECK rather than a runtime gate, and that is a decision worth
stating. A preflight fetch of robots.txt would add a request that is
itself refused from a datacentre address (403, like every route here),
so the gate would either fail closed exactly where the scraper already
fails — a second failure mode for no safety gained — or fail open, which
is a gate that does not gate. What makes the claim true is that the
routes this scraper ACCEPTS are allowed and the disallowed ones have no
mode; that is a property of the code, tested here and re-tested against
the live file by the canary.
"""
import urllib.robotparser
import product_parser as P
rp = urllib.robotparser.RobotFileParser()
rp.parse(ROBOTS_TXT.splitlines())
for path in ("/", "/jobs", "/role/software-engineer",
"/role/r/software-engineer",
"/role/l/data-scientist/new-york",
"/jobs/4697947-senior-software-engineer"):
check("robots allows a route this scraper reads: " + path,
rp.can_fetch("*", "https://wellfound.com" + path), path)
# ...and every route robots disallows must have no mode at all. `/u/` is
# a candidate's own profile, which CONTRIBUTING also refuses on data
# grounds rather than only on this one.
for path in ("/u/someone", "/_jobs/anything", "/jobs/signup",
"/auth/login", "/profile/resume"):
url = "https://wellfound.com" + path
check("robots disallows " + path + ", and so does the parser",
not rp.can_fetch("*", url) and not P.is_supported_url(url)[0],
path)
# The guard must have had something to read, or it passes for the wrong
# reason: an empty robots file allows everything.
rules = [l for l in ROBOTS_TXT.splitlines()
if l.lower().startswith("disallow")]
check("...and the fixture really carries rules", len(rules) > 10,
str(len(rules)) + " Disallow line(s)")
def check_unsupported_urls_are_refused_with_a_reason():
"""A wrong reason sends the reader hunting for a typo."""
import product_parser as P
ok, why = P.is_supported_url("https://wellfound.com/role/r/software-engineer")
check("supported: a landing page is accepted", ok, why)
ok, why = P.is_supported_url("https://example.com/role/r/x")
check("supported: another host is refused", not ok)
check("supported: ...and the reason names the host", "example.com" in why, why)
# angel.co really IS Wellfound — it redirects here — so "not a Wellfound
# host" would be a false statement, not merely an unhelpful one.
ok, why = P.is_supported_url("https://angel.co/role/r/x")
check("supported: angel.co is refused", not ok)
check("supported: ...and the reason says it redirects here",
"redirects" in why.lower(), why)
# /company/ is a Wellfound route and IS gated; the refusal says which.
ok, why = P.is_supported_url("https://wellfound.com/company/openai")
check("supported: /company/ is refused", not ok)
check("supported: ...and the reason names Cloudflare, not a typo",
"Cloudflare" in why, why)
ok, why = P.is_supported_url("https://wellfound.com/nothing/here")
check("supported: an unknown route is refused", not ok)
def check_category_label_names_the_query():
import product_parser as P
equal("label: remote role",
P.category_from_url("https://wellfound.com/role/r/software-engineer"),
"role/software-engineer/remote")
equal("label: role + location",
P.category_from_url("https://wellfound.com/role/l/data-scientist/new-york"),
"role/data-scientist@new-york")
equal("label: the feed",
P.category_from_url("https://wellfound.com/jobs"), "jobs")
equal("label: one job",
P.category_from_url("https://wellfound.com/jobs/4697947-x"),
"job/4697947")
def check_page_states_on_real_captures():
import product_parser as P
equal("state: a landing page is content",
P.detect_page_state(SERVED_WITH_SITE_TURNSTILE_HTML, 200, mode="role"),
"content")
equal("state: a job page is content",
P.detect_page_state(JOB_DETAIL_HTML, 200, mode="job"), "content")
equal("state: the branded 403 is blocked",
P.detect_page_state(BRANDED_BLOCK_HTML, 403, mode="role"), "blocked")
equal("state: the interstitial is blocked",
P.detect_page_state(INTERSTITIAL_HTML, 403, mode="role"), "blocked")
# The status alone settles it, because the branded refusal wears the
# site's own name and a text check calls it a real page.
equal("state: 403 is blocked whatever the markup says",
P.detect_page_state("<html><title>Wellfound</title></html>", 403),
"blocked")
equal("state: 404 is not a block", P.detect_page_state("<html></html>", 404),
"not_found")
# A served page whose payload we could not read is OUR bug, never
# "0 jobs".
broken = ('<html><body><img src="https://photos.wellfound.com/x.jpg">'
'<script id="__NEXT_DATA__" type="application/json">'
'{"props":{"pageProps":{"apolloState":{"data":{"ROOT_QUERY":{}}}}}}'
'</script></body></html>')
equal("state: served but unreadable is parse_error, not empty",
P.detect_page_state(broken, 200, mode="role"), "parse_error")
def check_markers_do_not_match_a_page_wellfound_serves():
"""Count every marker on a page known GOOD before trusting it.
This is the check that caught the mistake this repo would otherwise have
shipped. `challenges.cloudflare.com` is the marker the rest of this
family settled on, and on Wellfound it is INVERTED: the site loads
Cloudflare Turnstile as part of its own application — the api.js script,
a published `CLOUDFLARE_TURNSTILE_SITE_KEY`, and a fetch wrapper that
renders a widget into `#turnstile_widget` — so the marker is on every
good page and on neither refusal.
"""
import product_parser as P
good = SERVED_WITH_SITE_TURNSTILE_HTML
# The evidence, asserted rather than described:
check("marker evidence: a GOOD page carries challenges.cloudflare.com",
"challenges.cloudflare.com" in good)
check("marker evidence: neither refusal does",
"challenges.cloudflare.com" not in BRANDED_BLOCK_HTML
and "challenges.cloudflare.com" not in INTERSTITIAL_HTML)
# ...therefore it must not be in the set, and nor may a bare
# `cdn-cgi/challenge-platform`, which a served page loads too (the
# passive JS-detections beacon at .../scripts/jsd/main.js).
markers = [m for _, m in P.BOT_CHALLENGE_MARKERS]
for banned in ("challenges.cloudflare.com", "cf-turnstile", "turnstile"):
check("marker set excludes %r" % banned, banned not in markers,
repr(markers))
check("marker set does not use the bare cdn-cgi prefix",
"cdn-cgi/challenge-platform" not in markers, repr(markers))
# The set must be silent on every good page and fire on both refusals.
equal("markers: silent on a served landing page",
P.detect_bot_challenge(good), None)
equal("markers: silent on a served job page",
P.detect_bot_challenge(JOB_DETAIL_HTML), None)
equal("markers: fire on the branded 403",
P.detect_bot_challenge(BRANDED_BLOCK_HTML), "cloudflare")
equal("markers: fire on the interstitial",
P.detect_bot_challenge(INTERSTITIAL_HTML), "cloudflare")
def check_only_one_refusal_skin_carries_a_widget():
"""The two refusals are the same vendor and NOT the same page.
Counted 2026-09-18 across four real refusals:
branded "Security Check | Wellfound" _cf_chl_opt 7x, turnstile 0x
"Just a moment..." (browser) _cf_chl_opt 7x, turnstile 1x
So the no-JS skin carries **no widget at all**, and that is the precise
sense in which a page is unsolvable ("unsolvable" is a
property of a PAGE with nothing on it, never of a vendor). Paying for a
task built from a page like that buys an ERROR_CAPTCHA_UNSOLVABLE, which
is why the engines refuse to build one without a captured sitekey.
Both must still classify as blocked, because a caller's move is the same
either way — a different exit.
"""
import product_parser as P
for label, html in (("branded", BRANDED_BLOCK_HTML),
("interstitial", INTERSTITIAL_HTML)):
equal("%s refusal is detected as Cloudflare" % label,
P.detect_bot_challenge(html), "cloudflare")
equal("%s refusal is state=blocked" % label,
P.detect_page_state(html, 403, mode="role"), "blocked")
# Neither publishes a sitekey a static read could use: Cloudflare
# calls turnstile.render() once and keeps nothing.
equal("%s refusal publishes no usable sitekey" % label,
P.site_turnstile_sitekey(html), None)
# The branded skin specifically has nothing on it at all.
for marker in ("challenges.cloudflare.com", "cf-turnstile",
"turnstile.render"):
check("the branded refusal carries no %r" % marker,
marker not in BRANDED_BLOCK_HTML)
def check_the_sites_own_turnstile_is_recognised_as_configured():
""""Did we meet a captcha" is the wrong question; "is one configured, and
would we recognise it" is the right one.
One is: Wellfound publishes its sitekey in its own page config and
renders a widget with it when its own fetch wrapper is challenged. That
widget IS solvable from a static read — unlike Cloudflare's interstitial,
which publishes no sitekey at all and needs the `turnstile.render`
interception hook.
"""
import product_parser as P
equal("site turnstile: sitekey read from the page's own config",
P.site_turnstile_sitekey(SERVED_WITH_SITE_TURNSTILE_HTML),
"0x4AAAAAAAgpA-Qx7SsJOW-g")
equal("site turnstile: a refusal publishes none",
P.site_turnstile_sitekey(BRANDED_BLOCK_HTML), None)
equal("site turnstile: the interstitial publishes none",
P.site_turnstile_sitekey(INTERSTITIAL_HTML), None)
def check_positive_asset_detection():
""""Was this built out of the site's own assets?" — the only signal that
answers correctly for Chromium's own network-error page, which carries
the site's hostname in its <title> and nothing else of the site's."""
import product_parser as P
check("assets: a served page references them",
P.references_own_assets(SERVED_WITH_SITE_TURNSTILE_HTML) > 0)
equal("assets: the branded refusal references none",
P.references_own_assets(BRANDED_BLOCK_HTML), 0)
equal("assets: the interstitial references none",
P.references_own_assets(INTERSTITIAL_HTML), 0)
chrome_error = ("<html><head><title>wellfound.com</title></head><body>"
"<div>ERR_PROXY_CONNECTION_FAILED</div></body></html>")
equal("assets: Chromium's own error page references none",
P.references_own_assets(chrome_error), 0)
equal("assets: ...and it does not classify as content",
P.detect_page_state(chrome_error, None, mode="role"), "unknown")
def check_company_size_tokens():
import product_parser as P
for token, want in (("SIZE_1_10", "1-10"), ("SIZE_11_50", "11-50"),
("SIZE_201_500", "201-500"),
("SIZE_5000_PLUS", "5000+")):
equal("size: %s" % token, P.company_size(token), want)
# An unknown band is passed through, not dropped: a new value must show
# up in the data rather than look like a missing one.
equal("size: an unknown token survives", P.company_size("SIZE_WEIRD"),
"SIZE_WEIRD")
equal("size: absent stays absent", P.company_size(None), None)
def check_content_not_obtained_is_never_an_empty_result():
"""A page we never read must not report "0 jobs".
Found by a third-party audit on 2026-10-13. The chain was four links
long and every one of them looked right on its own: `unknown` ->
`should_parse` False -> an outcome still marked `ok` (it checked only
`load_failed` and `blocked_by`) -> `stop_reason` left at "completed" ->
zero rows -> exit 4. `finish_run` has had the exit-5 branch since the
family unified on it, and nothing ever reached it, because nothing told
it the run had failed.
`empty` is the one that must stay exit 4: the site served a listing and
it matched nothing, which is an answer about the catalogue. The other
three are not.
"""
import page_flow
from output_writer import COMPLETE_STOP_REASONS
# `not_found` is in the OBTAINED list on purpose: a 404 is the site
# answering definitively that the address holds nothing, so a consumer
# can conclude something about the catalogue from it. `unknown` and
# `parse_error` say nothing at all.
equal("an answered page counts as obtained",
[s for s in ("content", "empty", "not_found")
if page_flow.content_obtained(s)],
["content", "empty", "not_found"])
for state in ("unknown", "parse_error", "blocked"):
check("%s does NOT count as obtained" % state,
not page_flow.content_obtained(state))
reason = page_flow.stop_reason_for(False, None, state)
check("...and its stop_reason is not a COMPLETE one",
reason not in COMPLETE_STOP_REASONS, reason)
equal("an answered page keeps the plain reason",
page_flow.stop_reason_for(False, None, "empty"), "completed")
# A transport failure and a block still win over the state, because they
# say more about what happened.
equal("a load failure is still a load failure",
page_flow.stop_reason_for(True, None, "unknown"), "page_load_timeout")
equal("a block is still a block",
page_flow.stop_reason_for(False, "cloudflare", "blocked"),
"blocked_cloudflare")
# And the engines must consult it rather than keeping their own copy.
for module in ENGINES:
engine = _import_engine(module)
if engine is None:
skip("outcome-ok", "%s not importable here" % module)
continue
for state, want_ok in (("content", True), ("empty", True),
("not_found", True),
("unknown", False), ("parse_error", False)):
o = engine.PageOutcome(page_num=1, url="u")
o.state = state
equal("%s.PageOutcome.ok for state=%s" % (module, state),
o.ok, want_ok)
def check_a_page_that_yielded_nothing_is_dumped():
"""A state that holds no rows is one somebody will have to diagnose.
The blocked path has always written a debug dump. The
content-not-obtained path wrote NOTHING — so a failing run left no
artefact, and the 2026-10-13 audit's own words were "тело ответа не было
сохранено для последующего анализа". It was right, and that is half of
why the cause was misread: there was nothing to look at.
Asserted on the source rather than by driving a browser, because the
branch needs a live navigation and the suite must pass with no engine
library installed.
"""
for module in ENGINES:
path = os.path.join(HERE, module + ".py")
if not os.path.exists(path):
continue
source = open(path, encoding="utf-8").read()
marker = "nothing to parse"
check("%s has the content-not-obtained branch" % module,
marker in source)
if marker not in source:
continue
branch = source[source.index(marker) - 1200:source.index(marker) + 400]
check("%s dumps the body on that branch" % module,
"_debug.html" in branch,
"the branch writes no artefact to diagnose from")
check("%s reports the size and the asset count with it" % module,
"references_own_assets" in branch,
"a dump with no numbers beside it is harder to read")
def check_retries_counts_attempts_and_refuses_zero():
"""`--retries 0` ran no navigation at all and judged `about:blank`.
The load loop is `range(1, retries + 1)`, so zero never entered it:
`goto` was not called, and the engine then classified a 39-byte empty
DOM. That is how the 2026-10-13 audit concluded the site had stopped
serving this scraper — its own command had fetched nothing. The same
command with `--retries 1` returned the site's real refusal (11,922
bytes, `blocked`, exit 3).
Rejected rather than clamped: a run that silently becomes a different
run is exactly how the misreading happened.
"""
import argparse as _ap
import page_flow
equal("--retries 1 is accepted", page_flow.attempts("1"), 1)
equal("--retries 3 is accepted", page_flow.attempts("3"), 3)
for bad in ("0", "-1"):
try:
page_flow.attempts(bad)
check("--retries %s is refused" % bad, False, "it was accepted")
except _ap.ArgumentTypeError as e:
check("--retries %s is refused with a reason" % bad,
"at least 1" in str(e), str(e))
try:
page_flow.attempts("two")
check("a non-integer is refused", False, "it was accepted")
except _ap.ArgumentTypeError:
check("a non-integer is refused", True)
# Every engine must route the flag through it, or the guard is decorative.
for module in ENGINES:
path = os.path.join(HERE, module + ".py")
if not os.path.exists(path):
continue
source = open(path, encoding="utf-8").read()
check("%s parses --retries through page_flow.attempts" % module,
'"--retries", type=page_flow.attempts' in source,
"it still takes a bare int")
def check_state_policy():
import page_flow
equal("every state has a policy",
sorted(page_flow.STATE_POLICY),
["blocked", "content", "empty", "not_found", "parse_error",
"unknown"])
check("content: parsed, not retried, not blocked",
page_flow.should_parse("content")
and not page_flow.should_retry("content")
and not page_flow.counts_as_blocked("content"))
# ONE blocked state, not two, because Wellfound's two refusal skins —
# the branded 403 and the "Just a moment..." interstitial — are the same
# decision by the same vendor and answer to the same two moves. Solving
# is True because the challenge IS a test once the render hook has
# captured its parameters; retrying is True because a different exit
# clears it outright, which is measured and much cheaper.
check("blocked: retried, solvable, counts as blocked",
page_flow.should_retry("blocked")
and page_flow.should_solve("blocked")
and page_flow.counts_as_blocked("blocked"))
# A served page we could not read is OUR bug, never "0 jobs", so
# it neither counts as blocked nor buys a solve.
check("parse_error: retried once, never solved, NOT blocked",
page_flow.should_retry("parse_error")
and not page_flow.should_solve("parse_error")
and not page_flow.counts_as_blocked("parse_error")
and not page_flow.should_parse("parse_error"))
# Retrying an address that does not exist is waste, and calling it a
# block sends a user rotating proxies over a typo.
check("not_found: not retried, not blocked",
not page_flow.should_retry("not_found")
and not page_flow.counts_as_blocked("not_found"))
check("unknown: retried, not solved, NOT blocked",
page_flow.should_retry("unknown")
and not page_flow.should_solve("unknown")
and not page_flow.counts_as_blocked("unknown"))
check("an unrecognised state falls back to unknown's policy",
page_flow.should_retry("something-new")
and not page_flow.should_solve("something-new"))
equal("at most one solve per page", page_flow.SOLVES_PER_PAGE, 1)
def check_policy_constants_have_a_consumer():
"""A policy constant nothing reads is the same defect as dead code.
`RETRY_ON_BLOCKED` carried a paragraph of justification in a sibling repo
and no engine consulted it, so setting it False changed nothing.
"""
import page_flow
sources = []
for name in ("playwright_scraper.py", "selenium_scraper.py",
"puppeteer_scraper.py", "scraper_api_client.py"):
path = os.path.join(HERE, name)
if os.path.exists(path):
sources.append(open(path, encoding="utf-8").read())
joined = "\n".join(sources)
for constant in ("RETRY_ON_BLOCKED", "BLOCK_RETRIES_WITHOUT_POOL",
"SOLVES_PER_PAGE"):
check("page_flow.%s is CONSULTED by an engine" % constant,
constant in joined,
"defined in page_flow and read by nothing")
for fn in ("pages_to_plan", "ready_selector", "min_matches",
"content_timeout_ms", "wait_for_count", "classify",
"should_retry", "should_solve", "counts_as_blocked",
"should_parse", "concurrency_limit",
"pagination_is_addressable"):
check("page_flow.%s has a caller outside its own module" % fn,
fn in joined, "unused policy")
# ---------------------------------------------------------------------------
# The output contract
# ---------------------------------------------------------------------------
def check_row_schema():
from output_writer import JobPosting, ROW_CLASS_BY_MODE, UNIQUE_BY_SKU_MODES
names = [f.name for f in fields(JobPosting)]
equal("the family prefix is byte-identical and in order",
names[:5], ["source", "scraped_at", "url", "sku", "title"])
for gone in ("price", "currency", "original_price", "discount_pct",
"in_stock", "brand"):
check("the commerce column %r is absent, not null-forever" % gone,
gone not in names)
# `rating` is absent because Wellfound publishes none on any route this
# scraper reads: 0 rating fields across 432 job records and 210 company
# records in the 2026-09-17 captures. The nearest thing is a company
# BADGE ("Highly Rated"), which is a label and not a scale.
check("`rating` is absent — the site publishes none",
"rating" not in names)
check("...and the badges that stand in for it are present",
"company_badges" in names)
# A salary is a RANGE, which is two columns and not one, and equity is a
# second range no shop repo has at all.
for needed in ("salary_min", "salary_max", "salary_currency",
"equity_min", "equity_max", "has_equity", "compensation"):
check("the pay column %r is present" % needed, needed in names)
equal("every mode maps to a row class",
sorted(ROW_CLASS_BY_MODE), ["job", "jobs", "role"])
equal("every mode is one row per sku",
sorted(UNIQUE_BY_SKU_MODES), ["job", "jobs", "role"])
equal("all three modes share one class",
len({c for c in ROW_CLASS_BY_MODE.values()}), 1)
def check_csv_and_json_writers():
from output_writer import JobPosting, write_csv, write_json
import product_parser as P
rows = P.parse_listing(LISTING_NEXT_DATA)
with tempfile.TemporaryDirectory() as tmp:
csv_path = os.path.join(tmp, "out.csv")
write_csv(rows, csv_path, row_cls=JobPosting)
with open(csv_path, encoding="utf-8") as f:
reader = list(csv.reader(f))
equal("CSV header matches the dataclass, in order",
reader[0], [f.name for f in fields(JobPosting)])
equal("CSV holds every row", len(reader) - 1, len(rows))
# `company_badges` is the list column on this site; a landing row
# carries several, so the join is visible.
badges_col = reader[0].index("company_badges")
joined = [r[badges_col] for r in reader[1:] if r[badges_col]]
check("a list column is joined readably rather than repr()'d",
any(" | " in v for v in joined), repr(joined[:2]))
check("no Python list repr leaked into the CSV",
not any(cell.startswith("[") for row in reader[1:] for cell in row))
empty_csv = os.path.join(tmp, "empty.csv")
write_csv([], empty_csv, row_cls=JobPosting)
with open(empty_csv, encoding="utf-8") as f:
header = list(csv.reader(f))
equal("an EMPTY csv still carries its header", len(header), 1)
equal("...and it is the right one", header[0],
[f.name for f in fields(JobPosting)])
json_path = os.path.join(tmp, "out.json")
write_json(rows, json_path)
loaded = json.load(open(json_path, encoding="utf-8"))
equal("JSON holds every row", len(loaded), len(rows))
equal("JSON keys are the dataclass fields, in order",
list(loaded[0].keys()), [f.name for f in fields(JobPosting)])