From 1ab0949f9d8e97aafafbd347a625fd97eeaa48a3 Mon Sep 17 00:00:00 2001 From: makefu Date: Wed, 11 Mar 2020 09:22:58 +0100 Subject: init project setup --- .envrc | 154 ++++++++++++++++++++++++++++++++++ .nixpkgs-version.json | 7 ++ README.md | 7 ++ ebknotify/cli.py | 3 + ebknotify/client.py | 227 ++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 26 ++++++ shell.nix | 25 ++++++ 7 files changed, 449 insertions(+) create mode 100644 .envrc create mode 100644 .nixpkgs-version.json create mode 100644 README.md create mode 100644 ebknotify/cli.py create mode 100644 ebknotify/client.py create mode 100644 setup.py create mode 100644 shell.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..75fb474 --- /dev/null +++ b/.envrc @@ -0,0 +1,154 @@ +# Load environment variables from `nix-shell` and export it out. +# +# Usage: use_nix [-s ] [-w ] [-w ] ... +# -s nix-expression: The nix expression to use for building the shell environment. +# -w path: watch a file for changes. It can be specified multiple times. The +# shell specified with -s is automatically watched. +# +# If no nix-expression were given with -s, it will attempt to find and load +# the shell using the following files in order: shell.nix and default.nix. +# +# Example: +# - use_nix +# - use_nix -s shell.nix -w .nixpkgs-version.json +# +# The dependencies pulled by nix-shell are added to Nix's garbage collector +# roots, such that the environment remains persistent. +# +# Nix-shell is invoked only once per environment, and the output is cached for +# better performance. If any of the watched files change, then the environment +# is rebuilt. +# +# To remove old environments, and allow the GC to collect their dependencies: +# rm -f .direnv +# +use_nix() { + if ! validate_version; then + echo "This .envrc requires direnv version 2.18.2 or above." + exit 1 + fi + + # define all local variables + local shell + local files_to_watch=() + + local opt OPTARG OPTIND # define vars used by getopts locally + while getopts ":n:s:w:" opt; do + case "${opt}" in + s) + shell="${OPTARG}" + files_to_watch=("${files_to_watch[@]}" "${shell}") + ;; + w) + files_to_watch=("${files_to_watch[@]}" "${OPTARG}") + ;; + :) + fail "Invalid option: $OPTARG requires an argument" + ;; + \?) + fail "Invalid option: $OPTARG" + ;; + esac + done + shift $((OPTIND -1)) + + if [[ -z "${shell}" ]]; then + if [[ -f shell.nix ]]; then + shell=shell.nix + files_to_watch=("${files_to_watch[@]}" shell.nix) + elif [[ -f default.nix ]]; then + shell=default.nix + files_to_watch=("${files_to_watch[@]}" default.nix) + else + fail "ERR: no shell was given" + fi + fi + + local f + for f in "${files_to_watch[@]}"; do + if ! [[ -f "${f}" ]]; then + fail "cannot watch file ${f} because it does not exist" + fi + done + + # compute the hash of all the files that makes up the development environment + local env_hash="$(hash_contents "${files_to_watch[@]}")" + + # define the paths + local dir="$(direnv_layout_dir)" + local wd="${dir}/wd-${env_hash}" + local drv="${wd}/env.drv" + local dump="${wd}/dump.env" + + # Generate the environment if we do not have one generated already. + if [[ ! -f "${drv}" ]]; then + mkdir -p "${wd}" + + log_status "use nix: deriving new environment" + IN_NIX_SHELL=1 nix-instantiate --add-root "${drv}" --indirect "${shell}" > /dev/null + nix-store -r $(nix-store --query --references "${drv}") --add-root "${wd}/dep" --indirect > /dev/null + if [[ "${?}" -ne 0 ]] || [[ ! -f "${drv}" ]]; then + rm -rf "${wd}" + fail "use nix: was not able to derive the new environment. Please run 'direnv reload' to try again." + fi + + log_status "use nix: updating cache" + nix-shell --pure "${drv}" --show-trace --run "$(join_args "$direnv" dump bash)" > "${dump}" + if [[ "${?}" -ne 0 ]] || [[ ! -f "${dump}" ]] || ! grep -q IN_NIX_SHELL "${dump}"; then + rm -rf "${wd}" + fail "use nix: was not able to update the cache of the environment. Please run 'direnv reload' to try again." + fi + fi + + # evaluate the dump created by nix-shell earlier, but have to merge the PATH + # with the current PATH + # NOTE: we eval the dump here as opposed to direnv_load it because we don't + # want to persist environment variables coming from the shell at the time of + # the dump. See https://github.com/direnv/direnv/issues/405 for context. + local path_backup="${PATH}" + eval $(cat "${dump}") + export PATH="${PATH}:${path_backup}" + + # cleanup the environment of variables that are not requried, or are causing issues. + unset shellHook # when shellHook is present, then any nix-shell'd script will execute it! + + # watch all the files we were asked to watch for the environment + for f in "${files_to_watch[@]}"; do + watch_file "${f}" + done +} + +fail() { + log_error "${@}" + exit 1 +} + +hash_contents() { + if has md5sum; then + cat "${@}" | md5sum | cut -c -32 + elif has md5; then + cat "${@}" | md5 -q + fi +} + +hash_file() { + if has md5sum; then + md5sum "${@}" | cut -c -32 + elif has md5; then + md5 -q "${@}" + fi +} + +validate_version() { + local version="$("${direnv}" version)" + local major="$(echo "${version}" | cut -d. -f1)" + local minor="$(echo "${version}" | cut -d. -f2)" + local patch="$(echo "${version}" | cut -d. -f3)" + + if [[ "${major}" -gt 2 ]]; then return 0; fi + if [[ "${major}" -eq 2 ]] && [[ "${minor}" -gt 18 ]]; then return 0; fi + if [[ "${major}" -eq 2 ]] && [[ "${minor}" -eq 18 ]] && [[ "${patch}" -ge 2 ]]; then return 0; fi + return 1 +} + +use_nix -s shell.nix -w .nixpkgs-version.json diff --git a/.nixpkgs-version.json b/.nixpkgs-version.json new file mode 100644 index 0000000..f5496a7 --- /dev/null +++ b/.nixpkgs-version.json @@ -0,0 +1,7 @@ +{ + "url": "https://github.com/NixOS/nixpkgs-channels.git", + "rev": "3d6cdfa24bd771b36e8c87241d17275dd144c7a3", + "date": "2020-03-05T08:28:11+00:00", + "sha256": "1cif0z3zwwr2m5ibrm2b51dw8pqfqv4n95xhfiqr8zl941k18gkr", + "fetchSubmodules": false +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9ab36b --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# EBK Notify + +Notify via different channels once a new item popped up on Ebay Kleinanzeigen. +This Projekt utilizes the original code from [ebk-client](https://github.com/tejado/ebk-client/) + +# License +MIT diff --git a/ebknotify/cli.py b/ebknotify/cli.py new file mode 100644 index 0000000..67a4e9a --- /dev/null +++ b/ebknotify/cli.py @@ -0,0 +1,3 @@ + +def main(): + pass diff --git a/ebknotify/client.py b/ebknotify/client.py new file mode 100644 index 0000000..3abcb07 --- /dev/null +++ b/ebknotify/client.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +ebk-client - eBay Kleinanzeigen/Classifieds API client in Python +Copyright (c) 2016 tjado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +Author: tjado +""" + +import json +import base64 +import hashlib +import dateutil.parser +import requests + +from datetime import datetime +from dateutil.tz import tzlocal + +import logging +logging.basicConfig(level=logging.DEBUG) + +try: + from html import unescape # python 3.4+ +except ImportError: + try: + from html.parser import HTMLParser # python 3.x (<3.4) + except ImportError: + from HTMLParser import HTMLParser # python 2.x + unescape = HTMLParser().unescape + +class EbkClient: + + H_EBAYK_CLIENT_APP = '13a6dde3-935d-4cd8-9992-db8a8c4b6c0f1456515662228' + H_EBAYK_CLIENT_VERSION = '5423' + H_EBAYK_CLIENT_TYPE = 'ebayk-android-app-6.9.3' + H_EBAYK_CLIENT_UA = 'Dalvik/2.1.0' + + URL_PREFIX = 'https://api.ebay-kleinanzeigen.de/api' + + username = None + + def __init__(self, app_username, app_password, user_username, user_password): + + try: + import requests.packages.urllib3 + requests.packages.urllib3.disable_warnings() + except: + pass + + self.username = user_username + app_auth = base64.b64encode('{}:{}'.format(app_username, app_password).encode('ascii')).decode("utf-8") + + hashed_user_password = base64.b64encode(hashlib.sha1(user_password.encode('ascii')).digest()).decode("utf-8") + user_auth = 'email="{}",password="{}"'.format(user_username, hashed_user_password) + + header = { + 'X-EBAYK-APP': self.H_EBAYK_CLIENT_APP, + 'X-ECG-USER-VERSION': self.H_EBAYK_CLIENT_VERSION, + 'X-ECG-USER-AGENT': self.H_EBAYK_CLIENT_TYPE, + 'Authorization': 'Basic {}'.format(app_auth), + 'X-ECG-Authorization-User': user_auth, + 'User-Agent': self.H_EBAYK_CLIENT_UA, + } + + self._session = requests.session() + self._session.headers.update(header) + + def _validate_http_response(self, r): + if r.status_code == 401: + raise Exception('Access Denied (e.g. wrong app credentials or user credentials)') + elif r.status_code == 404: + raise Exception('Not found') + elif r.status_code == 500: + raise Exception('Internal Server Error (e.g. wrong request)') + + def _http_get(self, url_suffix,params=None): + if not params: params = {} + response = self._session.get( self.URL_PREFIX + url_suffix,params=params ) + self._validate_http_response(response) + return response + + def _http_post(self, url_suffix, post_data = ''): + response = self._session.post( self.URL_PREFIX + url_suffix, data=post_data, headers={'Content-Type': 'application/xml'} ) + self._validate_http_response(response) + return response + + def _http_put(self, url_suffix, post_data = ''): + response = self._session.put( self.URL_PREFIX + url_suffix, data=post_data, headers={'Content-Type': 'application/xml'} ) + self._validate_http_response(response) + return response + + def _http_delete(self, url_suffix): + response = self._session.delete( self.URL_PREFIX + url_suffix ) + self._validate_http_response(response) + return response + + + def get_ads(self,**params): + # latitude=12.42&longitude=-34.44&distance=50&distanceUnit=KM + # locationId=12,13 + # zipcode=70435 + # + # usage: + # api.get_ads(zipcode="70435",categoryId=80,distance=2,distanceUnit="KM") + if not params: params = {} + url = "/ads.json" + response_json = self._http_get(url,params).json() + ads = response_json.get('{http://www.ebayclassifiedsgroup.com/schema/ad/v1}ads', {}).get('value', {}).get('ad', None) + return ads + + def get_my_ads(self): + url = "/users/{}/ads.json?_in=id,title,start-date-time,ad-status".format(self.username) + response = self._http_get(url) + response_json = json.loads(response.content.decode('utf-8')) + my_ads = response_json.get('{http://www.ebayclassifiedsgroup.com/schema/ad/v1}ads', {}).get('value', {}).get('ad', None) + + return my_ads + + def get_ad_details(self,ident): + url = "/ads/{}.json".format(ident) + response = self._http_get(url).json() + return response.get('{http://www.ebayclassifiedsgroup.com/schema/ad/v1}ad', {}).get('value', {}) + + def change_ad_status(self, ad_id, status): + if status not in ['active', 'paused']: + raise Exception('Wrong ad status') + + url = "/users/{}/ads/{}/{}.json".format(self.username, status, ad_id) + response = self._http_put(url) + + if response.status_code == 204: + return True + else: + return False + + def activate_ad(self, ad_id): + return change_ad_status(ad_id, 'active') + + def deactivate_ad(self, ad_id): + return change_ad_status(ad_id, 'paused') + + def delete_ad(self, ad_id): + url = "/users/{}/ads/{}".format(self.username, ad_id) + response = self._http_delete(url) + + if response.status_code == 204: + return True + else: + return False + + def create_ad(self, xml): + url = "/users/{}/ads.json".format(self.username) + response = self._http_post(url, xml) + return response + + def get_categories(self, cat_id = None): + if cat_id is not None: + url = "/categories/{}.json".format(cat_id) + schema_uri = '{http://www.ebayclassifiedsgroup.com/schema/category/v1}category' + else: + url = "/categories.json" + schema_uri = '{http://www.ebayclassifiedsgroup.com/schema/category/v1}categories' + + response = self._http_get(url) + response_json = json.loads(response.content.decode('utf-8')) + categories = response_json.get(schema_uri, {}).get('value', {}).get('category', None) + + return categories + + def get_category_attributes(self, cat_id = None): + + url = "/attributes/metadata/{}.json".format(cat_id) + schema_uri = '{http://www.ebayclassifiedsgroup.com/schema/attribute/v1}attributes' + + response = self._http_get(url) + response_json = json.loads(response.content.decode('utf-8')) + cat_attr = response_json.get(schema_uri, {}).get('value', {}).get('attribute', None) + + return cat_attr + + def get_locations(self, url_suffix, depth = None, include_parent_path = False): + url = "/locations.json?{}".format(url_suffix) + if depth is not None: + url += '&depth={}'.format(depth) + + if include_parent_path is True: + url += '&includeParentPath=true' + + schema_uri = '{http://www.ebayclassifiedsgroup.com/schema/location/v1}locations' + + response = self._http_get(url) + response_json = json.loads(response.content.decode('utf-8')) + categories = response_json.get(schema_uri, {}).get('value', {}).get('location', None) + + return categories + + def get_location_by_name(self, location_name, depth = None, include_parent_path = False): + url_suffix = "q={}".format(location_name) + locations = self.get_locations(url_suffix, depth, include_parent_path) + return locations + + def get_location_by_coordinates(self, latitude, longitude, depth = None, include_parent_path = False): + url_suffix = "latitude={}&longitude={}".format(latitude,longitude) + locations = self.get_locations(url_suffix, depth, include_parent_path) + return locations + + def html_unescape(self, data): + return unescape(data.decode()) + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e4a01da --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup + +setup( + name="ebknotify", + description="Notify on new ebay kleinanzeigen inputs", + version="1.0.0", + packages=["ebknotify"], + license="MIT", + long_description=open("README.md").read(), + author="Felix Richter", + author_email="github@krebsco.de", + install_requires=[ + "requests", + "docopt" + ], + entry_points={"console_scripts": ["ebk-notify = ebknotify.cli:main"]}, + classifiers=[ + "Intended Audience :: Human", + "Natural Language :: English", + "Operating System :: POSIX :: Linux", + "Development Status :: 3 - Alpha", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: Implementation :: CPython", + ], +) diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..79beb8a --- /dev/null +++ b/shell.nix @@ -0,0 +1,25 @@ +let + # Look here for information about how to generate `nixpkgs-version.json`. + # → https://nixos.wiki/wiki/FAQ/Pinning_Nixpkgs + pinnedVersion = builtins.fromJSON (builtins.readFile ./.nixpkgs-version.json); + pinnedPkgs = import (builtins.fetchGit { + inherit (pinnedVersion) url rev; + + ref = "nixos-unstable"; + }) {}; +in + +# This allows overriding pkgs by passing `--arg pkgs ...` +{ pkgs ? pinnedPkgs }: + +with pkgs.python3.pkgs; +buildPythonPackage { + name = "env"; + src = ./.; + propagatedBuildInputs = [ + docopt + requests + beautifulsoup4 + ]; + checkInputs = [ black jq ]; +} -- cgit v1.2.3