1+ #!/usr/bin/env python3
2+ """
3+ Real-world Bible scraper test suite.
4+
5+ Unlike unit tests with mocks, this actually hits the real scraping targets
6+ (BibleGateway, BibleHub, Wikisource) and caches results. This allows:
7+ 1. Detection of real scraping failures (markup changes, rate limits, etc.)
8+ 2. Iterative debugging with cached results
9+ 3. Backoff and retry testing
10+ 4. Performance monitoring
11+
12+ Run with: python3 test_real_scrapers.py [--update-cache] [--verbose]
13+ """
14+
15+ import requests
16+ from bs4 import BeautifulSoup
17+ import json
18+ import time
19+ import os
20+ import sys
21+ from pathlib import Path
22+ from datetime import datetime
23+ from typing import Dict , List , Tuple , Optional
24+
25+ # Cache directory
26+ CACHE_DIR = Path (__file__ ).parent / ".cache" / "scrapers"
27+ CACHE_DIR .mkdir (parents = True , exist_ok = True )
28+
29+ # User agent to mimic real requests
30+ USER_AGENT = "Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36"
31+
32+ # Rate limiting
33+ MIN_REQUEST_DELAY = 1.0 # seconds between requests
34+ last_request_time = 0
35+
36+ def rate_limit ():
37+ """Ensure minimum delay between requests."""
38+ global last_request_time
39+ now = time .time ()
40+ if now - last_request_time < MIN_REQUEST_DELAY :
41+ time .sleep (MIN_REQUEST_DELAY - (now - last_request_time ))
42+ last_request_time = time .time ()
43+
44+ def get_cached_data (cache_key : str ) -> Optional [dict ]:
45+ """Load cached scraping results."""
46+ cache_file = CACHE_DIR / f"{ cache_key } .json"
47+ if cache_file .exists ():
48+ with open (cache_file , 'r' ) as f :
49+ data = json .load (f )
50+ data ['_cached_at' ] = cache_file .stat ().st_mtime
51+ return data
52+ return None
53+
54+ def save_cached_data (cache_key : str , data : dict ):
55+ """Save scraping results to cache."""
56+ cache_file = CACHE_DIR / f"{ cache_key } .json"
57+ with open (cache_file , 'w' ) as f :
58+ json .dump (data , f , indent = 2 )
59+
60+ def get_cached_html (cache_key : str ) -> Optional [str ]:
61+ """Load cached raw HTML."""
62+ cache_file = CACHE_DIR / f"{ cache_key } .html"
63+ if cache_file .exists ():
64+ with open (cache_file , 'r' , encoding = 'utf-8' ) as f :
65+ return f .read ()
66+ return None
67+
68+ def save_cached_html (cache_key : str , html : str ):
69+ """Save raw HTML to cache."""
70+ cache_file = CACHE_DIR / f"{ cache_key } .html"
71+ with open (cache_file , 'w' , encoding = 'utf-8' ) as f :
72+ f .write (html )
73+
74+ def test_biblegateway_chapter (book : str , chapter : int , version : str = "NKJV" ) -> dict :
75+ """Test BibleGateway chapter scraping."""
76+ cache_key = f"biblegateway_{ book } _{ chapter } _{ version } "
77+ cached_html = get_cached_html (cache_key )
78+
79+ # Only fetch if explicitly requested or no cache exists
80+ if not cached_html or '--update-cache' in sys .argv :
81+ rate_limit ()
82+
83+ url = f"https://www.biblegateway.com/passage/?search={ book } +{ chapter } &version={ version } &interface=print"
84+ print (f"Fetching: { url } " )
85+
86+ response = requests .get (url , headers = {"User-Agent" : USER_AGENT }, timeout = 30 )
87+
88+ if response .status_code != 200 :
89+ return {
90+ 'url' : url ,
91+ 'status_code' : response .status_code ,
92+ 'success' : False ,
93+ 'error' : f'HTTP { response .status_code } '
94+ }
95+
96+ cached_html = response .text
97+ save_cached_html (cache_key , cached_html )
98+ print (f"Cached { len (cached_html )} bytes" )
99+ else :
100+ print (f"Using cached HTML ({ len (cached_html )} bytes)" )
101+
102+ # Parse cached HTML
103+ soup = BeautifulSoup (cached_html , 'html.parser' )
104+
105+ result = {
106+ 'url' : f"https://www.biblegateway.com/passage/?search={ book } +{ chapter } &version={ version } &interface=print" ,
107+ 'status_code' : 200 ,
108+ 'success' : True ,
109+ 'content_length' : len (cached_html ),
110+ 'html_snippet' : cached_html [:500 ] if len (cached_html ) < 500 else cached_html [:500 ] + '...' ,
111+ 'verses' : [],
112+ 'debug' : {
113+ 'passage_text_divs' : len (soup .select ('div.passage-text' )),
114+ 'span_text_elements' : len (soup .select ('div.passage-text span.text' )),
115+ 'all_spans' : len (soup .select ('span' )),
116+ 'h1_elements' : len (soup .select ('h1' )),
117+ }
118+ }
119+
120+ # Remove headers
121+ for header in soup .select ('h1, h2, h3, h4, h5, h6' ):
122+ header .decompose ()
123+
124+ # Try to extract verses
125+ for span in soup .select ('div.passage-text span.text' ):
126+ import re
127+ classes = span .get ('class' , [])
128+ if isinstance (classes , list ):
129+ class_str = ' ' .join (classes )
130+ else :
131+ class_str = str (classes ) if classes else ''
132+ verse_match = re .search (r'-(\d+)$' , class_str )
133+ if verse_match :
134+ verse_num = int (verse_match .group (1 ))
135+ # Remove verse number markers
136+ for sup in span .select ('sup, span.chapternum, span.versenum' ):
137+ sup .decompose ()
138+ text = span .get_text (strip = True )
139+ result ['verses' ].append ({'verse' : verse_num , 'text' : text })
140+
141+ result ['verse_count' ] = len (result ['verses' ])
142+ save_cached_data (cache_key , result )
143+ return result
144+
145+ def test_biblehub_interlinear (book : str , chapter : int ) -> dict :
146+ """Test BibleHub interlinear scraping."""
147+ cache_key = f"biblehub_interlinear_{ book } _{ chapter } "
148+ cached = get_cached_data (cache_key )
149+
150+ if cached and not '--update-cache' in sys .argv :
151+ return cached
152+
153+ rate_limit ()
154+
155+ # Convert book name to URL slug
156+ book_slug = book .lower ().replace (' ' , '' )
157+ if 'songof' in book_slug :
158+ book_slug = 'songs'
159+ import re
160+ book_slug = re .sub (r'^(\d+)([a-z]+)$' , r'\1_\2' , book_slug )
161+
162+ url = f"https://biblehub.com/interlinear/{ book_slug } /{ chapter } .htm"
163+ print (f"Fetching: { url } " )
164+
165+ response = requests .get (url , headers = {"User-Agent" : USER_AGENT }, timeout = 30 )
166+
167+ result = {
168+ 'url' : url ,
169+ 'status_code' : response .status_code ,
170+ 'success' : response .status_code == 200 ,
171+ 'content_length' : len (response .text ),
172+ 'words' : []
173+ }
174+
175+ if response .status_code == 200 :
176+ soup = BeautifulSoup (response .text , 'html.parser' )
177+ current_verse = 0
178+ word_idx = 0
179+
180+ for element in soup .select ('table[class*=tablefloat], div.interlinear' ):
181+ v_span = element .select ('span.reftop3, span.reftop, a.vref' )
182+ if v_span :
183+ v_txt = '' .join (c for c in v_span [0 ].get_text () if c .isdigit ())
184+ if v_txt :
185+ n_v = int (v_txt )
186+ if n_v != current_verse :
187+ current_verse = n_v
188+ word_idx = 0
189+
190+ if current_verse > 0 :
191+ orig = element .select ('span.greek, span.heb, span.hebrew' )
192+ if orig :
193+ orig_text = orig [0 ].get_text (strip = True )
194+ if orig_text :
195+ strongs_el = element .select ('span.pos, span.strongs, a[href*="/strongs/"]' )
196+ strongs = strongs_el [0 ].get_text (strip = True ) if strongs_el else ''
197+ strongs = '' .join (c for c in strongs if c .isdigit ())
198+
199+ trans_el = element .select ('span.eng' )
200+ trans = trans_el [0 ].get_text (strip = True ) if trans_el else ''
201+
202+ result ['words' ].append ({
203+ 'verse' : current_verse ,
204+ 'word_index' : word_idx ,
205+ 'original' : orig_text ,
206+ 'translation' : trans ,
207+ 'strongs' : strongs
208+ })
209+ word_idx += 1
210+
211+ result ['word_count' ] = len (result ['words' ])
212+ save_cached_data (cache_key , result )
213+ return result
214+
215+ def test_wikisource_apocrypha (book : str , chapter : int ) -> dict :
216+ """Test Wikisource Apocrypha scraping."""
217+ cache_key = f"wikisource_{ book } _{ chapter } "
218+ cached = get_cached_data (cache_key )
219+
220+ if cached and not '--update-cache' in sys .argv :
221+ return cached
222+
223+ rate_limit ()
224+
225+ # Map book names to Wikisource slugs
226+ slugs = {
227+ "Tobit" : "Tobit" ,
228+ "Judith" : "Judith" ,
229+ "Wisdom" : "Wisdom_of_Solomon" ,
230+ "Sirach" : "Ecclesiasticus"
231+ }
232+
233+ slug = slugs .get (book )
234+ if not slug :
235+ return {'error' : f'Unknown book: { book } ' , 'success' : False }
236+
237+ url = f"https://en.wikisource.org/wiki/Bible_(King_James)/{ slug } "
238+ print (f"Fetching: { url } " )
239+
240+ response = requests .get (url , headers = {"User-Agent" : USER_AGENT }, timeout = 30 )
241+
242+ result = {
243+ 'url' : url ,
244+ 'status_code' : response .status_code ,
245+ 'success' : response .status_code == 200 ,
246+ 'content_length' : len (response .text ),
247+ 'verses' : []
248+ }
249+
250+ if response .status_code == 200 :
251+ soup = BeautifulSoup (response .text , 'html.parser' )
252+ prefix = f"{ chapter } :"
253+
254+ for p in soup .select ('p:has(span.wst-verse)' ):
255+ span = p .select_one ('span.wst-verse' )
256+ if span :
257+ span_id = span .get ('id' , '' )
258+ if span_id .startswith (prefix ):
259+ try :
260+ verse_num = int (span_id .split (':' )[1 ])
261+ sup_text = span .select_one ('sup' )
262+ if sup_text :
263+ sup_text .decompose ()
264+ text = p .get_text (strip = True )
265+ result ['verses' ].append ({'verse' : verse_num , 'text' : text })
266+ except (ValueError , IndexError ):
267+ continue
268+
269+ result ['verse_count' ] = len (result ['verses' ])
270+ save_cached_data (cache_key , result )
271+ return result
272+
273+ def run_test_suite ():
274+ """Run comprehensive real-world scraper tests."""
275+ tests = [
276+ # BibleGateway tests (OT books)
277+ ("BibleGateway Genesis 1" , lambda : test_biblegateway_chapter ("Genesis" , 1 )),
278+ ("BibleGateway Psalms 23" , lambda : test_biblegateway_chapter ("Psalms" , 23 )),
279+ ("BibleGateway Isaiah 53" , lambda : test_biblegateway_chapter ("Isaiah" , 53 )),
280+
281+ # BibleGateway tests (NT books)
282+ ("BibleGateway Matthew 1" , lambda : test_biblegateway_chapter ("Matthew" , 1 )),
283+ ("BibleGateway John 3" , lambda : test_biblegateway_chapter ("John" , 3 )),
284+ ("BibleGateway Romans 8" , lambda : test_biblegateway_chapter ("Romans" , 8 )),
285+
286+ # BibleHub interlinear tests
287+ ("BibleHub Genesis 1" , lambda : test_biblehub_interlinear ("Genesis" , 1 )),
288+ ("BibleHub John 1" , lambda : test_biblehub_interlinear ("John" , 1 )),
289+
290+ # Wikisource Apocrypha tests
291+ ("Wikisource Tobit 1" , lambda : test_wikisource_apocrypha ("Tobit" , 1 )),
292+ ("Wikisource Wisdom 1" , lambda : test_wikisource_apocrypha ("Wisdom" , 1 )),
293+ ]
294+
295+ results = []
296+
297+ for test_name , test_func in tests :
298+ print (f"\n { '=' * 60 } " )
299+ print (f"Running: { test_name } " )
300+ print ('=' * 60 )
301+
302+ try :
303+ result = test_func ()
304+ result ['test_name' ] = test_name
305+ result ['timestamp' ] = datetime .now ().isoformat ()
306+
307+ if result .get ('success' ):
308+ print (f"✓ SUCCESS" )
309+ if 'verse_count' in result :
310+ print (f" Verses found: { result ['verse_count' ]} " )
311+ if 'word_count' in result :
312+ print (f" Words found: { result ['word_count' ]} " )
313+ else :
314+ print (f"✗ FAILED" )
315+ if 'error' in result :
316+ print (f" Error: { result ['error' ]} " )
317+ if 'status_code' in result :
318+ print (f" HTTP { result ['status_code' ]} " )
319+
320+ results .append (result )
321+
322+ except Exception as e :
323+ print (f"✗ EXCEPTION: { e } " )
324+ results .append ({
325+ 'test_name' : test_name ,
326+ 'success' : False ,
327+ 'error' : str (e ),
328+ 'exception' : type (e ).__name__
329+ })
330+
331+ # Save summary
332+ summary_file = CACHE_DIR / "test_summary.json"
333+ with open (summary_file , 'w' ) as f :
334+ json .dump (results , f , indent = 2 )
335+
336+ print (f"\n { '=' * 60 } " )
337+ print ("SUMMARY" )
338+ print ('=' * 60 )
339+ success_count = sum (1 for r in results if r .get ('success' ))
340+ total_count = len (results )
341+ print (f"Passed: { success_count } /{ total_count } " )
342+
343+ failed = [r for r in results if not r .get ('success' )]
344+ if failed :
345+ print (f"\n Failed tests:" )
346+ for r in failed :
347+ print (f" - { r ['test_name' ]} : { r .get ('error' , r .get ('exception' , 'Unknown' ))} " )
348+
349+ return success_count == total_count
350+
351+ if __name__ == '__main__' :
352+ success = run_test_suite ()
353+ sys .exit (0 if success else 1 )
0 commit comments