99from typing import Literal
1010from urllib .parse import quote_plus
1111
12- from patchright .async_api import (
13- TimeoutError as PlaywrightTimeoutError ,
14- async_playwright ,
15- )
12+ from patchright .async_api import TimeoutError as PlaywrightTimeoutError , async_playwright
1613
17- from .utils import get_links_from_serp , to_markdown
14+ from .utils import extract_serp_candidates , to_markdown
1815
1916logger = logging .getLogger ("uvicorn.error" )
2017
@@ -51,6 +48,7 @@ async def chrome(**kws):
5148 "--ignore-certificate-errors" ,
5249 "--remote-debugging-port=9222" ,
5350 ],
51+ ** kws ,
5452 )
5553 yield context
5654 await context .close ()
@@ -64,7 +62,7 @@ async def handle_cookie_preferences(page) -> None:
6462
6563 for manager in cookie_managers :
6664 parent_locator = page
67- locator : "Locator" = None
65+ locator = None
6866
6967 actions = manager .get ("actions" , [])
7068 for action in actions :
@@ -81,48 +79,92 @@ async def handle_cookie_preferences(page) -> None:
8179 for selector in action ["value" ]:
8280 if await parent_locator .locator (selector ).first .is_visible ():
8381 locator = parent_locator .locator (selector )
84- # manager["selector-list-item"] = selector
8582 break
8683
8784 if locator is not None :
8885 try :
89- # explicit wait for navigation as some pages will reload after accepting cookies
90- # async with page.expect_navigation(wait_until="networkidle", timeout=15000):
9186 await locator .first .click (delay = 10 )
92- logger .info (f "Accepted cookie preferences: { manager [' name' ] } " )
87+ logger .info ("Accepted cookie preferences: %s" , manager [" name" ] )
9388 return
94-
9589 except (PlaywrightTimeoutError , Exception ):
96- logger .exception (
97- f"Could not handle cookie preferences: { manager ['name' ]} "
98- )
90+ logger .exception ("Could not handle cookie preferences: %s" , manager ["name" ])
9991 continue
10092
10193
102- async def scrape (url : str , ctx : "BrowserContext" ) -> dict [str , str ]:
94+ async def prepare_page (page ) -> None :
95+ await page .route (
96+ "**/*" ,
97+ lambda route : route .abort ()
98+ if route .request .resource_type in {"image" , "media" , "font" }
99+ else route .continue_ (),
100+ )
101+
102+
103+ async def human_pause (page , low_ms : int = 250 , high_ms : int = 900 ) -> None :
104+ await page .wait_for_timeout (random .randint (low_ms , high_ms ))
105+
106+
107+ async def settle_page (page ) -> None :
108+ await handle_cookie_preferences (page )
109+ for selector in ["main" , "article" , "[role='main']" , "body" ]:
110+ try :
111+ await page .locator (selector ).first .wait_for (state = "visible" , timeout = 2500 )
112+ break
113+ except PlaywrightTimeoutError :
114+ continue
115+ await human_pause (page , 700 , 1500 )
116+ await page .mouse .move (random .randint (200 , 700 ), random .randint (200 , 700 ))
117+ await page .mouse .wheel (0 , random .randint (200 , 1000 ))
118+ await human_pause (page , 200 , 700 )
119+
120+
121+ async def scrape (url : str , ctx , * , serp_title : str = "" , serp_rank : int = 0 ) -> dict [str , str ]:
103122 page = None
104123 try :
105124 page = await ctx .new_page ()
106- await page .route (
107- "**/*" ,
108- lambda route : route .abort ()
109- if route .request .resource_type == "image"
110- else route .continue_ (),
111- )
125+ await prepare_page (page )
112126 await page .goto (url , wait_until = "domcontentloaded" , timeout = 30000 )
113- await page .mouse .move (333 , 888 )
114- await page .mouse .wheel (0 , - 111 )
115- await handle_cookie_preferences (page )
116- await page .wait_for_timeout (random .randint (800 , 1600 ))
127+ await settle_page (page )
117128 scraped_page = {
118129 "title" : await page .title (),
119130 "html" : await page .content (),
120131 "current_url" : page .url ,
121132 "requested_url" : url ,
133+ "serp_title" : serp_title ,
134+ "serp_rank" : str (serp_rank ),
122135 }
123136 return scraped_page
124- except PlaywrightTimeoutError as e :
125- raise e
137+ finally :
138+ if page is not None :
139+ await page .close ()
140+
141+
142+ async def click_through_result (serp_url : str , candidate , ctx ) -> dict [str , str ]:
143+ page = None
144+ try :
145+ page = await ctx .new_page ()
146+ await prepare_page (page )
147+ await page .goto (serp_url , wait_until = "domcontentloaded" , timeout = 30000 )
148+ await settle_page (page )
149+
150+ link_locator = page .locator (f"a[href='{ candidate .url } ']" ).first
151+ if await link_locator .count () == 0 :
152+ raise PlaywrightTimeoutError (f"Could not find SERP link for { candidate .url } " )
153+
154+ await link_locator .hover ()
155+ await human_pause (page , 200 , 600 )
156+ async with page .expect_navigation (wait_until = "domcontentloaded" , timeout = 30000 ):
157+ await link_locator .click (delay = random .randint (25 , 120 ))
158+ await settle_page (page )
159+
160+ return {
161+ "title" : await page .title (),
162+ "html" : await page .content (),
163+ "current_url" : page .url ,
164+ "requested_url" : candidate .url ,
165+ "serp_title" : candidate .title ,
166+ "serp_rank" : str (candidate .rank ),
167+ }
126168 finally :
127169 if page is not None :
128170 await page .close ()
@@ -133,6 +175,7 @@ async def get_serp_results(
133175 search_engine : Literal ["brave" , "duckduckgo" ],
134176 exclude_hosts : list [str ],
135177 max_results : int = 5 ,
178+ visit_mode : Literal ["direct" , "click" ] = "click" ,
136179 ** chrome_kws ,
137180) -> list [dict [str , str ]]:
138181 serp_results : list [dict [str , str ]] = []
@@ -143,23 +186,36 @@ async def get_serp_results(
143186 base_se = "duckduckgo.com"
144187 path_se = ""
145188 else :
146- logger .error (f "invalid search_engine: { search_engine } " )
189+ logger .error ("invalid search_engine: %s" , search_engine )
147190 return []
191+
148192 async with chrome (** chrome_kws ) as ctx :
149193 serp_url = f"https://{ base_se } /{ path_se } ?q={ quote_plus (search_query )} "
150194 serp = await scrape (serp_url , ctx )
151- links = get_links_from_serp (serp ["html" ], search_engine , exclude_hosts )
152- if not links :
153- logger .warning (f"No links were extracted from { serp_url } " )
154- tasks = [scrape (link , ctx ) for link in links [:max_results ]]
155- for task in asyncio .as_completed (tasks ):
195+ candidates = extract_serp_candidates (serp ["html" ], base_se , exclude_hosts )
196+ if not candidates :
197+ logger .warning ("No links were extracted from %s" , serp_url )
198+ return []
199+
200+ chosen = candidates [:max_results ]
201+ if visit_mode == "direct" :
202+ tasks = [
203+ scrape (c .url , ctx , serp_title = c .title , serp_rank = c .rank )
204+ for c in chosen
205+ ]
206+ results_iter = asyncio .as_completed (tasks )
207+ else :
208+ tasks = [click_through_result (serp_url , c , ctx ) for c in chosen ]
209+ results_iter = asyncio .as_completed (tasks )
210+
211+ for task in results_iter :
156212 try :
157213 result = await task
158214 html = result .pop ("html" )
159215 result ["contents" ] = to_markdown (html )
160216 serp_results .append (result )
161- except :
162- logger .exception ("Could not individual extract page: " )
217+ except Exception :
218+ logger .exception ("Could not extract individual page" )
163219 return serp_results
164220
165221
0 commit comments