Allow using the eBay API

Fixes #1.

Need to set an eBay API key using EBAY_KEY env var.
This commit is contained in:
Olivier 'reivilibre' 2021-01-13 20:54:06 +00:00
parent c0a726174e
commit 3d2d9b7b65
6 changed files with 294 additions and 15 deletions

View File

@ -1,5 +1,211 @@
import itertools
import os
import pprint
from typing import List, Dict
from immutabledict import immutabledict
from requests import Session
from fleabay.definitions import EbayItemSummary, EbayItemDetails, EbayMultiItemModelAxis
from fleabay.utils import chunker, decimal_price_to_pence
class EbayApiExtractor: class EbayApiExtractor:
def __init__(self, api_key): def __init__(self, api_key: str):
pass self.api_key = api_key
self.session = Session()
def search(self, term: str, items_per_page: int = 200, remote_lowest_first: bool = False) -> List[EbayItemSummary]:
url = "https://svcs.ebay.com/services/search/FindingService/v1"
params = {
"SECURITY-APPNAME": self.api_key,
"OPERATION-NAME": "findItemsByKeywords",
"SERVICE-VERSION": "1.0.0",
"RESPONSE-DATA-FORMAT": "JSON",
"REST-PAYLOAD": "",
"keywords": term,
"paginationInput.entriesPerPage": items_per_page,
"GLOBAL-ID": "EBAY-GB",
"siteid": 3,
"buyerPostalCode": "M11 2GX"
}
if remote_lowest_first:
params["sortOrder"] = "PricePlusShippingLowest"
resp = self.session.get(url, params=params).json()
resp_k = resp["findItemsByKeywordsResponse"][0]
if resp_k["ack"][0] != "Success":
raise RuntimeError("search by API not successful.")
items = []
found_item_ids = dict()
if resp_k["searchResult"][0]["@count"] == "0":
return []
try:
item_results = resp_k["searchResult"][0]["item"]
except KeyError as e:
raise RuntimeError("%r" % resp_k["searchResult"][0].items()) from e
for result in item_results:
item_id = result["itemId"][0]
if item_id in found_item_ids:
continue
if item_id == "254817515397":
pprint.pprint(result)
is_buy_it_now = result["listingInfo"][0]["listingType"][0] != "Auction"
postage_price = result["shippingInfo"][0]["shippingServiceCost"][0]
postage_pence = -42
if postage_price["@currencyId"] == "GBP":
postage_pence = int(100 * float(postage_price["__value__"]))
if result["shippingInfo"][0]["shippingType"][0] == "FreePickup":
postage_pence = None
item_price = result["sellingStatus"][0]["currentPrice"][0]
item_pence = -42
bid_count = result["sellingStatus"][0].get("bidCount")
if bid_count is not None:
bid_count = bid_count[0]
if item_price["@currencyId"] == "GBP":
item_pence = int(100 * float(item_price["__value__"]))
view_url = f"https://www.ebay.co.uk/itm/{item_id}"
try:
condition = result["condition"][0]["conditionDisplayName"][0]
except KeyError:
condition = "???"
if item_id == "154280943581":
pprint.pprint(result)
item = EbayItemSummary(
item_id, view_url, result["title"][0], condition,
item_pence, -42, None if is_buy_it_now else bid_count, result["country"][0], postage_pence, result["galleryURL"][0]
)
found_item_ids[item_id] = item
items.append(item)
for item_id, details in self.details_batch(list(found_item_ids.keys())).items():
summary = found_item_ids[item_id]
summary.extras["details"] = details
summary.min_price = int(details.extra["min_price"])
summary.max_price = int(details.extra["max_price"])
return items
@staticmethod
def details(summary: EbayItemSummary) -> EbayItemDetails:
return summary.extras["details"]
def details_batch(self, ids: List[str]) -> Dict[str, EbayItemDetails]:
# eBay can do up to 20 items at once.
result = dict()
for id_chunk in chunker(ids, 20):
url = "https://open.api.ebay.com/shopping"
params = {
"ItemID": ",".join(id_chunk),
"callname": "GetMultipleItems",
"IncludeSelector": "Details,Variations",
"appid": self.api_key,
"siteid": 3,
"version": "967",
"responseencoding": "JSON"
}
response = self.session.get(url, params=params)
# print(response.status_code)
resp = response.json()
for item_dict in resp["Item"]:
item_id = item_dict["ItemID"]
seller_dict = item_dict["Seller"]
seller = {
"name": seller_dict["UserID"],
"feedback": f"{seller_dict['PositiveFeedbackPercent']}% ({seller_dict['FeedbackScore']})"
}
extras = {
"variant_extras": dict()
}
base_pence = -42
if item_dict["CurrentPrice"]["CurrencyID"] == "GBP":
base_pence = decimal_price_to_pence("%.2f" % item_dict["CurrentPrice"]["Value"])
min_pence = base_pence
max_pence = base_pence
variants_dict = dict()
axes: Dict[str, EbayMultiItemModelAxis] = dict()
axes_inverse: Dict[str, Dict[str, int]] = dict()
variations = item_dict.get("Variations")
if variations is not None:
min_pence = 1e9
max_pence = -42
for axis in variations["VariationSpecificsSet"]["NameValueList"]:
axes_inverse[axis["Name"]] = {
val: i for i, val in enumerate(axis["Value"])
}
axes[axis["Name"]] = EbayMultiItemModelAxis(axis["Name"], dict(enumerate(axis["Value"])))
for variation in variations["Variation"]:
quantity = variation["Quantity"] - variation["SellingStatus"].get("QuantitySold", 0)
if quantity <= 0:
continue
price = -42
if variation["StartPrice"]["CurrencyID"] == "GBP":
price = decimal_price_to_pence("%.2f" % variation["StartPrice"]["Value"])
min_pence = min(min_pence, price)
max_pence = max(max_pence, price)
sold = variation["SellingStatus"]["QuantitySold"]
variant_immdict = immutabledict({
pair["Name"]: axes_inverse[pair["Name"]][pair["Value"][0]]
for pair in variation["VariationSpecifics"]["NameValueList"]
})
extras["variant_extras"][variant_immdict] = {
"quantity": quantity,
"sold": sold
}
assert isinstance(price, int)
variants_dict[variant_immdict] = price
if item_id == "223749865106":
pprint.pprint(variation)
print("vi", "%r" % variant_immdict)
print("vp", "%r" % price)
print("qt", "%r" % quantity)
extras["min_price"] = int(min_pence)
extras["max_price"] = int(max_pence)
result[item_id] = EbayItemDetails(
seller_dict["UserID"],
int(seller_dict["FeedbackScore"]),
seller_dict["PositiveFeedbackPercent"],
axes,
variants_dict,
extras
)
return result

