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
@@ -64,7 +61,7 @@ async def handle_cookie_preferences(page) -> None:
6461
6562 for manager in cookie_managers :
6663 parent_locator = page
67- locator : "Locator" = None
64+ locator = None
6865
6966 actions = manager .get ("actions" , [])
7067 for action in actions :
@@ -81,48 +78,92 @@ async def handle_cookie_preferences(page) -> None:
8178 for selector in action ["value" ]:
8279 if await parent_locator .locator (selector ).first .is_visible ():
8380 locator = parent_locator .locator (selector )
84- # manager["selector-list-item"] = selector
8581 break
8682
8783 if locator is not None :
8884 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):
9185 await locator .first .click (delay = 10 )
92- logger .info (f "Accepted cookie preferences: { manager [' name' ] } " )
86+ logger .info ("Accepted cookie preferences: %s" , manager [" name" ] )
9387 return
94-
9588 except (PlaywrightTimeoutError , Exception ):
96- logger .exception (
97- f"Could not handle cookie preferences: { manager ['name' ]} "
98- )
89+ logger .exception ("Could not handle cookie preferences: %s" , manager ["name" ])
9990 continue
10091
10192
102- async def scrape (url : str , ctx : "BrowserContext" ) -> dict [str , str ]:
93+ async def prepare_page (page ) -> None :
94+ await page .route (
95+ "**/*" ,
96+ lambda route : route .abort ()
97+ if route .request .resource_type in {"image" , "media" , "font" }
98+ else route .continue_ (),
99+ )
100+
101+
102+ async def human_pause (page , low_ms : int = 250 , high_ms : int = 900 ) -> None :
103+ await page .wait_for_timeout (random .randint (low_ms , high_ms ))
104+
105+
106+ async def settle_page (page ) -> None :
107+ await handle_cookie_preferences (page )
108+ for selector in ["main" , "article" , "[role='main']" , "body" ]:
109+ try :
110+ await page .locator (selector ).first .wait_for (state = "visible" , timeout = 2500 )
111+ break
112+ except PlaywrightTimeoutError :
113+ continue
114+ await human_pause (page , 700 , 1500 )
115+ await page .mouse .move (random .randint (200 , 700 ), random .randint (200 , 700 ))
116+ await page .mouse .wheel (0 , random .randint (200 , 1000 ))
117+ await human_pause (page , 200 , 700 )
118+
119+
120+ async def scrape (url : str , ctx , * , serp_title : str = "" , serp_rank : int = 0 ) -> dict [str , str ]:
103121 page = None
104122 try :
105123 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- )
124+ await prepare_page (page )
112125 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 ))
126+ await settle_page (page )
117127 scraped_page = {
118128 "title" : await page .title (),
119129 "html" : await page .content (),
120130 "current_url" : page .url ,
121131 "requested_url" : url ,
132+ "serp_title" : serp_title ,
133+ "serp_rank" : str (serp_rank ),
122134 }
123135 return scraped_page
124- except PlaywrightTimeoutError as e :
125- raise e
136+ finally :
137+ if page is not None :
138+ await page .close ()
139+
140+
141+ async def click_through_result (serp_url : str , candidate , ctx ) -> dict [str , str ]:
142+ page = None
143+ try :
144+ page = await ctx .new_page ()
145+ await prepare_page (page )
146+ await page .goto (serp_url , wait_until = "domcontentloaded" , timeout = 30000 )
147+ await settle_page (page )
148+
149+ link_locator = page .locator (f"a[href='{ candidate .url } ']" ).first
150+ if await link_locator .count () == 0 :
151+ raise PlaywrightTimeoutError (f"Could not find SERP link for { candidate .url } " )
152+
153+ await link_locator .hover ()
154+ await human_pause (page , 200 , 600 )
155+ async with page .expect_navigation (wait_until = "domcontentloaded" , timeout = 30000 ):
156+ await link_locator .click (delay = random .randint (25 , 120 ))
157+ await settle_page (page )
158+
159+ return {
160+ "title" : await page .title (),
161+ "html" : await page .content (),
162+ "current_url" : page .url ,
163+ "requested_url" : candidate .url ,
164+ "serp_title" : candidate .title ,
165+ "serp_rank" : str (candidate .rank ),
166+ }
126167 finally :
127168 if page is not None :
128169 await page .close ()
@@ -133,6 +174,7 @@ async def get_serp_results(
133174 search_engine : Literal ["brave" , "duckduckgo" ],
134175 exclude_hosts : list [str ],
135176 max_results : int = 5 ,
177+ visit_mode : Literal ["direct" , "click" ] = "click" ,
136178 ** chrome_kws ,
137179) -> list [dict [str , str ]]:
138180 serp_results : list [dict [str , str ]] = []
@@ -143,23 +185,36 @@ async def get_serp_results(
143185 base_se = "duckduckgo.com"
144186 path_se = ""
145187 else :
146- logger .error (f "invalid search_engine: { search_engine } " )
188+ logger .error ("invalid search_engine: %s" , search_engine )
147189 return []
190+
148191 async with chrome (** chrome_kws ) as ctx :
149192 serp_url = f"https://{ base_se } /{ path_se } ?q={ quote_plus (search_query )} "
150193 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 ):
194+ candidates = extract_serp_candidates (serp ["html" ], base_se , exclude_hosts )
195+ if not candidates :
196+ logger .warning ("No links were extracted from %s" , serp_url )
197+ return []
198+
199+ chosen = candidates [:max_results ]
200+ if visit_mode == "direct" :
201+ tasks = [
202+ scrape (c .url , ctx , serp_title = c .title , serp_rank = c .rank )
203+ for c in chosen
204+ ]
205+ results_iter = asyncio .as_completed (tasks )
206+ else :
207+ tasks = [click_through_result (serp_url , c , ctx ) for c in chosen ]
208+ results_iter = asyncio .as_completed (tasks )
209+
210+ for task in results_iter :
156211 try :
157212 result = await task
158213 html = result .pop ("html" )
159214 result ["contents" ] = to_markdown (html )
160215 serp_results .append (result )
161- except :
162- logger .exception ("Could not individual extract page: " )
216+ except Exception :
217+ logger .exception ("Could not extract individual page" )
163218 return serp_results
164219
165220
0 commit comments