Skip to content

Commit 20da162

Browse files
committed
Add param to output as json-ld
1 parent a0cac8a commit 20da162

15 files changed

Lines changed: 256 additions & 225 deletions

app.py

Lines changed: 63 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from logging import Logger, getLogger
2+
from typing import Any
23

3-
from curl_cffi import Response, Session
4+
from curl_cffi import Response as CurlResponse, Session
45
from fastapi import Depends, FastAPI, HTTPException
5-
from fastapi.responses import JSONResponse
6+
from fastapi.responses import HTMLResponse, JSONResponse, Response
67

78
from config.constants import DEFAULT_USER_AGENT
8-
from models.extended_feed import ExtendedJsonFeedItem, ExtendedJsonFeedTopLevel
9-
from models.feed import JsonFeedItem, JsonFeedTopLevel
9+
from models.feed import JsonFeedTopLevel
1010
from models.query import (
1111
AmazonAsinQuery,
1212
AmazonKeywordQuery,
@@ -18,6 +18,7 @@
1818
from parsers.item_parser import parse_item_details
1919
from parsers.search_parser import parse_search_results
2020
from services.item_generator import get_top_level_feed
21+
from services.ld_generator import get_html
2122
from services.response_handler import get_response
2223
from services.url_builder import get_dimension_url, get_search_url
2324

@@ -26,103 +27,84 @@
2627

2728

2829
class AmazonFeedGenerator:
29-
def __init__(self) -> None:
30-
"""
31-
Initialize the Amazon Feed Generator with configuration and setup.
32-
"""
33-
# Setup can be done here if needed
34-
3530
def create_query_config(self) -> QueryConfig:
3631
return QueryConfig(
3732
session=Session(),
3833
logger=logger,
3934
useragent=DEFAULT_USER_AGENT,
4035
)
4136

37+
def process_query(
38+
self,
39+
params: QueryParams,
40+
query_class: type[AmazonKeywordQuery | AmazonAsinQuery],
41+
url_builder_func,
42+
parser_func,
43+
) -> Response:
44+
try:
45+
config: QueryConfig = self.create_query_config()
46+
47+
query: AmazonAsinQuery | AmazonKeywordQuery = query_class(
48+
status=QueryStatus(),
49+
query_str=params.q,
50+
locale=convert_to_locale(value=params.country),
51+
min_price=params.min_price,
52+
max_price=params.max_price,
53+
jsonld=params.jsonld,
54+
config=config,
55+
)
4256

43-
feed_generator: AmazonFeedGenerator = AmazonFeedGenerator()
44-
45-
46-
@app.get(path="/")
47-
@app.get(path="/query")
48-
async def keyword_search(params: QueryParams = Depends()) -> JSONResponse:
49-
"""
50-
Handle keyword search requests.
51-
"""
52-
try:
53-
config: QueryConfig = feed_generator.create_query_config()
54-
55-
query: AmazonKeywordQuery = AmazonKeywordQuery(
56-
status=QueryStatus(),
57-
query_str=params.q,
58-
locale=convert_to_locale(value=params.country),
59-
min_price=params.min_price,
60-
max_price=params.max_price,
61-
strict=params.strict,
62-
config=config,
63-
)
64-
65-
base_url: str = f"https://{query.locale.domain}"
66-
search_url: str = get_search_url(base_url, query)
67-
68-
response: Response | JSONResponse = get_response(url=search_url, query=query)
57+
base_url: str = f"https://{query.locale.domain}"
58+
search_url: Any = url_builder_func(base_url, query)
6959

70-
if isinstance(response, Response):
71-
feed_items: list[JsonFeedItem] = parse_search_results(
72-
response.content, query, base_url
60+
response: CurlResponse | JSONResponse = get_response(
61+
url=search_url, query=query
7362
)
7463

75-
json_feed: JsonFeedTopLevel = get_top_level_feed(
76-
base_url, query, feed_items
77-
)
64+
if isinstance(response, CurlResponse):
65+
feed_items: list = parser_func(
66+
response.content or response.json(), query, base_url
67+
)
7868

79-
return JSONResponse(content=json_feed.model_dump(exclude_none=True))
80-
else:
81-
return response
82-
83-
except Exception as e:
84-
logger.error(msg=f"Keyword search error: {e}")
85-
raise HTTPException(status_code=500, detail=f"Keyword search error: {e}")
69+
if params.jsonld:
70+
html_text: str = get_html(feed_items)
71+
return HTMLResponse(content=html_text)
72+
else:
73+
json_feed: JsonFeedTopLevel = get_top_level_feed(
74+
base_url, query, feed_items
75+
)
76+
return JSONResponse(content=json_feed.model_dump(exclude_none=True))
8677

78+
return response
8779

88-
@app.get(path="/asin")
89-
async def asin_lookup(params: QueryParams = Depends()) -> JSONResponse:
90-
"""
91-
Handle ASIN lookup requests.
92-
"""
93-
try:
94-
config: QueryConfig = feed_generator.create_query_config()
95-
96-
query: AmazonAsinQuery = AmazonAsinQuery(
97-
status=QueryStatus(),
98-
query_str=params.q,
99-
locale=convert_to_locale(value=params.country),
100-
min_price=params.min_price,
101-
max_price=params.max_price,
102-
config=config,
103-
)
80+
except Exception as e:
81+
error_msg: str = f"{'Keyword' if query_class is AmazonKeywordQuery else 'ASIN'} lookup error: {e}"
82+
logger.error(msg=error_msg)
83+
raise HTTPException(status_code=500, detail=error_msg)
10484

105-
base_url: str = f"https://{query.locale.domain}"
106-
search_url: str = get_dimension_url(query)
10785

108-
response: Response | JSONResponse = get_response(url=search_url, query=query)
86+
feed_generator: AmazonFeedGenerator = AmazonFeedGenerator()
10987

110-
if isinstance(response, Response):
111-
feed_items: list[JsonFeedItem | ExtendedJsonFeedItem] = parse_item_details(
112-
response.json(), query, base_url
113-
)
11488

115-
json_feed: ExtendedJsonFeedTopLevel = get_top_level_feed(
116-
base_url, query, feed_items
117-
)
89+
@app.get(path="/")
90+
@app.get(path="/query")
91+
async def keyword_search(params: QueryParams = Depends()) -> Response:
92+
return feed_generator.process_query(
93+
params,
94+
query_class=AmazonKeywordQuery,
95+
url_builder_func=get_search_url,
96+
parser_func=parse_search_results,
97+
)
11898

119-
return JSONResponse(content=json_feed.model_dump(exclude_none=True))
120-
else:
121-
return response
12299

123-
except Exception as e:
124-
logger.error(msg=f"ASIN lookup error: {e}")
125-
raise HTTPException(status_code=500, detail=f"ASIN lookup error: {e}")
100+
@app.get(path="/asin")
101+
async def asin_lookup(params: QueryParams = Depends()) -> Response:
102+
return feed_generator.process_query(
103+
params,
104+
query_class=AmazonAsinQuery,
105+
url_builder_func=get_dimension_url,
106+
parser_func=parse_item_details,
107+
)
126108

127109

128110
@app.get(path="/healthcheck")

models/amazon.py

Lines changed: 0 additions & 31 deletions
This file was deleted.

models/amazon/asin.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from typing import Annotated
2+
from pydantic import AfterValidator, BaseModel
3+
4+
from models.validators import validate_asin
5+
6+
7+
class Asin(BaseModel):
8+
id: Annotated[str, AfterValidator(func=validate_asin)]

models/amazon/locale.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pydantic import BaseModel
2+
3+
4+
class AmazonLocale(BaseModel):
5+
code: str
6+
domain: str
7+
currency_sign: str
8+
currency_code: str
9+
10+
11+
locale_list: list[AmazonLocale] = [
12+
AmazonLocale(
13+
code="AU", domain="www.amazon.com.au", currency_sign="$", currency_code="AUD"
14+
),
15+
AmazonLocale(
16+
code="DE", domain="www.amazon.de", currency_sign="€", currency_code="EUR"
17+
),
18+
AmazonLocale(
19+
code="ES", domain="www.amazon.es", currency_sign="€", currency_code="EUR"
20+
),
21+
AmazonLocale(
22+
code="FR", domain="www.amazon.fr", currency_sign="€", currency_code="EUR"
23+
),
24+
AmazonLocale(
25+
code="IT", domain="www.amazon.it", currency_sign="€", currency_code="EUR"
26+
),
27+
AmazonLocale(
28+
code="SG", domain="www.amazon.sg", currency_sign="S$", currency_code="SGD"
29+
),
30+
AmazonLocale(
31+
code="UK", domain="www.amazon.co.uk", currency_sign="£", currency_code="GBP"
32+
),
33+
AmazonLocale(
34+
code="US", domain="www.amazon.com", currency_sign="$", currency_code="USD"
35+
),
36+
]
37+
38+
default_locale: AmazonLocale = next(
39+
locale for locale in locale_list if locale.code == "US"
40+
)
Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
from pydantic import ConfigDict, Field, PositiveFloat
1+
from decimal import Decimal
2+
3+
from pydantic import ConfigDict, Field
24
from pydantic.main import BaseModel
35

4-
from models.feed import JsonFeedItem, JsonFeedTopLevel, SerHttpUrl
6+
from models.amazon.asin import Asin
7+
from models.feed import SerHttpUrl
58

69

710
class Thing(BaseModel):
@@ -12,24 +15,15 @@ class Thing(BaseModel):
1215

1316
class Offer(Thing):
1417
type: str = Field(default="Offer", serialization_alias="@type")
15-
priceCurrency: str | None
16-
price: PositiveFloat | None
18+
priceCurrency: str | None = None
19+
price: Decimal | None = None
1720
availability: str | None = "https://schema.org/InStock"
1821

1922

2023
class Product(Thing):
2124
type: str = Field(default="Product", serialization_alias="@type")
2225
context: str = Field(default="https://schema.org/", serialization_alias="@context")
2326
name: str | None
24-
asin: str | None
27+
asin: Asin | None
2528
image: list[SerHttpUrl] | None
2629
offers: Offer | None
27-
28-
29-
class ExtendedJsonFeedItem(JsonFeedItem):
30-
# Product schema encapsulated in <script type="application/ld+json">
31-
_linked_data: str | None = None
32-
33-
34-
class ExtendedJsonFeedTopLevel(JsonFeedTopLevel):
35-
items: list[JsonFeedItem | ExtendedJsonFeedItem]

models/query.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
from fastapi import Query
66
from pydantic import AfterValidator, BaseModel, Field, PositiveFloat
77

8-
from models.amazon import AmazonLocale, default_locale
8+
from models.amazon.locale import AmazonLocale, default_locale
99
from models.validators import (
1010
validate_asin,
1111
validate_country,
12-
validate_price_filters,
1312
validate_query_str,
1413
)
1514

@@ -39,9 +38,8 @@ class _BaseQuery(BaseModel):
3938
status: QueryStatus
4039
config: QueryConfig
4140
query_str: str
42-
locale: AmazonLocale = (
43-
default_locale
44-
)
41+
locale: AmazonLocale = default_locale
42+
jsonld: bool = False
4543

4644

4745
class FilterableQuery(_BaseQuery):
@@ -66,10 +64,7 @@ class QueryParams(BaseModel):
6664
country: Annotated[str, AfterValidator(func=validate_country)] = Field(
6765
Query("us", description="Country code")
6866
)
69-
min_price: Annotated[int, AfterValidator(func=validate_price_filters)] | None = (
70-
Field(Query(None, description="Minimum price"))
71-
)
72-
max_price: Annotated[int, AfterValidator(func=validate_price_filters)] | None = (
73-
Field(Query(None, description="Maximum price"))
74-
)
67+
min_price: PositiveFloat | None = Field(Query(None, description="Minimum price"))
68+
max_price: PositiveFloat | None = Field(Query(None, description="Maximum price"))
7569
strict: bool | None = Field(Query(False, description="Strict mode"))
70+
jsonld: bool = Field(Query(False, description="Return output as JSON-LD"))

models/validators.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import re
22

3-
from models.amazon import AmazonLocale, locale_list
3+
from models.amazon.locale import AmazonLocale, locale_list
44

55
ASIN_PATTERN = r"^(B[\dA-Z]{9}|\d{9}(X|\d))$"
66

@@ -18,12 +18,6 @@ def convert_to_locale(value: str) -> AmazonLocale:
1818
return next((locale for locale in locale_list if locale.code == country_code))
1919

2020

21-
def validate_price_filters(value: str) -> int:
22-
if value and not value.isnumeric():
23-
raise ValueError("Invalid price filter")
24-
return int(value)
25-
26-
2721
def validate_query_str(value: str) -> str:
2822
if value and not len(value):
2923
raise ValueError("Invalid query")

0 commit comments

Comments
 (0)