View File

@ -1,4 +1,4 @@
from typing import Optional, Dict from typing import Optional, Dict, Any
import attr import attr
from immutabledict import immutabledict from immutabledict import immutabledict
@ -6,6 +6,8 @@ from immutabledict import immutabledict
@attr.s(auto_attribs=True) @attr.s(auto_attribs=True)
class EbayItemSummary: class EbayItemSummary:
# arbitrary ID
id: str
href: str href: str
name: str name: str
condition: str condition: str
@ -18,6 +20,9 @@ class EbayItemSummary:
postage: Optional[int] postage: Optional[int]
image_src: str image_src: str
# extra data about the item.
extras: Dict[str, Any] = attr.attrib(factory=dict)
@attr.s(auto_attribs=True) @attr.s(auto_attribs=True)
class EbayMultiItemModelAxis: class EbayMultiItemModelAxis:
@ -35,3 +40,19 @@ class EbayItemDetails:
axes: Dict[str, EbayMultiItemModelAxis] axes: Dict[str, EbayMultiItemModelAxis]
# variants: Dict[immutabledict[str, int]] # variants: Dict[immutabledict[str, int]]
variants: Dict[immutabledict, int] variants: Dict[immutabledict, int]
extra: Dict[str, Any] = attr.attrib(factory=dict)
codes_to_symbols = {
"GBP": "£",
"EUR": "",
"USD": "$",
"JPY": "¥"
}
@attr.s(auto_attribs=True)
class Price:
currency_code: str
amount: int

View File

