Skip to content

Commit e8bf1d8

Browse files
committed
Dump suggestions
1 parent 4aa76d4 commit e8bf1d8

3 files changed

Lines changed: 311 additions & 70 deletions

File tree

src/tourist/app/routers/tour/routes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ class TouristSerpRequest(BaseRequest):
2525
search_engine: Literal["brave", "duckduckgo"]
2626
max_results: int = 5
2727
exclude_hosts: list[str] | None = []
28+
visit_mode: Literal["direct", "click"] = "click"
2829

2930
@field_validator("max_results")
3031
@classmethod
3132
def max_results_reasonable(cls, v: int) -> int:
32-
if v > 15 and v < 1:
33+
if v > 15 or v < 1:
3334
raise ValueError("max_results must be >= 1 and <= 15")
3435
return v
3536

src/tourist/service/driver.py

Lines changed: 91 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,9 @@
99
from typing import Literal
1010
from 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

1916
logger = 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

Comments
 (0)