Compare commits
53 Commits
Author | SHA1 | Date | |
---|---|---|---|
fb3134723a | |||
8155ba2f8d | |||
a97ba6e858 | |||
3939c21a72 | |||
9ff87dc107 | |||
de3821aa80 | |||
e67c1aa9f3 | |||
94a3a517c7 | |||
64897392f1 | |||
3199a3259f | |||
0e5ba991db | |||
3601bcc81b | |||
c234285788 | |||
3ec9fef3cf | |||
9e656ef329 | |||
cc829c3b54 | |||
105c02a3de | |||
4f56eec551 | |||
029c28166e | |||
1bb842ea3f | |||
9603a7e58f | |||
3dc955698e | |||
eadff66eb6 | |||
6ded276269 | |||
168313d882 | |||
57ac76386b | |||
a1f15d6e6a | |||
11ed680347 | |||
fff6007a10 | |||
846a44e5fc | |||
9360bc9f9a | |||
48fa6bfaa9 | |||
c151d41c43 | |||
9021151b74 | |||
f59f0f350c | |||
478ce4ec41 | |||
18ceef8351 | |||
87c84fd0a8 | |||
0c78276b12 | |||
47a1b1d3ac | |||
c889a84c34 | |||
1ef706afe5 | |||
3454f24451 | |||
7786655db0 | |||
ed52e7da04 | |||
bf3f4ddb38 | |||
3f53513c36 | |||
d3bd696d67 | |||
113a920da7 | |||
9d11adaf6c | |||
8de5bec523 | |||
0414018099 | |||
d4579a9db0 |
32
.gitea/workflows/deploy.yaml
Normal file
32
.gitea/workflows/deploy.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Deploy App to Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-buildx-action@v1
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker build -t giga_tcg .
|
||||
|
||||
- name: Remove existing Docker container
|
||||
run: |
|
||||
docker rm -f giga_tcg_container || true
|
||||
|
||||
- name: Run Docker container
|
||||
run: |
|
||||
docker run -d -v /mnt/user/appdata/gigatcg/giga_tcg/cookies:/app/cookies -v /mnt/user/appdata/gigatcg/tmp:/app/tmp -p 8000:8000 --name giga_tcg_container giga_tcg
|
14
Dockerfile
14
Dockerfile
@ -4,6 +4,20 @@ WORKDIR /app
|
||||
|
||||
ENV DATABASE_URL=postgresql://poggers:giga!@192.168.1.41:5432/omegatcgdb
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libjpeg62-turbo \
|
||||
libgif7 \
|
||||
libxml2 \
|
||||
fonts-liberation \
|
||||
libharfbuzz0b \
|
||||
libfribidi0 \
|
||||
libgtk-3-0 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
BIN
app/assets/images/ccrcardsaddress.png
Normal file
BIN
app/assets/images/ccrcardsaddress.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
87
app/assets/templates/address_label.html
Normal file
87
app/assets/templates/address_label.html
Normal file
@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
/* Setting up the page size for landscape orientation - 6x4 inches */
|
||||
@page {
|
||||
size: 6in 4in; /* Landscape orientation */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Force page breaks after each label */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 6in; /* Adjusted for landscape */
|
||||
height: 4in; /* Adjusted for landscape */
|
||||
position: relative;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.label-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25in;
|
||||
page-break-after: always; /* Ensures a page break after each label */
|
||||
}
|
||||
|
||||
/* Return address image in top left */
|
||||
.return-address {
|
||||
position: absolute;
|
||||
top: 0.25in;
|
||||
left: 0.25in;
|
||||
width: 2.75in;
|
||||
height: 0.85in;
|
||||
}
|
||||
|
||||
/* Stamp area in top right */
|
||||
.stamp-area {
|
||||
position: absolute;
|
||||
top: 0.25in;
|
||||
right: 0.25in;
|
||||
width: 0.8in;
|
||||
height: 0.9in;
|
||||
border: 1px dashed #999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stamp-text {
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Main address centered in the middle */
|
||||
.address {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 3in;
|
||||
text-align: center;
|
||||
font-size: 12pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label-container">
|
||||
<img src="{{ return_address_path }}" class="return-address" alt="Return Address">
|
||||
|
||||
<div class="stamp-area">
|
||||
<span class="stamp-text">PLACE<br>STAMP<br>HERE</span>
|
||||
</div>
|
||||
|
||||
<div class="address">
|
||||
{{ recipient_name }}<br>
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif %}
|
||||
{{ city }}, {{ state }} {{ zip_code }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -318,7 +318,8 @@ async def update_cookies(
|
||||
)
|
||||
|
||||
class TCGPlayerOrderRequest(BaseModel):
|
||||
order_ids: List[str]
|
||||
# optional
|
||||
order_ids: Optional[List[str]] = None
|
||||
|
||||
@router.post("/processOrders", response_model=ProcessOrdersResponse)
|
||||
async def process_orders(
|
||||
@ -327,7 +328,7 @@ async def process_orders(
|
||||
) -> ProcessOrdersResponse:
|
||||
"""Process TCGPlayer orders."""
|
||||
try:
|
||||
orders = tcgplayer_api_service.process_orders(body.order_ids)
|
||||
orders = tcgplayer_api_service.process_open_orders(body.order_ids)
|
||||
return ProcessOrdersResponse(
|
||||
status_code=200,
|
||||
success=True,
|
||||
|
@ -8,10 +8,14 @@ from app.db.utils import db_transaction
|
||||
from typing import List, Dict
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_PRICING_ALGORITHIM = 'tcgplayer_recommended_algo' # 'default_pricing_algo' or 'tcgplayer_recommended_algo'
|
||||
FREE_SHIPPING = True
|
||||
|
||||
|
||||
class PricingService:
|
||||
def __init__(self, db: Session, file_service: FileService, tcgplayer_service: TCGPlayerService):
|
||||
@ -23,6 +27,8 @@ class PricingService:
|
||||
# function for taking a tcgplayer pricing export with all set ids and loading it into the price table
|
||||
# can be run as needed or scheduled
|
||||
def get_pricing_export_content(self, file: File = None) -> bytes:
|
||||
if ACTIVE_PRICING_ALGORITHIM == 'default_pricing_algo' and FREE_SHIPPING == True:
|
||||
print("asdf")
|
||||
if file:
|
||||
file_content = self.file_service.get_file_content(file.id)
|
||||
else:
|
||||
@ -115,6 +121,132 @@ class PricingService:
|
||||
row[price_type] = price
|
||||
return row
|
||||
|
||||
def smooth_markup(self, price, markup_bands):
|
||||
"""
|
||||
Applies a smoothed markup based on the given price and markup bands.
|
||||
Uses numpy for smooth transitions.
|
||||
"""
|
||||
# Convert markup bands to lists for easy lookup
|
||||
markups = np.array(list(markup_bands.keys()))
|
||||
min_prices = np.array([x[0] for x in markup_bands.values()])
|
||||
max_prices = np.array([x[1] for x in markup_bands.values()])
|
||||
|
||||
# Find the index of the price's range
|
||||
idx = np.where((min_prices <= price) & (max_prices >= price))[0]
|
||||
|
||||
if len(idx) > 0:
|
||||
# If price is within a defined range, return the markup
|
||||
markup = markups[idx[0]]
|
||||
else:
|
||||
# If price is not directly within any range, check smooth transitions
|
||||
# Find the closest two bands for interpolation
|
||||
idx_lower = np.argmax(max_prices <= price) # Closest range below the price
|
||||
idx_upper = np.argmax(min_prices > price) # Closest range above the price
|
||||
|
||||
if idx_lower != idx_upper:
|
||||
# Linear interpolation between the two neighboring markups
|
||||
price_diff = (price - max_prices[idx_lower]) / (min_prices[idx_upper] - max_prices[idx_lower])
|
||||
markup = np.interp(price_diff, [0, 1], [markups[idx_lower], markups[idx_upper]])
|
||||
|
||||
# Apply the markup to the price
|
||||
return price * markup
|
||||
|
||||
def tcgplayer_recommended_algo(self, row: pd.Series) -> pd.Series:
|
||||
# Convert input values to Decimal for precise arithmetic
|
||||
tcg_low = Decimal(str(row.get('tcg_low_price'))) if not pd.isna(row.get('tcg_low_price')) else None
|
||||
tcg_low_shipping = Decimal(str(row.get('tcg_low_price_with_shipping'))) if not pd.isna(row.get('tcg_low_price_with_shipping')) else None
|
||||
tcg_market_price = Decimal(str(row.get('tcg_market_price'))) if not pd.isna(row.get('tcg_market_price')) else None
|
||||
current_price = Decimal(str(row.get('tcg_marketplace_price'))) if not pd.isna(row.get('tcg_marketplace_price')) else None
|
||||
total_quantity = str(row.get('total_quantity')) if not pd.isna(row.get('total_quantity')) else "0"
|
||||
added_quantity = str(row.get('add_to_quantity')) if not pd.isna(row.get('add_to_quantity')) else "0"
|
||||
quantity = int(total_quantity) + int(added_quantity)
|
||||
|
||||
if tcg_market_price is None:
|
||||
logger.warning(f"Missing pricing data for row: {row}")
|
||||
row['new_price'] = None
|
||||
return row
|
||||
|
||||
TWO_PLACES = Decimal('0.01')
|
||||
|
||||
# Original markup bands
|
||||
markup_bands = {
|
||||
Decimal('2.34'): (Decimal('0.01'), Decimal('0.50')),
|
||||
Decimal('1.36'): (Decimal('0.51'), Decimal('1.00')),
|
||||
Decimal('1.24'): (Decimal('1.01'), Decimal('3.00')),
|
||||
Decimal('1.15'): (Decimal('3.01'), Decimal('20.00')),
|
||||
Decimal('1.06'): (Decimal('20.01'), Decimal('35.00')),
|
||||
Decimal('1.05'): (Decimal('35.01'), Decimal('50.00')),
|
||||
Decimal('1.03'): (Decimal('50.01'), Decimal('100.00')),
|
||||
Decimal('1.02'): (Decimal('100.01'), Decimal('200.00')),
|
||||
Decimal('1.01'): (Decimal('200.01'), Decimal('1000.00'))
|
||||
}
|
||||
|
||||
# Adjust markups if quantity is high
|
||||
if quantity > 3:
|
||||
adjusted_bands = {}
|
||||
increment = Decimal('0.20')
|
||||
for markup, price_range in zip(markup_bands.keys(), markup_bands.values()):
|
||||
new_markup = Decimal(str(markup)) + increment
|
||||
adjusted_bands[new_markup] = price_range
|
||||
increment -= Decimal('0.02')
|
||||
markup_bands = adjusted_bands
|
||||
|
||||
#if FREE_SHIPPING:
|
||||
#if tcg_low_shipping and (tcg_low >= Decimal('5.00')):
|
||||
#tcg_compare_price = tcg_low_shipping
|
||||
#elif tcg_low_shipping and (tcg_low < Decimal('5.00')):
|
||||
#tcg_compare_price = max(tcg_low_shipping - Decimal('1.31'), tcg_low)
|
||||
#elif tcg_low:
|
||||
#tcg_compare_price = tcg_low
|
||||
#else:
|
||||
#logger.warning(f"No TCG low or shipping price available for row: {row}")
|
||||
#row['new_price'] = None
|
||||
#return row
|
||||
#else:
|
||||
#tcg_compare_price = tcg_low
|
||||
#if tcg_compare_price is None:
|
||||
#logger.warning(f"No TCG low price available for row: {row}")
|
||||
#row['new_price'] = None
|
||||
#return row
|
||||
|
||||
tcg_compare_price = tcg_low
|
||||
|
||||
# Apply the smoothed markup
|
||||
new_price = self.smooth_markup(tcg_compare_price, markup_bands)
|
||||
|
||||
# Enforce minimum price
|
||||
if new_price < Decimal('0.35'):
|
||||
new_price = Decimal('0.25')
|
||||
|
||||
# Avoid huge price drops
|
||||
if current_price is not None and Decimal(str(((current_price - new_price) / current_price))) > Decimal('0.5'):
|
||||
logger.warning(f"Price drop too large for row: {row}")
|
||||
new_price = current_price
|
||||
|
||||
# Round to 2 decimal places
|
||||
new_price = new_price.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
# Convert back to float for dataframe
|
||||
row['new_price'] = float(new_price)
|
||||
|
||||
logger.debug(f"""
|
||||
card: {row['product_name']}
|
||||
TCGplayer Id: {row['tcgplayer_id']}
|
||||
Algorithm: {ACTIVE_PRICING_ALGORITHIM}
|
||||
TCG Low: {tcg_low}
|
||||
TCG Low Shipping: {tcg_low_shipping}
|
||||
TCG Market Price: {tcg_market_price}
|
||||
Current Price: {current_price}
|
||||
Total Quantity: {total_quantity}
|
||||
Added Quantity: {added_quantity}
|
||||
Quantity: {quantity}
|
||||
TCG Compare Price: {tcg_compare_price}
|
||||
New Price: {new_price}
|
||||
""")
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def default_pricing_algo(self, row: pd.Series) -> pd.Series:
|
||||
"""Default pricing algorithm with complex pricing rules"""
|
||||
|
||||
@ -167,8 +299,19 @@ class PricingService:
|
||||
|
||||
def apply_pricing_algo(self, row: pd.Series, pricing_algo: callable = None) -> pd.Series:
|
||||
"""Modified to handle the pricing algorithm as an instance method"""
|
||||
if pricing_algo is None:
|
||||
if pricing_algo:
|
||||
logger.debug(f"Using custom pricing algorithm: {pricing_algo.__name__}")
|
||||
return pricing_algo(row)
|
||||
elif ACTIVE_PRICING_ALGORITHIM == 'default_pricing_algo':
|
||||
logger.debug(f"Using default pricing algorithm: {self.default_pricing_algo.__name__}")
|
||||
pricing_algo = self.default_pricing_algo
|
||||
elif ACTIVE_PRICING_ALGORITHIM == 'tcgplayer_recommended_algo':
|
||||
logger.debug(f"Using TCGPlayer recommended algorithm: {self.tcgplayer_recommended_algo.__name__}")
|
||||
pricing_algo = self.tcgplayer_recommended_algo
|
||||
else:
|
||||
logger.debug(f"Using default pricing algorithm: {self.default_pricing_algo.__name__}")
|
||||
pricing_algo = self.default_pricing_algo
|
||||
|
||||
return pricing_algo(row)
|
||||
|
||||
def generate_tcgplayer_inventory_update_file_with_pricing(self, open_box_ids: List[str] = None) -> bytes:
|
||||
@ -198,14 +341,24 @@ class PricingService:
|
||||
.all()
|
||||
}
|
||||
|
||||
# Map the ids using the dictionary
|
||||
df['product_id'] = df['tcgplayer_id'].map(product_id_mapping)
|
||||
# Map tcgplayer_id to product_id, ensure strings, keep None if not found
|
||||
df['product_id'] = df['tcgplayer_id'].map(product_id_mapping).apply(lambda x: str(x) if pd.notnull(x) else None)
|
||||
|
||||
# Log any tcgplayer_ids that didn't map to a product_id
|
||||
null_product_ids = df[df['product_id'].isnull()]['tcgplayer_id'].tolist()
|
||||
if null_product_ids:
|
||||
logger.warning(f"The following tcgplayer_ids could not be mapped to a product_id: {null_product_ids}")
|
||||
|
||||
price_lookup = self.get_all_prices_for_products(df['product_id'].unique())
|
||||
|
||||
# Apply price columns
|
||||
df = df.apply(lambda row: self.apply_price_to_df_columns(row, price_lookup), axis=1)
|
||||
|
||||
logger.debug(f"Applying pricing algorithm: {ACTIVE_PRICING_ALGORITHIM}")
|
||||
|
||||
# set listed price
|
||||
df['listed_price'] = df['tcg_marketplace_price'].copy()
|
||||
|
||||
# Apply pricing algorithm
|
||||
df = df.apply(self.apply_pricing_algo, axis=1)
|
||||
|
||||
@ -213,6 +366,7 @@ class PricingService:
|
||||
if update_type == 'update':
|
||||
df = df[df['new_price'] != df['listed_price']]
|
||||
|
||||
|
||||
# Set marketplace price
|
||||
df['TCG Marketplace Price'] = df['new_price']
|
||||
|
||||
|
@ -26,6 +26,7 @@ class TaskService:
|
||||
|
||||
def register_scheduled_tasks(self):
|
||||
self.scheduler.add_job(self.hourly_pricing, 'cron', minute='36')
|
||||
self.scheduler.add_job(self.hourly_orders, 'cron', hour='*', minute='03')
|
||||
# every 5 hours on the 24th minute
|
||||
#self.scheduler.add_job(self.inventory_pricing, 'cron', hour='*', minute='44')
|
||||
self.logger.info("Scheduled tasks registered.")
|
||||
@ -35,6 +36,11 @@ class TaskService:
|
||||
self.pricing_service.cron_load_prices()
|
||||
self.logger.info("Finished hourly pricing task")
|
||||
|
||||
def hourly_orders(self):
|
||||
self.logger.info("Running hourly orders task")
|
||||
self.tcgplayer_api_service.process_orders_task()
|
||||
self.logger.info("Finished hourly orders task")
|
||||
|
||||
def inventory_pricing(self):
|
||||
self.tcgplayer_api_service.cron_tcgplayer_api_pricing()
|
||||
|
||||
|
@ -7,7 +7,12 @@ from app.db.utils import db_transaction
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from uuid import uuid4 as uuid
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from weasyprint import HTML
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
import csv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -16,6 +21,7 @@ class TCGPlayerAPIConfig:
|
||||
"""Configuration for TCGPlayer API"""
|
||||
ORDER_BASE_URL: str = "https://order-management-api.tcgplayer.com/orders"
|
||||
API_VERSION: str = "?api-version=2.0"
|
||||
SELLER_KEY: str = "e576ed4c"
|
||||
|
||||
class TCGPlayerAPIService:
|
||||
def __init__(self, db: Session):
|
||||
@ -26,6 +32,10 @@ class TCGPlayerAPIService:
|
||||
self.config = TCGPlayerAPIConfig()
|
||||
self.cookies = self.get_cookies()
|
||||
self.session = None
|
||||
self.template_dir = "/app/app/assets/templates"
|
||||
self.env = Environment(loader=FileSystemLoader(self.template_dir))
|
||||
self.address_label_template = self.env.get_template("address_label.html")
|
||||
self.return_address_png = "file:///app/app/assets/images/ccrcardsaddress.png"
|
||||
|
||||
def get_cookies(self) -> dict:
|
||||
if self.is_in_docker:
|
||||
@ -40,6 +50,25 @@ class TCGPlayerAPIService:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
def get_orders(self, size: int = 25) -> dict:
|
||||
url = f"{self.config.ORDER_BASE_URL}/search{self.config.API_VERSION}"
|
||||
payload = {
|
||||
"searchRange": "LastThreeMonths",
|
||||
"filters": {
|
||||
"sellerKey": self.config.SELLER_KEY
|
||||
},
|
||||
"sortBy": [
|
||||
{"sortingType": "orderStatus", "direction": "ascending"},
|
||||
{"sortingType": "orderDate", "direction": "descending"}
|
||||
],
|
||||
"from": 0,
|
||||
"size": size
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
def get_product_ids_from_sku(self, sku_ids: list[str]) -> dict:
|
||||
"""Get product IDs from TCGPlayer SKU IDs"""
|
||||
# convert SKU IDs to integers
|
||||
@ -101,14 +130,30 @@ class TCGPlayerAPIService:
|
||||
self.db.add_all(order_products)
|
||||
return db_order
|
||||
|
||||
def process_orders(self, orders: list[str]):
|
||||
def process_orders_task(self):
|
||||
# get last 25 orders from tcgplayer
|
||||
orders = self.get_orders(size=100)
|
||||
if orders:
|
||||
# get list of order ids
|
||||
order_ids = [order['orderNumber'] for order in orders['orders']]
|
||||
# get a list of order ids that are not in the database
|
||||
existing_orders = self.db.query(Orders).filter(Orders.order_id.in_(order_ids)).all()
|
||||
existing_order_ids = [order.order_id for order in existing_orders]
|
||||
# get a list of order ids that are not in the database
|
||||
new_order_ids = [order_id for order_id in order_ids if order_id not in existing_order_ids]
|
||||
# process new orders
|
||||
processed_orders = []
|
||||
for order_id in orders:
|
||||
order = self.get_order(order_id)
|
||||
if order:
|
||||
if new_order_ids:
|
||||
logger.info(f"Processing {len(new_order_ids)} new orders")
|
||||
new_orders = [order for order in orders['orders'] if order['orderNumber'] in new_order_ids]
|
||||
for new_order in new_orders:
|
||||
order = self.get_order(new_order['orderNumber'])
|
||||
self.save_order(order)
|
||||
processed_orders.append(order_id)
|
||||
processed_orders.append(order['orderNumber'])
|
||||
logger.info(f"Processed {len(processed_orders)} new orders")
|
||||
return processed_orders
|
||||
else:
|
||||
logger.info("No new orders to process")
|
||||
|
||||
def get_scryfall_data(self, scryfall_id: str):
|
||||
url = f"https://api.scryfall.com/cards/{scryfall_id}?format=json"
|
||||
@ -133,7 +178,6 @@ class TCGPlayerAPIService:
|
||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0 Safari/537.36'
|
||||
}
|
||||
url = f"https://mp-search-api.tcgplayer.com/v2/product/{tcgplayer_id}/details?mpfev=3279"
|
||||
#self.requests_util.rate_limit()
|
||||
response = self.session.get(url, headers=headers)
|
||||
self.requests_util.previous_request_time = datetime.now()
|
||||
return response
|
||||
@ -187,6 +231,161 @@ class TCGPlayerAPIService:
|
||||
if pricing_data:
|
||||
self.save_tcgplayer_pricing_data(card.product_id, pricing_data)
|
||||
|
||||
def get_packing_slip_pdf_for_orders(self, order_ids: list[str]):
|
||||
url = f"{self.config.ORDER_BASE_URL}/packing-slips/export{self.config.API_VERSION}"
|
||||
payload = {
|
||||
"sortingType": "byRelease",
|
||||
"format": "default",
|
||||
"timezoneOffset": -4,
|
||||
"orderNumbers": order_ids
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
# get filename from response headers
|
||||
header = response.headers.get('Content-Disposition', '')
|
||||
match = re.search(r'filename="?([^";]+)"?', header)
|
||||
filename = match.group(1) if match else f'packingslip{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
output_filename = f'/app/tmp/{filename}'
|
||||
|
||||
# save file to disk
|
||||
with open(output_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return output_filename
|
||||
|
||||
def get_pull_sheet_for_orders(self, order_ids: list[str]):
|
||||
url = f"{self.config.ORDER_BASE_URL}/pull-sheets/export{self.config.API_VERSION}"
|
||||
payload = {
|
||||
"orderNumbers": order_ids,
|
||||
"timezoneOffset": -4,
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
# get filename from response headers
|
||||
header = response.headers.get('Content-Disposition', '')
|
||||
match = re.search(r'filename="?([^";]+)"?', header)
|
||||
filename = match.group(1) if match else f'packingslip{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
output_filename = f'/app/tmp/{filename}'
|
||||
# save file to disk
|
||||
with open(output_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return output_filename
|
||||
|
||||
def get_address_labels_csv(self, order_ids: list[str]):
|
||||
url = f"{self.config.ORDER_BASE_URL}/shipping/export{self.config.API_VERSION}"
|
||||
payload = {
|
||||
"orderNumbers": order_ids,
|
||||
"timezoneOffset": -4
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
# get filename from response headers
|
||||
header = response.headers.get('Content-Disposition', '')
|
||||
match = re.search(r'filename="?([^";]+)"?', header)
|
||||
filename = match.group(1) if match else f'shipping{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||
output_filename = f'/app/tmp/{filename}'
|
||||
# save file to disk
|
||||
with open(output_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return output_filename
|
||||
|
||||
def get_address_labels_pdf(self, order_ids: list[str]):
|
||||
shipping_csv_filename = self.get_address_labels_csv(order_ids)
|
||||
with open(shipping_csv_filename, 'r') as f:
|
||||
reader = csv.DictReader(f)
|
||||
orders = {}
|
||||
for row in reader:
|
||||
order_id = row.pop('Order #')
|
||||
orders[order_id] = row
|
||||
|
||||
labels_html = []
|
||||
|
||||
for order_id in orders:
|
||||
order = orders[order_id]
|
||||
#if float(order['Value of Products']) >49.99:
|
||||
#continue
|
||||
# Extract relevant information from the order
|
||||
order_info = {
|
||||
"recipient_name": order['FirstName'] + ' ' + order['LastName'],
|
||||
"address_line1": order['Address1'],
|
||||
"address_line2": order['Address2'] if 'Address2' in order else '',
|
||||
"city": order['City'],
|
||||
"state": order['State'],
|
||||
"zip_code": order['PostalCode'],
|
||||
"return_address_path": self.return_address_png
|
||||
}
|
||||
|
||||
# Render the label HTML using the template
|
||||
labels_html.append(self.address_label_template.render(order_info))
|
||||
|
||||
if labels_html:
|
||||
# Combine the rendered labels into one HTML string
|
||||
full_html = "<html><body>" + "\n".join(labels_html) + "</body></html>"
|
||||
|
||||
# Generate a unique output filename with a timestamp
|
||||
output_filename = f'/app/tmp/address_labels_{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
|
||||
# Generate the PDF from the HTML string
|
||||
HTML(string=full_html).write_pdf(output_filename)
|
||||
|
||||
return output_filename
|
||||
else:
|
||||
print("No orders found or no valid labels generated.")
|
||||
return None
|
||||
|
||||
def process_open_orders(self, order_ids: list[str]=None):
|
||||
# get all open orders
|
||||
url = f"{self.config.ORDER_BASE_URL}/search{self.config.API_VERSION}"
|
||||
"""{"searchRange":"LastThreeMonths","filters":{"sellerKey":"e576ed4c","orderStatuses":["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"],"fulfillmentTypes":["Normal"]},"sortBy":[{"sortingType":"orderStatus","direction":"ascending"},{"sortingType":"orderDate","direction":"ascending"}],"from":0,"size":25}"""
|
||||
payload = {
|
||||
"searchRange": "LastThreeMonths",
|
||||
"filters": {
|
||||
"sellerKey": self.config.SELLER_KEY,
|
||||
"orderStatuses": ["Processing", "ReadyToShip", "Received", "Pulling", "ReadyForPickup"],
|
||||
"fulfillmentTypes": ["Normal"]
|
||||
},
|
||||
"sortBy": [
|
||||
{"sortingType": "orderStatus", "direction": "ascending"},
|
||||
{"sortingType": "orderDate", "direction": "ascending"}
|
||||
],
|
||||
"from": 0,
|
||||
"size": 100
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
orders = response.json()
|
||||
if orders and 'orders' in orders:
|
||||
if order_ids is None:
|
||||
order_ids = [order['orderNumber'] for order in orders['orders']]
|
||||
# get packing slip pdf
|
||||
packing_slip_filename = self.get_packing_slip_pdf_for_orders(order_ids)
|
||||
# get pull sheet pdf
|
||||
pull_sheet_filename = self.get_pull_sheet_for_orders(order_ids)
|
||||
# get address labels pdf
|
||||
address_labels_filename = self.get_address_labels_pdf(order_ids)
|
||||
with open(packing_slip_filename, 'rb') as packing_slip_file, \
|
||||
open(pull_sheet_filename, 'rb') as pull_sheet_file, \
|
||||
open(address_labels_filename, 'rb') as address_labels_file:
|
||||
files = [
|
||||
#packing_slip_file,
|
||||
# pull_sheet_file,
|
||||
address_labels_file
|
||||
]
|
||||
# request post pdfs
|
||||
for file in files:
|
||||
self.requests_util.bare_request(
|
||||
url="http://192.168.1.110:8000/upload",
|
||||
method='POST',
|
||||
files={'file': file}
|
||||
)
|
||||
time.sleep(10)
|
||||
return order_ids
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# this one contains nearly everything, use it first
|
||||
|
@ -61,10 +61,14 @@ class RequestHeaders:
|
||||
'origin': Headers.SELLER_ORIGIN,
|
||||
'referer': Headers.SELLER_REFERER
|
||||
}
|
||||
POST_HEADERS = {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
|
||||
class URLHeaders:
|
||||
# combine base and seller headers
|
||||
ORDER_HEADERS = {**RequestHeaders.BASE_HEADERS, **RequestHeaders.SELLER_HEADERS}
|
||||
POST_HEADERS = {**RequestHeaders.BASE_HEADERS, **RequestHeaders.SELLER_HEADERS, **RequestHeaders.POST_HEADERS}
|
||||
|
||||
class RequestsUtil:
|
||||
def __init__(self, browser_type: Browser = Browser.BRAVE):
|
||||
@ -79,10 +83,10 @@ class RequestsUtil:
|
||||
session.cookies.update(cookies)
|
||||
return session
|
||||
|
||||
def bare_request(self, url: str, method: str, cookies: dict = None, data=None) -> requests.Response:
|
||||
def bare_request(self, url: str, method: str, cookies: dict = None, data=None, files=None) -> requests.Response:
|
||||
"""Send a request without any additional processing"""
|
||||
try:
|
||||
response = requests.request(method, url, cookies=cookies, data=data)
|
||||
response = requests.request(method, url, cookies=cookies, data=data, files=files)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.RequestException as e:
|
||||
@ -113,19 +117,18 @@ class RequestsUtil:
|
||||
"""Rate limit requests by waiting for a specified time between requests"""
|
||||
time_diff = (datetime.now() - self.previous_request_time).total_seconds()
|
||||
if time_diff < time_between_requests:
|
||||
# logger.info(f"Waiting {time_between_requests - time_diff} seconds before next request...")
|
||||
time.sleep(time_between_requests - time_diff)
|
||||
|
||||
def send_request(self, url: str, method: str, cookies: dict, data=None) -> requests.Response:
|
||||
def send_request(self, url: str, method: str, cookies: dict, data=None, json=None) -> requests.Response:
|
||||
"""Send a request with the specified cookies"""
|
||||
|
||||
headers = self.set_headers(url)
|
||||
headers = self.set_headers(url, method)
|
||||
if not headers:
|
||||
raise ValueError("Headers not set")
|
||||
|
||||
try:
|
||||
self.rate_limit()
|
||||
response = requests.request(method, url, headers=headers, cookies=cookies, data=data)
|
||||
response = requests.request(method, url, headers=headers, cookies=cookies, data=data, json=json)
|
||||
response.raise_for_status()
|
||||
self.previous_request_time = datetime.now()
|
||||
|
||||
@ -135,57 +138,12 @@ class RequestsUtil:
|
||||
logger.error(f"Request failed: {str(e)}")
|
||||
return None
|
||||
|
||||
def set_headers(self, url: str):
|
||||
def set_headers(self, url: str, method: str) -> Dict:
|
||||
# use tcgplayerendpoints enum to set headers where url partially matches enum value
|
||||
for endpoint in TCGPlayerEndpoints:
|
||||
if endpoint.value in url:
|
||||
if endpoint.value in url and str.upper(method) == "POST":
|
||||
return URLHeaders.POST_HEADERS
|
||||
elif endpoint.value in url:
|
||||
return URLHeaders.ORDER_HEADERS
|
||||
else:
|
||||
raise ValueError(f"Endpoint not found in TCGPlayerEndpoints: {url}")
|
||||
|
||||
def old_set_headers(self, method: str) -> Dict:
|
||||
base_headers = {
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'accept-language': 'en-US,en;q=0.8',
|
||||
'priority': 'u=0, i',
|
||||
'referer': 'https://store.tcgplayer.com/admin/pricing',
|
||||
'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132", "Brave";v="132"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'document',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'sec-fetch-user': '?1',
|
||||
'sec-gpc': '1',
|
||||
'upgrade-insecure-requests': '1',
|
||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36'
|
||||
}
|
||||
|
||||
if method == 'POST':
|
||||
post_headers = {
|
||||
'cache-control': 'max-age=0',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'origin': 'https://store.tcgplayer.com'
|
||||
}
|
||||
base_headers.update(post_headers)
|
||||
|
||||
return base_headers
|
||||
|
||||
|
||||
"""
|
||||
curl 'https://order-management-api.tcgplayer.com/orders/E576ED4C-38871F-B0277?api-version=2.0' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.8' \
|
||||
-b 'tcgpartner=PK=TRADECARDS&M=1; valid=set=true; product-display-settings=sort=price+shipping&size=10; TCG_Data=M=1&SearchGameNameID=magic; tcg-uuid=ab16b5f8-dd66-446d-b217-d394328a5cf1; setting=CD=US&M=1; tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; OAuthLoginSessionId=63b1a89d-1ac2-43f7-8e79-55a9ca5e761d; TCGAuthTicket_Production=F453BFF2FA3FAA3D1ACA23F319314F8273713DECEB06C69BB7C208A77C81559B46E1AA22A7E70FABC8D7F681A423C86870FAE318B76048CDE7BF6D73D220631B899BEBA86C422E1EBBF2ACD1921E0846F708AFE203C844031364E13B047465E7B41CB6460E4F4AAB278B614445B93E722E976688; BuyerRevalidationKey=; ASP.NET_SessionId=oouwzrh3jkhdrmaioooqhr4k; TCG_VisitorKey=431efcca-2d5b-404d-a04f-3ae979696051; __RequestVerificationToken_L2FkbWlu0=Lw1sfWh823UeJ7zRux0b1ZTI4Vg4i_dFt97a55aQpf-qBURVuwWDCJyuCxSwgLNLe9nPlfDSc1AMV5nyqhY4Q4jurxs1; fileDownloadToken=1740145585435; spDisabledUIFeatures=' \
|
||||
-H 'origin: https://sellerportal.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://sellerportal.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "macOS"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
|
||||
"""
|
20
requests.md
20
requests.md
@ -2,20 +2,24 @@ curl -J http://192.168.1.41:8000/api/tcgplayer/inventory/update --remote-name
|
||||
|
||||
curl -J -X POST http://192.168.1.41:8000/api/tcgplayer/inventory/add \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"open_box_ids": ["fcc28f9e-bc8b-4e89-a46e-7da746a96a5b","4a09930f-1830-4b8b-8006-a391e1adca66"]}' \
|
||||
-d '{"open_box_ids": ["f4a8e94c-5592-4b27-97b7-5cdb3eb45a71","81918f03-cbd2-4129-9f5c-eada6e1a811f","c2083c29-6fac-4621-b4b6-30c2dac75fab", "4ac89a6b-b8b7-44cf-8a4e-4a95c8e9006d", "0e809522-cef6-4c3c-b8a3-742c2e3c83fd","9e68466f-5abb-4725-9da8-91e5aaa4e805"]}' \
|
||||
--remote-name
|
||||
|
||||
curl -X POST http://192.168.1.41:8000/api/boxes \
|
||||
-F "type=collector" \
|
||||
-F "type=play" \
|
||||
-F "set_code=TDM" \
|
||||
-F "sku=1234" \
|
||||
-F "num_cards_expected=123"
|
||||
-F "num_cards_expected=420"
|
||||
|
||||
curl -X POST "http://192.168.1.41:8000/api/boxes/d95d26a8-1f82-47f2-89fa-3f88a4636823/open" \
|
||||
-F "product_id=d95d26a8-1f82-47f2-89fa-3f88a4636823" \
|
||||
-F "file_ids=0f29efd2-448c-4a05-ba49-1420fd3d524b" \
|
||||
-F "date_opened=2025-04-04"
|
||||
curl -X POST "http://192.168.1.41:8000/api/boxes/a77194be-8bd6-41cc-89a0-820e92ef9c04/open" \
|
||||
-F "product_id=a77194be-8bd6-41cc-89a0-820e92ef9c04" \
|
||||
-F "file_ids=b11a0292-bfdc-43de-90a8-6eb383332201" \
|
||||
-F "date_opened=2025-04-14"
|
||||
|
||||
curl -X POST "http://192.168.1.41:8000/api/processOrders" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"order_ids": ["E576ED4C-A472E7-36237","E576ED4C-16EDF6-A340B","E576ED4C-A25B9C-A8855","E576ED4C-0E1A20-C6350","E576ED4C-9E8C21-3A249","E576ED4C-3825F0-5A5CC","E576ED4C-628925-6348B","E576ED4C-5F4314-6E3D2","E576ED4C-60E0B9-69D1D","E576ED4C-4BEC42-B2D0A","E576ED4C-5253F2-E2E16","E576ED4C-C08EA2-F51B4","E576ED4C-EE350E-BA82C","E576ED4C-CB067C-21150","E576ED4C-85DE3E-4E518","E576ED4C-27DB4A-A7729","E576ED4C-91A537-2AEBA","E576ED4C-3961D0-4F5A9","E576ED4C-EB7B7D-DBE0D","E576ED4C-1F9576-A9351","E576ED4C-7EBF1E-6FDB9","E576ED4C-F549E2-C558B","E576ED4C-215B45-4F177","E576ED4C-572FAA-004F7","E576ED4C-9D5F33-1A3C4","E576ED4C-87276B-63EC8","E576ED4C-2143E7-4DE1B","E576ED4C-41E56A-04D55","E576ED4C-789397-BF6AD","E576ED4C-2F3F46-154FE","E576ED4C-EFCBEE-3FE93","E576ED4C-3ADBAE-7CA1B","E576ED4C-D9F68F-A5E6F","E576ED4C-DEA6E2-8B590","E576ED4C-86D96B-DC5C4","E576ED4C-EDFABA-67C3C","E576ED4C-C57373-3F638","E576ED4C-B2C2B4-FF53B","E576ED4C-3788E5-B3653","E576ED4C-8A573A-BB51B","E576ED4C-497380-63F5C","E576ED4C-A6C3F2-C7BF2","E576ED4C-FAC80B-148F3","E576ED4C-ECF1F3-AF3A4"]}'
|
||||
-d '{"order_ids": ["E576ED4C-AD8CC8-9E57E","E576ED4C-2EC48E-7F185","E576ED4C-9B72C2-5E208","E576ED4C-67DAA6-B06B5","E576ED4C-7E52E0-CCE11","E576ED4C-572216-87C96","E576ED4C-37BADB-8BE45","E576ED4C-2D9E83-89C64","E576ED4C-C8080B-8066C","E576ED4C-A41099-E4F92","E576ED4C-2B5122-5E719","E576ED4C-95BB1D-07DDA","E576ED4C-1CF99A-20072","E576ED4C-342542-28CDB","E576ED4C-42720B-514DB","E576ED4C-911CB9-15174","E576ED4C-EBD55A-27AE6","E576ED4C-CC32F2-76408","E576ED4C-45328B-E65B4","E576ED4C-1F26F5-84367","E576ED4C-1D4FE5-71100","E576ED4C-BCEC53-A7CF2","E576ED4C-132791-5AF2E"]}'
|
||||
|
||||
curl -X POST "http://192.168.1.41:8000/api/processOrders" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
@ -2,41 +2,69 @@ alembic==1.14.1
|
||||
annotated-types==0.7.0
|
||||
anyio==4.8.0
|
||||
APScheduler==3.11.0
|
||||
attrs==25.3.0
|
||||
blabel==0.1.6
|
||||
brother_ql_next==0.11.3
|
||||
Brotli==1.1.0
|
||||
browser-cookie3==0.20.1
|
||||
certifi==2025.1.31
|
||||
cffi==1.17.1
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
coverage==7.6.10
|
||||
cssselect2==0.8.0
|
||||
fastapi==0.115.8
|
||||
fonttools==4.57.0
|
||||
future==1.0.0
|
||||
greenlet==3.1.1
|
||||
h11==0.14.0
|
||||
httpcore==1.0.7
|
||||
httpx==0.28.1
|
||||
idna==3.10
|
||||
iniconfig==2.0.0
|
||||
jeepney==0.9.0
|
||||
Jinja2==3.1.6
|
||||
jsons==1.6.3
|
||||
lz4==4.4.3
|
||||
Mako==1.3.9
|
||||
MarkupSafe==3.0.2
|
||||
numpy==2.2.2
|
||||
packaging==24.2
|
||||
packbits==0.6
|
||||
pandas==2.2.3
|
||||
pdf2image==1.17.0
|
||||
pillow==11.1.0
|
||||
pluggy==1.5.0
|
||||
psycopg2-binary==2.9.10
|
||||
pycparser==2.22
|
||||
pycryptodomex==3.21.0
|
||||
pydantic==2.10.6
|
||||
pydantic_core==2.27.2
|
||||
pydyf==0.11.0
|
||||
pyphen==0.17.2
|
||||
pyStrich==0.9
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.3
|
||||
pytest-cov==6.0.0
|
||||
python-barcode==0.15.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-multipart==0.0.20
|
||||
pytz==2025.1
|
||||
pyusb==1.3.1
|
||||
qrcode==8.1
|
||||
requests==2.32.3
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.37
|
||||
starlette==0.45.3
|
||||
tinycss2==1.4.0
|
||||
tinyhtml5==2.0.0
|
||||
typing_extensions==4.12.2
|
||||
typish==1.9.3
|
||||
tzdata==2025.1
|
||||
tzlocal==5.2
|
||||
urllib3==2.3.0
|
||||
uvicorn==0.34.0
|
||||
weasyprint==65.0
|
||||
webencodings==0.5.1
|
||||
zopfli==0.2.3.post1
|
||||
|
Loading…
x
Reference in New Issue
Block a user