Skip to content

Commit a3e874a

Browse files
committed
Fix scrape availability: async/sync conflict broke all ExpertFlyer lookups
- Add missing credentials_available() method to ExpertFlyerScraper - Remove async from batch.check_itinerary_availability (Playwright sync API conflicts with asyncio event loop, causing all queries to error) - Fix CLI display to handle DClassResult objects (not dicts) - Add 2 max-stop test fixtures: CAI DONE6 16-seg, OSL DONE4 16-seg - Both verified fully bookable via real ExpertFlyer D-class checks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 478d181 commit a3e874a

File tree

6 files changed

+234
-10
lines changed

6 files changed

+234
-10
lines changed

rtw/cli.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -656,19 +656,26 @@ def scrape_availability(
656656
_setup_logging(verbose, quiet)
657657
try:
658658
itinerary = _load_itinerary(file)
659-
import asyncio
660659
from rtw.scraper.batch import check_itinerary_availability
661660

662-
results = asyncio.run(check_itinerary_availability(itinerary, booking_class))
661+
results = check_itinerary_availability(itinerary, booking_class)
663662

664663
typer.echo("Availability Results:")
665664
for i, r in enumerate(results):
666665
seg = itinerary.segments[i]
667666
route = f"{seg.from_airport}-{seg.to_airport}"
668667
if r is not None:
669-
avail = "AVAILABLE" if r.get("available") else "NOT AVAILABLE"
670-
seats = r.get("seats", "?")
671-
typer.echo(f" {i + 1}. {route}: {avail} ({seats} seats)")
668+
if hasattr(r, "available"):
669+
avail = "AVAILABLE" if r.available else "NOT AVAILABLE"
670+
seats = getattr(r, "seats", "?")
671+
display = getattr(r, "display_code", f"{seats} seats")
672+
flights = getattr(r, "flight_count", 0)
673+
extra = f" ({flights} flights)" if flights else ""
674+
typer.echo(f" {i + 1}. {route}: {avail}{display}{extra}")
675+
else:
676+
avail = "AVAILABLE" if r.get("available") else "NOT AVAILABLE"
677+
seats = r.get("seats", "?")
678+
typer.echo(f" {i + 1}. {route}: {avail} ({seats} seats)")
672679
elif seg.is_surface:
673680
typer.echo(f" {i + 1}. {route}: SURFACE (no flight)")
674681
else:

rtw/scraper/batch.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def _try_playwright_price(origin, dest, seg_date, cabin):
128128
return search_playwright_sync(origin, dest, seg_date, cabin)
129129

130130

131-
async def check_itinerary_availability(
131+
def check_itinerary_availability(
132132
itinerary: Itinerary,
133133
booking_class: str = "D",
134134
) -> list[Optional[dict]]:
@@ -156,7 +156,7 @@ async def check_itinerary_availability(
156156
continue
157157

158158
try:
159-
avail = await scraper.check_availability(
159+
avail = scraper.check_availability(
160160
origin=seg.from_airport,
161161
dest=seg.to_airport,
162162
date=seg.date,

rtw/scraper/expertflyer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ def __init__(self, session_path: Optional[str] = None) -> None:
109109
self._page = None
110110
self._logged_in = False
111111

112+
def credentials_available(self) -> bool:
113+
"""Check if ExpertFlyer credentials are stored in keychain."""
114+
return _get_credentials() is not None
115+
112116
def __enter__(self) -> "ExpertFlyerScraper":
113117
self._ensure_browser()
114118
return self
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Cairo DONE6 Eastbound — 16 segments, 6 continents, maximum value
2+
# Cheapest origin ($5,500 base), all 6 continents via JNB for sub-Saharan Africa
3+
# Direction: TC2 → TC3 → TC1 → TC2 (eastbound)
4+
# Continents: EU/ME, Africa, Asia, SWP, N.America, S.America
5+
ticket:
6+
type: DONE6
7+
cabin: business
8+
origin: CAI
9+
passengers: 1
10+
11+
segments:
12+
# EU/ME
13+
- from: CAI
14+
to: AMM
15+
carrier: RJ
16+
type: transit
17+
date: 2026-03-10
18+
# EU/ME
19+
- from: AMM
20+
to: DOH
21+
carrier: QR
22+
type: stopover
23+
date: 2026-03-10
24+
# EU/ME → Africa (sub-Saharan)
25+
- from: DOH
26+
to: JNB
27+
carrier: QR
28+
type: stopover
29+
date: 2026-03-14
30+
# Africa → Asia
31+
- from: JNB
32+
to: HKG
33+
carrier: CX
34+
type: stopover
35+
date: 2026-03-18
36+
# Asia
37+
- from: HKG
38+
to: NRT
39+
carrier: CX
40+
type: stopover
41+
date: 2026-03-22
42+
# Asia
43+
- from: NRT
44+
to: BKK
45+
carrier: JL
46+
type: stopover
47+
date: 2026-03-26
48+
# Asia (4th — at limit)
49+
- from: BKK
50+
to: KUL
51+
carrier: MH
52+
type: transit
53+
date: 2026-03-29
54+
# Asia → SWP
55+
- from: KUL
56+
to: SYD
57+
carrier: MH
58+
type: stopover
59+
date: 2026-03-29
60+
# SWP
61+
- from: SYD
62+
to: AKL
63+
carrier: QF
64+
type: stopover
65+
date: 2026-04-02
66+
# SWP
67+
- from: AKL
68+
to: NAN
69+
carrier: FJ
70+
type: transit
71+
date: 2026-04-05
72+
# SWP → N.America (Pacific crossing)
73+
- from: NAN
74+
to: LAX
75+
carrier: FJ
76+
type: stopover
77+
date: 2026-04-05
78+
# N.America
79+
- from: LAX
80+
to: ORD
81+
carrier: AA
82+
type: transit
83+
date: 2026-04-09
84+
# N.America
85+
- from: ORD
86+
to: MIA
87+
carrier: AA
88+
type: stopover
89+
date: 2026-04-09
90+
# N.America → S.America
91+
- from: MIA
92+
to: GRU
93+
carrier: AA
94+
type: stopover
95+
date: 2026-04-13
96+
# S.America → EU/ME (Atlantic crossing)
97+
- from: GRU
98+
to: LHR
99+
carrier: BA
100+
type: transit
101+
date: 2026-04-17
102+
# EU/ME (return)
103+
- from: LHR
104+
to: CAI
105+
carrier: BA
106+
type: stopover
107+
date: 2026-04-17
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Oslo DONE4 Westbound — 16 segments, 4 continents, Norway filing bargain
2+
# Second-cheapest business origin ($5,400 base), max intra-continent hops
3+
# Direction: TC2 → TC1 → TC3 → TC2 (westbound)
4+
# Continents: EU/ME, N.America, Asia, SWP
5+
ticket:
6+
type: DONE4
7+
cabin: business
8+
origin: OSL
9+
passengers: 1
10+
11+
segments:
12+
# EU/ME
13+
- from: OSL
14+
to: LHR
15+
carrier: BA
16+
type: transit
17+
date: 2026-03-10
18+
# EU/ME
19+
- from: LHR
20+
to: MAD
21+
carrier: IB
22+
type: stopover
23+
date: 2026-03-10
24+
# EU/ME → N.America (Atlantic crossing)
25+
- from: MAD
26+
to: JFK
27+
carrier: IB
28+
type: stopover
29+
date: 2026-03-14
30+
# N.America
31+
- from: JFK
32+
to: MIA
33+
carrier: AA
34+
type: stopover
35+
date: 2026-03-18
36+
# N.America
37+
- from: MIA
38+
to: ORD
39+
carrier: AA
40+
type: stopover
41+
date: 2026-03-22
42+
# N.America
43+
- from: ORD
44+
to: DFW
45+
carrier: AA
46+
type: transit
47+
date: 2026-03-25
48+
# N.America
49+
- from: DFW
50+
to: HNL
51+
carrier: AA
52+
type: stopover
53+
date: 2026-03-25
54+
# N.America → Asia (Pacific crossing)
55+
- from: HNL
56+
to: NRT
57+
carrier: JL
58+
type: stopover
59+
date: 2026-03-29
60+
# Asia
61+
- from: NRT
62+
to: HKG
63+
carrier: CX
64+
type: stopover
65+
date: 2026-04-02
66+
# Asia
67+
- from: HKG
68+
to: BKK
69+
carrier: CX
70+
type: stopover
71+
date: 2026-04-06
72+
# Asia
73+
- from: BKK
74+
to: KUL
75+
carrier: MH
76+
type: transit
77+
date: 2026-04-09
78+
# Asia → SWP
79+
- from: KUL
80+
to: SYD
81+
carrier: MH
82+
type: stopover
83+
date: 2026-04-09
84+
# SWP
85+
- from: SYD
86+
to: AKL
87+
carrier: QF
88+
type: stopover
89+
date: 2026-04-13
90+
# SWP
91+
- from: AKL
92+
to: MEL
93+
carrier: QF
94+
type: transit
95+
date: 2026-04-16
96+
# SWP → EU/ME
97+
- from: MEL
98+
to: LHR
99+
carrier: QF
100+
type: transit
101+
date: 2026-04-16
102+
# EU/ME (return)
103+
- from: LHR
104+
to: OSL
105+
carrier: BA
106+
type: stopover
107+
date: 2026-04-20

tests/test_scraper/test_batch.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,15 @@ async def test_never_crashes(self, tmp_path):
142142
class TestCheckItineraryAvailability:
143143
"""Test batch availability checking."""
144144

145-
@pytest.mark.asyncio
146-
async def test_returns_nones_without_credentials(self):
145+
def test_returns_nones_without_credentials(self):
147146
"""Returns all Nones when ExpertFlyer credentials are not available."""
148147
itin = _make_itinerary()
149148

150149
with patch("rtw.scraper.batch.ExpertFlyerScraper") as MockScraper:
151150
instance = MockScraper.return_value
152151
instance.credentials_available.return_value = False
153152

154-
results = await check_itinerary_availability(itin)
153+
results = check_itinerary_availability(itin)
155154

156155
assert len(results) == len(itin.segments)
157156
for r in results:

0 commit comments

Comments
 (0)