@ -107,6 +107,7 @@ class EbayExtractor:
results.append( results.append(
EbayItemSummary( EbayItemSummary(
link_href,
link_href, link_href,
title, title,
condition, condition,
@ -121,8 +122,8 @@ class EbayExtractor:
return results return results
def details(self, url: str) -> EbayItemDetails: def details(self, summary: EbayItemSummary) -> EbayItemDetails:
page = self.session.get(url) page = self.session.get(summary.href)
line: bytes line: bytes
for line in page.iter_lines(): for line in page.iter_lines():
opening = line.find(b"$rwidgets([") opening = line.find(b"$rwidgets([")

View File

@ -8,6 +8,11 @@
</div> </div>
</div> </div>
<div class="form-group">
{{ search_form.extractor.label }}
{{ search_form.extractor(class="form-control") }}
</div>
<div class="custom-checkbox d-inline-block mr-10"> <div class="custom-checkbox d-inline-block mr-10">
{{ search_form.remote_lowest_first }} {{ search_form.remote_lowest_first.label() }} {{ search_form.remote_lowest_first }} {{ search_form.remote_lowest_first.label() }}
</div> </div>

15
fleabay/utils.py Normal file
View File

@ -0,0 +1,15 @@
import re
def decimal_price_to_pence(price: str) -> int:
pmatch = re.search(r"£?([0-9]+)\.([0-9]+)", price)
if pmatch is None:
raise ValueError(
"Unable to extract price from " + price
)
return int(pmatch.group(1)) * 100 + int(pmatch.group(2))
# https://stackoverflow.com/a/434328 CC BY-SA 4.0
def chunker(seq, size):
return (seq[pos:pos + size] for pos in range(0, len(seq), size))

View File

@ -1,12 +1,15 @@
import asyncio import asyncio
import os
from concurrent.futures.thread import ThreadPoolExecutor from concurrent.futures.thread import ThreadPoolExecutor
from typing import Dict from pprint import pprint
from typing import Dict, Any, Tuple
import quart import quart
from quart import Quart, render_template, request from quart import Quart, render_template, request
from wtforms_async import Form, StringField, BooleanField from wtforms_async import Form, StringField, BooleanField, SelectField
from wtforms_async.widgets import CheckboxInput from wtforms_async.widgets import CheckboxInput
from fleabay.api_extractor import EbayApiExtractor
from fleabay.definitions import EbayItemSummary, EbayItemDetails from fleabay.definitions import EbayItemSummary, EbayItemDetails
from fleabay.extractor import EbayExtractor from fleabay.extractor import EbayExtractor
@ -15,7 +18,32 @@ app = Quart(__name__)
pool = ThreadPoolExecutor(8) pool = ThreadPoolExecutor(8)
def make_extractors() -> Tuple[Dict[str, Any], Dict[str, str]]:
available_extractors = dict()
extractor_names = dict()
ebay_key = os.environ.get("EBAY_KEY")
if ebay_key:
available_extractors["eBay_api"] = EbayApiExtractor(ebay_key)
extractor_names["eBay_api"] = "eBay (by API — best)"
available_extractors["eBay_scrape"] = EbayExtractor()
extractor_names["eBay_scrape"] = "eBay (by scraper — slow, error-prone)"
zapiex_key = os.environ.get("ZAPIEX_KEY")
if zapiex_key:
available_extractors["AE_zapiex"] = None
extractor_names["AE_zapiex"] = "AliExpress (by Zapiex API — limited)"
return available_extractors, extractor_names
# we need only initialise the extractors once.
available_extractors, extractor_names = make_extractors()
class SearchForm(Form): class SearchForm(Form):
extractor = SelectField("Site/Extractor", choices=list(extractor_names.items()))
search_term = StringField("Search Term") search_term = StringField("Search Term")
include_local_collection = BooleanField("Include local collection") include_local_collection = BooleanField("Include local collection")
remote_lowest_first = BooleanField("Ask remote for 'Lowest Price+P&P First'") remote_lowest_first = BooleanField("Ask remote for 'Lowest Price+P&P First'")
@ -33,13 +61,13 @@ async def search():
if not await search_form.validate(): if not await search_form.validate():
return await render_template("index.html.j2", search_form=SearchForm()) return await render_template("index.html.j2", search_form=SearchForm())
ex = EbayExtractor() ex = available_extractors[search_form.extractor.data]
search_term = search_form.search_term.data search_term = search_form.search_term.data
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
results = await loop.run_in_executor(pool, ex.search, search_term, 50) results = await loop.run_in_executor(pool, ex.search, search_term, 100, search_form.remote_lowest_first.data)
if not search_form.include_local_collection.data: if not search_form.include_local_collection.data:
results = [r for r in results if r.postage is not None] results = [r for r in results if r.postage is not None]
@ -47,9 +75,9 @@ async def search():
item_details_fut = dict() item_details_fut = dict()
for result in results: for result in results:
if result.min_price != result.max_price: if not isinstance(ex, EbayExtractor) or result.min_price != result.max_price:
fut = loop.run_in_executor(pool, ex.details, result.href) fut = loop.run_in_executor(pool, ex.details, result)
item_details_fut[fut] = result.href item_details_fut[fut] = result.id
futs = tuple(item_details_fut.keys()) futs = tuple(item_details_fut.keys())
detail_results = await asyncio.gather(*futs, return_exceptions=True) detail_results = await asyncio.gather(*futs, return_exceptions=True)
@ -70,7 +98,7 @@ async def search():
# want to find the most insightful axis to display over # want to find the most insightful axis to display over
# Insightful suggests that the individual values in the axis have a tight range # Insightful suggests that the individual values in the axis have a tight range
axes_ranges = dict() axes_ranges = dict()
for href, details in item_details.items(): for item_id, details in item_details.items():
best_axis = None best_axis = None
best_axis_tightness = 1.0e9 best_axis_tightness = 1.0e9
for axis in details.axes.values(): for axis in details.axes.values():
@ -96,15 +124,18 @@ async def search():
for value, value_name in best_axis.values.items(): for value, value_name in best_axis.values.items():
min_vp = 1e9 min_vp = 1e9
max_vp = 0 max_vp = 0
found_any = False
for variant, price in details.variants.items(): for variant, price in details.variants.items():
if variant[best_axis.code_name] != value: if variant[best_axis.code_name] != value:
continue continue
max_vp = max(max_vp, price) max_vp = max(max_vp, price)
min_vp = min(min_vp, price) min_vp = min(min_vp, price)
found_any = True
axis_value_ranges[value_name] = (min_vp, max_vp) if found_any:
axis_value_ranges[value_name] = (int(min_vp), int(max_vp))
axes_ranges[href] = (best_axis.code_name, axis_value_ranges) axes_ranges[item_id] = (best_axis.code_name, axis_value_ranges)
return await render_template( return await render_template(
"search.html.j2", "search.html.j2",