Skip to content

Commit d12f8f1

Browse files
authored
Merge pull request #3 from pogzyb/doink-improvements
Improve parsing and filtering
2 parents 4aa76d4 + f71b515 commit d12f8f1

3 files changed

Lines changed: 310 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: 90 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

@@ -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

Comments
 (0)