Almaatla commited on
Commit
5e51b32
·
unverified ·
2 Parent(s): d341a7acaf91fb

Add test harness and fix TDD/architecture review findings (#3)

Browse files
.github/workflows/deploy-to-hf.yml CHANGED
@@ -7,7 +7,28 @@ on:
7
  workflow_dispatch:
8
 
9
  jobs:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  deploy:
 
11
  runs-on: ubuntu-latest
12
  steps:
13
  - name: Checkout
 
7
  workflow_dispatch:
8
 
9
  jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+
21
+ - name: Install dependencies
22
+ run: pip install -r requirements-dev.txt
23
+
24
+ - name: Install Playwright's Chromium
25
+ run: playwright install --with-deps chromium
26
+
27
+ - name: Run tests
28
+ run: pytest
29
+
30
  deploy:
31
+ needs: test
32
  runs-on: ubuntu-latest
33
  steps:
34
  - name: Checkout
README.md CHANGED
@@ -13,3 +13,16 @@ pinned: true
13
  # `SERPent`
14
 
15
  `SERPent` provides a SERP / scrapping API for use by AI agents / projects.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  # `SERPent`
14
 
15
  `SERPent` provides a SERP / scrapping API for use by AI agents / projects.
16
+
17
+ ## Running tests
18
+
19
+ ```
20
+ pip install -r requirements.txt -r requirements-dev.txt
21
+ playwright install chromium # if not already present on the machine
22
+ pytest
23
+ ```
24
+
25
+ The suite mocks all outbound HTTP (via `respx`) and drives the Playwright-based
26
+ scrapers against local fixture HTML instead of live search engines - see
27
+ `tests/helpers.py` for how - so it runs fully offline and doesn't depend on,
28
+ or risk tripping, any site's anti-bot measures.
app.py CHANGED
@@ -104,40 +104,46 @@ ops_router = APIRouter(prefix="/ops", tags=["EPO OPS"])
104
  # ===================== Search endpoints =====================
105
 
106
 
107
- @serp_router.post("/search_scholar")
108
- async def search_google_scholar(params: SerpQuery):
109
- """Queries google scholar for the specified query"""
110
- logging.info(f"Searching Google Scholar for queries: {params.queries}")
111
- results = await asyncio.gather(*[query_google_scholar(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
112
- log_gathered_exceptions(results, "google scholar search", params)
113
-
114
- # Filter out exceptions and flatten the results
115
  filtered_results = [r for r in results if not isinstance(r, Exception)]
116
  flattened_results = [
117
  item for sublist in filtered_results for item in sublist]
118
 
119
- # all queries failed, return the last exception
120
  if len(filtered_results) == 0:
121
  return SerpResults(results=[], error=str(results[-1]))
122
 
123
  return SerpResults(results=flattened_results, error=None)
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  @serp_router.post("/search_arxiv")
127
  async def search_arxiv(params: SerpQuery):
128
  """Searches arxiv for the specified queries and returns the found documents."""
129
  logging.info(f"Searching Arxiv for queries: {params.queries}")
130
- results = await asyncio.gather(*[query_arxiv(httpx_client, q, params.n_results) for q in params.queries], return_exceptions=True)
131
- log_gathered_exceptions(results, "arxiv search", params)
132
-
133
- filtered_results = [r for r in results if not isinstance(r, Exception)]
134
- flattened_results = [
135
- item for sublist in filtered_results for item in sublist]
136
-
137
- if len(filtered_results) == 0:
138
- return SerpResults(results=[], error=str(results[-1]))
139
-
140
- return SerpResults(results=flattened_results, error=None)
141
 
142
 
143
  @serp_router.post("/search_patents")
@@ -162,73 +168,31 @@ async def search_patents(params: SerpQuery) -> SerpResults:
162
  except Exception as e:
163
  logging.warning(f"OPS fallback failed: {e}")
164
 
165
- # Filter out exceptions and flatten the results
166
- filtered_results = [r for r in results if not isinstance(r, Exception)]
167
- flattened_results = [
168
- item for sublist in filtered_results for item in sublist]
169
-
170
- # all queries failed, return the last exception
171
- if len(filtered_results) == 0:
172
- return SerpResults(results=[], error=str(results[-1]))
173
-
174
- return SerpResults(results=flattened_results, error=None)
175
 
176
 
177
  @serp_router.post("/search_brave")
178
  async def search_brave(params: SerpQuery) -> SerpResults:
179
  """Searches brave search for the specified queries and returns the found documents."""
180
  logging.info(f"Searching Brave Search for queries: {params.queries}")
181
- results = await asyncio.gather(*[query_brave_search(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
182
- log_gathered_exceptions(results, "brave search", params)
183
-
184
- # Filter out exceptions and flatten the results
185
- filtered_results = [r for r in results if not isinstance(r, Exception)]
186
- flattened_results = [
187
- item for sublist in filtered_results for item in sublist]
188
-
189
- # all queries failed, return the last exception
190
- if len(filtered_results) == 0:
191
- return SerpResults(results=[], error=str(results[-1]))
192
-
193
- return SerpResults(results=flattened_results, error=None)
194
 
195
 
196
  @serp_router.post("/search_bing")
197
  async def search_bing(params: SerpQuery) -> SerpResults:
198
  """Searches Bing search for the specified queries and returns the found documents."""
199
  logging.info(f"Searching Bing Search for queries: {params.queries}")
200
- results = await asyncio.gather(*[query_bing_search(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
201
- log_gathered_exceptions(results, "bing search", params)
202
-
203
- # Filter out exceptions and flatten the results
204
- filtered_results = [r for r in results if not isinstance(r, Exception)]
205
- flattened_results = [
206
- item for sublist in filtered_results for item in sublist]
207
-
208
- # all queries failed, return the last exception
209
- if len(filtered_results) == 0:
210
- return SerpResults(results=[], error=str(results[-1]))
211
-
212
- return SerpResults(results=flattened_results, error=None)
213
 
214
 
215
  @serp_router.post("/search_duck")
216
  async def search_duck(params: SerpQuery) -> SerpResults:
217
  """Searches duckduckgo for the specified queries and returns the found documents"""
218
  logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
219
- results = await asyncio.gather(*[query_ddg_search(q, params.n_results) for q in params.queries], return_exceptions=True)
220
- log_gathered_exceptions(results, "duckduckgo search", params)
221
-
222
- # Filter out exceptions and flatten the results
223
- filtered_results = [r for r in results if not isinstance(r, Exception)]
224
- flattened_results = [
225
- item for sublist in filtered_results for item in sublist]
226
-
227
- # all queries failed, return the last exception
228
- if len(filtered_results) == 0:
229
- return SerpResults(results=[], error=str(results[-1]))
230
-
231
- return SerpResults(results=flattened_results, error=None)
232
 
233
 
234
  async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
@@ -327,17 +291,8 @@ async def scrap_patents(params: ScrapPatentsRequest) -> PatentScrapBulkResponse:
327
  async def ops_keyword_search(params: SerpQuery) -> SerpResults:
328
  """Keyword-searches patents via the official EPO OPS API."""
329
  logging.info(f"Searching EPO OPS for queries: {params.queries}")
330
- results = await asyncio.gather(*[ops_search(httpx_client, q, params.n_results) for q in params.queries], return_exceptions=True)
331
- log_gathered_exceptions(results, "OPS search", params)
332
-
333
- filtered_results = [r for r in results if not isinstance(r, Exception)]
334
- flattened_results = [
335
- item for sublist in filtered_results for item in sublist]
336
-
337
- if len(filtered_results) == 0:
338
- return SerpResults(results=[], error=str(results[-1]))
339
-
340
- return SerpResults(results=flattened_results, error=None)
341
 
342
 
343
  @ops_router.get("/scrap_patent/{patent_id}")
 
104
  # ===================== Search endpoints =====================
105
 
106
 
107
+ def _shape_serp_results(results: list) -> SerpResults:
108
+ """Flatten a list of per-query result-lists (or Exceptions, from a
109
+ `return_exceptions=True` gather) into one SerpResults, surfacing the
110
+ last error only when every query failed.
111
+ """
 
 
 
112
  filtered_results = [r for r in results if not isinstance(r, Exception)]
113
  flattened_results = [
114
  item for sublist in filtered_results for item in sublist]
115
 
 
116
  if len(filtered_results) == 0:
117
  return SerpResults(results=[], error=str(results[-1]))
118
 
119
  return SerpResults(results=flattened_results, error=None)
120
 
121
 
122
+ async def _run_serp_queries(fn, params: SerpQuery, context: str) -> SerpResults:
123
+ """Run `fn(query, n_results)` concurrently for every query in `params`,
124
+ log any failures, and shape the result the way every simple /serp and
125
+ /ops search endpoint below expects.
126
+ """
127
+ results = await asyncio.gather(
128
+ *[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
129
+ log_gathered_exceptions(results, context, params)
130
+ return _shape_serp_results(results)
131
+
132
+
133
+ @serp_router.post("/search_scholar")
134
+ async def search_google_scholar(params: SerpQuery):
135
+ """Queries google scholar for the specified query"""
136
+ logging.info(f"Searching Google Scholar for queries: {params.queries}")
137
+ return await _run_serp_queries(
138
+ lambda q, n: query_google_scholar(pw_browser, q, n), params, "google scholar search")
139
+
140
+
141
  @serp_router.post("/search_arxiv")
142
  async def search_arxiv(params: SerpQuery):
143
  """Searches arxiv for the specified queries and returns the found documents."""
144
  logging.info(f"Searching Arxiv for queries: {params.queries}")
145
+ return await _run_serp_queries(
146
+ lambda q, n: query_arxiv(httpx_client, q, n), params, "arxiv search")
 
 
 
 
 
 
 
 
 
147
 
148
 
149
  @serp_router.post("/search_patents")
 
168
  except Exception as e:
169
  logging.warning(f"OPS fallback failed: {e}")
170
 
171
+ return _shape_serp_results(results)
 
 
 
 
 
 
 
 
 
172
 
173
 
174
  @serp_router.post("/search_brave")
175
  async def search_brave(params: SerpQuery) -> SerpResults:
176
  """Searches brave search for the specified queries and returns the found documents."""
177
  logging.info(f"Searching Brave Search for queries: {params.queries}")
178
+ return await _run_serp_queries(
179
+ lambda q, n: query_brave_search(pw_browser, q, n), params, "brave search")
 
 
 
 
 
 
 
 
 
 
 
180
 
181
 
182
  @serp_router.post("/search_bing")
183
  async def search_bing(params: SerpQuery) -> SerpResults:
184
  """Searches Bing search for the specified queries and returns the found documents."""
185
  logging.info(f"Searching Bing Search for queries: {params.queries}")
186
+ return await _run_serp_queries(
187
+ lambda q, n: query_bing_search(pw_browser, q, n), params, "bing search")
 
 
 
 
 
 
 
 
 
 
 
188
 
189
 
190
  @serp_router.post("/search_duck")
191
  async def search_duck(params: SerpQuery) -> SerpResults:
192
  """Searches duckduckgo for the specified queries and returns the found documents"""
193
  logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
194
+ return await _run_serp_queries(
195
+ lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
 
 
 
 
 
 
 
 
 
 
 
196
 
197
 
198
  async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
 
291
  async def ops_keyword_search(params: SerpQuery) -> SerpResults:
292
  """Keyword-searches patents via the official EPO OPS API."""
293
  logging.info(f"Searching EPO OPS for queries: {params.queries}")
294
+ return await _run_serp_queries(
295
+ lambda q, n: ops_search(httpx_client, q, n), params, "OPS search")
 
 
 
 
 
 
 
 
 
296
 
297
 
298
  @ops_router.get("/scrap_patent/{patent_id}")
pytest.ini ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ testpaths = tests
requirements-dev.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Test-only dependencies. Install with:
2
+ # pip install -r requirements.txt -r requirements-dev.txt
3
+ # playwright install chromium # if not already present on the machine
4
+ -r requirements.txt
5
+ pytest>=8,<10
6
+ pytest-asyncio>=0.24
7
+ respx>=0.21
scrap.py CHANGED
@@ -38,7 +38,18 @@ async def scrap_patent_async(client: AsyncClient, patent_url: str) -> PatentScra
38
  response = await client.get(patent_url, headers=headers)
39
  response.raise_for_status()
40
 
41
- soup = BeautifulSoup(response.text, "html.parser")
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  # Abstract
44
  abstract_div = soup.find("div", {"class": "abstract"})
 
38
  response = await client.get(patent_url, headers=headers)
39
  response.raise_for_status()
40
 
41
+ return parse_patent_html(response.text, patent_url)
42
+
43
+
44
+ def parse_patent_html(html: str, patent_url: str) -> PatentScrapResult:
45
+ """Parse a Google Patents patent page into a PatentScrapResult.
46
+
47
+ Pure function of the page's HTML - no network I/O - so it can be tested
48
+ directly against saved/fixture HTML without mocking a client.
49
+ `patent_url` is only used to identify the page in the error message
50
+ below.
51
+ """
52
+ soup = BeautifulSoup(html, "html.parser")
53
 
54
  # Abstract
55
  abstract_div = soup.find("div", {"class": "abstract"})
serp.py CHANGED
@@ -82,187 +82,193 @@ async def playwright_open_page(browser: Optional[Browser]):
82
  await context.close()
83
 
84
 
85
- async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
86
- """Queries google scholar for the specified query and number of results. Returns relevant papers"""
 
 
 
 
 
 
 
87
 
88
- async with playwright_open_page(browser) as page:
89
 
90
- async def _block_resources(route, request):
91
- if request.resource_type in ["stylesheet", "image"]:
92
- await route.abort()
93
- else:
94
- await route.continue_()
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- await page.route("**/*", _block_resources)
 
 
 
 
 
 
97
 
98
  url = f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
99
  await page.goto(url)
100
 
101
- await page.wait_for_selector("div.gs_ri")
102
 
103
- items = await page.locator("div.gs_ri").all()
104
- results = []
105
- for item in items[:n_results]:
106
- title = await item.locator("h3").inner_text(timeout=1000)
107
- body = await item.locator("div.gs_rs").inner_text(timeout=1000)
108
- href = await item.locator("h3 > a").get_attribute("href")
109
 
110
- results.append({
111
- "title": title,
112
- "body": body,
113
- "href": href
114
- })
115
 
116
- return results
117
 
 
 
 
 
 
 
 
 
118
 
119
- async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
120
- """Queries google patents for the specified query and number of results. Returns relevant patents"""
 
 
 
 
 
121
 
122
- # regex to locate a patent id
123
- PATENT_ID_REGEX = r"\b[A-Z]{2}\d{6,}(?:[A-Z]\d?)?\b"
124
 
125
- async with playwright_open_page(browser) as page:
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
- async def _block_resources(route, request):
128
- if request.resource_type in ["stylesheet", "image"]:
129
- await route.abort()
130
- else:
131
- await route.continue_()
132
 
133
- await page.route("**/*", _block_resources)
 
 
 
134
 
135
  url = f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
136
  await page.goto(url)
137
 
138
- # Wait for at least one search result item to appear
139
- # This ensures the page has loaded enough to start scraping
140
- await page.wait_for_function(
141
- f"""() => document.querySelectorAll('search-result-item').length >= 1""",
142
- timeout=30_000
143
- )
144
-
145
- items = await page.locator("search-result-item").all()
146
- results = []
147
- for item in items:
148
- text = " ".join(await item.locator("span").all_inner_texts())
149
- match = re.search(PATENT_ID_REGEX, text)
150
- if not match:
151
- continue
152
-
153
- patent_id = match.group()
154
-
155
- try:
156
- title = await item.locator("h3, h4").first.inner_text(timeout=1000)
157
- body = await item.locator("div.abstract, div.result-snippet, .snippet, .result-text").first.inner_text(timeout=1000)
158
- except:
159
- continue # If we can't get title or body, skip this item
160
 
161
- results.append({
162
- "id": patent_id,
163
- "href": f"https://patents.google.com/patent/{patent_id}/en",
164
- "title": title,
165
- "body": body
166
- })
167
 
168
- return results[:n_results]
 
169
 
 
 
 
 
170
 
171
- async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
172
- """Queries Brave Search for the specified query."""
173
 
174
- async with playwright_open_page(browser) as page:
 
175
 
176
- async def _block_resources(route, request):
177
- if request.resource_type in ["stylesheet", "image"]:
178
- await route.abort()
179
- else:
180
- await route.continue_()
181
 
182
- await page.route("**/*", _block_resources)
 
 
 
183
 
184
- url = f"https://search.brave.com/search?q={quote_plus(q)}"
185
- await page.goto(url)
 
186
 
187
- results_cards = await page.locator('.snippet').all()
 
 
 
 
188
 
189
- if len(results_cards) == 0:
190
- page_content = await page.content()
191
 
192
- if "suspicious" in page_content:
193
- raise BraveSearchBlockedException()
194
 
195
- results = []
196
 
197
- for result in results_cards:
198
- title = await result.locator('.title').all_inner_texts()
199
- description = await result.locator('.snippet-description').all_inner_texts()
200
- url = await result.locator('a').nth(0).get_attribute('href')
 
 
 
 
 
201
 
202
- # Filter out results with no URL or brave-specific URLs
203
- if url is None or url.startswith('/'):
204
- continue
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  results.append({
207
- "title": title[0] if title else "",
208
- "body": description[0] if description else "",
209
- "href": url
210
  })
211
 
212
- if len(results) >= n_results:
213
- break
214
-
215
- return results
216
 
217
 
218
  async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
219
  """Queries bing search for the specified query"""
220
  async with playwright_open_page(browser) as page:
221
- async def _block_resources(route, request):
222
- if request.resource_type in ["stylesheet", "image"]:
223
- await route.abort()
224
- else:
225
- await route.continue_()
226
-
227
- await page.route("**/*", _block_resources)
228
 
229
  url = f"https://www.bing.com/search?q={quote_plus(q)}"
230
  await page.goto(url)
231
 
232
- await page.wait_for_selector("li.b_algo")
233
-
234
- results = []
235
-
236
- items = await page.query_selector_all("li.b_algo")
237
- for item in items[:n_results]:
238
- title_el = await item.query_selector("h2 > a")
239
- url = await title_el.get_attribute("href") if title_el else None
240
- title = await title_el.inner_text() if title_el else ""
241
-
242
- snippet = ""
243
-
244
- # Try several fallback selectors
245
- for selector in [
246
- "div.b_caption p", # typical snippet
247
- "div.b_caption", # sometimes snippet is here
248
- "div.b_snippet", # used in some result types
249
- "div.b_text", # used in some panels
250
- "p" # fallback to any paragraph
251
- ]:
252
- snippet_el = await item.query_selector(selector)
253
- if snippet_el:
254
- snippet = await snippet_el.inner_text()
255
- if snippet.strip():
256
- break
257
-
258
- if title and url:
259
- results.append({
260
- "title": title.strip(),
261
- "href": url.strip(),
262
- "body": snippet.strip()
263
- })
264
-
265
- return results
266
 
267
 
268
  async def query_ddg_search(q: str, n_results: int = 10):
 
82
  await context.close()
83
 
84
 
85
+ async def _block_stylesheet_and_image_resources(route, request):
86
+ """Shared `page.route("**/*", ...)` handler for every scraper below: skip
87
+ fetching stylesheets/images, since only the page's text content matters
88
+ here and this meaningfully speeds up navigation.
89
+ """
90
+ if request.resource_type in ["stylesheet", "image"]:
91
+ await route.abort()
92
+ else:
93
+ await route.continue_()
94
 
 
95
 
96
+ async def _extract_google_scholar_results(page: Page, n_results: int) -> list[dict]:
97
+ """Extract results from an already-loaded Google Scholar results page."""
98
+ await page.wait_for_selector("div.gs_ri")
99
+
100
+ items = await page.locator("div.gs_ri").all()
101
+ results = []
102
+ for item in items[:n_results]:
103
+ title = await item.locator("h3").inner_text(timeout=1000)
104
+ body = await item.locator("div.gs_rs").inner_text(timeout=1000)
105
+ href = await item.locator("h3 > a").get_attribute("href")
106
+
107
+ results.append({
108
+ "title": title,
109
+ "body": body,
110
+ "href": href
111
+ })
112
 
113
+ return results
114
+
115
+
116
+ async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
117
+ """Queries google scholar for the specified query and number of results. Returns relevant papers"""
118
+ async with playwright_open_page(browser) as page:
119
+ await page.route("**/*", _block_stylesheet_and_image_resources)
120
 
121
  url = f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
122
  await page.goto(url)
123
 
124
+ return await _extract_google_scholar_results(page, n_results)
125
 
 
 
 
 
 
 
126
 
127
+ # regex to locate a patent id, e.g. "US11930446B2" or "EP4760514A1"
128
+ PATENT_ID_REGEX = r"\b[A-Z]{2}\d{6,}(?:[A-Z]\d?)?\b"
 
 
 
129
 
 
130
 
131
+ async def _extract_google_patents_results(page: Page, n_results: int) -> list[dict]:
132
+ """Extract results from an already-loaded Google Patents search page."""
133
+ # Wait for at least one search result item to appear
134
+ # This ensures the page has loaded enough to start scraping
135
+ await page.wait_for_function(
136
+ f"""() => document.querySelectorAll('search-result-item').length >= 1""",
137
+ timeout=30_000
138
+ )
139
 
140
+ items = await page.locator("search-result-item").all()
141
+ results = []
142
+ for item in items:
143
+ text = " ".join(await item.locator("span").all_inner_texts())
144
+ match = re.search(PATENT_ID_REGEX, text)
145
+ if not match:
146
+ continue
147
 
148
+ patent_id = match.group()
 
149
 
150
+ try:
151
+ title = await item.locator("h3, h4").first.inner_text(timeout=1000)
152
+ body = await item.locator("div.abstract, div.result-snippet, .snippet, .result-text").first.inner_text(timeout=1000)
153
+ except Exception:
154
+ continue # If we can't get title or body, skip this item
155
+
156
+ results.append({
157
+ "id": patent_id,
158
+ "href": f"https://patents.google.com/patent/{patent_id}/en",
159
+ "title": title,
160
+ "body": body
161
+ })
162
+
163
+ return results[:n_results]
164
 
 
 
 
 
 
165
 
166
+ async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
167
+ """Queries google patents for the specified query and number of results. Returns relevant patents"""
168
+ async with playwright_open_page(browser) as page:
169
+ await page.route("**/*", _block_stylesheet_and_image_resources)
170
 
171
  url = f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
172
  await page.goto(url)
173
 
174
+ return await _extract_google_patents_results(page, n_results)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
 
 
 
 
 
 
176
 
177
+ async def _extract_brave_results(page: Page, n_results: int) -> list[dict]:
178
+ """Extract results from an already-loaded Brave Search results page.
179
 
180
+ Raises BraveSearchBlockedException if the page looks like Brave's
181
+ anti-bot interstitial rather than a results page.
182
+ """
183
+ results_cards = await page.locator('.snippet').all()
184
 
185
+ if len(results_cards) == 0:
186
+ page_content = await page.content()
187
 
188
+ if "suspicious" in page_content:
189
+ raise BraveSearchBlockedException()
190
 
191
+ results = []
 
 
 
 
192
 
193
+ for result in results_cards:
194
+ title = await result.locator('.title').all_inner_texts()
195
+ description = await result.locator('.snippet-description').all_inner_texts()
196
+ url = await result.locator('a').nth(0).get_attribute('href')
197
 
198
+ # Filter out results with no URL or brave-specific URLs
199
+ if url is None or url.startswith('/'):
200
+ continue
201
 
202
+ results.append({
203
+ "title": title[0] if title else "",
204
+ "body": description[0] if description else "",
205
+ "href": url
206
+ })
207
 
208
+ if len(results) >= n_results:
209
+ break
210
 
211
+ return results
 
212
 
 
213
 
214
+ async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
215
+ """Queries Brave Search for the specified query."""
216
+ async with playwright_open_page(browser) as page:
217
+ await page.route("**/*", _block_stylesheet_and_image_resources)
218
+
219
+ url = f"https://search.brave.com/search?q={quote_plus(q)}"
220
+ await page.goto(url)
221
+
222
+ return await _extract_brave_results(page, n_results)
223
 
 
 
 
224
 
225
+ async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
226
+ """Extract results from an already-loaded Bing results page."""
227
+ await page.wait_for_selector("li.b_algo")
228
+
229
+ results = []
230
+
231
+ items = await page.query_selector_all("li.b_algo")
232
+ for item in items[:n_results]:
233
+ title_el = await item.query_selector("h2 > a")
234
+ url = await title_el.get_attribute("href") if title_el else None
235
+ title = await title_el.inner_text() if title_el else ""
236
+
237
+ snippet = ""
238
+
239
+ # Try several fallback selectors
240
+ for selector in [
241
+ "div.b_caption p", # typical snippet
242
+ "div.b_caption", # sometimes snippet is here
243
+ "div.b_snippet", # used in some result types
244
+ "div.b_text", # used in some panels
245
+ "p" # fallback to any paragraph
246
+ ]:
247
+ snippet_el = await item.query_selector(selector)
248
+ if snippet_el:
249
+ snippet = await snippet_el.inner_text()
250
+ if snippet.strip():
251
+ break
252
+
253
+ if title and url:
254
  results.append({
255
+ "title": title.strip(),
256
+ "href": url.strip(),
257
+ "body": snippet.strip()
258
  })
259
 
260
+ return results
 
 
 
261
 
262
 
263
  async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
264
  """Queries bing search for the specified query"""
265
  async with playwright_open_page(browser) as page:
266
+ await page.route("**/*", _block_stylesheet_and_image_resources)
 
 
 
 
 
 
267
 
268
  url = f"https://www.bing.com/search?q={quote_plus(q)}"
269
  await page.goto(url)
270
 
271
+ return await _extract_bing_results(page, n_results)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
 
274
  async def query_ddg_search(q: str, n_results: int = 10):
tests/conftest.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest_asyncio
6
+
7
+ # The app modules (serp.py, scrap.py, ops.py, utils.py) live at the repo
8
+ # root, not inside a package - make sure they're importable regardless of
9
+ # how pytest resolves its rootdir.
10
+ REPO_ROOT = Path(__file__).resolve().parent.parent
11
+ sys.path.insert(0, str(REPO_ROOT))
12
+
13
+
14
+ def _find_chromium_executable() -> str | None:
15
+ """Fallback lookup for environments where the browser Playwright's pip
16
+ package expects doesn't match what's actually on disk (e.g. a sandbox
17
+ image that pre-installs a specific Chromium revision separately from
18
+ however `pip install playwright` resolved). Mirrors the workaround
19
+ documented for the JS Playwright test runner in this environment.
20
+ """
21
+ browsers_path = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
22
+ if not browsers_path:
23
+ return None
24
+ root = Path(browsers_path)
25
+ if not root.is_dir():
26
+ return None
27
+ candidates = sorted(root.glob("chromium-*/chrome-linux/chrome"))
28
+ return str(candidates[-1]) if candidates else None
29
+
30
+
31
+ @pytest_asyncio.fixture
32
+ async def browser():
33
+ """A real headless Chromium instance, exactly like the one `app.py`'s
34
+ lifespan starts in production.
35
+
36
+ Function-scoped (one launch per test) rather than session-scoped: each
37
+ pytest-asyncio test runs on its own event loop by default, and a
38
+ session-scoped instance would be created on one loop's setup and then
39
+ awaited from a different, later loop in the next test. Playwright's
40
+ connection keeps a background reader task bound to the loop it was
41
+ created on, so calls from another loop don't error - they just hang
42
+ forever waiting for a reply the old loop's task will never deliver.
43
+ Confirmed by reproducing this exact hang with a session-scoped version
44
+ of this fixture, then ruling out the scraping logic itself by running
45
+ the same call in a plain script outside pytest (single event loop),
46
+ where it completed instantly. A fresh browser per test costs roughly a
47
+ second of extra startup but sidesteps the whole cross-loop class of bug.
48
+ """
49
+ from playwright.async_api import async_playwright
50
+
51
+ pw = await async_playwright().start()
52
+ try:
53
+ b = await pw.chromium.launch(headless=True)
54
+ except Exception:
55
+ executable_path = _find_chromium_executable()
56
+ if not executable_path:
57
+ raise
58
+ b = await pw.chromium.launch(headless=True, executable_path=executable_path)
59
+ yield b
60
+ await b.close()
61
+ await pw.stop()
tests/fixtures/arxiv_response.xml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <feed xmlns="http://www.w3.org/2005/Atom">
3
+ <entry>
4
+ <id>http://arxiv.org/abs/2401.00001v1</id>
5
+ <title>
6
+ A Study of Widgets
7
+ </title>
8
+ <summary>
9
+ This paper studies widgets in depth.
10
+ </summary>
11
+ </entry>
12
+ <entry>
13
+ <id>http://arxiv.org/abs/2401.00002v1</id>
14
+ <title>Gadgets and Their Applications</title>
15
+ <summary>An overview of gadgets.</summary>
16
+ </entry>
17
+ </feed>
tests/fixtures/bing_results.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <li class="b_algo">
5
+ <h2><a href="https://example.org/a">Result A</a></h2>
6
+ <div class="b_caption"><p>Snippet A</p></div>
7
+ </li>
8
+ <li class="b_algo">
9
+ <h2><a href="https://example.org/b">Result B</a></h2>
10
+ <div class="b_caption"><p>Snippet B</p></div>
11
+ </li>
12
+ </body>
13
+ </html>
tests/fixtures/brave_blocked.html ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <p>Your request looks suspicious. Please verify you are human to continue.</p>
5
+ </body>
6
+ </html>
tests/fixtures/brave_results.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <div class="snippet">
5
+ <a href="https://example.org/brave-a"><span class="title">Brave Result A</span></a>
6
+ <p class="snippet-description">Brave snippet A</p>
7
+ </div>
8
+ <div class="snippet">
9
+ <a href="https://example.org/brave-b"><span class="title">Brave Result B</span></a>
10
+ <p class="snippet-description">Brave snippet B</p>
11
+ </div>
12
+ </body>
13
+ </html>
tests/fixtures/ops/biblio_response.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ops:world-patent-data": {
3
+ "exchange-documents": {
4
+ "exchange-document": {
5
+ "@country": "US",
6
+ "@doc-number": "11930446",
7
+ "bibliographic-data": {
8
+ "publication-reference": {
9
+ "document-id": [
10
+ {"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}}
11
+ ]
12
+ },
13
+ "invention-title": {"@lang": "en", "$": "Widget apparatus"},
14
+ "patent-classifications": {
15
+ "patent-classification": [
16
+ {
17
+ "section": {"$": "G"},
18
+ "class": {"$": "06"},
19
+ "subclass": {"$": "F"},
20
+ "main-group": {"$": "17"},
21
+ "subgroup": {"$": "30"}
22
+ },
23
+ {
24
+ "section": {"$": "H"},
25
+ "class": {"$": "04"},
26
+ "subclass": {"$": "L"},
27
+ "main-group": {"$": "9"},
28
+ "subgroup": {"$": "00"}
29
+ }
30
+ ]
31
+ }
32
+ },
33
+ "abstract": {"@lang": "en", "p": [{"$": "An apparatus comprising a widget."}]}
34
+ }
35
+ }
36
+ }
37
+ }
tests/fixtures/ops/claims_response.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ops:world-patent-data": {
3
+ "ftxt:fulltext-documents": {
4
+ "ftxt:fulltext-document": {
5
+ "claims": {
6
+ "@lang": "en",
7
+ "claim": [
8
+ {"claim-text": {"$": "1. A widget comprising a gadget."}},
9
+ {"claim-text": {"$": "2. The widget of claim 1, further comprising a mechanism."}}
10
+ ]
11
+ }
12
+ }
13
+ }
14
+ }
15
+ }
tests/fixtures/ops/description_response.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ops:world-patent-data": {
3
+ "ftxt:fulltext-documents": {
4
+ "ftxt:fulltext-document": {
5
+ "description": {
6
+ "@lang": "en",
7
+ "p": [
8
+ {"$": "This invention relates to widgets."},
9
+ {"$": "The widget comprises a gadget and a mechanism."}
10
+ ]
11
+ }
12
+ }
13
+ }
14
+ }
15
+ }
tests/fixtures/ops/search_not_found_response.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "fault": {
3
+ "faultstring": "SERVER.EntityNotFound",
4
+ "detail": {"errorcode": "SERVER.EntityNotFound"}
5
+ }
6
+ }
tests/fixtures/ops/search_response.json ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ops:world-patent-data": {
3
+ "ops:biblio-search": {
4
+ "ops:search-result": {
5
+ "exchange-documents": [
6
+ {
7
+ "exchange-document": {
8
+ "@country": "US",
9
+ "@doc-number": "11930446",
10
+ "bibliographic-data": {
11
+ "publication-reference": {
12
+ "document-id": [
13
+ {"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}}
14
+ ]
15
+ },
16
+ "invention-title": [
17
+ {"@lang": "en", "$": "Widget apparatus"},
18
+ {"@lang": "de", "$": "Widgetvorrichtung"}
19
+ ]
20
+ },
21
+ "abstract": [
22
+ {"@lang": "en", "p": [{"$": "An apparatus comprising a widget."}]}
23
+ ]
24
+ }
25
+ },
26
+ {
27
+ "exchange-document": {
28
+ "@country": "EP",
29
+ "@doc-number": "4760514",
30
+ "bibliographic-data": {
31
+ "publication-reference": {
32
+ "document-id": [
33
+ {"@document-id-type": "epodoc", "doc-number": {"$": "EP4760514"}}
34
+ ]
35
+ },
36
+ "invention-title": {"@lang": "en", "$": "Gadget system"}
37
+ },
38
+ "abstract": {"@lang": "en", "p": {"$": "A system comprising a gadget."}}
39
+ }
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ }
45
+ }
tests/fixtures/ops/token_response.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "access_token": "test-access-token-123",
3
+ "expires_in": 1199,
4
+ "token_type": "Bearer"
5
+ }
tests/fixtures/patents/full_patent.html ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta name="DC.title" content="Widget with improved gadget mechanism">
5
+ </head>
6
+ <body>
7
+
8
+ <div class="abstract">A widget comprising a gadget and a mechanism for improving widget performance.</div>
9
+
10
+ <section itemprop="description">
11
+ <div>TECHNICAL FIELD</div>
12
+ <div>This invention relates to widgets and gadgets for testing purposes.</div>
13
+ <div>BACKGROUND OF THE INVENTION</div>
14
+ <div>Prior art widgets suffered from several problems described herein.</div>
15
+ <div>SUMMARY OF THE INVENTION</div>
16
+ <div>The present invention solves the problems of the prior art by providing an improved widget.</div>
17
+ <div>DETAILED DESCRIPTION</div>
18
+ <div>The following describes example embodiments of the widget in detail.</div>
19
+ </section>
20
+
21
+ <section itemprop="claims">
22
+ <div class="claim">1. A widget comprising a gadget.</div>
23
+ <div class="claim">2. The widget of claim 1, further comprising a mechanism.</div>
24
+ </section>
25
+
26
+ <ul class="classifications">
27
+ <li><span>G06F</span>
28
+ <ul>
29
+ <li><span>G06F17/30</span> Database structures therefor</li>
30
+ <li><span>G06F17/50</span> Other database related</li>
31
+ </ul>
32
+ </li>
33
+ <li><span>H04L</span>
34
+ <ul>
35
+ <li><span>H04L9/00</span> Cryptographic mechanisms</li>
36
+ </ul>
37
+ </li>
38
+ </ul>
39
+
40
+ </body>
41
+ </html>
tests/fixtures/patents/missing_title.html ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ </head>
5
+ <body>
6
+ <p>This is not a valid Google Patents page (interstitial, error page, or markup change) - it has no meta[name=DC.title].</p>
7
+ </body>
8
+ </html>
tests/fixtures/patents/search_results.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <search-result-item>
5
+ <span>US11930446B2</span>
6
+ <h3>Widget apparatus</h3>
7
+ <div class="abstract">An apparatus comprising a widget.</div>
8
+ </search-result-item>
9
+ <search-result-item>
10
+ <span>EP4760514A1</span>
11
+ <h4>Gadget system</h4>
12
+ <div class="result-snippet">A system comprising a gadget.</div>
13
+ </search-result-item>
14
+ </body>
15
+ </html>
tests/fixtures/scholar_results.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <div class="gs_ri">
5
+ <h3><a href="https://example.org/scholar-a">Scholar Paper A</a></h3>
6
+ <div class="gs_rs">Scholar snippet A</div>
7
+ </div>
8
+ <div class="gs_ri">
9
+ <h3><a href="https://example.org/scholar-b">Scholar Paper B</a></h3>
10
+ <div class="gs_rs">Scholar snippet B</div>
11
+ </div>
12
+ </body>
13
+ </html>
tests/helpers.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared test helpers: fixture loading and a fake-navigation Playwright
2
+ Browser wrapper used to characterize the scraper functions in `serp.py`
3
+ without hitting live search engines.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
9
+
10
+
11
+ def load_fixture(*parts: str) -> str:
12
+ """Read a text fixture from tests/fixtures/<parts...>."""
13
+ return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
14
+
15
+
16
+ class _GotoPatchingContext:
17
+ """Wraps a real Playwright BrowserContext so every Page it creates loads
18
+ fixture HTML via `set_content()` instead of navigating over the network.
19
+ """
20
+
21
+ def __init__(self, real_context, html: str):
22
+ self._real_context = real_context
23
+ self._html = html
24
+
25
+ async def new_page(self):
26
+ page = await self._real_context.new_page()
27
+ html = self._html
28
+
29
+ async def fake_goto(url, **kwargs):
30
+ # The scraper functions don't use the response object `goto`
31
+ # normally returns, so a plain None is a safe stand-in.
32
+ await page.set_content(html)
33
+ return None
34
+
35
+ page.goto = fake_goto
36
+ return page
37
+
38
+ async def close(self):
39
+ await self._real_context.close()
40
+
41
+
42
+ class _GotoPatchingBrowser:
43
+ """Wraps a real Playwright Browser the same way, so it can be passed
44
+ directly to `serp.py` functions in place of the real `pw_browser`.
45
+ """
46
+
47
+ def __init__(self, real_browser, html: str):
48
+ self._real_browser = real_browser
49
+ self._html = html
50
+
51
+ async def new_context(self, **kwargs):
52
+ real_context = await self._real_browser.new_context(**kwargs)
53
+ return _GotoPatchingContext(real_context, self._html)
54
+
55
+
56
+ def make_fixture_browser(real_browser, html: str) -> _GotoPatchingBrowser:
57
+ """Build a Browser-like object for characterizing `serp.py`'s scraper
58
+ functions against fixture HTML.
59
+
60
+ Why this exists: each scraper (e.g. `query_bing_search`) registers its
61
+ own catch-all `page.route("**/*", ...)` resource blocker *inside* the
62
+ function, and that handler calls `route.continue_()` (not
63
+ `route.fallback()`) for the main document request. Playwright invokes
64
+ the most-recently-registered matching route first, so any route we
65
+ register on the page *before* calling the function never runs -
66
+ verified empirically, it's not a documented guarantee either way.
67
+ Patching `page.goto` itself, on a page created through this wrapper, is
68
+ the only reliable seam into these functions without touching their
69
+ source (see the code review this test suite followed from: the
70
+ fetch/navigate and parse logic aren't separated, which is exactly what
71
+ makes this workaround necessary).
72
+ """
73
+ return _GotoPatchingBrowser(real_browser, html)
tests/test_app.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Characterization tests for app.py's search endpoints.
2
+
3
+ These monkeypatch the underlying query_*/ops_search functions rather than
4
+ hitting real search engines - app.py's own job here is just wiring
5
+ (gather queries concurrently, log failures, flatten results, decide when to
6
+ report an error), which is exactly what's being tested. The app's lifespan
7
+ (which starts a real Playwright browser) is never triggered: ASGITransport
8
+ doesn't run it, and these tests don't need it since pw_browser is never
9
+ actually used once the query functions are patched out.
10
+ """
11
+
12
+ import httpx
13
+ import pytest
14
+ from httpx import ASGITransport
15
+
16
+ import app as app_module
17
+ from serp import SerpQuery
18
+
19
+
20
+ @pytest.fixture
21
+ async def client():
22
+ transport = ASGITransport(app=app_module.app)
23
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
24
+ yield c
25
+
26
+
27
+ # ------------------------- _shape_serp_results / _run_serp_queries -------------------------
28
+
29
+
30
+ def test_shape_serp_results_flattens_successful_lists():
31
+ result = app_module._shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
32
+ assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
33
+ assert result.error is None
34
+
35
+
36
+ def test_shape_serp_results_surfaces_last_error_when_all_failed():
37
+ result = app_module._shape_serp_results([ValueError("first"), RuntimeError("second")])
38
+ assert result.results == []
39
+ assert "second" in result.error
40
+
41
+
42
+ def test_shape_serp_results_ignores_failures_when_some_succeed():
43
+ result = app_module._shape_serp_results([[{"a": 1}], RuntimeError("boom")])
44
+ assert result.results == [{"a": 1}]
45
+ assert result.error is None
46
+
47
+
48
+ async def test_run_serp_queries_calls_fn_per_query_and_shapes_result():
49
+ params = SerpQuery(queries=["x", "y"], n_results=5)
50
+ calls = []
51
+
52
+ async def fn(q, n):
53
+ calls.append((q, n))
54
+ return [{"q": q}]
55
+
56
+ result = await app_module._run_serp_queries(fn, params, "test context")
57
+
58
+ assert sorted(calls) == [("x", 5), ("y", 5)]
59
+ assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str)
60
+
61
+
62
+ # ------------------------------------- endpoints -------------------------------------
63
+
64
+
65
+ async def test_search_arxiv_flattens_results_from_all_queries(client, monkeypatch):
66
+ async def fake_query_arxiv(client_arg, q, n):
67
+ return [{"title": f"paper about {q}", "href": "x", "body": "y", "id": "1"}]
68
+
69
+ monkeypatch.setattr(app_module, "query_arxiv", fake_query_arxiv)
70
+
71
+ resp = await client.post("/serp/search_arxiv", json={"queries": ["a", "b"], "n_results": 5})
72
+
73
+ assert resp.status_code == 200
74
+ data = resp.json()
75
+ assert data["error"] is None
76
+ assert {r["title"] for r in data["results"]} == {"paper about a", "paper about b"}
77
+
78
+
79
+ async def test_search_arxiv_all_queries_failing_returns_error(client, monkeypatch):
80
+ async def failing_query_arxiv(client_arg, q, n):
81
+ raise RuntimeError(f"boom for {q}")
82
+
83
+ monkeypatch.setattr(app_module, "query_arxiv", failing_query_arxiv)
84
+
85
+ resp = await client.post("/serp/search_arxiv", json={"queries": ["a"], "n_results": 5})
86
+
87
+ assert resp.status_code == 200
88
+ data = resp.json()
89
+ assert data["results"] == []
90
+ assert "boom for a" in data["error"]
91
+
92
+
93
+ async def test_search_patents_does_not_fall_back_when_google_patents_succeeds(client, monkeypatch):
94
+ async def fake_google_patents(browser, q, n):
95
+ return [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
96
+
97
+ ops_search_called = False
98
+
99
+ async def fake_ops_search(client_arg, q, n):
100
+ nonlocal ops_search_called
101
+ ops_search_called = True
102
+ return []
103
+
104
+ monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
105
+ monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
106
+ monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
107
+ monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
108
+
109
+ resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
110
+
111
+ data = resp.json()
112
+ assert data["results"] == [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
113
+ assert ops_search_called is False
114
+
115
+
116
+ async def test_search_patents_falls_back_to_ops_when_google_patents_empty(client, monkeypatch):
117
+ async def fake_google_patents(browser, q, n):
118
+ return []
119
+
120
+ async def fake_ops_search(client_arg, q, n):
121
+ return [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
122
+
123
+ monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
124
+ monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
125
+ monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
126
+ monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
127
+
128
+ resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
129
+
130
+ data = resp.json()
131
+ assert data["results"] == [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
132
+
133
+
134
+ async def test_search_patents_no_fallback_when_ops_not_configured(client, monkeypatch):
135
+ async def fake_google_patents(browser, q, n):
136
+ return []
137
+
138
+ monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
139
+ monkeypatch.setattr(app_module.ops_token_manager, "_key", None)
140
+ monkeypatch.setattr(app_module.ops_token_manager, "_secret", None)
141
+
142
+ resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
143
+
144
+ data = resp.json()
145
+ assert data["results"] == []
146
+ assert data["error"] is None
147
+
148
+
149
+ async def test_ops_keyword_search_flattens_results(client, monkeypatch):
150
+ async def fake_ops_search(client_arg, q, n):
151
+ return [{"title": f"OPS {q}", "body": "", "href": "", "id": "1"}]
152
+
153
+ monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
154
+
155
+ resp = await client.post("/ops/search", json={"queries": ["a", "b"], "n_results": 5})
156
+
157
+ data = resp.json()
158
+ assert data["error"] is None
159
+ assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
tests/test_ops_client.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import httpx
5
+ import pytest
6
+ import respx
7
+
8
+ import ops as ops_module
9
+ from ops import OPSNotConfigured, _OPSTokenManager, _ops_get, ops_scrap_patent, ops_search
10
+
11
+ FIXTURES = Path(__file__).parent / "fixtures" / "ops"
12
+ TOKEN_URL = "https://ops.epo.org/3.2/auth/accesstoken"
13
+ OPS_BASE = "https://ops.epo.org/3.2/rest-services"
14
+
15
+
16
+ def load_json(name: str):
17
+ return json.loads((FIXTURES / name).read_text())
18
+
19
+
20
+ def mock_token_endpoint():
21
+ return respx.post(TOKEN_URL).mock(
22
+ return_value=httpx.Response(200, json=load_json("token_response.json"))
23
+ )
24
+
25
+
26
+ @pytest.fixture
27
+ def fresh_token_manager(monkeypatch):
28
+ """A token manager with test credentials, swapped in for the module-level
29
+ singleton so `_ops_get`/`ops_search`/etc. (which reach for the module
30
+ global `token_manager` directly) pick it up.
31
+ """
32
+ tm = _OPSTokenManager()
33
+ tm._key = "test-key"
34
+ tm._secret = "test-secret"
35
+ monkeypatch.setattr(ops_module, "token_manager", tm)
36
+ return tm
37
+
38
+
39
+ class TestTokenManager:
40
+ async def test_not_configured_raises(self):
41
+ tm = _OPSTokenManager()
42
+ tm._key = None
43
+ tm._secret = None
44
+ async with httpx.AsyncClient() as client:
45
+ with pytest.raises(OPSNotConfigured):
46
+ await tm.get_token(client)
47
+
48
+ async def test_fetches_and_caches_the_token(self):
49
+ tm = _OPSTokenManager()
50
+ tm._key = "k"
51
+ tm._secret = "s"
52
+ with respx.mock:
53
+ route = mock_token_endpoint()
54
+ async with httpx.AsyncClient() as client:
55
+ token1 = await tm.get_token(client)
56
+ token2 = await tm.get_token(client)
57
+
58
+ assert token1 == "test-access-token-123"
59
+ assert token2 == "test-access-token-123"
60
+ assert route.call_count == 1
61
+
62
+ async def test_force_refresh_refetches_even_if_cached(self):
63
+ tm = _OPSTokenManager()
64
+ tm._key = "k"
65
+ tm._secret = "s"
66
+ with respx.mock:
67
+ route = mock_token_endpoint()
68
+ async with httpx.AsyncClient() as client:
69
+ await tm.get_token(client)
70
+ await tm.get_token(client, force=True)
71
+
72
+ assert route.call_count == 2
73
+
74
+
75
+ class TestOpsGet:
76
+ async def test_retries_once_when_the_token_is_stale(self, fresh_token_manager):
77
+ with respx.mock:
78
+ mock_token_endpoint()
79
+ get_route = respx.get(f"{OPS_BASE}/some/path").mock(
80
+ side_effect=[
81
+ httpx.Response(401, text="Invalid access token."),
82
+ httpx.Response(200, json={"ok": True}),
83
+ ]
84
+ )
85
+ async with httpx.AsyncClient() as client:
86
+ data = await _ops_get(client, "/some/path")
87
+
88
+ assert data == {"ok": True}
89
+ assert get_route.call_count == 2
90
+
91
+ async def test_propagates_non_token_errors(self, fresh_token_manager):
92
+ with respx.mock:
93
+ mock_token_endpoint()
94
+ respx.get(f"{OPS_BASE}/some/path").mock(return_value=httpx.Response(500, text="server error"))
95
+ async with httpx.AsyncClient() as client:
96
+ with pytest.raises(httpx.HTTPStatusError):
97
+ await _ops_get(client, "/some/path")
98
+
99
+
100
+ class TestOpsSearch:
101
+ async def test_maps_ops_search_results_to_serp_dicts(self, fresh_token_manager):
102
+ with respx.mock:
103
+ mock_token_endpoint()
104
+ respx.get(f"{OPS_BASE}/published-data/search/biblio").mock(
105
+ return_value=httpx.Response(200, json=load_json("search_response.json"))
106
+ )
107
+ async with httpx.AsyncClient() as client:
108
+ results = await ops_search(client, "widget", 10)
109
+
110
+ assert results == [
111
+ {
112
+ "title": "Widget apparatus",
113
+ "body": "An apparatus comprising a widget.",
114
+ "href": "https://worldwide.espacenet.com/patent/search?q=US11930446",
115
+ "id": "US11930446",
116
+ },
117
+ {
118
+ "title": "Gadget system",
119
+ "body": "A system comprising a gadget.",
120
+ "href": "https://worldwide.espacenet.com/patent/search?q=EP4760514",
121
+ "id": "EP4760514",
122
+ },
123
+ ]
124
+
125
+ async def test_returns_empty_list_on_404_not_found(self, fresh_token_manager):
126
+ with respx.mock:
127
+ mock_token_endpoint()
128
+ respx.get(f"{OPS_BASE}/published-data/search/biblio").mock(
129
+ return_value=httpx.Response(404, json=load_json("search_not_found_response.json"))
130
+ )
131
+ async with httpx.AsyncClient() as client:
132
+ results = await ops_search(client, "nonexistent widget xyz", 10)
133
+
134
+ assert results == []
135
+
136
+
137
+ class TestOpsScrapPatent:
138
+ async def test_assembles_a_full_patent_from_three_endpoints(self, fresh_token_manager):
139
+ number = "US11930446"
140
+ with respx.mock:
141
+ mock_token_endpoint()
142
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/biblio").mock(
143
+ return_value=httpx.Response(200, json=load_json("biblio_response.json"))
144
+ )
145
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/claims").mock(
146
+ return_value=httpx.Response(200, json=load_json("claims_response.json"))
147
+ )
148
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/description").mock(
149
+ return_value=httpx.Response(200, json=load_json("description_response.json"))
150
+ )
151
+ async with httpx.AsyncClient() as client:
152
+ # Kind-code suffix ("B2") should be normalized away before
153
+ # any of the three requests go out.
154
+ result = await ops_scrap_patent(client, "US11930446B2")
155
+
156
+ assert result.title == "Widget apparatus"
157
+ assert result.abstract == "An apparatus comprising a widget."
158
+ assert result.claims == "1. A widget comprising a gadget.\n2. The widget of claim 1, further comprising a mechanism."
159
+ assert result.description == "This invention relates to widgets.\nThe widget comprises a gadget and a mechanism."
160
+ assert {c.code for c in result.classifications} == {"G06F17/30", "H04L9/00"}
161
+
162
+ async def test_missing_fulltext_degrades_to_none_instead_of_raising(self, fresh_token_manager):
163
+ number = "US11930446"
164
+ with respx.mock:
165
+ mock_token_endpoint()
166
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/biblio").mock(
167
+ return_value=httpx.Response(200, json=load_json("biblio_response.json"))
168
+ )
169
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/claims").mock(
170
+ return_value=httpx.Response(404)
171
+ )
172
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/description").mock(
173
+ return_value=httpx.Response(404)
174
+ )
175
+ async with httpx.AsyncClient() as client:
176
+ result = await ops_scrap_patent(client, number)
177
+
178
+ assert result.title == "Widget apparatus"
179
+ assert result.claims is None
180
+ assert result.description is None
tests/test_ops_parsing.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for ops.py's pure JSON-walking helpers.
2
+
3
+ OPS's XML-to-JSON conversion is the trickiest part of this module: text
4
+ nodes become {"$": "..."}, attributes become "@name" keys, and a repeated
5
+ XML element becomes a list *unless there's only one*, in which case it stays
6
+ a bare dict. Every helper here has to handle both shapes; these tests pin
7
+ both for each one.
8
+ """
9
+
10
+ import pytest
11
+
12
+ from ops import (
13
+ _as_list,
14
+ _text,
15
+ _pick_lang,
16
+ _epodoc_number,
17
+ _extract_title,
18
+ _extract_abstract,
19
+ _extract_classifications,
20
+ _fulltext_paragraphs,
21
+ _to_cql,
22
+ _normalize_epodoc,
23
+ )
24
+
25
+
26
+ class TestAsList:
27
+ def test_none_becomes_empty_list(self):
28
+ assert _as_list(None) == []
29
+
30
+ def test_single_dict_is_wrapped(self):
31
+ assert _as_list({"a": 1}) == [{"a": 1}]
32
+
33
+ def test_list_passes_through_unchanged(self):
34
+ assert _as_list([{"a": 1}, {"a": 2}]) == [{"a": 1}, {"a": 2}]
35
+
36
+
37
+ class TestText:
38
+ def test_extracts_dollar_key(self):
39
+ assert _text({"$": "hello"}) == "hello"
40
+
41
+ def test_plain_string_passes_through(self):
42
+ assert _text("hello") == "hello"
43
+
44
+ def test_none_for_anything_else(self):
45
+ assert _text(None) is None
46
+ assert _text({"@lang": "en"}) is None
47
+
48
+
49
+ class TestPickLang:
50
+ def test_picks_requested_language_from_list(self):
51
+ nodes = [{"@lang": "de", "$": "Hallo"}, {"@lang": "en", "$": "Hello"}]
52
+ assert _pick_lang(nodes) == {"@lang": "en", "$": "Hello"}
53
+
54
+ def test_falls_back_to_first_when_language_missing(self):
55
+ nodes = [{"@lang": "de", "$": "Hallo"}, {"@lang": "fr", "$": "Bonjour"}]
56
+ assert _pick_lang(nodes) == {"@lang": "de", "$": "Hallo"}
57
+
58
+ def test_single_dict_not_wrapped_in_a_list_still_works(self):
59
+ node = {"@lang": "en", "$": "Hello"}
60
+ assert _pick_lang(node) == node
61
+
62
+ def test_empty_input_returns_none(self):
63
+ assert _pick_lang(None) is None
64
+ assert _pick_lang([]) is None
65
+
66
+
67
+ class TestEpodocNumber:
68
+ def test_prefers_the_epodoc_document_id(self):
69
+ exchange_doc = {
70
+ "@country": "US",
71
+ "@doc-number": "11930446",
72
+ "bibliographic-data": {
73
+ "publication-reference": {
74
+ "document-id": [
75
+ {"@document-id-type": "docdb", "doc-number": {"$": "11930446"}},
76
+ {"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}},
77
+ ]
78
+ }
79
+ },
80
+ }
81
+ assert _epodoc_number(exchange_doc) == "US11930446"
82
+
83
+ def test_falls_back_to_country_plus_doc_number(self):
84
+ exchange_doc = {"@country": "US", "@doc-number": "11930446", "bibliographic-data": {}}
85
+ assert _epodoc_number(exchange_doc) == "US11930446"
86
+
87
+ def test_none_when_nothing_available(self):
88
+ assert _epodoc_number({"bibliographic-data": {}}) is None
89
+
90
+
91
+ class TestExtractTitle:
92
+ def test_from_a_list_of_languages(self):
93
+ bd = {"invention-title": [{"@lang": "en", "$": "Widget"}, {"@lang": "de", "$": "Widget-DE"}]}
94
+ assert _extract_title(bd) == "Widget"
95
+
96
+ def test_from_a_single_bare_dict(self):
97
+ bd = {"invention-title": {"@lang": "en", "$": "Widget"}}
98
+ assert _extract_title(bd) == "Widget"
99
+
100
+ def test_none_when_missing(self):
101
+ assert _extract_title({}) is None
102
+
103
+
104
+ class TestExtractAbstract:
105
+ def test_joins_paragraphs_from_a_list(self):
106
+ doc = {"abstract": [{"@lang": "en", "p": [{"$": "Part one."}, {"$": "Part two."}]}]}
107
+ assert _extract_abstract(doc) == "Part one. Part two."
108
+
109
+ def test_single_paragraph_not_wrapped_in_a_list(self):
110
+ doc = {"abstract": {"@lang": "en", "p": {"$": "Only paragraph."}}}
111
+ assert _extract_abstract(doc) == "Only paragraph."
112
+
113
+ def test_none_when_missing(self):
114
+ assert _extract_abstract({}) is None
115
+
116
+
117
+ class TestExtractClassifications:
118
+ def test_builds_codes_from_the_five_part_symbol(self):
119
+ bd = {
120
+ "patent-classifications": {
121
+ "patent-classification": [
122
+ {
123
+ "section": {"$": "G"},
124
+ "class": {"$": "06"},
125
+ "subclass": {"$": "F"},
126
+ "main-group": {"$": "17"},
127
+ "subgroup": {"$": "30"},
128
+ }
129
+ ]
130
+ }
131
+ }
132
+ codes = _extract_classifications(bd)
133
+ assert len(codes) == 1
134
+ assert codes[0].code == "G06F17/30"
135
+ assert codes[0].description == ""
136
+
137
+ def test_dedupes_and_skips_incomplete_entries(self):
138
+ bd = {
139
+ "patent-classifications": {
140
+ "patent-classification": [
141
+ {"section": {"$": "G"}, "class": {"$": "06"}, "subclass": {"$": "F"}, "main-group": {"$": "17"}, "subgroup": {"$": "30"}},
142
+ {"section": {"$": "G"}, "class": {"$": "06"}, "subclass": {"$": "F"}, "main-group": {"$": "17"}, "subgroup": {"$": "30"}},
143
+ {"section": {"$": "H"}}, # incomplete - no class/subclass/main-group
144
+ ]
145
+ }
146
+ }
147
+ codes = _extract_classifications(bd)
148
+ assert [c.code for c in codes] == ["G06F17/30"]
149
+
150
+ def test_none_when_no_classifications(self):
151
+ assert _extract_classifications({}) is None
152
+
153
+
154
+ class TestFulltextParagraphs:
155
+ def test_claims_join_claim_text_lines(self):
156
+ data = {
157
+ "ops:world-patent-data": {
158
+ "ftxt:fulltext-documents": {
159
+ "ftxt:fulltext-document": {
160
+ "claims": {
161
+ "@lang": "en",
162
+ "claim": [
163
+ {"claim-text": {"$": "1. A widget."}},
164
+ {"claim-text": [{"$": "2. A gadget,"}, {"$": "further comprising a mechanism."}]},
165
+ ],
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+ assert _fulltext_paragraphs(data, "claims") == (
172
+ "1. A widget.\n2. A gadget,\nfurther comprising a mechanism."
173
+ )
174
+
175
+ def test_description_joins_paragraphs(self):
176
+ data = {
177
+ "ops:world-patent-data": {
178
+ "ftxt:fulltext-documents": {
179
+ "ftxt:fulltext-document": {
180
+ "description": {"@lang": "en", "p": [{"$": "Para one."}, {"$": "Para two."}]}
181
+ }
182
+ }
183
+ }
184
+ }
185
+ assert _fulltext_paragraphs(data, "description") == "Para one.\nPara two."
186
+
187
+ def test_none_when_document_missing(self):
188
+ assert _fulltext_paragraphs({"ops:world-patent-data": {}}, "claims") is None
189
+
190
+
191
+ @pytest.mark.parametrize(
192
+ "raw, expected",
193
+ [
194
+ ("widget", 'txt all "widget"'),
195
+ ("agentic ai", 'txt all "agentic ai"'),
196
+ ('pn = "US1234"', 'pn = "US1234"'),
197
+ ("", ""),
198
+ ('has "quotes"', 'txt all "has quotes"'),
199
+ ],
200
+ )
201
+ def test_to_cql(raw, expected):
202
+ assert _to_cql(raw) == expected
203
+
204
+
205
+ @pytest.mark.parametrize(
206
+ "raw, expected",
207
+ [
208
+ ("US11930446B2", "US11930446"),
209
+ ("EP4760514A1", "EP4760514"),
210
+ ("US 11930446 B2", "US11930446"),
211
+ ("US11930446", "US11930446"),
212
+ ],
213
+ )
214
+ def test_normalize_epodoc(raw, expected):
215
+ assert _normalize_epodoc(raw) == expected
tests/test_scrap.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from helpers import load_fixture
6
+ from scrap import parse_patent_html, scrap_patent_async, scrap_patent_bulk_async
7
+
8
+ FULL_PATENT_HTML = load_fixture("patents", "full_patent.html")
9
+ MISSING_TITLE_HTML = load_fixture("patents", "missing_title.html")
10
+
11
+
12
+ def test_parse_patent_html_extracts_all_fields_with_no_network_involved():
13
+ """`scrap_patent_async` is now a thin fetch that delegates to this - a
14
+ plain, synchronous function of the HTML string, so the parsing logic
15
+ (including the regex-based section splitting, which is the part most
16
+ likely to break when Google Patents changes its markup) can be tested
17
+ directly against a saved page with no client/respx/event loop needed.
18
+ """
19
+ result = parse_patent_html(FULL_PATENT_HTML, "https://patents.google.com/patent/US11930446B2/en")
20
+
21
+ assert result.title == "Widget with improved gadget mechanism"
22
+ codes = {c.code: c.description for c in result.classifications}
23
+ assert codes == {
24
+ "G06F17/30": "Database structures therefor",
25
+ "G06F17/50": "Other database related",
26
+ "H04L9/00": "Cryptographic mechanisms",
27
+ }
28
+
29
+
30
+ def test_parse_patent_html_raises_when_page_has_no_title():
31
+ with pytest.raises(ValueError):
32
+ parse_patent_html(MISSING_TITLE_HTML, "https://patents.google.com/patent/BOGUS/en")
33
+
34
+
35
+ async def test_scrap_patent_async_extracts_all_fields():
36
+ url = "https://patents.google.com/patent/US11930446B2/en"
37
+ with respx.mock:
38
+ respx.get(url).mock(return_value=httpx.Response(200, text=FULL_PATENT_HTML))
39
+ async with httpx.AsyncClient() as client:
40
+ result = await scrap_patent_async(client, url)
41
+
42
+ assert result.title == "Widget with improved gadget mechanism"
43
+ assert result.abstract == "A widget comprising a gadget and a mechanism for improving widget performance."
44
+ assert result.field_of_invention == "This invention relates to widgets and gadgets for testing purposes."
45
+ assert result.background == "Prior art widgets suffered from several problems described herein."
46
+ assert "1. A widget comprising a gadget." in result.claims
47
+ assert "2. The widget of claim 1, further comprising a mechanism." in result.claims
48
+
49
+ codes = {c.code: c.description for c in result.classifications}
50
+ assert codes == {
51
+ "G06F17/30": "Database structures therefor",
52
+ "G06F17/50": "Other database related",
53
+ "H04L9/00": "Cryptographic mechanisms",
54
+ }
55
+
56
+
57
+ async def test_scrap_patent_async_raises_when_page_has_no_title():
58
+ """Pins the documented behavior at scrap.py: an unexpected page layout
59
+ (interstitial, non-patent page, markup change) raises ValueError rather
60
+ than crashing on `None.get(...)`, so callers can treat it as a failed
61
+ scrape.
62
+ """
63
+ url = "https://patents.google.com/patent/BOGUS/en"
64
+ with respx.mock:
65
+ respx.get(url).mock(return_value=httpx.Response(200, text=MISSING_TITLE_HTML))
66
+ async with httpx.AsyncClient() as client:
67
+ with pytest.raises(ValueError):
68
+ await scrap_patent_async(client, url)
69
+
70
+
71
+ async def test_scrap_patent_async_raises_on_http_error():
72
+ url = "https://patents.google.com/patent/US00000000/en"
73
+ with respx.mock:
74
+ respx.get(url).mock(return_value=httpx.Response(404))
75
+ async with httpx.AsyncClient() as client:
76
+ with pytest.raises(httpx.HTTPStatusError):
77
+ await scrap_patent_async(client, url)
78
+
79
+
80
+ async def test_scrap_patent_bulk_async_separates_successes_from_failures():
81
+ ok_url = "https://patents.google.com/patent/US_OK/en"
82
+ fail_url = "https://patents.google.com/patent/US_FAIL/en"
83
+ with respx.mock:
84
+ respx.get(ok_url).mock(return_value=httpx.Response(200, text=FULL_PATENT_HTML))
85
+ respx.get(fail_url).mock(return_value=httpx.Response(404))
86
+ async with httpx.AsyncClient() as client:
87
+ result = await scrap_patent_bulk_async(client, ["US_OK", "US_FAIL"])
88
+
89
+ assert len(result.patents) == 1
90
+ assert result.patents[0].title == "Widget with improved gadget mechanism"
91
+ assert result.failed_ids == ["US_FAIL"]
tests/test_serp_arxiv.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import respx
3
+
4
+ from helpers import load_fixture
5
+ from serp import query_arxiv
6
+
7
+ ARXIV_XML = load_fixture("arxiv_response.xml")
8
+
9
+
10
+ async def test_query_arxiv_parses_the_atom_feed():
11
+ with respx.mock:
12
+ respx.get("https://export.arxiv.org/api/query").mock(
13
+ return_value=httpx.Response(200, text=ARXIV_XML, headers={"content-type": "application/atom+xml"})
14
+ )
15
+ async with httpx.AsyncClient() as client:
16
+ results = await query_arxiv(client, "widgets", 2)
17
+
18
+ assert results == [
19
+ {
20
+ "title": "A Study of Widgets",
21
+ "href": "http://arxiv.org/pdf/2401.00001v1",
22
+ "body": "This paper studies widgets in depth.",
23
+ "id": "http://arxiv.org/abs/2401.00001v1",
24
+ },
25
+ {
26
+ "title": "Gadgets and Their Applications",
27
+ "href": "http://arxiv.org/pdf/2401.00002v1",
28
+ "body": "An overview of gadgets.",
29
+ "id": "http://arxiv.org/abs/2401.00002v1",
30
+ },
31
+ ]
tests/test_serp_ddg.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ import serp
4
+ from serp import DuckDuckGoBlockedException, query_ddg_search
5
+
6
+
7
+ class _FakeDDGS:
8
+ def __init__(self, results):
9
+ self._results = results
10
+
11
+ def text(self, q, max_results=10):
12
+ return iter(self._results)
13
+
14
+
15
+ async def test_maps_duckduckgo_results_to_serp_dicts(monkeypatch):
16
+ monkeypatch.setattr(
17
+ serp,
18
+ "DDGS",
19
+ lambda: _FakeDDGS(
20
+ [
21
+ {"title": "A", "body": "Body A", "href": "https://a.example"},
22
+ {"title": "B", "body": "Body B", "href": "https://b.example"},
23
+ ]
24
+ ),
25
+ )
26
+
27
+ results = await query_ddg_search("test query", 10)
28
+
29
+ assert results == [
30
+ {"title": "A", "body": "Body A", "href": "https://a.example"},
31
+ {"title": "B", "body": "Body B", "href": "https://b.example"},
32
+ ]
33
+
34
+
35
+ async def test_raises_blocked_exception_on_empty_results(monkeypatch):
36
+ """Pins the fix from commit d341a7a: duckduckgo_search doesn't always
37
+ raise when it's rate-limited/blocked - it can just parse zero results
38
+ from the anti-bot page. An empty list must be treated as a failure so
39
+ the `/serp/search` fallback chain moves on to the next backend.
40
+ """
41
+ monkeypatch.setattr(serp, "DDGS", lambda: _FakeDDGS([]))
42
+
43
+ with pytest.raises(DuckDuckGoBlockedException):
44
+ await query_ddg_search("test query", 10)
tests/test_serp_models.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from serp import SerpQuery
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ "raw, expected",
8
+ [
9
+ (10, 10),
10
+ (1, 1),
11
+ (100, 100),
12
+ (0, 1),
13
+ (-5, 1),
14
+ (500, 100),
15
+ (101, 100),
16
+ ],
17
+ )
18
+ def test_n_results_is_clamped_to_1_100(raw, expected):
19
+ q = SerpQuery(queries=["test"], n_results=raw)
20
+ assert q.n_results == expected
21
+
22
+
23
+ def test_n_results_defaults_to_10():
24
+ q = SerpQuery(queries=["test"])
25
+ assert q.n_results == 10
tests/test_serp_playwright.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Characterization tests for the Playwright-driven scrapers in serp.py.
2
+
3
+ These run against a real headless Chromium (same engine production uses) so
4
+ that DOM/CSS-selector behavior is genuinely exercised, but the target page is
5
+ served locally from a fixture instead of navigating to the live site - see
6
+ `helpers.make_fixture_browser` for how, and why a simpler
7
+ `page.route()`-based approach doesn't work here.
8
+ """
9
+
10
+ import pytest
11
+
12
+ from helpers import load_fixture, make_fixture_browser
13
+ from serp import (
14
+ BraveSearchBlockedException,
15
+ BrowserUnavailableError,
16
+ _extract_bing_results,
17
+ playwright_open_page,
18
+ query_bing_search,
19
+ query_brave_search,
20
+ query_google_patents,
21
+ query_google_scholar,
22
+ )
23
+
24
+
25
+ async def test_extract_bing_results_works_on_an_already_loaded_page(browser):
26
+ """Demonstrates the payoff of splitting fetch (navigate) from parse
27
+ (extract): `_extract_bing_results` takes a Page that's already loaded,
28
+ so this needs nothing beyond `set_content` - no goto-patching wrapper,
29
+ no browser-as-a-parameter indirection.
30
+ """
31
+ context = await browser.new_context()
32
+ page = await context.new_page()
33
+ await page.set_content(load_fixture("bing_results.html"))
34
+
35
+ results = await _extract_bing_results(page, 10)
36
+
37
+ assert results == [
38
+ {"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
39
+ {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
40
+ ]
41
+
42
+ await context.close()
43
+
44
+
45
+ async def test_bing_search_parses_results(browser):
46
+ fixture_browser = make_fixture_browser(browser, load_fixture("bing_results.html"))
47
+
48
+ results = await query_bing_search(fixture_browser, "test query", 10)
49
+
50
+ assert results == [
51
+ {"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
52
+ {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
53
+ ]
54
+
55
+
56
+ async def test_brave_search_parses_results(browser):
57
+ fixture_browser = make_fixture_browser(browser, load_fixture("brave_results.html"))
58
+
59
+ results = await query_brave_search(fixture_browser, "test query", 10)
60
+
61
+ assert results == [
62
+ {"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
63
+ {"title": "Brave Result B", "body": "Brave snippet B", "href": "https://example.org/brave-b"},
64
+ ]
65
+
66
+
67
+ async def test_brave_search_detects_the_suspicious_block_page(browser):
68
+ fixture_browser = make_fixture_browser(browser, load_fixture("brave_blocked.html"))
69
+
70
+ with pytest.raises(BraveSearchBlockedException):
71
+ await query_brave_search(fixture_browser, "test query", 10)
72
+
73
+
74
+ async def test_google_scholar_parses_results(browser):
75
+ fixture_browser = make_fixture_browser(browser, load_fixture("scholar_results.html"))
76
+
77
+ results = await query_google_scholar(fixture_browser, "test query", 10)
78
+
79
+ assert results == [
80
+ {"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
81
+ {"title": "Scholar Paper B", "body": "Scholar snippet B", "href": "https://example.org/scholar-b"},
82
+ ]
83
+
84
+
85
+ async def test_google_patents_parses_results(browser):
86
+ fixture_browser = make_fixture_browser(browser, load_fixture("patents", "search_results.html"))
87
+
88
+ results = await query_google_patents(fixture_browser, "test query", 10)
89
+
90
+ assert results == [
91
+ {
92
+ "id": "US11930446B2",
93
+ "href": "https://patents.google.com/patent/US11930446B2/en",
94
+ "title": "Widget apparatus",
95
+ "body": "An apparatus comprising a widget.",
96
+ },
97
+ {
98
+ "id": "EP4760514A1",
99
+ "href": "https://patents.google.com/patent/EP4760514A1/en",
100
+ "title": "Gadget system",
101
+ "body": "A system comprising a gadget.",
102
+ },
103
+ ]
104
+
105
+
106
+ async def test_playwright_open_page_raises_when_browser_unavailable():
107
+ with pytest.raises(BrowserUnavailableError):
108
+ async with playwright_open_page(None):
109
+ pass
tests/test_utils.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from serp import SerpQuery
4
+ from utils import log_gathered_exceptions
5
+
6
+
7
+ def test_logs_only_the_exceptions_with_their_query(caplog):
8
+ params = SerpQuery(queries=["q1", "q2", "q3"])
9
+ results = ["ok result", ValueError("boom"), "another ok"]
10
+
11
+ with caplog.at_level(logging.WARNING):
12
+ log_gathered_exceptions(results, "test context", params)
13
+
14
+ warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
15
+ assert len(warnings) == 1
16
+ assert "q2" in warnings[0].message
17
+ assert "boom" in warnings[0].message
18
+ assert "test context" in warnings[0].message
19
+
20
+
21
+ def test_logs_nothing_when_all_results_succeeded(caplog):
22
+ params = SerpQuery(queries=["q1", "q2"])
23
+ results = ["ok", "also ok"]
24
+
25
+ with caplog.at_level(logging.WARNING):
26
+ log_gathered_exceptions(results, "test context", params)
27
+
28
+ assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
29
+
30
+
31
+ def test_uses_its_own_logger_not_the_asyncio_one(caplog):
32
+ """Was `from asyncio.log import logger` (flagged in review), which
33
+ misattributed these warnings to the 'asyncio' logging namespace instead
34
+ of the app's own. Fixed to `logging.getLogger(__name__)`.
35
+ """
36
+ params = SerpQuery(queries=["q1"])
37
+ with caplog.at_level(logging.WARNING):
38
+ log_gathered_exceptions([ValueError("boom")], "ctx", params)
39
+
40
+ assert caplog.records[0].name == "utils"
utils.py CHANGED
@@ -1,6 +1,9 @@
1
- from asyncio.log import logger
 
2
  from serp import SerpQuery
3
 
 
 
4
 
5
  def log_gathered_exceptions(results: list, context: str, params: SerpQuery):
6
  """Logs gathered exceptions with context and parameters."""
 
1
+ import logging
2
+
3
  from serp import SerpQuery
4
 
5
+ logger = logging.getLogger(__name__)
6
+
7
 
8
  def log_gathered_exceptions(results: list, context: str, params: SerpQuery):
9
  """Logs gathered exceptions with context and parameters."""